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            local_files: false,
185            id_space: katui_proto::kitty::IdSpace::default(),
186            trust: ProbeSource::Sniffed,
187        }
188    }
189
190    fn plain(width: u32, height: u32) -> Surface {
191        crate::surface::test_support::solid(width, height, Rgba::solid(255, 80, 40))
192    }
193
194    #[test]
195    fn a_sixel_picture_covers_every_row_but_the_last_and_blocks_cover_all() {
196        let sixel = Capabilities {
197            sixel: true,
198            ..caps(false)
199        };
200        assert_eq!(PixelScreen::picture(&sixel, CELL).rows_shown(24), 23);
201        assert_eq!(PixelScreen::picture(&caps(false), CELL).rows_shown(24), 24);
202    }
203
204    #[test]
205    fn a_kitty_terminal_gets_graphics_frames() {
206        let patched = crate::surface::PatchedKitty::new(
207            90,
208            CellPixels {
209                width: 2,
210                height: 4,
211            },
212        );
213        let mut screen = PixelScreen::new(&caps(true), CELL, patched);
214        let mut out = Vec::new();
215        screen
216            .render_frame(&mut out, &plain(8, 8), 4, 2)
217            .expect("writing to a Vec cannot fail");
218        let text = String::from_utf8_lossy(&out);
219        assert!(text.contains("a=t"), "the surface transmits as an image");
220        assert!(text.contains("a=p"), "and is placed");
221    }
222
223    #[test]
224    fn a_sixel_terminal_gets_sixel_tiles() {
225        let patched = crate::surface::PatchedKitty::new(
226            90,
227            CellPixels {
228                width: 2,
229                height: 4,
230            },
231        );
232        let mut sixel = caps(false);
233        sixel.sixel = true;
234        let mut screen = PixelScreen::new(&sixel, CELL, patched);
235        let mut out = Vec::new();
236        screen
237            .render_frame(&mut out, &plain(8, 4), 4, 2)
238            .expect("writing to a Vec cannot fail");
239        assert!(String::from_utf8_lossy(&out).contains("\x1bP0;1;0q"));
240    }
241
242    #[test]
243    fn a_plain_terminal_gets_quarter_blocks() {
244        let patched = crate::surface::PatchedKitty::new(
245            90,
246            CellPixels {
247                width: 2,
248                height: 4,
249            },
250        );
251        let mut screen = PixelScreen::new(&caps(false), CELL, patched);
252        let mut out = Vec::new();
253        screen
254            .render_frame(&mut out, &plain(8, 4), 4, 2)
255            .expect("writing to a Vec cannot fail");
256        let text = String::from_utf8_lossy(&out);
257        assert!(!text.contains("a=t"), "no graphics on a plain terminal");
258        assert!(text.contains("\x1b[38;2;"), "quarter blocks render");
259    }
260
261    #[test]
262    fn an_animation_averse_graphics_terminal_gets_patches() {
263        let mut no_animation = caps(true);
264        no_animation.animation = false;
265        let animated = crate::surface::AnimatedKitty::new(90, 20, 8, 16);
266        let fallback = crate::surface::PatchedKitty::new(
267            90,
268            CellPixels {
269                width: 2,
270                height: 4,
271            },
272        );
273        let mut screen = PixelScreen::animated(&no_animation, CELL, animated, fallback);
274        let mut out = Vec::new();
275        screen
276            .render_frame(&mut out, &plain(8, 8), 4, 2)
277            .expect("writing to a Vec cannot fail");
278        let text = String::from_utf8_lossy(&out);
279        assert!(!text.contains("a=f"), "no animation frames");
280        assert!(text.contains("a=t"), "patch transmits carry the surface");
281    }
282
283    #[test]
284    fn an_animation_capable_terminal_gets_frames() {
285        let mut animated_caps = caps(true);
286        animated_caps.animation = true;
287        let animated = crate::surface::AnimatedKitty::new(90, 20, 8, 16);
288        let fallback = crate::surface::PatchedKitty::new(
289            90,
290            CellPixels {
291                width: 2,
292                height: 4,
293            },
294        );
295        let mut screen = PixelScreen::animated(&animated_caps, CELL, animated, fallback);
296        let mut out = Vec::new();
297        screen
298            .render_frame(&mut out, &plain(8, 8), 4, 2)
299            .expect("writing to a Vec cannot fail");
300        let text = String::from_utf8_lossy(&out);
301        assert!(text.contains("a=a"), "playback is terminal-driven");
302    }
303}