brewery
notashelf /
049ce83369b27693e5cf78960fd5a4609f3afc09

beer

public

A terminal worth pouring time into

Star0tarball
clone
ssh://git@git.notashelf.dev:33/notashelf/beer.git

Commit 049ce83369b2

tarball

NotAShelf <raf@notashelf.dev> · 2026-06-26 20:29 UTC

unverified10 files changed+1296-2
beer: display kitty graphics protocol images

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I48b6d5b42528f0de53b33ddda2110a356a6a6964
diff --git a/Cargo.lock b/Cargo.lockindex c04ac89..736cc5c 100644--- a/Cargo.lock+++ b/Cargo.lock@@ -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",+]diff --git a/Cargo.toml b/Cargo.tomlindex 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.tomlindex 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 = truediff --git a/crates/beer/src/graphics/mod.rs b/crates/beer/src/graphics/mod.rsnew file mode 100644index 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<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");+    }+}diff --git a/crates/beer/src/grid/mod.rs b/crates/beer/src/grid/mod.rsindex 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<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) {diff --git a/crates/beer/src/main.rs b/crates/beer/src/main.rsindex 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.rsindex 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<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).diff --git a/crates/beer/src/vt/mod.rs b/crates/beer/src/vt/mod.rsindex 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<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);diff --git a/crates/beer/src/wayland/mod.rs b/crates/beer/src/wayland/mod.rsindex 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.rsindex 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);