beer: display kitty graphics protocol images

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I48b6d5b42528f0de53b33ddda2110a356a6a6964
This commit is contained in:
raf 2026-06-26 22:34:20 +03:00
commit 049ce83369
No known key found for this signature in database
GPG key ID: 29D95B64378DB4BF
10 changed files with 1296 additions and 2 deletions

173
Cargo.lock generated
View file

@ -2,6 +2,12 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 4 version = 4
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]] [[package]]
name = "aho-corasick" name = "aho-corasick"
version = "1.1.4" version = "1.1.4"
@ -29,6 +35,12 @@ version = "0.7.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f02882884d3e1bc524fb12c79f107f6ad0e1cfd498c536ffb494301740995dfe" checksum = "f02882884d3e1bc524fb12c79f107f6ad0e1cfd498c536ffb494301740995dfe"
[[package]]
name = "autocfg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]] [[package]]
name = "beer" name = "beer"
version = "0.4.0" version = "0.4.0"
@ -37,9 +49,11 @@ dependencies = [
"beer-protocols", "beer-protocols",
"calloop", "calloop",
"calloop-wayland-source", "calloop-wayland-source",
"flate2",
"fontconfig", "fontconfig",
"freetype-rs", "freetype-rs",
"harfbuzz_rs_now", "harfbuzz_rs_now",
"image",
"lru", "lru",
"pound", "pound",
"rustix", "rustix",
@ -89,6 +103,12 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]] [[package]]
name = "calloop" name = "calloop"
version = "0.14.4" version = "0.14.4"
@ -137,6 +157,12 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "color_quant"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]] [[package]]
name = "concurrent-queue" name = "concurrent-queue"
version = "2.5.0" version = "2.5.0"
@ -146,6 +172,15 @@ dependencies = [
"crossbeam-utils", "crossbeam-utils",
] ]
[[package]]
name = "crc32fast"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
dependencies = [
"cfg-if",
]
[[package]] [[package]]
name = "crossbeam-utils" name = "crossbeam-utils"
version = "0.8.21" version = "0.8.21"
@ -189,12 +224,31 @@ dependencies = [
"windows-sys", "windows-sys",
] ]
[[package]]
name = "fdeflate"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
dependencies = [
"simd-adler32",
]
[[package]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.9" version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" 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]] [[package]]
name = "foldhash" name = "foldhash"
version = "0.2.0" version = "0.2.0"
@ -232,6 +286,16 @@ dependencies = [
"pkg-config", "pkg-config",
] ]
[[package]]
name = "gif"
version = "0.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159"
dependencies = [
"color_quant",
"weezl",
]
[[package]] [[package]]
name = "harfbuzz_rs_now" name = "harfbuzz_rs_now"
version = "2.3.2" version = "2.3.2"
@ -260,6 +324,34 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" 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]] [[package]]
name = "indexmap" name = "indexmap"
version = "2.14.0" version = "2.14.0"
@ -349,6 +441,26 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "miniz_oxide"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "moxcms"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
dependencies = [
"num-traits",
"pxfm",
]
[[package]] [[package]]
name = "nix" name = "nix"
version = "0.31.3" version = "0.31.3"
@ -370,6 +482,15 @@ dependencies = [
"windows-sys", "windows-sys",
] ]
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]] [[package]]
name = "once_cell" name = "once_cell"
version = "1.21.4" version = "1.21.4"
@ -388,6 +509,19 @@ version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" 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]] [[package]]
name = "polling" name = "polling"
version = "3.11.0" version = "3.11.0"
@ -431,6 +565,18 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "pxfm"
version = "0.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f"
[[package]]
name = "quick-error"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]] [[package]]
name = "quick-xml" name = "quick-xml"
version = "0.39.4" version = "0.39.4"
@ -543,6 +689,12 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "simd-adler32"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
[[package]] [[package]]
name = "slab" name = "slab"
version = "0.4.12" version = "0.4.12"
@ -888,6 +1040,12 @@ dependencies = [
"pkg-config", "pkg-config",
] ]
[[package]]
name = "weezl"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
[[package]] [[package]]
name = "windows-link" name = "windows-link"
version = "0.2.1" version = "0.2.1"
@ -945,3 +1103,18 @@ dependencies = [
"once_cell", "once_cell",
"pkg-config", "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",
]

