diff --git a/Cargo.lock b/Cargo.lock
index 9b7c309..a8fc97b 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -198,7 +198,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
- "windows-sys 0.61.2",
+ "windows-sys 0.60.2",
]
[[package]]
@@ -209,7 +209,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
- "windows-sys 0.61.2",
+ "windows-sys 0.60.2",
]
[[package]]
@@ -407,6 +407,12 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
+[[package]]
+name = "base64"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
+
[[package]]
name = "base64"
version = "0.22.1"
@@ -1146,7 +1152,7 @@ dependencies = [
"wasm-bindgen",
"wasm-bindgen-futures",
"web-time",
- "windows-sys 0.61.2",
+ "windows-sys 0.59.0",
]
[[package]]
@@ -1817,6 +1823,12 @@ version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ec7c5eb7a16992b1904d76c517d170ab353b0e0b3d5a0c81a8a0cd1037893cf"
+[[package]]
+name = "color_quant"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
+
[[package]]
name = "colorchoice"
version = "1.0.5"
@@ -2185,7 +2197,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users",
- "windows-sys 0.61.2",
+ "windows-sys 0.59.0",
]
[[package]]
@@ -2320,7 +2332,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
- "windows-sys 0.61.2",
+ "windows-sys 0.52.0",
]
[[package]]
@@ -2483,6 +2495,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
+[[package]]
+name = "font-types"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39a654f404bbcbd48ea58c617c2993ee91d1cb63727a37bf2323a4edeed1b8c5"
+dependencies = [
+ "bytemuck",
+]
+
[[package]]
name = "font-types"
version = "0.11.3"
@@ -2506,7 +2527,7 @@ dependencies = [
"objc2-core-text",
"objc2-foundation 0.3.2",
"parlance",
- "read-fonts",
+ "read-fonts 0.39.2",
"roxmltree",
"smallvec",
"windows",
@@ -2672,6 +2693,16 @@ dependencies = [
"wasip3",
]
+[[package]]
+name = "gif"
+version = "0.14.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159"
+dependencies = [
+ "color_quant",
+ "weezl",
+]
+
[[package]]
name = "gl_generator"
version = "0.14.0"
@@ -2714,10 +2745,13 @@ version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3ce1918195723ce6ac74e80542c5a96a40c2b26162c1957a5cd70799b8cacf7"
dependencies = [
+ "base64 0.13.1",
"byteorder",
"gltf-json",
+ "image",
"lazy_static",
"serde_json",
+ "urlencoding",
]
[[package]]
@@ -2828,7 +2862,7 @@ checksum = "d12c7c642d4ce8c2e784b4751a6634bd89583912265add4a679a8882d123fbcd"
dependencies = [
"bitflags 2.13.0",
"bytemuck",
- "read-fonts",
+ "read-fonts 0.39.2",
"smallvec",
]
@@ -3067,10 +3101,25 @@ checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
dependencies = [
"bytemuck",
"byteorder-lite",
+ "color_quant",
+ "gif",
+ "image-webp",
"moxcms",
"num-traits",
"png",
"tiff",
+ "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]]
@@ -3636,7 +3685,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
- "windows-sys 0.61.2",
+ "windows-sys 0.59.0",
]
[[package]]
@@ -4056,7 +4105,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
- "windows-sys 0.61.2",
+ "windows-sys 0.52.0",
]
[[package]]
@@ -4142,7 +4191,7 @@ dependencies = [
"linebender_resource_handle",
"parlance",
"parley_data",
- "skrifa",
+ "skrifa 0.42.1",
]
[[package]]
@@ -4712,10 +4761,13 @@ dependencies = [
"bevy",
"clap",
"etcetera",
+ "gltf",
"image",
+ "libc",
"parley_ratatui",
"portable-pty",
"ratatui",
+ "rio-graphics",
"rio-vt",
"rust-embed",
"serde",
@@ -4745,6 +4797,16 @@ dependencies = [
"objc2-quartz-core 0.3.2",
]
+[[package]]
+name = "read-fonts"
+version = "0.35.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6717cf23b488adf64b9d711329542ba34de147df262370221940dfabc2c91358"
+dependencies = [
+ "bytemuck",
+ "font-types 0.10.1",
+]
+
[[package]]
name = "read-fonts"
version = "0.39.2"
@@ -4752,7 +4814,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4ed38b89c2c77ff968c524145ad65fb010f38af5c7a224b53b81d47ac2daa81"
dependencies = [
"bytemuck",
- "font-types",
+ "font-types 0.11.3",
]
[[package]]
@@ -4836,9 +4898,8 @@ checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832"
[[package]]
name = "rio-grapheme-width"
-version = "0.5.19"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8748a230342b653830718978aa78fc7e4887bcf2d1292228ea1e033dd0207151"
+version = "0.5.20"
+source = "git+https://github.com/gold-silver-copper/rio?rev=535495772f8548e2cbf9a840938c8eca21714888#535495772f8548e2cbf9a840938c8eca21714888"
dependencies = [
"phf 0.13.1",
"ucd-trie",
@@ -4846,24 +4907,32 @@ dependencies = [
[[package]]
name = "rio-graphics"
-version = "0.5.19"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "150067cf37f1468cffffae9cbfd40e106adc5f8451239b5b0a7fa660b6a5a91c"
+version = "0.5.20"
+source = "git+https://github.com/gold-silver-copper/rio?rev=535495772f8548e2cbf9a840938c8eca21714888#535495772f8548e2cbf9a840938c8eca21714888"
dependencies = [
+ "image",
+ "parking_lot",
"rustc-hash 2.1.3",
+ "skrifa 0.37.0",
"tracing",
+ "web-time",
]
+[[package]]
+name = "rio-unicode"
+version = "0.1.0"
+source = "git+https://github.com/gold-silver-copper/rio?rev=535495772f8548e2cbf9a840938c8eca21714888#535495772f8548e2cbf9a840938c8eca21714888"
+
[[package]]
name = "rio-vt"
-version = "0.5.19"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "82bb771ed37480c3bec80684513a5d136a7cd5a32a86c0bf13a6d7cb0239b600"
+version = "0.5.20"
+source = "git+https://github.com/gold-silver-copper/rio?rev=535495772f8548e2cbf9a840938c8eca21714888#535495772f8548e2cbf9a840938c8eca21714888"
dependencies = [
"base64 0.23.0",
"bitflags 2.13.0",
"cursor-icon",
"flate2",
+ "image",
"libc",
"memchr",
"parking_lot",
@@ -4871,12 +4940,13 @@ dependencies = [
"regex-automata",
"rio-grapheme-width",
"rio-graphics",
+ "rio-unicode",
"rustc-hash 2.1.3",
"serde",
"simdutf",
"smallvec",
"tracing",
- "unicode-width-16",
+ "web-time",
"windows-sys 0.61.2",
]
@@ -4981,7 +5051,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
- "windows-sys 0.61.2",
+ "windows-sys 0.52.0",
]
[[package]]
@@ -5274,6 +5344,16 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e"
+[[package]]
+name = "skrifa"
+version = "0.37.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8c31071dedf532758ecf3fed987cdb4bd9509f900e026ab684b4ecb81ea49841"
+dependencies = [
+ "bytemuck",
+ "read-fonts 0.35.0",
+]
+
[[package]]
name = "skrifa"
version = "0.42.1"
@@ -5281,7 +5361,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c34617370ae968efb7161bb2beb517d9084659aae19e24b89e3db25b46e4564"
dependencies = [
"bytemuck",
- "read-fonts",
+ "read-fonts 0.39.2",
]
[[package]]
@@ -5429,7 +5509,7 @@ version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0811b01ca2c4e8718760713911feaf4675c24f94e50530a015ec646cfb622f7c"
dependencies = [
- "skrifa",
+ "skrifa 0.42.1",
"yazi",
"zeno",
]
@@ -5497,7 +5577,7 @@ dependencies = [
"parking_lot",
"rustix 1.1.4",
"signal-hook",
- "windows-sys 0.61.2",
+ "windows-sys 0.60.2",
]
[[package]]
@@ -5968,18 +6048,18 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
-[[package]]
-name = "unicode-width-16"
-version = "0.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9eba15036aa0f5bf8ed6cd12a624ddb61fd50b0779b1c05d89b663bcaed7b5c2"
-
[[package]]
name = "unicode-xid"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
+[[package]]
+name = "urlencoding"
+version = "2.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
+
[[package]]
name = "utf8_iter"
version = "1.0.4"
@@ -6033,7 +6113,7 @@ dependencies = [
"log",
"peniko",
"png",
- "skrifa",
+ "skrifa 0.42.1",
"static_assertions",
"thiserror 2.0.18",
"vello_encoding",
@@ -6050,7 +6130,7 @@ dependencies = [
"bytemuck",
"guillotiere 0.7.0",
"peniko",
- "skrifa",
+ "skrifa 0.42.1",
"smallvec",
]
@@ -6621,7 +6701,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
- "windows-sys 0.61.2",
+ "windows-sys 0.52.0",
]
[[package]]
diff --git a/Cargo.toml b/Cargo.toml
index 019d8d2..cdf3195 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -41,11 +41,14 @@ bevy = { version = "0.19.0", default-features = false, features = [
] }
clap = { version = "4.5", features = ["derive"] }
etcetera = "0.11"
+gltf = "1.4"
image = { version = "0.25", default-features = false, features = ["png", "ico"] }
+libc = "0.2"
parley_ratatui = "0.3.4"
portable-pty = "0.8"
ratatui = "0.30"
-rio-vt = { version = "0.5.19", default-features = false }
+rio-graphics = { git = "https://github.com/gold-silver-copper/rio", rev = "535495772f8548e2cbf9a840938c8eca21714888" }
+rio-vt = { git = "https://github.com/gold-silver-copper/rio", rev = "535495772f8548e2cbf9a840938c8eca21714888", default-features = false, features = ["graphics"] }
rust-embed = "8.5"
serde = { version = "1.0", features = ["derive"] }
shellexpand = "3.1"
diff --git a/README.md b/README.md
index 60c0e61..345583a 100644
--- a/README.md
+++ b/README.md
@@ -25,6 +25,7 @@ Inspired by TempleOS | Built with Rust & Ratatui
- Spinning rat cursor ([customizable](#changing-the-cursor))
- Traditional 2D and [new 3D mode](#3d-mode)!
- [Inline 3D objects](#inline-3d-objects)
+- [Inline 2D bitmap surfaces](#inline-2d-bitmap-surfaces)
- [GPU-backed text rendering](#rendering-pipeline)
- Image support (via Kitty Graphics Protocol >:\()
@@ -233,6 +234,96 @@ A blazingly fast serial monitor with plotter TUI and 3D telemetry
+## Inline 2D bitmap surfaces
+
+Ratty's [Bitmap Surface Protocol](protocols/bitmap.md) lets terminal applications
+register a PNG once, place it in terminal-cell space, and update its crop,
+destination, fit, filtering, or opacity without uploading the image again.
+Applications can also replace the pixels with fixed-size RGBA8 frames for video
+or screen-share rendering.
+
+Query support before using the protocol:
+
+```text
+ESC _ ratty;i;s ESC \
+```
+
+Ratty replies with:
+
+```text
+ESC _ ratty;i;s;v=1;fmt=png;frame=rgba8;payload=1;chunk=1;placement=1;crop=1;fit=contain|cover|fill;filter=nearest|linear;opacity=1 ESC \
+```
+
+If no reply arrives, treat bitmap surfaces as unsupported and use another render
+path or a text fallback. See the protocol specification for the complete wire
+format, validation rules, and lifecycle.
+
+Try interactive pan, zoom, fit, filtering, and opacity with a PNG:
+
+```bash
+cargo run --example bitmap_pan_zoom --
+```
+
+Try generated RGBA8 frames at 15 FPS for 10 seconds:
+
+```bash
+cargo run --example bitmap_frames -- --fps 15 --duration 10 --width 320 --height 180
+```
+
+`bitmap_frames` accepts `--fps`, `--duration`, `--width`, and `--height`; their
+defaults are `15`, `10`, `320`, and `180`. Both examples register one bitmap,
+create one placement, update that stable bitmap or placement, and delete both on
+exit. Multiple placements can share the same bitmap and Bevy image handle.
+
+Ratty owns APC parsing, PNG registration, raw RGBA8 frame validation and upload,
+stable image and placement state, and rendering. The application owns capture,
+network transport, codec choices such as VP8, and decoding compressed video to
+RGBA8 before sending frames to Ratty.
+
+The `[bitmap]` configuration section bounds registered bitmap and placement
+counts, image dimensions, bytes per decoded bitmap, total decoded bytes across
+registered bitmaps, retained bytes across incomplete transfers, and the number
+of concurrent incomplete transfers. See
+[`config/ratty.toml`](config/ratty.toml) for the default limits.
+
+Current v1 boundaries:
+
+- live frames are full, fixed-dimension RGBA8; frame resizing is rejected
+- no live-frame compression, delta updates, or dirty rectangles
+- no codec decoding, VP8 handling, capture, or networking in Ratty
+
+### Kitty graphics
+
+Kitty Graphics Protocol handling is native to `rio-vt`: Ratty forwards complete
+Kitty APC frames unchanged and owns only the Bevy image assets and draw
+entities created from rio-vt's graphics updates and placement geometry. This
+keeps Kitty parsing, replies, image lifetime, direct placement mutation,
+Unicode-placeholder cells, scrollback, reflow, and alternate-screen behavior
+in the terminal state machine.
+
+The renderer supports direct RGB/RGBA and PNG transfers, padded or unpadded
+standard base64, `o=z` zlib compression, multi-APC chunking, crop and cell
+spans, pixel offsets, z-index, retransmission, and deletion. Unicode
+placeholders remain ordinary terminal cells, so scrolling, resizing, reflow,
+and erasure naturally change which image slices are visible. Current `kitten
+icat --transfer-mode=stream` and its `--unicode-placeholder` mode use these
+paths.
+
+Ordinary negative Kitty z-index placements currently render below Ratty's
+combined terminal texture, so non-default cell backgrounds can obscure them.
+Extreme-negative/default-background placement and image-to-image z ordering
+remain supported.
+
+Ratty deliberately permits only Kitty's direct PTY-stream medium. File,
+temporary-file, and shared-memory media are disabled and capability queries for
+them fail. The `[bitmap]` limits also configure Kitty's encoded, decoded,
+decompressed, dimension, resident-byte, placement, and incomplete-transfer
+budgets. Incomplete transfers expire after ten seconds, malformed or
+interleaved transfers abort without replacing valid pixels, and an unterminated
+APC is discarded once it reaches its configured encoded bound. Ratty reports
+`OK` only after the configured parser, storage, placement, and render path can
+honor the request.
+
## Architecture
### Rendering pipeline
diff --git a/config/ratty.toml b/config/ratty.toml
index 5f31c56..859631e 100644
--- a/config/ratty.toml
+++ b/config/ratty.toml
@@ -12,6 +12,16 @@ default_rows = 32
scrollback = 2000
mouse_scroll_lines = 3
+[bitmap]
+max_bitmaps = 1024
+max_placements = 4096
+max_image_width = 8192
+max_image_height = 8192
+max_bitmap_bytes = 67108864
+max_total_bitmap_bytes = 268435456
+max_pending_bytes = 67108864
+max_pending_transfers = 16
+
# [shell]
# program = "/bin/bash"
# args = []
diff --git a/examples/bitmap_frames.rs b/examples/bitmap_frames.rs
new file mode 100644
index 0000000..16f09ad
--- /dev/null
+++ b/examples/bitmap_frames.rs
@@ -0,0 +1,676 @@
+use std::{
+ io::{self, Stdout, Write},
+ thread,
+ time::{Duration, Instant},
+};
+
+use anyhow::{Context, Result, ensure};
+use base64::Engine as _;
+use clap::Parser;
+use image::ImageEncoder as _;
+use ratatui::crossterm::{
+ cursor::{Hide, MoveTo, Show},
+ execute, queue,
+ style::Print,
+ terminal::{
+ self, Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode,
+ enable_raw_mode,
+ },
+};
+
+const BITMAP_ID: u32 = 42;
+const PLACEMENT_ID: u32 = 7;
+const MAX_BASE64_CHUNK: usize = 4096;
+const APC_PREFIX: &str = "\u{1b}_ratty;i;";
+const APC_END: &str = "\u{1b}\\";
+
+#[derive(Parser)]
+#[command(about = "Stream generated RGBA8 frames through Ratty's bitmap surface protocol")]
+struct Args {
+ /// Frames per second.
+ #[arg(long, default_value_t = 15)]
+ fps: u32,
+
+ /// Run duration in seconds.
+ #[arg(long, default_value_t = 10.0)]
+ duration: f64,
+
+ /// Bitmap width in pixels.
+ #[arg(long, default_value_t = 320)]
+ width: u32,
+
+ /// Bitmap height in pixels.
+ #[arg(long, default_value_t = 180)]
+ height: u32,
+}
+
+fn encode_registration(bitmap_id: u32, encoded_png: &str) -> Vec> {
+ debug_assert_eq!(MAX_BASE64_CHUNK % 4, 0);
+ let chunks: Vec<_> = encoded_png.as_bytes().chunks(MAX_BASE64_CHUNK).collect();
+ let last = chunks.len().saturating_sub(1);
+
+ chunks
+ .into_iter()
+ .enumerate()
+ .map(|(index, payload)| {
+ let payload = std::str::from_utf8(payload).expect("base64 is ASCII");
+ let more = u8::from(index != last);
+ if index == 0 {
+ encode_command(format!(
+ "r;id={bitmap_id};fmt=png;source=payload;more={more};{payload}"
+ ))
+ } else {
+ encode_command(format!("r;id={bitmap_id};more={more};{payload}"))
+ }
+ })
+ .collect()
+}
+
+fn encode_placement(
+ bitmap_id: u32,
+ placement_id: u32,
+ row: u16,
+ col: u16,
+ columns: u32,
+ rows: u32,
+) -> Vec {
+ // row/col are visible coordinates now; Ratty attaches the resulting
+ // placement to this alternate-screen content for later scroll/reflow.
+ encode_command(format!(
+ "p;id={bitmap_id};pid={placement_id};row={row};col={col};w={columns};h={rows};fit=contain;filter=linear;opacity=1"
+ ))
+}
+
+fn encode_frame(
+ bitmap_id: u32,
+ sequence: u32,
+ width: u32,
+ height: u32,
+ rgba: &[u8],
+) -> Result>> {
+ ensure!(width > 0 && height > 0, "frame dimensions must be nonzero");
+ let expected_len = width
+ .checked_mul(height)
+ .and_then(|pixels| pixels.checked_mul(4))
+ .and_then(|bytes| usize::try_from(bytes).ok())
+ .context("frame dimensions overflow")?;
+ ensure!(
+ rgba.len() == expected_len,
+ "RGBA8 frame length does not match its dimensions"
+ );
+
+ let encoded = base64::engine::general_purpose::STANDARD.encode(rgba);
+ debug_assert_eq!(MAX_BASE64_CHUNK % 4, 0);
+ let chunks: Vec<_> = encoded.as_bytes().chunks(MAX_BASE64_CHUNK).collect();
+ let last = chunks.len().saturating_sub(1);
+
+ Ok(chunks
+ .into_iter()
+ .enumerate()
+ .map(|(index, payload)| {
+ let payload = std::str::from_utf8(payload).expect("base64 is ASCII");
+ let more = u8::from(index != last);
+ if index == 0 {
+ encode_command(format!(
+ "f;id={bitmap_id};seq={sequence};fmt=rgba8;w={width};h={height};more={more};{payload}"
+ ))
+ } else {
+ encode_command(format!(
+ "f;id={bitmap_id};seq={sequence};more={more};{payload}"
+ ))
+ }
+ })
+ .collect())
+}
+
+fn encode_deletion(bitmap_id: u32, placement_id: u32) -> [Vec; 2] {
+ [
+ encode_command(format!("d;pid={placement_id}")),
+ encode_command(format!("d;id={bitmap_id}")),
+ ]
+}
+
+fn encode_command(body: String) -> Vec {
+ format!("{APC_PREFIX}{body}{APC_END}").into_bytes()
+}
+
+struct LatestFrameScheduler {
+ interval: Duration,
+ next_sequence: Option,
+}
+
+impl LatestFrameScheduler {
+ const fn new(interval: Duration) -> Self {
+ Self {
+ interval,
+ next_sequence: Some(1),
+ }
+ }
+
+ fn due_sequence(&mut self, elapsed: Duration) -> Option {
+ let next_sequence = self.next_sequence?;
+ let latest_tick = elapsed.as_nanos() / self.interval.as_nanos();
+ let latest_tick = u32::try_from(latest_tick).unwrap_or(u32::MAX);
+ if latest_tick < next_sequence {
+ return None;
+ }
+
+ self.next_sequence = latest_tick.checked_add(1);
+ Some(latest_tick)
+ }
+
+ fn next_deadline(&self) -> Duration {
+ self.next_sequence.map_or(Duration::MAX, |sequence| {
+ self.interval.saturating_mul(sequence)
+ })
+ }
+}
+
+#[derive(Debug)]
+struct TimingConfig {
+ interval: Duration,
+ run_duration: Duration,
+}
+
+fn validate_timing(fps: u32, duration_seconds: f64) -> Result {
+ ensure!(fps > 0, "--fps must be greater than zero");
+ ensure!(
+ duration_seconds.is_finite() && duration_seconds > 0.0,
+ "--duration must be a finite positive number"
+ );
+
+ let interval = Duration::try_from_secs_f64(1.0 / f64::from(fps))
+ .context("--fps cannot be represented as a frame interval")?;
+ ensure!(!interval.is_zero(), "--fps is too large");
+ let run_duration = Duration::try_from_secs_f64(duration_seconds)
+ .context("--duration is too large to represent")?;
+
+ // The loop emits only while elapsed < run_duration. At nanosecond
+ // resolution, this is the largest tick that can become due.
+ let maximum_due_tick = run_duration.as_nanos().saturating_sub(1) / interval.as_nanos();
+ ensure!(
+ maximum_due_tick <= u128::from(u32::MAX),
+ "--fps and --duration can exceed the u32 frame sequence capacity"
+ );
+
+ Ok(TimingConfig {
+ interval,
+ run_duration,
+ })
+}
+
+fn generate_frame(width: u32, height: u32, sequence: u32) -> Result> {
+ let len = width
+ .checked_mul(height)
+ .and_then(|pixels| pixels.checked_mul(4))
+ .and_then(|bytes| usize::try_from(bytes).ok())
+ .context("frame dimensions overflow")?;
+ let mut rgba = vec![0; len];
+ let square_size = (width.min(height) / 5).max(1);
+ let travel = width.saturating_sub(square_size).saturating_add(1);
+ let square_x = sequence.wrapping_mul(6) % travel;
+ let square_y = height.saturating_sub(square_size) / 2;
+
+ for y in 0..height {
+ for x in 0..width {
+ let offset = usize::try_from((y * width + x) * 4).expect("validated frame fits usize");
+ rgba[offset] = ((u64::from(x) * 255) / u64::from(width)) as u8;
+ rgba[offset + 1] = ((u64::from(y) * 255) / u64::from(height)) as u8;
+ rgba[offset + 2] = sequence.wrapping_mul(3) as u8;
+ rgba[offset + 3] = 255;
+
+ if x >= square_x
+ && x < square_x + square_size
+ && y >= square_y
+ && y < square_y + square_size
+ {
+ rgba[offset..offset + 4].copy_from_slice(&[255, 240, 32, 255]);
+ }
+ }
+ }
+
+ Ok(rgba)
+}
+
+fn encode_png(width: u32, height: u32, rgba: &[u8]) -> Result> {
+ let mut png = Vec::new();
+ image::codecs::png::PngEncoder::new(&mut png)
+ .write_image(rgba, width, height, image::ExtendedColorType::Rgba8)
+ .context("failed to encode initial PNG")?;
+ Ok(png)
+}
+
+trait TerminalBackend {
+ fn enable_raw(&mut self) -> io::Result<()>;
+ fn enter_alternate(&mut self) -> io::Result<()>;
+ fn hide_cursor(&mut self) -> io::Result<()>;
+ fn prepare_screen(&mut self) -> io::Result<()>;
+ fn show_cursor(&mut self) -> io::Result<()>;
+ fn leave_alternate(&mut self) -> io::Result<()>;
+ fn disable_raw(&mut self) -> io::Result<()>;
+}
+
+#[derive(Default)]
+struct TerminalSetupState {
+ raw_enabled: bool,
+ alternate_entered: bool,
+ cursor_hidden: bool,
+}
+
+fn setup_terminal(backend: &mut impl TerminalBackend) -> io::Result {
+ let mut state = TerminalSetupState {
+ raw_enabled: true,
+ ..TerminalSetupState::default()
+ };
+ if let Err(error) = backend.enable_raw() {
+ restore_terminal(backend, &mut state);
+ return Err(error);
+ }
+
+ state.alternate_entered = true;
+ if let Err(error) = backend.enter_alternate() {
+ restore_terminal(backend, &mut state);
+ return Err(error);
+ }
+
+ state.cursor_hidden = true;
+ if let Err(error) = backend.hide_cursor() {
+ restore_terminal(backend, &mut state);
+ return Err(error);
+ }
+
+ if let Err(error) = backend.prepare_screen() {
+ restore_terminal(backend, &mut state);
+ return Err(error);
+ }
+
+ Ok(state)
+}
+
+fn restore_terminal(backend: &mut impl TerminalBackend, state: &mut TerminalSetupState) {
+ if std::mem::take(&mut state.cursor_hidden) {
+ let _ = backend.show_cursor();
+ }
+ if std::mem::take(&mut state.alternate_entered) {
+ let _ = backend.leave_alternate();
+ }
+ if std::mem::take(&mut state.raw_enabled) {
+ let _ = backend.disable_raw();
+ }
+}
+
+struct CrosstermBackend {
+ stdout: Stdout,
+}
+
+impl TerminalBackend for CrosstermBackend {
+ fn enable_raw(&mut self) -> io::Result<()> {
+ enable_raw_mode()
+ }
+
+ fn enter_alternate(&mut self) -> io::Result<()> {
+ execute!(self.stdout, EnterAlternateScreen)
+ }
+
+ fn hide_cursor(&mut self) -> io::Result<()> {
+ execute!(self.stdout, Hide)
+ }
+
+ fn prepare_screen(&mut self) -> io::Result<()> {
+ execute!(self.stdout, Clear(ClearType::All), MoveTo(0, 0))
+ }
+
+ fn show_cursor(&mut self) -> io::Result<()> {
+ execute!(self.stdout, Show)
+ }
+
+ fn leave_alternate(&mut self) -> io::Result<()> {
+ execute!(self.stdout, LeaveAlternateScreen)
+ }
+
+ fn disable_raw(&mut self) -> io::Result<()> {
+ disable_raw_mode()
+ }
+}
+
+struct TerminalSession {
+ backend: CrosstermBackend,
+ setup: TerminalSetupState,
+ bitmap_id: u32,
+ placement_id: u32,
+}
+
+impl TerminalSession {
+ fn enter(bitmap_id: u32, placement_id: u32) -> io::Result {
+ let mut backend = CrosstermBackend {
+ stdout: io::stdout(),
+ };
+ let setup = setup_terminal(&mut backend)?;
+ Ok(Self {
+ backend,
+ setup,
+ bitmap_id,
+ placement_id,
+ })
+ }
+
+ fn write_commands(&mut self, commands: I) -> io::Result<()>
+ where
+ I: IntoIterator- >,
+ {
+ for command in commands {
+ self.backend.stdout.write_all(&command)?;
+ }
+ self.backend.stdout.flush()
+ }
+
+ fn draw_status(&mut self, sequence: u32, fps: u32) -> io::Result<()> {
+ queue!(
+ self.backend.stdout,
+ MoveTo(0, 0),
+ Clear(ClearType::CurrentLine),
+ Print(format!(
+ "Ratty RGBA8 bitmap stream | target {fps} FPS | sequence {sequence}"
+ ))
+ )?;
+ self.backend.stdout.flush()
+ }
+}
+
+impl Drop for TerminalSession {
+ fn drop(&mut self) {
+ for command in encode_deletion(self.bitmap_id, self.placement_id) {
+ let _ = self.backend.stdout.write_all(&command);
+ }
+ let _ = self.backend.stdout.flush();
+ restore_terminal(&mut self.backend, &mut self.setup);
+ }
+}
+
+fn main() -> Result<()> {
+ let args = Args::parse();
+ ensure!(
+ args.width > 0 && args.height > 0,
+ "--width and --height must be greater than zero"
+ );
+
+ let timing = validate_timing(args.fps, args.duration)?;
+ let initial_rgba = generate_frame(args.width, args.height, 0)?;
+ let png = encode_png(args.width, args.height, &initial_rgba)?;
+ let encoded_png = base64::engine::general_purpose::STANDARD.encode(png);
+
+ let mut terminal =
+ TerminalSession::enter(BITMAP_ID, PLACEMENT_ID).context("failed to enter terminal mode")?;
+ terminal.write_commands(encode_registration(BITMAP_ID, &encoded_png))?;
+ let (columns, rows) = terminal::size().context("failed to read terminal size")?;
+ terminal.write_commands([encode_placement(
+ BITMAP_ID,
+ PLACEMENT_ID,
+ 1,
+ 0,
+ u32::from(columns.max(1)),
+ u32::from(rows.saturating_sub(1).max(1)),
+ )])?;
+ terminal.draw_status(0, args.fps)?;
+
+ let started = Instant::now();
+ let mut scheduler = LatestFrameScheduler::new(timing.interval);
+ loop {
+ let elapsed = started.elapsed();
+ if elapsed >= timing.run_duration {
+ break;
+ }
+
+ if let Some(sequence) = scheduler.due_sequence(elapsed) {
+ let rgba = generate_frame(args.width, args.height, sequence)?;
+ terminal.write_commands(encode_frame(
+ BITMAP_ID,
+ sequence,
+ args.width,
+ args.height,
+ &rgba,
+ )?)?;
+ terminal.draw_status(sequence, args.fps)?;
+ continue;
+ }
+
+ let wait = scheduler
+ .next_deadline()
+ .saturating_sub(elapsed)
+ .min(timing.run_duration.saturating_sub(elapsed));
+ thread::sleep(wait);
+ }
+
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ const APC_END: &str = "\u{1b}\\";
+
+ fn command_text(commands: &[Vec]) -> Vec<&str> {
+ commands
+ .iter()
+ .map(|command| {
+ std::str::from_utf8(command).expect("valid example test input should succeed")
+ })
+ .collect()
+ }
+
+ fn payload(command: &str) -> &str {
+ command
+ .strip_suffix(APC_END)
+ .expect("valid example test input should succeed")
+ .rsplit_once(';')
+ .expect("valid example test input should succeed")
+ .1
+ }
+
+ #[derive(Clone, Copy, Debug, PartialEq, Eq)]
+ enum TerminalAction {
+ EnableRaw,
+ EnterAlternate,
+ HideCursor,
+ PrepareScreen,
+ ShowCursor,
+ LeaveAlternate,
+ DisableRaw,
+ }
+
+ struct MockTerminalBackend {
+ fail_at: TerminalAction,
+ actions: Vec,
+ }
+
+ impl MockTerminalBackend {
+ fn new(fail_at: TerminalAction) -> Self {
+ Self {
+ fail_at,
+ actions: Vec::new(),
+ }
+ }
+
+ fn record(&mut self, action: TerminalAction) -> io::Result<()> {
+ self.actions.push(action);
+ if action == self.fail_at {
+ Err(io::Error::other("injected terminal failure"))
+ } else {
+ Ok(())
+ }
+ }
+ }
+
+ impl TerminalBackend for MockTerminalBackend {
+ fn enable_raw(&mut self) -> io::Result<()> {
+ self.record(TerminalAction::EnableRaw)
+ }
+
+ fn enter_alternate(&mut self) -> io::Result<()> {
+ self.record(TerminalAction::EnterAlternate)
+ }
+
+ fn hide_cursor(&mut self) -> io::Result<()> {
+ self.record(TerminalAction::HideCursor)
+ }
+
+ fn prepare_screen(&mut self) -> io::Result<()> {
+ self.record(TerminalAction::PrepareScreen)
+ }
+
+ fn show_cursor(&mut self) -> io::Result<()> {
+ self.record(TerminalAction::ShowCursor)
+ }
+
+ fn leave_alternate(&mut self) -> io::Result<()> {
+ self.record(TerminalAction::LeaveAlternate)
+ }
+
+ fn disable_raw(&mut self) -> io::Result<()> {
+ self.record(TerminalAction::DisableRaw)
+ }
+ }
+
+ #[test]
+ fn partial_terminal_setup_attempts_reverse_cleanup() {
+ let mut backend = MockTerminalBackend::new(TerminalAction::HideCursor);
+
+ assert!(setup_terminal(&mut backend).is_err());
+
+ assert_eq!(
+ backend.actions,
+ vec![
+ TerminalAction::EnableRaw,
+ TerminalAction::EnterAlternate,
+ TerminalAction::HideCursor,
+ TerminalAction::ShowCursor,
+ TerminalAction::LeaveAlternate,
+ TerminalAction::DisableRaw,
+ ]
+ );
+ }
+
+ #[test]
+ fn lifecycle_registers_and_places_once_then_sends_monotonic_rgba_frames_and_deletes() {
+ let mut commands = encode_registration(BITMAP_ID, &"A".repeat(4100));
+ commands.push(encode_placement(BITMAP_ID, PLACEMENT_ID, 2, 1, 80, 24));
+ commands.extend(
+ encode_frame(BITMAP_ID, 1, 2, 2, &[0; 16])
+ .expect("valid example test input should succeed"),
+ );
+ commands.extend(
+ encode_frame(BITMAP_ID, 3, 2, 2, &[1; 16])
+ .expect("valid example test input should succeed"),
+ );
+ commands.extend(encode_deletion(BITMAP_ID, PLACEMENT_ID));
+
+ let commands = command_text(&commands);
+ assert_eq!(
+ commands
+ .iter()
+ .filter(|command| command.contains(";r;id=") && command.contains("fmt=png"))
+ .count(),
+ 1
+ );
+ assert_eq!(
+ commands
+ .iter()
+ .filter(|command| command.contains(";p;"))
+ .count(),
+ 1
+ );
+ assert_eq!(
+ commands
+ .iter()
+ .filter(|command| command.contains(";f;") && command.contains("fmt=rgba8"))
+ .map(|command| *command)
+ .collect::>(),
+ vec![
+ "\u{1b}_ratty;i;f;id=42;seq=1;fmt=rgba8;w=2;h=2;more=0;AAAAAAAAAAAAAAAAAAAAAA==\u{1b}\\",
+ "\u{1b}_ratty;i;f;id=42;seq=3;fmt=rgba8;w=2;h=2;more=0;AQEBAQEBAQEBAQEBAQEBAQ==\u{1b}\\",
+ ]
+ );
+ assert!(commands[commands.len() - 2].contains(";d;pid=7"));
+ assert!(commands[commands.len() - 1].contains(";d;id=42"));
+ }
+
+ #[test]
+ fn frame_chunks_base64_on_aligned_4096_character_boundaries() {
+ let rgba = vec![7; 3_075];
+
+ let chunks = encode_frame(BITMAP_ID, 9, 1, 3_075 / 4, &rgba)
+ .expect_err("invalid example test input should be rejected");
+ assert!(chunks.to_string().contains("length"));
+
+ let rgba = vec![7; 4_096 * 3 / 4 + 4];
+ let chunks = encode_frame(BITMAP_ID, 9, 1, rgba.len() as u32 / 4, &rgba)
+ .expect("valid example test input should succeed");
+ assert_eq!(chunks.len(), 2);
+ for (index, command) in command_text(&chunks).iter().enumerate() {
+ assert!(payload(command).len() <= 4096);
+ assert_eq!(payload(command).len() % 4, 0);
+ assert_eq!(command.contains("fmt=rgba8;w=1;h=769"), index == 0);
+ assert_eq!(command.contains("more=0"), index == 1);
+ if index == 1 {
+ assert!(command.starts_with("\u{1b}_ratty;i;f;id=42;seq=9;more=0;"));
+ }
+ }
+ }
+
+ #[test]
+ fn scheduler_skips_obsolete_ticks_instead_of_bursting() {
+ let mut scheduler = LatestFrameScheduler::new(std::time::Duration::from_millis(100));
+
+ assert_eq!(
+ scheduler.due_sequence(std::time::Duration::from_millis(99)),
+ None
+ );
+ assert_eq!(
+ scheduler.due_sequence(std::time::Duration::from_millis(100)),
+ Some(1)
+ );
+ assert_eq!(
+ scheduler.due_sequence(std::time::Duration::from_millis(450)),
+ Some(4)
+ );
+ assert_eq!(
+ scheduler.due_sequence(std::time::Duration::from_millis(451)),
+ None
+ );
+ assert_eq!(
+ scheduler.next_deadline(),
+ std::time::Duration::from_millis(500)
+ );
+ }
+
+ #[test]
+ fn timing_validation_rejects_duration_conversion_overflow() {
+ let error =
+ validate_timing(15, 1e300).expect_err("invalid example test input should be rejected");
+
+ assert!(error.to_string().contains("--duration"));
+ }
+
+ #[test]
+ fn timing_validation_rejects_sequence_capacity_overflow_and_accepts_boundary() {
+ let overflow = f64::from(u32::MAX) + 2.0;
+ let boundary = f64::from(u32::MAX) + 1.0;
+
+ assert!(validate_timing(1, overflow).is_err());
+ assert!(validate_timing(1, boundary).is_ok());
+ }
+
+ #[test]
+ fn scheduler_emits_u32_max_at_most_once() {
+ let mut scheduler = LatestFrameScheduler::new(std::time::Duration::from_nanos(1));
+ let saturated = std::time::Duration::from_nanos(u64::from(u32::MAX));
+
+ assert_eq!(scheduler.due_sequence(saturated), Some(u32::MAX));
+ assert_eq!(
+ scheduler.due_sequence(saturated.saturating_add(std::time::Duration::from_secs(1))),
+ None
+ );
+ assert_eq!(scheduler.next_deadline(), std::time::Duration::MAX);
+ }
+}
diff --git a/examples/bitmap_pan_zoom.rs b/examples/bitmap_pan_zoom.rs
new file mode 100644
index 0000000..53c7a28
--- /dev/null
+++ b/examples/bitmap_pan_zoom.rs
@@ -0,0 +1,650 @@
+use std::{
+ env, fs,
+ io::{self, Stdout, Write},
+};
+
+use anyhow::{Context, Result};
+use base64::Engine as _;
+use image::GenericImageView as _;
+use ratatui::crossterm::{
+ cursor::{Hide, MoveTo, Show},
+ event::{self, Event, KeyCode},
+ execute, queue,
+ style::Print,
+ terminal::{
+ self, Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode,
+ enable_raw_mode,
+ },
+};
+
+const BITMAP_ID: u32 = 42;
+const PLACEMENT_ID: u32 = 7;
+const MAX_BASE64_CHUNK: usize = 4096;
+const APC_PREFIX: &str = "\u{1b}_ratty;i;";
+const APC_END: &str = "\u{1b}\\";
+
+#[derive(Clone, Copy)]
+struct Destination {
+ row: u16,
+ col: u16,
+ columns: u32,
+ rows: u32,
+}
+
+impl Destination {
+ const fn new(row: u16, col: u16, columns: u32, rows: u32) -> Self {
+ Self {
+ row,
+ col,
+ columns,
+ rows,
+ }
+ }
+}
+
+#[derive(Clone, Copy, Debug)]
+enum Fit {
+ Contain,
+ Cover,
+ Fill,
+}
+
+impl Fit {
+ const fn protocol_value(self) -> &'static str {
+ match self {
+ Self::Contain => "contain",
+ Self::Cover => "cover",
+ Self::Fill => "fill",
+ }
+ }
+}
+
+#[derive(Clone, Copy, Debug)]
+enum Filter {
+ Nearest,
+ Linear,
+}
+
+impl Filter {
+ const fn protocol_value(self) -> &'static str {
+ match self {
+ Self::Nearest => "nearest",
+ Self::Linear => "linear",
+ }
+ }
+}
+
+#[derive(Clone, Copy)]
+enum Zoom {
+ In,
+ Out,
+}
+
+#[derive(Clone, Copy)]
+struct ViewState {
+ bitmap_width: u32,
+ bitmap_height: u32,
+ x: u32,
+ y: u32,
+ width: u32,
+ height: u32,
+ fit: Fit,
+ filter: Filter,
+ opacity: f32,
+}
+
+impl ViewState {
+ fn new(bitmap_width: u32, bitmap_height: u32) -> Self {
+ Self {
+ bitmap_width,
+ bitmap_height,
+ x: 0,
+ y: 0,
+ width: bitmap_width,
+ height: bitmap_height,
+ fit: Fit::Contain,
+ filter: Filter::Linear,
+ opacity: 1.0,
+ }
+ }
+
+ fn pan(&mut self, horizontal: i64, vertical: i64) {
+ self.x = offset_clamped(
+ self.x,
+ horizontal,
+ self.bitmap_width.saturating_sub(self.width),
+ );
+ self.y = offset_clamped(
+ self.y,
+ vertical,
+ self.bitmap_height.saturating_sub(self.height),
+ );
+ }
+
+ fn zoom(&mut self, direction: Zoom) {
+ let (new_width, new_height) = match direction {
+ Zoom::In => ((self.width / 2).max(1), (self.height / 2).max(1)),
+ Zoom::Out => (
+ self.width.saturating_mul(2).min(self.bitmap_width),
+ self.height.saturating_mul(2).min(self.bitmap_height),
+ ),
+ };
+ let center_x = self.x.saturating_add(self.width / 2);
+ let center_y = self.y.saturating_add(self.height / 2);
+ self.width = new_width;
+ self.height = new_height;
+ self.x = center_x
+ .saturating_sub(new_width / 2)
+ .min(self.bitmap_width.saturating_sub(new_width));
+ self.y = center_y
+ .saturating_sub(new_height / 2)
+ .min(self.bitmap_height.saturating_sub(new_height));
+ }
+
+ fn cycle_fit(&mut self) {
+ self.fit = match self.fit {
+ Fit::Contain => Fit::Cover,
+ Fit::Cover => Fit::Fill,
+ Fit::Fill => Fit::Contain,
+ };
+ }
+}
+
+fn offset_clamped(current: u32, delta: i64, maximum: u32) -> u32 {
+ let next = i64::from(current).saturating_add(delta);
+ next.clamp(0, i64::from(maximum)) as u32
+}
+
+fn encode_registration(bitmap_id: u32, encoded_png: &str) -> Vec> {
+ debug_assert_eq!(MAX_BASE64_CHUNK % 4, 0);
+ let chunks: Vec<_> = encoded_png.as_bytes().chunks(MAX_BASE64_CHUNK).collect();
+ let last = chunks.len().saturating_sub(1);
+
+ chunks
+ .into_iter()
+ .enumerate()
+ .map(|(index, payload)| {
+ let payload = std::str::from_utf8(payload).expect("base64 is ASCII");
+ let more = u8::from(index != last);
+ if index == 0 {
+ encode_command(format!(
+ "r;id={bitmap_id};fmt=png;source=payload;more={more};{payload}"
+ ))
+ } else {
+ encode_command(format!("r;id={bitmap_id};more={more};{payload}"))
+ }
+ })
+ .collect()
+}
+
+fn encode_placement(bitmap_id: u32, placement_id: u32, destination: Destination) -> Vec {
+ // row/col are visible coordinates now; Ratty attaches the resulting
+ // placement to this alternate-screen content for later scroll/reflow.
+ encode_command(format!(
+ "p;id={bitmap_id};pid={placement_id};row={};col={};w={};h={};fit=contain;filter=linear;opacity=1",
+ destination.row, destination.col, destination.columns, destination.rows
+ ))
+}
+
+fn encode_update(placement_id: u32, view: ViewState) -> Vec {
+ encode_command(format!(
+ "u;pid={placement_id};src_x={};src_y={};src_w={};src_h={};fit={};filter={};opacity={:.3}",
+ view.x,
+ view.y,
+ view.width,
+ view.height,
+ view.fit.protocol_value(),
+ view.filter.protocol_value(),
+ view.opacity
+ ))
+}
+
+fn encode_deletion(bitmap_id: u32, placement_id: u32) -> [Vec; 2] {
+ [
+ encode_command(format!("d;pid={placement_id}")),
+ encode_command(format!("d;id={bitmap_id}")),
+ ]
+}
+
+fn encode_command(body: String) -> Vec {
+ format!("{APC_PREFIX}{body}{APC_END}").into_bytes()
+}
+
+trait TerminalBackend {
+ fn enable_raw(&mut self) -> io::Result<()>;
+ fn enter_alternate(&mut self) -> io::Result<()>;
+ fn hide_cursor(&mut self) -> io::Result<()>;
+ fn prepare_screen(&mut self) -> io::Result<()>;
+ fn show_cursor(&mut self) -> io::Result<()>;
+ fn leave_alternate(&mut self) -> io::Result<()>;
+ fn disable_raw(&mut self) -> io::Result<()>;
+}
+
+#[derive(Default)]
+struct TerminalSetupState {
+ raw_enabled: bool,
+ alternate_entered: bool,
+ cursor_hidden: bool,
+}
+
+fn setup_terminal(backend: &mut impl TerminalBackend) -> io::Result {
+ let mut state = TerminalSetupState {
+ raw_enabled: true,
+ ..TerminalSetupState::default()
+ };
+ if let Err(error) = backend.enable_raw() {
+ restore_terminal(backend, &mut state);
+ return Err(error);
+ }
+
+ state.alternate_entered = true;
+ if let Err(error) = backend.enter_alternate() {
+ restore_terminal(backend, &mut state);
+ return Err(error);
+ }
+
+ state.cursor_hidden = true;
+ if let Err(error) = backend.hide_cursor() {
+ restore_terminal(backend, &mut state);
+ return Err(error);
+ }
+
+ if let Err(error) = backend.prepare_screen() {
+ restore_terminal(backend, &mut state);
+ return Err(error);
+ }
+
+ Ok(state)
+}
+
+fn restore_terminal(backend: &mut impl TerminalBackend, state: &mut TerminalSetupState) {
+ if std::mem::take(&mut state.cursor_hidden) {
+ let _ = backend.show_cursor();
+ }
+ if std::mem::take(&mut state.alternate_entered) {
+ let _ = backend.leave_alternate();
+ }
+ if std::mem::take(&mut state.raw_enabled) {
+ let _ = backend.disable_raw();
+ }
+}
+
+struct CrosstermBackend {
+ stdout: Stdout,
+}
+
+impl TerminalBackend for CrosstermBackend {
+ fn enable_raw(&mut self) -> io::Result<()> {
+ enable_raw_mode()
+ }
+
+ fn enter_alternate(&mut self) -> io::Result<()> {
+ execute!(self.stdout, EnterAlternateScreen)
+ }
+
+ fn hide_cursor(&mut self) -> io::Result<()> {
+ execute!(self.stdout, Hide)
+ }
+
+ fn prepare_screen(&mut self) -> io::Result<()> {
+ execute!(self.stdout, Clear(ClearType::All), MoveTo(0, 0))
+ }
+
+ fn show_cursor(&mut self) -> io::Result<()> {
+ execute!(self.stdout, Show)
+ }
+
+ fn leave_alternate(&mut self) -> io::Result<()> {
+ execute!(self.stdout, LeaveAlternateScreen)
+ }
+
+ fn disable_raw(&mut self) -> io::Result<()> {
+ disable_raw_mode()
+ }
+}
+
+struct TerminalSession {
+ backend: CrosstermBackend,
+ setup: TerminalSetupState,
+ bitmap_id: u32,
+ placement_id: u32,
+}
+
+impl TerminalSession {
+ fn enter(bitmap_id: u32, placement_id: u32) -> io::Result {
+ let mut backend = CrosstermBackend {
+ stdout: io::stdout(),
+ };
+ let setup = setup_terminal(&mut backend)?;
+ Ok(Self {
+ backend,
+ setup,
+ bitmap_id,
+ placement_id,
+ })
+ }
+
+ fn write_commands(&mut self, commands: I) -> io::Result<()>
+ where
+ I: IntoIterator
- >,
+ {
+ for command in commands {
+ self.backend.stdout.write_all(&command)?;
+ }
+ self.backend.stdout.flush()
+ }
+
+ fn draw_help(&mut self, view: ViewState) -> io::Result<()> {
+ queue!(
+ self.backend.stdout,
+ MoveTo(0, 0),
+ Clear(ClearType::CurrentLine),
+ Print("arrows pan | +/- zoom | f fit | n/l filter | [/] opacity | q quit"),
+ MoveTo(0, 1),
+ Clear(ClearType::CurrentLine),
+ Print(format!(
+ "crop {}x{}+{},{} | fit {} | filter {} | opacity {:.1}",
+ view.width,
+ view.height,
+ view.x,
+ view.y,
+ view.fit.protocol_value(),
+ view.filter.protocol_value(),
+ view.opacity
+ ))
+ )?;
+ self.backend.stdout.flush()
+ }
+}
+
+impl Drop for TerminalSession {
+ fn drop(&mut self) {
+ for command in encode_deletion(self.bitmap_id, self.placement_id) {
+ let _ = self.backend.stdout.write_all(&command);
+ }
+ let _ = self.backend.stdout.flush();
+ restore_terminal(&mut self.backend, &mut self.setup);
+ }
+}
+
+fn main() -> Result<()> {
+ let path = env::args_os()
+ .nth(1)
+ .context("usage: cargo run --example bitmap_pan_zoom -- ")?;
+ let png = fs::read(&path)
+ .with_context(|| format!("failed to read PNG from {}", path.to_string_lossy()))?;
+ let image = image::load_from_memory_with_format(&png, image::ImageFormat::Png)
+ .context("input is not a valid PNG")?;
+ let (bitmap_width, bitmap_height) = image.dimensions();
+ drop(image);
+ let encoded_png = base64::engine::general_purpose::STANDARD.encode(&png);
+
+ let mut terminal = TerminalSession::enter(BITMAP_ID, PLACEMENT_ID)
+ .context("failed to enter interactive terminal mode")?;
+ let mut view = ViewState::new(bitmap_width, bitmap_height);
+ terminal.draw_help(view)?;
+ terminal.write_commands(encode_registration(BITMAP_ID, &encoded_png))?;
+ let (columns, rows) = terminal::size().context("failed to read terminal size")?;
+ terminal.write_commands([encode_placement(
+ BITMAP_ID,
+ PLACEMENT_ID,
+ Destination::new(
+ 2,
+ 1,
+ u32::from(columns.saturating_sub(2).max(1)),
+ u32::from(rows.saturating_sub(3).max(1)),
+ ),
+ )])?;
+
+ loop {
+ let Event::Key(key) = event::read().context("failed to read terminal input")? else {
+ continue;
+ };
+ if !key.is_press() {
+ continue;
+ }
+
+ let pan_x = i64::from((view.width / 20).max(1));
+ let pan_y = i64::from((view.height / 20).max(1));
+ let changed = match key.code {
+ KeyCode::Char('q') => break,
+ KeyCode::Left => {
+ view.pan(-pan_x, 0);
+ true
+ }
+ KeyCode::Right => {
+ view.pan(pan_x, 0);
+ true
+ }
+ KeyCode::Up => {
+ view.pan(0, -pan_y);
+ true
+ }
+ KeyCode::Down => {
+ view.pan(0, pan_y);
+ true
+ }
+ KeyCode::Char('+') | KeyCode::Char('=') => {
+ view.zoom(Zoom::In);
+ true
+ }
+ KeyCode::Char('-') => {
+ view.zoom(Zoom::Out);
+ true
+ }
+ KeyCode::Char('f') => {
+ view.cycle_fit();
+ true
+ }
+ KeyCode::Char('n') => {
+ view.filter = Filter::Nearest;
+ true
+ }
+ KeyCode::Char('l') => {
+ view.filter = Filter::Linear;
+ true
+ }
+ KeyCode::Char('[') => {
+ view.opacity = (view.opacity - 0.1).max(0.0);
+ true
+ }
+ KeyCode::Char(']') => {
+ view.opacity = (view.opacity + 0.1).min(1.0);
+ true
+ }
+ _ => false,
+ };
+
+ if changed {
+ terminal.write_commands([encode_update(PLACEMENT_ID, view)])?;
+ terminal.draw_help(view)?;
+ }
+ }
+
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ const APC_END: &str = "\u{1b}\\";
+
+ #[derive(Clone, Copy, Debug, PartialEq, Eq)]
+ enum TerminalAction {
+ EnableRaw,
+ EnterAlternate,
+ HideCursor,
+ PrepareScreen,
+ ShowCursor,
+ LeaveAlternate,
+ DisableRaw,
+ }
+
+ struct MockTerminalBackend {
+ fail_at: TerminalAction,
+ actions: Vec,
+ }
+
+ impl MockTerminalBackend {
+ fn new(fail_at: TerminalAction) -> Self {
+ Self {
+ fail_at,
+ actions: Vec::new(),
+ }
+ }
+
+ fn record(&mut self, action: TerminalAction) -> io::Result<()> {
+ self.actions.push(action);
+ if action == self.fail_at {
+ Err(io::Error::other("injected terminal failure"))
+ } else {
+ Ok(())
+ }
+ }
+ }
+
+ impl TerminalBackend for MockTerminalBackend {
+ fn enable_raw(&mut self) -> io::Result<()> {
+ self.record(TerminalAction::EnableRaw)
+ }
+
+ fn enter_alternate(&mut self) -> io::Result<()> {
+ self.record(TerminalAction::EnterAlternate)
+ }
+
+ fn hide_cursor(&mut self) -> io::Result<()> {
+ self.record(TerminalAction::HideCursor)
+ }
+
+ fn prepare_screen(&mut self) -> io::Result<()> {
+ self.record(TerminalAction::PrepareScreen)
+ }
+
+ fn show_cursor(&mut self) -> io::Result<()> {
+ self.record(TerminalAction::ShowCursor)
+ }
+
+ fn leave_alternate(&mut self) -> io::Result<()> {
+ self.record(TerminalAction::LeaveAlternate)
+ }
+
+ fn disable_raw(&mut self) -> io::Result<()> {
+ self.record(TerminalAction::DisableRaw)
+ }
+ }
+
+ #[test]
+ fn enter_alternate_mutation_followed_by_error_still_attempts_reverse_cleanup() {
+ let mut backend = MockTerminalBackend::new(TerminalAction::EnterAlternate);
+
+ assert!(setup_terminal(&mut backend).is_err());
+
+ assert_eq!(
+ backend.actions,
+ vec![
+ TerminalAction::EnableRaw,
+ TerminalAction::EnterAlternate,
+ TerminalAction::LeaveAlternate,
+ TerminalAction::DisableRaw,
+ ]
+ );
+ }
+
+ #[test]
+ fn hide_cursor_mutation_followed_by_error_still_attempts_reverse_cleanup() {
+ let mut backend = MockTerminalBackend::new(TerminalAction::HideCursor);
+
+ assert!(setup_terminal(&mut backend).is_err());
+
+ assert_eq!(
+ backend.actions,
+ vec![
+ TerminalAction::EnableRaw,
+ TerminalAction::EnterAlternate,
+ TerminalAction::HideCursor,
+ TerminalAction::ShowCursor,
+ TerminalAction::LeaveAlternate,
+ TerminalAction::DisableRaw,
+ ]
+ );
+ }
+
+ #[test]
+ fn registration_chunks_base64_on_aligned_4096_character_boundaries() {
+ let encoded = "A".repeat(4096 * 2 + 8);
+
+ let chunks = encode_registration(BITMAP_ID, &encoded);
+
+ assert_eq!(chunks.len(), 3);
+ for (index, chunk) in chunks.iter().enumerate() {
+ let command =
+ std::str::from_utf8(chunk).expect("valid example test input should succeed");
+ let payload = command
+ .strip_suffix(APC_END)
+ .expect("valid example test input should succeed")
+ .rsplit_once(';')
+ .expect("valid example test input should succeed")
+ .1;
+ assert!(payload.len() <= 4096);
+ assert_eq!(payload.len() % 4, 0);
+ assert_eq!(command.contains("fmt=png;source=payload"), index == 0);
+ assert_eq!(command.contains("more=0"), index == 2);
+ }
+ }
+
+ #[test]
+ fn lifecycle_registers_and_places_once_then_only_updates_before_deletion() {
+ let mut commands = encode_registration(BITMAP_ID, "QUJDRA==");
+ commands.push(encode_placement(
+ BITMAP_ID,
+ PLACEMENT_ID,
+ Destination::new(2, 1, 80, 24),
+ ));
+ let mut view = ViewState::new(640, 480);
+ view.zoom(Zoom::In);
+ commands.push(encode_update(PLACEMENT_ID, view));
+ view.pan(16, 8);
+ commands.push(encode_update(PLACEMENT_ID, view));
+ view.cycle_fit();
+ commands.push(encode_update(PLACEMENT_ID, view));
+ view.filter = Filter::Nearest;
+ commands.push(encode_update(PLACEMENT_ID, view));
+ view.opacity = 0.5;
+ commands.push(encode_update(PLACEMENT_ID, view));
+ commands.extend(encode_deletion(BITMAP_ID, PLACEMENT_ID));
+
+ let commands: Vec<_> = commands
+ .iter()
+ .map(|command| {
+ std::str::from_utf8(command).expect("valid example test input should succeed")
+ })
+ .collect();
+ assert_eq!(
+ commands
+ .iter()
+ .filter(|command| command.contains(";r;id=") && command.contains("fmt=png"))
+ .count(),
+ 1
+ );
+ assert_eq!(
+ commands
+ .iter()
+ .filter(|command| command.contains(";p;"))
+ .count(),
+ 1
+ );
+ assert!(
+ commands[2..commands.len() - 2]
+ .iter()
+ .all(|command| command.contains(";u;pid="))
+ );
+ assert!(commands[2].contains("src_w=320;src_h=240"));
+ assert!(commands[3].contains("src_x=176;src_y=128"));
+ assert!(commands[4].contains("fit=cover"));
+ assert!(commands[5].contains("filter=nearest"));
+ assert!(commands[6].contains("opacity=0.500"));
+ assert!(commands[7].contains(";d;pid=7"));
+ assert!(commands[8].contains(";d;id=42"));
+ }
+}
diff --git a/protocols/bitmap.md b/protocols/bitmap.md
new file mode 100644
index 0000000..27a4880
--- /dev/null
+++ b/protocols/bitmap.md
@@ -0,0 +1,392 @@
+# Ratty Bitmap Surface Protocol
+
+Ratty Bitmap Surface is a terminal protocol for registering 2D bitmap assets,
+placing them in terminal cell space, changing placement and crop properties
+without re-uploading pixels, and replacing live pixels without changing the
+bitmap identity.
+
+Version 1 uses the `ratty;i` APC namespace. It supports PNG registration and
+full-frame RGBA8 replacement only.
+
+## Transport and framing
+
+Commands use APC (Application Program Command) framing:
+
+```text
+ESC _ ratty;i;[;...][;] ESC \
+```
+
+Both the two-byte `ESC \` string terminator and the single-byte C1 ST
+terminator are accepted. Header fields are semicolon-separated. A command with
+a payload places its base64 data after the header fields as the final
+semicolon-separated item.
+
+Each individual `r` or `f` APC chunk may decode to at most 64 MiB. Ratty
+preflights the encoded payload length before base64 decoding. The v1 encoded
+APC bound is derived as
+`len(ESC _ ratty;i;) + 4096 + 4 * ceil(64 MiB / 3) + len(ESC \)`: 4096 bytes
+are reserved for the verb, fields, and separators, and the two-byte terminator
+is the larger accepted terminator. The verb, fields, and separators may occupy
+at most those 4096 bytes. For `r` and `f`, the header extends through the final
+semicolon that separates the payload, so semicolon runs in an alleged payload
+cannot bypass the header limit. A client must split a transfer before any
+individual command reaches the complete APC bound.
+
+If an unterminated bitmap APC reaches the encoded bound, Ratty discards bytes
+through the next `ESC \` or C1 ST without retaining or displaying them, then
+resumes normal terminal parsing after the terminator. This bound applies only
+to the `ratty;i` namespace; it does not change RGP or Kitty limits.
+
+Ratty also applies configurable resource limits before PNG decoding and while
+retaining protocol state. The distributed defaults are:
+
+- maximum registered bitmaps: 1024
+- maximum active placements: 4096
+- maximum image width and height: 8192 pixels each
+- maximum decoded RGBA8 bytes per bitmap: 64 MiB
+- maximum decoded RGBA8 bytes across all registered bitmaps: 256 MiB
+- maximum decoded payload bytes across incomplete registrations and frames: 64 MiB
+- maximum concurrent incomplete registrations and frames: 16
+
+PNG decoding receives the configured dimension and allocation limits directly.
+Ratty also calculates `width * height * 4` with checked arithmetic before
+accepting a decoded bitmap. Exceeding any limit rejects the operation instead
+of evicting unrelated existing bitmaps or transfers. Registered-byte accounting
+remains active after pixel data is uploaded to the GPU and is released when the
+bitmap is deleted.
+
+Bitmap IDs, placement IDs, sequence numbers, source coordinates, and dimensions
+are unsigned decimal `u32` values on the wire. Destination `row` and `col` are
+unsigned decimal values limited to the `u16` range `0..=65535`. A bitmap ID is
+written as `id`; a placement ID is written as `pid`. Bitmap and placement IDs
+each belong to a single global namespace of their kind. Destination `w` and
+`h`, source `src_w` and `src_h`, and frame `w` and `h` must be nonzero; IDs,
+coordinates, and sequence numbers have no additional nonzero constraint.
+Opacity is a finite decimal floating-point value whose effective value is in
+`[0,1]`; finite input outside that range is clamped to the nearest endpoint.
+
+The verbs are:
+
+- `s`: query support
+- `r`: register a bitmap
+- `p`: create a placement
+- `u`: update a placement
+- `f`: replace a bitmap frame
+- `d`: delete a placement or bitmap
+
+## Support discovery
+
+A client queries support with:
+
+```text
+ESC _ ratty;i;s ESC \
+```
+
+Ratty replies with exactly:
+
+```text
+ESC _ ratty;i;s;v=1;fmt=png;frame=rgba8;payload=1;chunk=1;placement=1;crop=1;fit=contain|cover|fill;filter=nearest|linear;opacity=1 ESC \
+```
+
+The reply advertises protocol version 1, PNG payload registration, RGBA8 frame
+replacement, chunked transfers, independently addressable placements, source
+cropping, the three fit modes, the two filter modes, and opacity. If no reply
+arrives, the client must assume that Ratty Bitmap Surface is unsupported.
+Support queries are the only version 1 commands that produce a reply.
+
+## Coordinate systems and placement model
+
+A registered bitmap owns one pixel image and one stable bitmap identity. It may
+have multiple placements, and each placement has a globally unique `pid`.
+Registration and frame replacement operate on the shared bitmap; placement and
+update commands operate on one placement.
+
+Destination `row`, `col`, `w`, and `h` are measured in terminal cells.
+`row,col` is the top-left placement anchor, `w` is the number of columns, and
+`h` is the number of rows. Source `src_x`, `src_y`, `src_w`, and `src_h` are
+measured in source pixels from the bitmap's top-left origin.
+
+When no source rectangle is specified, the full bitmap is used. A supplied
+source rectangle is clamped to the bitmap bounds. The command is rejected if
+the clamped intersection is empty. Source and destination widths and heights
+must be nonzero.
+
+### Terminal attachment and mutation rules
+
+Wire `row,col` values are interpreted as visible-cell coordinates on the
+active screen at the exact point where the placement or row update occurs in
+the PTY stream. Ratty converts that row to rio-vt's signed absolute grid space;
+the placement is then attached to terminal content rather than to a fixed
+viewport pixel. Normal terminal bytes before and after an APC in the same PTY
+read therefore affect it in wire order.
+
+Each globally unique `pid` is owned by exactly one screen at a time. A new
+placement, or an update containing `row`, attaches it to the currently active
+main or alternate screen. A `col`, span, crop, fit, filter, or opacity-only
+update retains the placement's existing screen and absolute row. Main and
+alternate placement sets are isolated: switching screens renders only the
+active set, and entering a fresh 1049 alternate screen clears old alternate
+placements without clearing main-screen placements. A placement ID is still
+global, so explicitly moving the same `pid` on the other screen transfers its
+ownership rather than duplicating it.
+
+Content-attached placements follow the grid mutation that owns their rows:
+
+- Full-screen upward scroll caused by LF, IND, or SU carries the placement into
+ retained main-screen history. It reappears when the user views that history
+ and expires only when its entire remaining row range has been evicted from
+ the history ring. Downward RI and SD move placements with their content.
+- With DECSTBM/page margins, a placement moves only when its entire current row
+ range is inside the affected scrolling region. A placement wholly outside
+ the region, or crossing either margin before the mutation, stays fixed.
+ When a moved placement crosses the top or bottom margin, the escaped rows are
+ clipped and its source-row offset advances as needed. If no rows remain, the
+ placement expires.
+- IL and DL apply the same rule to the cursor-to-bottom subregion: wholly
+ contained placements move down or up with inserted or deleted lines, while
+ outside or margin-crossing placements remain fixed. Counts are clamped to
+ the affected region; a move may clip or expire the placement completely.
+- A top-anchored partial region can grow history while rows below its bottom
+ margin remain visually fixed. rio-vt adjusts absolute row space at the grid
+ mutation boundary, so those fixed placements do not drift.
+- Resize and reflow remap a content-attached placement's top row through the
+ same row mapping as terminal text. Viewport geometry and clipping are then
+ recomputed for the new dimensions. A placement whose anchor content is
+ dropped by truncation expires.
+
+Ordinary text writes, overwrites, EL, ED, and other text erasure do not delete
+bitmap placements; bitmap placement and pixel lifetime are controlled by the
+`d` verb. Clearing retained history can nevertheless expire a placement whose
+entire row range is discarded. A full terminal reset (RIS) clears placement
+records on both screens but leaves no stale renderer entity; registered bitmap
+pixels remain available until bitmap deletion or process teardown.
+
+## Register bitmap (`r`)
+
+Registration carries a base64-encoded PNG payload. A one-chunk registration is:
+
+```text
+ESC _ ratty;i;r;id=42;fmt=png;source=payload;more=0; ESC \
+```
+
+The first chunk requires:
+
+- `id`: the bitmap ID
+- `fmt=png`: the version 1 registration format
+- `source=payload`: the version 1 registration source
+- `more`: `1` when more chunks follow or `0` on the final chunk
+- a base64 payload item
+
+`name` is optional diagnostic metadata. For a multi-chunk registration, later
+chunks use the same `id`, include `more`, and carry the next base64 payload
+item. `fmt`, `source`, and `name` may be repeated after the first chunk only
+when their values exactly match the first chunk.
+
+```text
+ESC _ ratty;i;r;id=42;fmt=png;source=payload;more=1;name=photo.png; ESC \
+ESC _ ratty;i;r;id=42;more=1; ESC \
+ESC _ ratty;i;r;id=42;more=0; ESC \
+```
+
+After the per-chunk size preflight, Ratty base64-decodes each chunk and retains
+decoded bytes while `more=1`.
+`more=0` finalizes the transfer: Ratty decodes the accumulated PNG exactly
+once, creates the bitmap, and clears the pending transfer. The bitmap becomes
+visible to placement commands only after successful finalization.
+
+A pending registration may contain at most 64 MiB of decoded payload bytes,
+subject to the configured aggregate pending-byte and transfer-count budgets. If
+a chunk would exceed a limit, Ratty rejects the chunk and discards the affected
+pending registration. An invalid PNG on finalization also clears the pending
+transfer and does not register a bitmap. Other malformed chunks make no state
+change.
+
+Registering an `id` that is already registered or would exceed the configured
+global bitmap limit is rejected and never replaces the existing bitmap or its
+placements. Deleting a registered bitmap releases its bitmap-count slot.
+
+## Place bitmap (`p`)
+
+A placement refers to an already registered bitmap and receives its own
+globally unique placement ID:
+
+```text
+ESC _ ratty;i;p;id=42;pid=7;row=4;col=2;w=80;h=30;fit=contain;filter=linear;opacity=1 ESC \
+```
+
+Required fields are `id`, `pid`, `row`, `col`, `w`, and `h`. The destination
+dimensions must be nonzero. The optional placement fields are:
+
+- the complete `src_x`, `src_y`, `src_w`, `src_h` source rectangle
+- `fit=contain|cover|fill`, default `contain`
+- `filter=nearest|linear`, default `linear`
+- `opacity`, default `1`
+
+A source rectangle, when present, must contain all four source fields. Opacity
+must be finite and is clamped to the inclusive range `[0,1]`.
+
+Placement fails without mutation when the bitmap does not exist, `pid` is
+already in use, or the configured global placement limit has been reached.
+Multiple distinct placements may share a bitmap within that limit.
+
+## Update placement (`u`)
+
+An update changes an existing placement without re-registering the bitmap or
+changing its bitmap ID:
+
+```text
+ESC _ ratty;i;u;pid=7;src_x=300;src_y=120;src_w=900;src_h=600 ESC \
+```
+
+`pid` and at least one mutable field are required. Mutable fields are:
+
+- `row` or `col`, independently
+- `w` and `h`, as a complete pair
+- `src_x`, `src_y`, `src_w`, and `src_h`, as a complete quartet
+- `fit`
+- `filter`
+- `opacity`
+
+Updated destination dimensions must be nonzero. Updated source rectangles use
+the same clamping and nonempty-intersection rules as placement. Updated opacity
+must be finite and is clamped to `[0,1]`.
+
+Updates are transactional. A partial `w/h` pair, partial source quartet,
+unknown placement, invalid value, or failed validation rejects the whole
+command and leaves the placement unchanged.
+
+When `row` is present, its new value is resolved in visible coordinates on the
+active screen and moves the placement there. Without `row`, the update retains
+the terminal-owned absolute anchor and screen even if `col`, `w`, or `h`
+changes.
+
+## Replace frame (`f`)
+
+Frame replacement changes the pixels of a registered bitmap while retaining
+its identity and every placement:
+
+```text
+ESC _ ratty;i;f;id=42;seq=123;fmt=rgba8;w=1280;h=720;more=0; ESC \
+```
+
+The first chunk requires `id`, `seq`, `fmt=rgba8`, `w`, `h`, `more`, and a
+base64 payload item. The dimensions must exactly match the registered bitmap's
+dimensions. Continuation chunks require `id`, `seq`, `more`, and the next
+payload item. If `fmt`, `w`, or `h` is repeated on a continuation, it must
+exactly match the first chunk.
+
+Chunks are assembled by `(id, seq)`. `seq` is mandatory and must increase for
+each bitmap. When a newer sequence begins, Ratty discards any incomplete older
+sequence for that bitmap. A stale sequence never replaces displayed pixels.
+
+Ratty calculates the expected byte length as `w * h * 4` using checked
+arithmetic. Arithmetic overflow rejects the frame. If accumulated decoded data
+ever exceeds that expected length, Ratty immediately rejects the chunk and
+discards the affected pending `(id, seq)` frame instead of retaining excess
+data.
+
+On the final `more=0` chunk, the decoded payload length must equal the expected
+byte length. Pixels are tightly packed RGBA8 in row-major order from the
+top-left. After all validation succeeds, Ratty atomically replaces the pixels
+of the existing bitmap. Its bitmap ID, underlying image handle, dimensions,
+and all placements remain unchanged.
+
+Invalid base64, inconsistent continuation metadata, an oversized accumulated
+payload, or a final length mismatch discards the affected pending `(id, seq)`
+frame. Missing metadata, zero or mismatched dimensions, arithmetic overflow,
+stale sequencing, or any other malformed frame is rejected. Every frame error
+preserves the last valid displayed pixels and all placements.
+
+## Delete (`d`)
+
+Delete one placement with:
+
+```text
+ESC _ ratty;i;d;pid=7 ESC \
+```
+
+Delete one bitmap with:
+
+```text
+ESC _ ratty;i;d;id=42 ESC \
+```
+
+Exactly one of `pid` or `id` is required. A command with neither ID or both IDs
+is malformed and does not delete anything. Deleting an unknown placement or
+bitmap is an idempotent no-op. In particular, deleting an unknown bitmap ID
+does not cancel a pending registration for that ID. Bitmap deletion cascades
+only when the bitmap is already registered.
+
+Deleting a placement does not affect its bitmap or sibling placements.
+Deleting a bitmap atomically deletes all placements that refer to it.
+
+## Fit and filtering rules
+
+Fit is resolved from the selected source rectangle into the destination:
+
+- `fill`: map the full source rectangle to the full destination; aspect ratio
+ may change.
+- `contain`: preserve aspect ratio, center the image, and leave transparent
+ letterboxing in the unused destination area.
+- `cover`: preserve aspect ratio and fill the destination by applying a
+ symmetric crop to the source.
+
+`nearest` selects nearest-neighbor sampling. `linear` selects linear sampling.
+Filtering belongs to the placement, so placements that share a bitmap may use
+different filter modes.
+
+## Errors and mutation rules
+
+Ratty consumes commands in the `ratty;i` namespace even when they are
+malformed or unsupported. It logs a warning and sends no error reply. A
+malformed or unsupported command makes no state change, except that a failed or
+overflowed pending transfer is discarded as described above.
+
+Unknown verbs do nothing. Duplicate keys, unsupported values, missing required
+fields, invalid base64, and invalid numeric values are malformed. Only a valid
+support query generates output.
+
+Registration, placement, and frame state are separate. Placement changes never
+create or replace bitmap pixels. Frame changes never alter placement records.
+No version 1 operation accepts filesystem paths, compressed image formats other
+than registration PNG, dirty rectangles, codecs, or network sources.
+
+## Complete example
+
+Query support:
+
+```text
+ESC _ ratty;i;s ESC \
+ESC _ ratty;i;s;v=1;fmt=png;frame=rgba8;payload=1;chunk=1;placement=1;crop=1;fit=contain|cover|fill;filter=nearest|linear;opacity=1 ESC \
+```
+
+Register a PNG bitmap, potentially using repeated chunks with the same ID:
+
+```text
+ESC _ ratty;i;r;id=42;fmt=png;source=payload;more=0; ESC \
+```
+
+Place it in terminal cell space:
+
+```text
+ESC _ ratty;i;p;id=42;pid=7;row=4;col=2;w=80;h=30;fit=contain;filter=linear;opacity=1 ESC \
+```
+
+Change the placement's source crop without uploading the PNG again:
+
+```text
+ESC _ ratty;i;u;pid=7;src_x=300;src_y=120;src_w=900;src_h=600 ESC \
+```
+
+Replace the bitmap's pixels with a sequenced, fixed-dimension RGBA8 frame:
+
+```text
+ESC _ ratty;i;f;id=42;seq=123;fmt=rgba8;w=1280;h=720;more=0; ESC \
+```
+
+Delete the placement and then the bitmap:
+
+```text
+ESC _ ratty;i;d;pid=7 ESC \
+ESC _ ratty;i;d;id=42 ESC \
+```
diff --git a/protocols/graphics.md b/protocols/graphics.md
index fffbcb9..bfe805a 100644
--- a/protocols/graphics.md
+++ b/protocols/graphics.md
@@ -3,6 +3,9 @@
Ratty Graphics Protocol (RGP) is a custom terminal protocol for inserting
3D objects into the terminal as first-class inline objects.
+The `ratty;g` namespace is 3D and object-oriented. The separate `ratty;i`
+namespace is 2D and bitmap/texture-oriented.
+
The goal is to attach a semantic graphics object to terminal cells,
so it becomes part of the terminal surface rather than an external overlay.
@@ -145,7 +148,7 @@ Fields:
- `more`: continuation flag
- `1`: more register chunks follow for this object id
- `0`: this is the final chunk and registration can be finalized
-- `name`: optional source name for diagnostics and temporary asset naming
+- `name`: optional source name for diagnostics
- `normalize`: optional OBJ normalization flag on the first payload chunk, defaults to `1`
The terminal accumulates chunks for the same `id` until it receives the final
@@ -155,6 +158,22 @@ normally.
Path-based and payload-based registration are additive modes of the same `r` verb.
Clients may continue using `path=...` exactly as before.
+RGP registrations share the configured bitmap safety budgets: one encoded
+source is limited by `max_bitmap_bytes`, registered object count by
+`max_bitmaps`, total retained encoded/decoded mesh bytes by
+`max_total_bitmap_bytes`, and incomplete payloads by the pending byte,
+transfer-count, and ten-second inactivity limits. Path sources are resolved to
+canonical regular files and read through the same per-object byte limit;
+devices, FIFOs, directories, and oversized files are rejected. RGP GLB is
+restricted to exactly one self-contained BIN buffer, scene, node, mesh, and
+triangle primitive with an identity node transform, explicit float POSITION
+and NORMAL attributes, and optional U16/U32 triangle indices. Materials,
+nested nodes, animations, skins, cameras, morph targets, extensions, external
+buffers, images, samplers, and textures are rejected. JSON `.gltf` is
+rejected. The GLB JSON DOM, accessors, and render copies are bounded before
+parsing; accepted geometry is synchronously decoded into Ratty-owned mesh data
+from the exact validated bytes, without a mutable runtime asset path.
+
### 3. Place Object
Places a previously registered object into terminal cell space.
@@ -183,6 +202,44 @@ Fields:
Clients that only send the original v1 fields still work unchanged.
+#### Terminal attachment and mutation rules
+
+RGP's `row,col` is a center anchor in visible cell coordinates on the active
+screen at the exact point where the `p` APC occurs. Ratty derives the
+placement's top-left cell from that center and span, then registers a signed
+absolute, content-attached placement with rio-vt. Terminal text and RGP APCs
+within one PTY read are applied strictly in wire order.
+
+An object has at most one placed instance and one owning screen. Placing the
+same object ID again transfers its placement to the active main or alternate
+screen; it does not duplicate the object. Different object IDs may remain on
+their independently owned screens. Entering a fresh 1049 alternate screen
+clears stale alternate placements while preserving main-screen placements and
+registered object assets.
+
+The terminal, not Ratty's RGP parser, mutates the anchor:
+
+- Full-screen LF/IND/SU and RI/SD move it with content. Main-screen objects can
+ enter retained scrollback, reappear during scrollback navigation, and expire
+ only after their complete remaining row range is evicted.
+- Under DECSTBM/page margins, only an object placement wholly contained in the
+ affected region moves. Outside and pre-mutation margin-crossing placements
+ stay fixed. A contained placement's terminal-owned visible/source geometry
+ is clipped when movement carries rows past a margin and expires if no rows
+ remain. Ratty preserves the original 3D transform for a partial result; exact
+ fragment clipping at an interior margin is not yet implemented, so model
+ fragments can overdraw that margin until the placement fully expires.
+- IL/DL use the same rule for their cursor-to-bottom mutation region, including
+ clipped or expired results for large counts.
+- Resize and reflow map the anchor row through rio-vt's text reflow mapping and
+ recompute visible geometry. A dropped anchor expires.
+
+Text writes, overwrites, and erase commands do not delete an RGP placement.
+The `d` verb deletes it deliberately. RIS clears placements on both screens;
+the registered asset can be placed again unless it is deleted through RGP.
+The `u` verb changes only RGP presentation style and never moves the
+terminal-owned anchor.
+
### 4. Update Object
Updates the styling or transform of a previously placed object without changing
diff --git a/src/bitmap.rs b/src/bitmap.rs
new file mode 100644
index 0000000..e0bb97a
--- /dev/null
+++ b/src/bitmap.rs
@@ -0,0 +1,2680 @@
+//! Ratty Bitmap Surface protocol parsing.
+
+use std::{
+ collections::HashMap,
+ fmt,
+ io::Cursor,
+ time::{Duration, Instant},
+};
+
+use base64::Engine as _;
+use bevy::prelude::{Handle, Image};
+
+use crate::config::BitmapConfig;
+
+/// Ratty Bitmap Surface APC prefix.
+pub const BITMAP_APC_START: &[u8] = b"\x1b_ratty;i;";
+const ST: &[u8] = b"\x1b\\";
+const C1_ST: u8 = 0x9c;
+const SUPPORT_REPLY: &[u8] = b"\x1b_ratty;i;s;v=1;fmt=png;frame=rgba8;payload=1;chunk=1;placement=1;crop=1;fit=contain|cover|fill;filter=nearest|linear;opacity=1\x1b\\";
+pub(crate) const MAX_BITMAP_CHUNK_DECODED_BYTES: usize = 64 * 1024 * 1024;
+pub(crate) const BITMAP_APC_HEADER_ALLOWANCE: usize = 4 * 1024;
+const MAX_BITMAP_CHUNK_BASE64_BYTES: usize = MAX_BITMAP_CHUNK_DECODED_BYTES.div_ceil(3) * 4;
+pub(crate) const MAX_BITMAP_APC_BYTES: usize =
+ BITMAP_APC_START.len() + BITMAP_APC_HEADER_ALLOWANCE + MAX_BITMAP_CHUNK_BASE64_BYTES + ST.len();
+const MAX_REGISTRATION_BYTES: usize = 64 * 1024 * 1024;
+const BITMAP_INCOMPLETE_TIMEOUT: Duration = Duration::from_secs(10);
+const CHUNK_PAYLOAD_TOO_LARGE: &str = "bitmap APC chunk payload exceeds 64 MiB";
+const HEADER_TOO_LARGE: &str = "bitmap APC header exceeds 4 KiB";
+type BitmapConsumeResult = Option>), BitmapProtocolError>>;
+
+/// Returns the largest base64 representation of a decoded byte budget.
+pub(crate) fn max_base64_encoded_bytes(max_decoded_bytes: u64) -> usize {
+ usize::try_from(max_decoded_bytes)
+ .unwrap_or(usize::MAX)
+ .checked_add(2)
+ .map(|bytes| bytes / 3)
+ .and_then(|groups| groups.checked_mul(4))
+ .unwrap_or(usize::MAX)
+}
+
+/// How source pixels are fitted into a placement's destination rectangle.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum BitmapFit {
+ /// Preserve aspect ratio and letterbox the unused destination area.
+ Contain,
+ /// Preserve aspect ratio and crop symmetrically to fill the destination.
+ Cover,
+ /// Stretch the source to fill the destination.
+ Fill,
+}
+
+/// Texture filtering for a bitmap placement.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum BitmapFilter {
+ /// Select the nearest source pixel.
+ Nearest,
+ /// Interpolate between neighboring source pixels.
+ Linear,
+}
+
+/// A rectangle in source-pixel coordinates.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub struct SourceRect {
+ /// Horizontal offset from the bitmap's left edge.
+ pub x: u32,
+ /// Vertical offset from the bitmap's top edge.
+ pub y: u32,
+ /// Source width in pixels.
+ pub width: u32,
+ /// Source height in pixels.
+ pub height: u32,
+}
+
+/// One decoded chunk of a bitmap registration transfer.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct BitmapRegisterChunk {
+ /// Bitmap identifier.
+ pub bitmap_id: u32,
+ /// Registration format metadata, present on the first chunk.
+ pub format: Option,
+ /// Registration source metadata, present on the first chunk.
+ pub source: Option,
+ /// Optional diagnostic payload name.
+ pub name: Option,
+ /// Whether additional chunks follow.
+ pub more: bool,
+ /// Decoded payload bytes for this chunk.
+ pub data: Vec,
+}
+
+/// A complete bitmap placement request.
+#[derive(Clone, Debug, PartialEq)]
+pub struct BitmapPlacement {
+ /// Bitmap identifier.
+ pub bitmap_id: u32,
+ /// Globally unique placement identifier.
+ pub placement_id: u32,
+ /// Destination row in terminal cells.
+ pub row: u16,
+ /// Destination column in terminal cells.
+ pub col: u16,
+ /// Destination width in terminal cells.
+ pub columns: u32,
+ /// Destination height in terminal cells.
+ pub rows: u32,
+ /// Optional source-pixel crop.
+ pub source: Option,
+ /// Fit mode.
+ pub fit: BitmapFit,
+ /// Filtering mode.
+ pub filter: BitmapFilter,
+ /// Clamped placement opacity.
+ pub opacity: f32,
+}
+
+/// Transactional changes to an existing bitmap placement.
+#[derive(Clone, Debug, Default, PartialEq)]
+pub struct BitmapPlacementUpdate {
+ /// Optional destination row.
+ pub row: Option,
+ /// Optional destination column.
+ pub col: Option,
+ /// Optional destination width, paired with `rows`.
+ pub columns: Option,
+ /// Optional destination height, paired with `columns`.
+ pub rows: Option,
+ /// Optional complete source-pixel crop.
+ pub source: Option,
+ /// Optional fit mode.
+ pub fit: Option,
+ /// Optional filtering mode.
+ pub filter: Option,
+ /// Optional clamped opacity.
+ pub opacity: Option,
+}
+
+/// One decoded chunk of a sequenced RGBA8 frame transfer.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct BitmapFrameChunk {
+ /// Bitmap identifier.
+ pub bitmap_id: u32,
+ /// Per-bitmap frame sequence number.
+ pub sequence: u32,
+ /// Frame format metadata, present on the first chunk.
+ pub format: Option,
+ /// Frame width metadata, present on the first chunk.
+ pub width: Option,
+ /// Frame height metadata, present on the first chunk.
+ pub height: Option,
+ /// Whether additional chunks follow.
+ pub more: bool,
+ /// Decoded RGBA8 payload bytes for this chunk.
+ pub data: Vec,
+}
+
+/// A parsed Ratty Bitmap Surface operation.
+#[derive(Clone, Debug, PartialEq)]
+pub enum BitmapOperation {
+ /// Query protocol support.
+ SupportQuery,
+ /// Register a PNG bitmap transfer chunk.
+ Register(BitmapRegisterChunk),
+ /// Create a placement.
+ Place(BitmapPlacement),
+ /// Update an existing placement.
+ Update {
+ /// Placement identifier.
+ placement_id: u32,
+ /// Fields to update transactionally.
+ update: BitmapPlacementUpdate,
+ },
+ /// Replace bitmap pixels with a frame transfer chunk.
+ Frame(BitmapFrameChunk),
+ /// Delete one placement.
+ DeletePlacement(u32),
+ /// Delete one bitmap and its placements.
+ DeleteBitmap(u32),
+ /// Consume an unknown protocol verb without mutation.
+ Ignored,
+}
+
+/// An error in a command within the Ratty Bitmap Surface namespace.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct BitmapProtocolError {
+ message: &'static str,
+ cleanup: Option,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+enum BitmapErrorCleanup {
+ DiscardPendingRegistration {
+ bitmap_id: u32,
+ },
+ DiscardPendingFrame {
+ bitmap_id: u32,
+ sequence: u32,
+ format: Option,
+ width: Option,
+ height: Option,
+ },
+}
+
+impl BitmapProtocolError {
+ fn new(message: &'static str) -> Self {
+ Self {
+ message,
+ cleanup: None,
+ }
+ }
+
+ fn frame_payload(
+ message: &'static str,
+ bitmap_id: u32,
+ sequence: u32,
+ format: Option,
+ width: Option,
+ height: Option,
+ ) -> Self {
+ Self {
+ message,
+ cleanup: Some(BitmapErrorCleanup::DiscardPendingFrame {
+ bitmap_id,
+ sequence,
+ format,
+ width,
+ height,
+ }),
+ }
+ }
+
+ fn registration_payload(message: &'static str, bitmap_id: u32) -> Self {
+ Self {
+ message,
+ cleanup: Some(BitmapErrorCleanup::DiscardPendingRegistration { bitmap_id }),
+ }
+ }
+}
+
+impl fmt::Display for BitmapProtocolError {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter.write_str(self.message)
+ }
+}
+
+impl std::error::Error for BitmapProtocolError {}
+
+type Fields<'a> = HashMap<&'a str, &'a str>;
+
+/// Consumes a complete Ratty Bitmap Surface APC sequence.
+pub fn consume_sequence(sequence: &[u8]) -> Option> {
+ if !sequence.starts_with(BITMAP_APC_START) {
+ return None;
+ }
+
+ Some(parse_sequence(sequence))
+}
+
+/// Returns the exact v1 support-discovery response.
+pub fn support_reply() -> Vec {
+ SUPPORT_REPLY.to_vec()
+}
+
+fn parse_sequence(sequence: &[u8]) -> Result {
+ parse_sequence_with_payload_limit(sequence, MAX_BITMAP_CHUNK_DECODED_BYTES)
+}
+
+fn parse_sequence_with_payload_limit(
+ sequence: &[u8],
+ payload_limit: usize,
+) -> Result {
+ parse_sequence_with_limits(sequence, payload_limit, BITMAP_APC_HEADER_ALLOWANCE)
+}
+
+fn parse_sequence_with_limits(
+ sequence: &[u8],
+ payload_limit: usize,
+ header_limit: usize,
+) -> Result {
+ let content_end = if sequence.ends_with(&[C1_ST]) {
+ sequence.len() - 1
+ } else if sequence.ends_with(ST) {
+ sequence.len() - ST.len()
+ } else {
+ return Err(BitmapProtocolError::new("invalid bitmap APC terminator"));
+ };
+ let content = std::str::from_utf8(&sequence[BITMAP_APC_START.len()..content_end])
+ .map_err(|_| BitmapProtocolError::new("bitmap APC is not valid UTF-8"))?;
+ if bitmap_header_extent(content) > header_limit {
+ return Err(BitmapProtocolError::new(HEADER_TOO_LARGE));
+ }
+ let mut parts: Vec<_> = content.split(';').collect();
+ let verb = parts
+ .first()
+ .copied()
+ .ok_or_else(|| BitmapProtocolError::new("missing bitmap verb"))?;
+ if verb.is_empty() {
+ return Err(BitmapProtocolError::new("missing bitmap verb"));
+ }
+ parts.remove(0);
+
+ match verb {
+ "s" => parse_support(&parts),
+ "r" => parse_register(&parts, payload_limit),
+ "p" => parse_place(&parts),
+ "u" => parse_update(&parts),
+ "f" => parse_frame(&parts, payload_limit),
+ "d" => parse_delete(&parts),
+ _ => Ok(BitmapOperation::Ignored),
+ }
+}
+
+fn bitmap_header_extent(content: &str) -> usize {
+ if content.starts_with("r;") || content.starts_with("f;") {
+ content
+ .rfind(';')
+ .map_or(content.len(), |separator| separator + 1)
+ } else {
+ content.len()
+ }
+}
+
+fn parse_support(parts: &[&str]) -> Result {
+ if parts.is_empty() {
+ Ok(BitmapOperation::SupportQuery)
+ } else {
+ Err(BitmapProtocolError::new(
+ "support query does not accept fields",
+ ))
+ }
+}
+
+fn parse_register(
+ parts: &[&str],
+ payload_limit: usize,
+) -> Result {
+ let (payload, header) = split_payload(parts)?;
+ let fields = parse_fields(header, &["id", "fmt", "source", "more", "name"])?;
+ let format = optional_string(&fields, "fmt");
+ let source = optional_string(&fields, "source");
+ if format.is_some() != source.is_some() {
+ return Err(BitmapProtocolError::new(
+ "registration format and source must be provided together",
+ ));
+ }
+ if format.as_deref().is_some_and(|value| value != "png") {
+ return Err(BitmapProtocolError::new("unsupported bitmap format"));
+ }
+ if source.as_deref().is_some_and(|value| value != "payload") {
+ return Err(BitmapProtocolError::new(
+ "unsupported bitmap registration source",
+ ));
+ }
+
+ let bitmap_id = required_u32(&fields, "id")?;
+ let data = decode_payload(payload, payload_limit).map_err(|error| {
+ if error.message == CHUNK_PAYLOAD_TOO_LARGE {
+ BitmapProtocolError::registration_payload(error.message, bitmap_id)
+ } else {
+ error
+ }
+ })?;
+
+ Ok(BitmapOperation::Register(BitmapRegisterChunk {
+ bitmap_id,
+ format,
+ source,
+ name: optional_string(&fields, "name"),
+ more: required_bool(&fields, "more")?,
+ data,
+ }))
+}
+
+fn parse_place(parts: &[&str]) -> Result {
+ let fields = parse_fields(
+ parts,
+ &[
+ "id", "pid", "row", "col", "w", "h", "src_x", "src_y", "src_w", "src_h", "fit",
+ "filter", "opacity",
+ ],
+ )?;
+ let columns = required_nonzero_u32(&fields, "w")?;
+ let rows = required_nonzero_u32(&fields, "h")?;
+
+ Ok(BitmapOperation::Place(BitmapPlacement {
+ bitmap_id: required_u32(&fields, "id")?,
+ placement_id: required_u32(&fields, "pid")?,
+ row: required_u16(&fields, "row")?,
+ col: required_u16(&fields, "col")?,
+ columns,
+ rows,
+ source: parse_source(&fields)?,
+ fit: parse_fit(fields.get("fit").copied())?.unwrap_or(BitmapFit::Contain),
+ filter: parse_filter(fields.get("filter").copied())?.unwrap_or(BitmapFilter::Linear),
+ opacity: parse_opacity(fields.get("opacity").copied())?.unwrap_or(1.0),
+ }))
+}
+
+fn parse_update(parts: &[&str]) -> Result {
+ let fields = parse_fields(
+ parts,
+ &[
+ "pid", "row", "col", "w", "h", "src_x", "src_y", "src_w", "src_h", "fit", "filter",
+ "opacity",
+ ],
+ )?;
+ let placement_id = required_u32(&fields, "pid")?;
+ let columns = optional_nonzero_u32(&fields, "w")?;
+ let rows = optional_nonzero_u32(&fields, "h")?;
+ if columns.is_some() != rows.is_some() {
+ return Err(BitmapProtocolError::new(
+ "update width and height must be provided together",
+ ));
+ }
+ let update = BitmapPlacementUpdate {
+ row: optional_u16(&fields, "row")?,
+ col: optional_u16(&fields, "col")?,
+ columns,
+ rows,
+ source: parse_source(&fields)?,
+ fit: parse_fit(fields.get("fit").copied())?,
+ filter: parse_filter(fields.get("filter").copied())?,
+ opacity: parse_opacity(fields.get("opacity").copied())?,
+ };
+ if update == BitmapPlacementUpdate::default() {
+ return Err(BitmapProtocolError::new(
+ "placement update contains no mutable fields",
+ ));
+ }
+
+ Ok(BitmapOperation::Update {
+ placement_id,
+ update,
+ })
+}
+
+fn parse_frame(
+ parts: &[&str],
+ payload_limit: usize,
+) -> Result {
+ let (payload, header) = split_payload(parts)?;
+ let fields = parse_fields(header, &["id", "seq", "fmt", "w", "h", "more"])?;
+ let format = optional_string(&fields, "fmt");
+ let width = optional_nonzero_u32(&fields, "w")?;
+ let height = optional_nonzero_u32(&fields, "h")?;
+ let metadata_count = usize::from(format.is_some())
+ + usize::from(width.is_some())
+ + usize::from(height.is_some());
+ if metadata_count != 0 && metadata_count != 3 {
+ return Err(BitmapProtocolError::new(
+ "frame format and dimensions must be provided together",
+ ));
+ }
+ if format.as_deref().is_some_and(|value| value != "rgba8") {
+ return Err(BitmapProtocolError::new("unsupported bitmap frame format"));
+ }
+ let bitmap_id = required_u32(&fields, "id")?;
+ let sequence = required_u32(&fields, "seq")?;
+ let more = required_bool(&fields, "more")?;
+ let data = decode_payload(payload, payload_limit).map_err(|error| {
+ BitmapProtocolError::frame_payload(
+ error.message,
+ bitmap_id,
+ sequence,
+ format.clone(),
+ width,
+ height,
+ )
+ })?;
+
+ Ok(BitmapOperation::Frame(BitmapFrameChunk {
+ bitmap_id,
+ sequence,
+ format,
+ width,
+ height,
+ more,
+ data,
+ }))
+}
+
+fn parse_delete(parts: &[&str]) -> Result {
+ let fields = parse_fields(parts, &["id", "pid"])?;
+ match (fields.get("id"), fields.get("pid")) {
+ (Some(id), None) => Ok(BitmapOperation::DeleteBitmap(parse_u32(id)?)),
+ (None, Some(placement_id)) => {
+ Ok(BitmapOperation::DeletePlacement(parse_u32(placement_id)?))
+ }
+ _ => Err(BitmapProtocolError::new(
+ "delete requires exactly one bitmap or placement ID",
+ )),
+ }
+}
+
+fn split_payload<'a>(
+ parts: &'a [&'a str],
+) -> Result<(&'a str, &'a [&'a str]), BitmapProtocolError> {
+ let (payload, header) = parts
+ .split_last()
+ .ok_or_else(|| BitmapProtocolError::new("missing bitmap payload"))?;
+ if payload.is_empty() {
+ return Err(BitmapProtocolError::new("missing bitmap payload"));
+ }
+ Ok((payload, header))
+}
+
+fn parse_fields<'a>(
+ parts: &'a [&'a str],
+ allowed: &[&str],
+) -> Result, BitmapProtocolError> {
+ let mut fields = HashMap::new();
+ for part in parts {
+ let (key, value) = part
+ .split_once('=')
+ .ok_or_else(|| BitmapProtocolError::new("malformed bitmap field"))?;
+ if !allowed.contains(&key) {
+ return Err(BitmapProtocolError::new("unknown bitmap field"));
+ }
+ if fields.insert(key, value).is_some() {
+ return Err(BitmapProtocolError::new("duplicate bitmap field"));
+ }
+ }
+ Ok(fields)
+}
+
+fn required_u32(fields: &Fields<'_>, key: &str) -> Result {
+ fields
+ .get(key)
+ .ok_or_else(|| BitmapProtocolError::new("missing required bitmap field"))
+ .and_then(|value| parse_u32(value))
+}
+
+fn parse_u32(value: &str) -> Result {
+ value
+ .parse()
+ .map_err(|_| BitmapProtocolError::new("invalid unsigned bitmap integer"))
+}
+
+fn required_nonzero_u32(fields: &Fields<'_>, key: &str) -> Result {
+ let value = required_u32(fields, key)?;
+ if value == 0 {
+ Err(BitmapProtocolError::new(
+ "bitmap dimensions must be nonzero",
+ ))
+ } else {
+ Ok(value)
+ }
+}
+
+fn optional_nonzero_u32(
+ fields: &Fields<'_>,
+ key: &str,
+) -> Result