Skip to main content

katui_present/surface/
animated.rs

1//! The animated kitty strategy: the surface's generations become frames
2//! of one terminal-side animation, played by the terminal's own
3//! compositor while the wire only carries what changed.
4
5use crate::Sink;
6use std::io::{self, Write as _};
7
8use katui_proto::kitty::{
9    Animate, AnimationState, Canvas, Delete, FrameTransmit, ImageId as KittyImage,
10};
11use katui_scene::placement::CellRect;
12use katui_scene::surface::diff_tiles;
13use katui_scene::surface::{PixelRect, Surface};
14
15use super::patched::tile_pixels;
16use super::{damage_is_dense, write_whole_image};
17use crate::kitty::crop_rect;
18
19/// How many frames one animated image holds before a fresh root frame
20/// replaces it: a bound on the terminal's frame storage.
21const RESET_EVERY: u32 = 96;
22
23/// The animated kitty surface presenter: the first frame becomes the
24/// root frame of one image, placed once, and every later surface
25/// appends a frame composed onto the previous one plus the rectangles
26/// that changed. Playback runs in the terminal: loading mode plays the
27/// frames it holds at the gap pace and waits for more, so the wire is a
28/// buffer, jitter never reaches the screen, and motion happens at the
29/// compositor's rate rather than the client's. An unchanged frame
30/// writes nothing.
31///
32/// Frames accumulate terminal-side, so every 96 frames a fresh root frame
33/// (one whole compressed transmit) goes up under the alternate id, and
34/// then the finished reel stops and is freed.
35#[derive(Debug)]
36pub struct AnimatedKitty {
37    base: u32,
38    current: u32,
39    tile_side: u32,
40    budget: usize,
41    gap_ms: i32,
42    frame: u32,
43    since_reset: u32,
44    prev: Option<Surface>,
45}
46
47impl AnimatedKitty {
48    /// A presenter handing out image ids from `base` upward, playing at
49    /// `frames_per_second`, tiling changed rectangles at `tile_side`
50    /// surface pixels. Past `budget` damaged rectangles one whole frame
51    /// wins over hundreds of edits.
52    #[must_use]
53    pub fn new(base: u32, frames_per_second: u32, tile_side: u32, budget: usize) -> Self {
54        Self {
55            base,
56            current: base,
57            tile_side: tile_side.max(1),
58            budget,
59            gap_ms: (1000 / frames_per_second.max(1)).max(1).cast_signed(),
60            frame: 0,
61            since_reset: 0,
62            prev: None,
63        }
64    }
65
66    /// Start a fresh reel on the next frame, for when the terminal may
67    /// have lost what it was showing.
68    pub fn repaint(&mut self) {
69        self.prev = None;
70    }
71
72    /// Free both reels and start a fresh one on the next frame.
73    pub fn free<W: Sink>(&mut self, out: &mut W) -> io::Result<()> {
74        for image in [self.base, self.base + 1] {
75            out.write_all(&Delete::Free(KittyImage(image)).encode())?;
76        }
77        self.repaint();
78        Ok(())
79    }
80
81    /// Write one frame's data and return the bytes written.
82    pub fn render_frame<W: Sink>(
83        &mut self,
84        out: &mut W,
85        surface: &Surface,
86        area: CellRect,
87    ) -> io::Result<usize> {
88        let same_size = self.prev.as_ref().is_some_and(|prev| {
89            prev.width() == surface.width() && prev.height() == surface.height()
90        });
91        if !same_size {
92            return self.start_reel(out, surface, area);
93        }
94        if self.since_reset >= RESET_EVERY {
95            return self.rotate_reel(out, surface, area);
96        }
97        let damage = self
98            .prev
99            .as_ref()
100            .map(|prev| diff_tiles(prev, surface, self.tile_side))
101            .unwrap_or_default();
102        self.prev = Some(surface.clone());
103        if damage.is_empty() {
104            return Ok(0);
105        }
106        let next = self.frame + 1;
107        let mut bytes = Vec::new();
108        let dense = damage_is_dense(surface, self.tile_side, damage.len());
109        if !dense && damage.len() <= self.budget {
110            self.edited_frame(&mut bytes, surface, &damage, next)?;
111        } else {
112            self.whole_frame(&mut bytes, surface)?;
113        }
114        self.frame = next;
115        self.since_reset += 1;
116        let written = bytes.len();
117        out.write_all(&bytes)?;
118        Ok(written)
119    }
120
121    /// Start the next reel under the other id, then free the finished one,
122    /// so we always have a frame on screen.
123    fn rotate_reel<W: Sink>(
124        &mut self,
125        out: &mut W,
126        surface: &Surface,
127        area: CellRect,
128    ) -> io::Result<usize> {
129        let finished = self.current;
130        self.current = if finished == self.base {
131            self.base + 1
132        } else {
133            self.base
134        };
135        let written = self.start_reel(out, surface, area)?;
136        Self::stop_reel(out, finished)?;
137        Ok(written)
138    }
139
140    /// Frame `next` as a copy of its predecessor with one anchor pixel of
141    /// real data, which the damaged rectangles then edit in place.
142    fn edited_frame(
143        &self,
144        bytes: &mut Vec<u8>,
145        surface: &Surface,
146        damage: &[PixelRect],
147        next: u32,
148    ) -> io::Result<()> {
149        let anchor = surface.pixels().get(..4).unwrap_or(&[0, 0, 0, 255]);
150        let create = FrameTransmit {
151            id: KittyImage(self.current),
152            edit: None,
153            canvas: Canvas::Frame(self.frame),
154            rect: Some(crop_rect(PixelRect {
155                x: 0,
156                y: 0,
157                width: 1,
158                height: 1,
159            })),
160            width: 1,
161            height: 1,
162            payload: anchor,
163            compressed: false,
164            gap_ms: Some(self.gap_ms),
165        };
166        for frame in create.encode()? {
167            bytes.write_all(&frame)?;
168        }
169        for rect in damage {
170            let pixels = tile_pixels(surface, *rect);
171            let edit = FrameTransmit {
172                id: KittyImage(self.current),
173                edit: Some(next),
174                canvas: Canvas::Blank,
175                rect: Some(crop_rect(*rect)),
176                width: rect.width,
177                height: rect.height,
178                payload: &pixels,
179                compressed: true,
180                gap_ms: None,
181            };
182            for frame in edit.encode()? {
183                bytes.write_all(&frame)?;
184            }
185        }
186        Ok(())
187    }
188
189    /// The whole surface as the next frame.
190    fn whole_frame(&self, bytes: &mut Vec<u8>, surface: &Surface) -> io::Result<()> {
191        let whole = FrameTransmit {
192            id: KittyImage(self.current),
193            edit: None,
194            canvas: Canvas::Blank,
195            rect: None,
196            width: surface.width(),
197            height: surface.height(),
198            payload: surface.pixels(),
199            compressed: true,
200            gap_ms: Some(self.gap_ms),
201        };
202        for frame in whole.encode()? {
203            bytes.write_all(&frame)?;
204        }
205        Ok(())
206    }
207
208    /// Transmit the root frame, place it once, and hand playback to the
209    /// terminal.
210    fn start_reel<W: Sink>(
211        &mut self,
212        out: &mut W,
213        surface: &Surface,
214        area: CellRect,
215    ) -> io::Result<usize> {
216        let mut bytes = Vec::new();
217        write_whole_image(&mut bytes, KittyImage(self.current), surface, area, None)?;
218        let run = Animate {
219            id: KittyImage(self.current),
220            state: Some(AnimationState::Loading),
221            frame_gap: Some((1, self.gap_ms)),
222        };
223        bytes.write_all(&run.encode())?;
224        self.prev = Some(surface.clone());
225        self.frame = 1;
226        self.since_reset = 1;
227        let written = bytes.len();
228        out.write_all(&bytes)?;
229        Ok(written)
230    }
231
232    /// Stop playback of `image` and free its data.
233    fn stop_reel<W: Sink>(out: &mut W, image: u32) -> io::Result<()> {
234        let stop = Animate {
235            id: KittyImage(image),
236            state: Some(AnimationState::Stop),
237            frame_gap: None,
238        };
239        out.write_all(&stop.encode())?;
240        out.write_all(&Delete::Free(KittyImage(image)).encode())?;
241        Ok(())
242    }
243}
244
245#[cfg(test)]
246mod tests {
247    use super::AnimatedKitty;
248
249    fn plain_surface(width: u32, height: u32) -> Surface {
250        crate::surface::test_support::solid(width, height, Rgba::solid(10, 10, 30))
251    }
252
253    use katui_scene::surface::{Rgba, Surface};
254
255    #[test]
256    fn the_first_frame_is_a_root_frame_placed_and_playing() {
257        let mut presenter = AnimatedKitty::new(90, 20, 8, 16);
258        let mut out = Vec::new();
259        presenter
260            .render_frame(
261                &mut out,
262                &plain_surface(64, 64),
263                crate::surface::test_support::area(),
264            )
265            .expect("writing to a Vec cannot fail");
266        let text = String::from_utf8_lossy(&out);
267        assert!(
268            text.starts_with("\x1b_Ga=t,"),
269            "an image must exist before kitty accepts frames for it"
270        );
271        assert!(!text.contains("a=f"), "the root is no animation frame");
272        assert!(text.contains("o=z"), "compressed");
273        assert!(text.contains("a=p,i=90"), "placed once");
274        assert!(text.contains("a=a,i=90,s=2"), "playback is terminal-driven");
275        assert!(text.contains("r=1,z=50"), "the root gap is 50ms at 20fps");
276    }
277
278    #[test]
279    fn a_changed_frame_is_a_copy_plus_its_rectangles() {
280        let mut presenter = AnimatedKitty::new(90, 20, 8, 16);
281        let mut out = Vec::new();
282        presenter
283            .render_frame(
284                &mut out,
285                &plain_surface(64, 64),
286                crate::surface::test_support::area(),
287            )
288            .expect("writing to a Vec cannot fail");
289        let mut surface = plain_surface(64, 64);
290        surface.set(
291            katui_scene::surface::PixelPos::new(40, 40),
292            Rgba::solid(255, 255, 255),
293        );
294        let mut second = Vec::new();
295        presenter
296            .render_frame(&mut second, &surface, crate::surface::test_support::area())
297            .expect("writing to a Vec cannot fail");
298        let text = String::from_utf8_lossy(&second);
299        assert!(
300            !text.contains("s=64,v=64,"),
301            "the whole surface does not travel again"
302        );
303        assert!(
304            text.contains("c=1,x=0,y=0,s=1,v=1"),
305            "the frame is born a copy of its predecessor with an anchor pixel"
306        );
307        assert!(
308            text.contains("r=2,x=40,y=40,s=8,v=8"),
309            "the damaged tile edits frame two in place"
310        );
311    }
312
313    #[test]
314    fn an_unchanged_frame_writes_nothing() {
315        let mut presenter = AnimatedKitty::new(90, 20, 8, 16);
316        let mut out = Vec::new();
317        let surface = plain_surface(64, 64);
318        presenter
319            .render_frame(&mut out, &surface, crate::surface::test_support::area())
320            .expect("writing to a Vec cannot fail");
321        let mut second = Vec::new();
322        let written = presenter
323            .render_frame(&mut second, &surface, crate::surface::test_support::area())
324            .expect("writing to a Vec cannot fail");
325        assert_eq!(written, 0);
326        assert!(second.is_empty());
327    }
328
329    #[test]
330    fn a_reel_resets_after_its_frame_budget() {
331        let mut presenter = AnimatedKitty::new(90, 20, 8, 16);
332        let mut surface = plain_surface(32, 32);
333        let mut out = Vec::new();
334        presenter
335            .render_frame(&mut out, &surface, crate::surface::test_support::area())
336            .expect("writing to a Vec cannot fail");
337        for _ in 0..super::RESET_EVERY {
338            surface.set(
339                katui_scene::surface::PixelPos::new(4, 4),
340                Rgba::solid(
341                    (std::time::SystemTime::now()
342                        .duration_since(std::time::UNIX_EPOCH)
343                        .map_or(1, |d| d.subsec_nanos()) as u8)
344                        .saturating_mul(0)
345                        + 200,
346                    40,
347                    40,
348                ),
349            );
350            surface.set(
351                katui_scene::surface::PixelPos::new(5, 5),
352                Rgba::solid(40, 200, 40),
353            );
354            presenter
355                .render_frame(&mut out, &surface, crate::surface::test_support::area())
356                .expect("writing to a Vec cannot fail");
357            surface.set(
358                katui_scene::surface::PixelPos::new(4, 4),
359                Rgba::solid(40, 40, 200),
360            );
361            presenter
362                .render_frame(&mut out, &surface, crate::surface::test_support::area())
363                .expect("writing to a Vec cannot fail");
364        }
365        let text = String::from_utf8_lossy(&out);
366        assert!(text.contains("a=a,i=90,s=1"), "the finished reel stops");
367        assert!(
368            text.contains("a=t,f=32,t=d,o=z,s=32,v=32,i=91"),
369            "a fresh root frame starts the next reel"
370        );
371    }
372}