Skip to main content

tui_lipan/widgets/terminal/
graphics.rs

1//! Kitty graphics protocol (`APC _G`) support for [`TerminalScreen`](super::TerminalScreen).
2//!
3//! The child program speaks the [Kitty graphics protocol]; the host terminal may speak something
4//! else entirely (sixel, iTerm2, or nothing at all). So this module does not forward the child's
5//! escape sequences to the host: it decodes them into pixels, anchors each *placement* to an
6//! absolute scrollback line, and hands the renderer a plain list of "these pixels, at this cell
7//! rect". The renderer re-encodes through the same [`ratatui_image`] path the
8//! [`Image`](crate::widgets::Image) widget uses, which is what makes images survive a host that
9//! cannot speak Kitty, a pane that is only half on screen, and two panes that both picked image
10//! id `1`.
11//!
12//! Decoding rather than forwarding also keeps the cell grid honest. Nothing here touches the
13//! Alacritty grid: [`TerminalScreen`](super::TerminalScreen) splits the byte stream around each
14//! command, so the grid sees a stream with the graphics escapes removed, plus the cursor movement
15//! the protocol specifies for a placement. Images are a parallel layer keyed to the same
16//! absolute-line space as [`SemanticMark`](super::SemanticMark)s, and they scroll, evict, and
17//! reset with it.
18//!
19//! ## Implemented
20//!
21//! - Direct transmission (`t=d`), chunked with `m=1`, in RGB (`f=24`), RGBA (`f=32`) and PNG
22//!   (`f=100`), with optional zlib compression (`o=z`).
23//! - Transmit (`a=t`), transmit-and-display (`a=T`), display a stored image (`a=p`), delete
24//!   (`a=d`), and query (`a=q`).
25//! - Source cropping (`x`/`y`/`w`/`h`), explicit cell sizing (`c`/`r`), z-index (`z`), suppressed
26//!   cursor movement (`C=1`), image numbers (`I=`), and response quieting (`q=`).
27//!
28//! ## Not implemented
29//!
30//! - Transmission through a file or shared memory (`t=f`, `t=t`, `t=s`). A multiplexer client can
31//!   be on a different machine from the program that wrote the file, so the path is meaningless
32//!   often enough that answering `ENOTSUPP` is more honest than reading it sometimes.
33//! - Unicode placeholders (`U=1`), animation (`a=a`, `a=f`, `a=c`), and relative placements.
34//!
35//! Unsupported requests are answered with the protocol's own error report, so a child that probes
36//! before drawing gets a clean "no" instead of silence.
37//!
38//! [Kitty graphics protocol]: https://sw.kovidgoyal.net/kitty/graphics-protocol/
39
40use std::collections::HashMap;
41use std::fmt::Write as _;
42use std::ops::Range;
43use std::sync::Arc;
44
45use base64::Engine as _;
46use base64::engine::general_purpose::STANDARD as BASE64;
47use image::DynamicImage;
48
49use super::screen::TerminalCellSize;
50
51/// Largest payload accumulated across `m=1` chunks, before decoding.
52const MAX_TRANSMIT_BYTES: usize = 32 * 1024 * 1024;
53
54/// Largest single `APC` sequence buffered before it is abandoned.
55///
56/// The protocol tells senders to chunk at 4096 base64 bytes, but this is deliberately not held to
57/// that: plenty of senders emit raw pixels in one escape, which clears 64 KiB with a picture only
58/// a few hundred cells wide, and a dropped transmission is indistinguishable - from the sender's
59/// side - from a terminal with no graphics support at all. What this bound is actually for is
60/// stopping an *unterminated* `APC` from growing without end, and the accumulated-payload cap
61/// already decides how much a transmission may total, so matching it costs nothing.
62const MAX_APC_BYTES: usize = MAX_TRANSMIT_BYTES;
63
64/// Default decoded-pixel budget kept per screen, across every stored image.
65///
66/// Decoded pixels are 4 bytes each, so this is roughly sixteen 1080p frames. The budget is what
67/// stops a session that keeps plotting from growing without bound; images beyond it are evicted
68/// least-recently-used.
69const DEFAULT_IMAGE_BUDGET_BYTES: usize = 96 * 1024 * 1024;
70
71/// Largest number of live placements retained.
72const MAX_PLACEMENTS: usize = 256;
73
74/// Guard on decoded dimensions, applied before pixels are allocated.
75const MAX_IMAGE_DIMENSION: u32 = 16384;
76
77/// First auto-assigned image id.
78///
79/// Clients that transmit without `i=` get an id from here up, above the range a client numbering
80/// its own images from `1` would ever reach, so the two never collide.
81const FIRST_AUTO_ID: u32 = 1 << 24;
82
83// ─── Public types ────────────────────────────────────────────────────────────
84
85/// Decoded pixels the child transmitted.
86///
87/// Opaque on purpose: the pixels live behind this crate's `image` dependency, and pinning that
88/// crate's types into the public API would make each of its releases a breaking change here.
89#[derive(Clone)]
90pub struct TerminalImage {
91    pixels: Arc<DynamicImage>,
92    source_hash: u64,
93}
94
95impl TerminalImage {
96    /// Width in pixels.
97    pub fn width(&self) -> u32 {
98        self.pixels.width()
99    }
100
101    /// Height in pixels.
102    pub fn height(&self) -> u32 {
103        self.pixels.height()
104    }
105
106    /// Stable hash of the transmitted payload.
107    ///
108    /// Two images with the same hash decoded from the same bytes, which is what lets the renderer
109    /// cache one encoded protocol across frames and across panes showing the same picture.
110    pub fn source_hash(&self) -> u64 {
111        self.source_hash
112    }
113
114    pub(crate) fn pixels(&self) -> &Arc<DynamicImage> {
115        &self.pixels
116    }
117}
118
119impl PartialEq for TerminalImage {
120    /// Same payload, same image: contents are immutable once decoded.
121    fn eq(&self, other: &Self) -> bool {
122        self.source_hash == other.source_hash
123    }
124}
125
126impl std::fmt::Debug for TerminalImage {
127    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
128        f.debug_struct("TerminalImage")
129            .field("width", &self.width())
130            .field("height", &self.height())
131            .field("source_hash", &self.source_hash)
132            .finish()
133    }
134}
135
136/// An image the child asked to have shown, positioned against the current viewport.
137///
138/// A placement scrolled entirely out of view is absent from a snapshot; one that is partly visible
139/// still reports its whole rect (with `row`/`col` allowed to go negative), so the renderer can
140/// crop the pixels instead of squashing them into what is left.
141#[derive(Clone, Debug, PartialEq)]
142pub struct TerminalImagePlacement {
143    /// The image id the child assigned, which is what distinguishes two placements that happen to
144    /// hold identical pixels.
145    ///
146    /// The renderer must key its encoding on this and not on the pixels alone. A host that draws
147    /// through Kitty identifies a placement by image id, so two placements sharing one id are one
148    /// placement to it - and two copies of the same picture would collapse into a single one on
149    /// screen, the other simply not drawn.
150    pub image_id: u32,
151    /// The pixels to draw.
152    pub image: TerminalImage,
153    /// Top row, relative to the top of the viewport.
154    pub row: i32,
155    /// Left column, relative to the left of the viewport.
156    pub col: i32,
157    /// Height in cells.
158    pub rows: u16,
159    /// Width in cells.
160    pub cols: u16,
161    /// Kitty z-index; negative sits behind text. Placements are ordered back to front.
162    pub z: i32,
163    /// Source sub-rectangle to draw, when the child asked for one (`x`/`y`/`w`/`h`).
164    pub source_crop: Option<TerminalImageCrop>,
165}
166
167/// A source-pixel sub-rectangle of a transmitted image.
168#[derive(Clone, Copy, Debug, PartialEq, Eq)]
169pub struct TerminalImageCrop {
170    /// Left edge in source pixels.
171    pub x: u32,
172    /// Top edge in source pixels.
173    pub y: u32,
174    /// Width in source pixels.
175    pub width: u32,
176    /// Height in source pixels.
177    pub height: u32,
178}
179
180// ─── Wire scanning ───────────────────────────────────────────────────────────
181
182/// A piece of a byte stream, split around the graphics commands in it.
183#[derive(Debug)]
184pub(super) enum GraphicsSegment {
185    /// `bytes[range]` of the scanned chunk, to be handed to the VT parser unchanged.
186    Text(Range<usize>),
187    /// A lone `ESC` held back from the previous chunk that did not start a graphics command.
188    ///
189    /// Carried separately because it belongs to a slice the caller no longer has.
190    HeldEscape,
191    /// A complete, well-formed graphics command. Boxed to keep the enum small.
192    Command(Box<GraphicsCommand>),
193}
194
195#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
196enum ScanState {
197    /// Outside any escape sequence.
198    #[default]
199    Ground,
200    /// Saw `ESC`, waiting to find out whether `_` follows.
201    Escape,
202    /// Inside `APC …`, accumulating.
203    Apc,
204    /// Inside `APC …`, saw `ESC`, waiting for the `\` that ends it.
205    ApcEscape,
206}
207
208/// Splits a PTY byte stream into VT bytes and Kitty graphics commands.
209///
210/// Every `APC` sequence is swallowed, not just the graphics ones: the VT parser discards `APC`
211/// bodies wholesale, so removing them from its input cannot change the grid, and it saves this
212/// scanner from having to reproduce `vte`'s string-state rules byte for byte.
213#[derive(Debug, Default)]
214pub(super) struct GraphicsScanner {
215    state: ScanState,
216    /// Body of the `APC` being accumulated, without the `ESC _` introducer.
217    apc: Vec<u8>,
218    /// Set once the body passed [`MAX_APC_BYTES`] and is being discarded.
219    overflowed: bool,
220}
221
222impl GraphicsScanner {
223    /// Whether `bytes` can go to the VT parser whole, with no splitting and no allocation.
224    ///
225    /// The overwhelmingly common case for a terminal is a chunk with no graphics in it at all, so
226    /// it is worth one linear scan to keep [`scan`](Self::scan)'s bookkeeping off that path.
227    ///
228    /// A chunk ending on a bare `ESC` is never plain: the `_` that would make it a graphics
229    /// introducer is in the next chunk, and taking this path would leave the scanner unaware.
230    pub(super) fn is_plain(&self, bytes: &[u8]) -> bool {
231        self.state == ScanState::Ground
232            && bytes.last() != Some(&0x1b)
233            && !bytes.windows(2).any(|pair| pair == b"\x1b_")
234    }
235
236    /// Split `bytes`, in order.
237    ///
238    /// Ranges in [`GraphicsSegment::Text`] index `bytes`; whatever is needed to finish a command
239    /// straddling the chunk boundary stays in `self`.
240    pub(super) fn scan(&mut self, bytes: &[u8]) -> Vec<GraphicsSegment> {
241        let mut out = Vec::new();
242        // Start of the run of plain VT bytes not yet emitted.
243        let mut text_start = 0usize;
244        let mut idx = 0usize;
245
246        while idx < bytes.len() {
247            let byte = bytes[idx];
248            match self.state {
249                ScanState::Ground => {
250                    if byte == 0x1b {
251                        // Hold the ESC back: it rejoins the text if this is not an APC, and is
252                        // dropped with the rest of the sequence if it is.
253                        if text_start < idx {
254                            out.push(GraphicsSegment::Text(text_start..idx));
255                        }
256                        text_start = idx;
257                        self.state = ScanState::Escape;
258                    }
259                    idx += 1;
260                }
261                ScanState::Escape => {
262                    if byte == b'_' {
263                        self.state = ScanState::Apc;
264                        self.apc.clear();
265                        self.overflowed = false;
266                        idx += 1;
267                        text_start = idx;
268                    } else {
269                        self.state = ScanState::Ground;
270                        if text_start == idx {
271                            // The ESC was the last byte of an earlier chunk.
272                            out.push(GraphicsSegment::HeldEscape);
273                        }
274                        // Re-read this byte in `Ground`, so `ESC ESC` starts a fresh sequence.
275                    }
276                }
277                ScanState::Apc => {
278                    match byte {
279                        0x1b => self.state = ScanState::ApcEscape,
280                        // Some emitters end a string with BEL. Protocol payloads are base64 and
281                        // never contain one, so accepting it costs nothing.
282                        0x07 => {
283                            self.finish_apc(&mut out);
284                            self.state = ScanState::Ground;
285                        }
286                        // CAN / SUB abort a string in flight.
287                        0x18 | 0x1a => {
288                            self.apc.clear();
289                            self.overflowed = false;
290                            self.state = ScanState::Ground;
291                        }
292                        _ => self.push_apc(byte),
293                    }
294                    idx += 1;
295                    text_start = idx;
296                }
297                ScanState::ApcEscape => {
298                    if byte == b'\\' {
299                        self.finish_apc(&mut out);
300                        self.state = ScanState::Ground;
301                        idx += 1;
302                        text_start = idx;
303                    } else {
304                        // Unterminated: the VT parser would discard this body too, so drop it and
305                        // re-read the byte as the start of whatever follows.
306                        self.apc.clear();
307                        self.overflowed = false;
308                        self.state = ScanState::Ground;
309                        text_start = idx;
310                    }
311                }
312            }
313        }
314
315        if self.state == ScanState::Ground && text_start < bytes.len() {
316            out.push(GraphicsSegment::Text(text_start..bytes.len()));
317        }
318
319        out
320    }
321
322    fn push_apc(&mut self, byte: u8) {
323        if self.overflowed {
324            return;
325        }
326        if self.apc.len() >= MAX_APC_BYTES {
327            self.apc.clear();
328            self.overflowed = true;
329            return;
330        }
331        self.apc.push(byte);
332    }
333
334    fn finish_apc(&mut self, out: &mut Vec<GraphicsSegment>) {
335        let body = std::mem::take(&mut self.apc);
336        let overflowed = std::mem::take(&mut self.overflowed);
337        if overflowed {
338            return;
339        }
340        // Any other `APC` application command; the VT parser ignores those as well.
341        let Some(rest) = body.strip_prefix(b"G") else {
342            return;
343        };
344        if let Some(command) = GraphicsCommand::parse(rest) {
345            out.push(GraphicsSegment::Command(Box::new(command)));
346        }
347    }
348
349    /// Drop any sequence in flight, for a hard reset of the screen.
350    pub(super) fn reset(&mut self) {
351        self.state = ScanState::Ground;
352        self.apc.clear();
353        self.overflowed = false;
354    }
355}
356
357// ─── Command model ───────────────────────────────────────────────────────────
358
359/// What a command asks for (`a=`).
360#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
361enum GraphicsAction {
362    /// `a=t` - store without showing.
363    #[default]
364    Transmit,
365    /// `a=T` - store and place at the cursor.
366    TransmitAndDisplay,
367    /// `a=p` - place an already-stored image.
368    Display,
369    /// `a=d` - delete images and/or placements.
370    Delete,
371    /// `a=q` - capability probe.
372    Query,
373    /// `a=a`, `a=f`, `a=c` - animation, which this implementation does not do.
374    Animate,
375}
376
377/// Where the pixels come from (`t=`).
378#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
379enum GraphicsMedium {
380    /// `t=d` - inline in the escape sequence.
381    #[default]
382    Direct,
383    /// `t=f`, `t=t`, `t=s` - a path or shared-memory object on the sender's machine.
384    OutOfBand,
385}
386
387/// A parsed `APC _G` command.
388#[derive(Clone, Debug)]
389pub(super) struct GraphicsCommand {
390    action: GraphicsAction,
391    medium: GraphicsMedium,
392    /// `f=` - 24 (RGB), 32 (RGBA), or 100 (PNG).
393    format: u32,
394    /// `s=` / `v=` - pixel dimensions, required by the raw formats.
395    width: u32,
396    height: u32,
397    /// `i=` - client-assigned image id.
398    id: u32,
399    /// `I=` - client-assigned image number, mapped to an id on transmit.
400    number: u32,
401    /// `p=` - placement id.
402    placement: u32,
403    /// `m=1` - more chunks follow.
404    more: bool,
405    /// `o=z` - payload is zlib-compressed.
406    compressed: bool,
407    /// `x` / `y` / `w` / `h` - source rectangle to display.
408    src_x: u32,
409    src_y: u32,
410    src_w: u32,
411    src_h: u32,
412    /// `c` / `r` - explicit cell size of the placement.
413    cols: u32,
414    rows: u32,
415    /// `z=` - stacking order against the text layer.
416    z: i32,
417    /// `C=1` - leave the cursor where it is.
418    no_cursor_move: bool,
419    /// `U=1` - a virtual placement, shown wherever placeholder cells name it rather than here.
420    virtual_placement: bool,
421    /// `d=` - what a delete applies to.
422    delete: u8,
423    /// `q=` - 1 suppresses success reports, 2 suppresses failures too.
424    quiet: u32,
425    /// Payload with the base64 already undone.
426    payload: Vec<u8>,
427}
428
429impl Default for GraphicsCommand {
430    fn default() -> Self {
431        Self {
432            action: GraphicsAction::default(),
433            medium: GraphicsMedium::default(),
434            format: 32,
435            width: 0,
436            height: 0,
437            id: 0,
438            number: 0,
439            placement: 0,
440            more: false,
441            compressed: false,
442            src_x: 0,
443            src_y: 0,
444            src_w: 0,
445            src_h: 0,
446            cols: 0,
447            rows: 0,
448            z: 0,
449            no_cursor_move: false,
450            virtual_placement: false,
451            delete: b'a',
452            quiet: 0,
453            payload: Vec::new(),
454        }
455    }
456}
457
458impl GraphicsCommand {
459    fn parse(body: &[u8]) -> Option<Self> {
460        let (control, payload) = match body.iter().position(|byte| *byte == b';') {
461            Some(at) => (&body[..at], &body[at + 1..]),
462            None => (body, &body[body.len()..]),
463        };
464
465        let mut command = Self::default();
466        for pair in control.split(|byte| *byte == b',') {
467            let mut halves = pair.splitn(2, |byte| *byte == b'=');
468            let ([key], Some(value)) = (halves.next()?, halves.next()) else {
469                continue;
470            };
471            command.apply_key(*key, value);
472        }
473
474        // A payload that does not decode makes the whole command malformed rather than empty:
475        // acting on half an image would draw garbage.
476        command.payload = BASE64.decode(payload).ok()?;
477        Some(command)
478    }
479
480    fn apply_key(&mut self, key: u8, value: &[u8]) {
481        let text = std::str::from_utf8(value).unwrap_or("");
482        let first = value.first().copied().unwrap_or(0);
483        match key {
484            b'a' => {
485                self.action = match first {
486                    b'T' => GraphicsAction::TransmitAndDisplay,
487                    b'p' => GraphicsAction::Display,
488                    b'd' => GraphicsAction::Delete,
489                    b'q' => GraphicsAction::Query,
490                    b'a' | b'f' | b'c' => GraphicsAction::Animate,
491                    _ => GraphicsAction::Transmit,
492                }
493            }
494            b't' => {
495                self.medium = match first {
496                    b'f' | b't' | b's' => GraphicsMedium::OutOfBand,
497                    _ => GraphicsMedium::Direct,
498                }
499            }
500            b'f' => self.format = text.parse().unwrap_or(32),
501            b's' => self.width = text.parse().unwrap_or(0),
502            b'v' => self.height = text.parse().unwrap_or(0),
503            b'i' => self.id = text.parse().unwrap_or(0),
504            b'I' => self.number = text.parse().unwrap_or(0),
505            b'p' => self.placement = text.parse().unwrap_or(0),
506            b'm' => self.more = text.parse().unwrap_or(0) == 1,
507            b'o' => self.compressed = first == b'z',
508            b'x' => self.src_x = text.parse().unwrap_or(0),
509            b'y' => self.src_y = text.parse().unwrap_or(0),
510            b'w' => self.src_w = text.parse().unwrap_or(0),
511            b'h' => self.src_h = text.parse().unwrap_or(0),
512            b'c' => self.cols = text.parse().unwrap_or(0),
513            b'r' => self.rows = text.parse().unwrap_or(0),
514            b'z' => self.z = text.parse().unwrap_or(0),
515            b'C' => self.no_cursor_move = text.parse().unwrap_or(0) == 1,
516            b'U' => self.virtual_placement = text.parse().unwrap_or(0) == 1,
517            b'd' => self.delete = first,
518            b'q' => self.quiet = text.parse().unwrap_or(0),
519            _ => {}
520        }
521    }
522
523    /// Whether a report about this command goes back to the child.
524    fn reports(&self, ok: bool) -> bool {
525        match self.quiet {
526            0 => true,
527            1 => !ok,
528            _ => false,
529        }
530    }
531}
532
533// ─── Unicode placeholders ────────────────────────────────────────────────────
534
535/// The character a virtual placement is drawn with.
536///
537/// A program that wants an image to sit in the text flow - which is what every terminal UI
538/// toolkit wants - transmits it with `U=1` and then writes this character into the cells the image
539/// should cover, tagging each with the image id (in the cell's foreground colour) and its position
540/// inside the image (in combining marks). Nothing is drawn where the *transmission* happened, so a
541/// virtual placement is stored and then found again by reading the grid.
542pub(super) const PLACEHOLDER: char = '\u{10EEEE}';
543
544/// The Kitty protocol's row/column diacritics, in the order the protocol assigns them.
545///
546/// A placeholder cell names its position inside the image with up to three of these: the first is
547/// the image row, the second the column, and the third the most significant byte of the image id.
548/// The list is the one in the protocol specification, and is sorted by code point so a lookup can
549/// binary-search it.
550static ROWCOLUMN_DIACRITICS: [char; 297] = [
551    '\u{305}',
552    '\u{30d}',
553    '\u{30e}',
554    '\u{310}',
555    '\u{312}',
556    '\u{33d}',
557    '\u{33e}',
558    '\u{33f}',
559    '\u{346}',
560    '\u{34a}',
561    '\u{34b}',
562    '\u{34c}',
563    '\u{350}',
564    '\u{351}',
565    '\u{352}',
566    '\u{357}',
567    '\u{35b}',
568    '\u{363}',
569    '\u{364}',
570    '\u{365}',
571    '\u{366}',
572    '\u{367}',
573    '\u{368}',
574    '\u{369}',
575    '\u{36a}',
576    '\u{36b}',
577    '\u{36c}',
578    '\u{36d}',
579    '\u{36e}',
580    '\u{36f}',
581    '\u{483}',
582    '\u{484}',
583    '\u{485}',
584    '\u{486}',
585    '\u{487}',
586    '\u{592}',
587    '\u{593}',
588    '\u{594}',
589    '\u{595}',
590    '\u{597}',
591    '\u{598}',
592    '\u{599}',
593    '\u{59c}',
594    '\u{59d}',
595    '\u{59e}',
596    '\u{59f}',
597    '\u{5a0}',
598    '\u{5a1}',
599    '\u{5a8}',
600    '\u{5a9}',
601    '\u{5ab}',
602    '\u{5ac}',
603    '\u{5af}',
604    '\u{5c4}',
605    '\u{610}',
606    '\u{611}',
607    '\u{612}',
608    '\u{613}',
609    '\u{614}',
610    '\u{615}',
611    '\u{616}',
612    '\u{617}',
613    '\u{657}',
614    '\u{658}',
615    '\u{659}',
616    '\u{65a}',
617    '\u{65b}',
618    '\u{65d}',
619    '\u{65e}',
620    '\u{6d6}',
621    '\u{6d7}',
622    '\u{6d8}',
623    '\u{6d9}',
624    '\u{6da}',
625    '\u{6db}',
626    '\u{6dc}',
627    '\u{6df}',
628    '\u{6e0}',
629    '\u{6e1}',
630    '\u{6e2}',
631    '\u{6e4}',
632    '\u{6e7}',
633    '\u{6e8}',
634    '\u{6eb}',
635    '\u{6ec}',
636    '\u{730}',
637    '\u{732}',
638    '\u{733}',
639    '\u{735}',
640    '\u{736}',
641    '\u{73a}',
642    '\u{73d}',
643    '\u{73f}',
644    '\u{740}',
645    '\u{741}',
646    '\u{743}',
647    '\u{745}',
648    '\u{747}',
649    '\u{749}',
650    '\u{74a}',
651    '\u{7eb}',
652    '\u{7ec}',
653    '\u{7ed}',
654    '\u{7ee}',
655    '\u{7ef}',
656    '\u{7f0}',
657    '\u{7f1}',
658    '\u{7f3}',
659    '\u{816}',
660    '\u{817}',
661    '\u{818}',
662    '\u{819}',
663    '\u{81b}',
664    '\u{81c}',
665    '\u{81d}',
666    '\u{81e}',
667    '\u{81f}',
668    '\u{820}',
669    '\u{821}',
670    '\u{822}',
671    '\u{823}',
672    '\u{825}',
673    '\u{826}',
674    '\u{827}',
675    '\u{829}',
676    '\u{82a}',
677    '\u{82b}',
678    '\u{82c}',
679    '\u{82d}',
680    '\u{951}',
681    '\u{953}',
682    '\u{954}',
683    '\u{f82}',
684    '\u{f83}',
685    '\u{f86}',
686    '\u{f87}',
687    '\u{135d}',
688    '\u{135e}',
689    '\u{135f}',
690    '\u{17dd}',
691    '\u{193a}',
692    '\u{1a17}',
693    '\u{1a75}',
694    '\u{1a76}',
695    '\u{1a77}',
696    '\u{1a78}',
697    '\u{1a79}',
698    '\u{1a7a}',
699    '\u{1a7b}',
700    '\u{1a7c}',
701    '\u{1b6b}',
702    '\u{1b6d}',
703    '\u{1b6e}',
704    '\u{1b6f}',
705    '\u{1b70}',
706    '\u{1b71}',
707    '\u{1b72}',
708    '\u{1b73}',
709    '\u{1cd0}',
710    '\u{1cd1}',
711    '\u{1cd2}',
712    '\u{1cda}',
713    '\u{1cdb}',
714    '\u{1ce0}',
715    '\u{1dc0}',
716    '\u{1dc1}',
717    '\u{1dc3}',
718    '\u{1dc4}',
719    '\u{1dc5}',
720    '\u{1dc6}',
721    '\u{1dc7}',
722    '\u{1dc8}',
723    '\u{1dc9}',
724    '\u{1dcb}',
725    '\u{1dcc}',
726    '\u{1dd1}',
727    '\u{1dd2}',
728    '\u{1dd3}',
729    '\u{1dd4}',
730    '\u{1dd5}',
731    '\u{1dd6}',
732    '\u{1dd7}',
733    '\u{1dd8}',
734    '\u{1dd9}',
735    '\u{1dda}',
736    '\u{1ddb}',
737    '\u{1ddc}',
738    '\u{1ddd}',
739    '\u{1dde}',
740    '\u{1ddf}',
741    '\u{1de0}',
742    '\u{1de1}',
743    '\u{1de2}',
744    '\u{1de3}',
745    '\u{1de4}',
746    '\u{1de5}',
747    '\u{1de6}',
748    '\u{1dfe}',
749    '\u{20d0}',
750    '\u{20d1}',
751    '\u{20d4}',
752    '\u{20d5}',
753    '\u{20d6}',
754    '\u{20d7}',
755    '\u{20db}',
756    '\u{20dc}',
757    '\u{20e1}',
758    '\u{20e7}',
759    '\u{20e9}',
760    '\u{20f0}',
761    '\u{2cef}',
762    '\u{2cf0}',
763    '\u{2cf1}',
764    '\u{2de0}',
765    '\u{2de1}',
766    '\u{2de2}',
767    '\u{2de3}',
768    '\u{2de4}',
769    '\u{2de5}',
770    '\u{2de6}',
771    '\u{2de7}',
772    '\u{2de8}',
773    '\u{2de9}',
774    '\u{2dea}',
775    '\u{2deb}',
776    '\u{2dec}',
777    '\u{2ded}',
778    '\u{2dee}',
779    '\u{2def}',
780    '\u{2df0}',
781    '\u{2df1}',
782    '\u{2df2}',
783    '\u{2df3}',
784    '\u{2df4}',
785    '\u{2df5}',
786    '\u{2df6}',
787    '\u{2df7}',
788    '\u{2df8}',
789    '\u{2df9}',
790    '\u{2dfa}',
791    '\u{2dfb}',
792    '\u{2dfc}',
793    '\u{2dfd}',
794    '\u{2dfe}',
795    '\u{2dff}',
796    '\u{a66f}',
797    '\u{a67c}',
798    '\u{a67d}',
799    '\u{a6f0}',
800    '\u{a6f1}',
801    '\u{a8e0}',
802    '\u{a8e1}',
803    '\u{a8e2}',
804    '\u{a8e3}',
805    '\u{a8e4}',
806    '\u{a8e5}',
807    '\u{a8e6}',
808    '\u{a8e7}',
809    '\u{a8e8}',
810    '\u{a8e9}',
811    '\u{a8ea}',
812    '\u{a8eb}',
813    '\u{a8ec}',
814    '\u{a8ed}',
815    '\u{a8ee}',
816    '\u{a8ef}',
817    '\u{a8f0}',
818    '\u{a8f1}',
819    '\u{aab0}',
820    '\u{aab2}',
821    '\u{aab3}',
822    '\u{aab7}',
823    '\u{aab8}',
824    '\u{aabe}',
825    '\u{aabf}',
826    '\u{aac1}',
827    '\u{fe20}',
828    '\u{fe21}',
829    '\u{fe22}',
830    '\u{fe23}',
831    '\u{fe24}',
832    '\u{fe25}',
833    '\u{fe26}',
834    '\u{10a0f}',
835    '\u{10a38}',
836    '\u{1d185}',
837    '\u{1d186}',
838    '\u{1d187}',
839    '\u{1d188}',
840    '\u{1d189}',
841    '\u{1d1aa}',
842    '\u{1d1ab}',
843    '\u{1d1ac}',
844    '\u{1d1ad}',
845    '\u{1d242}',
846    '\u{1d243}',
847    '\u{1d244}',
848];
849
850/// The diacritic that encodes `index`, saturating at the last one the protocol defines.
851///
852/// The decoder never needs this - it only reads marks - but building the sequences a real sender
853/// emits is how the placeholder path is tested, so it lives next to the table it indexes.
854#[cfg(test)]
855pub(super) fn diacritic(index: u16) -> char {
856    ROWCOLUMN_DIACRITICS[usize::from(index).min(ROWCOLUMN_DIACRITICS.len() - 1)]
857}
858
859/// The position a row/column diacritic encodes, or `None` if the character is not one.
860fn diacritic_value(mark: char) -> Option<u16> {
861    ROWCOLUMN_DIACRITICS
862        .binary_search(&mark)
863        .ok()
864        .map(|index| index as u16)
865}
866
867/// One placeholder cell, as read off the grid.
868#[derive(Clone, Copy, Debug)]
869pub(super) struct PlaceholderCell {
870    /// Viewport row.
871    pub(super) row: u16,
872    /// Viewport column.
873    pub(super) col: u16,
874    /// The low 24 bits of the image id, carried by the cell's foreground colour.
875    pub(super) id_low: u32,
876    /// Image row, when the cell spelled one out.
877    pub(super) image_row: Option<u16>,
878    /// Image column, when the cell spelled one out.
879    pub(super) image_col: Option<u16>,
880    /// High byte of the image id, for ids that do not fit in a colour.
881    pub(super) id_high: Option<u16>,
882}
883
884impl PlaceholderCell {
885    /// Read the position marks off a cell's combining characters.
886    ///
887    /// The protocol lets a cell omit any of them, in which case it continues the cell to its left:
888    /// that is what keeps a row of placeholders down to one escape sequence instead of one per
889    /// cell, and it is why these are resolved in a left-to-right pass rather than per cell.
890    pub(super) fn new(row: u16, col: u16, id_low: u32, marks: &[char]) -> Self {
891        let mut values = marks.iter().filter_map(|mark| diacritic_value(*mark));
892        Self {
893            row,
894            col,
895            id_low,
896            image_row: values.next(),
897            image_col: values.next(),
898            id_high: values.next(),
899        }
900    }
901}
902
903/// A resolved run of placeholder cells: one screen row of one image.
904#[derive(Clone, Copy, Debug)]
905struct PlaceholderRun {
906    image_id: u32,
907    /// High byte of the image id, kept so the cells continuing this run can inherit it.
908    id_high: u16,
909    row: u16,
910    col: u16,
911    width: u16,
912    image_row: u16,
913    image_col: u16,
914}
915
916/// Resolve placeholder cells into runs, applying the protocol's inheritance rules.
917///
918/// Everything a cell can leave out is inherited from the cell to its left: its row, its column
919/// (which advances by one), and the high byte of the image id. Inheriting the id byte matters as
920/// much as the position - ids above 24 bits split across the foreground colour and a third
921/// combining mark, and a sender writes that mark on the first cell of a row only. Defaulting it
922/// to zero instead of inheriting makes every cell after the first name a *different* image, which
923/// looks exactly like an image one column wide.
924fn placeholder_runs(cells: &[PlaceholderCell]) -> Vec<PlaceholderRun> {
925    let mut runs: Vec<PlaceholderRun> = Vec::new();
926    let mut open: Option<PlaceholderRun> = None;
927
928    for cell in cells {
929        // Adjacency has to be settled before the id, since the id is what may be inherited.
930        let adjacent =
931            open.is_some_and(|run| run.row == cell.row && run.col + run.width == cell.col);
932        let inherited_high = match (adjacent, open) {
933            (true, Some(run)) => run.id_high,
934            _ => 0,
935        };
936        let id_high = cell.id_high.unwrap_or(inherited_high);
937        let image_id = (u32::from(id_high) << 24) | (cell.id_low & 0x00ff_ffff);
938
939        let continues = adjacent
940            && open.is_some_and(|run| {
941                run.image_id == image_id
942                    && cell.image_row.is_none_or(|value| value == run.image_row)
943                    && cell
944                        .image_col
945                        .is_none_or(|value| value == run.image_col + run.width)
946            });
947
948        if continues {
949            if let Some(run) = open.as_mut() {
950                run.width += 1;
951            }
952            continue;
953        }
954
955        if let Some(run) = open.take() {
956            runs.push(run);
957        }
958        open = Some(PlaceholderRun {
959            image_id,
960            id_high,
961            row: cell.row,
962            col: cell.col,
963            width: 1,
964            image_row: cell.image_row.unwrap_or(0),
965            image_col: cell.image_col.unwrap_or(0),
966        });
967    }
968
969    runs.extend(open);
970    runs
971}
972
973/// A rectangle of one image, assembled from the rows of placeholders covering it.
974#[derive(Clone, Copy, Debug, PartialEq, Eq)]
975struct PlaceholderRect {
976    image_id: u32,
977    row: u16,
978    col: u16,
979    width: u16,
980    height: u16,
981    image_row: u16,
982    image_col: u16,
983}
984
985/// Stack runs into rectangles, so a whole image is one placement instead of one per row.
986///
987/// It matters: each placement is separately cropped and encoded, so leaving a 40-row picture as 40
988/// strips would mean 40 encodes and 40 sequences on the wire for what the sender meant as one
989/// image. Rows that do not line up stay separate rather than being forced together.
990fn merge_placeholder_runs(runs: &[PlaceholderRun]) -> Vec<PlaceholderRect> {
991    let mut rects: Vec<PlaceholderRect> = Vec::new();
992
993    for run in runs {
994        let stackable = rects.iter_mut().find(|rect| {
995            rect.image_id == run.image_id
996                && rect.col == run.col
997                && rect.width == run.width
998                && rect.image_col == run.image_col
999                && rect.row + rect.height == run.row
1000                && rect.image_row + rect.height == run.image_row
1001        });
1002        if let Some(rect) = stackable {
1003            rect.height += 1;
1004            continue;
1005        }
1006        rects.push(PlaceholderRect {
1007            image_id: run.image_id,
1008            row: run.row,
1009            col: run.col,
1010            width: run.width,
1011            height: 1,
1012            image_row: run.image_row,
1013            image_col: run.image_col,
1014        });
1015    }
1016
1017    rects
1018}
1019
1020// ─── Store ───────────────────────────────────────────────────────────────────
1021
1022/// What the screen tells the store so a placement lands in the right place.
1023#[derive(Clone, Copy, Debug)]
1024pub(super) struct GraphicsContext {
1025    /// Absolute line the cursor sits on, in the same space as
1026    /// [`TerminalScreen::total_text_lines`](super::TerminalScreen::total_text_lines).
1027    pub(super) cursor_line: usize,
1028    /// Cursor column.
1029    pub(super) cursor_col: u16,
1030    /// Absolute line at the top of the live viewport, for screen-addressed deletes.
1031    pub(super) viewport_top_line: usize,
1032    /// Whether the alternate screen is active.
1033    pub(super) alt_screen: bool,
1034    /// Host cell size, for converting pixels to cells.
1035    pub(super) cell: TerminalCellSize,
1036    /// Viewport width in cells.
1037    pub(super) cols: u16,
1038}
1039
1040/// What the screen does after the store handled a command.
1041#[derive(Debug, Default)]
1042pub(super) struct GraphicsOutcome {
1043    /// A protocol report to write back to the child.
1044    pub(super) response: Option<Vec<u8>>,
1045    /// Cursor movement the placement implies, as `(rows down, columns right)`.
1046    pub(super) advance: Option<(u16, u16)>,
1047}
1048
1049/// A stored image plus the accounting the budget needs.
1050struct StoredImage {
1051    image: TerminalImage,
1052    bytes: usize,
1053    /// Monotonic tick of the last transmit or placement, for LRU eviction.
1054    used: u64,
1055}
1056
1057/// A live placement of a stored image.
1058#[derive(Clone, Debug)]
1059struct Placement {
1060    image_id: u32,
1061    placement_id: u32,
1062    /// Absolute line of the placement's top row.
1063    line: usize,
1064    col: u16,
1065    rows: u16,
1066    cols: u16,
1067    z: i32,
1068    crop: Option<TerminalImageCrop>,
1069    /// Placements made on the alternate screen die with it.
1070    alt_screen: bool,
1071}
1072
1073impl Placement {
1074    fn covers_cell(&self, line: usize, col: u16) -> bool {
1075        self.covers_line(line) && self.covers_column(col)
1076    }
1077
1078    fn covers_line(&self, line: usize) -> bool {
1079        line >= self.line && line < self.line.saturating_add(usize::from(self.rows))
1080    }
1081
1082    fn covers_column(&self, col: u16) -> bool {
1083        col >= self.col && col < self.col.saturating_add(self.cols)
1084    }
1085}
1086
1087/// A transmission still accumulating `m=1` chunks.
1088struct PendingTransmit {
1089    id: u32,
1090    /// The first chunk's keys, which carry the format and any display request.
1091    header: GraphicsCommand,
1092    data: Vec<u8>,
1093}
1094
1095/// Decoded images and their placements for one [`TerminalScreen`](super::TerminalScreen).
1096pub(super) struct TerminalGraphics {
1097    images: HashMap<u32, StoredImage>,
1098    /// `I=` image numbers mapped to the ids they were transmitted under.
1099    numbers: HashMap<u32, u32>,
1100    placements: Vec<Placement>,
1101    pending: Option<PendingTransmit>,
1102    next_auto_id: u32,
1103    budget: usize,
1104    used_bytes: usize,
1105    clock: u64,
1106}
1107
1108impl Default for TerminalGraphics {
1109    fn default() -> Self {
1110        Self {
1111            images: HashMap::new(),
1112            numbers: HashMap::new(),
1113            placements: Vec::new(),
1114            pending: None,
1115            next_auto_id: FIRST_AUTO_ID,
1116            budget: DEFAULT_IMAGE_BUDGET_BYTES,
1117            used_bytes: 0,
1118            clock: 0,
1119        }
1120    }
1121}
1122
1123impl TerminalGraphics {
1124    /// Whether any image has been transmitted, so a session with none can skip the grid walk
1125    /// that looks for placeholder cells.
1126    pub(super) fn has_images(&self) -> bool {
1127        !self.images.is_empty()
1128    }
1129
1130    /// Replace the decoded-pixel budget, evicting immediately if it shrank.
1131    pub(super) fn set_budget(&mut self, bytes: usize) {
1132        self.budget = bytes;
1133        self.enforce_budget();
1134    }
1135
1136    /// Drop everything, for `RIS` or a screen reset.
1137    pub(super) fn reset(&mut self) {
1138        self.images.clear();
1139        self.numbers.clear();
1140        self.placements.clear();
1141        self.pending = None;
1142        self.used_bytes = 0;
1143    }
1144
1145    /// Drop every placement while keeping the images themselves.
1146    ///
1147    /// For a reflow: a column change rewraps history, so the absolute line a placement was
1148    /// anchored to no longer names the text it was drawn against, and no shift can correct it.
1149    pub(super) fn clear_placements(&mut self) {
1150        self.placements.clear();
1151    }
1152
1153    /// Drop placements made on the alternate screen, on the way back to the primary one.
1154    pub(super) fn clear_alt_screen(&mut self) -> bool {
1155        let before = self.placements.len();
1156        self.placements.retain(|placement| !placement.alt_screen);
1157        before != self.placements.len()
1158    }
1159
1160    /// Shift placements up by the lines that just fell out of scrollback.
1161    ///
1162    /// Mirrors the semantic-mark bookkeeping: eviction is only observable as it happens, so a
1163    /// placement not corrected here silently slides onto unrelated text.
1164    pub(super) fn drop_evicted(&mut self, evicted: usize) -> bool {
1165        if evicted == 0 || self.placements.is_empty() {
1166            return false;
1167        }
1168        // An image scrolling off the top keeps its remaining rows, so it fades out row by row
1169        // instead of vanishing whole.
1170        self.placements
1171            .retain(|placement| placement.line + usize::from(placement.rows) > evicted);
1172        for placement in &mut self.placements {
1173            placement.line = placement.line.saturating_sub(evicted);
1174        }
1175        true
1176    }
1177
1178    /// Placements overlapping a viewport of `rows` rows, back to front.
1179    ///
1180    /// `history_lines` is the number of lines above the live viewport (the grid's history size),
1181    /// and `display_offset` is how far the view is scrolled into it. Only placements belonging to
1182    /// the grid currently on screen are returned: the alternate screen has an absolute-line space
1183    /// of its own, so primary-screen placements would land on unrelated rows there.
1184    pub(super) fn visible(
1185        &self,
1186        history_lines: usize,
1187        display_offset: usize,
1188        rows: u16,
1189        alt_screen: bool,
1190    ) -> Vec<TerminalImagePlacement> {
1191        let mut visible: Vec<_> = self
1192            .placements
1193            .iter()
1194            .filter(|placement| placement.alt_screen == alt_screen)
1195            .filter_map(|placement| {
1196                let row = placement.line as i64 - history_lines as i64 + display_offset as i64;
1197                if row + i64::from(placement.rows) <= 0 || row >= i64::from(rows) {
1198                    return None;
1199                }
1200                Some(TerminalImagePlacement {
1201                    image_id: placement.image_id,
1202                    image: self.images.get(&placement.image_id)?.image.clone(),
1203                    row: row.clamp(i32::MIN as i64, i32::MAX as i64) as i32,
1204                    col: i32::from(placement.col),
1205                    rows: placement.rows,
1206                    cols: placement.cols,
1207                    z: placement.z,
1208                    source_crop: placement.crop,
1209                })
1210            })
1211            .collect();
1212        visible.sort_by_key(|placement| placement.z);
1213        visible
1214    }
1215
1216    /// Turn the placeholder cells on screen into placements.
1217    ///
1218    /// Unlike a direct placement, a virtual one is not anchored to a scrollback line: it *is* the
1219    /// text, so it scrolls, clears, and reflows for free, and it disappears the moment the cells
1220    /// holding it do. That is why these are derived per snapshot rather than stored.
1221    pub(super) fn placeholder_placements(
1222        &self,
1223        cells: &[PlaceholderCell],
1224        cell: TerminalCellSize,
1225    ) -> Vec<TerminalImagePlacement> {
1226        merge_placeholder_runs(&placeholder_runs(cells))
1227            .into_iter()
1228            .filter_map(|rect| {
1229                let stored = self.images.get(&rect.image_id)?;
1230                let (width, height) = (stored.image.width(), stored.image.height());
1231                // The source region a rect covers, in the cell grid the sender laid the image out
1232                // on. Clamped rather than scaled: a rect that runs past the pixels it names is a
1233                // sender that rounded up, not an image that should stretch.
1234                let x = u32::from(rect.image_col) * u32::from(cell.width);
1235                let y = u32::from(rect.image_row) * u32::from(cell.height);
1236                if x >= width || y >= height {
1237                    return None;
1238                }
1239                let crop = TerminalImageCrop {
1240                    x,
1241                    y,
1242                    width: (u32::from(rect.width) * u32::from(cell.width)).min(width - x),
1243                    height: (u32::from(rect.height) * u32::from(cell.height)).min(height - y),
1244                };
1245                Some(TerminalImagePlacement {
1246                    image_id: rect.image_id,
1247                    image: stored.image.clone(),
1248                    row: i32::from(rect.row),
1249                    col: i32::from(rect.col),
1250                    rows: rect.height,
1251                    cols: rect.width,
1252                    z: 0,
1253                    source_crop: Some(crop),
1254                })
1255            })
1256            .collect()
1257    }
1258
1259    /// Handle one command.
1260    pub(super) fn apply(
1261        &mut self,
1262        command: GraphicsCommand,
1263        ctx: GraphicsContext,
1264    ) -> GraphicsOutcome {
1265        self.clock = self.clock.wrapping_add(1);
1266        match command.action {
1267            GraphicsAction::Query => self.query(&command),
1268            GraphicsAction::Delete => {
1269                self.delete(&command, ctx);
1270                GraphicsOutcome::default()
1271            }
1272            GraphicsAction::Display => self.display_stored(&command, ctx),
1273            GraphicsAction::Transmit | GraphicsAction::TransmitAndDisplay => {
1274                self.transmit(command, ctx)
1275            }
1276            GraphicsAction::Animate => GraphicsOutcome {
1277                response: report(&command, command.id, Err("ENOTSUPP:animation")),
1278                advance: None,
1279            },
1280        }
1281    }
1282
1283    /// Answer a capability probe without storing anything.
1284    ///
1285    /// A probe carries a real (tiny) payload, so it is validated exactly like a transmission: the
1286    /// client learns from the answer whether this terminal understands the format it wants to use.
1287    fn query(&mut self, command: &GraphicsCommand) -> GraphicsOutcome {
1288        let result = match command.medium {
1289            GraphicsMedium::OutOfBand => Err("ENOTSUPP:file transmission"),
1290            GraphicsMedium::Direct => decode_payload(command, &command.payload).map(|_| ()),
1291        };
1292        GraphicsOutcome {
1293            response: report(command, command.id, result),
1294            advance: None,
1295        }
1296    }
1297
1298    fn transmit(&mut self, command: GraphicsCommand, ctx: GraphicsContext) -> GraphicsOutcome {
1299        if command.medium == GraphicsMedium::OutOfBand {
1300            self.pending = None;
1301            return GraphicsOutcome {
1302                response: report(&command, command.id, Err("ENOTSUPP:file transmission")),
1303                advance: None,
1304            };
1305        }
1306        if command.more || self.pending.is_some() {
1307            return self.transmit_chunked(command, ctx);
1308        }
1309        let id = self.resolve_id(command.id, command.number);
1310        let payload = command.payload.clone();
1311        self.finish_transmit(id, &command, payload, ctx)
1312    }
1313
1314    /// Accumulate a `m=1` run.
1315    ///
1316    /// Only the first chunk carries the format and display keys; every later chunk is payload with
1317    /// an `m` flag, so the first chunk's command is what the completed transmission is judged by.
1318    fn transmit_chunked(
1319        &mut self,
1320        command: GraphicsCommand,
1321        ctx: GraphicsContext,
1322    ) -> GraphicsOutcome {
1323        let mut pending = self.pending.take().unwrap_or_else(|| PendingTransmit {
1324            id: 0,
1325            header: command.clone(),
1326            data: Vec::new(),
1327        });
1328        if pending.id == 0 {
1329            pending.id = self.resolve_id(pending.header.id, pending.header.number);
1330        }
1331
1332        if pending.data.len().saturating_add(command.payload.len()) > MAX_TRANSMIT_BYTES {
1333            return GraphicsOutcome {
1334                response: report(&command, pending.id, Err("EFBIG:payload too large")),
1335                advance: None,
1336            };
1337        }
1338        pending.data.extend_from_slice(&command.payload);
1339
1340        if command.more {
1341            self.pending = Some(pending);
1342            return GraphicsOutcome::default();
1343        }
1344        self.finish_transmit(pending.id, &pending.header, pending.data, ctx)
1345    }
1346
1347    fn finish_transmit(
1348        &mut self,
1349        id: u32,
1350        command: &GraphicsCommand,
1351        payload: Vec<u8>,
1352        ctx: GraphicsContext,
1353    ) -> GraphicsOutcome {
1354        let decoded = match decode_payload(command, &payload) {
1355            Ok(image) => image,
1356            Err(error) => {
1357                return GraphicsOutcome {
1358                    response: report(command, id, Err(error)),
1359                    advance: None,
1360                };
1361            }
1362        };
1363
1364        let bytes = decoded_bytes(&decoded);
1365        let image = TerminalImage {
1366            pixels: Arc::new(decoded),
1367            source_hash: hash_payload(command.format, &payload),
1368        };
1369        self.insert_image(id, image, bytes);
1370        if command.number != 0 {
1371            self.numbers.insert(command.number, id);
1372        }
1373
1374        GraphicsOutcome {
1375            response: report(command, id, Ok(())),
1376            advance: (command.action == GraphicsAction::TransmitAndDisplay)
1377                .then(|| self.place(id, command, ctx))
1378                .flatten(),
1379        }
1380    }
1381
1382    fn display_stored(
1383        &mut self,
1384        command: &GraphicsCommand,
1385        ctx: GraphicsContext,
1386    ) -> GraphicsOutcome {
1387        let id = match self.lookup(command.id, command.number) {
1388            Some(id) => id,
1389            None => {
1390                return GraphicsOutcome {
1391                    response: report(command, command.id, Err("ENOENT:no such image")),
1392                    advance: None,
1393                };
1394            }
1395        };
1396        let advance = self.place(id, command, ctx);
1397        GraphicsOutcome {
1398            response: report(command, id, Ok(())),
1399            advance,
1400        }
1401    }
1402
1403    /// Add a placement, returning the cursor movement it implies.
1404    fn place(
1405        &mut self,
1406        id: u32,
1407        command: &GraphicsCommand,
1408        ctx: GraphicsContext,
1409    ) -> Option<(u16, u16)> {
1410        let clock = self.clock;
1411        let (image_w, image_h) = {
1412            let stored = self.images.get_mut(&id)?;
1413            stored.used = clock;
1414            (stored.image.width(), stored.image.height())
1415        };
1416        // A virtual placement draws nothing here and moves nothing: the sender goes on to write
1417        // placeholder cells naming this image, and those are what put it on screen.
1418        if command.virtual_placement {
1419            return None;
1420        }
1421        if image_w == 0 || image_h == 0 {
1422            return None;
1423        }
1424
1425        let crop = source_crop(command, image_w, image_h);
1426        let (src_w, src_h) = crop
1427            .map(|crop| (crop.width, crop.height))
1428            .unwrap_or((image_w, image_h));
1429
1430        // Cells the image occupies: what the client asked for, else what its pixels need.
1431        let cols = match command.cols {
1432            0 => src_w.div_ceil(u32::from(ctx.cell.width)),
1433            cols => cols,
1434        };
1435        let rows = match command.rows {
1436            0 => src_h.div_ceil(u32::from(ctx.cell.height)),
1437            rows => rows,
1438        };
1439        let cols = cols.clamp(1, u32::from(ctx.cols.max(1))) as u16;
1440        let rows = rows.clamp(1, u32::from(u16::MAX)) as u16;
1441
1442        // A second placement with the same ids replaces the first, as the protocol specifies.
1443        self.placements.retain(|placement| {
1444            placement.image_id != id || placement.placement_id != command.placement
1445        });
1446        self.placements.push(Placement {
1447            image_id: id,
1448            placement_id: command.placement,
1449            line: ctx.cursor_line,
1450            col: ctx.cursor_col,
1451            rows,
1452            cols,
1453            z: command.z,
1454            crop,
1455            alt_screen: ctx.alt_screen,
1456        });
1457        while self.placements.len() > MAX_PLACEMENTS {
1458            self.placements.remove(0);
1459        }
1460
1461        (!command.no_cursor_move).then_some((rows, cols))
1462    }
1463
1464    fn delete(&mut self, command: &GraphicsCommand, ctx: GraphicsContext) {
1465        // An uppercase selector also frees the image data; lowercase only drops placements.
1466        let free_data = command.delete.is_ascii_uppercase();
1467        let selector = command.delete.to_ascii_lowercase();
1468        // Screen-addressed deletes use 1-based viewport coordinates.
1469        let target_col = command.src_x.saturating_sub(1).min(u32::from(u16::MAX)) as u16;
1470        let target_line = ctx
1471            .viewport_top_line
1472            .saturating_add(command.src_y.saturating_sub(1) as usize);
1473
1474        let hit: Box<dyn Fn(&Placement) -> bool> = match selector {
1475            b'a' => Box::new(|_| true),
1476            b'i' => {
1477                let (id, placement) = (command.id, command.placement);
1478                Box::new(move |item| {
1479                    item.image_id == id && (placement == 0 || item.placement_id == placement)
1480                })
1481            }
1482            b'n' => {
1483                let id = self.numbers.get(&command.number).copied().unwrap_or(0);
1484                Box::new(move |item| id != 0 && item.image_id == id)
1485            }
1486            b'c' => {
1487                let (line, col) = (ctx.cursor_line, ctx.cursor_col);
1488                Box::new(move |item| item.covers_cell(line, col))
1489            }
1490            b'z' => {
1491                let z = command.z;
1492                Box::new(move |item| item.z == z)
1493            }
1494            b'p' => Box::new(move |item| item.covers_cell(target_line, target_col)),
1495            b'x' => Box::new(move |item| item.covers_column(target_col)),
1496            b'y' => Box::new(move |item| item.covers_line(target_line)),
1497            _ => return,
1498        };
1499
1500        let mut freed: Vec<u32> = Vec::new();
1501        self.placements.retain(|item| {
1502            if !hit(item) {
1503                return true;
1504            }
1505            if free_data {
1506                freed.push(item.image_id);
1507            }
1508            false
1509        });
1510
1511        if free_data {
1512            match selector {
1513                // "Delete all" frees every stored image, placed or not.
1514                b'a' => {
1515                    let ids: Vec<u32> = self.images.keys().copied().collect();
1516                    for id in ids {
1517                        self.remove_image(id);
1518                    }
1519                }
1520                b'i' if command.placement == 0 => self.remove_image(command.id),
1521                b'n' => {
1522                    if let Some(id) = self.numbers.get(&command.number).copied() {
1523                        self.remove_image(id);
1524                    }
1525                }
1526                _ => {
1527                    for id in freed {
1528                        self.remove_image(id);
1529                    }
1530                }
1531            }
1532        }
1533    }
1534
1535    fn insert_image(&mut self, id: u32, image: TerminalImage, bytes: usize) {
1536        self.remove_image(id);
1537        let clock = self.clock;
1538        self.images.insert(
1539            id,
1540            StoredImage {
1541                image,
1542                bytes,
1543                used: clock,
1544            },
1545        );
1546        self.used_bytes = self.used_bytes.saturating_add(bytes);
1547        self.enforce_budget();
1548    }
1549
1550    fn remove_image(&mut self, id: u32) {
1551        if let Some(stored) = self.images.remove(&id) {
1552            self.used_bytes = self.used_bytes.saturating_sub(stored.bytes);
1553        }
1554        self.numbers.retain(|_, mapped| *mapped != id);
1555        self.placements.retain(|placement| placement.image_id != id);
1556    }
1557
1558    /// Drop least-recently-used images until the decoded-pixel budget is met.
1559    ///
1560    /// Placed images are not exempt: a session that keeps drawing must not be able to pin memory
1561    /// just by leaving old plots on screen. The placement goes with the pixels, so an evicted
1562    /// image disappears rather than rendering as a hole.
1563    ///
1564    /// The last image standing is never evicted. One picture larger than the whole budget is a
1565    /// budget that is too small, not a picture that should silently fail to appear.
1566    fn enforce_budget(&mut self) {
1567        while self.used_bytes > self.budget && self.images.len() > 1 {
1568            let victim = self
1569                .images
1570                .iter()
1571                .min_by_key(|(_, stored)| (stored.used, stored.bytes))
1572                .map(|(id, _)| *id);
1573            let Some(victim) = victim else { break };
1574            self.remove_image(victim);
1575        }
1576    }
1577
1578    /// The id a command addresses, for commands that do not create one.
1579    fn lookup(&self, id: u32, number: u32) -> Option<u32> {
1580        if id != 0 {
1581            return self.images.contains_key(&id).then_some(id);
1582        }
1583        let mapped = *self.numbers.get(&number)?;
1584        self.images.contains_key(&mapped).then_some(mapped)
1585    }
1586
1587    /// The id a transmission stores under, assigning one when the client did not.
1588    fn resolve_id(&mut self, id: u32, number: u32) -> u32 {
1589        if id != 0 {
1590            return id;
1591        }
1592        if number != 0
1593            && let Some(existing) = self.numbers.get(&number).copied()
1594        {
1595            return existing;
1596        }
1597        let assigned = self.next_auto_id;
1598        self.next_auto_id = self.next_auto_id.checked_add(1).unwrap_or(FIRST_AUTO_ID);
1599        assigned
1600    }
1601}
1602
1603fn source_crop(command: &GraphicsCommand, width: u32, height: u32) -> Option<TerminalImageCrop> {
1604    if command.src_x == 0 && command.src_y == 0 && command.src_w == 0 && command.src_h == 0 {
1605        return None;
1606    }
1607    let x = command.src_x.min(width.saturating_sub(1));
1608    let y = command.src_y.min(height.saturating_sub(1));
1609    let w = match command.src_w {
1610        0 => width - x,
1611        requested => requested.min(width - x),
1612    };
1613    let h = match command.src_h {
1614        0 => height - y,
1615        requested => requested.min(height - y),
1616    };
1617    (w > 0 && h > 0).then_some(TerminalImageCrop {
1618        x,
1619        y,
1620        width: w,
1621        height: h,
1622    })
1623}
1624
1625/// Decode a transmitted payload into pixels, or say why it could not be.
1626///
1627/// Errors are the protocol's own codes, so they can be reported straight back to the child.
1628fn decode_payload(command: &GraphicsCommand, payload: &[u8]) -> Result<DynamicImage, &'static str> {
1629    let mut data = if command.compressed {
1630        decompress(payload).ok_or("EINVAL:bad zlib payload")?
1631    } else {
1632        payload.to_vec()
1633    };
1634
1635    match command.format {
1636        100 => decode_png(&data),
1637        format @ (24 | 32) => {
1638            let channels = if format == 24 { 3usize } else { 4usize };
1639            let (width, height) = (command.width, command.height);
1640            if width == 0 || height == 0 {
1641                return Err("EINVAL:missing s/v for raw pixels");
1642            }
1643            if width > MAX_IMAGE_DIMENSION || height > MAX_IMAGE_DIMENSION {
1644                return Err("EFBIG:image too large");
1645            }
1646            let expected = (width as usize)
1647                .checked_mul(height as usize)
1648                .and_then(|pixels| pixels.checked_mul(channels))
1649                .ok_or("EFBIG:image too large")?;
1650            if data.len() < expected {
1651                return Err("EINVAL:truncated pixel payload");
1652            }
1653            data.truncate(expected);
1654            if channels == 3 {
1655                image::RgbImage::from_raw(width, height, data).map(DynamicImage::ImageRgb8)
1656            } else {
1657                image::RgbaImage::from_raw(width, height, data).map(DynamicImage::ImageRgba8)
1658            }
1659            .ok_or("EINVAL:bad pixel payload")
1660        }
1661        _ => Err("ENOTSUPP:unsupported format"),
1662    }
1663}
1664
1665fn decode_png(data: &[u8]) -> Result<DynamicImage, &'static str> {
1666    let mut reader =
1667        image::ImageReader::with_format(std::io::Cursor::new(data), image::ImageFormat::Png);
1668    let mut limits = image::Limits::default();
1669    limits.max_image_width = Some(MAX_IMAGE_DIMENSION);
1670    limits.max_image_height = Some(MAX_IMAGE_DIMENSION);
1671    limits.max_alloc = Some(MAX_TRANSMIT_BYTES as u64);
1672    reader.limits(limits);
1673    reader.decode().map_err(|_| "EINVAL:bad PNG payload")
1674}
1675
1676fn decompress(payload: &[u8]) -> Option<Vec<u8>> {
1677    use std::io::Read as _;
1678
1679    let mut out = Vec::new();
1680    flate2::read::ZlibDecoder::new(payload)
1681        .take(MAX_TRANSMIT_BYTES as u64)
1682        .read_to_end(&mut out)
1683        .ok()?;
1684    Some(out)
1685}
1686
1687fn decoded_bytes(image: &DynamicImage) -> usize {
1688    (image.width() as usize)
1689        .saturating_mul(image.height() as usize)
1690        .saturating_mul(4)
1691}
1692
1693fn hash_payload(format: u32, payload: &[u8]) -> u64 {
1694    use std::hash::{Hash as _, Hasher as _};
1695
1696    let mut hasher = std::collections::hash_map::DefaultHasher::new();
1697    format.hash(&mut hasher);
1698    payload.hash(&mut hasher);
1699    hasher.finish()
1700}
1701
1702/// Build the protocol's report for a command, when it asked for one.
1703fn report(command: &GraphicsCommand, id: u32, result: Result<(), &str>) -> Option<Vec<u8>> {
1704    if !command.reports(result.is_ok()) {
1705        return None;
1706    }
1707    let mut response = format!("\x1b_Gi={id}");
1708    if command.number != 0 {
1709        let _ = write!(response, ",I={}", command.number);
1710    }
1711    if command.placement != 0 {
1712        let _ = write!(response, ",p={}", command.placement);
1713    }
1714    let body = result.err().unwrap_or("OK");
1715    let _ = write!(response, ";{body}\x1b\\");
1716    Some(response.into_bytes())
1717}
1718
1719#[cfg(test)]
1720mod tests {
1721    use super::*;
1722
1723    /// A base64 direct-transmission command for a `width` x `height` solid RGB image.
1724    fn rgb_command(keys: &str, width: u32, height: u32) -> Vec<u8> {
1725        let pixels = vec![0xa0u8; (width * height * 3) as usize];
1726        let payload = BASE64.encode(pixels);
1727        format!("\x1b_Gf=24,s={width},v={height},t=d,{keys};{payload}\x1b\\").into_bytes()
1728    }
1729
1730    fn context() -> GraphicsContext {
1731        GraphicsContext {
1732            cursor_line: 0,
1733            cursor_col: 0,
1734            viewport_top_line: 0,
1735            alt_screen: false,
1736            cell: TerminalCellSize::new(10, 20),
1737            cols: 80,
1738        }
1739    }
1740
1741    fn scan_all(scanner: &mut GraphicsScanner, bytes: &[u8]) -> (Vec<u8>, Vec<GraphicsCommand>) {
1742        let mut text = Vec::new();
1743        let mut commands = Vec::new();
1744        for segment in scanner.scan(bytes) {
1745            match segment {
1746                GraphicsSegment::Text(range) => text.extend_from_slice(&bytes[range]),
1747                GraphicsSegment::HeldEscape => text.push(0x1b),
1748                GraphicsSegment::Command(command) => commands.push(*command),
1749            }
1750        }
1751        (text, commands)
1752    }
1753
1754    #[test]
1755    fn scanner_lifts_commands_out_of_surrounding_text() {
1756        let mut scanner = GraphicsScanner::default();
1757        let mut stream = b"before".to_vec();
1758        stream.extend_from_slice(&rgb_command("a=T", 2, 2));
1759        stream.extend_from_slice(b"after");
1760
1761        let (text, commands) = scan_all(&mut scanner, &stream);
1762        assert_eq!(text, b"beforeafter");
1763        assert_eq!(commands.len(), 1);
1764        assert_eq!(commands[0].action, GraphicsAction::TransmitAndDisplay);
1765        assert_eq!(commands[0].payload.len(), 2 * 2 * 3);
1766    }
1767
1768    #[test]
1769    fn scanner_survives_a_command_split_across_chunks() {
1770        let command = rgb_command("a=T", 2, 2);
1771        // Every split point must produce the same command: the PTY chooses where chunks land.
1772        for split in 1..command.len() {
1773            let mut scanner = GraphicsScanner::default();
1774            let (head_text, head) = scan_all(&mut scanner, &command[..split]);
1775            let (tail_text, tail) = scan_all(&mut scanner, &command[split..]);
1776            assert!(
1777                head_text.is_empty() && tail_text.is_empty(),
1778                "split at {split} leaked graphics bytes into the grid stream"
1779            );
1780            assert_eq!(
1781                head.len() + tail.len(),
1782                1,
1783                "split at {split} lost or duplicated the command"
1784            );
1785        }
1786    }
1787
1788    #[test]
1789    fn escape_that_is_not_a_command_reaches_the_grid() {
1790        let mut scanner = GraphicsScanner::default();
1791        // Split so the chunk ends on the bare ESC, the case `is_plain` must refuse.
1792        assert!(!scanner.is_plain(b"red\x1b"));
1793        let (first, _) = scan_all(&mut scanner, b"red\x1b");
1794        let (second, commands) = scan_all(&mut scanner, b"[0m");
1795        let mut text = first;
1796        text.extend_from_slice(&second);
1797        assert_eq!(text, b"red\x1b[0m");
1798        assert!(commands.is_empty());
1799    }
1800
1801    #[test]
1802    fn non_graphics_apc_is_swallowed_like_the_vt_parser_would() {
1803        let mut scanner = GraphicsScanner::default();
1804        let (text, commands) = scan_all(&mut scanner, b"a\x1b_Xsomething\x1b\\b");
1805        assert_eq!(text, b"ab");
1806        assert!(commands.is_empty());
1807    }
1808
1809    #[test]
1810    fn transmit_and_display_places_the_image_and_moves_the_cursor() {
1811        let mut graphics = TerminalGraphics::default();
1812        let mut scanner = GraphicsScanner::default();
1813        // 30x40 pixels in 10x20 cells is 3 columns by 2 rows.
1814        let (_, commands) = scan_all(&mut scanner, &rgb_command("a=T,i=7", 30, 40));
1815
1816        let outcome = graphics.apply(commands[0].clone(), context());
1817        assert_eq!(outcome.advance, Some((2, 3)));
1818
1819        let visible = graphics.visible(0, 0, 24, false);
1820        assert_eq!(visible.len(), 1);
1821        assert_eq!((visible[0].row, visible[0].col), (0, 0));
1822        assert_eq!((visible[0].rows, visible[0].cols), (2, 3));
1823    }
1824
1825    #[test]
1826    fn explicit_cell_size_overrides_the_pixel_size() {
1827        let mut graphics = TerminalGraphics::default();
1828        let mut scanner = GraphicsScanner::default();
1829        let (_, commands) = scan_all(&mut scanner, &rgb_command("a=T,c=8,r=4", 30, 40));
1830
1831        let outcome = graphics.apply(commands[0].clone(), context());
1832        assert_eq!(outcome.advance, Some((4, 8)));
1833    }
1834
1835    #[test]
1836    fn suppressed_cursor_movement_still_places() {
1837        let mut graphics = TerminalGraphics::default();
1838        let mut scanner = GraphicsScanner::default();
1839        let (_, commands) = scan_all(&mut scanner, &rgb_command("a=T,C=1", 30, 40));
1840
1841        let outcome = graphics.apply(commands[0].clone(), context());
1842        assert_eq!(outcome.advance, None);
1843        assert_eq!(graphics.visible(0, 0, 24, false).len(), 1);
1844    }
1845
1846    #[test]
1847    fn a_probe_is_answered_without_storing_anything() {
1848        let mut graphics = TerminalGraphics::default();
1849        let mut scanner = GraphicsScanner::default();
1850        let (_, commands) = scan_all(&mut scanner, &rgb_command("a=q,i=31", 1, 1));
1851
1852        let outcome = graphics.apply(commands[0].clone(), context());
1853        assert_eq!(
1854            outcome.response.as_deref(),
1855            Some(b"\x1b_Gi=31;OK\x1b\\".as_ref())
1856        );
1857        assert!(graphics.visible(0, 0, 24, false).is_empty());
1858    }
1859
1860    #[test]
1861    fn out_of_band_transmission_is_refused_in_the_protocol_s_own_terms() {
1862        let mut graphics = TerminalGraphics::default();
1863        let mut scanner = GraphicsScanner::default();
1864        let (_, commands) = scan_all(&mut scanner, b"\x1b_Ga=T,t=f,i=3;L3RtcC9pbWcucG5n\x1b\\");
1865
1866        let outcome = graphics.apply(commands[0].clone(), context());
1867        let response = String::from_utf8(outcome.response.expect("a refusal is reported")).unwrap();
1868        assert!(
1869            response.contains("ENOTSUPP"),
1870            "unexpected report: {response}"
1871        );
1872    }
1873
1874    #[test]
1875    fn quiet_two_suppresses_even_failures() {
1876        let mut graphics = TerminalGraphics::default();
1877        let mut scanner = GraphicsScanner::default();
1878        let (_, commands) = scan_all(&mut scanner, b"\x1b_Ga=T,t=f,q=2;Lw==\x1b\\");
1879
1880        assert!(
1881            graphics
1882                .apply(commands[0].clone(), context())
1883                .response
1884                .is_none()
1885        );
1886    }
1887
1888    #[test]
1889    fn chunked_transmission_reassembles_before_decoding() {
1890        let mut graphics = TerminalGraphics::default();
1891        let pixels = vec![0x40u8; 30 * 40 * 3];
1892        let encoded = BASE64.encode(&pixels);
1893        let (head, tail) = encoded.split_at(encoded.len() / 2);
1894
1895        let mut scanner = GraphicsScanner::default();
1896        let mut stream = format!("\x1b_Ga=T,f=24,s=30,v=40,t=d,i=9,m=1;{head}\x1b\\").into_bytes();
1897        stream.extend_from_slice(format!("\x1b_Gm=0;{tail}\x1b\\").as_bytes());
1898        let (_, commands) = scan_all(&mut scanner, &stream);
1899        assert_eq!(commands.len(), 2);
1900
1901        assert!(
1902            graphics
1903                .apply(commands[0].clone(), context())
1904                .advance
1905                .is_none()
1906        );
1907        let outcome = graphics.apply(commands[1].clone(), context());
1908        assert_eq!(outcome.advance, Some((2, 3)));
1909        assert_eq!(graphics.visible(0, 0, 24, false).len(), 1);
1910    }
1911
1912    #[test]
1913    fn deleting_by_id_drops_the_placement() {
1914        let mut graphics = TerminalGraphics::default();
1915        let mut scanner = GraphicsScanner::default();
1916        let (_, commands) = scan_all(&mut scanner, &rgb_command("a=T,i=4", 30, 40));
1917        graphics.apply(commands[0].clone(), context());
1918
1919        let (_, deletes) = scan_all(&mut scanner, b"\x1b_Ga=d,d=i,i=4;\x1b\\");
1920        graphics.apply(deletes[0].clone(), context());
1921        assert!(graphics.visible(0, 0, 24, false).is_empty());
1922    }
1923
1924    #[test]
1925    fn evicted_scrollback_pulls_placements_up_and_then_off() {
1926        let mut graphics = TerminalGraphics::default();
1927        let mut scanner = GraphicsScanner::default();
1928        let (_, commands) = scan_all(&mut scanner, &rgb_command("a=T", 30, 40));
1929        let mut ctx = context();
1930        ctx.cursor_line = 5;
1931        graphics.apply(commands[0].clone(), ctx);
1932
1933        graphics.drop_evicted(3);
1934        assert_eq!(graphics.visible(0, 0, 24, false)[0].row, 2);
1935
1936        // Two rows tall: evicting the top row leaves the bottom one, still on screen.
1937        graphics.drop_evicted(3);
1938        assert_eq!(graphics.visible(0, 0, 24, false)[0].row, 0);
1939
1940        // The placement only disappears once its last row is gone too.
1941        graphics.drop_evicted(4);
1942        assert!(graphics.visible(0, 0, 24, false).is_empty());
1943    }
1944
1945    #[test]
1946    fn alt_screen_placements_are_kept_apart_from_the_primary_ones() {
1947        let mut graphics = TerminalGraphics::default();
1948        let mut scanner = GraphicsScanner::default();
1949        let (_, commands) = scan_all(&mut scanner, &rgb_command("a=T,i=1", 30, 40));
1950        graphics.apply(commands[0].clone(), context());
1951
1952        let (_, alt) = scan_all(&mut scanner, &rgb_command("a=T,i=2", 30, 40));
1953        let mut alt_ctx = context();
1954        alt_ctx.alt_screen = true;
1955        graphics.apply(alt[0].clone(), alt_ctx);
1956
1957        assert_eq!(graphics.visible(0, 0, 24, true).len(), 1);
1958        assert_eq!(graphics.visible(0, 0, 24, false).len(), 1);
1959
1960        graphics.clear_alt_screen();
1961        assert!(graphics.visible(0, 0, 24, true).is_empty());
1962        assert_eq!(graphics.visible(0, 0, 24, false).len(), 1);
1963    }
1964
1965    #[test]
1966    fn the_budget_evicts_least_recently_used_images() {
1967        let mut graphics = TerminalGraphics::default();
1968        // Room for one 30x40 image's decoded pixels, and no more.
1969        graphics.set_budget(30 * 40 * 4);
1970
1971        let mut scanner = GraphicsScanner::default();
1972        let (_, first) = scan_all(&mut scanner, &rgb_command("a=T,i=1", 30, 40));
1973        graphics.apply(first[0].clone(), context());
1974        let (_, second) = scan_all(&mut scanner, &rgb_command("a=T,i=2", 31, 40));
1975        graphics.apply(second[0].clone(), context());
1976
1977        let visible = graphics.visible(0, 0, 24, false);
1978        assert_eq!(visible.len(), 1, "the older image must have been evicted");
1979        assert_eq!(visible[0].image.width(), 31);
1980    }
1981
1982    #[test]
1983    fn a_source_rectangle_is_carried_to_the_renderer() {
1984        let mut graphics = TerminalGraphics::default();
1985        let mut scanner = GraphicsScanner::default();
1986        let (_, commands) = scan_all(&mut scanner, &rgb_command("a=T,x=5,y=6,w=10,h=12", 30, 40));
1987        graphics.apply(commands[0].clone(), context());
1988
1989        let visible = graphics.visible(0, 0, 24, false);
1990        assert_eq!(
1991            visible[0].source_crop,
1992            Some(TerminalImageCrop {
1993                x: 5,
1994                y: 6,
1995                width: 10,
1996                height: 12,
1997            })
1998        );
1999        // The placement is sized from the crop, not from the whole image.
2000        assert_eq!((visible[0].rows, visible[0].cols), (1, 1));
2001    }
2002
2003    #[test]
2004    fn a_large_unchunked_transmission_is_not_dropped() {
2005        // The protocol tells senders to chunk at 4096 base64 bytes, but plenty do not - anything
2006        // emitting raw pixels in one escape clears 64 KiB with a picture barely 300 cells wide.
2007        // Dropping those on the floor is indistinguishable, from the sender's side, from the
2008        // terminal not supporting graphics at all.
2009        let mut graphics = TerminalGraphics::default();
2010        let mut scanner = GraphicsScanner::default();
2011        let (text, commands) = scan_all(&mut scanner, &rgb_command("a=T,i=1", 280, 160));
2012
2013        assert!(
2014            text.is_empty(),
2015            "the escape must not leak into the grid stream"
2016        );
2017        assert_eq!(
2018            commands.len(),
2019            1,
2020            "a large single-escape transmit must survive scanning"
2021        );
2022        assert_eq!(
2023            graphics.apply(commands[0].clone(), context()).advance,
2024            Some((8, 28))
2025        );
2026    }
2027
2028    #[test]
2029    fn a_truncated_raw_payload_is_reported_rather_than_drawn() {
2030        let mut graphics = TerminalGraphics::default();
2031        let mut scanner = GraphicsScanner::default();
2032        // Claims 30x40 RGB but sends one pixel.
2033        let payload = BASE64.encode([1u8, 2, 3]);
2034        let (_, commands) = scan_all(
2035            &mut scanner,
2036            format!("\x1b_Ga=T,f=24,s=30,v=40,t=d;{payload}\x1b\\").as_bytes(),
2037        );
2038
2039        let outcome = graphics.apply(commands[0].clone(), context());
2040        let response = String::from_utf8(outcome.response.expect("a refusal is reported")).unwrap();
2041        assert!(response.contains("EINVAL"), "unexpected report: {response}");
2042        assert!(graphics.visible(0, 0, 24, false).is_empty());
2043    }
2044}