tear_types/graphics.rs
1//! Inline-image payloads carried through the authority.
2//!
3//! ## The seam: the authority owns TRANSMISSION, the renderer owns PIXELS
4//!
5//! A terminal image arrives as an escape sequence carrying encoded bytes —
6//! a sixel stream, or a PNG / raw RGBA under the kitty protocol. Two
7//! separate facts come out of that, and conflating them is what makes this
8//! hard:
9//!
10//! 1. **What was transmitted, and where the cursor was.** That is terminal
11//! state. It belongs to the authority, exactly like a cell.
12//! 2. **What those bytes look like as pixels.** That is rendering. It needs
13//! a PNG decoder, a sixel decoder, a GPU texture.
14//!
15//! tear owns (1) and deliberately **not** (2). The payload is stored
16//! undecoded, so `tear-core` needs no `image` crate, no `icy_sixel`, and no
17//! GPU dependency — a daemon on a headless box carries images perfectly
18//! well without being able to draw one.
19//!
20//! This is what closes the last flip blocker in
21//! [`SHUKEN`](https://github.com/pleme-io/tear/blob/main/docs/SHUKEN.md).
22//! Before it, `GridState` implemented no `hook`/`put`/`unhook` and vte
23//! silently swallows APC in its `SosPmApcString` state, so **every sixel
24//! and every kitty image vanished with no error and no flag** — a renderer
25//! could not even know content had been dropped. Carrying them undecoded
26//! keeps mado's decoders exactly where they are while making the authority
27//! lossless.
28
29use serde::{Deserialize, Serialize};
30
31/// Largest payload accepted for one image.
32///
33/// Matches mado's `SIXEL_DCS_MAX` / `APC_MAX`. A terminal image is bounded
34/// by what a program can reasonably paint; anything past this is a runaway
35/// or hostile stream, and accepting it would let a child process drive the
36/// daemon out of memory.
37pub const GRAPHIC_PAYLOAD_MAX: usize = 8 * 1024 * 1024;
38
39/// Which protocol delivered a payload. The renderer needs this to pick a
40/// decoder; the authority only needs to record it faithfully.
41#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(rename_all = "snake_case")]
43pub enum GraphicProtocol {
44 /// DCS `q` — a sixel stream.
45 Sixel,
46 /// APC `G` — the kitty graphics protocol. `params` carries its
47 /// key/value prefix (`a=T,f=100,…`), which says whether the data is
48 /// PNG or raw RGBA, and how it is chunked.
49 Kitty,
50}
51
52/// One transmitted image, undecoded, with the cursor position it arrived
53/// at.
54#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
55pub struct Graphic {
56 pub protocol: GraphicProtocol,
57 /// Protocol parameters preceding the payload. Empty for sixel, whose
58 /// parameters are part of the stream itself.
59 pub params: String,
60 /// The payload exactly as transmitted. **Never decoded here** — see
61 /// the module docs.
62 pub data: Vec<u8>,
63 /// Cursor row when the sequence completed, so a renderer can place it
64 /// without re-deriving where the program thought it was.
65 pub at_row: usize,
66 /// Cursor column when the sequence completed.
67 pub at_col: usize,
68 /// True when the payload hit [`GRAPHIC_PAYLOAD_MAX`] and was cut.
69 ///
70 /// Recorded rather than dropped: a truncated image is a fact the
71 /// renderer must be able to SEE, because silently rendering a partial
72 /// image is worse than rendering none. This is the flag whose absence
73 /// made the old swallow-everything behaviour undiagnosable.
74 pub truncated: bool,
75}
76
77impl Graphic {
78 /// Bytes actually retained.
79 #[must_use]
80 pub fn len(&self) -> usize {
81 self.data.len()
82 }
83
84 #[must_use]
85 pub fn is_empty(&self) -> bool {
86 self.data.is_empty()
87 }
88}
89
90#[cfg(test)]
91mod tests {
92 use super::*;
93
94 #[test]
95 fn a_truncated_graphic_says_so_rather_than_pretending_to_be_whole() {
96 let g = Graphic {
97 protocol: GraphicProtocol::Sixel,
98 params: String::new(),
99 data: vec![0u8; 4],
100 at_row: 2,
101 at_col: 5,
102 truncated: true,
103 };
104 assert!(g.truncated, "a renderer must be able to see the cut");
105 assert_eq!(g.len(), 4);
106 }
107
108 #[test]
109 fn the_payload_bound_is_the_same_one_mado_uses() {
110 assert_eq!(GRAPHIC_PAYLOAD_MAX, 8 * 1024 * 1024);
111 }
112}