Skip to main content

katui_present/surface/
sixel.rs

1//! The sixel strategy: the surface scaled to the screen's real pixels,
2//! sent as one sixel image per changed tile at the tile's cell.
3
4use std::io::{self, Write as _};
5
6use katui_proto::probe::CellPixels;
7use katui_proto::sixel;
8use katui_scene::cell::Position;
9use katui_scene::surface::{PixelRect, Surface, diff_tiles};
10
11use super::damage_is_dense;
12use super::patched::tile_pixels;
13use crate::Sink;
14use crate::cells::move_cursor;
15
16/// The sixel surface presenter. Sixel images have no scaling, so we scale
17/// the surface to whole cells ourselves, nearest pixel. We leave the
18/// bottom row out, because a sixel touching it scrolls the screen.
19#[derive(Debug)]
20pub struct SixelSurface {
21    cell: CellPixels,
22    prev: Option<Surface>,
23}
24
25impl SixelSurface {
26    /// A presenter for a terminal whose cells are `cell` pixels.
27    #[must_use]
28    pub fn new(cell: CellPixels) -> Self {
29        Self { cell, prev: None }
30    }
31
32    /// Send every pixel again on the next frame.
33    pub fn repaint(&mut self) {
34        self.prev = None;
35    }
36
37    /// Write `surface` scaled over `columns` x `rows` cells, minus the
38    /// bottom row, and return the bytes written.
39    pub fn render_frame<W: Sink>(
40        &mut self,
41        out: &mut W,
42        surface: &Surface,
43        columns: u16,
44        rows: u16,
45    ) -> io::Result<usize> {
46        let width = u32::from(columns) * u32::from(self.cell.width);
47        let height = u32::from(rows.saturating_sub(1)) * u32::from(self.cell.height);
48        let Some(screen) = surface.scaled(width, height) else {
49            return Ok(0);
50        };
51        let tile_side = u32::from(self.cell.width) * u32::from(self.cell.height);
52        let damage = self.prev.as_ref().map_or_else(
53            || vec![whole(width, height)],
54            |prev| diff_tiles(prev, &screen, tile_side),
55        );
56        let damage = if damage_is_dense(&screen, tile_side, damage.len()) {
57            vec![whole(width, height)]
58        } else {
59            damage
60        };
61        let mut bytes = Vec::new();
62        if !damage.is_empty() {
63            bytes.write_all(b"\x1b[?2026h")?;
64            for rect in &damage {
65                self.write_tile(&mut bytes, &screen, *rect)?;
66            }
67            bytes.write_all(b"\x1b[?2026l")?;
68        }
69        self.prev = Some(screen);
70        out.write_all(&bytes)?;
71        Ok(bytes.len())
72    }
73
74    fn write_tile(&self, bytes: &mut Vec<u8>, screen: &Surface, rect: PixelRect) -> io::Result<()> {
75        let pixels = tile_pixels(screen, rect);
76        let Some(image) = sixel::encode(&pixels, rect.width, rect.height) else {
77            return Ok(());
78        };
79        let at = Position {
80            x: u16::try_from(rect.x / u32::from(self.cell.width)).unwrap_or(u16::MAX),
81            y: u16::try_from(rect.y / u32::from(self.cell.height)).unwrap_or(u16::MAX),
82        };
83        move_cursor(bytes, at)?;
84        bytes.write_all(&image)
85    }
86}
87
88fn whole(width: u32, height: u32) -> PixelRect {
89    PixelRect {
90        x: 0,
91        y: 0,
92        width,
93        height,
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    use katui_proto::probe::CellPixels;
100    use katui_scene::surface::{PixelPos, Rgba};
101
102    use super::SixelSurface;
103    use crate::surface::test_support::solid;
104
105    const CELL: CellPixels = CellPixels {
106        width: 4,
107        height: 6,
108    };
109
110    #[test]
111    fn the_first_frame_fills_the_screen_but_the_bottom_row() {
112        let mut presenter = SixelSurface::new(CELL);
113        let mut out = Vec::new();
114        presenter
115            .render_frame(&mut out, &solid(10, 10, Rgba::solid(255, 0, 0)), 20, 6)
116            .expect("writing to a Vec cannot fail");
117        let text = String::from_utf8_lossy(&out);
118        assert!(
119            text.contains("\x1b[1;1H\x1bP0;1;0q\"1;1;80;30"),
120            "80x30 pixels from the top left: {text:.60}"
121        );
122        assert_eq!(text.matches("\x1bP").count(), 1, "one image");
123    }
124
125    #[test]
126    fn a_changed_pixel_redraws_one_tile_at_its_cell() {
127        let mut presenter = SixelSurface::new(CELL);
128        let mut surface = solid(80, 60, Rgba::solid(0, 0, 0));
129        let mut out = Vec::new();
130        presenter
131            .render_frame(&mut out, &surface, 20, 11)
132            .expect("writing to a Vec cannot fail");
133        surface.set(PixelPos::new(50, 30), Rgba::solid(255, 255, 255));
134        out.clear();
135        presenter
136            .render_frame(&mut out, &surface, 20, 11)
137            .expect("writing to a Vec cannot fail");
138        let text = String::from_utf8_lossy(&out);
139        assert_eq!(text.matches("\x1bP").count(), 1, "one tile");
140        assert!(
141            text.contains("\x1b[5;13H\x1bP0;1;0q\"1;1;24;24"),
142            "the 24 pixel tile at x 48, y 24: {text:.80}"
143        );
144        out.clear();
145        let written = presenter
146            .render_frame(&mut out, &surface, 20, 11)
147            .expect("writing to a Vec cannot fail");
148        assert_eq!(written, 0, "an unchanged frame writes nothing");
149    }
150}