use vt100::Parser;
const MIN_VIEWPORT: u16 = 2;
#[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(MIN_VIEWPORT), cols.max(MIN_VIEWPORT), 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) {
let rows = rows.max(MIN_VIEWPORT);
let cols = cols.max(MIN_VIEWPORT);
let preserved = self.snapshot(0);
self.parser = Parser::new(rows, cols, 0);
if !preserved.is_empty() {
self.parser.process(preserved.join("\r\n").as_bytes());
}
self.last_snapshot = None;
}
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(_)));
}
use proptest::prelude::*;
proptest! {
#[test]
fn feed_arbitrary_bytes_never_panics_and_stays_bounded(
rows in 1u16..40,
cols in 1u16..120,
chunks in prop::collection::vec(prop::collection::vec(any::<u8>(), 0..48), 0..24),
) {
let eff_rows = rows.max(MIN_VIEWPORT);
let eff_cols = cols.max(MIN_VIEWPORT);
let mut t = LiveTerminal::new(rows, cols);
for chunk in &chunks {
t.feed(chunk);
}
let full = t.snapshot(0);
prop_assert!(full.len() <= eff_rows as usize, "snapshot {} rows > viewport {eff_rows}", full.len());
let (cr, cc) = t.cursor_position();
prop_assert!(cr < eff_rows, "cursor row {cr} >= rows {eff_rows}");
prop_assert!(cc <= eff_cols, "cursor col {cc} > cols {eff_cols}");
prop_assert!(t.snapshot(3).len() <= 3);
let _ = t.snapshot_diff(0, 4);
if let ScreenUpdate::Diff(changed) = t.snapshot_diff(0, 4) {
prop_assert!(changed.len() <= 4);
}
}
#[test]
fn resize_after_feed_never_panics(
r1 in 1u16..30, c1 in 1u16..80,
r2 in 1u16..30, c2 in 1u16..80,
data in prop::collection::vec(any::<u8>(), 0..256),
) {
let eff_r2 = r2.max(MIN_VIEWPORT) as usize;
let mut t = LiveTerminal::new(r1, c1);
t.feed(&data);
t.resize(r2, c2);
prop_assert!(t.snapshot(0).len() <= eff_r2);
t.feed(&data);
prop_assert!(t.snapshot(0).len() <= eff_r2);
}
}
}