Skip to main content

katui_present/
kitty.rs

1//! The kitty placement presenter: a placement diff as graphics APC
2//! frames, wrapped in synchronized output.
3//!
4//! Image ids are content-stable: new pixels get a new id, so an id is
5//! transmitted once per session and never re-sent. Re-transmitting is the
6//! one operation the protocol defines destructively (it deletes the image
7//! and every placement of it), so the presenter tracks what it has sent
8//! and which pairs are live, and retires an image once its last placement
9//! leaves.
10
11use crate::Sink;
12use crate::anchor::Anchor;
13use std::collections::HashSet;
14use std::io::{self, Write as _};
15
16use katui_proto::kitty::{self, IdSpace, ImageId as KittyImage, PlacementId as KittyPlacement};
17use katui_proto::probe::Capabilities;
18use katui_scene::cell::Position;
19use katui_scene::placement::{ImageId, Placement, PlacementDelta, PlacementId, diff};
20use katui_scene::surface::PixelRect;
21
22/// The raw pixel bytes of one upload command under a multiplexer, small
23/// enough that one compressed band fits a paced batch.
24const MULTIPLEXER_BAND_BYTES: usize = 12 * 1024;
25
26/// One image's RGBA payload, ready to transmit. `pixels` holds exactly
27/// `4 * width * height` bytes.
28#[derive(Debug, Clone, Copy)]
29pub struct RgbaImage<'a> {
30    width: u32,
31    height: u32,
32    pixels: &'a [u8],
33}
34
35impl<'a> RgbaImage<'a> {
36    /// An image of `width` x `height` RGBA pixels; `None` when `pixels`
37    /// does not hold exactly `4 * width * height` bytes.
38    #[must_use]
39    pub fn new(width: u32, height: u32, pixels: &'a [u8]) -> Option<Self> {
40        let expected = (4 * width).saturating_mul(height);
41        (usize::try_from(expected).is_ok_and(|expected| pixels.len() == expected)).then_some(Self {
42            width,
43            height,
44            pixels,
45        })
46    }
47}
48
49/// The images a diff's added placements draw from, keyed by scene id.
50#[derive(Debug, Default)]
51pub struct Images<'a> {
52    map: std::collections::HashMap<ImageId, RgbaImage<'a>>,
53}
54
55impl<'a> Images<'a> {
56    /// Register an image under its scene id.
57    pub fn insert(&mut self, id: ImageId, image: RgbaImage<'a>) {
58        self.map.insert(id, image);
59    }
60
61    fn get(&self, id: ImageId) -> Option<RgbaImage<'_>> {
62        self.map.get(&id).copied()
63    }
64}
65
66/// The kitty presenter. Holds wire state only: which images were sent and
67/// which placement pairs are live. Scene ids throughout; the wrap into
68/// protocol ids happens once, at each encode.
69#[derive(Debug, Default)]
70pub struct Kitty {
71    sent: HashSet<ImageId>,
72    live: HashSet<(ImageId, PlacementId)>,
73    anchor: Option<Anchor>,
74    space: Option<IdSpace>,
75    banded: bool,
76}
77
78impl Kitty {
79    /// A fresh presenter for a fresh terminal session.
80    #[must_use]
81    pub fn new() -> Self {
82        Self::default()
83    }
84
85    /// Place every image relative to a placeholder anchor under a
86    /// multiplexer, which then hides them with their pane. We also leave
87    /// out the synchronized-output wrapper there, because we send terminal
88    /// queries between batches and a terminal holds its answers until a
89    /// synchronized update ends.
90    /// Every image id goes into `space`, so programs sharing the terminal
91    /// keep their images apart.
92    #[must_use]
93    pub fn for_multiplexer(mut self, space: IdSpace) -> Self {
94        self.space = Some(space);
95        self.anchor = Some(Anchor::new(space));
96        self
97    }
98
99    /// Send big images as a blank image plus bands of rows, so a
100    /// multiplexer can pace between them. The bands are frame edits, so
101    /// only a terminal that stores animation frames takes them.
102    #[must_use]
103    pub fn banded(mut self, banded: bool) -> Self {
104        self.banded = banded;
105        self
106    }
107
108    /// The wire id of a scene image, inside our id space when we share
109    /// the terminal.
110    fn wire(&self, image: ImageId) -> KittyImage {
111        self.space
112            .map_or(KittyImage(image.0), |space| space.image(image.0))
113    }
114
115    /// Free every image this presenter sent and forget them all, as for a
116    /// new window. Other programs on the terminal keep theirs.
117    pub fn reset<W: Sink>(&mut self, out: &mut W) -> io::Result<()> {
118        let mut sent: Vec<ImageId> = self.sent.drain().collect();
119        sent.sort_unstable();
120        self.live.clear();
121        if let Some(anchor) = self.anchor.as_mut() {
122            anchor.forget();
123        }
124        for image in sent {
125            out.write_all(&kitty::Delete::Free(self.wire(image)).encode())?;
126        }
127        Ok(())
128    }
129
130    fn upload(
131        &self,
132        bytes: &mut Vec<u8>,
133        image: ImageId,
134        payload: &RgbaImage<'_>,
135    ) -> io::Result<()> {
136        let band = if self.banded {
137            MULTIPLEXER_BAND_BYTES
138        } else {
139            usize::MAX
140        };
141        for frame in kitty::banded_upload(
142            self.wire(image),
143            payload.width,
144            payload.height,
145            payload.pixels,
146            band,
147        )? {
148            bytes.write_all(&frame)?;
149        }
150        Ok(())
151    }
152
153    /// Write `delta` as kitty graphics frames and return the byte count,
154    /// the input of the frame budget pacing. The whole exchange sits in
155    /// DEC 2026 synchronized output and leaves in one write, so a
156    /// terminal never shows half of it. An empty diff writes nothing.
157    ///
158    /// Added placements place their image, transmitting it once if this
159    /// presenter has not sent it yet; an added placement whose image is
160    /// missing from `images` is skipped, because its pixels are not on
161    /// the wire. Moves re-place their pair at the new origin. Removed
162    /// pairs delete themselves, and an image whose last placement left is
163    /// deleted whole.
164    pub fn render<W: Sink>(
165        &mut self,
166        out: &mut W,
167        delta: &PlacementDelta,
168        images: &Images<'_>,
169    ) -> io::Result<usize> {
170        if delta.is_empty() {
171            return Ok(0);
172        }
173        let mut bytes = Vec::new();
174        match self.anchor.as_mut() {
175            Some(anchor) => anchor.write(&mut bytes)?,
176            None => bytes.write_all(b"\x1b[?2026h")?,
177        }
178        let transmitted = self.place_added(&mut bytes, &delta.added, images)?;
179        self.place_moved(&mut bytes, &delta.moved)?;
180        self.delete_removed(&mut bytes, &delta.removed)?;
181        self.free_emptied(&mut bytes)?;
182        self.sent.extend(transmitted);
183        if self.anchor.is_none() {
184            bytes.write_all(b"\x1b[?2026l")?;
185        }
186        let written = bytes.len();
187        out.write_all(&bytes)?;
188        Ok(written)
189    }
190
191    /// Place each added placement, transmitting its image first unless it
192    /// went out before. Returns the images transmitted now.
193    fn place_added(
194        &mut self,
195        bytes: &mut Vec<u8>,
196        added: &[Placement],
197        images: &Images<'_>,
198    ) -> io::Result<HashSet<ImageId>> {
199        let mut transmitted: HashSet<ImageId> = HashSet::new();
200        for placement in added {
201            let image = placement.image;
202            let on_wire = self.sent.contains(&image) || transmitted.contains(&image);
203            if !on_wire && let Some(payload) = images.get(image) {
204                self.upload(bytes, image, &payload)?;
205                transmitted.insert(image);
206            }
207            if self.sent.contains(&image) || transmitted.contains(&image) {
208                place(bytes, self.wire(image), placement, self.anchor.as_ref())?;
209                self.live.insert((image, placement.id));
210            }
211        }
212        Ok(transmitted)
213    }
214
215    fn place_moved(&self, bytes: &mut Vec<u8>, moved: &[Placement]) -> io::Result<()> {
216        for placement in moved {
217            if self.live.contains(&(placement.image, placement.id)) {
218                place(
219                    bytes,
220                    self.wire(placement.image),
221                    placement,
222                    self.anchor.as_ref(),
223                )?;
224            }
225        }
226        Ok(())
227    }
228
229    fn delete_removed(&mut self, bytes: &mut Vec<u8>, removed: &[Placement]) -> io::Result<()> {
230        for placement in removed {
231            self.live.remove(&(placement.image, placement.id));
232            let wire = self.wire(placement.image);
233            bytes.write_all(
234                &kitty::Delete::Placement(wire, KittyPlacement(placement.id.0)).encode(),
235            )?;
236        }
237        Ok(())
238    }
239
240    /// Delete whole every sent image whose last placement left.
241    fn free_emptied(&mut self, bytes: &mut Vec<u8>) -> io::Result<()> {
242        let mut emptied: Vec<ImageId> = self
243            .sent
244            .iter()
245            .copied()
246            .filter(|image| !self.live.iter().any(|(sent, _)| sent == image))
247            .collect();
248        emptied.sort_unstable();
249        for image in emptied {
250            bytes.write_all(&kitty::Delete::Free(self.wire(image)).encode())?;
251            self.sent.remove(&image);
252        }
253        Ok(())
254    }
255}
256
257/// A [`Kitty`] presenter that remembers what it showed: each frame passes
258/// every placement it wants on screen, and only the difference goes out.
259#[derive(Debug, Default)]
260pub struct KittyScreen {
261    kitty: Kitty,
262    shown: Vec<Placement>,
263    fresh: bool,
264}
265
266impl KittyScreen {
267    /// A screen for a fresh session on the terminal `caps` describes,
268    /// anchored and banded under a multiplexer.
269    #[must_use]
270    pub fn new(caps: &Capabilities) -> Self {
271        let kitty = if caps.multiplexed() {
272            Kitty::new()
273                .for_multiplexer(caps.id_space)
274                .banded(caps.animation)
275        } else {
276            Kitty::new()
277        };
278        Self {
279            kitty,
280            ..Self::default()
281        }
282    }
283
284    /// Send everything again on the next frame, for a terminal that may
285    /// have lost what it showed.
286    pub fn repaint(&mut self) {
287        self.fresh = true;
288    }
289
290    /// Whether the next frame sends everything, so the caller passes the
291    /// pixels of every image it shows.
292    #[must_use]
293    pub fn sends_everything(&self) -> bool {
294        self.fresh || self.shown.is_empty()
295    }
296
297    /// Show exactly `next`, sending only what changed, and return the
298    /// byte count.
299    pub fn present<W: Sink>(
300        &mut self,
301        out: &mut W,
302        next: Vec<Placement>,
303        images: &Images<'_>,
304    ) -> io::Result<usize> {
305        if std::mem::take(&mut self.fresh) {
306            self.kitty.reset(out)?;
307            self.shown.clear();
308        }
309        let delta = diff(&self.shown, &next);
310        let written = self.kitty.render(out, &delta, images)?;
311        self.shown = next;
312        Ok(written)
313    }
314}
315
316/// Convert a scene crop to a protocol crop.
317pub(crate) fn crop_rect(crop: PixelRect) -> kitty::PixelRect {
318    kitty::PixelRect {
319        x: crop.x,
320        y: crop.y,
321        width: crop.width,
322        height: crop.height,
323    }
324}
325
326/// Address the placement origin, then anchor the place frame there.
327fn place(
328    bytes: &mut Vec<u8>,
329    image: KittyImage,
330    placement: &Placement,
331    anchor: Option<&Anchor>,
332) -> io::Result<()> {
333    let origin = Position {
334        x: placement.area.x,
335        y: placement.area.y,
336    };
337    let mut command = kitty::Place::new(image);
338    command.placement = KittyPlacement(placement.id.0);
339    command.columns = u32::from(placement.area.columns);
340    command.rows = u32::from(placement.area.rows);
341    command.crop = placement.crop.map(crop_rect);
342    command.z = placement.z;
343    match anchor {
344        Some(anchor) => command.parent = Some(anchor.offset_to(origin)),
345        None => crate::cells::move_cursor(bytes, origin)?,
346    }
347    bytes.write_all(&command.encode())
348}
349
350#[cfg(test)]
351mod tests {
352    use super::{Images, Kitty, RgbaImage};
353    use katui_proto::kitty::{self, ImageId as KittyImage, PlacementId as KittyPlacement};
354    use katui_scene::placement::{CellRect, ImageId, Placement, PlacementDelta, PlacementId};
355
356    const PIXELS: &[u8] = &[0xff, 0, 0, 0xff];
357
358    fn placement(image: u32, id: u32, x: u16, y: u16) -> Placement {
359        let area = CellRect {
360            x,
361            y,
362            columns: 4,
363            rows: 2,
364        };
365        Placement::new(ImageId(image), PlacementId(id), area)
366    }
367
368    fn delta(added: &[Placement], moved: &[Placement], removed: &[Placement]) -> PlacementDelta {
369        PlacementDelta {
370            added: added.to_vec(),
371            moved: moved.to_vec(),
372            removed: removed.to_vec(),
373        }
374    }
375
376    fn images() -> Images<'static> {
377        let mut images = Images::default();
378        images.insert(
379            ImageId(7),
380            RgbaImage::new(1, 1, PIXELS).expect("the fixture payload is exact"),
381        );
382        images.insert(
383            ImageId(8),
384            RgbaImage::new(1, 1, PIXELS).expect("the fixture payload is exact"),
385        );
386        images
387    }
388
389    fn transmit_frame(id: u32) -> String {
390        let mut transmit = kitty::Transmit::new(KittyImage(id), kitty::Format::Rgba, 1, 1, PIXELS);
391        transmit.compressed = true;
392        let frames = transmit.encode().expect("a Vec encoder cannot fail");
393        let mut joined = String::new();
394        for frame in frames {
395            joined.push_str(&String::from_utf8_lossy(&frame));
396        }
397        joined
398    }
399
400    #[test]
401    fn a_reset_frees_everything_and_sends_it_all_again() {
402        let mut presenter = Kitty::new();
403        let shown = placement(7, 1, 0, 0);
404        let mut out = Vec::new();
405        presenter
406            .render(&mut out, &delta(&[shown], &[], &[]), &images())
407            .expect("writing to a Vec cannot fail");
408        out.clear();
409        presenter
410            .reset(&mut out)
411            .expect("writing to a Vec cannot fail");
412        assert_eq!(out, kitty::Delete::Free(KittyImage(7)).encode());
413        out.clear();
414        presenter
415            .render(&mut out, &delta(&[shown], &[], &[]), &images())
416            .expect("writing to a Vec cannot fail");
417        assert_eq!(occurrences(&String::from_utf8_lossy(&out), "a=t,"), 1);
418    }
419
420    #[test]
421    fn a_shared_screen_bands_big_images_only_where_frames_are_stored() {
422        use super::KittyScreen;
423        use katui_proto::probe::{Capabilities, Identity};
424
425        let big = vec![0x80; 4 * 64 * 64];
426        let send = |animation: bool| {
427            let caps = Capabilities {
428                kitty: true,
429                animation,
430                identity: Some(Identity {
431                    name: "tmux".to_owned(),
432                    version: None,
433                }),
434                id_space: kitty::IdSpace::of_process(7),
435                ..Capabilities::default()
436            };
437            let mut screen = KittyScreen::new(&caps);
438            let mut images = Images::default();
439            images.insert(
440                ImageId(3),
441                RgbaImage::new(64, 64, &big).expect("exact pixels"),
442            );
443            let mut out = Vec::new();
444            screen
445                .present(&mut out, vec![placement(3, 1, 0, 0)], &images)
446                .expect("writing to a Vec cannot fail");
447            String::from_utf8_lossy(&out).into_owned()
448        };
449        let space = kitty::IdSpace::of_process(7).image(3).0;
450        let banded = send(true);
451        assert!(banded.contains("a=f,"), "frame edits carry the bands");
452        assert!(
453            banded.contains(&format!("i={space},")),
454            "the id sits in our space"
455        );
456        let whole = send(false);
457        assert!(
458            !whole.contains("a=f,"),
459            "a terminal without frames gets one transmit"
460        );
461        assert_eq!(whole.matches("a=t,").count(), 2, "the anchor and the image");
462    }
463
464    #[test]
465    fn a_multiplexer_render_has_no_sync_wrapper_and_keeps_z() {
466        let mut presenter = Kitty::new().for_multiplexer(katui_proto::kitty::IdSpace::default());
467        let layered = Placement {
468            z: -5,
469            ..placement(7, 1, 0, 0)
470        };
471        let mut out = Vec::new();
472        presenter
473            .render(&mut out, &delta(&[layered], &[], &[]), &images())
474            .expect("writing to a Vec cannot fail");
475        let text = String::from_utf8_lossy(&out);
476        assert!(!text.contains("2026"), "queries travel between batches");
477        assert!(text.contains(",z=-5,"), "the layer order reaches the wire");
478    }
479
480    #[test]
481    fn a_screen_sends_only_changes_and_everything_after_a_repaint() {
482        let mut screen = super::KittyScreen::new(&katui_proto::probe::Capabilities::default());
483        let shown = vec![placement(7, 1, 0, 0)];
484        let mut first = Vec::new();
485        screen
486            .present(&mut first, shown.clone(), &images())
487            .expect("writing to a Vec cannot fail");
488        assert!(!screen.sends_everything());
489        let mut same = Vec::new();
490        screen
491            .present(&mut same, shown.clone(), &images())
492            .expect("writing to a Vec cannot fail");
493        assert!(same.is_empty(), "an unchanged frame writes nothing");
494        screen.repaint();
495        assert!(screen.sends_everything());
496        let mut again = Vec::new();
497        screen
498            .present(&mut again, shown, &images())
499            .expect("writing to a Vec cannot fail");
500        let text = String::from_utf8_lossy(&again);
501        assert!(text.contains("a=t,") && text.contains("a=p,"));
502    }
503
504    fn occurrences(text: &str, needle: &str) -> usize {
505        text.matches(needle).count()
506    }
507
508    /// Draw the first frame of `image` into a throwaway writer, the
509    /// starting state of every later-frame test.
510    fn seed(presenter: &mut Kitty, image: u32) {
511        let first = [placement(image, 1, 0, 0)];
512        presenter
513            .render(&mut Vec::new(), &delta(&first, &[], &[]), &images())
514            .expect("writing to a Vec cannot fail");
515    }
516
517    #[test]
518    fn an_empty_diff_writes_nothing() {
519        let mut out = Vec::new();
520        let written = Kitty::new()
521            .render(&mut out, &delta(&[], &[], &[]), &images())
522            .expect("writing to a Vec cannot fail");
523        assert_eq!(written, 0);
524        assert!(out.is_empty());
525    }
526
527    #[test]
528    fn an_exact_payload_is_an_image_and_a_short_one_is_not() {
529        assert!(RgbaImage::new(1, 1, PIXELS).is_some());
530        assert!(RgbaImage::new(2, 1, PIXELS).is_none(), "too few bytes");
531        assert!(
532            RgbaImage::new(0, 0, &[]).is_some(),
533            "an empty image is exact"
534        );
535    }
536
537    #[test]
538    fn an_added_placement_transmits_once_then_places_inside_sync() {
539        let mut out = Vec::new();
540        let added = [placement(7, 1, 2, 3)];
541        Kitty::new()
542            .render(&mut out, &delta(&added, &[], &[]), &images())
543            .expect("writing to a Vec cannot fail");
544        let text = String::from_utf8_lossy(&out);
545        assert!(text.starts_with("\x1b[?2026h"));
546        assert!(text.ends_with("\x1b[?2026l"));
547        let transmit_at = text
548            .find(&transmit_frame(7))
549            .expect("the transmit frame is on the wire");
550        let cursor_at = text.find("\x1b[4;3H").expect("cursor at the origin");
551        let mut place = kitty::Place::new(KittyImage(7));
552        place.placement = KittyPlacement(1);
553        place.columns = 4;
554        place.rows = 2;
555        let place_at = text
556            .find(String::from_utf8_lossy(&place.encode()).as_ref())
557            .expect("the place frame follows");
558        assert!(transmit_at < cursor_at, "payload before addressing");
559        assert!(cursor_at < place_at, "addressing before the place frame");
560    }
561
562    #[test]
563    fn two_pairs_of_one_image_share_one_transmission() {
564        let mut out = Vec::new();
565        let added = [placement(7, 1, 0, 0), placement(7, 2, 6, 0)];
566        let mut presenter = Kitty::new();
567        presenter
568            .render(&mut out, &delta(&added, &[], &[]), &images())
569            .expect("writing to a Vec cannot fail");
570        let text = String::from_utf8_lossy(&out);
571        assert_eq!(
572            occurrences(&text, &transmit_frame(7)),
573            1,
574            "one transmission per image per session"
575        );
576        assert!(text.contains("\x1b[1;1H"));
577        assert!(text.contains("\x1b[1;7H"));
578    }
579
580    #[test]
581    fn a_later_pair_places_without_retransmitting_the_live_image() {
582        let mut presenter = Kitty::new();
583        seed(&mut presenter, 7);
584        let second = [placement(7, 2, 6, 0)];
585        let mut out = Vec::new();
586        presenter
587            .render(&mut out, &delta(&second, &[], &[]), &images())
588            .expect("writing to a Vec cannot fail");
589        let text = String::from_utf8_lossy(&out);
590        assert!(
591            !text.contains("/wAA"),
592            "the live image is never re-transmitted"
593        );
594        assert!(text.contains("\x1b[1;7H"), "the new pair places");
595    }
596
597    #[test]
598    fn a_move_replaces_the_pair_without_transmitting() {
599        let mut presenter = Kitty::new();
600        seed(&mut presenter, 7);
601        let moved = [placement(7, 1, 9, 9)];
602        let mut out = Vec::new();
603        presenter
604            .render(&mut out, &delta(&[], &moved, &[]), &images())
605            .expect("writing to a Vec cannot fail");
606        let text = String::from_utf8_lossy(&out);
607        assert!(!text.contains("/wAA"), "no payload on a move");
608        assert!(text.contains("\x1b[10;10H"));
609    }
610
611    #[test]
612    fn removing_the_last_pair_retires_the_image() {
613        let mut presenter = Kitty::new();
614        seed(&mut presenter, 7);
615        let removed = [placement(7, 1, 0, 0)];
616        let mut out = Vec::new();
617        presenter
618            .render(&mut out, &delta(&[], &[], &removed), &images())
619            .expect("writing to a Vec cannot fail");
620        let text = String::from_utf8_lossy(&out);
621        let pair_delete = kitty::Delete::Placement(KittyImage(7), KittyPlacement(1)).encode();
622        let image_delete = kitty::Delete::Free(KittyImage(7)).encode();
623        let pair_at = text
624            .find(String::from_utf8_lossy(&pair_delete).as_ref())
625            .expect("the pair deletes");
626        let image_at = text
627            .find(String::from_utf8_lossy(&image_delete).as_ref())
628            .expect("the emptied image retires");
629        assert!(pair_at < image_at, "pairs go before their image");
630    }
631
632    #[test]
633    fn an_added_placement_without_pixels_is_skipped() {
634        let mut out = Vec::new();
635        let added = [placement(99, 1, 0, 0)];
636        Kitty::new()
637            .render(&mut out, &delta(&added, &[], &[]), &images())
638            .expect("writing to a Vec cannot fail");
639        assert_eq!(
640            String::from_utf8_lossy(&out),
641            "\x1b[?2026h\x1b[?2026l",
642            "only the sync wrapper"
643        );
644    }
645}