diff --git a/Cargo.lock b/Cargo.lock index c04ac89..736cc5c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aho-corasick" version = "1.1.4" @@ -29,6 +35,12 @@ version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f02882884d3e1bc524fb12c79f107f6ad0e1cfd498c536ffb494301740995dfe" +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + [[package]] name = "beer" version = "0.4.0" @@ -37,9 +49,11 @@ dependencies = [ "beer-protocols", "calloop", "calloop-wayland-source", + "flate2", "fontconfig", "freetype-rs", "harfbuzz_rs_now", + "image", "lru", "pound", "rustix", @@ -89,6 +103,12 @@ dependencies = [ "syn", ] +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "calloop" version = "0.14.4" @@ -137,6 +157,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -146,6 +172,15 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -189,12 +224,31 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "foldhash" version = "0.2.0" @@ -232,6 +286,16 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "harfbuzz_rs_now" version = "2.3.2" @@ -260,6 +324,34 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -349,6 +441,26 @@ dependencies = [ "libc", ] +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "nix" version = "0.31.3" @@ -370,6 +482,15 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -388,6 +509,19 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "polling" version = "3.11.0" @@ -431,6 +565,18 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pxfm" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quick-xml" version = "0.39.4" @@ -543,6 +689,12 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + [[package]] name = "slab" version = "0.4.12" @@ -888,6 +1040,12 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "windows-link" version = "0.2.1" @@ -945,3 +1103,18 @@ dependencies = [ "once_cell", "pkg-config", ] + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml index eb94403..ed395a8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,9 +16,17 @@ beer-protocols = { path = "crates/beer-protocols", version = "0.4.0" } anyhow = "1.0.102" calloop = { version = "0.14.4", features = ["signals"] } calloop-wayland-source = "0.4.1" +flate2 = "1.0.35" fontconfig = "0.11.0" freetype-rs = "0.38.0" harfbuzz_rs_now = "2.3.2" +image = { version = "0.25.5", default-features = false, features = [ + "png", + "jpeg", + "gif", + "bmp", + "webp", +] } lru = "0.18.0" pound = "0.1.6" rustix = { version = "1.1.4", features = [ @@ -27,6 +35,8 @@ rustix = { version = "1.1.4", features = [ "termios", "stdio", "fs", + "shm", + "mm", ] } serde = { version = "1.0.228", features = ["derive"] } serde_ignored = "0.1.14" diff --git a/crates/beer/Cargo.toml b/crates/beer/Cargo.toml index e4c38b7..d59402d 100644 --- a/crates/beer/Cargo.toml +++ b/crates/beer/Cargo.toml @@ -12,9 +12,11 @@ anyhow.workspace = true beer-protocols.workspace = true calloop.workspace = true calloop-wayland-source.workspace = true +flate2.workspace = true fontconfig.workspace = true freetype-rs.workspace = true harfbuzz_rs_now.workspace = true +image.workspace = true lru.workspace = true pound.workspace = true rustix.workspace = true diff --git a/crates/beer/src/graphics/mod.rs b/crates/beer/src/graphics/mod.rs new file mode 100644 index 0000000..973178e --- /dev/null +++ b/crates/beer/src/graphics/mod.rs @@ -0,0 +1,703 @@ +//! The kitty graphics protocol engine: image storage, transmission assembly, +//! decoding, placements, deletion, and the OK/error responses. +//! +//! [`beer_protocols::graphics`] parses the APC control data; this module is the +//! stateful half. It accumulates chunked direct transmissions, reads file and +//! shared-memory payloads, decodes RGB/RGBA/PNG (optionally zlib-compressed) +//! into RGBA, and tracks images and their on-screen placements. The grid carries +//! a per-cell [`crate::grid::ImageRef`] for each displayed cell, so images scroll +//! and clear with the text; this engine owns the pixels and the geometry the +//! renderer composites from. + +use std::collections::HashMap; +use std::io::{Read as _, Seek as _, SeekFrom}; + +use beer_protocols::codec::base64_decode; +use beer_protocols::graphics::{Action, Format, GraphicsCommand, Medium}; + +/// Cap on a single image's pixel buffer (decoded RGBA), guarding against a +/// client claiming an enormous size. 64 MiB is far beyond any real preview. +const MAX_IMAGE_BYTES: usize = 64 * 1024 * 1024; + +/// Cap on accumulated direct-transmission base64 across chunks. +const MAX_TRANSMIT_BYTES: usize = 96 * 1024 * 1024; + +/// A decoded image, normalized to 8-bit RGBA. +#[derive(Clone, Debug)] +pub struct Image { + pub width: u32, + pub height: u32, + /// `width * height * 4` bytes, row-major, non-premultiplied RGBA. + pub rgba: Vec, +} + +/// One on-screen placement of an image: the cell rectangle it occupies and the +/// source region/offsets/stacking that decide how it is drawn. +#[derive(Clone, Copy, Debug)] +pub struct Placement { + pub image: u32, + /// Cell rectangle size. + pub cols: u16, + pub rows: u16, + /// Source rectangle in image pixels (`w == 0` means to the image edge). + pub src_x: u32, + pub src_y: u32, + pub src_w: u32, + pub src_h: u32, + /// Pixel offset within the first cell. + pub off_x: u32, + pub off_y: u32, + /// Stacking order: negative draws below text, non-negative above. + pub z: i32, +} + +/// What the terminal must do to the grid after a command: stamp a placement, or +/// clear image cells matching a spec. Returned alongside any wire response. +#[derive(Clone, Debug)] +pub enum GridOp { + /// Stamp a `cols` by `rows` placement at the cursor. + Place { + image: u32, + placement: u32, + cols: usize, + rows: usize, + keep_cursor: bool, + }, + /// Clear cells whose image reference matches the spec. + Clear(ClearSpec), +} + +/// Which displayed image cells a delete affects. +#[derive(Clone, Copy, Debug)] +pub enum ClearSpec { + All, + Image(u32), + Placement(u32, u32), + AtCursor, +} + +/// The result of handling one command: an optional response to write back to the +/// application, and an optional grid mutation for the terminal to apply. +#[derive(Default, Debug)] +pub struct Outcome { + pub response: Option>, + pub grid_op: Option, +} + +/// An in-progress chunked direct transmission: the opening command (which holds +/// the format and dimensions) and the base64 text accumulated so far. +#[derive(Debug)] +struct Pending { + cmd: GraphicsCommand, + b64: Vec, +} + +/// The graphics state for one terminal. +#[derive(Default, Debug)] +pub struct Graphics { + images: HashMap, + /// Image number (`I`) to the id it most recently resolved to. + by_number: HashMap, + placements: HashMap<(u32, u32), Placement>, + pending: Option, + /// Source of ids for images transmitted without one. + next_auto_id: u32, +} + +impl Graphics { + pub fn new() -> Self { + Self { + next_auto_id: 0xf000_0000, + ..Self::default() + } + } + + pub fn image(&self, id: u32) -> Option<&Image> { + self.images.get(&id) + } + + pub fn placement(&self, image: u32, placement: u32) -> Option<&Placement> { + self.placements.get(&(image, placement)) + } + + /// Handle one fully-received graphics command. `cell_px` is the current cell + /// size in pixels, needed to translate image dimensions into a cell + /// rectangle when the client does not give one. + pub fn handle(&mut self, cmd: GraphicsCommand, payload: &[u8], cell_px: (u32, u32)) -> Outcome { + match cmd.action { + Action::Delete => self.delete(cmd), + Action::Put => self.put(cmd, cell_px), + Action::Animate | Action::Frame | Action::Compose => { + // Animation lands in a later pass; accept silently so a client + // probing for it does not get spurious errors. + Outcome::default() + } + // Transmit / TransmitAndDisplay / Query all assemble pixel data. + _ => self.transmit(cmd, payload, cell_px), + } + } + + /// Assemble (possibly chunked) pixel data, decode and store it, and - for + /// `a=T` - emit a placement. `a=q` decodes to verify but neither stores nor + /// displays. + fn transmit(&mut self, cmd: GraphicsCommand, payload: &[u8], cell_px: (u32, u32)) -> Outcome { + // Continuation chunk: append to the in-flight transmission. + if self.pending.is_some() && cmd.action == Action::Transmit && payload_is_chunk(&cmd) { + return self.accumulate(cmd, payload, cell_px); + } + if cmd.more { + // First chunk of a multi-chunk transmission: start accumulating. + let mut b64 = Vec::new(); + push_capped(&mut b64, payload, MAX_TRANSMIT_BYTES); + self.pending = Some(Pending { cmd, b64 }); + return Outcome::default(); + } + // Single-shot transmission. + self.finalize(cmd, payload, cell_px) + } + + fn accumulate( + &mut self, + cont: GraphicsCommand, + payload: &[u8], + cell_px: (u32, u32), + ) -> Outcome { + let done = { + let pending = self.pending.as_mut().expect("pending checked by caller"); + push_capped(&mut pending.b64, payload, MAX_TRANSMIT_BYTES); + // The last chunk carries m=0; the opening command's geometry is used. + !cont.more + }; + if !done { + return Outcome::default(); + } + let Pending { cmd, b64 } = self.pending.take().expect("pending checked by caller"); + self.finalize_b64(cmd, &b64, cell_px) + } + + /// Finalize a single-shot transmission whose payload is one base64 blob. + fn finalize(&mut self, cmd: GraphicsCommand, payload: &[u8], cell_px: (u32, u32)) -> Outcome { + self.finalize_b64(cmd, payload, cell_px) + } + + fn finalize_b64(&mut self, cmd: GraphicsCommand, b64: &[u8], cell_px: (u32, u32)) -> Outcome { + match self.load_image(&cmd, b64) { + Ok(image) => { + if cmd.action == Action::Query { + // Verify only: report success, store nothing. + return respond(&cmd, "OK"); + } + let id = self.store(&cmd, image); + if cmd.action == Action::TransmitAndDisplay { + let mut out = self.display(id, &cmd, cell_px); + out.response = respond(&cmd, "OK").response; + out + } else { + respond(&cmd, "OK") + } + } + Err(msg) => respond_error(&cmd, &msg), + } + } + + /// Decode the transmitted payload into an RGBA [`Image`]. + fn load_image(&self, cmd: &GraphicsCommand, b64: &[u8]) -> Result { + let raw = base64_decode(b64).ok_or("EINVAL: bad base64 payload")?; + // For non-direct mediums the payload is the path / shared-memory name. + let bytes = match cmd.medium { + Medium::Direct => raw, + _ => read_source(cmd, &raw)?, + }; + let bytes = if cmd.compressed { + inflate(&bytes)? + } else { + bytes + }; + decode(cmd, bytes) + } + + /// Store an image under its id (or number, or an auto id), replacing any + /// existing image with that id. Returns the id used. + fn store(&mut self, cmd: &GraphicsCommand, image: Image) -> u32 { + let id = if cmd.id != 0 { + cmd.id + } else if cmd.number != 0 { + // A fresh id for this number; remember the mapping. + let id = self.alloc_id(); + self.by_number.insert(cmd.number, id); + id + } else { + self.alloc_id() + }; + self.images.insert(id, image); + id + } + + fn alloc_id(&mut self) -> u32 { + let id = self.next_auto_id; + self.next_auto_id = self.next_auto_id.wrapping_add(1).max(0xf000_0000); + id + } + + /// Display an already-stored image (`a=p`). + fn put(&mut self, cmd: GraphicsCommand, cell_px: (u32, u32)) -> Outcome { + let id = self.resolve_id(&cmd); + if id.is_none() || !self.images.contains_key(&id.unwrap()) { + return respond_error(&cmd, "ENOENT: no such image"); + } + let id = id.unwrap(); + let mut out = self.display(id, &cmd, cell_px); + out.response = respond(&cmd, "OK").response; + out + } + + /// Compute and register a placement for `id`, returning the grid stamp op. + fn display(&mut self, id: u32, cmd: &GraphicsCommand, cell_px: (u32, u32)) -> Outcome { + let Some(img) = self.images.get(&id) else { + return respond_error(cmd, "ENOENT: no such image"); + }; + let (cell_w, cell_h) = (cell_px.0.max(1), cell_px.1.max(1)); + + // Source rectangle, clamped to the image. + let src_w = if cmd.w == 0 { + img.width.saturating_sub(cmd.x) + } else { + cmd.w.min(img.width.saturating_sub(cmd.x)) + }; + let src_h = if cmd.h == 0 { + img.height.saturating_sub(cmd.y) + } else { + cmd.h.min(img.height.saturating_sub(cmd.y)) + }; + + // Cell rectangle: explicit c/r, else derived from the source pixels, + // filling in a missing dimension by aspect ratio. + let (cols, rows) = cell_rect(cmd.c, cmd.r, src_w, src_h, cell_w, cell_h); + if cols == 0 || rows == 0 { + return respond_error(cmd, "EINVAL: zero-sized placement"); + } + + let placement_id = cmd.placement; + self.placements.insert( + (id, placement_id), + Placement { + image: id, + cols: cols.min(u16::MAX as usize) as u16, + rows: rows.min(u16::MAX as usize) as u16, + src_x: cmd.x, + src_y: cmd.y, + src_w, + src_h, + off_x: cmd.cap_x, + off_y: cmd.cap_y, + z: cmd.z, + }, + ); + Outcome { + response: None, + grid_op: Some(GridOp::Place { + image: id, + placement: placement_id, + cols, + rows, + keep_cursor: cmd.cursor_policy == 1, + }), + } + } + + /// Resolve the image an action refers to: by id, else by number. + fn resolve_id(&self, cmd: &GraphicsCommand) -> Option { + if cmd.id != 0 { + Some(cmd.id) + } else if cmd.number != 0 { + self.by_number.get(&cmd.number).copied() + } else { + None + } + } + + /// Handle `a=d`: clear placements (and free image data for uppercase forms). + fn delete(&mut self, cmd: GraphicsCommand) -> Outcome { + let free = cmd.delete_frees_data(); + let spec = match cmd.delete.to_ascii_lowercase() { + 0 | b'a' => ClearSpec::All, + b'i' => { + let id = cmd.id; + if cmd.placement != 0 { + ClearSpec::Placement(id, cmd.placement) + } else { + ClearSpec::Image(id) + } + } + b'n' => match self.by_number.get(&cmd.number).copied() { + Some(id) => ClearSpec::Image(id), + None => return Outcome::default(), + }, + b'c' => ClearSpec::AtCursor, + // Other targets (by column/row/z-index, frames) are not yet + // distinguished; treat them as a visible-placement clear. + _ => ClearSpec::All, + }; + if free { + self.free_for(&spec); + } + Outcome { + response: None, + grid_op: Some(GridOp::Clear(spec)), + } + } + + /// Drop stored image data for an uppercase delete, when not pinned elsewhere. + fn free_for(&mut self, spec: &ClearSpec) { + match *spec { + ClearSpec::All => { + self.images.clear(); + self.placements.clear(); + self.by_number.clear(); + } + ClearSpec::Image(id) => { + self.images.remove(&id); + self.placements.retain(|&(img, _), _| img != id); + } + ClearSpec::Placement(id, p) => { + self.placements.remove(&(id, p)); + } + ClearSpec::AtCursor => {} + } + } +} + +/// Whether a continuation chunk carries only the `m`/`q` keys (no fresh action), +/// i.e. it belongs to the in-flight transmission rather than starting a new one. +fn payload_is_chunk(cmd: &GraphicsCommand) -> bool { + cmd.action == Action::Transmit && cmd.id == 0 && cmd.number == 0 && cmd.format == Format::Rgba +} + +/// Choose the cell rectangle for a placement. Explicit `c`/`r` win; a missing +/// dimension is filled by source aspect ratio; with neither given the pixel +/// size is divided by the cell size (rounded up). +fn cell_rect(c: u32, r: u32, src_w: u32, src_h: u32, cell_w: u32, cell_h: u32) -> (usize, usize) { + let by_px_w = src_w.div_ceil(cell_w).max(1); + let by_px_h = src_h.div_ceil(cell_h).max(1); + let (cols, rows) = match (c, r) { + (0, 0) => (by_px_w, by_px_h), + (c, 0) => ( + c, + ((c * cell_w) as u64 * src_h as u64 / (src_w.max(1) as u64 * cell_h as u64)).max(1) + as u32, + ), + (0, r) => ( + ((r * cell_h) as u64 * src_w as u64 / (src_h.max(1) as u64 * cell_w as u64)).max(1) + as u32, + r, + ), + (c, r) => (c, r), + }; + (cols as usize, rows as usize) +} + +/// Decode transmitted bytes into RGBA per the command's format. +fn decode(cmd: &GraphicsCommand, bytes: Vec) -> Result { + match cmd.format { + Format::Png => { + let img = image::load_from_memory(&bytes).map_err(|e| format!("EINVAL: {e}"))?; + let rgba = img.to_rgba8(); + let (width, height) = (rgba.width(), rgba.height()); + check_size(width, height)?; + Ok(Image { + width, + height, + rgba: rgba.into_raw(), + }) + } + Format::Rgba => { + check_size(cmd.width, cmd.height)?; + let want = cmd.width as usize * cmd.height as usize * 4; + if bytes.len() < want { + return Err("EINVAL: RGBA data smaller than s*v*4".into()); + } + Ok(Image { + width: cmd.width, + height: cmd.height, + rgba: bytes[..want].to_vec(), + }) + } + Format::Rgb => { + check_size(cmd.width, cmd.height)?; + let px = cmd.width as usize * cmd.height as usize; + if bytes.len() < px * 3 { + return Err("EINVAL: RGB data smaller than s*v*3".into()); + } + let mut rgba = Vec::with_capacity(px * 4); + for chunk in bytes[..px * 3].chunks_exact(3) { + rgba.extend_from_slice(chunk); + rgba.push(0xff); + } + Ok(Image { + width: cmd.width, + height: cmd.height, + rgba, + }) + } + } +} + +fn check_size(width: u32, height: u32) -> Result<(), String> { + if width == 0 || height == 0 { + return Err("EINVAL: zero image dimension".into()); + } + let bytes = width as usize * height as usize * 4; + if bytes > MAX_IMAGE_BYTES { + return Err("EINVAL: image too large".into()); + } + Ok(()) +} + +/// Append `data` to `buf`, dropping the excess once `cap` is reached so a +/// runaway transmission cannot grow memory without bound. +fn push_capped(buf: &mut Vec, data: &[u8], cap: usize) { + let room = cap.saturating_sub(buf.len()); + buf.extend_from_slice(&data[..data.len().min(room)]); +} + +/// zlib-inflate `o=z` payloads. +fn inflate(bytes: &[u8]) -> Result, String> { + let mut out = Vec::new(); + flate2::read::ZlibDecoder::new(bytes) + .read_to_end(&mut out) + .map_err(|e| format!("EINVAL: zlib: {e}"))?; + Ok(out) +} + +/// Read the data for a file / temp-file / shared-memory transmission. `name` is +/// the decoded path or shared-memory object name; `O`/`S` give an offset and +/// length. A temp file is deleted after reading when it is clearly a graphics +/// temp file in a known temp directory. +fn read_source(cmd: &GraphicsCommand, name: &[u8]) -> Result, String> { + let name = std::str::from_utf8(name).map_err(|_| "EINVAL: non-UTF-8 path".to_string())?; + let data = match cmd.medium { + Medium::SharedMemory => read_shm(name, cmd.read_offset, cmd.read_size)?, + _ => read_file(name, cmd.read_offset, cmd.read_size)?, + }; + if cmd.medium == Medium::TempFile && is_safe_temp(name) { + let _ = std::fs::remove_file(name); + } + Ok(data) +} + +fn read_file(path: &str, offset: u32, size: u32) -> Result, String> { + let mut f = std::fs::File::open(path).map_err(|e| format!("EBADF: {e}"))?; + read_region(&mut f, offset, size) +} + +/// Open a POSIX shared-memory object, read it, and unlink it (the protocol +/// requires the terminal to consume and remove the object). +fn read_shm(name: &str, offset: u32, size: u32) -> Result, String> { + use rustix::shm; + let fd = shm::open(name, shm::OFlags::RDONLY, shm::Mode::empty()) + .map_err(|e| format!("EBADF: shm {e}"))?; + let mut f = std::fs::File::from(fd); + let data = read_region(&mut f, offset, size); + let _ = shm::unlink(name); + data +} + +fn read_region(f: &mut std::fs::File, offset: u32, size: u32) -> Result, String> { + if offset != 0 { + f.seek(SeekFrom::Start(offset as u64)) + .map_err(|e| format!("EIO: {e}"))?; + } + let mut buf = Vec::new(); + if size != 0 { + buf.resize(size as usize, 0); + f.read_exact(&mut buf).map_err(|e| format!("EIO: {e}"))?; + } else { + f.read_to_end(&mut buf).map_err(|e| format!("EIO: {e}"))?; + } + Ok(buf) +} + +/// Whether a temp-file path is safe to delete: it lives in a known temporary +/// directory and its path contains the protocol's `tty-graphics-protocol` +/// marker, exactly as kitty requires before unlinking a client file. +fn is_safe_temp(path: &str) -> bool { + if !path.contains("tty-graphics-protocol") { + return false; + } + let tmpdir = std::env::var("TMPDIR").unwrap_or_default(); + let roots = ["/tmp/", "/dev/shm/", "/var/tmp/"]; + roots.iter().any(|r| path.starts_with(r)) || (!tmpdir.is_empty() && path.starts_with(&tmpdir)) +} + +/// Build a success response (`ESC _G ; OK ESC \`), unless suppressed +/// by the quiet level (`q>=1` mutes success). +fn respond(cmd: &GraphicsCommand, msg: &str) -> Outcome { + if cmd.quiet >= 1 { + return Outcome::default(); + } + Outcome { + response: Some(build_response(cmd, msg)), + grid_op: None, + } +} + +/// Build an error response unless fully quiet (`q>=2`). +fn respond_error(cmd: &GraphicsCommand, msg: &str) -> Outcome { + if cmd.quiet >= 2 { + return Outcome::default(); + } + Outcome { + response: Some(build_response(cmd, msg)), + grid_op: None, + } +} + +fn build_response(cmd: &GraphicsCommand, msg: &str) -> Vec { + let mut out = Vec::from(&b"\x1b_G"[..]); + if cmd.id != 0 { + out.extend_from_slice(format!("i={}", cmd.id).as_bytes()); + } + if cmd.number != 0 { + if cmd.id != 0 { + out.push(b','); + } + out.extend_from_slice(format!("I={}", cmd.number).as_bytes()); + } + out.push(b';'); + out.extend_from_slice(msg.as_bytes()); + out.extend_from_slice(b"\x1b\\"); + out +} + +#[cfg(test)] +mod tests { + use super::*; + use beer_protocols::codec::base64_encode; + + fn b64(data: &[u8]) -> Vec { + base64_encode(data).into_bytes() + } + + fn rgba_cmd(w: u32, h: u32, id: u32, action: Action) -> GraphicsCommand { + GraphicsCommand { + action, + format: Format::Rgba, + width: w, + height: h, + id, + ..Default::default() + } + } + + #[test] + fn transmit_rgba_stores_and_acks() { + let mut g = Graphics::new(); + let px = vec![0xab; 2 * 2 * 4]; + let out = g.handle(rgba_cmd(2, 2, 1, Action::Transmit), &b64(&px), (8, 16)); + assert_eq!(out.response.as_deref(), Some(&b"\x1b_Gi=1;OK\x1b\\"[..])); + let img = g.image(1).expect("stored"); + assert_eq!((img.width, img.height), (2, 2)); + assert_eq!(img.rgba.len(), 16); + } + + #[test] + fn rgb_expands_to_rgba() { + let mut g = Graphics::new(); + let px = vec![0x10; 2 * 3]; // 2x1 RGB + let mut cmd = rgba_cmd(2, 1, 5, Action::Transmit); + cmd.format = Format::Rgb; + g.handle(cmd, &b64(&px), (8, 16)); + let img = g.image(5).unwrap(); + assert_eq!( + img.rgba, + vec![0x10, 0x10, 0x10, 0xff, 0x10, 0x10, 0x10, 0xff] + ); + } + + #[test] + fn transmit_and_display_emits_placement() { + let mut g = Graphics::new(); + let px = vec![0; 16 * 16 * 4]; + let out = g.handle( + rgba_cmd(16, 16, 2, Action::TransmitAndDisplay), + &b64(&px), + (8, 16), + ); + match out.grid_op { + Some(GridOp::Place { + image, cols, rows, .. + }) => { + assert_eq!(image, 2); + assert_eq!(cols, 2); // 16px / 8px cell + assert_eq!(rows, 1); // 16px / 16px cell + } + other => panic!("expected placement, got {other:?}"), + } + assert!(g.placement(2, 0).is_some()); + } + + #[test] + fn chunked_direct_transmission_assembles() { + let mut g = Graphics::new(); + let px = vec![0x7f; 4 * 4]; // 4x1 RGBA + let full = base64_encode(&px).into_bytes(); + let (a, b) = full.split_at(8); // 8 is a multiple of 4 + let mut first = rgba_cmd(4, 1, 9, Action::Transmit); + first.more = true; + assert!(g.handle(first, a, (8, 16)).response.is_none()); + // Continuation carries only m=0. + let last = GraphicsCommand { + action: Action::Transmit, + more: false, + ..Default::default() + }; + let out = g.handle(last, b, (8, 16)); + assert_eq!(out.response.as_deref(), Some(&b"\x1b_Gi=9;OK\x1b\\"[..])); + assert_eq!(g.image(9).unwrap().rgba.len(), 16); + } + + #[test] + fn query_verifies_without_storing() { + let mut g = Graphics::new(); + let px = vec![0; 2 * 2 * 4]; + let out = g.handle(rgba_cmd(2, 2, 3, Action::Query), &b64(&px), (8, 16)); + assert_eq!(out.response.as_deref(), Some(&b"\x1b_Gi=3;OK\x1b\\"[..])); + assert!(g.image(3).is_none()); + } + + #[test] + fn bad_payload_reports_error() { + let mut g = Graphics::new(); + let out = g.handle(rgba_cmd(100, 100, 1, Action::Transmit), b"!!!!", (8, 16)); + let resp = out.response.expect("error response"); + assert!(resp.starts_with(b"\x1b_Gi=1;")); + assert!(resp.windows(6).any(|w| w == b"EINVAL")); + } + + #[test] + fn quiet_suppresses_success() { + let mut g = Graphics::new(); + let px = vec![0; 4]; + let mut cmd = rgba_cmd(1, 1, 1, Action::Transmit); + cmd.quiet = 1; + assert!(g.handle(cmd, &b64(&px), (8, 16)).response.is_none()); + } + + #[test] + fn delete_all_clears() { + let mut g = Graphics::new(); + g.handle( + rgba_cmd(2, 2, 1, Action::TransmitAndDisplay), + &b64(&[0; 16]), + (8, 16), + ); + let cmd = GraphicsCommand { + action: Action::Delete, + delete: b'A', + ..Default::default() + }; + let out = g.handle(cmd, &[], (8, 16)); + assert!(matches!(out.grid_op, Some(GridOp::Clear(ClearSpec::All)))); + assert!(g.image(1).is_none(), "uppercase delete frees data"); + } +} diff --git a/crates/beer/src/grid/mod.rs b/crates/beer/src/grid/mod.rs index b1cf307..e775c21 100644 --- a/crates/beer/src/grid/mod.rs +++ b/crates/beer/src/grid/mod.rs @@ -87,6 +87,24 @@ pub struct Sized { pub run: Option>, } +/// A cell's membership in a displayed graphics-protocol image. The image pixels +/// and the placement geometry live in the graphics engine, keyed by +/// `(image, placement)`; the cell records only which placement it belongs to and +/// its `(dx, dy)` position within that placement's cell rectangle, so the +/// renderer can composite the matching slice. Carrying the reference on the cell +/// is what makes images scroll, clear, and erase with the text for free. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub struct ImageRef { + /// Image id in the graphics store. + pub image: u32, + /// Placement id under that image. + pub placement: u32, + /// This cell's column within the placement rectangle. + pub dx: u16, + /// This cell's row within the placement rectangle. + pub dy: u16, +} + /// One grid cell: a character plus its rendering style. #[derive(Clone, PartialEq, Eq, Debug)] pub struct Cell { @@ -105,6 +123,8 @@ pub struct Cell { pub link: Option, /// Text-sizing block membership (`OSC 66`), or `None` for ordinary cells. pub sized: Option>, + /// Graphics-protocol image membership, or `None` for ordinary cells. + pub image: Option, } impl Default for Cell { @@ -119,6 +139,7 @@ impl Default for Cell { combining: None, link: None, sized: None, + image: None, } } } @@ -748,6 +769,77 @@ impl Grid { } } + /// Stamp a graphics-protocol placement as a `cols` by `rows` cell rectangle + /// with its top-left at the cursor, then move the cursor unless `keep_cursor` + /// (the `C=1` policy) is set. Each cell records its `(dx, dy)` in the + /// placement so the renderer composites the right image slice; the pixels and + /// geometry live in the graphics engine. Cells beyond the screen are clipped. + pub fn place_image( + &mut self, + image: u32, + placement: u32, + cols: usize, + rows: usize, + keep_cursor: bool, + ) { + let (x0, y0) = (self.cursor.x, self.cursor.y); + for dy in 0..rows { + let cy = y0 + dy; + if cy >= self.rows { + break; + } + for dx in 0..cols { + let cx = x0 + dx; + if cx >= self.cols { + break; + } + let cell = &mut self.lines[cy].cells[cx]; + cell.image = Some(ImageRef { + image, + placement, + dx: dx as u16, + dy: dy as u16, + }); + } + } + if keep_cursor { + return; + } + // Land the cursor just past the image on its bottom row, the way kitty + // leaves it, clamped to the screen. + self.cursor.y = (y0 + rows.saturating_sub(1)).min(self.rows - 1); + self.cursor.x = (x0 + cols).min(self.cols - 1); + self.wrap_pending = false; + } + + /// Remove image placements: every cell whose reference matches `pred` is + /// cleared back to a blank. With `pred` always true this erases all images. + pub fn clear_images(&mut self, pred: impl Fn(&ImageRef) -> bool) { + let blank = Cell::default(); + let touch = |line: &mut Line| { + for cell in &mut line.cells { + if cell.image.is_some_and(|r| pred(&r)) { + *cell = blank.clone(); + } + } + }; + self.lines.iter_mut().for_each(touch); + self.scrollback.iter_mut().for_each(touch); + if let Some(alt) = self.alt_saved.as_mut() { + alt.iter_mut().for_each(touch); + } + } + + /// The image placements intersecting the current cursor cell, for `d=c` + /// deletes: returns each `(image, placement)` found there. + pub fn images_at_cursor(&self) -> Vec<(u32, u32)> { + let mut out = Vec::new(); + if let Some(r) = self.lines[self.cursor.y].cells[self.cursor.x].image { + out.push((r.image, r.placement)); + } + out + } + /// If `(x, y)` belongs to a text-sizing block, blank every cell of that /// block so a write into it cannot orphan continuation cells. fn clear_sized_at(&mut self, x: usize, y: usize) { diff --git a/crates/beer/src/main.rs b/crates/beer/src/main.rs index 3dc53a0..bdfdba4 100644 --- a/crates/beer/src/main.rs +++ b/crates/beer/src/main.rs @@ -3,6 +3,7 @@ mod bindings; mod config; mod font; +mod graphics; mod grid; mod pty; mod render; diff --git a/crates/beer/src/render.rs b/crates/beer/src/render.rs index b64bdd7..34a64ae 100644 --- a/crates/beer/src/render.rs +++ b/crates/beer/src/render.rs @@ -79,6 +79,21 @@ impl Canvas<'_> { self.fill_rect(x0, y, w, 1, c); } + /// Composite one straight-alpha RGBA source pixel over the destination. + fn blend_rgba(&mut self, x: i32, y: i32, rgba: [u8; 4]) { + let a = u32::from(rgba[3]); + if a == 0 { + return; + } + let Some(i) = self.index(x, y) else { return }; + let inv = 255 - a; + let mix = |src: u8, dst: u8| ((u32::from(src) * a + u32::from(dst) * inv) / 255) as u8; + self.pixels[i] = mix(rgba[2], self.pixels[i]); + self.pixels[i + 1] = mix(rgba[1], self.pixels[i + 1]); + self.pixels[i + 2] = mix(rgba[0], self.pixels[i + 2]); + self.pixels[i + 3] = 0xff; + } + /// Composite one pre-multiplied BGRA source pixel over the destination. fn over(&mut self, x: i32, y: i32, src: &[u8]) { let Some(i) = self.index(x, y) else { return }; @@ -100,6 +115,8 @@ pub struct Frame<'a> { pub blink_on: bool, /// Hyperlink currently under the pointer; its cells get a hover underline. pub hovered_link: Option, + /// The graphics engine, source of image pixels and placement geometry. + pub images: &'a crate::graphics::Graphics, } #[derive(Debug)] @@ -198,6 +215,18 @@ impl Renderer { } } + // Graphics images stacked below the text (negative z-index). + draw_image_cells( + &mut canvas, + frame.images, + cells, + cols, + pad_x, + row_top, + m, + |z| z < 0, + ); + for (x, cell) in cells.iter().take(cols).enumerate() { if cell.flags.contains(Flags::WIDE_CONT) { continue; @@ -269,6 +298,18 @@ impl Renderer { } } + // Graphics images stacked above the text (z-index >= 0). + draw_image_cells( + &mut canvas, + frame.images, + cells, + cols, + pad_x, + row_top, + m, + |z| z >= 0, + ); + // The cursor belongs to the live screen; hide it while scrolled back. if grid.view_at_bottom() && grid.cursor().1 == y { self.draw_cursor(&mut canvas, grid, theme, m, focused, blink_on); @@ -579,6 +620,93 @@ fn blit_glyph_clipped( } } +/// Composite the graphics-image cells of one row whose placement z-index passes +/// `z_filter` (one call below the text, one above). Each cell carries its +/// `(dx, dy)` in the placement; the engine supplies the pixels and geometry. +#[allow(clippy::too_many_arguments)] +fn draw_image_cells( + canvas: &mut Canvas, + images: &crate::graphics::Graphics, + cells: &[Cell], + cols: usize, + pad_x: i32, + row_top: i32, + m: CellMetrics, + z_filter: impl Fn(i32) -> bool, +) { + for (x, cell) in cells.iter().take(cols).enumerate() { + let Some(r) = cell.image else { continue }; + let Some(p) = images.placement(r.image, r.placement) else { + continue; + }; + if !z_filter(p.z) { + continue; + } + let Some(img) = images.image(p.image) else { + continue; + }; + let origin_x = pad_x + x as i32 * m.width as i32; + blit_image_cell( + canvas, + img, + p, + r.dx as i32, + r.dy as i32, + origin_x, + row_top, + m, + ); + } +} + +/// Composite one cell's slice of an image placement. The placement's source +/// rectangle is scaled to its full cell-pixel area; this cell shows the +/// sub-rectangle for its `(dx, dy)`, sampled nearest-neighbour and alpha-blended. +#[allow(clippy::too_many_arguments)] +fn blit_image_cell( + canvas: &mut Canvas, + img: &crate::graphics::Image, + p: &crate::graphics::Placement, + dx: i32, + dy: i32, + origin_x: i32, + row_top: i32, + m: CellMetrics, +) { + let (cell_w, cell_h) = (m.width as i32, m.height as i32); + // The source rectangle is scaled to the cell area less the first-cell pixel + // offset, so a non-zero X/Y shifts the image inward from the top-left cell. + let span_w = (p.cols as i32 * cell_w - p.off_x as i32).max(1); + let span_h = (p.rows as i32 * cell_h - p.off_y as i32).max(1); + let src_w = if p.src_w == 0 { img.width } else { p.src_w } as i32; + let src_h = if p.src_h == 0 { img.height } else { p.src_h } as i32; + let (iw, ih) = (img.width as i32, img.height as i32); + for cy in 0..cell_h { + // Map this cell row to a source row through the placement's scale. + let placed_y = dy * cell_h + cy - p.off_y as i32; + if placed_y < 0 { + continue; + } + let sy = p.src_y as i32 + placed_y * src_h / span_h; + if sy < 0 || sy >= ih { + continue; + } + for cx in 0..cell_w { + let placed_x = dx * cell_w + cx - p.off_x as i32; + if placed_x < 0 { + continue; + } + let sx = p.src_x as i32 + placed_x * src_w / span_w; + if sx < 0 || sx >= iw { + continue; + } + let i = ((sy * iw + sx) * 4) as usize; + let px = &img.rgba[i..i + 4]; + canvas.blend_rgba(origin_x + cx, row_top + cy, [px[0], px[1], px[2], px[3]]); + } + } +} + /// Composite a rasterized glyph into the canvas. `origin_x`/`cell_top` are the /// cell's top-left; `rise` lifts the glyph above the baseline (HarfBuzz's /// vertical offset, 0 for the unshaped path). diff --git a/crates/beer/src/vt/mod.rs b/crates/beer/src/vt/mod.rs index 4039665..581f42f 100644 --- a/crates/beer/src/vt/mod.rs +++ b/crates/beer/src/vt/mod.rs @@ -12,6 +12,7 @@ use beer_protocols::codec::{base64_decode, decode_hex, file_uri_path}; use beer_protocols::sgr::{ext_color, underline_from}; use beer_protocols::style::prompt_kind; +use crate::graphics::Graphics; use crate::grid::{Color, CursorShape, Flags, Grid, MouseEncoding, MouseProtocol, Underline}; use crate::theme::{Rgb, Theme}; @@ -103,8 +104,35 @@ pub struct Term { cwd: Option, /// Desktop notifications requested via OSC 9/777/99, drained by the front-end. notifications: Vec, + /// Kitty graphics protocol state (images, placements, transmissions). + graphics: Graphics, + /// APC capture state, since `vte` does not surface APC sequences. + apc: ApcScan, + /// Payload of an APC being captured, between `ESC _` and its terminator. + apc_buf: Vec, + /// Current cell size in pixels, for translating image sizes into cells. + cell_px: (u32, u32), } +/// Where the APC capture splitter is in the byte stream. `vte` consumes APC +/// (`ESC _ ... ST`) silently, so [`Term::feed`] runs this small machine in front +/// of it: graphics payloads are diverted, everything else flows to `vte`. +#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] +enum ApcScan { + /// Forwarding bytes to `vte`. + #[default] + Normal, + /// Saw `ESC`; the next byte decides APC vs an ordinary escape. + Esc, + /// Collecting an APC payload. + Apc, + /// Saw `ESC` inside an APC; `\` ends it (ST), else it stays in the payload. + ApcEsc, +} + +/// Cap on a captured APC payload (one chunk is at most ~4 KiB of base64). +const APC_MAX: usize = 1 << 20; + impl Term { pub fn new(cols: usize, rows: usize) -> Self { Self { @@ -121,9 +149,135 @@ impl Term { bell: false, cwd: None, notifications: Vec::new(), + graphics: Graphics::new(), + apc: ApcScan::default(), + apc_buf: Vec::new(), + cell_px: (1, 1), } } + /// Feed PTY bytes to the terminal. Graphics APC sequences (`ESC _ G ... ST`) + /// are split out and handled here; all other bytes go to the `vte` parser. + /// `cell_px` is the current cell size, recorded for graphics layout. + pub fn feed(&mut self, parser: &mut vte::Parser, bytes: &[u8], cell_px: (u32, u32)) { + self.cell_px = cell_px; + let mut i = 0; + while i < bytes.len() { + match self.apc { + ApcScan::Normal => { + let start = i; + while i < bytes.len() && bytes[i] != 0x1b { + i += 1; + } + if i > start { + parser.advance(self, &bytes[start..i]); + } + if i < bytes.len() { + self.apc = ApcScan::Esc; + i += 1; + } + } + ApcScan::Esc => { + if bytes[i] == b'_' { + self.apc = ApcScan::Apc; + self.apc_buf.clear(); + i += 1; + } else { + // Not APC: hand the lone ESC to vte and let it pair with + // the following bytes as an ordinary escape sequence. + parser.advance(self, &[0x1b]); + self.apc = ApcScan::Normal; + } + } + ApcScan::Apc => match bytes[i] { + 0x07 => { + self.finish_apc(); + self.apc = ApcScan::Normal; + i += 1; + } + 0x1b => { + self.apc = ApcScan::ApcEsc; + i += 1; + } + b => { + if self.apc_buf.len() < APC_MAX { + self.apc_buf.push(b); + } + i += 1; + } + }, + ApcScan::ApcEsc => { + if bytes[i] == b'\\' { + self.finish_apc(); + self.apc = ApcScan::Normal; + i += 1; + } else { + // A stray ESC inside the payload: keep it and re-read this + // byte in APC state. + if self.apc_buf.len() < APC_MAX { + self.apc_buf.push(0x1b); + } + self.apc = ApcScan::Apc; + } + } + } + } + } + + /// Dispatch a captured APC payload. Only graphics commands (`G...`) are + /// handled; other APC strings are ignored as `vte` would have. + fn finish_apc(&mut self) { + let buf = std::mem::take(&mut self.apc_buf); + let Some((&b'G', body)) = buf.split_first() else { + return; + }; + let (control, payload) = match body.iter().position(|&b| b == b';') { + Some(p) => (&body[..p], &body[p + 1..]), + None => (body, &[][..]), + }; + let cmd = beer_protocols::graphics::parse(control); + let outcome = self.graphics.handle(cmd, payload, self.cell_px); + if let Some(resp) = outcome.response { + self.response.extend_from_slice(&resp); + } + if let Some(op) = outcome.grid_op { + self.apply_grid_op(op); + } + } + + /// Apply a graphics grid mutation: stamp a placement or clear image cells. + fn apply_grid_op(&mut self, op: crate::graphics::GridOp) { + use crate::graphics::{ClearSpec, GridOp}; + match op { + GridOp::Place { + image, + placement, + cols, + rows, + keep_cursor, + } => self + .grid + .place_image(image, placement, cols, rows, keep_cursor), + GridOp::Clear(spec) => match spec { + ClearSpec::All => self.grid.clear_images(|_| true), + ClearSpec::Image(id) => self.grid.clear_images(|r| r.image == id), + ClearSpec::Placement(id, p) => self + .grid + .clear_images(|r| r.image == id && r.placement == p), + ClearSpec::AtCursor => { + let targets = self.grid.images_at_cursor(); + self.grid + .clear_images(|r| targets.contains(&(r.image, r.placement))); + } + }, + } + } + + /// The graphics engine, for the renderer to read images and placements from. + pub fn graphics(&self) -> &Graphics { + &self.graphics + } + /// The working directory last reported by the shell (OSC 7), if any. pub fn cwd(&self) -> Option<&str> { self.cwd.as_deref() @@ -475,7 +629,7 @@ mod tests { fn feed(term: &mut Term, bytes: &[u8]) { let mut parser = vte::Parser::new(); - parser.advance(term, bytes); + term.feed(&mut parser, bytes, (8, 16)); } #[test] @@ -500,6 +654,33 @@ mod tests { assert_eq!(t.grid().row_text(1), "two"); } + #[test] + fn kitty_graphics_apc_transmits_and_displays() { + // ESC _ G a=T,f=32,s=2,v=2,i=1 ; ESC \: a 2x2 image, + // transmitted and displayed at the cursor. + let mut t = Term::new(20, 4); + let px = vec![0xffu8; 2 * 2 * 4]; + let b64 = beer_protocols::codec::base64_encode(&px); + let seq = format!("\x1b_Ga=T,f=32,s=2,v=2,i=1;{b64}\x1b\\"); + feed(&mut t, seq.as_bytes()); + // With an 8x16 cell the 2x2 image occupies one cell, stamped at (0,0). + let cell = t.grid().cell(0, 0); + assert_eq!(cell.image.map(|r| r.image), Some(1)); + let resp = t.take_response(); + assert!(resp.windows(2).any(|w| w == b"OK"), "expected OK response"); + } + + #[test] + fn apc_does_not_disturb_surrounding_text() { + // Text, then a graphics query APC, then more text: the text is intact and + // the APC did not leak bytes into the grid. + let mut t = Term::new(20, 2); + let px = beer_protocols::codec::base64_encode(&[0u8; 4]); + let seq = format!("ab\x1b_Ga=q,f=32,s=1,v=1,i=2;{px}\x1b\\cd"); + feed(&mut t, seq.as_bytes()); + assert_eq!(t.grid().row_text(0), "abcd"); + } + #[test] fn text_sizing_osc66_lays_out_a_scaled_block() { // `OSC 66 ; s=2 ; X BEL`: a 2x2 scaled block, cursor advances two cells. diff --git a/crates/beer/src/wayland/mod.rs b/crates/beer/src/wayland/mod.rs index 53f8e29..e896314 100644 --- a/crates/beer/src/wayland/mod.rs +++ b/crates/beer/src/wayland/mod.rs @@ -581,8 +581,11 @@ impl App { return Ok(PostAction::Remove); } }; + let cell = app.renderer.metrics(); if let Some(session) = app.session.as_mut() { - parser.advance(&mut session.term, &buf[..n]); + session + .term + .feed(&mut parser, &buf[..n], (cell.width, cell.height)); } app.after_feed(); Ok(PostAction::Continue) diff --git a/crates/beer/src/wayland/rendering.rs b/crates/beer/src/wayland/rendering.rs index 9c69731..02b7be5 100644 --- a/crates/beer/src/wayland/rendering.rs +++ b/crates/beer/src/wayland/rendering.rs @@ -171,6 +171,7 @@ impl App { focused, blink_on, hovered_link: self.hovered_link, + images: session.term.graphics(), }; if fresh { self.renderer.clear(canvas, dims, theme);