| Draw a picture in a terminal, over the Kitty graphics protocol c2d912f nandi 16d ago | 1 | //! 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 | |
| 20 | use std::collections::HashMap; |
| 21 | use std::io::{self, Write}; |
| 22 | |
| 23 | use crate::screen::Rect; |
| 24 | |
| 25 | /// Where one picture goes, as the painter left it. |
| 26 | #[derive(Clone, Debug, PartialEq, Eq)] |
| 27 | pub 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. |
| 48 | pub 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 ago | 53 | 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 ago | 55 | 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. |
| 67 | pub 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. |
| 94 | pub 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 | |
| 108 | thread_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)] |
| 129 | pub fn force(answer: Option<bool>) { |
| 130 | FORCED.with(|f| f.set(answer)); |
| 131 | } |
| 132 | |
| 133 | pub fn set_cell(cell: (u16, u16)) { |
| 134 | CELL.with(|c| c.set(cell)); |
| 135 | } |
| 136 | |
| 137 | pub fn cell() -> (u16, u16) { |
| 138 | CELL.with(|c| c.get()) |
| 139 | } |
| 140 | |
| 141 | /// The size of the picture at `path`, remembered. |
| 142 | pub 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. |
| 160 | pub 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)] |
| 181 | pub 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 | |
| 191 | impl 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 ago | 208 | 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 ago | 214 | } |
| 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. |
| 306 | fn base64(bytes: &[u8]) -> String { |
| Run the formatter over the tree 3e8c6f0 nandi 13d ago | 307 | const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; |
| Draw a picture in a terminal, over the Kitty graphics protocol c2d912f nandi 16d ago | 308 | 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 ago | 310 | 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 ago | 315 | 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)] |
| 330 | mod 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 ago | 340 | 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 ago | 344 | } |
| 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 | } |