use std::collections::VecDeque;
use std::sync::{Arc, Mutex};
use alacritty_terminal::event::{Event, EventListener, WindowSize};
use alacritty_terminal::grid::Dimensions;
use alacritty_terminal::index::{Column, Line};
use alacritty_terminal::term::cell::{Cell, Flags};
use alacritty_terminal::term::{Config as TermConfig, Osc52, Term, TermMode};
use alacritty_terminal::vte::ansi::{Color, CursorShape, NamedColor, Processor, Rgb};
use tracing::{debug, warn};
use super::frame::{
CellFrame, CursorState, DiffEngine, RowData, decscusr_code, mouse_encoding_str, mouse_mode_str,
};
const VT_SCROLLBACK_LINES: usize = 1000;
const MAX_RESPONSE_ENTRIES: usize = 64;
const MAX_RESPONSE_BYTES: usize = 8 * 1024;
const RESPONSE_CLOSURE_EST_BYTES: usize = 32;
#[derive(Default)]
struct ResponseSink {
inner: Mutex<SinkInner>,
}
#[derive(Default)]
struct SinkInner {
queue: VecDeque<PendingResponse>,
bytes: usize,
title: String,
}
enum PendingResponse {
Bytes(Vec<u8>),
Color(usize, Arc<dyn Fn(Rgb) -> String + Sync + Send + 'static>),
TextArea(Arc<dyn Fn(WindowSize) -> String + Sync + Send + 'static>),
}
fn response_len(resp: &PendingResponse) -> usize {
match resp {
PendingResponse::Bytes(b) => b.len(),
PendingResponse::Color(..) | PendingResponse::TextArea(_) => RESPONSE_CLOSURE_EST_BYTES,
}
}
impl ResponseSink {
fn push(&self, resp: PendingResponse) {
let len = response_len(&resp);
if len > MAX_RESPONSE_BYTES {
warn!("vt response entry exceeds {MAX_RESPONSE_BYTES}B cap, dropped");
return;
}
let mut g = self.inner.lock().unwrap();
while g.queue.len() >= MAX_RESPONSE_ENTRIES || g.bytes + len > MAX_RESPONSE_BYTES {
let Some(oldest) = g.queue.pop_front() else { break };
g.bytes -= response_len(&oldest);
warn!(
"vt response buffer overflow (cap {MAX_RESPONSE_ENTRIES} entries / {MAX_RESPONSE_BYTES}B), oldest dropped"
);
}
g.queue.push_back(resp);
g.bytes += len;
}
}
struct VtEventListener {
sink: Arc<ResponseSink>,
}
impl EventListener for VtEventListener {
fn send_event(&self, event: Event) {
match event {
Event::Title(title) => self.sink.inner.lock().unwrap().title = title,
Event::ResetTitle => self.sink.inner.lock().unwrap().title.clear(),
Event::PtyWrite(text) => self.sink.push(PendingResponse::Bytes(text.into_bytes())),
Event::ColorRequest(index, fmt) => self.sink.push(PendingResponse::Color(index, fmt)),
Event::TextAreaSizeRequest(fmt) => self.sink.push(PendingResponse::TextArea(fmt)),
_ => {}
}
}
}
struct VtSize {
rows: usize,
cols: usize,
}
impl Dimensions for VtSize {
fn total_lines(&self) -> usize {
self.rows + VT_SCROLLBACK_LINES
}
fn screen_lines(&self) -> usize {
self.rows
}
fn columns(&self) -> usize {
self.cols
}
}
const FALLBACK_COLOR: Rgb = Rgb { r: 0xd8, g: 0xd8, b: 0xd8 };
#[derive(Clone, Copy, PartialEq)]
pub struct CellStyle {
fg: Option<Color>,
bg: Option<Color>,
flags: Flags,
}
impl Default for CellStyle {
fn default() -> Self {
Self { fg: None, bg: None, flags: Flags::empty() }
}
}
impl CellStyle {
pub fn of(cell: &Cell) -> Self {
Self { fg: normalize_color(cell.fg), bg: normalize_color(cell.bg), flags: cell.flags }
}
pub fn is_default(&self) -> bool {
*self == Self::default()
}
}
fn normalize_color(c: Color) -> Option<Color> {
let Color::Named(n) = c else { return Some(c) };
match n {
NamedColor::Black
| NamedColor::Red
| NamedColor::Green
| NamedColor::Yellow
| NamedColor::Blue
| NamedColor::Magenta
| NamedColor::Cyan
| NamedColor::White
| NamedColor::BrightBlack
| NamedColor::BrightRed
| NamedColor::BrightGreen
| NamedColor::BrightYellow
| NamedColor::BrightBlue
| NamedColor::BrightMagenta
| NamedColor::BrightCyan
| NamedColor::BrightWhite => Some(c),
_ => None,
}
}
fn color_key(c: Option<Color>) -> u64 {
match c {
None => 0,
Some(Color::Named(n)) => 1 + n as u64,
Some(Color::Indexed(i)) => 0x100 + i as u64,
Some(Color::Spec(rgb)) => {
0x1_0000 + ((rgb.r as u64) << 16 | (rgb.g as u64) << 8 | rgb.b as u64)
}
}
}
fn hash_grid_row(grid: &alacritty_terminal::grid::Grid<Cell>, cols: usize, line: Line) -> u64 {
const FNV_OFFSET: u64 = 14695981039346656037;
const FNV_PRIME: u64 = 1099511628211;
let mut h = FNV_OFFSET;
let mut mix = |v: u64| {
h ^= v;
h = h.wrapping_mul(FNV_PRIME);
};
for col in 0..cols {
let cell_ref = &grid[line][Column(col)];
if cell_ref.flags.intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER) {
mix(0x42);
continue;
}
mix(cell_ref.c as u64);
if let Some(zw) = cell_ref.zerowidth() {
for z in zw {
mix(*z as u64);
}
}
let style = CellStyle::of(cell_ref);
mix(style.flags.bits() as u64);
mix(color_key(style.fg));
mix(color_key(style.bg));
}
h
}
fn push_cell_text(out: &mut String, cell: &Cell) {
if cell.c == '\t' {
out.push(' ');
} else {
out.push(cell.c);
}
if let Some(zw) = cell.zerowidth() {
out.extend(zw.iter().copied());
}
}
pub fn sgr_body(style: &CellStyle) -> Option<String> {
let mut parts: Vec<String> = Vec::new();
for (flag, code) in [
(Flags::BOLD, "1"),
(Flags::DIM, "2"),
(Flags::ITALIC, "3"),
(Flags::UNDERLINE, "4"),
(Flags::INVERSE, "7"),
(Flags::HIDDEN, "8"),
(Flags::STRIKEOUT, "9"),
] {
if style.flags.contains(flag) {
parts.push(code.to_string());
}
}
if let Some(c) = style.fg {
let p = color_sgr(c, true);
if !p.is_empty() {
parts.push(p);
}
}
if let Some(c) = style.bg {
let p = color_sgr(c, false);
if !p.is_empty() {
parts.push(p);
}
}
(!parts.is_empty()).then(|| parts.join(";"))
}
pub fn color_sgr(c: Color, foreground: bool) -> String {
let std = if foreground { 30 } else { 40 };
let bright = if foreground { 90 } else { 100 };
let ext = if foreground { 38 } else { 48 };
match c {
Color::Named(n) => {
let idx: u8 = match n {
NamedColor::Black => 0,
NamedColor::Red => 1,
NamedColor::Green => 2,
NamedColor::Yellow => 3,
NamedColor::Blue => 4,
NamedColor::Magenta => 5,
NamedColor::Cyan => 6,
NamedColor::White => 7,
NamedColor::BrightBlack => 8,
NamedColor::BrightRed => 9,
NamedColor::BrightGreen => 10,
NamedColor::BrightYellow => 11,
NamedColor::BrightBlue => 12,
NamedColor::BrightMagenta => 13,
NamedColor::BrightCyan => 14,
_ => return String::new(),
};
if idx < 8 { (std + idx).to_string() } else { (bright + idx - 8).to_string() }
}
Color::Indexed(i) => format!("{ext};5;{i}"),
Color::Spec(rgb) => format!("{ext};2;{};{};{}", rgb.r, rgb.g, rgb.b),
}
}
enum AnchorAdjust {
Adjusted { shift: usize, clamped_to_zero: bool },
Miss,
Inactive,
}
pub struct VtState {
term: Term<VtEventListener>,
processor: Processor,
sink: Arc<ResponseSink>,
rows: u16,
cols: u16,
diff_engine: DiffEngine,
last_cursor: Mutex<Option<(i32, u16, u8, bool)>>,
frame_seq: u64,
viewport_anchor: Option<i32>,
prev_screen_hashes: Vec<u64>,
prev_history_size: i32,
scroll_detect_last_hit: Option<bool>,
}
impl VtState {
pub fn new(rows: u16, cols: u16) -> Self {
let rows = rows.max(1) as usize;
let cols = cols.max(1) as usize;
let config = TermConfig {
scrolling_history: VT_SCROLLBACK_LINES,
osc52: Osc52::Disabled,
..Default::default()
};
let sink = Arc::new(ResponseSink::default());
let size = VtSize { rows, cols };
let term = Term::new(config, &size, VtEventListener { sink: Arc::clone(&sink) });
Self {
term,
processor: Processor::new(),
sink,
rows: rows as u16,
cols: cols as u16,
diff_engine: DiffEngine::with_rows(rows),
last_cursor: Mutex::new(None),
frame_seq: 0,
viewport_anchor: None,
prev_screen_hashes: Vec::new(),
prev_history_size: 0,
scroll_detect_last_hit: None,
}
}
pub fn feed(&mut self, bytes: &[u8]) {
self.processor.advance(&mut self.term, bytes);
}
pub fn take_responses(&mut self) -> Vec<u8> {
let pending: Vec<PendingResponse> = {
let mut g = self.sink.inner.lock().unwrap();
g.bytes = 0;
g.queue.drain(..).collect()
};
let mut out = Vec::new();
for resp in pending {
match resp {
PendingResponse::Bytes(b) => out.extend_from_slice(&b),
PendingResponse::Color(index, fmt) => {
let rgb = self.term.colors()[index].unwrap_or(FALLBACK_COLOR);
out.extend_from_slice(fmt(rgb).as_bytes());
}
PendingResponse::TextArea(fmt) => {
let ws = WindowSize {
num_lines: self.rows,
num_cols: self.cols,
cell_width: 0,
cell_height: 0,
};
out.extend_from_slice(fmt(ws).as_bytes());
}
}
}
out
}
pub fn title(&self) -> String {
self.sink.inner.lock().unwrap().title.clone()
}
pub fn resize(&mut self, rows: u16, cols: u16) {
let rows = rows.max(1) as usize;
let cols = cols.max(1) as usize;
self.rows = rows as u16;
self.cols = cols as u16;
self.term.resize(VtSize { rows, cols });
self.diff_engine.resize(rows);
self.diff_engine.invalidate();
if let Ok(mut lc) = self.last_cursor.lock() {
lc.take();
}
self.viewport_anchor = None;
self.prev_screen_hashes.clear();
self.prev_history_size = 0;
}
pub fn capture_visible(&self) -> String {
let grid = self.term.grid();
let rows = self.term.screen_lines();
let cols = Column(self.term.columns());
let mut out = String::new();
for row in 0..rows {
let mut line = String::new();
for cell in &grid[Line(row as i32)][Column(0)..cols] {
if cell.flags.intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER)
{
continue;
}
line.push(cell.c);
}
out.push_str(line.trim_end());
out.push('\n');
}
out
}
#[cfg(test)]
pub fn renderable_cursor_for_test(&self) -> (i32, u16) {
let rc = self.term.renderable_content();
(rc.cursor.point.line.0, rc.cursor.point.column.0 as u16)
}
pub fn render_screen(&self) -> Vec<u8> {
let grid = self.term.grid();
let rows = self.term.screen_lines();
let cols = self.term.columns();
let mut out: Vec<u8> = Vec::with_capacity(rows * cols * 8 + 64);
let mut cur = CellStyle::default();
for row in 0..rows {
let cells: Vec<(char, CellStyle)> = grid[Line(row as i32)][Column(0)..Column(cols)]
.iter()
.filter(|c| {
!c.flags.intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER)
})
.map(|c| (c.c, CellStyle::of(c)))
.collect();
let trimmed =
cells.iter().rev().take_while(|(ch, st)| *ch == ' ' && st.is_default()).count();
for (ch, st) in &cells[..cells.len() - trimmed] {
if *st != cur {
out.extend_from_slice(b"\x1b[0m");
if let Some(body) = sgr_body(st) {
out.push(b'\x1b');
out.push(b'[');
out.extend_from_slice(body.as_bytes());
out.push(b'm');
}
cur = *st;
}
let ch = if *ch == '\t' { ' ' } else { *ch };
let mut buf = [0u8; 4];
out.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes());
}
if row + 1 < rows {
out.extend_from_slice(b"\r\n");
}
}
if cur != CellStyle::default() {
out.extend_from_slice(b"\x1b[0m");
}
let rc = self.term.renderable_content();
let point = rc.cursor.point;
out.extend_from_slice(
format!("\x1b[{};{}H", point.line.0 + 1, point.column.0 + 1).as_bytes(),
);
match rc.cursor.shape {
CursorShape::Hidden => out.extend_from_slice(b"\x1b[?25l"),
_ => out.extend_from_slice(b"\x1b[?25h"),
}
out
}
pub fn encode_cell_frame(&mut self, session_id: &str, force_full: bool) -> String {
self.frame_seq = self.frame_seq.wrapping_add(1);
let seq = self.frame_seq;
self.encode_frame_body(session_id, false, Some(seq), force_full)
}
pub fn encode_overlay_frame(&mut self, session_id: &str) -> String {
let grid = self.term.grid();
let rows = self.term.screen_lines();
let cols = self.term.columns();
let out_rows: Vec<RowData> =
(0..rows).map(|r| self.encode_row_static(grid, cols, Line(r as i32))).collect();
let rc = self.term.renderable_content();
let cursor = Some(CursorState {
row: rc.cursor.point.line.0 + 1,
col: (rc.cursor.point.column.0 + 1) as u16,
shape: Some(decscusr_code(rc.cursor.shape)),
visible: !matches!(rc.cursor.shape, CursorShape::Hidden),
});
let frame = CellFrame {
t: "cell_frame",
session_id: session_id.to_string(),
width: cols as u16,
height: rows as u16,
full: true,
cursor,
overlay: true,
row_indices: None,
viewport: None,
alt_screen: Some(self.mode().contains(TermMode::ALT_SCREEN)),
bracketed_paste: Some(self.mode().contains(TermMode::BRACKETED_PASTE)),
mouse_mode: Some(mouse_mode_str(self.mode())),
mouse_encoding: Some(mouse_encoding_str(self.mode())),
history_size: grid.history_size() as u32,
seq: None,
rows: out_rows,
};
serde_json::to_string(&frame).expect("CellFrame serialization must not fail")
}
pub fn encode_viewport_frame(&mut self, session_id: &str, y: u32, refresh: bool) -> String {
let hs = self.term.grid().history_size() as i32;
let rows = self.term.screen_lines();
let cols = self.term.columns();
let y = (y as i64).clamp(0, hs as i64) as i32;
let top = if refresh {
match self.viewport_anchor {
Some(anchor) => anchor.clamp(0, hs),
None => hs - y,
}
} else {
let anchor = hs - y;
if y == 0 {
self.viewport_anchor = None;
} else {
self.viewport_anchor = Some(anchor);
}
anchor
};
let grid = self.term.grid();
let start = top - hs; let y = (hs - top) as u32;
let out_rows: Vec<RowData> =
(0..rows).map(|i| self.encode_row_static(grid, cols, Line(start + i as i32))).collect();
let rc = self.term.renderable_content();
let cursor = Some(CursorState {
row: 1,
col: 1,
shape: Some(decscusr_code(rc.cursor.shape)),
visible: y == 0 && !matches!(rc.cursor.shape, CursorShape::Hidden),
});
let frame = CellFrame {
t: "cell_frame",
session_id: session_id.to_string(),
width: cols as u16,
height: rows as u16,
full: true,
cursor,
overlay: false,
row_indices: None,
viewport: Some(y),
alt_screen: None,
bracketed_paste: Some(self.mode().contains(TermMode::BRACKETED_PASTE)),
mouse_mode: Some(mouse_mode_str(self.mode())),
mouse_encoding: Some(mouse_encoding_str(self.mode())),
history_size: grid.history_size() as u32,
seq: None,
rows: out_rows,
};
serde_json::to_string(&frame).expect("CellFrame serialization must not fail")
}
pub fn clear_viewport_anchor(&mut self) {
self.viewport_anchor = None;
}
fn adjust_anchor_for_scroll(
anchor: &mut Option<i32>,
prev_hashes: &mut Vec<u64>,
prev_hs: &mut i32,
row_hashes: &[u64],
hs: i32,
) -> AnchorAdjust {
let outcome = if prev_hashes.len() == row_hashes.len()
&& *prev_hs == hs
&& row_hashes != prev_hashes.as_slice()
{
let rows = row_hashes.len();
let min_overlap = rows / 4;
let mut hit = None;
for s in 1..=rows.saturating_sub(min_overlap) {
let overlap = rows - s;
let threshold = (overlap * 3 / 4).max(1);
let mut matched = 0usize;
for i in s..rows {
if row_hashes[i - s] == prev_hashes[i] {
matched += 1;
}
}
if matched >= threshold {
hit = Some(s);
break;
}
}
match hit {
Some(s) => {
if let Some(a) = anchor.as_mut() {
let clamped = *a > 0 && *a - s as i32 <= 0;
*a = (*a - s as i32).max(0);
AnchorAdjust::Adjusted { shift: s, clamped_to_zero: clamped }
} else {
AnchorAdjust::Inactive
}
}
None => AnchorAdjust::Miss,
}
} else {
AnchorAdjust::Inactive
};
*prev_hashes = row_hashes.to_vec();
*prev_hs = hs;
outcome
}
pub fn invalidate_diff(&mut self) {
self.diff_engine.invalidate();
if let Ok(mut lc) = self.last_cursor.lock() {
lc.take();
}
}
pub fn mode(&self) -> TermMode {
*self.term.mode()
}
fn encode_frame_body(
&mut self,
session_id: &str,
overlay: bool,
seq: Option<u64>,
force_full: bool,
) -> String {
use crate::engine::pty::frame::{CellFrame, RowData};
let grid = self.term.grid();
let rows = self.term.screen_lines();
let cols = self.term.columns();
if force_full {
self.diff_engine.invalidate();
}
let full = self.diff_engine.is_untracked();
let mut row_hashes: Vec<u64> = Vec::with_capacity(rows);
for r in 0..rows {
row_hashes.push(hash_grid_row(grid, cols, Line(r as i32)));
}
let changed_indices = self.diff_engine.changed_rows_from(&row_hashes);
let hs = grid.history_size() as i32;
let outcome = Self::adjust_anchor_for_scroll(
&mut self.viewport_anchor,
&mut self.prev_screen_hashes,
&mut self.prev_history_size,
&row_hashes,
hs,
);
if !matches!(outcome, AnchorAdjust::Inactive) {
let hit = matches!(outcome, AnchorAdjust::Adjusted { .. });
if self.scroll_detect_last_hit != Some(hit) {
self.scroll_detect_last_hit = Some(hit);
if let AnchorAdjust::Adjusted { shift, clamped_to_zero } = outcome {
debug!(
session = session_id,
shift, clamped_to_zero, "viewport anchor adjusted for scroll"
);
} else {
debug!(session = session_id, "viewport scroll detect miss — anchor held");
}
}
}
let out_rows: Vec<RowData> = changed_indices
.iter()
.map(|&r| self.encode_row_static(grid, cols, Line(r as i32)))
.collect();
let rc = self.term.renderable_content();
let cursor_key = (
rc.cursor.point.line.0 + 1,
(rc.cursor.point.column.0 + 1) as u16,
decscusr_code(rc.cursor.shape),
!matches!(rc.cursor.shape, CursorShape::Hidden),
);
let cursor = {
let mut last = self.last_cursor.lock().unwrap();
let changed = last.map(|l| l != cursor_key).unwrap_or(true);
if changed {
*last = Some(cursor_key);
Some(CursorState {
row: cursor_key.0,
col: cursor_key.1,
shape: Some(cursor_key.2),
visible: cursor_key.3,
})
} else {
None
}
};
let row_indices = if full { None } else { Some(changed_indices) };
let frame = CellFrame {
t: "cell_frame",
session_id: session_id.to_string(),
width: cols as u16,
height: rows as u16,
full,
cursor,
overlay,
row_indices,
viewport: None,
alt_screen: None,
bracketed_paste: Some(self.mode().contains(TermMode::BRACKETED_PASTE)),
mouse_mode: Some(mouse_mode_str(self.mode())),
mouse_encoding: Some(mouse_encoding_str(self.mode())),
history_size: grid.history_size() as u32,
seq,
rows: out_rows,
};
serde_json::to_string(&frame).expect("CellFrame serialization must not fail")
}
fn encode_row_static(
&self,
grid: &alacritty_terminal::grid::Grid<Cell>,
cols: usize,
line: Line,
) -> RowData {
let mut runs: Vec<String> = Vec::new();
let mut current: Option<String> = None;
let mut text = String::new();
for col in 0..cols {
let cell_ref = &grid[line][Column(col)];
if cell_ref.flags.intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER)
{
continue;
}
let style = CellStyle::of(cell_ref);
let sgr = sgr_body(&style).unwrap_or_default();
match current {
Some(ref cur) if *cur == sgr => push_cell_text(&mut text, cell_ref),
_ => {
if let Some(prev) = current.take() {
runs.push(prev);
runs.push(std::mem::take(&mut text));
}
current = Some(sgr);
push_cell_text(&mut text, cell_ref);
}
}
}
if let Some(prev) = current.take() {
runs.push(prev);
runs.push(std::mem::take(&mut text));
}
RowData { runs }
}
}
#[cfg(test)]
mod tests {
use super::*;
fn vt(rows: u16, cols: u16) -> VtState {
VtState::new(rows, cols)
}
#[test]
fn capture_strips_ansi_and_keeps_text() {
let mut v = vt(24, 80);
v.feed(b"\x1b[1;32mgreen bold\x1b[0m plain\r\n");
v.feed(b"second line\r\n");
let cap = v.capture_visible();
assert!(cap.starts_with("green bold plain\nsecond line\n"), "got: {cap:?}");
assert!(!cap.contains('\x1b'), "capture must not contain escape bytes");
}
#[test]
fn carriage_return_overwrites_in_grid() {
let mut v = vt(24, 80);
v.feed(b"AAAA\rBB\r\n");
let cap = v.capture_visible();
assert!(cap.starts_with("BBAA\n"), "CR overwrite wrong: {cap:?}");
}
#[test]
fn osc2_sets_title() {
let mut v = vt(24, 80);
v.feed(b"\x1b]2;my agent task\x07");
assert_eq!(v.title(), "my agent task");
}
#[test]
fn resize_preserves_content() {
let mut v = vt(24, 80);
v.feed(b"keep me\r\n");
v.resize(30, 120);
let cap = v.capture_visible();
assert!(cap.contains("keep me"), "content lost after resize: {cap:?}");
}
#[test]
fn split_escape_sequence_across_feeds() {
let mut v = vt(24, 80);
v.feed(b"\x1b[3");
v.feed(b"1mred\x1b[0m");
let cap = v.capture_visible();
assert!(cap.starts_with("red\n"), "split sequence mishandled: {cap:?}");
}
#[test]
fn alt_screen_toggle_restores_main() {
let mut v = vt(24, 80);
v.feed(b"main screen\r\n");
v.feed(b"\x1b[?1049h"); v.feed(b"alt content\r\n");
let alt_cap = v.capture_visible();
assert!(alt_cap.contains("alt content"), "alt screen missing content: {alt_cap:?}");
v.feed(b"\x1b[?1049l"); let cap = v.capture_visible();
assert!(cap.contains("main screen"), "main screen lost after toggle: {cap:?}");
assert!(!cap.contains("alt content"), "alt content leaked into main screen");
}
#[test]
fn wide_chars_not_duplicated_in_capture() {
let mut v = vt(24, 80);
v.feed("终端宽度测试\r\n".as_bytes());
let cap = v.capture_visible();
assert!(cap.starts_with("终端宽度测试\n"), "got: {cap:?}");
assert_eq!(cap.matches("终端宽度测试").count(), 1, "wide char duplicated: {cap:?}");
}
#[test]
fn dsr_generates_response_for_drain() {
let mut v = vt(24, 80);
v.feed(b"\x1b[6n"); let resp = v.take_responses();
let text = String::from_utf8_lossy(&resp);
assert!(text.starts_with("\x1b["), "DSR response missing: {text:?}");
assert!(text.ends_with('R'), "DSR response must be CPR: {text:?}");
assert!(v.take_responses().is_empty(), "responses must drain");
}
#[test]
fn response_buffer_overflow_drops_oldest() {
let sink = ResponseSink::default();
for _ in 0..(MAX_RESPONSE_ENTRIES + 10) {
sink.push(PendingResponse::Bytes(vec![b'x'; 4]));
}
let g = sink.inner.lock().unwrap();
assert_eq!(g.queue.len(), MAX_RESPONSE_ENTRIES);
assert!(g.bytes <= MAX_RESPONSE_BYTES);
}
#[test]
fn response_buffer_byte_cap_enforced() {
let sink = ResponseSink::default();
for _ in 0..8 {
sink.push(PendingResponse::Bytes(vec![b'y'; MAX_RESPONSE_BYTES / 3]));
}
let g = sink.inner.lock().unwrap();
assert!(g.bytes <= MAX_RESPONSE_BYTES, "byte cap violated: {}", g.bytes);
assert!(!g.queue.is_empty(), "entries within cap must be kept");
}
#[test]
fn oversized_response_entry_rejected() {
let sink = ResponseSink::default();
sink.push(PendingResponse::Bytes(vec![b'z'; MAX_RESPONSE_BYTES + 1]));
let g = sink.inner.lock().unwrap();
assert!(g.queue.is_empty(), "oversized entry must be rejected, not queued");
}
#[test]
fn render_screen_emits_styled_text_with_reset() {
let mut v = vt(24, 80);
v.feed(b"\x1b[1;31mRED\x1b[0m tail\r\n");
let rendered = v.render_screen();
let r = String::from_utf8_lossy(&rendered);
assert!(r.contains("\x1b[0m\x1b[1;31mRED\x1b[0m tail"), "got: {r:?}");
}
#[test]
fn render_screen_trims_only_default_trailing_blank() {
let mut v = vt(24, 80);
v.feed(b"abc\r\n"); v.feed(b"\x1b[41mA \x1b[0m\r\n"); let rendered = v.render_screen();
let r = String::from_utf8_lossy(&rendered);
assert!(r.contains("abc\r\n"), "default trailing blank must be trimmed: {r:?}");
assert!(r.contains("\x1b[41mA \r\n"), "colored trailing blank must be kept: {r:?}");
}
#[test]
fn render_screen_restores_cursor_position_and_visibility() {
let mut v = vt(24, 80);
v.feed(b"abc\r\nxy"); let rendered = v.render_screen();
let r = String::from_utf8_lossy(&rendered);
assert!(r.ends_with("\x1b[2;3H\x1b[?25h"), "cursor restore wrong: {r:?}");
v.feed(b"\x1b[?25l"); let rendered = v.render_screen();
let r = String::from_utf8_lossy(&rendered);
assert!(r.ends_with("\x1b[?25l"), "hidden cursor not synced: {r:?}");
}
#[test]
fn render_screen_wide_chars_once() {
let mut v = vt(24, 80);
v.feed("终端宽度测试".as_bytes());
let rendered = v.render_screen();
let r = String::from_utf8_lossy(&rendered);
assert_eq!(r.matches("终端宽度测试").count(), 1, "wide char duplicated: {r:?}");
}
#[test]
fn render_screen_shows_active_screen() {
let mut v = vt(24, 80);
v.feed(b"main screen\r\n");
v.feed(b"\x1b[?1049h"); v.feed(b"alt content");
let rendered = v.render_screen();
let r = String::from_utf8_lossy(&rendered);
assert!(r.contains("alt content"), "alt screen missing: {r:?}");
assert!(!r.contains("main screen"), "main screen leaked into render: {r:?}");
}
#[test]
fn render_screen_roundtrip_reproduces_visible_screen() {
let mut v = vt(24, 80);
v.feed(b"\x1b[1;1Hheader line\x1b[K"); v.feed(b"\x1b[2;1H\x1b[32mbody: ok\x1b[0m");
v.feed(b"\x1b[5;10Hprogress: 42%");
let frame = v.render_screen();
let mut client = vt(24, 80);
client.feed(&frame);
assert_eq!(
client.capture_visible(),
v.capture_visible(),
"replay frame diverges from server grid"
);
assert_eq!(
client.term.renderable_content().cursor.point,
v.term.renderable_content().cursor.point
);
}
#[test]
fn encode_cell_frame_produces_valid_json() {
let mut v = vt(24, 80);
let json = v.encode_cell_frame("test-session", false);
let parsed: serde_json::Value = serde_json::from_str(&json).expect("must be valid JSON");
assert_eq!(parsed["t"], "cell_frame");
assert_eq!(parsed["session_id"], "test-session");
assert_eq!(parsed["width"], 80);
assert_eq!(parsed["height"], 24);
assert_eq!(parsed["full"], true);
assert_eq!(parsed["overlay"], false);
assert!(parsed["cursor"].is_object(), "cursor must be present");
assert_eq!(parsed["rows"].as_array().unwrap().len(), 24);
}
#[test]
fn encode_cell_frame_includes_sgr_for_styled_runs() {
let mut v = vt(24, 80);
v.feed(b"\x1b[1;31mRED\x1b[0m");
let json = v.encode_cell_frame("test-session", false);
let parsed: serde_json::Value = serde_json::from_str(&json).expect("must be valid JSON");
let runs = parsed["rows"][0]["runs"].as_array().unwrap();
assert_eq!(runs[0].as_str(), Some("1;31"));
assert_eq!(runs[1].as_str(), Some("RED"));
let cursor = parsed["cursor"].as_object().unwrap();
assert_eq!(cursor["col"], 4);
}
#[test]
fn encode_cell_frame_merges_wide_chars_into_one_run() {
let mut v = vt(24, 80);
v.feed("你好".as_bytes()); let json = v.encode_cell_frame("test-session", false);
let parsed: serde_json::Value = serde_json::from_str(&json).expect("must be valid JSON");
let runs = parsed["rows"][0]["runs"].as_array().unwrap();
assert_eq!(runs[0].as_str(), Some(""), "unstyled row starts with the default run");
assert_eq!(runs[1].as_str().unwrap().trim_end(), "你好");
}
#[test]
fn encode_cell_frame_empty_screen_is_one_blank_run() {
let mut v = vt(24, 80);
let json = v.encode_cell_frame("test-session", false);
let parsed: serde_json::Value = serde_json::from_str(&json).expect("must be valid JSON");
let rows = parsed["rows"].as_array().unwrap();
assert_eq!(rows.len(), 24);
let runs = rows[0]["runs"].as_array().unwrap();
assert_eq!(runs.len(), 2);
assert_eq!(runs[0].as_str(), Some(""));
assert_eq!(runs[1].as_str().unwrap().len(), 80);
}
#[test]
fn first_cell_frame_is_full() {
let mut v = vt(24, 80);
let json = v.encode_cell_frame("test-session", false);
let parsed: serde_json::Value = serde_json::from_str(&json).expect("must be valid JSON");
assert_eq!(parsed["full"], true, "first frame must be full");
assert!(parsed["row_indices"].is_null(), "full frame must not have row_indices");
}
#[test]
fn unchanged_second_frame_is_diff() {
let mut v = vt(24, 80);
v.feed(b"stable\r\n");
let _ = v.encode_cell_frame("test-session", false); let json = v.encode_cell_frame("test-session", false); let parsed: serde_json::Value = serde_json::from_str(&json).expect("must be valid JSON");
assert_eq!(parsed["full"], false, "unchanged second frame should be diff");
assert!(parsed.get("row_indices").is_some(), "diff frame must have row_indices");
let indices = parsed["row_indices"].as_array().unwrap();
assert!(indices.is_empty(), "unchanged rows must produce empty diff");
}
#[test]
fn changed_row_appears_in_row_indices() {
let mut v = vt(24, 80);
v.feed(b"stable\r\n"); let _ = v.encode_cell_frame("test-session", false); v.feed(b"\x1b[5;1Hchanged"); let json = v.encode_cell_frame("test-session", false);
let parsed: serde_json::Value = serde_json::from_str(&json).expect("must be valid JSON");
assert_eq!(parsed["full"], false);
let indices = parsed["row_indices"].as_array().unwrap();
assert_eq!(indices.len(), 1, "one row changed");
let rows = parsed["rows"].as_array().unwrap();
assert_eq!(rows.len(), 1, "one row in diff");
assert!(rows[0]["runs"][1].as_str().unwrap().starts_with("changed"));
}
#[test]
fn tab_cells_are_normalized_to_spaces_in_frame_and_screen() {
let mut v = vt(24, 80);
v.feed(b" \t x"); let json = v.encode_cell_frame("test-session", false);
let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
let runs = parsed["rows"][0]["runs"].as_array().unwrap();
let text: String =
runs.iter().skip(1).step_by(2).map(|s| s.as_str().unwrap_or_default()).collect();
assert!(!text.contains('\t'), "runs must not contain raw TAB: {text:?}");
assert!(text.contains('x'), "content must survive normalization: {text:?}");
let screen = v.render_screen();
let screen_str = String::from_utf8(screen).expect("utf8");
assert!(!screen_str.contains('\t'), "render_screen must not emit raw TAB");
}
#[test]
fn cursor_omitted_when_unchanged() {
let mut v = vt(24, 80);
v.feed(b"hello");
let json1 = v.encode_cell_frame("test-session", false);
let parsed: serde_json::Value = serde_json::from_str(&json1).expect("must be valid JSON");
assert!(parsed["cursor"].is_object(), "first frame must include cursor");
let _ = v.encode_cell_frame("test-session", false); let json2 = v.encode_cell_frame("test-session", false);
let parsed2: serde_json::Value = serde_json::from_str(&json2).expect("must be valid JSON");
assert!(parsed2["cursor"].is_null(), "cursor must be omitted when unchanged");
}
#[test]
fn invalidate_diff_forces_next_frame_full() {
let mut v = vt(24, 80);
v.feed(b"a\r\n");
let _ = v.encode_cell_frame("ts", false); let _ = v.encode_cell_frame("ts", false); v.invalidate_diff(); let json = v.encode_cell_frame("ts", false);
let parsed: serde_json::Value = serde_json::from_str(&json).expect("must be valid JSON");
assert_eq!(parsed["full"], true, "after invalidate_diff must be full");
assert!(parsed["row_indices"].is_null());
}
#[test]
fn diff_engine_resizes_on_vt_resize() {
let mut v = vt(24, 80);
let _ = v.encode_cell_frame("ts", false); let _ = v.encode_cell_frame("ts", false); v.resize(40, 80);
let json = v.encode_cell_frame("ts", false);
let parsed: serde_json::Value = serde_json::from_str(&json).expect("must be valid JSON");
assert_eq!(parsed["full"], true, "after resize must be full");
assert_eq!(parsed["height"], 40);
}
#[test]
fn overlay_frame_includes_shape_in_cursor() {
let mut v = vt(24, 80);
v.feed(b"hello");
let json = v.encode_overlay_frame("test-session");
let parsed: serde_json::Value = serde_json::from_str(&json).expect("must be valid JSON");
assert_eq!(parsed["overlay"], true);
assert_eq!(parsed["full"], true);
let cursor = parsed["cursor"].as_object().unwrap();
assert!(cursor.get("shape").is_some(), "overlay cursor must include shape");
}
#[test]
fn seq_increments_on_live_frames_and_skips_viewport_overlay() {
let mut v = vt(24, 80);
let f1: serde_json::Value =
serde_json::from_str(&v.encode_cell_frame("ts", false)).unwrap();
assert_eq!(f1["seq"], 1, "first live frame seq = 1");
let f2: serde_json::Value =
serde_json::from_str(&v.encode_cell_frame("ts", false)).unwrap();
assert_eq!(f2["seq"], 2, "seq increments per live encode");
let vp: serde_json::Value =
serde_json::from_str(&v.encode_viewport_frame("ts", 0, false)).unwrap();
assert!(vp.get("seq").is_none(), "viewport frame must omit seq");
let ov: serde_json::Value = serde_json::from_str(&v.encode_overlay_frame("ts")).unwrap();
assert!(ov.get("seq").is_none(), "overlay frame must omit seq");
let f3: serde_json::Value =
serde_json::from_str(&v.encode_cell_frame("ts", false)).unwrap();
assert_eq!(f3["seq"], 3, "viewport/overlay must not consume seq numbers");
}
#[test]
fn force_full_emits_all_rows_and_keeps_baseline_semantics() {
let mut v = vt(24, 80);
v.feed(b"stable\r\n");
let _ = v.encode_cell_frame("ts", false); let _ = v.encode_cell_frame("ts", false);
let f: serde_json::Value = serde_json::from_str(&v.encode_cell_frame("ts", true)).unwrap();
assert_eq!(f["full"], true, "force_full must produce a full frame");
assert!(f["row_indices"].is_null(), "full frame must not have row_indices");
let rows = f["rows"].as_array().unwrap();
assert_eq!(rows.len(), 24, "full frame must carry every screen row");
let d: serde_json::Value = serde_json::from_str(&v.encode_cell_frame("ts", false)).unwrap();
assert_eq!(d["full"], false, "frame after force_full returns to diff");
let indices = d["row_indices"].as_array().unwrap();
assert!(indices.is_empty(), "baseline after force_full must match current grid");
}
#[test]
fn overlay_frame_carries_alt_screen_flag() {
let mut v = vt(24, 80);
v.feed(b"\x1b[?1049h"); let parsed: serde_json::Value =
serde_json::from_str(&v.encode_overlay_frame("ts")).unwrap();
assert_eq!(parsed["alt_screen"], true, "enter overlay must carry alt_screen=true");
v.feed(b"\x1b[?1049l"); let parsed: serde_json::Value =
serde_json::from_str(&v.encode_overlay_frame("ts")).unwrap();
assert_eq!(parsed["alt_screen"], false, "exit overlay must carry alt_screen=false");
}
#[test]
fn regular_frames_omit_alt_screen_field() {
let mut v = vt(24, 80);
let parsed: serde_json::Value =
serde_json::from_str(&v.encode_cell_frame("ts", false)).unwrap();
assert!(parsed.get("alt_screen").is_none(), "regular frame must omit alt_screen");
let parsed: serde_json::Value =
serde_json::from_str(&v.encode_viewport_frame("ts", 0, false)).unwrap();
assert!(parsed.get("alt_screen").is_none(), "viewport frame must omit alt_screen");
}
#[test]
fn frame_carries_bracketed_paste_flag() {
let mut v = vt(24, 80);
v.feed(b"\x1b[?2004h"); let parsed: serde_json::Value =
serde_json::from_str(&v.encode_cell_frame("ts", false)).unwrap();
assert_eq!(
parsed["bracketed_paste"], true,
"frame after ?2004h must carry bracketed_paste=true"
);
v.feed(b"\x1b[?2004l"); let parsed: serde_json::Value =
serde_json::from_str(&v.encode_cell_frame("ts", false)).unwrap();
assert_eq!(
parsed["bracketed_paste"], false,
"frame after ?2004l must carry bracketed_paste=false"
);
}
#[test]
fn overlay_frame_carries_bracketed_paste_flag() {
let mut v = vt(24, 80);
v.feed(b"\x1b[?2004h");
let parsed: serde_json::Value =
serde_json::from_str(&v.encode_overlay_frame("ts")).unwrap();
assert_eq!(
parsed["bracketed_paste"], true,
"overlay frame must carry bracketed_paste=true"
);
}
#[test]
fn viewport_frame_carries_bracketed_paste_flag() {
let mut v = vt(24, 80);
v.feed(b"\x1b[?2004h");
let parsed: serde_json::Value =
serde_json::from_str(&v.encode_viewport_frame("ts", 0, false)).unwrap();
assert_eq!(
parsed["bracketed_paste"], true,
"viewport frame must carry bracketed_paste=true"
);
}
#[test]
fn frame_carries_mouse_mode_fields() {
let mut v = vt(24, 80);
v.feed(b"\x1b[?1000h\x1b[?1006h"); let parsed: serde_json::Value =
serde_json::from_str(&v.encode_cell_frame("ts", false)).unwrap();
assert_eq!(parsed["mouse_mode"], "press", "frame after ?1000h must carry mouse_mode=press");
assert_eq!(
parsed["mouse_encoding"], "sgr",
"frame after ?1006h must carry mouse_encoding=sgr"
);
v.feed(b"\x1b[?1002h"); let parsed: serde_json::Value =
serde_json::from_str(&v.encode_cell_frame("ts", false)).unwrap();
assert_eq!(parsed["mouse_mode"], "drag", "frame after ?1002h must carry mouse_mode=drag");
v.feed(b"\x1b[?1003h"); let parsed: serde_json::Value =
serde_json::from_str(&v.encode_cell_frame("ts", false)).unwrap();
assert_eq!(
parsed["mouse_mode"], "motion",
"frame after ?1003h must carry mouse_mode=motion"
);
v.feed(b"\x1b[?1003l\x1b[?1002l\x1b[?1000l\x1b[?1006l"); let parsed: serde_json::Value =
serde_json::from_str(&v.encode_cell_frame("ts", false)).unwrap();
assert_eq!(
parsed["mouse_mode"], "none",
"frame after DECSET resets must carry mouse_mode=none"
);
assert_eq!(
parsed["mouse_encoding"], "default",
"frame after ?1006l must carry mouse_encoding=default"
);
v.feed(b"\x1b[?1005h"); let parsed: serde_json::Value =
serde_json::from_str(&v.encode_cell_frame("ts", false)).unwrap();
assert_eq!(
parsed["mouse_encoding"], "utf8",
"frame after ?1005h must carry mouse_encoding=utf8"
);
}
#[test]
fn overlay_frame_carries_mouse_mode_fields() {
let mut v = vt(24, 80);
v.feed(b"\x1b[?1003h\x1b[?1005h");
let parsed: serde_json::Value =
serde_json::from_str(&v.encode_overlay_frame("ts")).unwrap();
assert_eq!(parsed["mouse_mode"], "motion", "overlay frame must carry mouse_mode=motion");
assert_eq!(
parsed["mouse_encoding"], "utf8",
"overlay frame must carry mouse_encoding=utf8"
);
}
#[test]
fn viewport_frame_carries_mouse_mode_fields() {
let mut v = vt(24, 80);
v.feed(b"\x1b[?1003h\x1b[?1005h");
let parsed: serde_json::Value =
serde_json::from_str(&v.encode_viewport_frame("s", 0, false)).unwrap();
assert_eq!(parsed["mouse_mode"], "motion", "viewport frame must carry mouse_mode=motion");
assert_eq!(
parsed["mouse_encoding"], "utf8",
"viewport frame must carry mouse_encoding=utf8"
);
}
fn vt_with_history() -> VtState {
let mut v = vt(24, 80);
for i in 0..30 {
v.feed(format!("L{i}\r\n").as_bytes());
}
v
}
#[test]
fn viewport_frame_y0_is_live_screen_with_marker() {
let mut v = vt_with_history();
let json = v.encode_viewport_frame("ts", 0, false);
let parsed: serde_json::Value = serde_json::from_str(&json).expect("must be valid JSON");
assert_eq!(parsed["viewport"], 0, "viewport marker must carry y");
assert_eq!(parsed["full"], true);
assert_eq!(parsed["overlay"], false);
assert!(parsed["row_indices"].is_null());
let rows = parsed["rows"].as_array().unwrap();
assert_eq!(rows.len(), 24);
assert_eq!(top_row_text(&parsed), "L7");
assert_eq!(parsed["cursor"]["visible"].as_bool(), Some(true));
}
fn top_row_text(parsed: &serde_json::Value) -> String {
let runs = parsed["rows"][0]["runs"].as_array().unwrap();
runs[1].as_str().unwrap().chars().take(2).collect()
}
#[test]
fn viewport_frame_scrolls_into_history() {
let mut v = vt_with_history();
let json = v.encode_viewport_frame("ts", 7, false);
let parsed: serde_json::Value = serde_json::from_str(&json).expect("must be valid JSON");
assert_eq!(top_row_text(&parsed), "L0");
let json = v.encode_viewport_frame("ts", 5, false);
let parsed: serde_json::Value = serde_json::from_str(&json).expect("must be valid JSON");
assert_eq!(top_row_text(&parsed), "L2");
}
#[test]
fn viewport_frame_y_clamps_to_history_size() {
let mut v = vt_with_history();
let json = v.encode_viewport_frame("ts", u32::MAX, false);
let parsed: serde_json::Value = serde_json::from_str(&json).expect("must be valid JSON");
assert_eq!(parsed["viewport"], 7, "y must clamp to history size");
assert_eq!(top_row_text(&parsed), "L0");
}
#[test]
fn viewport_frame_hides_cursor_above_bottom() {
let mut v = vt_with_history();
let json = v.encode_viewport_frame("ts", 6, false);
let parsed: serde_json::Value = serde_json::from_str(&json).expect("must be valid JSON");
assert_eq!(
parsed["cursor"]["visible"].as_bool(),
Some(false),
"history window has no live cursor"
);
let json = v.encode_viewport_frame("ts", 0, false);
let parsed: serde_json::Value = serde_json::from_str(&json).expect("must be valid JSON");
assert_eq!(parsed["cursor"]["visible"].as_bool(), Some(true));
}
#[test]
fn viewport_frame_does_not_disturb_diff_baseline() {
let mut v = vt_with_history();
let _ = v.encode_cell_frame("ts", false); let _ = v.encode_viewport_frame("ts", 6, false); let json = v.encode_cell_frame("ts", false); let parsed: serde_json::Value = serde_json::from_str(&json).expect("must be valid JSON");
assert_eq!(parsed["full"], false, "viewport encode must not invalidate diff");
assert!(parsed["row_indices"].as_array().unwrap().is_empty());
}
#[test]
fn viewport_frame_empty_history_clamps_to_zero() {
let mut v = vt(24, 80); let json = v.encode_viewport_frame("ts", 10, false);
let parsed: serde_json::Value = serde_json::from_str(&json).expect("must be valid JSON");
assert_eq!(parsed["viewport"], 0, "empty history clamps y to 0");
}
fn top_row_full(parsed: &serde_json::Value) -> String {
let runs = parsed["rows"][0]["runs"].as_array().unwrap();
(1..runs.len()).step_by(2).map(|i| runs[i].as_str().unwrap()).collect()
}
fn feed_lines(v: &mut VtState, from: usize, to: usize) {
for i in from..to {
v.feed(format!("L{i:05}\r\n").as_bytes());
}
}
#[test]
fn scroll_request_stores_anchor_and_refresh_serves_it() {
let mut v = vt(24, 80);
feed_lines(&mut v, 0, 50);
for _ in 0..40 {
v.feed(b"\r\n"); }
let first: serde_json::Value =
serde_json::from_str(&v.encode_viewport_frame("ts", 5, false)).unwrap();
let anchored = top_row_full(&first);
assert!(anchored.trim().is_empty(), "前置:锚点行应为空行(周期性内容)");
for i in 50..70 {
v.feed(format!("L{i:05}\r\n\r\n").as_bytes());
}
let held: serde_json::Value =
serde_json::from_str(&v.encode_viewport_frame("ts", 0, true)).unwrap();
assert_eq!(top_row_full(&held), anchored, "保锚刷新必须停在同一批内容");
assert!(held["viewport"].as_u64().unwrap() > 5, "历史增长后实际 y 必须前移");
}
#[test]
fn refresh_without_anchor_falls_back_to_requested_y() {
let mut v = vt(24, 80);
feed_lines(&mut v, 0, 60);
let parsed: serde_json::Value =
serde_json::from_str(&v.encode_viewport_frame("ts", 10, true)).unwrap();
assert_eq!(parsed["viewport"], 10);
assert_eq!(
top_row_full(&parsed),
top_row_full(&serde_json::from_str(&v.encode_viewport_frame("ts", 10, false)).unwrap()),
"无锚回退后内容必须与滚动请求一致"
);
}
#[test]
fn anchor_stable_while_history_grows_unsaturated() {
let mut v = vt(24, 80);
feed_lines(&mut v, 0, 60); let first: serde_json::Value =
serde_json::from_str(&v.encode_viewport_frame("ts", 10, false)).unwrap();
let anchored = top_row_full(&first);
feed_lines(&mut v, 60, 80); let held: serde_json::Value =
serde_json::from_str(&v.encode_viewport_frame("ts", 0, true)).unwrap();
assert_eq!(top_row_full(&held), anchored, "未饱和期绝对索引稳定,锚不动");
assert_eq!(held["viewport"], 30, "y 随新增行数前移(20 行)");
}
#[test]
fn anchor_follows_screen_scroll_when_history_saturated() {
let mut v = vt(24, 80);
feed_lines(&mut v, 0, 1100); let _ = v.encode_cell_frame("ts", false); let first: serde_json::Value =
serde_json::from_str(&v.encode_viewport_frame("ts", 50, false)).unwrap();
let anchored = top_row_full(&first);
for i in 1100..1130 {
v.feed(format!("L{i:05}\r\n").as_bytes());
let _ = v.encode_cell_frame("ts", false);
}
let held: serde_json::Value =
serde_json::from_str(&v.encode_viewport_frame("ts", 0, true)).unwrap();
assert_eq!(top_row_full(&held), anchored, "饱和淘汰期锚定内容必须不变");
assert_eq!(held["viewport"], 80, "锚随 30 行淘汰前移(950 → 920),y = 1000 - 920");
}
#[test]
fn anchor_clamps_to_zero_when_anchor_line_evicted() {
let mut v = vt(24, 80);
feed_lines(&mut v, 0, 1100);
let _ = v.encode_cell_frame("ts", false);
let first: serde_json::Value =
serde_json::from_str(&v.encode_viewport_frame("ts", 998, false)).unwrap();
assert_eq!(first["viewport"], 998);
for i in 1100..1105 {
v.feed(format!("L{i:05}\r\n").as_bytes());
let _ = v.encode_cell_frame("ts", false); }
let held: serde_json::Value =
serde_json::from_str(&v.encode_viewport_frame("ts", 0, true)).unwrap();
assert_eq!(held["viewport"], 1000, "锚钳 0 → 视口贴住最旧可用行");
let oldest: serde_json::Value =
serde_json::from_str(&v.encode_viewport_frame("ts", 1000, false)).unwrap();
assert_eq!(top_row_full(&held), top_row_full(&oldest));
}
#[test]
fn anchor_holds_when_full_screen_redraw_misses_detection() {
let mut v = vt(24, 80);
feed_lines(&mut v, 0, 1100);
let _ = v.encode_cell_frame("ts", false);
let first: serde_json::Value =
serde_json::from_str(&v.encode_viewport_frame("ts", 50, false)).unwrap();
let anchored = top_row_full(&first);
for r in 0..24 {
v.feed(format!("\x1b[{};1Hredraw-{r}\x1b[K", r + 1).as_bytes());
}
let _ = v.encode_cell_frame("ts", false); let held: serde_json::Value =
serde_json::from_str(&v.encode_viewport_frame("ts", 0, true)).unwrap();
assert_eq!(held["viewport"], 50, "miss 不调整——有界缓滑移,无棘轮");
assert_eq!(top_row_full(&held), anchored, "历史行未被重写,内容不变");
}
#[test]
fn scroll_detection_skips_unchanged_screen() {
let mut v = vt(24, 80);
feed_lines(&mut v, 0, 1100); for _ in 0..100 {
v.feed(b"\r\n"); }
let _ = v.encode_cell_frame("ts", false); let first: serde_json::Value =
serde_json::from_str(&v.encode_viewport_frame("ts", 50, false)).unwrap();
assert_eq!(first["viewport"], 50);
for _ in 0..5 {
let _ = v.encode_cell_frame("ts", false); }
let held: serde_json::Value =
serde_json::from_str(&v.encode_viewport_frame("ts", 0, true)).unwrap();
assert_eq!(held["viewport"], 50, "静态屏不得被滚移检测误调");
}
#[test]
fn scroll_to_y0_clears_anchor() {
let mut v = vt(24, 80);
feed_lines(&mut v, 0, 60);
let _ = v.encode_viewport_frame("ts", 10, false); feed_lines(&mut v, 60, 80);
let _ = v.encode_viewport_frame("ts", 0, false); feed_lines(&mut v, 80, 90); let held: serde_json::Value =
serde_json::from_str(&v.encode_viewport_frame("ts", 5, true)).unwrap();
assert_eq!(held["viewport"], 5, "y=0 已清锚:刷新按请求 y 定位");
assert_eq!(
top_row_full(&held),
top_row_full(&serde_json::from_str(&v.encode_viewport_frame("ts", 5, false)).unwrap()),
"清锚后刷新内容与同 y 滚动请求一致"
);
}
#[test]
fn resize_clears_anchor() {
let mut v = vt(24, 80);
feed_lines(&mut v, 0, 60);
let _ = v.encode_viewport_frame("ts", 10, false);
v.resize(30, 100); let held: serde_json::Value =
serde_json::from_str(&v.encode_viewport_frame("ts", 5, true)).unwrap();
assert_eq!(held["viewport"], 5, "resize 清锚:刷新回退按请求 y");
}
#[test]
fn alt_screen_enter_clears_anchor() {
let mut v = vt(24, 80);
feed_lines(&mut v, 0, 60);
let _ = v.encode_viewport_frame("ts", 10, false);
v.clear_viewport_anchor(); feed_lines(&mut v, 60, 80);
let held: serde_json::Value =
serde_json::from_str(&v.encode_viewport_frame("ts", 5, true)).unwrap();
assert_eq!(held["viewport"], 5, "alt-screen 进入清锚:刷新回退按请求 y");
}
fn visible_seq(row: &serde_json::Value) -> Vec<(String, String)> {
let mut out = Vec::new();
let runs = row["runs"].as_array().expect("row must carry runs");
for pair in runs.chunks(2) {
let sgr = pair[0].as_str().unwrap_or_default();
for c in pair[1].as_str().unwrap_or_default().chars() {
out.push((c.to_string(), sgr.to_string()));
}
}
out
}
fn grid_seq(v: &VtState, line: Line) -> Vec<(String, String)> {
let grid = v.term.grid();
let mut out = Vec::new();
for col in 0..v.term.columns() {
let cell = &grid[line][Column(col)];
if cell.flags.intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER) {
continue;
}
let sgr = sgr_body(&CellStyle::of(cell)).unwrap_or_default();
let mut text = String::from(cell.c);
if let Some(zw) = cell.zerowidth() {
text.extend(zw.iter().copied());
}
for ch in text.chars() {
out.push((ch.to_string(), sgr.clone()));
}
}
out
}
fn rows_of(feed: &[u8]) -> Vec<serde_json::Value> {
let mut v = vt(6, 20);
v.feed(feed);
let json = v.encode_viewport_frame("ts", 0, false);
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
parsed["rows"].as_array().unwrap().clone()
}
#[test]
fn runs_encoding_is_lossless_against_grid() {
let cases: &[&[u8]] = &[
b"\r\n",
b"plain ascii row\r\n",
b"\x1b[1;32mgreen\x1b[0m plain\r\n",
b"\x1b[31mR\x1b[32mG\x1b[33mY\x1b[34mB\x1b[0m tail\r\n",
b"trailing spaces \r\n",
"中文测试 ターミナル 混排\r\n".as_bytes(),
"\x1b[31m中文\x1b[0m 混排\r\n".as_bytes(),
"emoji \u{1f600}\u{1f1fa}\u{1f1f8} end\r\n".as_bytes(),
"combining e\u{301} cafe\u{301}\r\n".as_bytes(),
];
for feed in cases {
let mut v = vt(6, 20);
v.feed(feed);
let json = v.encode_viewport_frame("ts", 0, false);
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
let rows = parsed["rows"].as_array().unwrap();
for (i, row) in rows.iter().enumerate() {
assert_eq!(
visible_seq(row),
grid_seq(&v, Line(i as i32)),
"row {i} lost information for input {:?}",
String::from_utf8_lossy(feed)
);
}
}
}
#[test]
fn runs_merges_consecutive_cells_sharing_sgr() {
let rows = rows_of(b"\x1b[1;32mAB\x1b[0mcd\r\n");
let runs = rows[0]["runs"].as_array().unwrap();
assert_eq!(runs.len(), 4, "must be (sgr, text) pairs, got {runs:?}");
assert_eq!(runs[0], "1;32");
assert_eq!(runs[1], "AB");
assert_eq!(runs[2], "");
assert_eq!(runs[3].as_str().unwrap().trim_end(), "cd");
}
#[test]
fn runs_skips_wide_char_spacers_without_empty_run() {
let rows = rows_of("\x1b[31m中文\x1b[0m\r\n".as_bytes());
let runs = rows[0]["runs"].as_array().unwrap();
assert_eq!(runs.len(), 4, "spacers must not split the run, got {runs:?}");
assert_eq!(runs[0], "31");
assert_eq!(runs[1].as_str().unwrap().trim_end(), "中文");
assert_eq!(runs[2], "");
}
#[test]
fn runs_keeps_zerowidth_combining_characters() {
let rows = rows_of("cafe\u{301}\r\n".as_bytes());
let text = rows[0]["runs"][1].as_str().unwrap();
assert!(text.starts_with("cafe\u{301}"), "combining accent must survive, got {text:?}");
}
#[test]
fn runs_handles_full_width_row_as_single_run() {
let mut v = vt(2, 200);
v.feed(&[b'x'; 200]);
let json = v.encode_viewport_frame("ts", 0, false);
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
let runs = parsed["rows"][0]["runs"].as_array().unwrap();
assert_eq!(runs.len(), 2, "single-style row must be one run, got {runs:?}");
assert_eq!(runs[1].as_str().unwrap().len(), 200);
}
#[test]
fn row_hash_detects_content_and_style_changes() {
let mut v = vt(6, 20);
v.feed(b"abc\r\ndef\r\n");
let base = hash_grid_row(v.term.grid(), 20, Line(0));
assert_eq!(base, hash_grid_row(v.term.grid(), 20, Line(0)), "同内容必须同指纹");
assert_ne!(base, hash_grid_row(v.term.grid(), 20, Line(1)), "不同行内容必须不同");
v.feed(b"\x1b[1;1Habd");
assert_ne!(base, hash_grid_row(v.term.grid(), 20, Line(0)), "字符变化必须检出");
v.feed(b"\x1b[1;1Habc"); assert_eq!(base, hash_grid_row(v.term.grid(), 20, Line(0)), "还原后回到原指纹");
v.feed(b"\x1b[1;1H\x1b[1mabc"); assert_ne!(base, hash_grid_row(v.term.grid(), 20, Line(0)), "样式变化必须检出");
}
#[test]
fn runs_encoding_applies_to_all_frame_types() {
let mut v = vt(6, 20);
v.feed(b"abc\r\n");
for json in [
v.encode_cell_frame("ts", false),
v.encode_overlay_frame("ts"),
v.encode_viewport_frame("ts", 0, false),
] {
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(parsed["rows"][0]["runs"].is_array(), "all frames must use runs: {parsed}");
}
}
}