View file

@ -16,9 +16,17 @@ beer-protocols = { path = "crates/beer-protocols", version = "0.4.0" }
anyhow = "1.0.102" anyhow = "1.0.102"
calloop = { version = "0.14.4", features = ["signals"] } calloop = { version = "0.14.4", features = ["signals"] }
calloop-wayland-source = "0.4.1" calloop-wayland-source = "0.4.1"
flate2 = "1.0.35"
fontconfig = "0.11.0" fontconfig = "0.11.0"
freetype-rs = "0.38.0" freetype-rs = "0.38.0"
harfbuzz_rs_now = "2.3.2" harfbuzz_rs_now = "2.3.2"
image = { version = "0.25.5", default-features = false, features = [
"png",
"jpeg",
"gif",
"bmp",
"webp",
] }
lru = "0.18.0" lru = "0.18.0"
pound = "0.1.6" pound = "0.1.6"
rustix = { version = "1.1.4", features = [ rustix = { version = "1.1.4", features = [
@ -27,6 +35,8 @@ rustix = { version = "1.1.4", features = [
"termios", "termios",
"stdio", "stdio",
"fs", "fs",
"shm",
"mm",
] } ] }
serde = { version = "1.0.228", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] }
serde_ignored = "0.1.14" serde_ignored = "0.1.14"

View file

@ -12,9 +12,11 @@ anyhow.workspace = true
beer-protocols.workspace = true beer-protocols.workspace = true
calloop.workspace = true calloop.workspace = true
calloop-wayland-source.workspace = true calloop-wayland-source.workspace = true
flate2.workspace = true
fontconfig.workspace = true fontconfig.workspace = true
freetype-rs.workspace = true freetype-rs.workspace = true
harfbuzz_rs_now.workspace = true harfbuzz_rs_now.workspace = true
image.workspace = true
lru.workspace = true lru.workspace = true
pound.workspace = true pound.workspace = true
rustix.workspace = true rustix.workspace = true

View file

@ -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");
}
}

View file

