Skip to main content

tear_types/
pane_snapshot.rs

1//! Typed pane snapshot — the wire payload that ferries a rendered
2//! pane state from `tear-core` / `tear-daemon` to a consumer.
3//!
4//! Lives in `tear-types` (not `tear-core`) because the wire (and
5//! therefore `tear-client`) needs to deserialize these without
6//! pulling in the parser. The parser side (`tear_core::PaneGrid`)
7//! constructs these via `PaneGrid::snapshot()`.
8
9use serde::{Deserialize, Serialize};
10use std::io::Write;
11
12// ── Color ──────────────────────────────────────────────────────────
13
14/// 24-bit RGB color. Default ANSI palette entries are concrete
15/// values (see [`default_ansi_palette`]); SGR 38/48 5;n / 38;2;r;g;b
16/// resolve to one of these via the consumer's theme. The wire only
17/// ferries explicit RGB so consumers don't have to share a palette.
18#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
19pub struct Color {
20    pub r: u8,
21    pub g: u8,
22    pub b: u8,
23}
24
25impl Color {
26    pub const WHITE: Self = Self { r: 255, g: 255, b: 255 };
27    pub const BLACK: Self = Self { r: 0, g: 0, b: 0 };
28
29    #[must_use]
30    pub const fn new(r: u8, g: u8, b: u8) -> Self {
31        Self { r, g, b }
32    }
33}
34
35impl Default for Color {
36    fn default() -> Self {
37        Self::WHITE
38    }
39}
40
41/// Standard 8-color ANSI palette (normal intensity). Ported from
42/// mado's `terminal::ANSI_COLORS` — the canonical fleet palette
43/// (the same one mado renders with).
44pub const ANSI_COLORS: [Color; 8] = [
45    Color::new(0, 0, 0),       // 0 black
46    Color::new(205, 49, 49),   // 1 red
47    Color::new(13, 188, 121),  // 2 green
48    Color::new(229, 229, 16),  // 3 yellow
49    Color::new(36, 114, 200),  // 4 blue
50    Color::new(188, 63, 188),  // 5 magenta
51    Color::new(17, 168, 205),  // 6 cyan
52    Color::new(229, 229, 229), // 7 white
53];
54
55/// Bright ANSI palette (indices 8-15). Ported from mado.
56pub const ANSI_BRIGHT_COLORS: [Color; 8] = [
57    Color::new(102, 102, 102), // 8  bright black
58    Color::new(241, 76, 76),   // 9  bright red
59    Color::new(35, 209, 139),  // 10 bright green
60    Color::new(245, 245, 67),  // 11 bright yellow
61    Color::new(59, 142, 234),  // 12 bright blue
62    Color::new(214, 112, 214), // 13 bright magenta
63    Color::new(41, 184, 219),  // 14 bright cyan
64    Color::new(255, 255, 255), // 15 bright white
65];
66
67/// Build the default 16-color ANSI palette from the const arrays.
68#[must_use]
69pub fn default_ansi_palette() -> [Color; 16] {
70    let mut palette = [Color::BLACK; 16];
71    palette[..8].copy_from_slice(&ANSI_COLORS);
72    palette[8..].copy_from_slice(&ANSI_BRIGHT_COLORS);
73    palette
74}
75
76/// Resolve a 256-color index (SGR 38;5;n / 48;5;n) into a concrete
77/// RGB color via the given palette. Ported from mado verbatim so
78/// both apps interpret 256-color indices identically.
79#[must_use]
80pub fn ansi_256_color(idx: u16, palette: &[Color; 16]) -> Color {
81    match idx {
82        0..=15 => palette[idx as usize],
83        16..=231 => {
84            let idx = idx - 16;
85            let r_idx = idx / 36;
86            let g_idx = (idx % 36) / 6;
87            let b_idx = idx % 6;
88            let to_byte = |i: u16| -> u8 {
89                if i == 0 { 0 } else { (55 + 40 * i) as u8 }
90            };
91            Color::new(to_byte(r_idx), to_byte(g_idx), to_byte(b_idx))
92        }
93        232..=255 => {
94            let v = (8 + 10 * (idx - 232)) as u8;
95            Color::new(v, v, v)
96        }
97        _ => Color::WHITE,
98    }
99}
100
101// ── CellAttrs ──────────────────────────────────────────────────────
102
103/// Bitflags-style attribute set. Bit positions match mado's
104/// `terminal::CellAttrs` so the two apps interpret SGR-derived
105/// attrs identically.
106#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
107pub struct CellAttrs(pub u8);
108
109impl CellAttrs {
110    pub const NONE: Self = Self(0);
111    pub const BOLD: Self = Self(1 << 0);
112    pub const ITALIC: Self = Self(1 << 1);
113    pub const UNDERLINE: Self = Self(1 << 2);
114    pub const BLINK: Self = Self(1 << 3);
115    pub const INVERSE: Self = Self(1 << 4);
116    pub const STRIKETHROUGH: Self = Self(1 << 5);
117    pub const DIM: Self = Self(1 << 6);
118    pub const HIDDEN: Self = Self(1 << 7);
119
120    #[must_use]
121    pub const fn contains(self, other: Self) -> bool {
122        (self.0 & other.0) == other.0
123    }
124
125    pub fn insert(&mut self, other: Self) {
126        self.0 |= other.0;
127    }
128
129    pub fn remove(&mut self, other: Self) {
130        self.0 &= !other.0;
131    }
132
133    #[must_use]
134    pub const fn is_empty(self) -> bool {
135        self.0 == 0
136    }
137
138    #[must_use]
139    pub const fn bits(self) -> u8 {
140        self.0
141    }
142}
143
144// ── Cell ───────────────────────────────────────────────────────────
145
146/// One cell in a snapshotted pane. Carries the rendered character
147/// + foreground / background colors + attrs + display width.
148/// Hyperlink / combining-char fields stay mado-side until a later
149/// phase ports mado's full Cell wholesale.
150#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
151pub struct Cell {
152    pub ch: char,
153    pub fg: Color,
154    pub bg: Color,
155    pub attrs: CellAttrs,
156    /// Display columns this cell occupies: `1` normal, `2` the LEAD of a
157    /// double-width glyph, `0` the CONTINUATION cell owned by the lead to
158    /// its left.
159    ///
160    /// `#[serde(default = "width_one")]` and NOT a bare `#[serde(default)]`:
161    /// bare default is `0`, which means *continuation*, so an old daemon's
162    /// snapshot would decode as a grid of continuation cells and a renderer
163    /// that skips width-0 would draw a completely blank pane. This is the
164    /// highest-consequence default in the type.
165    #[serde(default = "width_one")]
166    pub width: u8,
167    /// Combining marks attached to this cell, as a 1-based index into
168    /// [`PaneSnapshot::combining`]. `0` means none — the overwhelmingly
169    /// common case, and why this is an index rather than an inline box.
170    ///
171    /// ## Why interned and not `Option<Box<Vec<char>>>`
172    ///
173    /// mado stores marks inline on the cell, and copying that shape here
174    /// would cost `Cell` its `Copy`. That matters because
175    /// `PaneGrid::snapshot` clones the **entire scrollback**, whose default
176    /// cap is `usize::MAX` rows: with `Copy` those clones are a memcpy,
177    /// while a boxed field turns every one into a per-cell branch plus drop
178    /// glue over potentially millions of cells.
179    ///
180    /// Interning keeps the hot path a memcpy and moves the rare data to the
181    /// side, at the cost of one extra `u16` per cell (`Cell` is 16 bytes,
182    /// still under mado's 24-byte budget).
183    #[serde(default)]
184    pub combining: u16,
185}
186
187/// serde default for [`Cell::width`] — see the field's doc for why this is
188/// not `Default::default()`.
189fn width_one() -> u8 {
190    1
191}
192
193impl Cell {
194    pub const BLANK: Self = Self {
195        ch: ' ',
196        fg: Color::WHITE,
197        bg: Color::BLACK,
198        attrs: CellAttrs::NONE,
199        width: 1,
200        combining: 0,
201    };
202
203    /// True when this cell is the second half of a double-width glyph and
204    /// therefore owns no character of its own.
205    #[must_use]
206    pub const fn is_continuation(&self) -> bool {
207        self.width == 0
208    }
209
210    /// The combining marks attached to this cell, resolved against a
211    /// snapshot's [`PaneSnapshot::combining`] table.
212    ///
213    /// Empty for the common case. An index that outruns the table also
214    /// yields empty rather than panicking — a truncated or mismatched
215    /// table is a wire-level defect that must not take the renderer down.
216    #[must_use]
217    pub fn marks<'a>(&self, table: &'a [Vec<char>]) -> &'a [char] {
218        if self.combining == 0 {
219            return &[];
220        }
221        table
222            .get(self.combining as usize - 1)
223            .map_or(&[], Vec::as_slice)
224    }
225}
226
227impl Default for Cell {
228    fn default() -> Self {
229        Self::BLANK
230    }
231}
232
233// ── Snapshot ───────────────────────────────────────────────────────
234
235/// Serializable snapshot of one pane's visible grid + cursor. Sent
236/// over the tear-daemon ↔ tear-client wire so consumers can render
237/// without holding a reference into the live parser state.
238#[derive(Clone, Debug, Serialize, Deserialize)]
239pub struct PaneSnapshot {
240    pub rows: usize,
241    pub cols: usize,
242    pub cells: Vec<Vec<Cell>>,
243    pub cursor_row: usize,
244    pub cursor_col: usize,
245    /// True when the alternate screen buffer is active (vim, less,
246    /// htop, btop, etc. all enter this). Consumers may want to
247    /// suppress scrollback rendering when alt-screen is on.
248    #[serde(default)]
249    pub alt_screen_active: bool,
250    /// Cursor visibility (DEC mode 25). When false, renderers
251    /// should not draw the cursor cell. Defaults to true (cursor
252    /// shows by default per xterm semantics).
253    #[serde(default = "default_true")]
254    pub cursor_visible: bool,
255    /// Window/tab title set via OSC 0 / OSC 2. None until first
256    /// title set; clears to None on RIS.
257    #[serde(default)]
258    pub title: Option<String>,
259    /// DECCKM (DEC mode 1) — cursor-keys application mode.
260    ///
261    /// When true, the running program has requested application-
262    /// mode cursor keys (ESC O A/B/C/D) instead of normal-mode
263    /// (ESC [ A/B/C/D). Mado's input encoder (and any other
264    /// consumer that translates host keystrokes to PTY bytes)
265    /// reads this to pick the right sequence so editors and
266    /// pagers (vim, less, htop, …) receive the cursor keys they
267    /// expect.
268    ///
269    /// Resets to false on RIS (ESC c), DECSTR (ESC [ ! p), and
270    /// when DECCKM is explicitly reset (ESC [ ? 1 l).
271    ///
272    /// Serde default is `false` so wire payloads from older
273    /// daemons that don't emit this field deserialize cleanly.
274    #[serde(default)]
275    pub cursor_keys_mode: bool,
276    /// Bounded scrollback rows that have rolled off the top of the
277    /// primary screen, oldest first. Carried in the snapshot so a
278    /// consumer re-attaching to (or switching back to) a pane restores
279    /// its full history — without this, a session switch replays only
280    /// the visible grid and the scrollback is lost. Empty on the
281    /// alternate screen (vim/htop/less have no meaningful scrollback to
282    /// restore). `#[serde(default)]` so older wire payloads that omit
283    /// it deserialize cleanly to no scrollback.
284    #[serde(default)]
285    pub scrollback: Vec<Vec<Cell>>,
286    /// Combining-mark table. [`Cell::combining`] indexes this 1-based
287    /// (`0` = no marks), so entry `n` is at index `n - 1`.
288    ///
289    /// Resolve through [`Cell::marks`] rather than indexing directly — it
290    /// handles the empty case and a short table without panicking.
291    ///
292    /// **Growth is bounded by history, not by time**: entries accumulate as
293    /// marks are printed and are carried whole in the snapshot. For an
294    /// unbounded scrollback (tear's default) that is the same order as the
295    /// text itself. For a *bounded* scrollback, entries belonging to
296    /// evicted rows are not reclaimed — a named follow-up
297    /// (`pending-combining-gc`), and the reason mado's style/link tables
298    /// carry a gc that remaps live ids.
299    #[serde(default)]
300    pub combining: Vec<Vec<char>>,
301    /// Every terminal mode this pane was in **at the instant these cells
302    /// were taken**.
303    ///
304    /// Carried here rather than fetched separately, and that is the point:
305    /// a client that could ask for modes independently could render frame
306    /// N's cells while encoding a keystroke under frame N+1's modes —
307    /// bracketed paste toggling in the gap between the grid you drew and
308    /// the key you sent. Because a `ModeSet` is only obtainable from the
309    /// snapshot it came from, that skew has no representation.
310    ///
311    /// See `crate::modes` for why each mode is its own type.
312    #[serde(default)]
313    pub modes: crate::modes::ModeSet,
314    /// Images transmitted into this pane, **undecoded**, in arrival order.
315    ///
316    /// Carrying them is what makes the authority lossless: before this,
317    /// `GridState` implemented no DCS `hook`/`put`/`unhook` and vte
318    /// swallows APC in its `SosPmApcString` state, so every sixel and every
319    /// kitty image disappeared with no error and no flag — a renderer could
320    /// not even know content had been dropped.
321    ///
322    /// Bytes, not pixels: see [`crate::graphics`] for why decoding stays
323    /// with the renderer and the daemon needs no image crate.
324    #[serde(default)]
325    pub graphics: Vec<crate::graphics::Graphic>,
326}
327
328fn default_true() -> bool {
329    true
330}
331
332impl PaneSnapshot {
333    /// Project to plain text — one String per row, blanks rendered
334    /// as ASCII spaces. Drops color/attr information; useful for
335    /// assertions and grep-style introspection.
336    #[must_use]
337    pub fn to_text_rows(&self) -> Vec<String> {
338        self.cells
339            .iter()
340            .map(|row| row.iter().map(|c| c.ch).collect::<String>())
341            .collect()
342    }
343
344    /// Joined text grid (rows separated by `\n`).
345    #[must_use]
346    pub fn to_text(&self) -> String {
347        self.to_text_rows().join("\n")
348    }
349
350    /// Serialize the snapshot as a stream of ANSI bytes that, when
351    /// fed into a fresh VT parser, reproduces the snapshot state
352    /// (cells, colors, attrs, cursor, alt-screen, cursor-visibility).
353    ///
354    /// The bug class this kills: a producer (tear pane) starts
355    /// emitting before a consumer (mado terminal model) attaches via
356    /// `subscribe_pane_bytes`. The early bytes (shell prompt, vim
357    /// initial frame) reach tear's grid but never the consumer; the
358    /// consumer's local model stays empty even though tear's snapshot
359    /// shows the right content. Calling `to_ansi()` and feeding the
360    /// result into the consumer's VT parser BEFORE the live byte
361    /// stream begins guarantees the consumer's model matches the
362    /// producer's grid at attach time.
363    ///
364    /// Long-term home: this lives in `engate` as the canonical
365    /// "history replay" operation in the typed attach protocol —
366    /// `EngateAttach<Synced>` is constructed by feeding `to_ansi()`
367    /// bytes through the consumer's parser, then subscribing to the
368    /// live stream.
369    #[must_use]
370    pub fn to_ansi(&self) -> Vec<u8> {
371        let mut buf: Vec<u8> = Vec::with_capacity(self.rows * self.cols * 4 + 64);
372        // Enter alt-screen first if the pane is in alt-screen mode
373        // (vim / htop / less). Without this the cells would paint over
374        // the primary screen, corrupting it when the app exits alt.
375        if self.alt_screen_active {
376            buf.extend_from_slice(b"\x1b[?1049h");
377        }
378        // Scrollback restore (primary screen only). Lay each rolled-off
379        // row down as a line, then scroll `rows` blank lines past them so
380        // every scrollback row lands in the CONSUMER's scrollback buffer
381        // BEFORE the visible grid repaints. Without this a re-attach /
382        // session switch replays only the visible grid and the history is
383        // lost. The visible-grid emission below is byte-identical to
384        // before, so this can only ADD history, never disturb the screen.
385        // Trailing blanks are trimmed per row, so a full-width row can't
386        // auto-wrap into a spurious blank line.
387        if !self.alt_screen_active && !self.scrollback.is_empty() {
388            buf.extend_from_slice(b"\x1b[0m\x1b[2J\x1b[H");
389            for row in &self.scrollback {
390                write_scrollback_row(&mut buf, row, &self.combining);
391                buf.extend_from_slice(b"\x1b[0m\r\n");
392            }
393            // Push the last `rows` scrollback lines off-screen (into the
394            // scrollback buffer) so the upcoming `\x1b[2J` can't erase them.
395            for _ in 0..self.rows {
396                buf.extend_from_slice(b"\r\n");
397            }
398        }
399        // Reset SGR, clear screen, home cursor.
400        buf.extend_from_slice(b"\x1b[0m\x1b[2J\x1b[H");
401        // Track current SGR state so we only emit deltas.
402        let mut cur_fg = Color::WHITE;
403        let mut cur_bg = Color::BLACK;
404        let mut cur_attrs = CellAttrs::NONE;
405        for (r, row) in self.cells.iter().enumerate() {
406            // Move to start of this row (1-based CSI).
407            let _ = write!(buf, "\x1b[{};1H", r + 1);
408            for cell in row {
409                // A continuation cell is the second half of a double-width
410                // glyph and owns no character. Re-emitting its spacer would
411                // move the rest of the row one column right PER wide glyph on
412                // replay, so a snapshot round-trip would not be identity.
413                //
414                // This `continue` must stay AHEAD of the SGR delta blocks
415                // below: a skipped cell emits nothing, so letting it update
416                // `cur_fg`/`cur_bg`/`cur_attrs` would leave the pen tracking
417                // state that was never written.
418                //
419                // It is also what keeps the dirty-pen fix intact — no skipped
420                // cell can introduce an SGR after the closing `\x1b[0m`. Do
421                // NOT "optimise" this by emitting the continuation's SGR to
422                // keep the pen in sync; that re-opens that class.
423                if cell.is_continuation() {
424                    continue;
425                }
426                if cell.attrs != cur_attrs {
427                    // Attrs only get cleared by full SGR reset — emit
428                    // reset + re-establish colors + new attrs.
429                    buf.extend_from_slice(b"\x1b[0m");
430                    cur_fg = Color::WHITE;
431                    cur_bg = Color::BLACK;
432                    write_sgr_attrs(&mut buf, cell.attrs);
433                    cur_attrs = cell.attrs;
434                }
435                if cell.fg != cur_fg {
436                    let _ = write!(buf, "\x1b[38;2;{};{};{}m", cell.fg.r, cell.fg.g, cell.fg.b);
437                    cur_fg = cell.fg;
438                }
439                if cell.bg != cur_bg {
440                    let _ = write!(buf, "\x1b[48;2;{};{};{}m", cell.bg.r, cell.bg.g, cell.bg.b);
441                    cur_bg = cell.bg;
442                }
443                let mut tmp = [0u8; 4];
444                buf.extend_from_slice(cell.ch.encode_utf8(&mut tmp).as_bytes());
445                // Combining marks follow their base glyph and add no
446                // columns, so they need no cursor arithmetic — but omitting
447                // them would silently strip every accent on replay.
448                for m in cell.marks(&self.combining) {
449                    buf.extend_from_slice(m.encode_utf8(&mut tmp).as_bytes());
450                }
451            }
452        }
453        // CLOSE THE PEN. This serializer walks cells and emits SGR only on
454        // *change*, so whatever the final cell required is still latched when
455        // the bytes run out. The consumer's parser then applies it to
456        // everything that follows.
457        //
458        // That is what turned a one-shot replay artifact into a permanent one.
459        // mado feeds `to_ansi` into its VT on attach and on every session
460        // switch (`engate_consumer::replay` -> `gui_tear_attach`), so a dirty
461        // final pen underlined/dimmed the entire live stream afterwards — the
462        // "everything is underlined in mado" report. It is guaranteed dirty
463        // whenever the pen is, because `blank_cell()` fills erased cells from
464        // `pen_attrs`/`pen_bg`, so even trailing blanks carry the attribute.
465        //
466        // Emitted BEFORE the cursor CUP so the reset cannot clobber the
467        // position, and before `?25l` so visibility survives it (SGR 0 does
468        // not touch DECTCEM, but ordering it this way removes the question).
469        buf.extend_from_slice(b"\x1b[0m");
470        // Position cursor (CSI is 1-based).
471        let _ = write!(
472            buf,
473            "\x1b[{};{}H",
474            self.cursor_row + 1,
475            self.cursor_col + 1
476        );
477        // Cursor visibility.
478        if !self.cursor_visible {
479            buf.extend_from_slice(b"\x1b[?25l");
480        }
481        buf
482    }
483}
484
485/// Emit SGR attribute bytes for the given attr set (does NOT include
486/// the leading reset — caller resets first if previous state had
487/// attrs the new state doesn't). Each attr gets its own CSI sequence
488/// for simplicity; size cost is negligible vs the cell payload.
489#[cfg(test)]
490mod to_ansi_tests {
491    use super::*;
492
493    fn snap_with(rows: usize, cols: usize, ch: char) -> PaneSnapshot {
494        PaneSnapshot {
495            rows,
496            cols,
497            cells: (0..rows)
498                .map(|_| (0..cols).map(|_| Cell { ch, ..Cell::BLANK }).collect())
499                .collect(),
500            cursor_row: 0,
501            cursor_col: 0,
502            alt_screen_active: false,
503            cursor_visible: true,
504            title: None,
505            cursor_keys_mode: false,
506            scrollback: Vec::new(),
507            combining: Vec::new(),
508            modes: crate::modes::ModeSet::default(),
509            graphics: Vec::new(),
510        }
511    }
512
513    #[test]
514    fn empty_grid_emits_clear_and_home() {
515        let s = snap_with(2, 3, ' ');
516        let bytes = s.to_ansi();
517        let text = String::from_utf8_lossy(&bytes);
518        assert!(text.contains("\x1b[0m"));
519        assert!(text.contains("\x1b[2J"));
520        assert!(text.contains("\x1b[H"));
521        assert!(text.contains("\x1b[1;1H"));
522    }
523
524    /// THE PEN MUST BE CLOSED — the last SGR in the stream is a reset.
525    ///
526    /// `empty_grid_emits_clear_and_home` above asserts `contains("\x1b[0m")`,
527    /// which the LEADING reset satisfies. That assertion passed for the whole
528    /// life of the bug while the tail leaked: `to_ansi` emits SGR only on
529    /// change, so whatever the final cell required stayed latched when the
530    /// bytes ran out, and mado's parser applied it to the entire live stream
531    /// that followed. Containment is the wrong predicate; POSITION is the
532    /// property. This pins the last SGR, not the presence of one.
533    #[test]
534    fn to_ansi_closes_the_pen_so_a_dirty_attribute_cannot_escape() {
535        let mut s = snap_with(1, 3, 'x');
536        // A final cell that REQUIRES an SGR — without a closing reset its
537        // attribute is what the consumer inherits.
538        s.cells[0][2] = Cell {
539            ch: 'x',
540            attrs: CellAttrs::UNDERLINE,
541            ..Cell::BLANK
542        };
543        let bytes = s.to_ansi();
544        let text = String::from_utf8_lossy(&bytes);
545
546        // Every SGR in emission order; the last one must be the reset.
547        let sgrs: Vec<&str> = text
548            .match_indices('\u{1b}')
549            .filter_map(|(i, _)| {
550                let rest = &text[i..];
551                rest.find('m').and_then(|end| {
552                    let seq = &rest[..=end];
553                    // SGR only: CSI ... m with no intervening CSI final byte.
554                    if seq.starts_with("\u{1b}[") && !seq[2..end].contains(['H', 'J', '?']) {
555                        Some(seq)
556                    } else {
557                        None
558                    }
559                })
560            })
561            .collect();
562
563        assert!(!sgrs.is_empty(), "expected SGR sequences, got: {text:?}");
564        assert_eq!(
565            *sgrs.last().unwrap(),
566            "\u{1b}[0m",
567            "the last SGR must be a reset, or the pen escapes into the \
568             consumer's live stream; full emission: {sgrs:?}"
569        );
570    }
571
572    #[test]
573    fn cells_appear_in_output() {
574        let mut s = snap_with(1, 5, 'x');
575        s.cells[0][2].ch = 'Y';
576        let text = String::from_utf8_lossy(&s.to_ansi()).into_owned();
577        assert!(text.contains("xxYxx"), "got: {text:?}");
578    }
579
580    #[test]
581    fn scrollback_rows_are_emitted_before_the_visible_grid() {
582        // A pane with one scrollback row ("HISTORY") and a visible grid
583        // ("VIS"). to_ansi must lay the history down first so a re-attach
584        // restores it into the consumer's scrollback.
585        let mut s = snap_with(1, 7, ' ');
586        for (i, ch) in "VIS".chars().enumerate() {
587            s.cells[0][i].ch = ch;
588        }
589        let mut hist: Vec<Cell> = (0..7).map(|_| Cell::BLANK).collect();
590        for (i, ch) in "HISTORY".chars().enumerate() {
591            hist[i].ch = ch;
592        }
593        s.scrollback = vec![hist];
594        let text = String::from_utf8_lossy(&s.to_ansi()).into_owned();
595        let h = text.find("HISTORY").expect("history emitted");
596        let v = text.find("VIS").expect("visible grid emitted");
597        assert!(h < v, "scrollback must precede the visible grid: {text:?}");
598    }
599
600    #[test]
601    fn alt_screen_suppresses_scrollback_emission() {
602        // On the alternate screen there is no scrollback to restore.
603        let mut s = snap_with(1, 3, 'a');
604        s.alt_screen_active = true;
605        let mut hist: Vec<Cell> = (0..3).map(|_| Cell::BLANK).collect();
606        hist[0].ch = 'Z';
607        s.scrollback = vec![hist];
608        let text = String::from_utf8_lossy(&s.to_ansi()).into_owned();
609        assert!(!text.contains('Z'), "alt-screen must not emit scrollback: {text:?}");
610    }
611
612    #[test]
613    fn cursor_position_emitted_one_based() {
614        let mut s = snap_with(5, 5, ' ');
615        s.cursor_row = 3;
616        s.cursor_col = 2;
617        let text = String::from_utf8_lossy(&s.to_ansi()).into_owned();
618        assert!(text.contains("\x1b[4;3H"), "got: {text:?}");
619    }
620
621    #[test]
622    fn alt_screen_active_prepends_csi_1049h() {
623        let mut s = snap_with(1, 1, ' ');
624        s.alt_screen_active = true;
625        let bytes = s.to_ansi();
626        assert!(bytes.starts_with(b"\x1b[?1049h"));
627    }
628
629    #[test]
630    fn invisible_cursor_emits_csi_25l() {
631        let mut s = snap_with(1, 1, ' ');
632        s.cursor_visible = false;
633        let text = String::from_utf8_lossy(&s.to_ansi()).into_owned();
634        assert!(text.contains("\x1b[?25l"));
635    }
636
637    // ── Expanded coverage: colors ─────────────────────────────────
638
639    #[test]
640    fn fg_color_change_emits_truecolor_sgr() {
641        let mut s = snap_with(1, 1, 'r');
642        s.cells[0][0].fg = Color::new(255, 100, 0); // orange
643        let text = String::from_utf8_lossy(&s.to_ansi()).into_owned();
644        assert!(text.contains("\x1b[38;2;255;100;0m"), "got: {text:?}");
645    }
646
647    #[test]
648    fn bg_color_change_emits_truecolor_sgr() {
649        let mut s = snap_with(1, 1, ' ');
650        s.cells[0][0].bg = Color::new(0, 50, 100);
651        let text = String::from_utf8_lossy(&s.to_ansi()).into_owned();
652        assert!(text.contains("\x1b[48;2;0;50;100m"), "got: {text:?}");
653    }
654
655    #[test]
656    fn both_fg_and_bg_change_in_one_cell() {
657        let mut s = snap_with(1, 1, '!');
658        s.cells[0][0].fg = Color::new(10, 20, 30);
659        s.cells[0][0].bg = Color::new(200, 150, 100);
660        let text = String::from_utf8_lossy(&s.to_ansi()).into_owned();
661        assert!(text.contains("\x1b[38;2;10;20;30m"));
662        assert!(text.contains("\x1b[48;2;200;150;100m"));
663        assert!(text.contains('!'));
664    }
665
666    // ── Expanded coverage: each CellAttrs flag ────────────────────
667
668    #[test]
669    fn each_cellattr_flag_emits_matching_sgr() {
670        let cases: &[(CellAttrs, &str)] = &[
671            (CellAttrs::BOLD,          "\x1b[1m"),
672            (CellAttrs::DIM,           "\x1b[2m"),
673            (CellAttrs::ITALIC,        "\x1b[3m"),
674            (CellAttrs::UNDERLINE,     "\x1b[4m"),
675            (CellAttrs::BLINK,         "\x1b[5m"),
676            (CellAttrs::INVERSE,       "\x1b[7m"),
677            (CellAttrs::HIDDEN,        "\x1b[8m"),
678            (CellAttrs::STRIKETHROUGH, "\x1b[9m"),
679        ];
680        for (attr, expected_sgr) in cases {
681            let mut s = snap_with(1, 1, 'a');
682            s.cells[0][0].attrs = *attr;
683            let text = String::from_utf8_lossy(&s.to_ansi()).into_owned();
684            assert!(
685                text.contains(expected_sgr),
686                "attr {:?} should emit {:?}, got {text:?}",
687                attr,
688                expected_sgr
689            );
690        }
691    }
692
693    #[test]
694    fn combined_attrs_emit_all_sgr_codes() {
695        let mut s = snap_with(1, 1, 'a');
696        let mut combined = CellAttrs::NONE;
697        combined.insert(CellAttrs::BOLD);
698        combined.insert(CellAttrs::UNDERLINE);
699        combined.insert(CellAttrs::ITALIC);
700        s.cells[0][0].attrs = combined;
701        let text = String::from_utf8_lossy(&s.to_ansi()).into_owned();
702        assert!(text.contains("\x1b[1m"));
703        assert!(text.contains("\x1b[3m"));
704        assert!(text.contains("\x1b[4m"));
705    }
706
707    // ── Expanded coverage: SGR delta-encoding ─────────────────────
708
709    #[test]
710    fn identical_runs_do_not_re_emit_sgr() {
711        // Three cells with the same SGR state should emit the SGR
712        // sequence at most once for the row, not three times.
713        let mut s = snap_with(1, 3, 'x');
714        for c in &mut s.cells[0] {
715            c.fg = Color::new(50, 100, 150);
716        }
717        let text = String::from_utf8_lossy(&s.to_ansi()).into_owned();
718        let count = text.matches("\x1b[38;2;50;100;150m").count();
719        assert_eq!(count, 1, "expected 1 fg SGR for identical run, got {count}");
720    }
721
722    #[test]
723    fn fg_change_mid_row_re_emits_sgr_once() {
724        let mut s = snap_with(1, 4, 'a');
725        s.cells[0][0].fg = Color::new(255, 0, 0);
726        s.cells[0][1].fg = Color::new(255, 0, 0);
727        s.cells[0][2].fg = Color::new(0, 255, 0);
728        s.cells[0][3].fg = Color::new(0, 255, 0);
729        let text = String::from_utf8_lossy(&s.to_ansi()).into_owned();
730        assert_eq!(text.matches("\x1b[38;2;255;0;0m").count(), 1);
731        assert_eq!(text.matches("\x1b[38;2;0;255;0m").count(), 1);
732    }
733
734    // ── Expanded coverage: layout edge cases ──────────────────────
735
736    #[test]
737    fn each_row_gets_explicit_cursor_position() {
738        let s = snap_with(3, 2, '.');
739        let text = String::from_utf8_lossy(&s.to_ansi()).into_owned();
740        // Row 1, row 2, row 3 each emitted with CSI <r>;1H.
741        assert!(text.contains("\x1b[1;1H"));
742        assert!(text.contains("\x1b[2;1H"));
743        assert!(text.contains("\x1b[3;1H"));
744    }
745
746    #[test]
747    fn utf8_multibyte_chars_round_trip() {
748        let mut s = snap_with(1, 5, '·');
749        s.cells[0][0].ch = '日';
750        s.cells[0][1].ch = '本';
751        s.cells[0][2].ch = '語';
752        let text = String::from_utf8_lossy(&s.to_ansi()).into_owned();
753        assert!(text.contains("日本語"), "got: {text:?}");
754    }
755
756    #[test]
757    fn alt_screen_plus_cursor_hidden_combine() {
758        let mut s = snap_with(1, 1, ' ');
759        s.alt_screen_active = true;
760        s.cursor_visible = false;
761        let bytes = s.to_ansi();
762        let text = String::from_utf8_lossy(&bytes);
763        // alt-screen prelude first.
764        assert!(bytes.starts_with(b"\x1b[?1049h"));
765        // cursor-hide somewhere after.
766        assert!(text.contains("\x1b[?25l"));
767        // Ordering invariant: cursor-hide comes after final cursor
768        // position so the hidden cursor sits at the recorded coords
769        // (matters when the consumer later toggles visible again).
770        let pos_idx = text.find("\x1b[1;1H").unwrap();
771        let hide_idx = text.find("\x1b[?25l").unwrap();
772        assert!(hide_idx > pos_idx, "cursor hide must follow position");
773    }
774
775    #[test]
776    fn wide_grid_emits_proportional_bytes() {
777        // 80x24 = 1920 cells. Output should be at least that many
778        // chars (one byte per cell minimum). Sanity check that we
779        // don't accidentally truncate or omit rows.
780        let s = snap_with(24, 80, '*');
781        let bytes = s.to_ansi();
782        assert!(bytes.len() >= 1920, "expected >=1920 bytes, got {}", bytes.len());
783        // All 24 row-position CSI sequences present.
784        let text = String::from_utf8_lossy(&bytes);
785        for row in 1..=24 {
786            let csi = format!("\x1b[{row};1H");
787            assert!(text.contains(&csi), "row {row} CSI missing");
788        }
789    }
790}
791
792/// Emit one scrollback row as SGR-formatted text (fresh SGR state, no
793/// cursor positioning) for `to_ansi`'s scrollback restore. Trailing
794/// blank cells (a space in the default colours) are trimmed so a
795/// full-width row can't auto-wrap into a spurious extra blank line and
796/// so mostly-empty history rows stay compact.
797fn write_scrollback_row(buf: &mut Vec<u8>, row: &[Cell], combining: &[Vec<char>]) {
798    let last = row
799        .iter()
800        .rposition(|c| c.ch != ' ' || c.fg != Color::WHITE || c.bg != Color::BLACK)
801        .map_or(0, |i| i + 1);
802    let mut cur_fg = Color::WHITE;
803    let mut cur_bg = Color::BLACK;
804    let mut cur_attrs = CellAttrs::NONE;
805    for cell in &row[..last] {
806        // Skip the second half of a double-width glyph — same reasoning as
807        // the visible-grid loop in `to_ansi`, and it must stay ahead of the
808        // SGR delta blocks for the same reason.
809        //
810        // Note the trim predicate above classifies a continuation as blank
811        // (space, default colours). That is the RIGHT outcome — `rposition`
812        // stops at the non-blank lead and the consumer's parser re-lays the
813        // pair from the lead alone — but it is load-bearing and one refactor
814        // away from wrong, so it is pinned by a test.
815        if cell.is_continuation() {
816            continue;
817        }
818        if cell.attrs != cur_attrs {
819            buf.extend_from_slice(b"\x1b[0m");
820            cur_fg = Color::WHITE;
821            cur_bg = Color::BLACK;
822            write_sgr_attrs(buf, cell.attrs);
823            cur_attrs = cell.attrs;
824        }
825        if cell.fg != cur_fg {
826            let _ = write!(buf, "\x1b[38;2;{};{};{}m", cell.fg.r, cell.fg.g, cell.fg.b);
827            cur_fg = cell.fg;
828        }
829        if cell.bg != cur_bg {
830            let _ = write!(buf, "\x1b[48;2;{};{};{}m", cell.bg.r, cell.bg.g, cell.bg.b);
831            cur_bg = cell.bg;
832        }
833        let mut tmp = [0u8; 4];
834        buf.extend_from_slice(cell.ch.encode_utf8(&mut tmp).as_bytes());
835        for m in cell.marks(combining) {
836            buf.extend_from_slice(m.encode_utf8(&mut tmp).as_bytes());
837        }
838    }
839}
840
841fn write_sgr_attrs(buf: &mut Vec<u8>, attrs: CellAttrs) {
842    if attrs.contains(CellAttrs::BOLD)          { buf.extend_from_slice(b"\x1b[1m"); }
843    if attrs.contains(CellAttrs::DIM)           { buf.extend_from_slice(b"\x1b[2m"); }
844    if attrs.contains(CellAttrs::ITALIC)        { buf.extend_from_slice(b"\x1b[3m"); }
845    if attrs.contains(CellAttrs::UNDERLINE)     { buf.extend_from_slice(b"\x1b[4m"); }
846    if attrs.contains(CellAttrs::BLINK)         { buf.extend_from_slice(b"\x1b[5m"); }
847    if attrs.contains(CellAttrs::INVERSE)       { buf.extend_from_slice(b"\x1b[7m"); }
848    if attrs.contains(CellAttrs::HIDDEN)        { buf.extend_from_slice(b"\x1b[8m"); }
849    if attrs.contains(CellAttrs::STRIKETHROUGH) { buf.extend_from_slice(b"\x1b[9m"); }
850}