feat(core): deterministic ANSI snapshot of the grid for reattach repaint
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<String> = 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user