brewery
notashelf /
f29038f592f186426ab2a5529cf07c90ce20fb3f

beer

public

A terminal worth pouring time into

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

Commit f29038f592f1

tarball

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

unverified2 files changed+338-0
beer-protocols: add the kitty graphics protocol control-data parser

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I41ed33ba570b43142574dc4d8c04de266a6a6964
diff --git a/crates/beer-protocols/src/graphics.rs b/crates/beer-protocols/src/graphics.rsnew file mode 100644index 0000000..5696615--- /dev/null+++ b/crates/beer-protocols/src/graphics.rs@@ -0,0 +1,336 @@+//! The kitty terminal graphics protocol: parsing the control data of a graphics+//! command.+//!+//! A graphics command arrives as an Application Programming Command (APC):+//! `ESC _ G <control data> ; <payload> ESC \`. The control data is a+//! comma-separated list of `key=value` pairs; the payload is base64-encoded+//! binary. This module turns the control data into a typed [`GraphicsCommand`].+//! Capturing the APC from the byte stream, accumulating the payload across+//! chunks, decoding pixels, and storing/displaying images all live in the+//! terminal (`vte` does not surface APC, and those steps need terminal state);+//! this module is the pure, testable parse step.+//!+//! Several keys are overloaded by action: `x/y/w/h` are a source rectangle when+//! displaying but a destination rectangle for animation frames; `X/Y/z/c/r`+//! likewise change meaning between display, frame transmission, and frame+//! composition. The parser stores the raw value under one field and the engine+//! interprets it by [`Action`]; the field docs note each reuse.++/// The overall action of a graphics command (`a` key).+#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]+pub enum Action {+    /// `a=t`: transmit image data and store it.+    #[default]+    Transmit,+    /// `a=T`: transmit and immediately display.+    TransmitAndDisplay,+    /// `a=q`: query support; load but neither store nor display.+    Query,+    /// `a=p`: display (put) an already-transmitted image.+    Put,+    /// `a=d`: delete images/placements.+    Delete,+    /// `a=f`: transmit animation frame data.+    Frame,+    /// `a=a`: control animation playback.+    Animate,+    /// `a=c`: compose animation frames.+    Compose,+}++/// Pixel format of transmitted data (`f` key).+#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]+pub enum Format {+    /// `f=24`: 24-bit RGB, three bytes per pixel.+    Rgb,+    /// `f=32` (default): 32-bit RGBA, four bytes per pixel.+    #[default]+    Rgba,+    /// `f=100`: a PNG image; dimensions are read from the data.+    Png,+}++/// How the pixel data reaches the terminal (`t` key).+#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]+pub enum Medium {+    /// `t=d` (default): inline in the escape code payload, base64, chunked.+    #[default]+    Direct,+    /// `t=f`: a regular file at the path given in the payload.+    File,+    /// `t=t`: a temporary file the terminal deletes after reading.+    TempFile,+    /// `t=s`: a POSIX shared-memory object, unlinked after reading.+    SharedMemory,+}++/// Parsed control data of one graphics command. Every field's protocol default+/// is its type's default (zero, `false`, or the `#[default]` enum variant), so+/// an absent key simply leaves the default in place.+#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]+pub struct GraphicsCommand {+    /// `a`: the action.+    pub action: Action,+    /// `q`: response suppression (0 = all, 1 = errors only off too, 2 = quiet).+    pub quiet: u8,+    /// `f`: transmitted pixel format.+    pub format: Format,+    /// `t`: transmission medium.+    pub medium: Medium,+    /// `s`: image width in pixels (RGB/RGBA), or a frame source-rect width.+    pub width: u32,+    /// `v`: image height in pixels (RGB/RGBA), or a frame source-rect height.+    pub height: u32,+    /// `S`: number of bytes to read from a file/shared-memory object.+    pub read_size: u32,+    /// `O`: byte offset to start reading a file/shared-memory object from.+    pub read_offset: u32,+    /// `i`: image id (1..=u32::MAX; 0 means unset).+    pub id: u32,+    /// `I`: image number, an alternative client-side handle.+    pub number: u32,+    /// `p`: placement id.+    pub placement: u32,+    /// `o`: whether the payload is zlib-compressed (`o=z`).+    pub compressed: bool,+    /// `m`: whether more chunks follow (`m=1`); the last chunk has `m=0`.+    pub more: bool,+    /// `x`: source-rect left (display) or frame destination left, in pixels.+    pub x: u32,+    /// `y`: source-rect top (display) or frame destination top, in pixels.+    pub y: u32,+    /// `w`: source-rect width (display) or frame destination width, in pixels.+    pub w: u32,+    /// `h`: source-rect height (display) or frame destination height, in pixels.+    pub h: u32,+    /// `c`: columns to display in, or (compose) the overlaid frame number, or+    /// (frame transmit) the base frame number.+    pub c: u32,+    /// `r`: rows to display in, or the frame number being edited.+    pub r: u32,+    /// `X`: cell x pixel offset (display) or composition mode (frame: 1 =+    /// overwrite, else alpha blend).+    pub cap_x: u32,+    /// `Y`: cell y pixel offset (display) or background RGBA (frame).+    pub cap_y: u32,+    /// `z`: z-index / vertical stacking order (display) or frame gap in ms.+    pub z: i32,+    /// `C`: cursor-movement policy on display (`C=1` leaves the cursor put).+    pub cursor_policy: u8,+    /// `U`: create a virtual placement for a Unicode placeholder (`U=1`).+    pub virtual_placement: bool,+    /// `P`: parent image id for a relative placement.+    pub parent_id: u32,+    /// `Q`: parent placement id for a relative placement.+    pub parent_placement: u32,+    /// `H`: relative-placement horizontal cell offset (signed).+    pub rel_h: i32,+    /// `V`: relative-placement vertical cell offset (signed).+    pub rel_v: i32,+    /// `d`: delete target, the raw key char (0 when absent). Uppercase variants+    /// also free the stored image data; the engine reads the case.+    pub delete: u8,+}++impl GraphicsCommand {+    /// Whether a `d`-key delete frees the stored image data (uppercase variant).+    pub fn delete_frees_data(&self) -> bool {+        self.delete.is_ascii_uppercase()+    }+}++/// Parse the control-data field of a graphics command (everything between+/// `ESC _ G` and the `;` that precedes the payload). Unknown keys and malformed+/// pairs are ignored; an empty field yields the default command.+pub fn parse(control: &[u8]) -> GraphicsCommand {+    let mut cmd = GraphicsCommand::default();+    for pair in control.split(|&b| b == b',') {+        let Some(eq) = pair.iter().position(|&b| b == b'=') else {+            continue;+        };+        let (key, value) = (pair.get(..eq).unwrap_or(&[]), &pair[eq + 1..]);+        let [key] = key else { continue };+        let ch = value.first().copied();+        match key {+            b'a' => {+                cmd.action = match ch {+                    Some(b'T') => Action::TransmitAndDisplay,+                    Some(b'q') => Action::Query,+                    Some(b'p') => Action::Put,+                    Some(b'd') => Action::Delete,+                    Some(b'f') => Action::Frame,+                    Some(b'a') => Action::Animate,+                    Some(b'c') => Action::Compose,+                    _ => Action::Transmit,+                }+            }+            b'f' => {+                cmd.format = match parse_u32(value) {+                    Some(24) => Format::Rgb,+                    Some(100) => Format::Png,+                    _ => Format::Rgba,+                }+            }+            b't' => {+                cmd.medium = match ch {+                    Some(b'f') => Medium::File,+                    Some(b't') => Medium::TempFile,+                    Some(b's') => Medium::SharedMemory,+                    _ => Medium::Direct,+                }+            }+            b'o' => cmd.compressed = ch == Some(b'z'),+            b'm' => cmd.more = parse_u32(value) == Some(1),+            b'q' => set_u8(&mut cmd.quiet, value),+            b'C' => set_u8(&mut cmd.cursor_policy, value),+            b'U' => cmd.virtual_placement = parse_u32(value) == Some(1),+            b'd' => cmd.delete = ch.unwrap_or(0),+            b's' => set_u32(&mut cmd.width, value),+            b'v' => set_u32(&mut cmd.height, value),+            b'S' => set_u32(&mut cmd.read_size, value),+            b'O' => set_u32(&mut cmd.read_offset, value),+            b'i' => set_u32(&mut cmd.id, value),+            b'I' => set_u32(&mut cmd.number, value),+            b'p' => set_u32(&mut cmd.placement, value),+            b'x' => set_u32(&mut cmd.x, value),+            b'y' => set_u32(&mut cmd.y, value),+            b'w' => set_u32(&mut cmd.w, value),+            b'h' => set_u32(&mut cmd.h, value),+            b'c' => set_u32(&mut cmd.c, value),+            b'r' => set_u32(&mut cmd.r, value),+            b'X' => set_u32(&mut cmd.cap_x, value),+            b'Y' => set_u32(&mut cmd.cap_y, value),+            b'P' => set_u32(&mut cmd.parent_id, value),+            b'Q' => set_u32(&mut cmd.parent_placement, value),+            b'z' => set_i32(&mut cmd.z, value),+            b'H' => set_i32(&mut cmd.rel_h, value),+            b'V' => set_i32(&mut cmd.rel_v, value),+            _ => {}+        }+    }+    cmd+}++fn set_u32(slot: &mut u32, value: &[u8]) {+    if let Some(v) = parse_u32(value) {+        *slot = v;+    }+}++fn set_u8(slot: &mut u8, value: &[u8]) {+    if let Some(v) = parse_u32(value) {+        *slot = v.min(u8::MAX as u32) as u8;+    }+}++fn set_i32(slot: &mut i32, value: &[u8]) {+    if let Some(v) = parse_i32(value) {+        *slot = v;+    }+}++/// Parse ASCII digits into a `u32`, saturating. `None` if empty or non-digit.+fn parse_u32(bytes: &[u8]) -> Option<u32> {+    if bytes.is_empty() {+        return None;+    }+    let mut acc: u32 = 0;+    for &b in bytes {+        let d = b.checked_sub(b'0').filter(|&d| d < 10)?;+        acc = acc.saturating_mul(10).saturating_add(d as u32);+    }+    Some(acc)+}++/// Parse an optionally-signed decimal into an `i32`, saturating.+fn parse_i32(bytes: &[u8]) -> Option<i32> {+    let (neg, digits) = match bytes.split_first() {+        Some((b'-', rest)) => (true, rest),+        _ => (false, bytes),+    };+    let mag = parse_u32(digits)?;+    Some(if neg {+        -(mag.min(i32::MAX as u32 + 1) as i64) as i32+    } else {+        mag.min(i32::MAX as u32) as i32+    })+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn empty_is_default_transmit() {+        let c = parse(b"");+        assert_eq!(c, GraphicsCommand::default());+        assert_eq!(c.action, Action::Transmit);+        assert_eq!(c.format, Format::Rgba);+        assert_eq!(c.medium, Medium::Direct);+    }++    #[test]+    fn transmit_and_display_rgb_with_dimensions() {+        let c = parse(b"a=T,f=24,s=10,v=20,i=3");+        assert_eq!(c.action, Action::TransmitAndDisplay);+        assert_eq!(c.format, Format::Rgb);+        assert_eq!((c.width, c.height), (10, 20));+        assert_eq!(c.id, 3);+    }++    #[test]+    fn chunked_direct_transmission_flags() {+        let first = parse(b"a=t,f=100,m=1");+        assert!(first.more);+        assert_eq!(first.format, Format::Png);+        let last = parse(b"m=0");+        assert!(!last.more);+    }++    #[test]+    fn file_medium_with_size_and_offset() {+        let c = parse(b"t=t,S=4096,O=128,i=7");+        assert_eq!(c.medium, Medium::TempFile);+        assert_eq!((c.read_size, c.read_offset), (4096, 128));+    }++    #[test]+    fn compression_flag() {+        assert!(parse(b"o=z").compressed);+        assert!(!parse(b"o=0").compressed);+    }++    #[test]+    fn display_layout_keys_and_negative_z() {+        let c = parse(b"a=p,c=4,r=2,X=3,Y=5,z=-1,C=1");+        assert_eq!(c.action, Action::Put);+        assert_eq!((c.c, c.r), (4, 2));+        assert_eq!((c.cap_x, c.cap_y), (3, 5));+        assert_eq!(c.z, -1);+        assert_eq!(c.cursor_policy, 1);+    }++    #[test]+    fn delete_case_controls_freeing() {+        let keep = parse(b"a=d,d=i,i=9");+        assert_eq!(keep.delete, b'i');+        assert!(!keep.delete_frees_data());+        let free = parse(b"a=d,d=I,i=9");+        assert!(free.delete_frees_data());+    }++    #[test]+    fn relative_placement_and_placeholder() {+        let c = parse(b"a=p,U=1,P=2,Q=3,H=-4,V=5");+        assert!(c.virtual_placement);+        assert_eq!((c.parent_id, c.parent_placement), (2, 3));+        assert_eq!((c.rel_h, c.rel_v), (-4, 5));+    }++    #[test]+    fn unknown_and_malformed_keys_ignored() {+        let c = parse(b"a=t,zz=9,bad,=5,s=8");+        assert_eq!(c.action, Action::Transmit);+        assert_eq!(c.width, 8);+    }+}diff --git a/crates/beer-protocols/src/lib.rs b/crates/beer-protocols/src/lib.rsindex d5428a0..fbbd1e0 100644--- a/crates/beer-protocols/src/lib.rs+++ b/crates/beer-protocols/src/lib.rs@@ -12,6 +12,7 @@ //! //! - [`codec`] - base64 (OSC 52 clipboard), hex (XTGETTCAP), and `file://` URI //!   percent-decoding (OSC 7).+//! - [`graphics`] - the kitty graphics protocol APC control-data parse. //! - [`caps`] - the terminfo capabilities answered over XTGETTCAP. //! - [`charset`] - G0/G1 designation and DEC special-graphics line drawing. //! - [`sgr`] - the multi-parameter SGR colour and underline forms.@@ -27,6 +28,7 @@ pub mod caps; pub mod charset; pub mod codec;+pub mod graphics; pub mod key; pub mod mouse; pub mod sgr;