nandi/jolt-nativepublic Fork 0
5ae197f7be55bd41678978f11e41a4282ffcf4b7
Commits
Clone
git clone https://git.rickub.com/nandi/jolt-native.git
git clone ssh://git@rickub.com/nandi/jolt-native.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

graphics.rs · 354 lines · 13.9 KBRust Blame HistoryRaw
Draw a picture in a terminal, over the Kitty graphics protocol c2d912f nandi 16d ago1//! Pictures in a terminal, over the Kitty graphics protocol.
2//!
3//! A cell grid has no pixels, so an `:image` is not painted like everything
4//! else here: the painter reserves the cells and records where they are, and
5//! this turns that into the escape sequences that put a picture there. The
6//! terminal draws it over the blank cells the grid left behind.
7//!
8//! PNG only, and deliberately: the file is handed over as it is, with `f=100`,
9//! so nothing here decodes an image. That is the same bargain the window
10//! backend makes — `libvidya` decodes PNG and nothing else, and frq only ever
11//! caches PNG — and it is what keeps a terminal backend free of an image
12//! library.
13//!
14//! Not every terminal has the protocol, and one that has not would print the
15//! escape as text: a screenful of base64 where a conversation was. So this is
16//! off unless the terminal is one that is known to answer — `$TERM`,
17//! `$TERM_PROGRAM` and kitty's own variable say so — and `JOLT_TUI_GRAPHICS`
18//! forces it either way for a terminal this does not know about yet.
19
20use std::collections::HashMap;
21use std::io::{self, Write};
22
23use crate::screen::Rect;
24
25/// Where one picture goes, as the painter left it.
26#[derive(Clone, Debug, PartialEq, Eq)]
27pub struct Placement {
28 /// The node it belongs to, which is what makes it the same picture across
29 /// frames while it scrolls.
30 pub node: u32,
31 /// The file, which is what makes it the same *picture* — two nodes showing
32 /// one file are transmitted once.
33 pub path: String,
34 /// The cells it was given.
35 pub area: Rect,
36 /// Rows of the picture cut off the top and the bottom by whatever it is
37 /// scrolling inside. A partly-visible picture is placed partly, rather
38 /// than whole and over its neighbours, or not at all and flickering.
39 pub crop_top: u16,
40 pub crop_bottom: u16,
41}
42
43/// A picture's size in pixels, read out of the PNG header.
44///
45/// The eight-byte signature, then the first chunk, which the format says is
46/// `IHDR`: length, type, width, height. Sixteen bytes of a file, so this is a
47/// read of the head rather than a decode of the whole.
48pub fn png_size(bytes: &[u8]) -> Option<(u32, u32)> {
49 const SIGNATURE: [u8; 8] = [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
50 if bytes.len() < 24 || bytes[..8] != SIGNATURE || &bytes[12..16] != b"IHDR" {
51 return None;
52 }
53 let read = |at: usize| {
54 u32::from_be_bytes([bytes[at], bytes[at + 1], bytes[at + 2], bytes[at + 3]])
55 };
56 let (w, h) = (read(16), read(20));
57 (w > 0 && h > 0).then_some((w, h))
58}
59
60/// Whether this terminal draws pictures.
61///
62/// A name rather than a question asked over the wire: the protocol's query is
63/// an escape whose answer comes back as input, and the answer arrives while
64/// the first frame is being painted — too late for the layout that has to
65/// decide how many rows a picture takes. Names are what a terminal is
66/// identified by everywhere else here, and `JOLT_TUI_GRAPHICS=1` is the way in
67/// for one this list has not learned yet.
68pub fn supported() -> bool {
69 #[cfg(test)]
70 if let Some(forced) = FORCED.with(|f| f.get()) {
71 return forced;
72 }
73 match std::env::var("JOLT_TUI_GRAPHICS").as_deref() {
74 Ok("1") | Ok("true") => return true,
75 Ok("0") | Ok("false") => return false,
76 _ => {}
77 }
78 if std::env::var_os("KITTY_WINDOW_ID").is_some()
79 || std::env::var_os("GHOSTTY_RESOURCES_DIR").is_some()
80 {
81 return true;
82 }
83 let known = |name: String| {
84 let name = name.to_ascii_lowercase();
85 name.contains("kitty") || name.contains("ghostty") || name.contains("wezterm")
86 };
87 std::env::var("TERM").map(known).unwrap_or(false)
88 || std::env::var("TERM_PROGRAM").map(known).unwrap_or(false)
89}
90
91/// One cell in pixels. The terminal is asked; where it will not say — a
92/// multiplexer in the way, a terminal that answers zero — the usual size of a
93/// cell in a terminal font stands in, which is wrong by a little rather than
94/// by an order of magnitude.
95pub fn cell_pixels(columns: u16, rows: u16, width: u16, height: u16) -> (u16, u16) {
96 let w = if columns > 0 && width > 0 {
97 width / columns
98 } else {
99 0
100 };
101 let h = if rows > 0 && height > 0 {
102 height / rows
103 } else {
104 0
105 };
106 (if w > 0 { w } else { 8 }, if h > 0 { h } else { 16 })
107}
108
109thread_local! {
110 /// An answer for [`supported`] that does not come from the environment —
111 /// what a test says, so one asserting on a screen with a picture in it
112 /// does not depend on the terminal it happens to be run from.
113 #[cfg(test)]
114 static FORCED: std::cell::Cell<Option<bool>> = const { std::cell::Cell::new(None) };
115 /// What a cell measures, and the size of every picture on screen follows
116 /// from it. The terminal is asked once when it opens and again when it is
117 /// resized; a headless session keeps the default, which is what makes a
118 /// test of a layout with a picture in it repeatable.
119 static CELL: std::cell::Cell<(u16, u16)> = const { std::cell::Cell::new((8, 16)) };
120 /// Sizes read out of PNG headers, by path. A layout asks for every picture
121 /// on screen every frame, and a file's header does not change under it —
122 /// frq writes a cached picture once, under a name of its own.
123 static SIZES: std::cell::RefCell<HashMap<String, Option<(u32, u32)>>> =
124 std::cell::RefCell::new(HashMap::new());
125}
126
127/// Say yes or no for this thread, whatever the terminal is. `None` gives the
128/// question back to the environment.
129#[cfg(test)]
130pub fn force(answer: Option<bool>) {
131 FORCED.with(|f| f.set(answer));
132}
133
134pub fn set_cell(cell: (u16, u16)) {
135 CELL.with(|c| c.set(cell));
136}
137
138pub fn cell() -> (u16, u16) {
139 CELL.with(|c| c.get())
140}
141
142/// The size of the picture at `path`, remembered.
143pub fn size_of(path: &str) -> Option<(u32, u32)> {
144 SIZES.with(|sizes| {
145 if let Some(known) = sizes.borrow().get(path) {
146 return *known;
147 }
148 let size = std::fs::read(path).ok().and_then(|bytes| png_size(&bytes));
149 sizes.borrow_mut().insert(path.to_owned(), size);
150 size
151 })
152}
153
154/// How many cells a picture takes, given what it may not exceed.
155///
156/// The caller's `:max-width` and `:max-height` are the bounds — frq measures
157/// those against the window, and against a terminal they arrive as cells — and
158/// the picture is fitted inside them with its shape kept. A picture whose size
159/// cannot be read at all is given a modest box: something is there, and the
160/// line above it says what.
161pub fn cells_for(path: &str, max_cols: u16, max_rows: u16) -> (u16, u16) {
162 let (max_cols, max_rows) = (max_cols.max(1), max_rows.max(1));
163 let Some((pw, ph)) = size_of(path) else {
164 return (max_cols.min(24), max_rows.min(3));
165 };
166 let (cw, ch) = cell();
167 let natural_cols = (pw as f64 / cw as f64).ceil().max(1.0);
168 let natural_rows = (ph as f64 / ch as f64).ceil().max(1.0);
169 // Whichever bound it meets first, and never scaled up: a small picture is
170 // small, the way it is in the window.
171 let scale = (max_cols as f64 / natural_cols)
172 .min(max_rows as f64 / natural_rows)
173 .min(1.0);
174 (
175 ((natural_cols * scale).round() as u16).max(1),
176 ((natural_rows * scale).round() as u16).max(1),
177 )
178}
179
180/// The pictures on screen, and what the terminal has been told about them.
181#[derive(Default)]
182pub struct Graphics {
183 /// File -> the id it was transmitted under. A picture crosses the wire
184 /// once, however many frames it is on screen for and wherever it scrolls.
185 sent: HashMap<String, u32>,
186 /// Node -> what it was last placed as. A placement that has not moved is
187 /// left alone; the wire is quiet while a reader reads.
188 placed: HashMap<u32, Placement>,
189 next_id: u32,
190}
191
192impl Graphics {
193 /// Bring the terminal's idea of what is on screen into line with `now`.
194 ///
195 /// Deletions first: a picture that has moved is deleted and placed again,
196 /// and doing it in that order means the cells it used to be over are the
197 /// terminal's own to redraw rather than a hole under the new placement.
198 pub fn sync(
199 &mut self,
200 out: &mut impl Write,
201 now: &[Placement],
202 cell: (u16, u16),
203 ) -> io::Result<()> {
204 for (node, was) in std::mem::take(&mut self.placed) {
205 let still = now.iter().any(|p| p.node == node && *p == was);
206 if still {
207 self.placed.insert(node, was);
208 } else {
209 write!(out, "\x1b_Ga=d,d=i,i={},p={},q=2\x1b\\", self.id(&was.path), node)?;
210 }
211 }
212 for placement in now {
213 if self.placed.get(&placement.node) == Some(placement) {
214 continue;
215 }
216 self.place(out, placement, cell)?;
217 self.placed.insert(placement.node, placement.clone());
218 }
219 Ok(())
220 }
221
222 /// Every picture forgotten, and the terminal told to drop them all. What
223 /// closing a session, or leaving the alternate screen, has to do: a
224 /// placement outlives the frame it was made in.
225 pub fn clear(&mut self, out: &mut impl Write) -> io::Result<()> {
226 self.sent.clear();
227 self.placed.clear();
228 write!(out, "\x1b_Ga=d,d=A,q=2\x1b\\")
229 }
230
231 fn id(&self, path: &str) -> u32 {
232 self.sent.get(path).copied().unwrap_or(0)
233 }
234
235 fn place(
236 &mut self,
237 out: &mut impl Write,
238 placement: &Placement,
239 cell: (u16, u16),
240 ) -> io::Result<()> {
241 let Some(id) = self.transmit(out, &placement.path)? else {
242 return Ok(());
243 };
244 let area = placement.area;
245 if area.w == 0 || area.h == 0 {
246 return Ok(());
247 }
248 // The cursor is where a placement lands, so it goes there first — and
249 // `C=1` leaves it there rather than letting the picture move it, which
250 // would put the next thing this writes somewhere else entirely.
251 write!(out, "\x1b[{};{}H", area.y + 1, area.x + 1)?;
252 let mut keys = format!(
253 "a=p,i={id},p={},c={},r={},C=1,q=2",
254 placement.node, area.w, area.h
255 );
256 if placement.crop_top > 0 || placement.crop_bottom > 0 {
257 // In pixels of the source, which is what the protocol crops in.
258 let top = placement.crop_top as u32 * cell.1 as u32;
259 let rows = area.h as u32 * cell.1 as u32;
260 keys.push_str(&format!(",y={top},h={rows}"));
261 }
262 write!(out, "\x1b_G{keys}\x1b\\")
263 }
264
265 /// Hand the file over, once. Answers the id it went under, or `None` for a
266 /// file that is not there any more or is not a PNG — a fetch that failed
267 /// leaves a link in the message, which is the right thing to be left with.
268 fn transmit(&mut self, out: &mut impl Write, path: &str) -> io::Result<Option<u32>> {
269 if let Some(id) = self.sent.get(path) {
270 return Ok(Some(*id));
271 }
272 let Ok(bytes) = std::fs::read(path) else {
273 return Ok(None);
274 };
275 if png_size(&bytes).is_none() {
276 return Ok(None);
277 }
278 self.next_id += 1;
279 let id = self.next_id;
280 // Base64 in chunks the protocol's own size, each saying whether more
281 // is coming. The first carries the keys; the rest carry only `m`.
282 let encoded = base64(&bytes);
283 let mut chunks = encoded.as_bytes().chunks(4096).peekable();
284 let mut first = true;
285 while let Some(chunk) = chunks.next() {
286 let more = u8::from(chunks.peek().is_some());
287 if first {
288 write!(out, "\x1b_Ga=t,i={id},f=100,t=d,m={more},q=2;")?;
289 first = false;
290 } else {
291 write!(out, "\x1b_Gm={more},q=2;")?;
292 }
293 out.write_all(chunk)?;
294 write!(out, "\x1b\\")?;
295 }
296 self.sent.insert(path.to_owned(), id);
297 Ok(Some(id))
298 }
299}
300
301/// Standard base64, which is what the protocol's payload is written in.
302fn base64(bytes: &[u8]) -> String {
303 const ALPHABET: &[u8; 64] =
304 b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
305 let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
306 for group in bytes.chunks(3) {
307 let b = [group[0], *group.get(1).unwrap_or(&0), *group.get(2).unwrap_or(&0)];
308 let n = u32::from_be_bytes([0, b[0], b[1], b[2]]);
309 let mut quad = [0u8; 4];
310 for (i, slot) in quad.iter_mut().enumerate() {
311 *slot = ALPHABET[(n >> (18 - 6 * i) & 0x3F) as usize];
312 }
313 for (i, ch) in quad.iter().enumerate() {
314 // A group short of three bytes pads: two characters carry one
315 // byte, three carry two.
316 out.push(if i > group.len() { '=' } else { *ch as char });
317 }
318 }
319 out
320}
321
322#[cfg(test)]
323mod tests {
324 use super::*;
325
326 #[test]
327 fn base64_pads_the_way_the_alphabet_says() {
328 assert_eq!(base64(b""), "");
329 assert_eq!(base64(b"f"), "Zg==");
330 assert_eq!(base64(b"fo"), "Zm8=");
331 assert_eq!(base64(b"foo"), "Zm9v");
332 assert_eq!(base64(b"foob"), "Zm9vYg==");
333 assert_eq!(base64(b"any carnal pleasure."), "YW55IGNhcm5hbCBwbGVhc3VyZS4=");
334 }
335
336 #[test]
337 fn a_png_header_gives_up_its_size_and_anything_else_gives_up_nothing() {
338 let mut png = vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
339 png.extend_from_slice(&13u32.to_be_bytes());
340 png.extend_from_slice(b"IHDR");
341 png.extend_from_slice(&640u32.to_be_bytes());
342 png.extend_from_slice(&480u32.to_be_bytes());
343 assert_eq!(png_size(&png), Some((640, 480)));
344 assert_eq!(png_size(b"not a picture at all"), None);
345 assert_eq!(png_size(&png[..20]), None);
346 }
347
348 #[test]
349 fn a_cell_is_the_screen_divided_by_the_grid_or_a_sensible_guess() {
350 assert_eq!(cell_pixels(80, 24, 640, 384), (8, 16));
351 assert_eq!(cell_pixels(80, 24, 0, 0), (8, 16));
352 assert_eq!(cell_pixels(0, 0, 640, 384), (8, 16));
353 }
354}