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}