@ -87,6 +87,24 @@ pub struct Sized {
pub run: Option<Box<str>>, 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. /// One grid cell: a character plus its rendering style.
#[derive(Clone, PartialEq, Eq, Debug)] #[derive(Clone, PartialEq, Eq, Debug)]
pub struct Cell { pub struct Cell {
@ -105,6 +123,8 @@ pub struct Cell {
pub link: Option<NonZeroU16>, pub link: Option<NonZeroU16>,
/// Text-sizing block membership (`OSC 66`), or `None` for ordinary cells. /// Text-sizing block membership (`OSC 66`), or `None` for ordinary cells.
pub sized: Option<Box<Sized>>, pub sized: Option<Box<Sized>>,
/// Graphics-protocol image membership, or `None` for ordinary cells.
pub image: Option<ImageRef>,
} }
impl Default for Cell { impl Default for Cell {
@ -119,6 +139,7 @@ impl Default for Cell {
combining: None, combining: None,
link: None, link: None,
sized: 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 /// If `(x, y)` belongs to a text-sizing block, blank every cell of that
/// block so a write into it cannot orphan continuation cells. /// block so a write into it cannot orphan continuation cells.
fn clear_sized_at(&mut self, x: usize, y: usize) { fn clear_sized_at(&mut self, x: usize, y: usize) {

View file

@ -3,6 +3,7 @@
mod bindings; mod bindings;
mod config; mod config;
mod font; mod font;
mod graphics;
mod grid; mod grid;
mod pty; mod pty;
mod render; mod render;

View file

@ -79,6 +79,21 @@ impl Canvas<'_> {
self.fill_rect(x0, y, w, 1, c); 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. /// Composite one pre-multiplied BGRA source pixel over the destination.
fn over(&mut self, x: i32, y: i32, src: &[u8]) { fn over(&mut self, x: i32, y: i32, src: &[u8]) {
let Some(i) = self.index(x, y) else { return }; let Some(i) = self.index(x, y) else { return };
@ -100,6 +115,8 @@ pub struct Frame<'a> {
pub blink_on: bool, pub blink_on: bool,
/// Hyperlink currently under the pointer; its cells get a hover underline. /// Hyperlink currently under the pointer; its cells get a hover underline.
pub hovered_link: Option<NonZeroU16>, pub hovered_link: Option<NonZeroU16>,
/// The graphics engine, source of image pixels and placement geometry.
pub images: &'a crate::graphics::Graphics,
} }
#[derive(Debug)] #[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() { for (x, cell) in cells.iter().take(cols).enumerate() {
if cell.flags.contains(Flags::WIDE_CONT) { if cell.flags.contains(Flags::WIDE_CONT) {
continue; 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. // The cursor belongs to the live screen; hide it while scrolled back.
if grid.view_at_bottom() && grid.cursor().1 == y { if grid.view_at_bottom() && grid.cursor().1 == y {
self.draw_cursor(&mut canvas, grid, theme, m, focused, blink_on); 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 /// 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 /// cell's top-left; `rise` lifts the glyph above the baseline (HarfBuzz's
/// vertical offset, 0 for the unshaped path). /// vertical offset, 0 for the unshaped path).

View file

@ -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::sgr::{ext_color, underline_from};
use beer_protocols::style::prompt_kind; use beer_protocols::style::prompt_kind;
use crate::graphics::Graphics;
use crate::grid::{Color, CursorShape, Flags, Grid, MouseEncoding, MouseProtocol, Underline}; use crate::grid::{Color, CursorShape, Flags, Grid, MouseEncoding, MouseProtocol, Underline};
use crate::theme::{Rgb, Theme}; use crate::theme::{Rgb, Theme};
@ -103,8 +104,35 @@ pub struct Term {
cwd: Option<String>, cwd: Option<String>,
/// Desktop notifications requested via OSC 9/777/99, drained by the front-end. /// Desktop notifications requested via OSC 9/777/99, drained by the front-end.
notifications: Vec<Notification>, 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 { impl Term {
pub fn new(cols: usize, rows: usize) -> Self { pub fn new(cols: usize, rows: usize) -> Self {
Self { Self {
@ -121,9 +149,135 @@ impl Term {
bell: false, bell: false,
cwd: None, cwd: None,
notifications: Vec::new(), 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. /// The working directory last reported by the shell (OSC 7), if any.
pub fn cwd(&self) -> Option<&str> { pub fn cwd(&self) -> Option<&str> {
self.cwd.as_deref() self.cwd.as_deref()
@ -475,7 +629,7 @@ mod tests {
fn feed(term: &mut Term, bytes: &[u8]) { fn feed(term: &mut Term, bytes: &[u8]) {
let mut parser = vte::Parser::new(); let mut parser = vte::Parser::new();
parser.advance(term, bytes); term.feed(&mut parser, bytes, (8, 16));
} }
#[test] #[test]
@ -500,6 +654,33 @@ mod tests {
assert_eq!(t.grid().row_text(1), "two"); assert_eq!(t.grid().row_text(1), "two");
} }
#[test]
fn kitty_graphics_apc_transmits_and_displays() {
// ESC _ G a=T,f=32,s=2,v=2,i=1 ; <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] #[test]
fn text_sizing_osc66_lays_out_a_scaled_block() { fn text_sizing_osc66_lays_out_a_scaled_block() {
// `OSC 66 ; s=2 ; X BEL`: a 2x2 scaled block, cursor advances two cells. // `OSC 66 ; s=2 ; X BEL`: a 2x2 scaled block, cursor advances two cells.

View file

@ -581,8 +581,11 @@ impl App {
return Ok(PostAction::Remove); return Ok(PostAction::Remove);
} }
}; };
let cell = app.renderer.metrics();
if let Some(session) = app.session.as_mut() { 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(); app.after_feed();
Ok(PostAction::Continue) Ok(PostAction::Continue)

View file

@ -171,6 +171,7 @@ impl App {
focused, focused,
blink_on, blink_on,
hovered_link: self.hovered_link, hovered_link: self.hovered_link,
images: session.term.graphics(),
}; };
if fresh { if fresh {
self.renderer.clear(canvas, dims, theme); self.renderer.clear(canvas, dims, theme);