diff --git a/crates/beer-protocols/src/graphics.rs b/crates/beer-protocols/src/graphics.rs new file mode 100644 index 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 ; 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 { + 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 { + 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.rs index 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;