From b201d0104ecdbe26e270e762b6386518b03b5393 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Tue, 9 Jun 2026 20:11:37 +0700 Subject: [PATCH] feat(core): deterministic ANSI snapshot of the grid for reattach repaint Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/spacesh-core/src/lib.rs | 5 +- crates/spacesh-core/src/snapshot.rs | 132 ++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+), 3 deletions(-) create mode 100644 crates/spacesh-core/src/snapshot.rs diff --git a/crates/spacesh-core/src/lib.rs b/crates/spacesh-core/src/lib.rs index 50db45b..107702d 100644 --- a/crates/spacesh-core/src/lib.rs +++ b/crates/spacesh-core/src/lib.rs @@ -1,6 +1,5 @@ pub mod grid; -// snapshot module added in Task 12 -// pub mod snapshot; +pub mod snapshot; pub use grid::GridSurface; -// pub use snapshot::Snapshot; +pub use snapshot::Snapshot; diff --git a/crates/spacesh-core/src/snapshot.rs b/crates/spacesh-core/src/snapshot.rs new file mode 100644 index 0000000..85597a1 --- /dev/null +++ b/crates/spacesh-core/src/snapshot.rs @@ -0,0 +1,132 @@ +use serde::Serialize; +use alacritty_terminal::index::Point; +use alacritty_terminal::term::cell::Flags; +use alacritty_terminal::vte::ansi::Color; +use crate::grid::GridSurface; + +/// Serializable snapshot returned by `attach`. +#[derive(Debug, Clone, Serialize)] +pub struct Snapshot { + /// ANSI byte dump suitable for `xterm.write()`. + pub ansi: String, + pub cols: u16, + pub rows: u16, + /// 1-based cursor position. + pub cursor_row: u16, + pub cursor_col: u16, +} + +fn sgr_for_color(c: Color, foreground: bool) -> String { + let base = if foreground { 38 } else { 48 }; + match c { + Color::Named(named) => { + // Map common named colors to SGR; default fg/bg reset for the rest. + use alacritty_terminal::vte::ansi::NamedColor; + let code = match named { + NamedColor::Black => Some(if foreground { 30 } else { 40 }), + NamedColor::Red => Some(if foreground { 31 } else { 41 }), + NamedColor::Green => Some(if foreground { 32 } else { 42 }), + NamedColor::Yellow => Some(if foreground { 33 } else { 43 }), + NamedColor::Blue => Some(if foreground { 34 } else { 44 }), + NamedColor::Magenta => Some(if foreground { 35 } else { 45 }), + NamedColor::Cyan => Some(if foreground { 36 } else { 46 }), + NamedColor::White => Some(if foreground { 37 } else { 47 }), + NamedColor::BrightBlack => Some(if foreground { 90 } else { 100 }), + NamedColor::BrightRed => Some(if foreground { 91 } else { 101 }), + NamedColor::BrightGreen => Some(if foreground { 92 } else { 102 }), + NamedColor::BrightYellow => Some(if foreground { 93 } else { 103 }), + NamedColor::BrightBlue => Some(if foreground { 94 } else { 104 }), + NamedColor::BrightMagenta => Some(if foreground { 95 } else { 105 }), + NamedColor::BrightCyan => Some(if foreground { 96 } else { 106 }), + NamedColor::BrightWhite => Some(if foreground { 97 } else { 107 }), + _ => None, // Foreground/Background/Cursor etc. → use reset. + }; + match code { + Some(n) => format!("{n}"), + None => format!("{}", if foreground { 39 } else { 49 }), + } + } + Color::Indexed(i) => format!("{base};5;{i}"), + Color::Spec(rgb) => format!("{base};2;{};{};{}", rgb.r, rgb.g, rgb.b), + } +} + +/// Serialize the visible grid into an ANSI dump. +pub fn snapshot_ansi(g: &GridSurface) -> Snapshot { + let size = g.size(); + let term = g.term(); + let grid = term.grid(); + + let mut out = String::new(); + out.push_str("\x1b[2J\x1b[H"); // clear + home + + let cols = size.cols; + let lines = size.lines; + + // Track the last emitted attributes to avoid redundant SGR sequences. + let mut last: Option<(Color, Color, Flags)> = None; + + for line in 0..lines { + for col in 0..cols { + let point = Point::new(alacritty_terminal::index::Line(line as i32), alacritty_terminal::index::Column(col)); + let cell = &grid[point]; + let cur = (cell.fg, cell.bg, cell.flags); + if last != Some(cur) { + let mut codes: Vec = vec!["0".into()]; // reset, then re-apply + if cell.flags.contains(Flags::BOLD) { codes.push("1".into()); } + if cell.flags.contains(Flags::DIM) { codes.push("2".into()); } + if cell.flags.contains(Flags::ITALIC) { codes.push("3".into()); } + if cell.flags.contains(Flags::UNDERLINE) { codes.push("4".into()); } + if cell.flags.contains(Flags::INVERSE) { codes.push("7".into()); } + codes.push(sgr_for_color(cell.fg, true)); + codes.push(sgr_for_color(cell.bg, false)); + out.push_str(&format!("\x1b[{}m", codes.join(";"))); + last = Some(cur); + } + out.push(cell.c); + } + out.push_str("\r\n"); + } + out.push_str("\x1b[0m"); // reset attributes at end + + let cursor = grid.cursor.point; + let cursor_row = (cursor.line.0 as i64 + 1).clamp(1, lines as i64) as u16; + let cursor_col = (cursor.column.0 as i64 + 1).clamp(1, cols as i64) as u16; + out.push_str(&format!("\x1b[{cursor_row};{cursor_col}H")); + + Snapshot { + ansi: out, + cols: cols as u16, + rows: lines as u16, + cursor_row, + cursor_col, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn snapshot_contains_fed_text_and_is_deterministic() { + let mut g = GridSurface::new(10, 3); + g.feed(b"hi"); + let a = snapshot_ansi(&g); + let b = snapshot_ansi(&g); + assert_eq!(a.ansi, b.ansi, "snapshot must be deterministic"); + assert!(a.ansi.contains("hi")); + assert!(a.ansi.starts_with("\x1b[2J\x1b[H")); + assert_eq!(a.cols, 10); + assert_eq!(a.rows, 3); + } + + #[test] + fn cursor_is_one_based_after_input() { + let mut g = GridSurface::new(10, 3); + g.feed(b"abc"); + let s = snapshot_ansi(&g); + // After 'abc' the cursor sits at column 4 (1-based) on row 1. + assert_eq!(s.cursor_row, 1); + assert_eq!(s.cursor_col, 4); + } +}