use crate::ssp::SyncState;
use serde::{Deserialize, Serialize};
mod server;
pub use server::ServerTerminal;
pub const DEFAULT_ROWS: u16 = 24;
pub const DEFAULT_COLS: u16 = 80;
pub const ECHO_TIMEOUT_MS: u64 = 50;
fn blank_screen(rows: u16, cols: u16) -> vt100::Screen {
vt100::Parser::new(rows, cols, 0).screen().clone()
}
pub struct TerminalScreen {
screen: vt100::Screen,
echo_ack: u64,
title: String,
exit_code: Option<u32>,
parser: Option<Box<vt100::Parser>>,
}
impl Clone for TerminalScreen {
fn clone(&self) -> Self {
Self {
screen: self.screen.clone(),
echo_ack: self.echo_ack,
title: self.title.clone(),
exit_code: self.exit_code,
parser: None,
}
}
}
impl std::fmt::Debug for TerminalScreen {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TerminalScreen")
.field("size", &self.screen.size())
.field("echo_ack", &self.echo_ack)
.field("title", &self.title)
.field("exit_code", &self.exit_code)
.finish_non_exhaustive()
}
}
impl Default for TerminalScreen {
fn default() -> Self {
Self {
screen: blank_screen(DEFAULT_ROWS, DEFAULT_COLS),
echo_ack: 0,
title: String::new(),
exit_code: None,
parser: None,
}
}
}
impl TerminalScreen {
pub fn from_bytes(rows: u16, cols: u16, bytes: &[u8]) -> Self {
let mut p = vt100::Parser::new(rows, cols, 0);
p.process(bytes);
Self {
screen: p.screen().clone(),
echo_ack: 0,
title: String::new(),
exit_code: None,
parser: None,
}
}
pub fn screen(&self) -> &vt100::Screen {
&self.screen
}
pub fn exit_code(&self) -> Option<u32> {
self.exit_code
}
pub fn size(&self) -> (u16, u16) {
self.screen.size()
}
pub fn echo_ack(&self) -> u64 {
self.echo_ack
}
pub fn title(&self) -> &str {
&self.title
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ScreenDiff {
pub resize: Option<(u16, u16)>,
pub echo_ack: u64,
pub title: Option<String>,
pub exit_code: Option<u32>,
pub vt: Vec<u8>,
}
impl SyncState for TerminalScreen {
type Diff = ScreenDiff;
fn diff_from(&self, base: &Self) -> Self::Diff {
let resized = self.size() != base.size();
let vt = if resized {
self.screen.state_formatted()
} else {
self.screen.state_diff(&base.screen)
};
ScreenDiff {
resize: resized.then(|| self.size()),
echo_ack: self.echo_ack,
title: (self.title != base.title).then(|| self.title.clone()),
exit_code: self.exit_code,
vt,
}
}
fn apply(&mut self, diff: &Self::Diff) {
if let Some((rows, cols)) = diff.resize {
let mut p = Box::new(vt100::Parser::new(rows, cols, 0));
p.process(&diff.vt);
self.screen = p.screen().clone();
self.parser = Some(p);
} else {
let parser = self.parser.get_or_insert_with(|| {
let (rows, cols) = self.screen.size();
let mut p = Box::new(vt100::Parser::new(rows, cols, 0));
p.process(&self.screen.state_formatted());
p
});
if !diff.vt.is_empty() {
parser.process(&diff.vt);
}
self.screen = parser.screen().clone();
}
self.echo_ack = self.echo_ack.max(diff.echo_ack);
if let Some(title) = &diff.title {
self.title.clone_from(title);
}
if diff.exit_code.is_some() {
self.exit_code = diff.exit_code;
}
}
}
impl PartialEq for TerminalScreen {
fn eq(&self, other: &Self) -> bool {
self.echo_ack == other.echo_ack
&& self.title == other.title
&& self.exit_code == other.exit_code
&& self.screen.size() == other.screen.size()
&& self.screen.state_diff(&other.screen).is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ssp::testkit::{LinkParams, SimHarness};
fn screen_from(rows: u16, cols: u16, bytes: &[u8]) -> TerminalScreen {
TerminalScreen::from_bytes(rows, cols, bytes)
}
#[test]
fn diff_apply_roundtrip_simple() {
let base = TerminalScreen::default();
let target = screen_from(24, 80, b"hello \x1b[31mworld\x1b[m");
let diff = target.diff_from(&base);
let mut c = base;
c.apply(&diff);
assert_eq!(c, target);
}
#[test]
fn diff_apply_roundtrip_incremental() {
let a = screen_from(24, 80, b"line one\r\nline two");
let b = screen_from(24, 80, b"line one\r\nline two\r\nline three\x1b[1;1Hedited");
let diff = b.diff_from(&a);
assert!(!diff.vt.is_empty());
assert!(diff.resize.is_none());
let mut c = a;
c.apply(&diff);
assert_eq!(c, b);
}
#[test]
fn resize_roundtrip_full_repaint() {
let a = screen_from(24, 80, b"small screen content here");
let b = screen_from(
40,
120,
b"now a much wider and taller screen\r\nwith two lines",
);
let diff = b.diff_from(&a);
assert_eq!(diff.resize, Some((40, 120)));
let mut c = a;
c.apply(&diff);
assert_eq!(c, b);
assert_eq!(c.size(), (40, 120));
}
#[test]
fn equal_screens_compare_equal() {
let a = screen_from(24, 80, b"identical");
let b = screen_from(24, 80, b"identical");
assert_eq!(a, b);
assert!(a.diff_from(&b).vt.is_empty());
}
#[test]
fn wide_chars_and_emoji_roundtrip() {
let base = TerminalScreen::default();
let target = screen_from(24, 80, "日本語 café 🦀 e\u{0301}".as_bytes());
let diff = target.diff_from(&base);
let mut c = base;
c.apply(&diff);
assert_eq!(c, target);
}
#[test]
fn converges_over_lossy_link() {
let mut h =
SimHarness::<TerminalScreen, TerminalScreen>::new(LinkParams::lossy(), 77, 1200);
let mut emu = ServerTerminal::new(24, 80, 0);
for i in 0..30u32 {
emu.process(format!("\r\nframe {i} of output").as_bytes());
*h.a_mut() = emu.snapshot();
h.run_steps(5);
}
let final_snap = emu.snapshot();
h.run_until(20_000, move |h| *h.b_view_of_a() == final_snap);
}
#[test]
fn many_incremental_applies_without_reclone_track_server() {
let mut emu = ServerTerminal::new(24, 80, 0);
emu.process(b"line 0\r\n");
let mut client = emu.snapshot();
let mut base = client.clone();
for i in 1..=20 {
emu.process(format!("line {i}\r\n").as_bytes());
let target = emu.snapshot();
let diff = target.diff_from(&base);
assert!(
diff.resize.is_none(),
"no resize -> incremental (persistent) path"
);
client.apply(&diff); base = target.clone();
assert_eq!(
client, base,
"client must track server after incremental diff {i}"
);
}
}
#[test]
fn exit_code_propagates_through_diff_apply() {
let mut emu = ServerTerminal::new(24, 80, 0);
emu.process(b"bye");
emu.set_exit_code(42);
let target = emu.snapshot();
assert_eq!(target.exit_code(), Some(42));
let base = TerminalScreen::default();
let diff = target.diff_from(&base);
assert_eq!(diff.exit_code, Some(42));
let mut c = base.clone();
c.apply(&diff);
assert_eq!(c.exit_code(), Some(42));
assert_ne!(
base, c,
"a state carrying an exit code differs from one without"
);
}
fn roundtrip(rows: u16, cols: u16, bytes: &[u8]) -> TerminalScreen {
let mut emu = ServerTerminal::new(rows, cols, 0);
emu.process(bytes);
let target = emu.snapshot();
let base = TerminalScreen::default();
let diff = target.diff_from(&base);
let mut client = base;
client.apply(&diff);
assert_eq!(
client, target,
"client must reconstruct the server screen exactly"
);
client
}
fn row_text(s: &vt100::Screen, row: u16) -> String {
let (_, cols) = s.size();
(0..cols)
.map(|c| match s.cell(row, c).map(vt100::Cell::contents) {
Some(g) if !g.is_empty() => g.to_string(),
_ => " ".to_string(),
})
.collect::<String>()
.trim_end()
.to_string()
}
#[test]
fn attributes_survive_roundtrip() {
let bytes = b"\x1b[1mB\x1b[m\x1b[4mU\x1b[m\x1b[7mR\x1b[m\x1b[3mI\x1b[m\
\x1b[31mC\x1b[m\x1b[38;5;208mP\x1b[m\x1b[38;2;10;20;30mT\x1b[m";
let c = roundtrip(24, 80, bytes);
let s = c.screen();
assert!(s.cell(0, 0).unwrap().bold(), "bold");
assert!(s.cell(0, 1).unwrap().underline(), "underline");
assert!(s.cell(0, 2).unwrap().inverse(), "inverse");
assert!(s.cell(0, 3).unwrap().italic(), "italic");
assert_eq!(
s.cell(0, 4).unwrap().fgcolor(),
vt100::Color::Idx(1),
"16-color red"
);
assert_eq!(
s.cell(0, 5).unwrap().fgcolor(),
vt100::Color::Idx(208),
"256-color"
);
assert_eq!(
s.cell(0, 6).unwrap().fgcolor(),
vt100::Color::Rgb(10, 20, 30),
"truecolor"
);
}
#[test]
fn cursor_motion_roundtrip() {
let bytes = b"\x1b[H\x1b[J\x1b[1;1HA\x1b[1;10HB\x1b[4;1HC\x1b[24;1Hdone";
let c = roundtrip(24, 80, bytes);
let s = c.screen();
assert_eq!(s.cell(0, 0).unwrap().contents(), "A");
assert_eq!(s.cell(0, 9).unwrap().contents(), "B");
assert_eq!(s.cell(3, 0).unwrap().contents(), "C");
assert_eq!(row_text(s, 23), "done");
}
#[test]
fn scroll_up_down_roundtrip() {
let mut bytes = Vec::from(&b"\x1b[H\x1b[J"[..]);
for i in 1..=24 {
bytes.extend_from_slice(format!("\x1b[{i};1Hline{i}").as_bytes());
}
bytes.extend_from_slice(b"\x1b[4S\x1b[2T");
let c = roundtrip(24, 80, &bytes);
let s = c.screen();
assert_eq!(
row_text(s, 0),
"",
"two blank rows pushed in at the top after SD 2"
);
assert_eq!(
row_text(s, 2),
"line5",
"line5 reached the top after SU 4, then down 2"
);
assert_eq!(row_text(s, 21), "line24", "last line still present");
}
#[test]
fn insert_delete_lines_roundtrip_no_panic() {
let mut bytes = Vec::from(&b"\x1b[H\x1b[J"[..]);
for i in 1..=24 {
bytes.extend_from_slice(format!("\x1b[{i};1Hrow{i}").as_bytes());
}
for n in [0u32, 1, 2, 22, 26] {
bytes.extend_from_slice(format!("\x1b[3;1H\x1b[{n}L").as_bytes());
bytes.extend_from_slice(format!("\x1b[3;1H\x1b[{n}M").as_bytes());
}
let _ = roundtrip(24, 80, &bytes); }
#[test]
fn back_and_forward_tab_unsupported_but_roundtrip_clean() {
let c = roundtrip(24, 80, b"hello, wurld\x1b[Zo");
assert_eq!(
row_text(c.screen(), 0),
"hello, wurldo",
"CBT currently a no-op in vt100"
);
let c2 = roundtrip(24, 80, b"ab\x1b[Itab");
assert_eq!(
row_text(c2.screen(), 0),
"abtab",
"CHT currently a no-op in vt100"
);
}
#[test]
fn column_80_no_premature_wrap_roundtrip() {
let mut bytes = Vec::from(&b"\x1b[H\x1b[J"[..]);
bytes.resize(bytes.len() + 80, b'E'); bytes.extend_from_slice(b"\r\nM");
let c = roundtrip(24, 80, &bytes);
let s = c.screen();
assert_eq!(row_text(s, 0), "E".repeat(80), "80 chars fill row 0");
assert_eq!(
s.cell(1, 0).unwrap().contents(),
"M",
"M lands on row 1, no spurious wrap row"
);
}
#[test]
fn wrap_across_incremental_frames() {
let mut emu = ServerTerminal::new(24, 80, 0);
emu.process(b"\x1b[H\x1b[J");
emu.process(&[b'a'; 80]); let frame_n = emu.snapshot();
let mut client = TerminalScreen::default();
client.apply(&frame_n.diff_from(&TerminalScreen::default()));
assert_eq!(client, frame_n);
emu.process(b"b"); let frame_n1 = emu.snapshot();
let diff = frame_n1.diff_from(&frame_n);
assert!(
diff.resize.is_none(),
"incremental path, not a full repaint"
);
client.apply(&diff);
assert_eq!(
client, frame_n1,
"wrap across frames must reconstruct incrementally"
);
assert_eq!(
client.screen().cell(1, 0).unwrap().contents(),
"b",
"wrapped char on row 1"
);
}
#[test]
fn combining_mark_after_erase_does_not_panic() {
let _ = roundtrip(24, 80, b"0\x1b[1J\xcc\xb4");
}
#[test]
fn combining_mark_on_blank_line_roundtrip() {
let c = roundtrip(24, 80, b"abc\n\xcc\x82\ndef\n");
let contents = c.screen().contents();
assert!(contents.contains("abc") && contents.contains("def"));
}
#[test]
fn latin1_supplement_roundtrip() {
let c = roundtrip(24, 80, "àáâãäåæçèéêëìíîïñòóôõöøùúûüýþÿ".as_bytes());
assert!(c.screen().contents().contains("àáâãä"));
}
}