clone
ssh://git@git.notashelf.dev:33/notashelf/beer.gitCommit 049ce83369b2
tarballunverified10 files changed+1296-2
@@ -3,6 +3,12 @@ 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" source = "registry+https://github.com/rust-lang/crates.io-index"@@ -30,6 +36,12 @@ 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" dependencies = [@@ -37,9 +49,11 @@ dependencies = [ "beer-protocols", "calloop", "calloop-wayland-source",+ "flate2", "fontconfig", "freetype-rs", "harfbuzz_rs_now",+ "image", "lru", "pound", "rustix",@@ -90,6 +104,12 @@ dependencies = [ ] [[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" source = "registry+https://github.com/rust-lang/crates.io-index"@@ -138,6 +158,12 @@ 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" source = "registry+https://github.com/rust-lang/crates.io-index"@@ -147,6 +173,15 @@ dependencies = [ ] [[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" source = "registry+https://github.com/rust-lang/crates.io-index"@@ -190,12 +225,31 @@ dependencies = [ ] [[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" source = "registry+https://github.com/rust-lang/crates.io-index"@@ -233,6 +287,16 @@ dependencies = [ ] [[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" source = "registry+https://github.com/rust-lang/crates.io-index"@@ -261,6 +325,34 @@ 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" source = "registry+https://github.com/rust-lang/crates.io-index"@@ -350,6 +442,26 @@ dependencies = [ ] [[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" source = "registry+https://github.com/rust-lang/crates.io-index"@@ -371,6 +483,15 @@ dependencies = [ ] [[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" source = "registry+https://github.com/rust-lang/crates.io-index"@@ -389,6 +510,19 @@ 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" source = "registry+https://github.com/rust-lang/crates.io-index"@@ -432,6 +566,18 @@ dependencies = [ ] [[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" source = "registry+https://github.com/rust-lang/crates.io-index"@@ -544,6 +690,12 @@ 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" source = "registry+https://github.com/rust-lang/crates.io-index"@@ -889,6 +1041,12 @@ dependencies = [ ] [[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" source = "registry+https://github.com/rust-lang/crates.io-index"@@ -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",+]@@ -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"@@ -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@@ -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<u8>,+}++/// 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<Vec<u8>>,+ pub grid_op: Option<GridOp>,+}++/// 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<u8>,+}++/// The graphics state for one terminal.+#[derive(Default, Debug)]+pub struct Graphics {+ images: HashMap<u32, Image>,+ /// Image number (`I`) to the id it most recently resolved to.+ by_number: HashMap<u32, u32>,+ placements: HashMap<(u32, u32), Placement>,+ pending: Option<Pending>,+ /// 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<Image, String> {+ 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<u32> {+ 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<u8>) -> Result<Image, String> {+ 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<u8>, 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<Vec<u8>, 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<Vec<u8>, 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<Vec<u8>, 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<Vec<u8>, 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<Vec<u8>, 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 <id keys> ; 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<u8> {+ 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<u8> {+ 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");+ }+}@@ -87,6 +87,24 @@ pub struct Sized { pub run: Option<Box<str>>, } +/// 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<NonZeroU16>, /// Text-sizing block membership (`OSC 66`), or `None` for ordinary cells. pub sized: Option<Box<Sized>>,+ /// Graphics-protocol image membership, or `None` for ordinary cells.+ pub image: Option<ImageRef>, } 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) {@@ -3,6 +3,7 @@ mod bindings; mod config; mod font;+mod graphics; mod grid; mod pty; mod render;@@ -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<NonZeroU16>,+ /// 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).@@ -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<String>, /// Desktop notifications requested via OSC 9/777/99, drained by the front-end. notifications: Vec<Notification>,+ /// 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<u8>,+ /// 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]@@ -501,6 +655,33 @@ mod tests { } #[test]+ fn kitty_graphics_apc_transmits_and_displays() {+ // ESC _ G a=T,f=32,s=2,v=2,i=1 ; <base64 RGBA> 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. let mut t = Term::new(20, 4);@@ -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)@@ -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);