use std::collections::BTreeSet;
use tastty::{Attrs, CursorStyle, Hyperlink, Position, Screen, TerminalMode, TerminalSize};
#[derive(Debug, Eq, PartialEq)]
#[non_exhaustive]
pub struct Snapshot {
pub(crate) size: TerminalSize,
pub(crate) cursor: Position,
pub(crate) cursor_visible: bool,
pub(crate) alternate_screen: bool,
pub(crate) title: String,
pub(crate) icon_name: String,
pub(crate) cells: Vec<Vec<CellSnapshot>>,
}
impl Snapshot {
#[must_use]
pub fn size(&self) -> TerminalSize {
self.size
}
#[must_use]
pub fn cursor(&self) -> Position {
self.cursor
}
#[must_use]
pub fn cursor_visible(&self) -> bool {
self.cursor_visible
}
#[must_use]
pub fn alternate_screen(&self) -> bool {
self.alternate_screen
}
#[must_use]
pub fn title(&self) -> &str {
&self.title
}
#[must_use]
pub fn icon_name(&self) -> &str {
&self.icon_name
}
#[must_use]
pub fn cells(&self) -> &[Vec<CellSnapshot>] {
&self.cells
}
#[must_use]
pub fn lines(&self) -> Vec<String> {
let cols = self.size.cols;
self.cells
.iter()
.map(|row| line_from_row(row, cols))
.collect()
}
#[must_use]
pub fn line(&self, row: u16) -> Option<String> {
self.cells
.get(row as usize)
.map(|cells| line_from_row(cells, self.size.cols))
}
#[must_use]
pub fn style_runs(&self) -> Vec<Vec<StyleRun>> {
self.cells
.iter()
.map(|row| style_runs_for_row(row))
.collect()
}
#[must_use]
pub fn text(&self) -> String {
self.lines().join("\n")
}
pub fn hyperlinks(&self) -> impl Iterator<Item = (Position, &Hyperlink)> + '_ {
self.cells
.iter()
.enumerate()
.flat_map(|(row, row_cells)| row_hyperlinks(row as u16, row_cells))
}
}
fn line_from_row(cells: &[CellSnapshot], cols: u16) -> String {
let mut text = String::new();
let mut col: u16 = 0;
while col < cols {
if let Some(cell) = cells.get(col as usize) {
if cell.wide_continuation {
col += 1;
continue;
}
if cell.contents.is_empty() {
text.push(' ');
} else {
text.push_str(&cell.contents);
}
col += if cell.wide { 2 } else { 1 };
} else {
text.push(' ');
col += 1;
}
}
text
}
fn row_hyperlinks(row: u16, cells: &[CellSnapshot]) -> Vec<(Position, &Hyperlink)> {
let mut out: Vec<(Position, &Hyperlink)> = Vec::new();
let mut prev: Option<&Hyperlink> = None;
for (col, cell) in cells.iter().enumerate() {
if cell.wide_continuation {
continue;
}
match cell.hyperlink.as_ref() {
Some(link) => {
if prev != Some(link) {
out.push((
Position {
row,
col: col as u16,
},
link,
));
}
prev = Some(link);
}
None => prev = None,
}
}
out
}
#[derive(Clone, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub struct CellSnapshot {
pub contents: String,
pub attrs: Attrs,
pub wide: bool,
pub wide_continuation: bool,
pub hyperlink: Option<Hyperlink>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub struct ScrollbackSnapshot {
pub lines: Vec<String>,
pub scrollback_lines: usize,
}
#[derive(Clone, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub struct InspectSnapshot {
pub modes: BTreeSet<TerminalMode>,
pub cursor_style: CursorStyle,
pub kitty_keyboard_flags: u8,
pub title: String,
pub icon_name: String,
pub size: TerminalSize,
}
#[derive(Clone, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub struct StyleRun {
pub start_col: u16,
pub end_col: u16,
pub attrs: Attrs,
}
pub(crate) fn snapshot_from_screen(screen: &Screen) -> Snapshot {
let size = screen.size();
let mut cells = Vec::with_capacity(size.rows as usize);
for row in 0..size.rows {
let mut row_cells: Vec<CellSnapshot> = Vec::with_capacity(size.cols as usize);
for col in 0..size.cols {
if let Some(cell) = screen.cell(row, col) {
row_cells.push(CellSnapshot {
contents: cell.contents().to_string(),
attrs: *cell.attrs(),
wide: cell.is_wide(),
wide_continuation: cell.is_wide_continuation(),
hyperlink: cell.hyperlink().cloned(),
});
}
}
cells.push(row_cells);
}
Snapshot {
size,
cursor: screen.cursor(),
cursor_visible: !screen.mode(TerminalMode::HideCursor),
alternate_screen: screen.mode(TerminalMode::AlternateScreen),
title: screen.title().to_string(),
icon_name: screen.icon_name().to_string(),
cells,
}
}
fn style_runs_for_row(cells: &[CellSnapshot]) -> Vec<StyleRun> {
let mut runs: Vec<StyleRun> = Vec::new();
for (col, cell) in cells.iter().enumerate() {
if cell.wide_continuation {
continue;
}
let col = col as u16;
if let Some(last) = runs.last_mut()
&& last.attrs == cell.attrs
&& last.end_col == col
{
last.end_col = col + cell_width(cell);
continue;
}
runs.push(StyleRun {
start_col: col,
end_col: col + cell_width(cell),
attrs: cell.attrs,
});
}
runs
}
fn cell_width(cell: &CellSnapshot) -> u16 {
if cell.wide { 2 } else { 1 }
}
pub(crate) fn scrollback_snapshot_from_screen(
screen: &Screen,
limit: Option<usize>,
) -> ScrollbackSnapshot {
let mut lines = screen.scrollback_contents(limit);
let scrollback_lines = lines.len();
lines.extend(screen.visible_text_rows());
ScrollbackSnapshot {
lines,
scrollback_lines,
}
}
pub(crate) fn inspect_snapshot_from_screen(screen: &Screen) -> InspectSnapshot {
let candidates = [
TerminalMode::ApplicationCursor,
TerminalMode::BracketedPaste,
TerminalMode::MouseReportClick,
TerminalMode::MouseReportCellMotion,
TerminalMode::MouseReportAllMotion,
TerminalMode::FocusInOut,
TerminalMode::SgrMouse,
TerminalMode::SgrPixelMouse,
TerminalMode::SyncUpdate,
TerminalMode::GraphemeCluster,
TerminalMode::ColorSchemeUpdates,
TerminalMode::InBandResize,
TerminalMode::AlternateScreen,
TerminalMode::HideCursor,
TerminalMode::ReverseVideo,
TerminalMode::AlternateScroll,
TerminalMode::MouseReportX10,
TerminalMode::BackspaceBs,
TerminalMode::LineFeedNewLine,
];
let modes = candidates.into_iter().filter(|m| screen.mode(*m)).collect();
InspectSnapshot {
modes,
cursor_style: screen.cursor_style(),
kitty_keyboard_flags: screen.kitty_keyboard_flags(),
title: screen.title().to_string(),
icon_name: screen.icon_name().to_string(),
size: screen.size(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeSet;
use tastty::{Parser, TerminalSize};
#[test]
fn inspect_modes_collects_active_terminal_modes() {
let mut parser = Parser::new(TerminalSize { rows: 4, cols: 16 }, 0);
parser.process(b"\x1b[?2004h\x1b[?25l\x1b[?1049h\x1b[?5h\x1b[?67h");
let snapshot = inspect_snapshot_from_screen(parser.screen());
let expected: BTreeSet<TerminalMode> = [
TerminalMode::BracketedPaste,
TerminalMode::HideCursor,
TerminalMode::AlternateScreen,
TerminalMode::ReverseVideo,
TerminalMode::BackspaceBs,
]
.into_iter()
.collect();
assert_eq!(snapshot.modes, expected);
}
#[test]
fn inspect_modes_empty_for_default_screen() {
let parser = Parser::new(TerminalSize { rows: 4, cols: 16 }, 0);
let snapshot = inspect_snapshot_from_screen(parser.screen());
assert!(snapshot.modes.is_empty());
}
#[test]
fn cell_snapshots_indexed_by_column() {
let mut parser = Parser::new(TerminalSize { rows: 1, cols: 5 }, 0);
parser.process(b"abcde");
let snap = snapshot_from_screen(parser.screen());
let row = &snap.cells()[0];
assert_eq!(row.len(), 5);
for (col, cell) in row.iter().enumerate() {
let glyph = b"abcde"[col] as char;
assert_eq!(cell.contents, glyph.to_string(), "col={col}");
}
}
#[test]
fn snapshot_preserves_osc8_hyperlink_directly() {
let mut parser = Parser::new(TerminalSize { rows: 1, cols: 4 }, 0);
parser.process(b"\x1b]8;;https://example.com/\x1b\\X\x1b]8;;\x1b\\");
let snap = snapshot_from_screen(parser.screen());
let cell = &snap.cells()[0][0];
let link = cell
.hyperlink
.as_ref()
.expect("OSC 8 hyperlink should be captured on the cell");
assert_eq!(link.uri.as_ref(), "https://example.com/");
assert!(link.id.is_none());
}
#[test]
fn hyperlinks_empty_when_no_links_present() {
let mut parser = Parser::new(TerminalSize { rows: 2, cols: 4 }, 0);
parser.process(b"abcdEFGH");
let snap = snapshot_from_screen(parser.screen());
assert_eq!(snap.hyperlinks().count(), 0);
}
#[test]
fn hyperlinks_yields_single_run_for_contiguous_same_link() {
let mut parser = Parser::new(TerminalSize { rows: 1, cols: 6 }, 0);
parser.process(b"\x1b]8;;https://example.com/\x1b\\ABC\x1b]8;;\x1b\\");
let snap = snapshot_from_screen(parser.screen());
let links: Vec<_> = snap.hyperlinks().collect();
assert_eq!(links.len(), 1);
let (pos, link) = links[0];
assert_eq!(pos, Position { row: 0, col: 0 });
assert_eq!(link.uri.as_ref(), "https://example.com/");
}
#[test]
fn hyperlinks_splits_runs_on_uri_change() {
let mut parser = Parser::new(TerminalSize { rows: 1, cols: 6 }, 0);
parser.process(b"\x1b]8;;https://a/\x1b\\X\x1b]8;;https://b/\x1b\\Y\x1b]8;;\x1b\\");
let snap = snapshot_from_screen(parser.screen());
let links: Vec<_> = snap.hyperlinks().collect();
assert_eq!(links.len(), 2);
assert_eq!(links[0].0, Position { row: 0, col: 0 });
assert_eq!(links[0].1.uri.as_ref(), "https://a/");
assert_eq!(links[1].0, Position { row: 0, col: 1 });
assert_eq!(links[1].1.uri.as_ref(), "https://b/");
}
#[test]
fn hyperlinks_splits_runs_on_id_change_with_same_uri() {
let mut parser = Parser::new(TerminalSize { rows: 1, cols: 6 }, 0);
parser.process(
b"\x1b]8;id=a;https://example.com/\x1b\\X\
\x1b]8;id=b;https://example.com/\x1b\\Y\
\x1b]8;;\x1b\\",
);
let snap = snapshot_from_screen(parser.screen());
let links: Vec<_> = snap.hyperlinks().collect();
assert_eq!(links.len(), 2);
assert_eq!(links[0].1.id.as_deref().map(AsRef::as_ref), Some("a"));
assert_eq!(links[1].1.id.as_deref().map(AsRef::as_ref), Some("b"));
}
#[test]
fn hyperlinks_treats_unlinked_cells_as_boundaries() {
let mut parser = Parser::new(TerminalSize { rows: 1, cols: 6 }, 0);
parser.process(
b"\x1b]8;;https://example.com/\x1b\\A\x1b]8;;\x1b\\B\
\x1b]8;;https://example.com/\x1b\\C\x1b]8;;\x1b\\",
);
let snap = snapshot_from_screen(parser.screen());
let links: Vec<_> = snap.hyperlinks().collect();
assert_eq!(links.len(), 2);
assert_eq!(links[0].0, Position { row: 0, col: 0 });
assert_eq!(links[1].0, Position { row: 0, col: 2 });
assert_eq!(links[0].1, links[1].1);
}
#[test]
fn hyperlinks_skips_wide_cell_continuation() {
let mut parser = Parser::new(TerminalSize { rows: 1, cols: 4 }, 0);
parser.process("\x1b]8;;https://example.com/\x1b\\中X\x1b]8;;\x1b\\".as_bytes());
let snap = snapshot_from_screen(parser.screen());
assert!(snap.cells()[0][1].wide_continuation);
let links: Vec<_> = snap.hyperlinks().collect();
assert_eq!(links.len(), 1);
assert_eq!(links[0].0, Position { row: 0, col: 0 });
}
#[test]
fn hyperlinks_does_not_cross_row_boundaries() {
let mut parser = Parser::new(TerminalSize { rows: 2, cols: 4 }, 0);
parser.process(b"\x1b]8;;https://example.com/\x1b\\ABCDEFGH\x1b]8;;\x1b\\");
let snap = snapshot_from_screen(parser.screen());
let links: Vec<_> = snap.hyperlinks().collect();
assert_eq!(links.len(), 2);
assert_eq!(links[0].0, Position { row: 0, col: 0 });
assert_eq!(links[1].0, Position { row: 1, col: 0 });
assert_eq!(links[0].1, links[1].1);
}
#[test]
fn lines_pad_each_row_to_column_width() {
let mut parser = Parser::new(TerminalSize { rows: 2, cols: 8 }, 0);
parser.process(b"hi");
let snap = snapshot_from_screen(parser.screen());
let lines = snap.lines();
assert_eq!(lines.len(), 2);
assert_eq!(lines[0].chars().count(), 8);
assert!(lines[0].starts_with("hi"));
assert!(lines[0].ends_with(" "));
assert_eq!(lines[1].chars().count(), 8);
assert_eq!(lines[1], " ");
}
#[test]
fn line_returns_same_padding_as_lines_for_indexed_row() {
let mut parser = Parser::new(TerminalSize { rows: 2, cols: 6 }, 0);
parser.process(b"ab");
let snap = snapshot_from_screen(parser.screen());
let lines = snap.lines();
assert_eq!(snap.line(0), Some(lines[0].clone()));
assert_eq!(snap.line(1), Some(lines[1].clone()));
assert_eq!(snap.line(2), None);
}
#[test]
fn lines_render_wide_glyph_in_one_char_and_pad_to_column_width() {
let mut parser = Parser::new(TerminalSize { rows: 1, cols: 6 }, 0);
parser.process("中X".as_bytes());
let snap = snapshot_from_screen(parser.screen());
let row = &snap.cells()[0];
assert!(row[0].wide);
assert!(row[1].wide_continuation);
assert_eq!(row[2].contents, "X");
let line = snap.line(0).expect("row 0 visible");
assert_eq!(line.chars().count(), 5);
assert!(line.starts_with("中X"));
assert!(line.ends_with(" "));
}
#[test]
fn text_joins_padded_rows_with_newlines() {
let mut parser = Parser::new(TerminalSize { rows: 2, cols: 4 }, 0);
parser.process(b"ab\r\ncd");
let snap = snapshot_from_screen(parser.screen());
let text = snap.text();
let parts: Vec<&str> = text.split('\n').collect();
assert_eq!(parts.len(), 2);
assert!(parts[0].starts_with("ab"));
assert!(parts[1].starts_with("cd"));
assert_eq!(parts[0].chars().count(), 4);
assert_eq!(parts[1].chars().count(), 4);
}
#[test]
fn style_runs_group_adjacent_cells_with_same_attrs() {
let mut parser = Parser::new(TerminalSize { rows: 1, cols: 6 }, 0);
parser.process(b"\x1b[31mAB\x1b[0mCD");
let snap = snapshot_from_screen(parser.screen());
let runs = snap.style_runs();
assert_eq!(runs.len(), 1);
let row0 = &runs[0];
assert!(
row0.len() >= 2,
"expected at least two runs for split styling, got {row0:?}"
);
assert_eq!(row0[0].start_col, 0);
assert_eq!(row0[0].end_col, 2);
assert_eq!(row0[1].start_col, 2);
}
#[test]
fn accessors_match_construction_inputs() {
let mut parser = Parser::new(TerminalSize { rows: 4, cols: 12 }, 0);
parser.process(b"\x1b[?25lhello");
let snap = snapshot_from_screen(parser.screen());
assert_eq!(snap.size(), TerminalSize { rows: 4, cols: 12 });
assert_eq!(snap.cursor(), Position { row: 0, col: 5 });
assert!(!snap.cursor_visible());
assert!(!snap.alternate_screen());
assert_eq!(snap.title(), "");
assert_eq!(snap.icon_name(), "");
}
}