Skip to main content

katui_present/surface/
mod.rs

1//! Surface presenters: the kitty strategies (whole frames with
2//! alternating image ids, tile patches, terminal-side animation), sixel
3//! tiles for terminals without kitty graphics, and the damage-tracked
4//! blocks fallback every truecolor terminal runs.
5
6mod animated;
7mod blocks;
8mod buffer;
9mod full;
10mod patched;
11mod screen;
12mod sixel;
13
14pub use animated::AnimatedKitty;
15pub use blocks::BlocksSurface;
16pub use buffer::BufferSurface;
17pub use full::KittySurface;
18pub use patched::PatchedKitty;
19pub use screen::PixelScreen;
20pub use sixel::SixelSurface;
21
22use crate::Sink;
23use std::io;
24
25use katui_proto::kitty::{
26    Format, ImageId as KittyImage, Medium, Place, PlacementId as KittyPlacement, Transmit,
27};
28use katui_scene::cell::Position;
29use katui_scene::placement::CellRect;
30use katui_scene::surface::Surface;
31
32use crate::cells::move_cursor;
33
34/// Whether so much of a fine tile grid changed that one whole image is
35/// the better package for the frame.
36pub(crate) fn damage_is_dense(surface: &Surface, tile_side: u32, damaged: usize) -> bool {
37    // on a coarse grid a tile is already most of a frame, so patching
38    // stays cheaper there
39    const FINE_GRID_TILES: u64 = 8;
40    let across = surface.width().div_ceil(tile_side);
41    let down = surface.height().div_ceil(tile_side);
42    let total = u64::from(across.saturating_mul(down).max(1));
43    total >= FINE_GRID_TILES && 4 * u64::try_from(damaged).unwrap_or(u64::MAX) >= total
44}
45
46/// Puts pixels where a terminal on this machine reads them, so a frame
47/// skips compression, base64 and the terminal connection.
48pub trait Stash: std::fmt::Debug + Send {
49    /// Store `pixels` and say where the terminal finds them.
50    fn put(&mut self, pixels: &[u8]) -> io::Result<Medium>;
51}
52
53/// Write `pixels` as one RGBA transmit under `image`: through `stash` when
54/// there is one, compressed in the escape code otherwise.
55pub(crate) fn transmit_pixels<W: Sink>(
56    bytes: &mut W,
57    image: KittyImage,
58    pixels: &[u8],
59    (width, height): (u32, u32),
60    stash: Option<&mut (dyn Stash + 'static)>,
61) -> io::Result<()> {
62    let mut transmit = Transmit::new(image, Format::Rgba, width, height, pixels);
63    transmit.compressed = true;
64    if let Some(stash) = stash {
65        transmit.medium = stash.put(pixels)?;
66    }
67    for frame in transmit.encode()? {
68        bytes.write_all(&frame)?;
69    }
70    Ok(())
71}
72
73/// One whole surface as a frame image: a transmit under `image`, the
74/// cursor at the area origin, one placement filling `area`. The caller
75/// owns the sync wrapper and any previous-placement delete.
76pub(crate) fn write_whole_image<W: Sink>(
77    bytes: &mut W,
78    image: KittyImage,
79    surface: &Surface,
80    area: CellRect,
81    stash: Option<&mut (dyn Stash + 'static)>,
82) -> io::Result<()> {
83    let size = (surface.width(), surface.height());
84    transmit_pixels(bytes, image, surface.pixels(), size, stash)?;
85    move_cursor(
86        bytes,
87        Position {
88            x: area.x,
89            y: area.y,
90        },
91    )?;
92    let mut place = Place::new(image);
93    place.placement = KittyPlacement(1);
94    place.columns = u32::from(area.columns);
95    place.rows = u32::from(area.rows);
96    bytes.write_all(&place.encode())
97}
98
99/// The solid-surface fixture the surface tests share.
100#[cfg(test)]
101pub(crate) mod test_support {
102    use katui_scene::placement::CellRect;
103    use katui_scene::surface::{Rgba, Surface};
104
105    pub(crate) fn solid(width: u32, height: u32, color: Rgba) -> Surface {
106        let mut surface = Surface::new(width, height);
107        for y in 0..height {
108            for x in 0..width {
109                surface.set(katui_scene::surface::PixelPos::new(x, y), color);
110            }
111        }
112        surface
113    }
114
115    /// A 16 by 16 cell area at the origin.
116    pub(crate) fn area() -> CellRect {
117        CellRect::full(16, 16)
118    }
119}
120
121/// Byte budgets per frame for the case every game hits: a small sprite
122/// moving over a static scene. A presenter that regresses into sending
123/// whole frames fails here long before anyone notices a slow game.
124#[cfg(test)]
125mod budget_tests {
126    use katui_proto::probe::CellPixels;
127    use katui_scene::placement::CellRect;
128    use katui_scene::surface::{PixelPos, Rgba, Surface};
129
130    use super::{AnimatedKitty, BlocksSurface, PatchedKitty, SixelSurface};
131
132    const WIDTH: u32 = 640;
133    const HEIGHT: u32 = 360;
134    const SPRITE: u32 = 16;
135    const FRAMES: u32 = 30;
136    /// A hundredth of one whole frame.
137    const BUDGET: usize = 8 * 1024;
138
139    fn background() -> Surface {
140        let mut surface = Surface::new(WIDTH, HEIGHT);
141        for y in 0..HEIGHT {
142            for x in 0..WIDTH {
143                let shade = Rgba::solid((x / 3) as u8, (y / 2) as u8, 90);
144                surface.set(PixelPos::new(x, y), shade);
145            }
146        }
147        surface
148    }
149
150    fn scene(background: &Surface, frame: u32) -> Surface {
151        let mut surface = background.clone();
152        let left = 40 + frame * 4;
153        for y in 100..100 + SPRITE {
154            for x in left..left + SPRITE {
155                surface.set(PixelPos::new(x, y), Rgba::solid(255, 240, 40));
156            }
157        }
158        surface
159    }
160
161    /// The largest frame after the first, in bytes.
162    fn worst_frame(mut render: impl FnMut(&mut Vec<u8>, &Surface) -> usize) -> usize {
163        let background = background();
164        let mut out = Vec::new();
165        render(&mut out, &scene(&background, 0));
166        (1..FRAMES)
167            .map(|frame| {
168                out.clear();
169                render(&mut out, &scene(&background, frame))
170            })
171            .max()
172            .unwrap_or(0)
173    }
174
175    fn area() -> CellRect {
176        CellRect::full(80, 20)
177    }
178
179    #[test]
180    fn a_moving_sprite_costs_a_few_kilobytes_a_frame_in_every_strategy() {
181        let cell = CellPixels {
182            width: 8,
183            height: 18,
184        };
185        let mut patched = PatchedKitty::with_tiles(900, cell, 32, 64);
186        let patched_worst = worst_frame(|out, surface| {
187            patched
188                .render_frame(out, surface, area())
189                .expect("writing to a Vec cannot fail")
190        });
191        let mut tiled = PatchedKitty::with_tiles(900, cell, 32, 64)
192            .for_multiplexer(katui_proto::kitty::IdSpace::of_process(4242), false);
193        let tiled_worst = worst_frame(|out, surface| {
194            tiled
195                .render_frame(out, surface, area())
196                .expect("writing to a Vec cannot fail")
197        });
198        let mut animated = AnimatedKitty::new(900, 60, 32, 64);
199        let animated_worst = worst_frame(|out, surface| {
200            animated
201                .render_frame(out, surface, area())
202                .expect("writing to a Vec cannot fail")
203        });
204        let mut sixel = SixelSurface::new(cell);
205        let sixel_worst = worst_frame(|out, surface| {
206            sixel
207                .render_frame(out, surface, 80, 21)
208                .expect("writing to a Vec cannot fail")
209        });
210        let mut blocks = BlocksSurface::new();
211        let blocks_worst = worst_frame(|out, surface| {
212            blocks
213                .render_frame(out, surface)
214                .expect("writing to a Vec cannot fail")
215        });
216        let worst = [
217            ("patched", patched_worst),
218            ("multiplexer tiles", tiled_worst),
219            ("animated", animated_worst),
220            ("sixel", sixel_worst),
221            ("blocks", blocks_worst),
222        ];
223        for (strategy, bytes) in worst {
224            assert!(bytes > 0, "{strategy} sends the moved sprite");
225            assert!(
226                bytes <= BUDGET,
227                "{strategy} spent {bytes} bytes on one frame"
228            );
229        }
230    }
231}