1mod image;
11pub mod kitty;
12pub mod sixel;
13
14use std::fmt;
15use std::io;
16
17use urushi::{
18 Available, RenderSettings, ResolvedView, TerminalTextStyle, TextStyle, View, resolve,
19};
20use urushi_terminal::{
21 Command, CommandWriter, CursorMove, HyperlinkParameter, PixelSize as CellPixelSize,
22 Position as TerminalPosition, TerminalCapabilities, TerminalGraphicsProtocol,
23 TerminalHyperlink, TerminalOutput, TerminalQuery, TerminalText,
24};
25
26pub use image::{
27 CellSize, GraphicPlacement, Image, ImagePresentation, InvalidRgbaRaster, PixelPosition,
28 PixelSize, RgbaRaster, placements as image_placements,
29};
30
31#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
36pub enum GraphicsPreference {
37 #[default]
39 Auto,
40 Kitty,
42 Sixel,
44 Text,
46}
47
48#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
50pub enum GraphicsSelection {
51 Kitty,
52 Sixel,
53 Text,
54}
55
56#[derive(Clone, Copy, Debug, PartialEq, Eq)]
58pub enum GraphicsUnavailable {
59 UnsupportedProtocol(TerminalGraphicsProtocol),
60 MissingCellPixelGeometry,
61}
62
63impl fmt::Display for GraphicsUnavailable {
64 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
65 match self {
66 Self::UnsupportedProtocol(TerminalGraphicsProtocol::Kitty) => {
67 formatter.write_str("Kitty graphics were requested, but the terminal did not confirm support")
68 }
69 Self::UnsupportedProtocol(TerminalGraphicsProtocol::Sixel) => {
70 formatter.write_str("Sixel graphics were requested, but the terminal did not confirm support")
71 }
72 Self::MissingCellPixelGeometry => formatter.write_str(
73 "Sixel graphics were requested, but the terminal did not report uniform character-cell pixel geometry",
74 ),
75 }
76 }
77}
78
79impl std::error::Error for GraphicsUnavailable {}
80
81pub fn select_graphics(
83 preference: GraphicsPreference,
84 capabilities: TerminalCapabilities,
85 cell_pixels: Option<CellPixelSize>,
86) -> Result<GraphicsSelection, GraphicsUnavailable> {
87 let kitty = capabilities.supports_graphics(TerminalGraphicsProtocol::Kitty);
88 let sixel = capabilities.supports_graphics(TerminalGraphicsProtocol::Sixel);
89 match preference {
90 GraphicsPreference::Auto if kitty => Ok(GraphicsSelection::Kitty),
91 GraphicsPreference::Auto if sixel && cell_pixels.is_some() => Ok(GraphicsSelection::Sixel),
92 GraphicsPreference::Auto | GraphicsPreference::Text => Ok(GraphicsSelection::Text),
93 GraphicsPreference::Kitty if kitty => Ok(GraphicsSelection::Kitty),
94 GraphicsPreference::Kitty => Err(GraphicsUnavailable::UnsupportedProtocol(
95 TerminalGraphicsProtocol::Kitty,
96 )),
97 GraphicsPreference::Sixel if !sixel => Err(GraphicsUnavailable::UnsupportedProtocol(
98 TerminalGraphicsProtocol::Sixel,
99 )),
100 GraphicsPreference::Sixel if cell_pixels.is_none() => {
101 Err(GraphicsUnavailable::MissingCellPixelGeometry)
102 }
103 GraphicsPreference::Sixel => Ok(GraphicsSelection::Sixel),
104 }
105}
106
107pub fn render_view(
117 terminal: &mut (impl CommandWriter + TerminalQuery + ?Sized),
118 view: &View,
119) -> io::Result<Option<TerminalGraphicsProtocol>> {
120 let window = terminal.window_size()?;
121 let capabilities = terminal.terminal_capabilities()?;
122 let resolved = resolve(
123 view,
124 Available::size(window.cells().columns(), window.cells().rows()),
125 )
126 .map_err(io::Error::other)?;
127 write_cells(terminal, &resolved, &RenderSettings::from(capabilities))?;
128 let images = image::collect(view);
129 if images.is_empty() {
130 TerminalOutput::flush(terminal)?;
131 return Ok(None);
132 }
133 let protocol = render_resolved_images(
134 &resolved,
135 images,
136 capabilities,
137 window.cell_pixels(),
138 terminal,
139 )?;
140 if protocol.is_none() {
141 TerminalOutput::flush(terminal)?;
142 }
143 Ok(protocol)
144}
145
146pub fn render_resolved_images<'a>(
151 view: &ResolvedView,
152 images: impl IntoIterator<Item = &'a Image>,
153 capabilities: TerminalCapabilities,
154 cell_pixels: Option<CellPixelSize>,
155 terminal: &mut (impl CommandWriter + ?Sized),
156) -> io::Result<Option<TerminalGraphicsProtocol>> {
157 match select_graphics(GraphicsPreference::Auto, capabilities, cell_pixels)
158 .expect("automatic graphics selection is always available")
159 {
160 GraphicsSelection::Kitty => {
161 kitty::render_kitty(view, images, cell_pixels, terminal)?;
162 Ok(Some(TerminalGraphicsProtocol::Kitty))
163 }
164 GraphicsSelection::Sixel => {
165 sixel::render_sixel(
166 view,
167 images,
168 cell_pixels.expect("Sixel selection requires cell-pixel geometry"),
169 terminal,
170 )?;
171 Ok(Some(TerminalGraphicsProtocol::Sixel))
172 }
173 GraphicsSelection::Text => Ok(None),
174 }
175}
176
177fn write_cells(
178 terminal: &mut (impl CommandWriter + ?Sized),
179 view: &ResolvedView,
180 settings: &RenderSettings,
181) -> io::Result<()> {
182 for (row, graphemes) in view.rows().iter().enumerate() {
183 terminal.write_command(Command::MoveCursor(CursorMove::To(TerminalPosition::new(
184 0, row,
185 ))))?;
186 let mut index = 0;
187 while index < graphemes.len() {
188 let style = settings.resolve_text_style(graphemes[index].style());
189 let mut text = String::new();
190 while index < graphemes.len()
191 && settings.resolve_text_style(graphemes[index].style()) == style
192 {
193 text.push_str(graphemes[index].symbol());
194 index += 1;
195 }
196 write_run(terminal, &text, &style)?;
197 }
198 }
199 Ok(())
200}
201
202fn write_run(
203 terminal: &mut (impl CommandWriter + ?Sized),
204 text: &str,
205 style: &TextStyle,
206) -> io::Result<()> {
207 let text = TerminalText::try_from(text)
208 .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?;
209 let style = TerminalTextStyle::from(style);
210 terminal.write_command(Command::SetStyle(style.style()))?;
211 if let Some(link) = style.hyperlink() {
212 let parameters = link
213 .parameters()
214 .iter()
215 .map(|(key, value)| HyperlinkParameter { key, value })
216 .collect::<Vec<_>>();
217 terminal.write_command(Command::SetHyperlink(Some(TerminalHyperlink {
218 uri: link.uri(),
219 parameters: ¶meters,
220 })))?;
221 terminal.write_command(Command::Print(text))?;
222 terminal.write_command(Command::SetHyperlink(None))?;
223 } else {
224 terminal.write_command(Command::Print(text))?;
225 }
226 terminal.write_command(Command::ResetStyle)
227}
228
229#[cfg(test)]
230mod tests {
231 use urushi::{Available, resolve};
232 use urushi_terminal::{
233 Position, TerminalGraphicsProtocols, TerminalSize, WindowSize, backend::ansi::AnsiWriter,
234 };
235
236 use super::*;
237
238 fn fixture() -> (Image, ResolvedView) {
239 let image =
240 Image::rgba("placement", "asset", PixelSize::new(1, 1), [255, 0, 0, 255]).unwrap();
241 let view = ImagePresentation::new().compose(&image, CellSize::new(1, 1));
242 let resolved = resolve(&view, Available::NONE).unwrap();
243 (image, resolved)
244 }
245
246 #[test]
247 fn automatic_rendering_prefers_kitty_then_sixel_then_fallback() {
248 let (image, resolved) = fixture();
249 let both = TerminalCapabilities::none().with_graphics_protocols(
250 TerminalGraphicsProtocols::KITTY | TerminalGraphicsProtocols::SIXEL,
251 );
252 let mut kitty_output = AnsiWriter::new(Vec::new());
253 assert_eq!(
254 render_resolved_images(
255 &resolved,
256 [&image],
257 both,
258 Some(CellPixelSize::new(8, 16)),
259 &mut kitty_output,
260 )
261 .unwrap(),
262 Some(TerminalGraphicsProtocol::Kitty)
263 );
264 assert!(
265 kitty_output
266 .into_inner()
267 .windows(3)
268 .any(|bytes| bytes == b"\x1b_G")
269 );
270
271 let sixel =
272 TerminalCapabilities::none().with_graphics_protocols(TerminalGraphicsProtocols::SIXEL);
273 let mut sixel_output = AnsiWriter::new(Vec::new());
274 assert_eq!(
275 render_resolved_images(
276 &resolved,
277 [&image],
278 sixel,
279 Some(CellPixelSize::new(2, 3)),
280 &mut sixel_output,
281 )
282 .unwrap(),
283 Some(TerminalGraphicsProtocol::Sixel)
284 );
285 assert!(
286 sixel_output
287 .into_inner()
288 .windows(2)
289 .any(|bytes| bytes == b"\x1bP")
290 );
291
292 let mut fallback_output = AnsiWriter::new(Vec::new());
293 assert_eq!(
294 render_resolved_images(
295 &resolved,
296 [&image],
297 TerminalCapabilities::none(),
298 None,
299 &mut fallback_output,
300 )
301 .unwrap(),
302 None
303 );
304 assert!(fallback_output.into_inner().is_empty());
305 }
306
307 #[test]
308 fn explicit_selection_reports_missing_protocol_or_geometry() {
309 let both = TerminalCapabilities::none().with_graphics_protocols(
310 TerminalGraphicsProtocols::KITTY | TerminalGraphicsProtocols::SIXEL,
311 );
312 assert_eq!(
313 select_graphics(
314 GraphicsPreference::Sixel,
315 both,
316 Some(CellPixelSize::new(8, 16)),
317 ),
318 Ok(GraphicsSelection::Sixel)
319 );
320 assert_eq!(
321 select_graphics(GraphicsPreference::Sixel, both, None),
322 Err(GraphicsUnavailable::MissingCellPixelGeometry)
323 );
324 assert_eq!(
325 select_graphics(
326 GraphicsPreference::Kitty,
327 TerminalCapabilities::none(),
328 None,
329 ),
330 Err(GraphicsUnavailable::UnsupportedProtocol(
331 TerminalGraphicsProtocol::Kitty
332 ))
333 );
334 }
335
336 struct QueryingTerminal {
337 writer: AnsiWriter<Vec<u8>>,
338 window: WindowSize,
339 capabilities: TerminalCapabilities,
340 }
341
342 impl QueryingTerminal {
343 fn new(window: WindowSize, capabilities: TerminalCapabilities) -> Self {
344 Self {
345 writer: AnsiWriter::new(Vec::new()),
346 window,
347 capabilities,
348 }
349 }
350 }
351
352 impl TerminalOutput for QueryingTerminal {
353 fn flush(&mut self) -> io::Result<()> {
354 self.writer.flush()
355 }
356 }
357
358 impl CommandWriter for QueryingTerminal {
359 fn write_command(&mut self, command: Command<'_>) -> io::Result<()> {
360 self.writer.write_command(command)
361 }
362 }
363
364 impl TerminalQuery for QueryingTerminal {
365 fn terminal_size(&mut self) -> io::Result<TerminalSize> {
366 Ok(self.window.cells())
367 }
368
369 fn cursor_position(&mut self) -> io::Result<Position> {
370 Ok(Position::new(0, 0))
371 }
372
373 fn window_size(&mut self) -> io::Result<WindowSize> {
374 Ok(self.window)
375 }
376
377 fn raw_mode_enabled(&mut self) -> io::Result<bool> {
378 Ok(false)
379 }
380
381 fn terminal_capabilities(&mut self) -> io::Result<TerminalCapabilities> {
382 Ok(self.capabilities)
383 }
384 }
385
386 fn owned_image_view() -> View {
387 let image = Image::rgba("placement", "asset", PixelSize::new(1, 1), [255, 0, 0, 255])
388 .unwrap()
389 .fallback("fallback");
390 ImagePresentation::new().compose(&image, CellSize::new(8, 1))
391 }
392
393 #[test]
394 fn high_level_rendering_owns_the_image_and_prefers_kitty() {
395 let view = owned_image_view();
396 let capabilities = TerminalCapabilities::none().with_graphics_protocols(
397 TerminalGraphicsProtocols::KITTY | TerminalGraphicsProtocols::SIXEL,
398 );
399 let window = WindowSize::new(TerminalSize::new(8, 1), Some(CellPixelSize::new(64, 16)));
400 let mut terminal = QueryingTerminal::new(window, capabilities);
401
402 assert_eq!(
403 render_view(&mut terminal, &view).unwrap(),
404 Some(TerminalGraphicsProtocol::Kitty)
405 );
406
407 let output = terminal.writer.into_inner();
408 assert!(
409 output
410 .windows(b"fallback".len())
411 .any(|bytes| bytes == b"fallback")
412 );
413 assert!(output.windows(3).any(|bytes| bytes == b"\x1b_G"));
414 assert!(!output.windows(2).any(|bytes| bytes == b"\x1bP"));
415 }
416}