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