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}