Skip to main content

katui_present/surface/
screen.rs

1//! The composite pixel strategy: patched kitty tiles or terminal-side
2//! animation when the terminal speaks kitty graphics, sixel tiles when it
3//! speaks sixel, the damage-tracked blocks otherwise.
4
5use crate::Sink;
6use std::io;
7
8use katui_proto::probe::{Capabilities, CellPixels};
9
10use crate::select::{Backend, select};
11use katui_scene::placement::CellRect;
12use katui_scene::surface::Surface;
13
14use super::{AnimatedKitty, BlocksSurface, PatchedKitty, SixelSurface};
15
16/// The pixel strategy a [`PixelScreen`] drives on a graphics terminal.
17#[derive(Debug)]
18pub(super) enum PixelStrategy {
19    /// Patched tile retransmits, each frame displayed as it arrives.
20    Patched(PatchedKitty),
21    /// Terminal-side animation frames, played by the compositor.
22    Animated(AnimatedKitty),
23    /// Sixel tiles, for terminals without kitty graphics.
24    Sixel(SixelSurface),
25}
26
27/// Renders a pixel surface full-screen through the best strategy the
28/// terminal offers.
29#[derive(Debug, Default)]
30pub struct PixelScreen {
31    graphics: Option<PixelStrategy>,
32    blocks: BlocksSurface,
33}
34
35impl PixelScreen {
36    /// The screen for `caps` with cells of `cell` pixels: `patched` when
37    /// the terminal speaks the kitty graphics protocol, sixel tiles when it
38    /// speaks sixel, quarter blocks otherwise.
39    #[must_use]
40    pub fn new(caps: &Capabilities, cell: CellPixels, patched: PatchedKitty) -> Self {
41        let patched = if caps.multiplexed() {
42            patched.for_multiplexer(caps.id_space, caps.relative_placements)
43        } else {
44            patched
45        };
46        let graphics = match select(caps) {
47            Backend::Kitty => Some(PixelStrategy::Patched(patched)),
48            Backend::Sixel => Some(PixelStrategy::Sixel(SixelSurface::new(cell))),
49            Backend::Blocks => None,
50        };
51        Self {
52            graphics,
53            blocks: BlocksSurface::new(),
54        }
55    }
56
57    /// The screen for a terminal without kitty graphics: sixel tiles when
58    /// it speaks sixel, quarter blocks otherwise.
59    #[must_use]
60    pub fn picture(caps: &Capabilities, cell: CellPixels) -> Self {
61        let graphics =
62            (select(caps) == Backend::Sixel).then(|| PixelStrategy::Sixel(SixelSurface::new(cell)));
63        Self {
64            graphics,
65            blocks: BlocksSurface::new(),
66        }
67    }
68
69    /// How many of `rows` screen rows a frame covers. Sixel leaves the
70    /// bottom row free, because a sixel touching it scrolls the screen.
71    #[must_use]
72    pub fn rows_shown(&self, rows: u16) -> u16 {
73        match self.graphics {
74            Some(PixelStrategy::Sixel(_)) => rows.saturating_sub(1),
75            _ => rows,
76        }
77    }
78
79    /// The same screen driven by a terminal-side animation when the
80    /// terminal stores animation frames outside a multiplexer, which
81    /// swallows frames, and by [`Self::new`] with `fallback` otherwise.
82    #[must_use]
83    pub fn animated(
84        caps: &Capabilities,
85        cell: CellPixels,
86        animated: AnimatedKitty,
87        fallback: PatchedKitty,
88    ) -> Self {
89        if select(caps) == Backend::Kitty && caps.animation && !caps.multiplexed() {
90            Self {
91                graphics: Some(PixelStrategy::Animated(animated)),
92                blocks: BlocksSurface::new(),
93            }
94        } else {
95            Self::new(caps, cell, fallback)
96        }
97    }
98
99    /// Draw for cells of `cell` pixels from now on, after a font zoom.
100    pub fn set_cell(&mut self, cell: CellPixels) {
101        if let Some(PixelStrategy::Patched(patched)) = &mut self.graphics {
102            patched.set_cell(cell);
103        }
104    }
105
106    /// Send every graphics pixel again on the next frame, for when the
107    /// terminal may have lost its images, such as after a window resize.
108    pub fn repaint(&mut self) {
109        match &mut self.graphics {
110            Some(PixelStrategy::Patched(patched)) => patched.repaint(),
111            Some(PixelStrategy::Animated(animated)) => animated.repaint(),
112            Some(PixelStrategy::Sixel(sixel)) => sixel.repaint(),
113            None => {}
114        }
115    }
116
117    /// Free every graphics image this screen sent and repaint on the next
118    /// frame, for a screen that went out of view or is being replaced.
119    pub fn free<W: Sink>(&mut self, out: &mut W) -> io::Result<()> {
120        match &mut self.graphics {
121            Some(PixelStrategy::Patched(patched)) => patched.free(out),
122            Some(PixelStrategy::Animated(animated)) => animated.free(out),
123            Some(PixelStrategy::Sixel(sixel)) => {
124                sixel.repaint();
125                Ok(())
126            }
127            None => Ok(()),
128        }
129    }
130
131    /// Write `surface` scaled to the whole `columns` x `rows` screen and
132    /// return the bytes written. Blocks show two by two pixels a cell, so
133    /// a surface of another size is resized to that first.
134    pub fn render_frame<W: Sink>(
135        &mut self,
136        out: &mut W,
137        surface: &Surface,
138        columns: u16,
139        rows: u16,
140    ) -> io::Result<usize> {
141        match &mut self.graphics {
142            Some(PixelStrategy::Patched(patched)) => {
143                patched.render_frame(out, surface, CellRect::full(columns, rows))
144            }
145            Some(PixelStrategy::Animated(animated)) => {
146                animated.render_frame(out, surface, CellRect::full(columns, rows))
147            }
148            Some(PixelStrategy::Sixel(sixel)) => sixel.render_frame(out, surface, columns, rows),
149            None => {
150                let (width, height) = (u32::from(columns) * 2, u32::from(rows) * 2);
151                if (surface.width(), surface.height()) == (width, height) {
152                    self.blocks.render_frame(out, surface)
153                } else {
154                    surface
155                        .scaled(width, height)
156                        .map_or(Ok(0), |fitted| self.blocks.render_frame(out, &fitted))
157                }
158            }
159        }
160    }
161}
162
163#[cfg(test)]
164mod tests {
165    use super::PixelScreen;
166    use katui_proto::probe::{Capabilities, CellPixels, ProbeSource};
167    use katui_scene::surface::{Rgba, Surface};
168
169    const CELL: CellPixels = CellPixels {
170        width: 2,
171        height: 4,
172    };
173
174    fn caps(kitty: bool) -> Capabilities {
175        Capabilities {
176            kitty,
177            animation: false,
178            sixel: false,
179            truecolor: false,
180            identity: None,
181            cell: None,
182            text_area: None,
183            relative_placements: false,
184            id_space: katui_proto::kitty::IdSpace::default(),
185            trust: ProbeSource::Sniffed,
186        }
187    }
188
189    fn plain(width: u32, height: u32) -> Surface {
190        crate::surface::test_support::solid(width, height, Rgba::solid(255, 80, 40))
191    }
192
193    #[test]
194    fn a_sixel_picture_covers_every_row_but_the_last_and_blocks_cover_all() {
195        let sixel = Capabilities {
196            sixel: true,
197            ..caps(false)
198        };
199        assert_eq!(PixelScreen::picture(&sixel, CELL).rows_shown(24), 23);
200        assert_eq!(PixelScreen::picture(&caps(false), CELL).rows_shown(24), 24);
201    }
202
203    #[test]
204    fn a_kitty_terminal_gets_graphics_frames() {
205        let patched = crate::surface::PatchedKitty::new(
206            90,
207            CellPixels {
208                width: 2,
209                height: 4,
210            },
211        );
212        let mut screen = PixelScreen::new(&caps(true), CELL, patched);
213        let mut out = Vec::new();
214        screen
215            .render_frame(&mut out, &plain(8, 8), 4, 2)
216            .expect("writing to a Vec cannot fail");
217        let text = String::from_utf8_lossy(&out);
218        assert!(text.contains("a=t"), "the surface transmits as an image");
219        assert!(text.contains("a=p"), "and is placed");
220    }
221
222    #[test]
223    fn a_sixel_terminal_gets_sixel_tiles() {
224        let patched = crate::surface::PatchedKitty::new(
225            90,
226            CellPixels {
227                width: 2,
228                height: 4,
229            },
230        );
231        let mut sixel = caps(false);
232        sixel.sixel = true;
233        let mut screen = PixelScreen::new(&sixel, CELL, patched);
234        let mut out = Vec::new();
235        screen
236            .render_frame(&mut out, &plain(8, 4), 4, 2)
237            .expect("writing to a Vec cannot fail");
238        assert!(String::from_utf8_lossy(&out).contains("\x1bP0;1;0q"));
239    }
240
241    #[test]
242    fn a_plain_terminal_gets_quarter_blocks() {
243        let patched = crate::surface::PatchedKitty::new(
244            90,
245            CellPixels {
246                width: 2,
247                height: 4,
248            },
249        );
250        let mut screen = PixelScreen::new(&caps(false), CELL, patched);
251        let mut out = Vec::new();
252        screen
253            .render_frame(&mut out, &plain(8, 4), 4, 2)
254            .expect("writing to a Vec cannot fail");
255        let text = String::from_utf8_lossy(&out);
256        assert!(!text.contains("a=t"), "no graphics on a plain terminal");
257        assert!(text.contains("\x1b[38;2;"), "quarter blocks render");
258    }
259
260    #[test]
261    fn an_animation_averse_graphics_terminal_gets_patches() {
262        let mut no_animation = caps(true);
263        no_animation.animation = false;
264        let animated = crate::surface::AnimatedKitty::new(90, 20, 8, 16);
265        let fallback = crate::surface::PatchedKitty::new(
266            90,
267            CellPixels {
268                width: 2,
269                height: 4,
270            },
271        );
272        let mut screen = PixelScreen::animated(&no_animation, CELL, animated, fallback);
273        let mut out = Vec::new();
274        screen
275            .render_frame(&mut out, &plain(8, 8), 4, 2)
276            .expect("writing to a Vec cannot fail");
277        let text = String::from_utf8_lossy(&out);
278        assert!(!text.contains("a=f"), "no animation frames");
279        assert!(text.contains("a=t"), "patch transmits carry the surface");
280    }
281
282    #[test]
283    fn an_animation_capable_terminal_gets_frames() {
284        let mut animated_caps = caps(true);
285        animated_caps.animation = true;
286        let animated = crate::surface::AnimatedKitty::new(90, 20, 8, 16);
287        let fallback = crate::surface::PatchedKitty::new(
288            90,
289            CellPixels {
290                width: 2,
291                height: 4,
292            },
293        );
294        let mut screen = PixelScreen::animated(&animated_caps, CELL, animated, fallback);
295        let mut out = Vec::new();
296        screen
297            .render_frame(&mut out, &plain(8, 8), 4, 2)
298            .expect("writing to a Vec cannot fail");
299        let text = String::from_utf8_lossy(&out);
300        assert!(text.contains("a=a"), "playback is terminal-driven");
301    }
302}