Skip to main content

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}