Draw a picture in a terminal, over the Kitty graphics protocol
An `:image` was a tag with a `src`, which this measured as nothing and painted as nothing: a picture in a message was the link that fetched it and no more. A cell grid has no pixels, so it cannot be otherwise — unless the terminal is asked to draw over the cells, which is what the Kitty protocol is for and what Ghostty, kitty and WezTerm speak. So the painter reserves the cells a picture's shape asks for and records where they are, and the flush turns that into an escape. The file goes over as it is, with `f=100`: nothing here decodes an image, which is the bargain the window backend already makes — libvidya decodes PNG and nothing else, and frq caches nothing else. The size comes out of the twenty-four byte header, the size of a cell comes from the terminal, and between them they give the rows and columns with the picture's shape kept. A picture crosses the wire once. Afterwards only its placement moves, and a placement that has not moved is not sent again — the wire is quiet while a reader reads. One inside a scroll is cropped from the source rather than dropped, so a backlog scrolls past a picture a row at a time instead of losing it whole at the edge or painting it over the compose bar. Closing the session deletes them: a placement is the terminal's, and outlives the alternate screen it was made on. Off unless the terminal is one that answers. The protocol's own query comes back as input, which is too late for the layout that has to decide how many rows a picture takes — and a terminal without the protocol would print the escape, which is a screenful of base64 where the conversation was. So it is `$TERM`, `$TERM_PROGRAM` and kitty's own variable that say, with `JOLT_TUI_GRAPHICS` to force it either way. Where the answer is no, the cells carry a dim note that a picture is there and the link above it is the way to it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
c2d912f parent: 3dd441e added
crates/jolt-tui/src/graphics.rs +354 -0 | new file mode 100644 | ||
| @@ -0,0 +1,354 @@ | ||
| 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 | + } | |
| 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. | |
| 68 | +pub 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. | |
| 95 | +pub 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 | + | |
| 109 | +thread_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)] | |
| 130 | +pub fn force(answer: Option<bool>) { | |
| 131 | + FORCED.with(|f| f.set(answer)); | |
| 132 | +} | |
| 133 | + | |
| 134 | +pub fn set_cell(cell: (u16, u16)) { | |
| 135 | + CELL.with(|c| c.set(cell)); | |
| 136 | +} | |
| 137 | + | |
| 138 | +pub fn cell() -> (u16, u16) { | |
| 139 | + CELL.with(|c| c.get()) | |
| 140 | +} | |
| 141 | + | |
| 142 | +/// The size of the picture at `path`, remembered. | |
| 143 | +pub 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. | |
| 161 | +pub 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)] | |
| 182 | +pub 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 | + | |
| 192 | +impl 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. | |
| 302 | +fn 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)] | |
| 323 | +mod 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 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,354 @@ | |||
| 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 | + } | ||
| 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. | ||
| 68 | +pub 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. | ||
| 95 | +pub 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 | + | ||
| 109 | +thread_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)] | ||
| 130 | +pub fn force(answer: Option<bool>) { | ||
| 131 | + FORCED.with(|f| f.set(answer)); | ||
| 132 | +} | ||
| 133 | + | ||
| 134 | +pub fn set_cell(cell: (u16, u16)) { | ||
| 135 | + CELL.with(|c| c.set(cell)); | ||
| 136 | +} | ||
| 137 | + | ||
| 138 | +pub fn cell() -> (u16, u16) { | ||
| 139 | + CELL.with(|c| c.get()) | ||
| 140 | +} | ||
| 141 | + | ||
| 142 | +/// The size of the picture at `path`, remembered. | ||
| 143 | +pub 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. | ||
| 161 | +pub 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)] | ||
| 182 | +pub 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 | + | ||
| 192 | +impl 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. | ||
| 302 | +fn 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)] | ||
| 323 | +mod 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 | +} | ||
modified
crates/jolt-tui/src/layout.rs +31 -0 | @@ -10,6 +10,7 @@ | ||
| 10 | 10 | //! Nothing here touches a terminal or the screen grid: sizes are arithmetic on |
| 11 | 11 | //! the tree, which is why the layout tests below need no TTY. |
| 12 | 12 | |
| 13 | +use crate::graphics; | |
| 13 | 14 | use crate::screen::{glyph_cols, glyphs, text_cols, Rect}; |
| 14 | 15 | use crate::tree::{Props, Tag, Tree}; |
| 15 | 16 | |
| @@ -129,6 +130,34 @@ fn longest_word(text: &str) -> u16 { | ||
| 129 | 130 | text.split([' ', '\n']).map(text_cols).max().unwrap_or(0) |
| 130 | 131 | } |
| 131 | 132 | |
| 133 | +/// The cells a picture is given: its own shape, inside the caller's bounds and | |
| 134 | +/// inside the room the column has. | |
| 135 | +/// | |
| 136 | +/// Without the protocol to draw one there is no picture, only the note that | |
| 137 | +/// says there was — so the box is a line, and the link in the message above it | |
| 138 | +/// is what the reader is left with either way. | |
| 139 | +pub fn image_cells(props: &Props, avail: u16) -> (u16, u16) { | |
| 140 | + let path = props.str("src"); | |
| 141 | + if path.is_empty() { | |
| 142 | + return (0, 0); | |
| 143 | + } | |
| 144 | + if !graphics::supported() { | |
| 145 | + return (text_cols(PICTURE), 1); | |
| 146 | + } | |
| 147 | + let max_cols = match props.cells("max-width", 0) { | |
| 148 | + 0 => avail, | |
| 149 | + want => want.min(avail), | |
| 150 | + }; | |
| 151 | + let max_rows = match props.cells("max-height", 0) { | |
| 152 | + 0 => 16, | |
| 153 | + want => want, | |
| 154 | + }; | |
| 155 | + graphics::cells_for(path, max_cols, max_rows) | |
| 156 | +} | |
| 157 | + | |
| 158 | +/// What stands in for a picture where one cannot be drawn. | |
| 159 | +pub const PICTURE: &str = "[ picture ]"; | |
| 160 | + | |
| 132 | 161 | /// The text an entry shows: its own, or its placeholder when it has none. |
| 133 | 162 | pub fn entry_text(props: &Props) -> String { |
| 134 | 163 | let text = props.str("text"); |
| @@ -180,6 +209,7 @@ fn intrinsic_width(tree: &Tree, id: u32, minimum: bool) -> u16 { | ||
| 180 | 209 | Tag::Separator => 1, |
| 181 | 210 | Tag::Spacer => props.cells("size", 1), |
| 182 | 211 | Tag::Emoji => text_cols(props.str("emoji")), |
| 212 | + Tag::Image => image_cells(&props, u16::MAX).0, | |
| 183 | 213 | Tag::Reaction => text_cols(&pill_text(&props)), |
| 184 | 214 | Tag::Progress => { |
| 185 | 215 | if minimum { |
| @@ -269,6 +299,7 @@ pub fn height_for_width(tree: &Tree, id: u32, avail: u16) -> u16 { | ||
| 269 | 299 | | Tag::Spinner |
| 270 | 300 | | Tag::Reaction |
| 271 | 301 | | Tag::Emoji => 1, |
| 302 | + Tag::Image => image_cells(&props, inner).1, | |
| 272 | 303 | Tag::Entry => props.cells("rows", 1).max(1), |
| 273 | 304 | Tag::Spacer => props.cells("size", 1), |
| 274 | 305 | Tag::Listbox => tree.child_count(id) as u16, |
| @@ -10,6 +10,7 @@ | |||
| 10 | //! Nothing here touches a terminal or the screen grid: sizes are arithmetic on | 10 | //! Nothing here touches a terminal or the screen grid: sizes are arithmetic on |
| 11 | //! the tree, which is why the layout tests below need no TTY. | 11 | //! the tree, which is why the layout tests below need no TTY. |
| 12 | 12 | ||
| 13 | +use crate::graphics; | ||
| 13 | use crate::screen::{glyph_cols, glyphs, text_cols, Rect}; | 14 | use crate::screen::{glyph_cols, glyphs, text_cols, Rect}; |
| 14 | use crate::tree::{Props, Tag, Tree}; | 15 | use crate::tree::{Props, Tag, Tree}; |
| 15 | 16 | ||
| @@ -129,6 +130,34 @@ fn longest_word(text: &str) -> u16 { | |||
| 129 | text.split([' ', '\n']).map(text_cols).max().unwrap_or(0) | 130 | text.split([' ', '\n']).map(text_cols).max().unwrap_or(0) |
| 130 | } | 131 | } |
| 131 | 132 | ||
| 133 | +/// The cells a picture is given: its own shape, inside the caller's bounds and | ||
| 134 | +/// inside the room the column has. | ||
| 135 | +/// | ||
| 136 | +/// Without the protocol to draw one there is no picture, only the note that | ||
| 137 | +/// says there was — so the box is a line, and the link in the message above it | ||
| 138 | +/// is what the reader is left with either way. | ||
| 139 | +pub fn image_cells(props: &Props, avail: u16) -> (u16, u16) { | ||
| 140 | + let path = props.str("src"); | ||
| 141 | + if path.is_empty() { | ||
| 142 | + return (0, 0); | ||
| 143 | + } | ||
| 144 | + if !graphics::supported() { | ||
| 145 | + return (text_cols(PICTURE), 1); | ||
| 146 | + } | ||
| 147 | + let max_cols = match props.cells("max-width", 0) { | ||
| 148 | + 0 => avail, | ||
| 149 | + want => want.min(avail), | ||
| 150 | + }; | ||
| 151 | + let max_rows = match props.cells("max-height", 0) { | ||
| 152 | + 0 => 16, | ||
| 153 | + want => want, | ||
| 154 | + }; | ||
| 155 | + graphics::cells_for(path, max_cols, max_rows) | ||
| 156 | +} | ||
| 157 | + | ||
| 158 | +/// What stands in for a picture where one cannot be drawn. | ||
| 159 | +pub const PICTURE: &str = "[ picture ]"; | ||
| 160 | + | ||
| 132 | /// The text an entry shows: its own, or its placeholder when it has none. | 161 | /// The text an entry shows: its own, or its placeholder when it has none. |
| 133 | pub fn entry_text(props: &Props) -> String { | 162 | pub fn entry_text(props: &Props) -> String { |
| 134 | let text = props.str("text"); | 163 | let text = props.str("text"); |
| @@ -180,6 +209,7 @@ fn intrinsic_width(tree: &Tree, id: u32, minimum: bool) -> u16 { | |||
| 180 | Tag::Separator => 1, | 209 | Tag::Separator => 1, |
| 181 | Tag::Spacer => props.cells("size", 1), | 210 | Tag::Spacer => props.cells("size", 1), |
| 182 | Tag::Emoji => text_cols(props.str("emoji")), | 211 | Tag::Emoji => text_cols(props.str("emoji")), |
| 212 | + Tag::Image => image_cells(&props, u16::MAX).0, | ||
| 183 | Tag::Reaction => text_cols(&pill_text(&props)), | 213 | Tag::Reaction => text_cols(&pill_text(&props)), |
| 184 | Tag::Progress => { | 214 | Tag::Progress => { |
| 185 | if minimum { | 215 | if minimum { |
| @@ -269,6 +299,7 @@ pub fn height_for_width(tree: &Tree, id: u32, avail: u16) -> u16 { | |||
| 269 | | Tag::Spinner | 299 | | Tag::Spinner |
| 270 | | Tag::Reaction | 300 | | Tag::Reaction |
| 271 | | Tag::Emoji => 1, | 301 | | Tag::Emoji => 1, |
| 302 | + Tag::Image => image_cells(&props, inner).1, | ||
| 272 | Tag::Entry => props.cells("rows", 1).max(1), | 303 | Tag::Entry => props.cells("rows", 1).max(1), |
| 273 | Tag::Spacer => props.cells("size", 1), | 304 | Tag::Spacer => props.cells("size", 1), |
| 274 | Tag::Listbox => tree.child_count(id) as u16, | 305 | Tag::Listbox => tree.child_count(id) as u16, |
modified
crates/jolt-tui/src/lib.rs +3 -1 | @@ -31,6 +31,7 @@ | ||
| 31 | 31 | // vocabulary, not dead code, so a headless build does not warn about them. |
| 32 | 32 | #![cfg_attr(not(feature = "terminal"), allow(dead_code))] |
| 33 | 33 | |
| 34 | +mod graphics; | |
| 34 | 35 | mod keys; |
| 35 | 36 | mod layout; |
| 36 | 37 | mod paint; |
| @@ -225,7 +226,8 @@ pub extern "C" fn tui_frame() { | ||
| 225 | 226 | #[cfg(feature = "terminal")] |
| 226 | 227 | if let Some(term) = session.term.as_mut() { |
| 227 | 228 | let cursor = session.ui.cursor(); |
| 228 | - if let Err(e) = term.flush(&session.ui.screen, cursor) { | |
| 229 | + let images = session.ui.images().to_vec(); | |
| 230 | + if let Err(e) = term.flush(&session.ui.screen, cursor, &images) { | |
| 229 | 231 | log::error!("jolt-tui: could not write a frame: {e}"); |
| 230 | 232 | } |
| 231 | 233 | } |
| @@ -31,6 +31,7 @@ | |||
| 31 | // vocabulary, not dead code, so a headless build does not warn about them. | 31 | // vocabulary, not dead code, so a headless build does not warn about them. |
| 32 | #![cfg_attr(not(feature = "terminal"), allow(dead_code))] | 32 | #![cfg_attr(not(feature = "terminal"), allow(dead_code))] |
| 33 | 33 | ||
| 34 | +mod graphics; | ||
| 34 | mod keys; | 35 | mod keys; |
| 35 | mod layout; | 36 | mod layout; |
| 36 | mod paint; | 37 | mod paint; |
| @@ -225,7 +226,8 @@ pub extern "C" fn tui_frame() { | |||
| 225 | #[cfg(feature = "terminal")] | 226 | #[cfg(feature = "terminal")] |
| 226 | if let Some(term) = session.term.as_mut() { | 227 | if let Some(term) = session.term.as_mut() { |
| 227 | let cursor = session.ui.cursor(); | 228 | let cursor = session.ui.cursor(); |
| 228 | - if let Err(e) = term.flush(&session.ui.screen, cursor) { | 229 | + let images = session.ui.images().to_vec(); |
| 230 | + if let Err(e) = term.flush(&session.ui.screen, cursor, &images) { | ||
| 229 | log::error!("jolt-tui: could not write a frame: {e}"); | 231 | log::error!("jolt-tui: could not write a frame: {e}"); |
| 230 | } | 232 | } |
| 231 | } | 233 | } |
modified
crates/jolt-tui/src/paint.rs +62 -0 | @@ -10,6 +10,7 @@ | ||
| 10 | 10 | //! asked for, in the middle. |
| 11 | 11 | |
| 12 | 12 | use crate::layout::{self, wrap, Align}; |
| 13 | +use crate::graphics; | |
| 13 | 14 | use crate::screen::{self, attr, Color, Rect, Screen, Style}; |
| 14 | 15 | use crate::tree::{Props, Tag, Tree}; |
| 15 | 16 | |
| @@ -32,6 +33,10 @@ pub struct Painted { | ||
| 32 | 33 | pub scrolled: Vec<(u32, u16, u16, Rect)>, |
| 33 | 34 | /// Where the cursor should sit — the focused entry's caret, if any. |
| 34 | 35 | pub cursor: Option<(u16, u16)>, |
| 36 | + /// The pictures this frame wants on screen, in the cells they were given. | |
| 37 | + /// Nothing was painted for them: the grid has no pixels, and the terminal | |
| 38 | + /// is what draws one — see [`crate::graphics`]. | |
| 39 | + pub images: Vec<graphics::Placement>, | |
| 35 | 40 | } |
| 36 | 41 | |
| 37 | 42 | struct Painter<'a> { |
| @@ -155,6 +160,7 @@ impl Painter<'_> { | ||
| 155 | 160 | let ch = SPINNER[(self.tick as usize) % SPINNER.len()]; |
| 156 | 161 | self.screen.set(inner.x, inner.y, ch, style); |
| 157 | 162 | } |
| 163 | + Tag::Image => self.image(id, inner, &props, style), | |
| 158 | 164 | Tag::Reaction => self.reaction(id, inner, &props, style), |
| 159 | 165 | // The same glyph with nothing around it: a character in a line, |
| 160 | 166 | // and the line is what says anything about it. |
| @@ -219,6 +225,39 @@ impl Painter<'_> { | ||
| 219 | 225 | self.screen.text(area.x, area.y, area.w, &label, style); |
| 220 | 226 | } |
| 221 | 227 | |
| 228 | + /// A picture: the cells it was given, and a note of where they are. | |
| 229 | + /// | |
| 230 | + /// Nothing goes in them. The terminal draws the picture over the blank | |
| 231 | + /// cells when the frame is flushed, which is the only way pixels reach a | |
| 232 | + /// grid; where there is no protocol for that, the cells carry the note | |
| 233 | + /// that says a picture is here, and the link above it is the way to it. | |
| 234 | + fn image(&mut self, id: u32, area: Rect, props: &Props, style: Style) { | |
| 235 | + let path = props.str("src"); | |
| 236 | + if path.is_empty() || area.is_empty() { | |
| 237 | + return; | |
| 238 | + } | |
| 239 | + if !graphics::supported() { | |
| 240 | + self.screen | |
| 241 | + .text(area.x, area.y, area.w, layout::PICTURE, style.with(attr::DIM)); | |
| 242 | + return; | |
| 243 | + } | |
| 244 | + // The column hands a child its whole width; a picture takes only what | |
| 245 | + // its shape asks for out of that, so the placement is the picture and | |
| 246 | + // not the room around it. | |
| 247 | + let (cols, rows) = layout::image_cells(props, area.w); | |
| 248 | + let area = Rect::new(area.x, area.y, cols.min(area.w), rows.min(area.h)); | |
| 249 | + if area.is_empty() { | |
| 250 | + return; | |
| 251 | + } | |
| 252 | + self.out.images.push(graphics::Placement { | |
| 253 | + node: id, | |
| 254 | + path: path.to_owned(), | |
| 255 | + area, | |
| 256 | + crop_top: 0, | |
| 257 | + crop_bottom: 0, | |
| 258 | + }); | |
| 259 | + } | |
| 260 | + | |
| 222 | 261 | /// A reaction pill: the glyph, the tally where there is one, and whether |
| 223 | 262 | /// you are on it. |
| 224 | 263 | /// |
| @@ -425,6 +464,29 @@ impl Painter<'_> { | ||
| 425 | 464 | // and drop the ones the viewport is not showing. A wheel over a nested |
| 426 | 465 | // list has to land on the list under the pointer, and a rect left in |
| 427 | 466 | // the wrong space is a wheel aimed at whatever happens to be there. |
| 467 | + // A picture inside a scroll moves with it, and is cut off by the | |
| 468 | + // viewport rather than painted over what is above or below: the | |
| 469 | + // protocol crops from the source, so a backlog scrolls past a picture | |
| 470 | + // a row at a time instead of losing it whole at the edge. | |
| 471 | + let bottom = offset.saturating_add(area.h); | |
| 472 | + for mut placement in learned.images { | |
| 473 | + let top = placement.area.y; | |
| 474 | + let foot = top.saturating_add(placement.area.h); | |
| 475 | + let seen_top = top.max(offset); | |
| 476 | + let seen_foot = foot.min(bottom); | |
| 477 | + if seen_foot <= seen_top { | |
| 478 | + continue; | |
| 479 | + } | |
| 480 | + placement.crop_top += seen_top - top; | |
| 481 | + placement.crop_bottom += foot - seen_foot; | |
| 482 | + placement.area = Rect::new( | |
| 483 | + area.x + placement.area.x, | |
| 484 | + area.y + seen_top - offset, | |
| 485 | + placement.area.w, | |
| 486 | + seen_foot - seen_top, | |
| 487 | + ); | |
| 488 | + self.out.images.push(placement); | |
| 489 | + } | |
| 428 | 490 | for (node, inner_offset, max, rect) in learned.scrolled { |
| 429 | 491 | if rect.y >= offset && rect.y < offset.saturating_add(area.h) { |
| 430 | 492 | self.out.scrolled.push(( |
| @@ -10,6 +10,7 @@ | |||
| 10 | //! asked for, in the middle. | 10 | //! asked for, in the middle. |
| 11 | 11 | ||
| 12 | use crate::layout::{self, wrap, Align}; | 12 | use crate::layout::{self, wrap, Align}; |
| 13 | +use crate::graphics; | ||
| 13 | use crate::screen::{self, attr, Color, Rect, Screen, Style}; | 14 | use crate::screen::{self, attr, Color, Rect, Screen, Style}; |
| 14 | use crate::tree::{Props, Tag, Tree}; | 15 | use crate::tree::{Props, Tag, Tree}; |
| 15 | 16 | ||
| @@ -32,6 +33,10 @@ pub struct Painted { | |||
| 32 | pub scrolled: Vec<(u32, u16, u16, Rect)>, | 33 | pub scrolled: Vec<(u32, u16, u16, Rect)>, |
| 33 | /// Where the cursor should sit — the focused entry's caret, if any. | 34 | /// Where the cursor should sit — the focused entry's caret, if any. |
| 34 | pub cursor: Option<(u16, u16)>, | 35 | pub cursor: Option<(u16, u16)>, |
| 36 | + /// The pictures this frame wants on screen, in the cells they were given. | ||
| 37 | + /// Nothing was painted for them: the grid has no pixels, and the terminal | ||
| 38 | + /// is what draws one — see [`crate::graphics`]. | ||
| 39 | + pub images: Vec<graphics::Placement>, | ||
| 35 | } | 40 | } |
| 36 | 41 | ||
| 37 | struct Painter<'a> { | 42 | struct Painter<'a> { |
| @@ -155,6 +160,7 @@ impl Painter<'_> { | |||
| 155 | let ch = SPINNER[(self.tick as usize) % SPINNER.len()]; | 160 | let ch = SPINNER[(self.tick as usize) % SPINNER.len()]; |
| 156 | self.screen.set(inner.x, inner.y, ch, style); | 161 | self.screen.set(inner.x, inner.y, ch, style); |
| 157 | } | 162 | } |
| 163 | + Tag::Image => self.image(id, inner, &props, style), | ||
| 158 | Tag::Reaction => self.reaction(id, inner, &props, style), | 164 | Tag::Reaction => self.reaction(id, inner, &props, style), |
| 159 | // The same glyph with nothing around it: a character in a line, | 165 | // The same glyph with nothing around it: a character in a line, |
| 160 | // and the line is what says anything about it. | 166 | // and the line is what says anything about it. |
| @@ -219,6 +225,39 @@ impl Painter<'_> { | |||
| 219 | self.screen.text(area.x, area.y, area.w, &label, style); | 225 | self.screen.text(area.x, area.y, area.w, &label, style); |
| 220 | } | 226 | } |
| 221 | 227 | ||
| 228 | + /// A picture: the cells it was given, and a note of where they are. | ||
| 229 | + /// | ||
| 230 | + /// Nothing goes in them. The terminal draws the picture over the blank | ||
| 231 | + /// cells when the frame is flushed, which is the only way pixels reach a | ||
| 232 | + /// grid; where there is no protocol for that, the cells carry the note | ||
| 233 | + /// that says a picture is here, and the link above it is the way to it. | ||
| 234 | + fn image(&mut self, id: u32, area: Rect, props: &Props, style: Style) { | ||
| 235 | + let path = props.str("src"); | ||
| 236 | + if path.is_empty() || area.is_empty() { | ||
| 237 | + return; | ||
| 238 | + } | ||
| 239 | + if !graphics::supported() { | ||
| 240 | + self.screen | ||
| 241 | + .text(area.x, area.y, area.w, layout::PICTURE, style.with(attr::DIM)); | ||
| 242 | + return; | ||
| 243 | + } | ||
| 244 | + // The column hands a child its whole width; a picture takes only what | ||
| 245 | + // its shape asks for out of that, so the placement is the picture and | ||
| 246 | + // not the room around it. | ||
| 247 | + let (cols, rows) = layout::image_cells(props, area.w); | ||
| 248 | + let area = Rect::new(area.x, area.y, cols.min(area.w), rows.min(area.h)); | ||
| 249 | + if area.is_empty() { | ||
| 250 | + return; | ||
| 251 | + } | ||
| 252 | + self.out.images.push(graphics::Placement { | ||
| 253 | + node: id, | ||
| 254 | + path: path.to_owned(), | ||
| 255 | + area, | ||
| 256 | + crop_top: 0, | ||
| 257 | + crop_bottom: 0, | ||
| 258 | + }); | ||
| 259 | + } | ||
| 260 | + | ||
| 222 | /// A reaction pill: the glyph, the tally where there is one, and whether | 261 | /// A reaction pill: the glyph, the tally where there is one, and whether |
| 223 | /// you are on it. | 262 | /// you are on it. |
| 224 | /// | 263 | /// |
| @@ -425,6 +464,29 @@ impl Painter<'_> { | |||
| 425 | // and drop the ones the viewport is not showing. A wheel over a nested | 464 | // and drop the ones the viewport is not showing. A wheel over a nested |
| 426 | // list has to land on the list under the pointer, and a rect left in | 465 | // list has to land on the list under the pointer, and a rect left in |
| 427 | // the wrong space is a wheel aimed at whatever happens to be there. | 466 | // the wrong space is a wheel aimed at whatever happens to be there. |
| 467 | + // A picture inside a scroll moves with it, and is cut off by the | ||
| 468 | + // viewport rather than painted over what is above or below: the | ||
| 469 | + // protocol crops from the source, so a backlog scrolls past a picture | ||
| 470 | + // a row at a time instead of losing it whole at the edge. | ||
| 471 | + let bottom = offset.saturating_add(area.h); | ||
| 472 | + for mut placement in learned.images { | ||
| 473 | + let top = placement.area.y; | ||
| 474 | + let foot = top.saturating_add(placement.area.h); | ||
| 475 | + let seen_top = top.max(offset); | ||
| 476 | + let seen_foot = foot.min(bottom); | ||
| 477 | + if seen_foot <= seen_top { | ||
| 478 | + continue; | ||
| 479 | + } | ||
| 480 | + placement.crop_top += seen_top - top; | ||
| 481 | + placement.crop_bottom += foot - seen_foot; | ||
| 482 | + placement.area = Rect::new( | ||
| 483 | + area.x + placement.area.x, | ||
| 484 | + area.y + seen_top - offset, | ||
| 485 | + placement.area.w, | ||
| 486 | + seen_foot - seen_top, | ||
| 487 | + ); | ||
| 488 | + self.out.images.push(placement); | ||
| 489 | + } | ||
| 428 | for (node, inner_offset, max, rect) in learned.scrolled { | 490 | for (node, inner_offset, max, rect) in learned.scrolled { |
| 429 | if rect.y >= offset && rect.y < offset.saturating_add(area.h) { | 491 | if rect.y >= offset && rect.y < offset.saturating_add(area.h) { |
| 430 | self.out.scrolled.push(( | 492 | self.out.scrolled.push(( |
modified
crates/jolt-tui/src/term.rs +32 -1 | @@ -17,6 +17,7 @@ use crossterm::terminal::{ | ||
| 17 | 17 | use crossterm::{cursor, execute, queue, style}; |
| 18 | 18 | |
| 19 | 19 | use crate::keys; |
| 20 | +use crate::graphics::{self, Graphics, Placement}; | |
| 20 | 21 | use crate::screen::{self, attr, Color, Screen, Style}; |
| 21 | 22 | |
| 22 | 23 | /// How far one notch of the wheel moves a list, in rows. |
| @@ -36,6 +37,8 @@ pub struct Term { | ||
| 36 | 37 | /// What is on the screen now, so a frame only sends what changed. |
| 37 | 38 | last: Screen, |
| 38 | 39 | mouse: bool, |
| 40 | + /// The pictures the terminal has been given, and where they are. | |
| 41 | + graphics: Graphics, | |
| 39 | 42 | } |
| 40 | 43 | |
| 41 | 44 | impl Term { |
| @@ -49,10 +52,12 @@ impl Term { | ||
| 49 | 52 | if mouse { |
| 50 | 53 | execute!(out, EnableMouseCapture)?; |
| 51 | 54 | } |
| 55 | + graphics::set_cell(measure_cell()); | |
| 52 | 56 | Ok(Self { |
| 53 | 57 | out, |
| 54 | 58 | last: Screen::new(w, h), |
| 55 | 59 | mouse, |
| 60 | + graphics: Graphics::default(), | |
| 56 | 61 | }) |
| 57 | 62 | } |
| 58 | 63 | |
| @@ -64,6 +69,10 @@ impl Term { | ||
| 64 | 69 | /// hook — leaving a shell in raw mode with no cursor is the one failure a |
| 65 | 70 | /// TUI must not have. |
| 66 | 71 | pub fn close(&mut self) { |
| 72 | + // The pictures first: a placement is the terminal's, not the screen | |
| 73 | + // buffer's, and one left behind outlives the alternate screen it was | |
| 74 | + // made on. | |
| 75 | + let _ = self.graphics.clear(&mut self.out); | |
| 67 | 76 | if self.mouse { |
| 68 | 77 | let _ = execute!(self.out, DisableMouseCapture); |
| 69 | 78 | } |
| @@ -122,9 +131,18 @@ impl Term { | ||
| 122 | 131 | } |
| 123 | 132 | |
| 124 | 133 | /// Send whatever differs between `screen` and what is already up there. |
| 125 | - pub fn flush(&mut self, screen: &Screen, cursor: Option<(u16, u16)>) -> io::Result<()> { | |
| 134 | + pub fn flush( | |
| 135 | + &mut self, | |
| 136 | + screen: &Screen, | |
| 137 | + cursor: Option<(u16, u16)>, | |
| 138 | + images: &[Placement], | |
| 139 | + ) -> io::Result<()> { | |
| 126 | 140 | if (self.last.width(), self.last.height()) != (screen.width(), screen.height()) { |
| 127 | 141 | self.last = Screen::new(screen.width(), screen.height()); |
| 142 | + // A resize is a new cell size as often as not — a font change, a | |
| 143 | + // window dragged to another screen — and every picture's size is | |
| 144 | + // measured in cells. | |
| 145 | + graphics::set_cell(measure_cell()); | |
| 128 | 146 | queue!( |
| 129 | 147 | self.out, |
| 130 | 148 | crossterm::terminal::Clear(crossterm::terminal::ClearType::All) |
| @@ -167,6 +185,11 @@ impl Term { | ||
| 167 | 185 | at = Some((x + screen::glyph_cols(&glyph), y)); |
| 168 | 186 | } |
| 169 | 187 | queue!(self.out, style::ResetColor)?; |
| 188 | + // The pictures after the text, and before the cursor is put back: a | |
| 189 | + // placement is drawn where the cursor is, so it moves the cursor, and | |
| 190 | + // whatever the frame decided about the caret has to be the last word. | |
| 191 | + let cell = graphics::cell(); | |
| 192 | + self.graphics.sync(&mut self.out, images, cell)?; | |
| 170 | 193 | match cursor { |
| 171 | 194 | Some((x, y)) => queue!(self.out, cursor::MoveTo(x, y), cursor::Show)?, |
| 172 | 195 | None => queue!(self.out, cursor::Hide)?, |
| @@ -214,3 +237,11 @@ fn write_style(out: &mut Stdout, style: Style) -> io::Result<()> { | ||
| 214 | 237 | style::SetBackgroundColor(convert(style.bg)) |
| 215 | 238 | ) |
| 216 | 239 | } |
| 240 | + | |
| 241 | +/// What one cell measures, asked of the terminal. | |
| 242 | +fn measure_cell() -> (u16, u16) { | |
| 243 | + match crossterm::terminal::window_size() { | |
| 244 | + Ok(size) => graphics::cell_pixels(size.columns, size.rows, size.width, size.height), | |
| 245 | + Err(_) => graphics::cell_pixels(0, 0, 0, 0), | |
| 246 | + } | |
| 247 | +} | |
| @@ -17,6 +17,7 @@ use crossterm::terminal::{ | |||
| 17 | use crossterm::{cursor, execute, queue, style}; | 17 | use crossterm::{cursor, execute, queue, style}; |
| 18 | 18 | ||
| 19 | use crate::keys; | 19 | use crate::keys; |
| 20 | +use crate::graphics::{self, Graphics, Placement}; | ||
| 20 | use crate::screen::{self, attr, Color, Screen, Style}; | 21 | use crate::screen::{self, attr, Color, Screen, Style}; |
| 21 | 22 | ||
| 22 | /// How far one notch of the wheel moves a list, in rows. | 23 | /// How far one notch of the wheel moves a list, in rows. |
| @@ -36,6 +37,8 @@ pub struct Term { | |||
| 36 | /// What is on the screen now, so a frame only sends what changed. | 37 | /// What is on the screen now, so a frame only sends what changed. |
| 37 | last: Screen, | 38 | last: Screen, |
| 38 | mouse: bool, | 39 | mouse: bool, |
| 40 | + /// The pictures the terminal has been given, and where they are. | ||
| 41 | + graphics: Graphics, | ||
| 39 | } | 42 | } |
| 40 | 43 | ||
| 41 | impl Term { | 44 | impl Term { |
| @@ -49,10 +52,12 @@ impl Term { | |||
| 49 | if mouse { | 52 | if mouse { |
| 50 | execute!(out, EnableMouseCapture)?; | 53 | execute!(out, EnableMouseCapture)?; |
| 51 | } | 54 | } |
| 55 | + graphics::set_cell(measure_cell()); | ||
| 52 | Ok(Self { | 56 | Ok(Self { |
| 53 | out, | 57 | out, |
| 54 | last: Screen::new(w, h), | 58 | last: Screen::new(w, h), |
| 55 | mouse, | 59 | mouse, |
| 60 | + graphics: Graphics::default(), | ||
| 56 | }) | 61 | }) |
| 57 | } | 62 | } |
| 58 | 63 | ||
| @@ -64,6 +69,10 @@ impl Term { | |||
| 64 | /// hook — leaving a shell in raw mode with no cursor is the one failure a | 69 | /// hook — leaving a shell in raw mode with no cursor is the one failure a |
| 65 | /// TUI must not have. | 70 | /// TUI must not have. |
| 66 | pub fn close(&mut self) { | 71 | pub fn close(&mut self) { |
| 72 | + // The pictures first: a placement is the terminal's, not the screen | ||
| 73 | + // buffer's, and one left behind outlives the alternate screen it was | ||
| 74 | + // made on. | ||
| 75 | + let _ = self.graphics.clear(&mut self.out); | ||
| 67 | if self.mouse { | 76 | if self.mouse { |
| 68 | let _ = execute!(self.out, DisableMouseCapture); | 77 | let _ = execute!(self.out, DisableMouseCapture); |
| 69 | } | 78 | } |
| @@ -122,9 +131,18 @@ impl Term { | |||
| 122 | } | 131 | } |
| 123 | 132 | ||
| 124 | /// Send whatever differs between `screen` and what is already up there. | 133 | /// Send whatever differs between `screen` and what is already up there. |
| 125 | - pub fn flush(&mut self, screen: &Screen, cursor: Option<(u16, u16)>) -> io::Result<()> { | 134 | + pub fn flush( |
| 135 | + &mut self, | ||
| 136 | + screen: &Screen, | ||
| 137 | + cursor: Option<(u16, u16)>, | ||
| 138 | + images: &[Placement], | ||
| 139 | + ) -> io::Result<()> { | ||
| 126 | if (self.last.width(), self.last.height()) != (screen.width(), screen.height()) { | 140 | if (self.last.width(), self.last.height()) != (screen.width(), screen.height()) { |
| 127 | self.last = Screen::new(screen.width(), screen.height()); | 141 | self.last = Screen::new(screen.width(), screen.height()); |
| 142 | + // A resize is a new cell size as often as not — a font change, a | ||
| 143 | + // window dragged to another screen — and every picture's size is | ||
| 144 | + // measured in cells. | ||
| 145 | + graphics::set_cell(measure_cell()); | ||
| 128 | queue!( | 146 | queue!( |
| 129 | self.out, | 147 | self.out, |
| 130 | crossterm::terminal::Clear(crossterm::terminal::ClearType::All) | 148 | crossterm::terminal::Clear(crossterm::terminal::ClearType::All) |
| @@ -167,6 +185,11 @@ impl Term { | |||
| 167 | at = Some((x + screen::glyph_cols(&glyph), y)); | 185 | at = Some((x + screen::glyph_cols(&glyph), y)); |
| 168 | } | 186 | } |
| 169 | queue!(self.out, style::ResetColor)?; | 187 | queue!(self.out, style::ResetColor)?; |
| 188 | + // The pictures after the text, and before the cursor is put back: a | ||
| 189 | + // placement is drawn where the cursor is, so it moves the cursor, and | ||
| 190 | + // whatever the frame decided about the caret has to be the last word. | ||
| 191 | + let cell = graphics::cell(); | ||
| 192 | + self.graphics.sync(&mut self.out, images, cell)?; | ||
| 170 | match cursor { | 193 | match cursor { |
| 171 | Some((x, y)) => queue!(self.out, cursor::MoveTo(x, y), cursor::Show)?, | 194 | Some((x, y)) => queue!(self.out, cursor::MoveTo(x, y), cursor::Show)?, |
| 172 | None => queue!(self.out, cursor::Hide)?, | 195 | None => queue!(self.out, cursor::Hide)?, |
| @@ -214,3 +237,11 @@ fn write_style(out: &mut Stdout, style: Style) -> io::Result<()> { | |||
| 214 | style::SetBackgroundColor(convert(style.bg)) | 237 | style::SetBackgroundColor(convert(style.bg)) |
| 215 | ) | 238 | ) |
| 216 | } | 239 | } |
| 240 | + | ||
| 241 | +/// What one cell measures, asked of the terminal. | ||
| 242 | +fn measure_cell() -> (u16, u16) { | ||
| 243 | + match crossterm::terminal::window_size() { | ||
| 244 | + Ok(size) => graphics::cell_pixels(size.columns, size.rows, size.width, size.height), | ||
| 245 | + Err(_) => graphics::cell_pixels(0, 0, 0, 0), | ||
| 246 | + } | ||
| 247 | +} | ||
modified
crates/jolt-tui/src/tests.rs +80 -0 | @@ -459,6 +459,86 @@ fn an_emoji_is_two_columns_wide_and_what_follows_it_knows_that() { | ||
| 459 | 459 | ); |
| 460 | 460 | } |
| 461 | 461 | |
| 462 | +/// A PNG that is only a header: this backend reads the size out of one and | |
| 463 | +/// hands the file to the terminal, so a header is all a test of the layout | |
| 464 | +/// needs. | |
| 465 | +fn png_file(name: &str, w: u32, h: u32) -> String { | |
| 466 | + let mut bytes = vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]; | |
| 467 | + bytes.extend_from_slice(&13u32.to_be_bytes()); | |
| 468 | + bytes.extend_from_slice(b"IHDR"); | |
| 469 | + bytes.extend_from_slice(&w.to_be_bytes()); | |
| 470 | + bytes.extend_from_slice(&h.to_be_bytes()); | |
| 471 | + let path = std::env::temp_dir().join(name); | |
| 472 | + std::fs::write(&path, &bytes).expect("wrote the picture"); | |
| 473 | + path.to_string_lossy().into_owned() | |
| 474 | +} | |
| 475 | + | |
| 476 | +#[test] | |
| 477 | +fn a_picture_takes_the_cells_its_shape_asks_for_and_says_where_it_is() { | |
| 478 | + crate::graphics::force(Some(true)); | |
| 479 | + crate::graphics::set_cell((8, 16)); | |
| 480 | + let path = png_file("jolt-tui-wide.png", 320, 160); | |
| 481 | + | |
| 482 | + let mut ui = Ui::new(60, 20); | |
| 483 | + let root = ui.tree.root(); | |
| 484 | + node(&mut ui, root, "label", &[("label", "a picture:")]); | |
| 485 | + let image = node(&mut ui, root, "image", &[("src", &path)]); | |
| 486 | + ui.tree.set(image, "max-height", Value::Num(6.0)); | |
| 487 | + ui.frame(); | |
| 488 | + | |
| 489 | + // 320x160 pixels over 8x16 cells is 40 by 10, held to the six rows the | |
| 490 | + // caller allowed — and the width comes down with it, not separately. | |
| 491 | + let placed = ui.images().first().cloned().expect("a placement"); | |
| 492 | + assert_eq!((placed.area.w, placed.area.h), (24, 6)); | |
| 493 | + assert_eq!((placed.area.x, placed.area.y), (0, 1)); | |
| 494 | + assert_eq!(placed.path, path); | |
| 495 | + // The cells themselves stay blank: the terminal draws over them. | |
| 496 | + assert_eq!(ui.screen.line(1), ""); | |
| 497 | + crate::graphics::force(None); | |
| 498 | +} | |
| 499 | + | |
| 500 | +#[test] | |
| 501 | +fn a_picture_scrolling_past_the_edge_is_cropped_rather_than_lost() { | |
| 502 | + crate::graphics::force(Some(true)); | |
| 503 | + crate::graphics::set_cell((8, 16)); | |
| 504 | + let path = png_file("jolt-tui-tall.png", 160, 320); | |
| 505 | + | |
| 506 | + let mut ui = Ui::new(40, 4); | |
| 507 | + let root = ui.tree.root(); | |
| 508 | + let scroll = node(&mut ui, root, "scroll", &[("scroll-key", "backlog")]); | |
| 509 | + let image = node(&mut ui, scroll, "image", &[("src", &path)]); | |
| 510 | + ui.tree.set(image, "max-height", Value::Num(8.0)); | |
| 511 | + node(&mut ui, scroll, "label", &[("label", "under it")]); | |
| 512 | + ui.frame(); | |
| 513 | + let placed = ui.images().first().cloned().expect("a placement"); | |
| 514 | + assert_eq!((placed.area.y, placed.area.h), (0, 4), "as much as fits"); | |
| 515 | + assert_eq!((placed.crop_top, placed.crop_bottom), (0, 4)); | |
| 516 | + | |
| 517 | + ui.wheel(1, 1, 2); | |
| 518 | + ui.frame(); | |
| 519 | + let placed = ui.images().first().cloned().expect("still placed"); | |
| 520 | + assert_eq!((placed.area.y, placed.area.h), (0, 4)); | |
| 521 | + assert_eq!( | |
| 522 | + (placed.crop_top, placed.crop_bottom), | |
| 523 | + (2, 2), | |
| 524 | + "two rows of the picture have gone off the top" | |
| 525 | + ); | |
| 526 | + crate::graphics::force(None); | |
| 527 | +} | |
| 528 | + | |
| 529 | +#[test] | |
| 530 | +fn a_terminal_that_draws_no_pictures_says_so_where_one_would_be() { | |
| 531 | + crate::graphics::force(Some(false)); | |
| 532 | + let path = png_file("jolt-tui-note.png", 320, 160); | |
| 533 | + let mut ui = Ui::new(40, 4); | |
| 534 | + let root = ui.tree.root(); | |
| 535 | + node(&mut ui, root, "image", &[("src", &path)]); | |
| 536 | + ui.frame(); | |
| 537 | + assert_eq!(ui.screen.line(0), "[ picture ]"); | |
| 538 | + assert!(ui.images().is_empty()); | |
| 539 | + crate::graphics::force(None); | |
| 540 | +} | |
| 541 | + | |
| 462 | 542 | #[test] |
| 463 | 543 | fn an_overlay_floats_in_the_middle_over_whatever_was_under_it() { |
| 464 | 544 | let mut ui = Ui::new(14, 5); |
| @@ -459,6 +459,86 @@ fn an_emoji_is_two_columns_wide_and_what_follows_it_knows_that() { | |||
| 459 | ); | 459 | ); |
| 460 | } | 460 | } |
| 461 | 461 | ||
| 462 | +/// A PNG that is only a header: this backend reads the size out of one and | ||
| 463 | +/// hands the file to the terminal, so a header is all a test of the layout | ||
| 464 | +/// needs. | ||
| 465 | +fn png_file(name: &str, w: u32, h: u32) -> String { | ||
| 466 | + let mut bytes = vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]; | ||
| 467 | + bytes.extend_from_slice(&13u32.to_be_bytes()); | ||
| 468 | + bytes.extend_from_slice(b"IHDR"); | ||
| 469 | + bytes.extend_from_slice(&w.to_be_bytes()); | ||
| 470 | + bytes.extend_from_slice(&h.to_be_bytes()); | ||
| 471 | + let path = std::env::temp_dir().join(name); | ||
| 472 | + std::fs::write(&path, &bytes).expect("wrote the picture"); | ||
| 473 | + path.to_string_lossy().into_owned() | ||
| 474 | +} | ||
| 475 | + | ||
| 476 | +#[test] | ||
| 477 | +fn a_picture_takes_the_cells_its_shape_asks_for_and_says_where_it_is() { | ||
| 478 | + crate::graphics::force(Some(true)); | ||
| 479 | + crate::graphics::set_cell((8, 16)); | ||
| 480 | + let path = png_file("jolt-tui-wide.png", 320, 160); | ||
| 481 | + | ||
| 482 | + let mut ui = Ui::new(60, 20); | ||
| 483 | + let root = ui.tree.root(); | ||
| 484 | + node(&mut ui, root, "label", &[("label", "a picture:")]); | ||
| 485 | + let image = node(&mut ui, root, "image", &[("src", &path)]); | ||
| 486 | + ui.tree.set(image, "max-height", Value::Num(6.0)); | ||
| 487 | + ui.frame(); | ||
| 488 | + | ||
| 489 | + // 320x160 pixels over 8x16 cells is 40 by 10, held to the six rows the | ||
| 490 | + // caller allowed — and the width comes down with it, not separately. | ||
| 491 | + let placed = ui.images().first().cloned().expect("a placement"); | ||
| 492 | + assert_eq!((placed.area.w, placed.area.h), (24, 6)); | ||
| 493 | + assert_eq!((placed.area.x, placed.area.y), (0, 1)); | ||
| 494 | + assert_eq!(placed.path, path); | ||
| 495 | + // The cells themselves stay blank: the terminal draws over them. | ||
| 496 | + assert_eq!(ui.screen.line(1), ""); | ||
| 497 | + crate::graphics::force(None); | ||
| 498 | +} | ||
| 499 | + | ||
| 500 | +#[test] | ||
| 501 | +fn a_picture_scrolling_past_the_edge_is_cropped_rather_than_lost() { | ||
| 502 | + crate::graphics::force(Some(true)); | ||
| 503 | + crate::graphics::set_cell((8, 16)); | ||
| 504 | + let path = png_file("jolt-tui-tall.png", 160, 320); | ||
| 505 | + | ||
| 506 | + let mut ui = Ui::new(40, 4); | ||
| 507 | + let root = ui.tree.root(); | ||
| 508 | + let scroll = node(&mut ui, root, "scroll", &[("scroll-key", "backlog")]); | ||
| 509 | + let image = node(&mut ui, scroll, "image", &[("src", &path)]); | ||
| 510 | + ui.tree.set(image, "max-height", Value::Num(8.0)); | ||
| 511 | + node(&mut ui, scroll, "label", &[("label", "under it")]); | ||
| 512 | + ui.frame(); | ||
| 513 | + let placed = ui.images().first().cloned().expect("a placement"); | ||
| 514 | + assert_eq!((placed.area.y, placed.area.h), (0, 4), "as much as fits"); | ||
| 515 | + assert_eq!((placed.crop_top, placed.crop_bottom), (0, 4)); | ||
| 516 | + | ||
| 517 | + ui.wheel(1, 1, 2); | ||
| 518 | + ui.frame(); | ||
| 519 | + let placed = ui.images().first().cloned().expect("still placed"); | ||
| 520 | + assert_eq!((placed.area.y, placed.area.h), (0, 4)); | ||
| 521 | + assert_eq!( | ||
| 522 | + (placed.crop_top, placed.crop_bottom), | ||
| 523 | + (2, 2), | ||
| 524 | + "two rows of the picture have gone off the top" | ||
| 525 | + ); | ||
| 526 | + crate::graphics::force(None); | ||
| 527 | +} | ||
| 528 | + | ||
| 529 | +#[test] | ||
| 530 | +fn a_terminal_that_draws_no_pictures_says_so_where_one_would_be() { | ||
| 531 | + crate::graphics::force(Some(false)); | ||
| 532 | + let path = png_file("jolt-tui-note.png", 320, 160); | ||
| 533 | + let mut ui = Ui::new(40, 4); | ||
| 534 | + let root = ui.tree.root(); | ||
| 535 | + node(&mut ui, root, "image", &[("src", &path)]); | ||
| 536 | + ui.frame(); | ||
| 537 | + assert_eq!(ui.screen.line(0), "[ picture ]"); | ||
| 538 | + assert!(ui.images().is_empty()); | ||
| 539 | + crate::graphics::force(None); | ||
| 540 | +} | ||
| 541 | + | ||
| 462 | #[test] | 542 | #[test] |
| 463 | fn an_overlay_floats_in_the_middle_over_whatever_was_under_it() { | 543 | fn an_overlay_floats_in_the_middle_over_whatever_was_under_it() { |
| 464 | let mut ui = Ui::new(14, 5); | 544 | let mut ui = Ui::new(14, 5); |
modified
crates/jolt-tui/src/tree.rs +6 -0 | @@ -45,6 +45,7 @@ pub enum Tag { | ||
| 45 | 45 | Spinner, |
| 46 | 46 | Reaction, |
| 47 | 47 | Emoji, |
| 48 | + Image, | |
| 48 | 49 | Unknown(String), |
| 49 | 50 | } |
| 50 | 51 | |
| @@ -80,6 +81,10 @@ impl Tag { | ||
| 80 | 81 | // label, which is why an unknown tag painted neither. |
| 81 | 82 | "reaction" => Self::Reaction, |
| 82 | 83 | "emoji" => Self::Emoji, |
| 84 | + // A picture, which a cell grid cannot hold: the painter reserves | |
| 85 | + // the cells and the terminal draws it over them, where it has the | |
| 86 | + // protocol for that. See `crate::graphics`. | |
| 87 | + "image" => Self::Image, | |
| 83 | 88 | other => Self::Unknown(other.to_owned()), |
| 84 | 89 | } |
| 85 | 90 | } |
| @@ -106,6 +111,7 @@ impl Tag { | ||
| 106 | 111 | Self::Spinner => "spinner", |
| 107 | 112 | Self::Reaction => "reaction", |
| 108 | 113 | Self::Emoji => "emoji", |
| 114 | + Self::Image => "image", | |
| 109 | 115 | Self::Unknown(name) => name, |
| 110 | 116 | } |
| 111 | 117 | } |
| @@ -45,6 +45,7 @@ pub enum Tag { | |||
| 45 | Spinner, | 45 | Spinner, |
| 46 | Reaction, | 46 | Reaction, |
| 47 | Emoji, | 47 | Emoji, |
| 48 | + Image, | ||
| 48 | Unknown(String), | 49 | Unknown(String), |
| 49 | } | 50 | } |
| 50 | 51 | ||
| @@ -80,6 +81,10 @@ impl Tag { | |||
| 80 | // label, which is why an unknown tag painted neither. | 81 | // label, which is why an unknown tag painted neither. |
| 81 | "reaction" => Self::Reaction, | 82 | "reaction" => Self::Reaction, |
| 82 | "emoji" => Self::Emoji, | 83 | "emoji" => Self::Emoji, |
| 84 | + // A picture, which a cell grid cannot hold: the painter reserves | ||
| 85 | + // the cells and the terminal draws it over them, where it has the | ||
| 86 | + // protocol for that. See `crate::graphics`. | ||
| 87 | + "image" => Self::Image, | ||
| 83 | other => Self::Unknown(other.to_owned()), | 88 | other => Self::Unknown(other.to_owned()), |
| 84 | } | 89 | } |
| 85 | } | 90 | } |
| @@ -106,6 +111,7 @@ impl Tag { | |||
| 106 | Self::Spinner => "spinner", | 111 | Self::Spinner => "spinner", |
| 107 | Self::Reaction => "reaction", | 112 | Self::Reaction => "reaction", |
| 108 | Self::Emoji => "emoji", | 113 | Self::Emoji => "emoji", |
| 114 | + Self::Image => "image", | ||
| 109 | Self::Unknown(name) => name, | 115 | Self::Unknown(name) => name, |
| 110 | } | 116 | } |
| 111 | } | 117 | } |
modified
crates/jolt-tui/src/ui.rs +5 -0 | @@ -81,6 +81,11 @@ impl Ui { | ||
| 81 | 81 | self.painted.cursor |
| 82 | 82 | } |
| 83 | 83 | |
| 84 | + /// The pictures the last frame wants on screen, for whoever can draw one. | |
| 85 | + pub fn images(&self) -> &[crate::graphics::Placement] { | |
| 86 | + &self.painted.images | |
| 87 | + } | |
| 88 | + | |
| 84 | 89 | /// Paint one frame, then settle the things painting decided: what is |
| 85 | 90 | /// focusable now, and how far each scroll area really is. |
| 86 | 91 | pub fn frame(&mut self) { |
| @@ -81,6 +81,11 @@ impl Ui { | |||
| 81 | self.painted.cursor | 81 | self.painted.cursor |
| 82 | } | 82 | } |
| 83 | 83 | ||
| 84 | + /// The pictures the last frame wants on screen, for whoever can draw one. | ||
| 85 | + pub fn images(&self) -> &[crate::graphics::Placement] { | ||
| 86 | + &self.painted.images | ||
| 87 | + } | ||
| 88 | + | ||
| 84 | /// Paint one frame, then settle the things painting decided: what is | 89 | /// Paint one frame, then settle the things painting decided: what is |
| 85 | /// focusable now, and how far each scroll area really is. | 90 | /// focusable now, and how far each scroll area really is. |
| 86 | pub fn frame(&mut self) { | 91 | pub fn frame(&mut self) { |