Skip to main content

urushi_graphics/
lib.rs

1//! Image components and terminal graphics adapters for Urushi.
2//!
3//! Core `urushi` reserves and resolves image regions through its generic anchor
4//! contract. This crate owns the image data, presentation, placement lookup,
5//! and terminal-protocol adapters layered on those resolved regions. One-shot
6//! rendering stays stateless; an interactive renderer may retain
7//! [`kitty::KittyLifecycle`] for Kitty upload and placement reuse or
8//! [`sixel::SixelLifecycle`] for encoded Sixel band reuse.
9
10mod 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/// Caller-selected policy for terminal image output.
32///
33/// Automatic selection prefers Kitty, then Sixel when character-cell pixel
34/// geometry is available, and finally the Image component's text fallback.
35#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
36pub enum GraphicsPreference {
37    /// Choose the strongest positively confirmed usable protocol.
38    #[default]
39    Auto,
40    /// Require Kitty graphics.
41    Kitty,
42    /// Require Sixel graphics and character-cell pixel geometry.
43    Sixel,
44    /// Always use the Image component's text fallback.
45    Text,
46}
47
48/// The graphics path selected for one terminal presentation.
49#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
50pub enum GraphicsSelection {
51    Kitty,
52    Sixel,
53    Text,
54}
55
56/// Why an explicitly requested graphics path cannot be used.
57#[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
81/// Selects the graphics path from caller policy and positive terminal evidence.
82pub 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
107/// Resolves and renders one complete View at the terminal's top-left cell.
108///
109/// Image components embedded in the View are discovered internally. The
110/// terminal is queried for current geometry and positively confirmed
111/// capabilities; output prefers Kitty, then Sixel when cell-pixel geometry is
112/// available, and otherwise leaves the View's text fallbacks visible.
113///
114/// This operation retains no graphics state after it returns. Interactive
115/// renderers that reconcile multiple frames own their caches and lifecycle state.
116pub 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
146/// Renders caller-supplied images over an already resolved View.
147///
148/// This is the low-level path for a caller that owns resolution, capability
149/// observation, and cell output. Ordinary one-shot callers use [`render_view`].
150pub 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: &parameters,
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}