use vt100::Parser;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ScreenUpdate {
Full(Vec<String>),
Diff(Vec<(usize, String)>),
Unchanged,
}
pub struct LiveTerminal {
parser: Parser,
last_snapshot: Option<Vec<String>>,
}
impl LiveTerminal {
pub fn new(rows: u16, cols: u16) -> Self {
Self { parser: Parser::new(rows.max(1), cols.max(1), 0), last_snapshot: None }
}
pub fn feed(&mut self, bytes: &[u8]) {
self.parser.process(bytes);
}
pub fn in_alt_screen(&self) -> bool {
self.parser.screen().alternate_screen()
}
pub fn resize(&mut self, rows: u16, cols: u16) {
self.parser.screen_mut().set_size(rows.max(1), cols.max(1));
}
pub fn snapshot(&self, max_lines: usize) -> Vec<String> {
let contents = self.parser.screen().contents();
let mut lines: Vec<String> = contents.lines().map(str::to_string).collect();
while lines.last().is_some_and(|l| l.trim().is_empty()) {
lines.pop();
}
if max_lines > 0 && lines.len() > max_lines {
lines = lines.split_off(lines.len() - max_lines);
}
lines
}
pub fn cursor_position(&self) -> (u16, u16) {
self.parser.screen().cursor_position()
}
pub fn snapshot_diff(&mut self, max_lines: usize, threshold: usize) -> ScreenUpdate {
let current = self.snapshot(max_lines);
let update = match self.last_snapshot.as_ref() {
Some(previous) => {
let changed = diff_lines(previous, ¤t);
if changed.is_empty() {
ScreenUpdate::Unchanged
} else if changed.len() <= threshold {
ScreenUpdate::Diff(changed)
} else {
ScreenUpdate::Full(current.clone())
}
}
None => ScreenUpdate::Full(current.clone()),
};
self.last_snapshot = Some(current);
update
}
}
fn diff_lines(previous: &[String], current: &[String]) -> Vec<(usize, String)> {
let rows = previous.len().max(current.len());
(0..rows)
.filter_map(|i| {
let was = previous.get(i).map_or("", String::as_str);
let now = current.get(i).map_or("", String::as_str);
(was != now).then(|| (i + 1, now.to_string()))
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn inline_redraw_with_cursor_up_consolidates_no_ghosting() {
let mut t = LiveTerminal::new(10, 40);
t.feed(b"line one\r\nGenerating...\r\n"); t.feed(b"\x1b[2A"); t.feed(b"\x1b[Jline one\r\nDONE\r\n"); let joined = t.snapshot(0).join("\n");
assert!(joined.contains("DONE"), "missing latest frame: {joined:?}");
assert!(!joined.contains("Generating"), "ghost frame leaked: {joined:?}");
}
#[test]
fn viewport_is_fixed_old_lines_scroll_off() {
let mut t = LiveTerminal::new(3, 40); for i in 0..10 {
t.feed(format!("row{i}\r\n").as_bytes());
}
let snap = t.snapshot(0);
assert!(snap.len() <= 3, "viewport overflowed its row count: {snap:?}");
assert!(snap.iter().any(|l| l.contains("row9")), "newest row missing: {snap:?}");
assert!(
!snap.iter().any(|l| l.contains("row0")),
"oldest row should have scrolled off a fixed viewport: {snap:?}"
);
}
#[test]
fn detects_alternate_screen_toggle() {
let mut t = LiveTerminal::new(10, 40);
assert!(!t.in_alt_screen());
t.feed(b"\x1b[?1049h");
assert!(t.in_alt_screen());
t.feed(b"\x1b[?1049l");
assert!(!t.in_alt_screen());
}
#[test]
fn snapshot_last_n_lines() {
let mut t = LiveTerminal::new(10, 40);
t.feed(b"a\r\nb\r\nc\r\nd\r\n");
assert_eq!(t.snapshot(2), vec!["c".to_string(), "d".to_string()]);
}
#[test]
fn cursor_position_tracks_feed() {
let mut t = LiveTerminal::new(10, 40);
t.feed(b"abc");
assert_eq!(t.cursor_position(), (0, 3), "cursor should sit after 'abc' on row 0");
}
#[test]
fn snapshot_diff_full_then_unchanged_then_one_line() {
let mut t = LiveTerminal::new(5, 40);
t.feed(b"alpha\r\nbeta\r\ngamma\r\n");
assert!(matches!(t.snapshot_diff(0, 10), ScreenUpdate::Full(_)));
assert_eq!(t.snapshot_diff(0, 10), ScreenUpdate::Unchanged);
t.feed(b"\x1b[2;1Hbeta2");
let update = t.snapshot_diff(0, 10);
assert!(
matches!(&update, ScreenUpdate::Diff(changed) if changed.len() == 1),
"expected a one-line diff, got {update:?}"
);
if let ScreenUpdate::Diff(changed) = &update {
assert_eq!(changed.first().map(|c| c.0), Some(2), "1-based row 2");
assert!(changed.first().is_some_and(|c| c.1.contains("beta2")), "got {changed:?}");
}
}
#[test]
fn snapshot_diff_big_change_falls_back_to_full() {
let mut t = LiveTerminal::new(10, 40);
t.feed(b"seed\r\n");
let _ = t.snapshot_diff(0, 1); for i in 0..8 {
t.feed(format!("line{i}\r\n").as_bytes());
}
assert!(matches!(t.snapshot_diff(0, 1), ScreenUpdate::Full(_)));
}
}