forked from NotAShelf/beer
beer: display kitty graphics protocol images
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I48b6d5b42528f0de53b33ddda2110a356a6a6964
This commit is contained in:
parent
f29038f592
commit
049ce83369
10 changed files with 1296 additions and 2 deletions
173
Cargo.lock
generated
173
Cargo.lock
generated
|
|
@ -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",
|
||||||
|
]
|
||||||
|
|
|
||||||
10
Cargo.toml
10
Cargo.toml
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
703
crates/beer/src/graphics/mod.rs
Normal file
703
crates/beer/src/graphics/mod.rs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue