Skip to main content

pristine/tui/treemap/
kitty.rs

1//! The kitty graphics protocol, in the two sequences this needs.
2//!
3//! # What is sent
4//!
5//! An `APC` — `ESC _ G <keys> ; <payload> ESC \` — carrying the canvas as base64 RGB, split
6//! into chunks of at most [`CHUNK`] payload bytes with `m=1` on every one but the last. The
7//! keys say what the bytes are (`f=24`, `s`, `v`), where they go (`a=T`, `c`, `r`) and how to
8//! behave (`i`, `q=2`, `C=1`).
9//!
10//! # The three keys that are not about the picture
11//!
12//! - **`q=2` suppresses the terminal's reply**, and it is not an optimisation. Without it the
13//!   terminal answers every transmission with `ESC _ G i=… ; OK ESC \` *on stdin*, which
14//!   arrives in the event loop as a burst of keystrokes — an `i`, an `=`, a `;`, an `O`, a
15//!   `K`. Half of those are bound. A picture that types into the tree it is a picture of is
16//!   not a degradation, it is a hazard.
17//! - **`C=1` stops the cursor moving.** The default is for the cursor to end up after the
18//!   image, which ratatui does not know about and would then draw from.
19//! - **`i=`** names the image so it can be taken back. See [`Image::gone`]: a graphics image
20//!   is *stored by the terminal*, not by this process, so leaving one behind is leaving a
21//!   megabyte in somebody's terminal after pristine has exited — #619's "a state that cannot
22//!   be given back is a state you do not take", in the one place here where the state lives
23//!   in another program's memory.
24//!
25//! # Nothing here asks the terminal a question
26//!
27//! There is a documented query for "do you speak this protocol" and it is a round trip: write
28//! a probe, then read the answer, with no bound on how long a terminal that does not speak it
29//! takes to not answer. That is exactly the blocking probe [`super::super::chrome`] refuses to
30//! make, so this is allowlisted from the environment on the same terms as everything else —
31//! see [`super::Graphics`].
32
33use super::paint::Canvas;
34
35/// The most base64 one chunk may carry, from the protocol's own limit.
36const CHUNK: usize = 4096;
37
38/// An image this process has given the terminal, and the id it can take it back by.
39///
40/// The number is arbitrary but must not be one another program is using; the protocol's own
41/// advice is to pick a random one, and a constant is fine here because two pristines sharing
42/// a terminal would each be drawing over the other's screen anyway.
43pub const ID: u32 = 1_976_622;
44
45/// Everything this sends the terminal, as bytes ready to be written.
46pub struct Image;
47
48impl Image {
49    /// Puts `canvas` on the screen at `at`, a one-based (row, column) cell, filling
50    /// `cells` (columns, rows) of the grid.
51    ///
52    /// The cursor is saved and put back around the placement, because the terminal's cursor
53    /// belongs to ratatui: a frame drawn from wherever an image happened to leave it is a
54    /// frame drawn in the wrong place.
55    #[must_use]
56    pub fn shown(canvas: &Canvas, at: (u16, u16), cells: (u16, u16)) -> Vec<u8> {
57        let payload = base64(&canvas.rgb);
58        let mut out = Vec::with_capacity(payload.len() + payload.len() / CHUNK * 32 + 64);
59        out.extend_from_slice(b"\x1b7");
60        out.extend_from_slice(format!("\x1b[{};{}H", at.0, at.1).as_bytes());
61        // Replaced rather than overwritten: the same id twice is defined to replace, and
62        // saying so costs twenty bytes against a payload of a megabyte.
63        out.extend_from_slice(&Self::gone());
64        let chunks = payload.as_bytes().chunks(CHUNK);
65        let last = chunks.len().saturating_sub(1);
66        for (nth, chunk) in chunks.enumerate() {
67            out.extend_from_slice(b"\x1b_G");
68            if nth == 0 {
69                out.extend_from_slice(
70                    format!(
71                        "a=T,q=2,C=1,i={ID},f=24,s={},v={},c={},r={},",
72                        canvas.width, canvas.height, cells.0, cells.1
73                    )
74                    .as_bytes(),
75                );
76            }
77            out.extend_from_slice(if nth == last { b"m=0;" } else { b"m=1;" });
78            out.extend_from_slice(chunk);
79            out.extend_from_slice(b"\x1b\\");
80        }
81        out.extend_from_slice(b"\x1b8");
82        out
83    }
84
85    /// Takes the image back, data and all.
86    ///
87    /// `d=I` rather than `d=i`: the lower case one removes the *placement* and leaves the
88    /// pixels in the terminal's memory, which is a leak that outlives the process.
89    #[must_use]
90    pub fn gone() -> Vec<u8> {
91        format!("\x1b_Ga=d,d=I,i={ID},q=2\x1b\\").into_bytes()
92    }
93}
94
95/// Standard base64, padded.
96///
97/// Hand-rolled rather than a dependency, because it is twenty lines and the alternative is a
98/// crate in the tree of a tool whose whole pitch is that it is one binary.
99fn base64(bytes: &[u8]) -> String {
100    const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
101    let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
102    for group in bytes.chunks(3) {
103        let packed = u32::from(group[0]) << 16
104            | u32::from(group.get(1).copied().unwrap_or(0)) << 8
105            | u32::from(group.get(2).copied().unwrap_or(0));
106        for shift in [18, 12, 6, 0] {
107            out.push(char::from(ALPHABET[((packed >> shift) & 0x3f) as usize]));
108        }
109        // The padding is over the *characters* that stood for bytes nobody sent.
110        let missing = 3 - group.len();
111        out.truncate(out.len() - missing);
112        for _ in 0..missing {
113            out.push('=');
114        }
115    }
116    out
117}
118
119#[cfg(test)]
120mod tests {
121    use super::{CHUNK, ID, Image, base64};
122    use crate::tui::treemap::paint::Canvas;
123
124    fn said(bytes: &[u8]) -> String {
125        String::from_utf8(bytes.to_vec()).unwrap()
126    }
127
128    #[test]
129    fn base64_agrees_with_the_standard_on_every_length_of_tail() {
130        assert_eq!(base64(b""), "");
131        assert_eq!(base64(b"f"), "Zg==");
132        assert_eq!(base64(b"fo"), "Zm8=");
133        assert_eq!(base64(b"foo"), "Zm9v");
134        assert_eq!(base64(b"foob"), "Zm9vYg==");
135        assert_eq!(base64(b"fooba"), "Zm9vYmE=");
136        assert_eq!(base64(b"foobar"), "Zm9vYmFy");
137        // The bytes an image is actually made of, which are not text.
138        assert_eq!(base64(&[0x00, 0xff, 0x1a]), "AP8a");
139    }
140
141    #[test]
142    fn one_placement_says_what_the_bytes_are_where_they_go_and_to_keep_quiet() {
143        let canvas = Canvas::new(2, 2);
144        let out = said(&Image::shown(&canvas, (3, 41), (20, 10)));
145
146        assert!(out.starts_with("\x1b7\x1b[3;41H"), "{out:?}");
147        assert!(
148            out.ends_with("\x1b8"),
149            "the cursor was not put back: {out:?}"
150        );
151        assert!(out.contains("f=24,s=2,v=2,c=20,r=10,"), "{out:?}");
152        // The one that is a hazard rather than an optimisation: without it the terminal
153        // answers on stdin and the answer arrives as keystrokes the tree is bound to.
154        assert!(
155            out.contains("q=2"),
156            "the terminal was not told to keep quiet"
157        );
158        assert!(
159            out.contains("C=1"),
160            "the cursor would be left after the image"
161        );
162        assert!(out.contains(&format!("i={ID}")), "{out:?}");
163        assert!(out.contains("m=0;"), "no chunk was marked as the last");
164    }
165
166    #[test]
167    fn a_payload_too_big_for_one_chunk_is_split_and_only_the_last_says_so() {
168        // 64×64 of RGB is 12 KiB, which is four chunks of base64.
169        let canvas = Canvas::new(64, 64);
170        let out = said(&Image::shown(&canvas, (1, 1), (8, 4)));
171
172        assert!(
173            out.matches("m=1;").count() >= 3,
174            "not chunked: {}",
175            out.len()
176        );
177        assert_eq!(out.matches("m=0;").count(), 1, "more than one last chunk");
178        // Every chunk's payload is inside the protocol's limit, which is what the terminal
179        // enforces by dropping the image rather than by complaining.
180        // Every chunk that carries one — the delete at the front has no payload and so no
181        // `;` either, which is the protocol's own shape rather than an omission.
182        let mut counted = 0;
183        for chunk in out.split("\x1b_G").skip(1) {
184            let Some((_, rest)) = chunk.split_once(';') else {
185                continue;
186            };
187            let payload = rest.split_once('\x1b').unwrap().0;
188            assert!(payload.len() <= CHUNK, "a chunk of {}", payload.len());
189            counted += 1;
190        }
191        assert!(counted >= 4, "only {counted} chunks");
192        // Only the first carries the keys; repeating them would be a second image.
193        assert_eq!(out.matches("a=T").count(), 1);
194    }
195
196    #[test]
197    fn the_image_can_be_taken_back_with_its_pixels() {
198        let gone = said(&Image::gone());
199        // Upper case: the lower case one leaves the pixels in the terminal's memory after
200        // this process has exited, which is a leak nothing is left alive to notice.
201        assert!(gone.contains("d=I"), "{gone}");
202        assert!(gone.contains(&format!("i={ID}")), "{gone}");
203        assert!(gone.contains("q=2"), "{gone}");
204        // …and every placement begins by taking back the one before it.
205        let shown = said(&Image::shown(&Canvas::new(2, 2), (1, 1), (1, 1)));
206        assert!(
207            shown.contains(&gone),
208            "a placement left the last one behind"
209        );
210    }
211}