use crate::terminal::cell::{
self, ATTR_BLINK, ATTR_BOLD, ATTR_DIM, ATTR_HIDDEN, ATTR_INVERSE, ATTR_ITALIC, ATTR_STRIKE,
ATTR_UNDERLINE, Cell, CellAttrs, CellColor,
};
use crate::terminal::{CursorStyle, MouseEncoding, MouseTracking};
pub trait TerminalOps {
fn put_cell(&mut self, cell: Cell);
fn put_ascii_bytes(&mut self, bytes: &[u8], fg: CellColor, bg: CellColor, attrs: CellAttrs) {
for byte in bytes {
self.put_cell(Cell::ascii_with_attrs(*byte, fg, bg, attrs));
}
}
fn newline(&mut self, blank: Cell);
fn carriage_return(&mut self);
fn backspace(&mut self);
fn tab(&mut self);
fn clear_all(&mut self, blank: Cell);
fn clear_display_forward(&mut self, blank: Cell);
fn clear_display_backward(&mut self, blank: Cell);
fn clear_line_forward(&mut self, blank: Cell);
fn clear_line_from_start(&mut self, blank: Cell);
fn clear_line(&mut self, blank: Cell);
fn cursor_up(&mut self, n: usize);
fn cursor_down(&mut self, n: usize);
fn cursor_next_line(&mut self, n: usize);
fn cursor_prev_line(&mut self, n: usize);
fn cursor_forward(&mut self, n: usize);
fn cursor_backward(&mut self, n: usize);
fn cursor_horizontal_absolute(&mut self, col: usize);
fn cursor_vertical_absolute(&mut self, row: usize);
fn cursor_position(&mut self, row: usize, col: usize);
fn set_scroll_region(&mut self, top_1: usize, bot_1: usize);
fn insert_chars(&mut self, n: usize, blank: Cell);
fn delete_chars(&mut self, n: usize, blank: Cell);
fn erase_chars(&mut self, n: usize, blank: Cell);
fn insert_lines(&mut self, n: usize, blank: Cell);
fn delete_lines(&mut self, n: usize, blank: Cell);
fn scroll_up(&mut self, n: usize, blank: Cell);
fn scroll_down(&mut self, n: usize, blank: Cell);
fn repeat_preceding_cell(&mut self, n: usize);
fn save_cursor(&mut self);
fn restore_cursor(&mut self);
fn enter_alt_screen(&mut self);
fn exit_alt_screen(&mut self);
fn set_app_cursor(&mut self, enabled: bool);
fn set_bracketed_paste(&mut self, enabled: bool);
fn set_cursor_visible(&mut self, visible: bool);
fn set_cursor_style(&mut self, style: CursorStyle);
fn set_mouse_tracking(&mut self, tracking: MouseTracking);
fn set_mouse_sgr(&mut self, enabled: bool);
fn set_mouse_encoding(&mut self, encoding: MouseEncoding);
fn set_focus_reporting(&mut self, enabled: bool);
fn set_title(&mut self, title: String);
fn set_clipboard_text(&mut self, text: Option<String>);
fn set_active_hyperlink(&mut self, uri: Option<String>);
}
#[derive(Default)]
struct CsiSignals {
alt_screen_on: bool,
alt_screen_off: bool,
app_cursor_on: bool,
app_cursor_off: bool,
bracketed_paste_on: bool,
bracketed_paste_off: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum FastPathScanState {
Ground,
Escape,
EscapeIntermediate,
Csi,
Osc,
OscEscape,
Dcs,
DcsEscape,
String,
StringEscape,
}
#[derive(Debug, Clone, Default)]
pub struct ParserProfile {
pub cell_writes: u64,
pub sgr_updates: u64,
pub line_advances: u64,
pub bytes_received: u64,
}
pub struct Parser {
vte: vte::Parser,
bytes_received: u64,
attrs: CellAttrs,
fg: CellColor,
bg: CellColor,
sgr_buf: Vec<u16>,
ascii_buf: Vec<u8>,
ground_fast_path: bool,
fast_path_scan_state: FastPathScanState,
pub alt_screen_requested: bool,
pub alt_screen_exit_requested: bool,
pub app_cursor_requested: bool,
pub app_cursor_exit_requested: bool,
pub bracketed_paste_requested: bool,
pub bracketed_paste_exit_requested: bool,
pub last_profile: ParserProfile,
}
impl Parser {
pub fn new() -> Self {
Self {
vte: vte::Parser::new(),
bytes_received: 0,
attrs: 0,
fg: cell::DEFAULT_FG,
bg: cell::DEFAULT_BG,
sgr_buf: Vec::new(),
ascii_buf: Vec::with_capacity(4096),
ground_fast_path: true,
fast_path_scan_state: FastPathScanState::Ground,
alt_screen_requested: false,
alt_screen_exit_requested: false,
app_cursor_requested: false,
app_cursor_exit_requested: false,
bracketed_paste_requested: false,
bracketed_paste_exit_requested: false,
last_profile: ParserProfile::default(),
}
}
pub fn advance<T: TerminalOps>(&mut self, bytes: &[u8], terminal: &mut T) {
self.bytes_received += bytes.len() as u64;
self.clear_transient_signals();
let mut profile = ParserProfile {
bytes_received: bytes.len() as u64,
..ParserProfile::default()
};
let mut suffix = bytes;
if self.ground_fast_path && self.ascii_buf.is_empty() {
let prefix_len = fast_ground_ascii_prefix_len(bytes);
if prefix_len > 0 {
let ascii_profile = self.advance_ground_ascii(&bytes[..prefix_len], terminal);
profile.add_work_from(&ascii_profile);
suffix = &bytes[prefix_len..];
}
if suffix.is_empty() {
self.last_profile = profile;
return;
}
}
self.scan_fast_path_state(suffix);
let (signals, vte_profile) = self.advance_vte(suffix, terminal);
profile.add_work_from(&vte_profile);
self.last_profile = profile;
self.alt_screen_requested = signals.alt_screen_on;
self.alt_screen_exit_requested = signals.alt_screen_off;
self.app_cursor_requested = signals.app_cursor_on;
self.app_cursor_exit_requested = signals.app_cursor_off;
self.bracketed_paste_requested = signals.bracketed_paste_on;
self.bracketed_paste_exit_requested = signals.bracketed_paste_off;
self.ground_fast_path = self.fast_path_scan_state == FastPathScanState::Ground
&& !ends_with_incomplete_utf8(bytes);
}
fn advance_vte<T: TerminalOps>(
&mut self,
bytes: &[u8],
terminal: &mut T,
) -> (CsiSignals, ParserProfile) {
let mut signals = CsiSignals::default();
let mut profile = ParserProfile {
bytes_received: bytes.len() as u64,
..ParserProfile::default()
};
{
let mut writer = GridWriter {
terminal,
signals: &mut signals,
attrs: self.attrs,
fg: self.fg,
bg: self.bg,
sgr_buf: std::mem::take(&mut self.sgr_buf),
ascii_buf: std::mem::take(&mut self.ascii_buf),
cell_writes: 0,
sgr_updates: 0,
line_advances: 0,
};
self.vte.advance(&mut writer, bytes);
writer.flush_ascii();
self.attrs = writer.attrs;
self.fg = writer.fg;
self.bg = writer.bg;
self.sgr_buf = writer.sgr_buf;
self.ascii_buf = writer.ascii_buf;
profile.cell_writes = writer.cell_writes;
profile.sgr_updates = writer.sgr_updates;
profile.line_advances = writer.line_advances;
}
(signals, profile)
}
fn clear_transient_signals(&mut self) {
self.alt_screen_requested = false;
self.alt_screen_exit_requested = false;
self.app_cursor_requested = false;
self.app_cursor_exit_requested = false;
self.bracketed_paste_requested = false;
self.bracketed_paste_exit_requested = false;
}
fn advance_ground_ascii<T: TerminalOps>(
&mut self,
bytes: &[u8],
terminal: &mut T,
) -> ParserProfile {
let mut profile = ParserProfile {
bytes_received: bytes.len() as u64,
..ParserProfile::default()
};
let mut run_start = 0usize;
for (idx, byte) in bytes.iter().copied().enumerate() {
if byte.is_ascii_graphic() || byte == b' ' {
continue;
}
if run_start < idx {
terminal.put_ascii_bytes(&bytes[run_start..idx], self.fg, self.bg, self.attrs);
profile.cell_writes += (idx - run_start) as u64;
}
match byte {
b'\n' => {
profile.line_advances += 1;
terminal.newline(Cell::blank_with_attrs(self.fg, self.bg, self.attrs));
}
b'\r' => terminal.carriage_return(),
0x08 => terminal.backspace(),
b'\t' => terminal.tab(),
0x07 => {}
0x0C => terminal.clear_all(Cell::blank_with_attrs(self.fg, self.bg, self.attrs)),
_ => unreachable!("fast ground ASCII prefilter rejected byte {byte:#x}"),
}
run_start = idx + 1;
}
if run_start < bytes.len() {
terminal.put_ascii_bytes(&bytes[run_start..], self.fg, self.bg, self.attrs);
profile.cell_writes += (bytes.len() - run_start) as u64;
}
profile
}
pub fn bytes_received(&self) -> u64 {
self.bytes_received
}
fn scan_fast_path_state(&mut self, bytes: &[u8]) {
for byte in bytes.iter().copied() {
self.fast_path_scan_state = next_fast_path_scan_state(self.fast_path_scan_state, byte);
}
}
}
impl ParserProfile {
fn add_work_from(&mut self, other: &Self) {
self.cell_writes += other.cell_writes;
self.sgr_updates += other.sgr_updates;
self.line_advances += other.line_advances;
}
}
fn fast_ground_ascii_prefix_len(bytes: &[u8]) -> usize {
bytes
.iter()
.position(|byte| !is_fast_ground_byte(*byte))
.unwrap_or(bytes.len())
}
fn is_fast_ground_byte(byte: u8) -> bool {
byte.is_ascii_graphic() || matches!(byte, b' ' | b'\n' | b'\r' | b'\t' | 0x08 | 0x07 | 0x0C)
}
fn next_fast_path_scan_state(state: FastPathScanState, byte: u8) -> FastPathScanState {
use FastPathScanState::*;
match state {
Ground => match byte {
0x1B => Escape,
_ => Ground,
},
Escape => match byte {
b'[' => Csi,
b']' => Osc,
b'P' => Dcs,
b'X' | b'^' | b'_' => String,
0x1B => Escape,
0x20..=0x2F => EscapeIntermediate,
0x30..=0x7E => Ground,
_ => Escape,
},
EscapeIntermediate => match byte {
0x1B => Escape,
0x20..=0x2F => EscapeIntermediate,
0x30..=0x7E => Ground,
_ => EscapeIntermediate,
},
Csi => match byte {
0x1B => Escape,
0x40..=0x7E => Ground,
_ => Csi,
},
Osc => match byte {
0x07 => Ground,
0x1B => OscEscape,
_ => Osc,
},
OscEscape => match byte {
b'\\' => Ground,
0x1B => OscEscape,
_ => Osc,
},
Dcs => match byte {
0x1B => DcsEscape,
_ => Dcs,
},
DcsEscape => match byte {
b'\\' => Ground,
0x1B => DcsEscape,
_ => Dcs,
},
String => match byte {
0x1B => StringEscape,
_ => String,
},
StringEscape => match byte {
b'\\' => Ground,
0x1B => StringEscape,
_ => String,
},
}
}
fn ends_with_incomplete_utf8(bytes: &[u8]) -> bool {
if bytes.is_empty() {
return false;
}
let continuation_count = bytes
.iter()
.rev()
.take_while(|byte| **byte & 0b1100_0000 == 0b1000_0000)
.count();
if continuation_count == bytes.len() {
return true;
}
let lead = bytes[bytes.len() - continuation_count - 1];
let expected_continuations = if lead & 0b1000_0000 == 0 {
return false;
} else if lead & 0b1110_0000 == 0b1100_0000 {
1
} else if lead & 0b1111_0000 == 0b1110_0000 {
2
} else if lead & 0b1111_1000 == 0b1111_0000 {
3
} else {
return false;
};
continuation_count < expected_continuations
}
struct GridWriter<'a, T: TerminalOps> {
terminal: &'a mut T,
signals: &'a mut CsiSignals,
attrs: CellAttrs,
fg: CellColor,
bg: CellColor,
sgr_buf: Vec<u16>,
ascii_buf: Vec<u8>,
cell_writes: u64,
sgr_updates: u64,
line_advances: u64,
}
impl<T: TerminalOps> GridWriter<'_, T> {
fn reset_sgr(&mut self) {
self.attrs = 0;
self.fg = cell::DEFAULT_FG;
self.bg = cell::DEFAULT_BG;
}
fn apply_sgr(&mut self, params: &[u16]) {
self.sgr_updates += 1;
let mut i = 0;
while i < params.len() {
match params[i] {
0 => self.reset_sgr(),
1 => self.attrs |= ATTR_BOLD,
2 => self.attrs |= ATTR_DIM,
3 => self.attrs |= ATTR_ITALIC,
4 => self.attrs |= ATTR_UNDERLINE,
5 | 6 => self.attrs |= ATTR_BLINK,
7 => self.attrs |= ATTR_INVERSE,
8 => self.attrs |= ATTR_HIDDEN,
9 => self.attrs |= ATTR_STRIKE,
22 => {
self.attrs &= !ATTR_BOLD;
self.attrs &= !ATTR_DIM;
}
23 => self.attrs &= !ATTR_ITALIC,
24 => self.attrs &= !ATTR_UNDERLINE,
25 => self.attrs &= !ATTR_BLINK,
27 => self.attrs &= !ATTR_INVERSE,
28 => self.attrs &= !ATTR_HIDDEN,
29 => self.attrs &= !ATTR_STRIKE,
30..=37 => self.fg = cell::color_ansi((params[i] - 30) as u8),
38 if i + 1 < params.len() => match params[i + 1] {
5 if i + 2 < params.len() => {
self.fg = cell::color_indexed(params[i + 2] as u8);
i += 2;
}
2 if i + 4 < params.len() => {
self.fg = cell::color_rgb(
params[i + 2] as u8,
params[i + 3] as u8,
params[i + 4] as u8,
);
i += 4;
}
_ => {}
},
39 => self.fg = cell::DEFAULT_FG,
40..=47 => self.bg = cell::color_ansi((params[i] - 40) as u8),
48 if i + 1 < params.len() => match params[i + 1] {
5 if i + 2 < params.len() => {
self.bg = cell::color_indexed(params[i + 2] as u8);
i += 2;
}
2 if i + 4 < params.len() => {
self.bg = cell::color_rgb(
params[i + 2] as u8,
params[i + 3] as u8,
params[i + 4] as u8,
);
i += 4;
}
_ => {}
},
49 => self.bg = cell::DEFAULT_BG,
90..=97 => self.fg = cell::color_ansi((params[i] - 82) as u8),
100..=107 => self.bg = cell::color_ansi((params[i] - 92) as u8),
_ => {}
}
i += 1;
}
}
fn set_private_mode(&mut self, mode: usize, enabled: bool) {
match (mode, enabled) {
(1, true) => {
self.signals.app_cursor_on = true;
self.terminal.set_app_cursor(true);
}
(1, false) => {
self.signals.app_cursor_off = true;
self.terminal.set_app_cursor(false);
}
(1049, true) => {
self.signals.alt_screen_on = true;
self.terminal.enter_alt_screen();
}
(1049, false) => {
self.signals.alt_screen_off = true;
self.terminal.exit_alt_screen();
}
(2004, true) => {
self.signals.bracketed_paste_on = true;
self.terminal.set_bracketed_paste(true);
}
(2004, false) => {
self.signals.bracketed_paste_off = true;
self.terminal.set_bracketed_paste(false);
}
(25, true) => self.terminal.set_cursor_visible(true),
(25, false) => self.terminal.set_cursor_visible(false),
(9, true) => self.terminal.set_mouse_tracking(MouseTracking::X10),
(9, false) => self.terminal.set_mouse_tracking(MouseTracking::Off),
(1000, true) => self.terminal.set_mouse_tracking(MouseTracking::Normal),
(1000, false) => self.terminal.set_mouse_tracking(MouseTracking::Off),
(1004, true) => self.terminal.set_focus_reporting(true),
(1004, false) => self.terminal.set_focus_reporting(false),
(1005, true) => self.terminal.set_mouse_encoding(MouseEncoding::Utf8),
(1005, false) => self.terminal.set_mouse_encoding(MouseEncoding::Normal),
(1002, true) => self
.terminal
.set_mouse_tracking(MouseTracking::ButtonMotion),
(1002, false) => self.terminal.set_mouse_tracking(MouseTracking::Off),
(1003, true) => self.terminal.set_mouse_tracking(MouseTracking::AnyMotion),
(1003, false) => self.terminal.set_mouse_tracking(MouseTracking::Off),
(1006, true) => self.terminal.set_mouse_sgr(true),
(1006, false) => self.terminal.set_mouse_sgr(false),
(1015, true) => self.terminal.set_mouse_encoding(MouseEncoding::Urxvt),
(1015, false) => self.terminal.set_mouse_encoding(MouseEncoding::Normal),
(1016, true) => self.terminal.set_mouse_encoding(MouseEncoding::SgrPixels),
(1016, false) => self.terminal.set_mouse_encoding(MouseEncoding::Normal),
_ => {}
}
}
fn handle_osc(&mut self, params: &[&[u8]]) {
let Some(selector) = params.first().and_then(|p| std::str::from_utf8(p).ok()) else {
return;
};
match selector {
"0" | "2" => {
if let Some(title) = params.get(1).and_then(|p| std::str::from_utf8(p).ok()) {
self.terminal.set_title(title.to_string());
}
}
"52" => {
let payload = params.get(2).and_then(|p| std::str::from_utf8(p).ok());
self.terminal
.set_clipboard_text(payload.filter(|p| *p != "?").map(ToString::to_string));
}
"8" => {
let uri = params
.get(2)
.and_then(|p| std::str::from_utf8(p).ok())
.filter(|uri| !uri.is_empty())
.map(ToString::to_string);
self.terminal.set_active_hyperlink(uri);
}
_ => {}
}
}
fn flush_ascii(&mut self) {
if self.ascii_buf.is_empty() {
return;
}
self.terminal
.put_ascii_bytes(&self.ascii_buf, self.fg, self.bg, self.attrs);
self.ascii_buf.clear();
}
fn blank(&self) -> Cell {
Cell::blank_with_attrs(self.fg, self.bg, self.attrs)
}
}
impl<T: TerminalOps> vte::Perform for GridWriter<'_, T> {
fn print(&mut self, c: char) {
self.cell_writes += 1;
if c.is_ascii() && !c.is_ascii_control() {
self.ascii_buf.push(c as u8);
if self.ascii_buf.len() >= 4096 {
self.flush_ascii();
}
} else {
self.flush_ascii();
self.terminal
.put_cell(Cell::with_attrs(c, self.fg, self.bg, self.attrs));
}
}
fn execute(&mut self, byte: u8) {
self.flush_ascii();
match byte {
b'\n' => {
self.line_advances += 1;
self.terminal.newline(self.blank());
}
b'\r' => self.terminal.carriage_return(),
0x08 => self.terminal.backspace(),
b'\t' => self.terminal.tab(),
0x07 => {}
0x0C => self.terminal.clear_all(self.blank()),
_ => {}
}
}
fn csi_dispatch(
&mut self,
params: &vte::Params,
intermediates: &[u8],
_ignore: bool,
action: char,
) {
self.flush_ascii();
let raw = |i: usize| -> usize {
params
.iter()
.nth(i)
.and_then(|s| s.first())
.copied()
.map(|v| v as usize)
.unwrap_or(0)
};
let cnt = |i: usize| -> usize {
let v = raw(i);
if v == 0 { 1 } else { v }
};
let has_question = intermediates.contains(&b'?');
match action {
'A' => self.terminal.cursor_up(cnt(0)),
'B' => self.terminal.cursor_down(cnt(0)),
'C' => self.terminal.cursor_forward(cnt(0)),
'D' => self.terminal.cursor_backward(cnt(0)),
'E' => self.terminal.cursor_next_line(cnt(0)),
'F' => self.terminal.cursor_prev_line(cnt(0)),
'e' => self.terminal.cursor_down(cnt(0)),
'G' => self
.terminal
.cursor_horizontal_absolute(cnt(0).saturating_sub(1)),
'd' => self
.terminal
.cursor_vertical_absolute(cnt(0).saturating_sub(1)),
'a' => self.terminal.cursor_forward(cnt(0)),
'H' | 'f' => self
.terminal
.cursor_position(cnt(0).saturating_sub(1), cnt(1).saturating_sub(1)),
'J' => match raw(0) {
0 => self.terminal.clear_display_forward(self.blank()),
1 => self.terminal.clear_display_backward(self.blank()),
_ => self.terminal.clear_all(self.blank()),
},
'K' => match raw(0) {
0 => self.terminal.clear_line_forward(self.blank()),
1 => self.terminal.clear_line_from_start(self.blank()),
_ => self.terminal.clear_line(self.blank()),
},
'@' => self.terminal.insert_chars(cnt(0), self.blank()),
'P' => self.terminal.delete_chars(cnt(0), self.blank()),
'X' => self.terminal.erase_chars(cnt(0), self.blank()),
'S' => self.terminal.scroll_up(cnt(0), self.blank()),
'T' => self.terminal.scroll_down(cnt(0), self.blank()),
'b' => self.terminal.repeat_preceding_cell(cnt(0)),
'm' => {
self.sgr_buf.clear();
self.sgr_buf
.extend(params.iter().flat_map(|s| s.iter().copied()));
let collected = std::mem::take(&mut self.sgr_buf);
self.apply_sgr(&collected);
self.sgr_buf = collected;
}
'r' => self.terminal.set_scroll_region(raw(0), raw(1)),
'q' if intermediates == b" " => {
self.terminal
.set_cursor_style(CursorStyle::from_dec_style(raw(0)));
}
'L' => self.terminal.insert_lines(cnt(0), self.blank()),
'M' => self.terminal.delete_lines(cnt(0), self.blank()),
'h' if has_question => {
for mode in params.iter().flat_map(|s| s.iter().copied()) {
self.set_private_mode(mode as usize, true);
}
}
'l' if has_question => {
for mode in params.iter().flat_map(|s| s.iter().copied()) {
self.set_private_mode(mode as usize, false);
}
}
_ => {}
}
}
fn esc_dispatch(&mut self, _intermediates: &[u8], _ignore: bool, byte: u8) {
self.flush_ascii();
match byte {
b'7' => self.terminal.save_cursor(),
b'8' => self.terminal.restore_cursor(),
_ => {}
}
}
fn osc_dispatch(&mut self, params: &[&[u8]], _bell_terminated: bool) {
self.flush_ascii();
self.handle_osc(params);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::terminal::TerminalState;
#[test]
fn test_basic_text() {
let mut p = Parser::new();
let mut terminal = TerminalState::new(10, 3, 10_000);
p.advance(b"Hello", &mut terminal);
assert_eq!(terminal.grid.cell(0, 0).unwrap().c, 'H');
}
#[test]
fn test_plain_ascii_fast_path_handles_controls_and_profile() {
let mut p = Parser::new();
let mut terminal = TerminalState::new(10, 3, 10_000);
p.advance(b"Hello\nWorld", &mut terminal);
assert_eq!(terminal.grid.row_text(0), "Hello ");
assert_eq!(terminal.grid.row_text(1), "World ");
assert_eq!(p.last_profile.cell_writes, 10);
assert_eq!(p.last_profile.line_advances, 1);
assert_eq!(p.last_profile.bytes_received, 11);
}
#[test]
fn test_split_escape_disables_plain_ascii_fast_path() {
let mut p = Parser::new();
let mut terminal = TerminalState::new(10, 3, 10_000);
p.advance(b"\x1b[31", &mut terminal);
assert!(!p.ground_fast_path);
p.advance(b"mR", &mut terminal);
assert!(p.ground_fast_path);
assert_eq!(cell::color_type(terminal.grid.cell(0, 0).unwrap().fg), 1);
assert_eq!(cell::color_value(terminal.grid.cell(0, 0).unwrap().fg), 1);
}
#[test]
fn test_fast_path_recovers_after_complete_sgr() {
let mut p = Parser::new();
let mut terminal = TerminalState::new(20, 3, 10_000);
p.advance(b"\x1b[32mgreen\x1b[0m", &mut terminal);
assert!(p.ground_fast_path);
p.advance(b" plain", &mut terminal);
assert_eq!(terminal.grid.row_text(0), "green plain ");
assert_eq!(p.last_profile.cell_writes, 6);
}
#[test]
fn test_fast_path_prefix_before_sgr_suffix_keeps_style() {
let mut p = Parser::new();
let mut terminal = TerminalState::new(20, 3, 10_000);
p.advance(b"abc\x1b[31mR", &mut terminal);
assert!(p.ground_fast_path);
assert_eq!(terminal.grid.row_text(0), "abcR ");
assert_eq!(p.last_profile.cell_writes, 4);
assert_eq!(p.last_profile.sgr_updates, 1);
assert_eq!(p.last_profile.bytes_received, 9);
assert_eq!(cell::color_type(terminal.grid.cell(0, 3).unwrap().fg), 1);
assert_eq!(cell::color_value(terminal.grid.cell(0, 3).unwrap().fg), 1);
}
#[test]
fn test_fast_path_prefix_before_unicode_suffix_keeps_graphemes() {
let mut p = Parser::new();
let mut terminal = TerminalState::new(30, 3, 10_000);
let text = "ascii café 👍🏽 你好";
p.advance(text.as_bytes(), &mut terminal);
assert!(p.ground_fast_path);
assert_eq!(terminal.grid.row_text(0), format!("{text} "));
assert_eq!(terminal.grid.cursor_col(), 20);
assert_eq!(p.last_profile.bytes_received, text.len() as u64);
}
#[test]
fn test_fast_path_stays_disabled_inside_partial_osc() {
let mut p = Parser::new();
let mut terminal = TerminalState::new(20, 3, 10_000);
p.advance(b"\x1b]2;partial title", &mut terminal);
assert!(!p.ground_fast_path);
p.advance(b"\x07text", &mut terminal);
assert_eq!(terminal.title(), "partial title");
assert!(p.ground_fast_path);
assert_eq!(terminal.grid.row_text(0), "text ");
}
#[test]
fn test_sgr_bold() {
let mut p = Parser::new();
let mut terminal = TerminalState::new(10, 3, 10_000);
p.advance(b"\x1b[1mX", &mut terminal);
assert!(terminal.grid.cell(0, 0).unwrap().attrs & ATTR_BOLD != 0);
}
#[test]
fn test_text_applies_before_cursor_motion() {
let mut p = Parser::new();
let mut terminal = TerminalState::new(10, 3, 10_000);
p.advance(b"abc\x1b[1GZ", &mut terminal);
assert_eq!(terminal.grid.row_text(0), "Zbc ");
}
#[test]
fn test_sgr_fg_red() {
let mut p = Parser::new();
let mut terminal = TerminalState::new(10, 3, 10_000);
p.advance(b"\x1b[31mR\x1b[0mN", &mut terminal);
assert_eq!(cell::color_type(terminal.grid.cell(0, 0).unwrap().fg), 1);
assert_eq!(cell::color_value(terminal.grid.cell(0, 0).unwrap().fg), 1);
assert_eq!(terminal.grid.cell(0, 1).unwrap().fg, cell::DEFAULT_FG);
}
#[test]
fn test_sgr_truecolor() {
let mut p = Parser::new();
let mut terminal = TerminalState::new(20, 3, 10_000);
p.advance(b"\x1b[38;2;255;128;64mC", &mut terminal);
assert_eq!(cell::color_type(terminal.grid.cell(0, 0).unwrap().fg), 3);
assert_eq!(
cell::color_value(terminal.grid.cell(0, 0).unwrap().fg),
0xFF8040
);
}
#[test]
fn test_alt_screen_detect() {
let mut p = Parser::new();
let mut terminal = TerminalState::new(10, 3, 10_000);
p.advance(b"\x1b[?1049h", &mut terminal);
assert!(p.alt_screen_requested);
assert!(terminal.in_alt_screen());
}
#[test]
fn test_app_cursor_detect() {
let mut p = Parser::new();
let mut terminal = TerminalState::new(10, 3, 10_000);
p.advance(b"\x1b[?1h", &mut terminal);
assert!(p.app_cursor_requested);
assert!(terminal.app_cursor());
}
#[test]
fn test_cursor_style_and_visibility_modes() {
let mut p = Parser::new();
let mut terminal = TerminalState::new(10, 3, 10_000);
p.advance(b"\x1b[6 q\x1b[?25l", &mut terminal);
assert_eq!(terminal.cursor_style(), CursorStyle::Bar);
assert!(!terminal.cursor_visible());
p.advance(b"\x1b[4 q\x1b[?25h", &mut terminal);
assert_eq!(terminal.cursor_style(), CursorStyle::Underline);
assert!(terminal.cursor_visible());
}
#[test]
fn test_mouse_and_osc_modes() {
let mut p = Parser::new();
let mut terminal = TerminalState::new(10, 3, 10_000);
p.advance(b"\x1b[?1000;1006h\x1b]2;demo title\x07", &mut terminal);
assert_eq!(terminal.mouse_tracking(), MouseTracking::Normal);
assert!(terminal.mouse_sgr());
assert_eq!(terminal.title(), "demo title");
p.advance(b"\x1b[?1006;1000l", &mut terminal);
assert_eq!(terminal.mouse_tracking(), MouseTracking::Off);
assert!(!terminal.mouse_sgr());
}
#[test]
fn test_extended_mouse_and_focus_modes() {
let mut p = Parser::new();
let mut terminal = TerminalState::new(10, 3, 10_000);
p.advance(b"\x1b[?1004;1005h", &mut terminal);
assert!(terminal.focus_reporting());
assert_eq!(terminal.mouse_encoding(), MouseEncoding::Utf8);
p.advance(b"\x1b[?1015h", &mut terminal);
assert_eq!(terminal.mouse_encoding(), MouseEncoding::Urxvt);
p.advance(b"\x1b[?1016h", &mut terminal);
assert_eq!(terminal.mouse_encoding(), MouseEncoding::SgrPixels);
assert!(terminal.mouse_sgr());
p.advance(b"\x1b[?1004;1016l", &mut terminal);
assert!(!terminal.focus_reporting());
assert_eq!(terminal.mouse_encoding(), MouseEncoding::Normal);
}
#[test]
fn test_ncurses_character_editing_and_bce_erases() {
let mut p = Parser::new();
let mut terminal = TerminalState::new(8, 3, 10_000);
p.advance(b"abcdef", &mut terminal);
p.advance(b"\x1b[1;3H\x1b[42m\x1b[2X", &mut terminal);
assert_eq!(terminal.grid.row_text(0), "ab ef ");
assert_eq!(terminal.grid.cell(0, 2).unwrap().bg, cell::color_ansi(2));
assert_eq!(terminal.grid.cell(0, 3).unwrap().bg, cell::color_ansi(2));
p.advance(b"\x1b[1;3H\x1b[1@", &mut terminal);
assert_eq!(terminal.grid.row_text(0), "ab ef ");
assert_eq!(terminal.grid.cell(0, 2).unwrap().bg, cell::color_ansi(2));
p.advance(b"\x1b[1;3H\x1b[2P", &mut terminal);
assert_eq!(terminal.grid.row_text(0), "ab ef ");
assert_eq!(terminal.grid.cell(0, 6).unwrap().bg, cell::color_ansi(2));
}
#[test]
fn test_ncurses_cursor_scroll_and_repeat_sequences() {
let mut p = Parser::new();
let mut terminal = TerminalState::new(6, 4, 10_000);
p.advance(b"\x1b[2dA\x1b[2EBC\x1b[2b", &mut terminal);
assert_eq!(terminal.grid.row_text(1), "A ");
assert_eq!(terminal.grid.row_text(3), "BCCC ");
p.advance(b"\x1b[2FZ", &mut terminal);
assert_eq!(terminal.grid.row_text(1), "Z ");
p.advance(
b"\x1b[1;1H\x1b[44m111111\x1b[2;1H222222\x1b[3;1H333333\x1b[1;4r\x1b[2S",
&mut terminal,
);
assert_eq!(terminal.grid.row_text(0), "333333");
assert_eq!(terminal.grid.row_text(2), " ");
assert_eq!(terminal.grid.cell(2, 0).unwrap().bg, cell::color_ansi(4));
p.advance(b"\x1b[1T", &mut terminal);
assert_eq!(terminal.grid.row_text(0), " ");
assert_eq!(terminal.grid.row_text(1), "333333");
assert_eq!(terminal.grid.cell(0, 0).unwrap().bg, cell::color_ansi(4));
}
#[test]
fn test_htop_style_semantic_redraw_fixture() {
let mut p = Parser::new();
let mut terminal = TerminalState::new(24, 6, 10_000);
let fixture = concat!(
"\x1b[?1049h\x1b[H\x1b[2J",
"\x1b[42;30mPID USER CPU Command\x1b[K",
"\x1b[2;1H\x1b[46;30m123 jeremy 9.9 htop\x1b[K",
"\x1b[3;1H\x1b[46;30mstale selected row\x1b[K",
"\x1b[3;1H\x1b[0m\x1b[K",
"\x1b[6;1H\x1b[46;30mF1Help\x1b[0m F10Quit\x1b[K"
);
p.advance(fixture.as_bytes(), &mut terminal);
assert!(terminal.in_alt_screen());
assert!(
terminal
.grid
.row_text(0)
.starts_with("PID USER CPU Command")
);
assert!(terminal.grid.row_text(1).starts_with("123 jeremy 9.9 htop"));
assert!(terminal.grid.row_text(5).contains("F1Help"));
assert!(terminal.grid.row_text(5).contains("F10Quit"));
assert!(terminal.grid.row_text(2).trim().is_empty());
for col in 0..terminal.grid.cols() {
assert_eq!(terminal.grid.cell(0, col).unwrap().bg, cell::color_ansi(2));
assert_eq!(terminal.grid.cell(1, col).unwrap().bg, cell::color_ansi(6));
assert_eq!(terminal.grid.cell(2, col).unwrap().bg, cell::DEFAULT_BG);
}
}
#[test]
fn test_hyperlink_osc_tracks_active_uri() {
let mut p = Parser::new();
let mut terminal = TerminalState::new(10, 3, 10_000);
p.advance(
b"\x1b]8;;https://example.com\x1b\\x\x1b]8;;\x1b\\y",
&mut terminal,
);
assert_eq!(terminal.grid.row_text(0), "xy ");
let linked = terminal.grid.cell(0, 0).unwrap();
assert_ne!(linked.hyperlink_id, 0);
assert_eq!(
terminal.hyperlink_uri(linked.hyperlink_id),
Some("https://example.com")
);
assert_eq!(terminal.grid.cell(0, 1).unwrap().hyperlink_id, 0);
assert_eq!(terminal.active_hyperlink(), None);
}
#[test]
fn test_readline_redraw_no_garbage() {
let mut p = Parser::new();
let mut terminal = TerminalState::new(20, 3, 10_000);
p.advance(b"$ echo first", &mut terminal);
p.advance(b"\r\x1b[2K$ echo second", &mut terminal);
let line = terminal.grid.row_text(0);
assert!(line.starts_with("$ echo second"), "got: {:?}", line);
}
#[test]
fn test_zsh_transient_prompt_clear_no_inverse_garbage() {
let mut p = Parser::new();
let mut terminal = TerminalState::new(5, 3, 10_000);
p.advance(
b"\x1b[1m\x1b[7m%\x1b[27m\x1b[1m\x1b[0m \r \r\r\x1b[0m\x1b[27m\x1b[24m\x1b[Jsh% \x1b[K",
&mut terminal,
);
assert_eq!(terminal.grid.row_text(0), "sh% ");
assert_eq!(terminal.grid.row_text(1), " ");
assert_eq!(terminal.grid.cell(0, 0).unwrap().attrs, 0);
}
#[test]
fn test_zsh_unicode_paste_backspace_keeps_prompt_intact() {
let mut p = Parser::new();
let mut terminal = TerminalState::new(100, 3, 10_000);
let prompt = "jeremy@Jeremys-MacBook-Air-2 panasyn % ";
let pasted = "unicode: café 👍🏽 你好";
p.advance(prompt.as_bytes(), &mut terminal);
p.advance(b"\x1b[7m", &mut terminal);
p.advance(pasted.as_bytes(), &mut terminal);
p.advance(b"\x1b[27m\x1b[23D\x1b[K", &mut terminal);
let row = terminal.grid.row_text(0);
assert!(row.starts_with(prompt), "row: {row:?}");
assert!(row[prompt.len()..].trim().is_empty(), "row: {row:?}");
p.advance(pasted.as_bytes(), &mut terminal);
p.advance(b"\x08\x08\x08\x08 \x08\x08\x08\x08", &mut terminal);
let row = terminal.grid.row_text(0);
let expected = format!("{prompt}unicode: café 👍🏽 ");
assert!(row.starts_with(&expected), "row: {row:?}");
assert!(row[expected.len()..].trim().is_empty(), "row: {row:?}");
}
#[test]
fn test_zsh_stepwise_unicode_backspace_keeps_prompt_intact() {
let mut p = Parser::new();
let mut terminal = TerminalState::new(100, 3, 10_000);
let prompt = "jeremy@Jeremys-MacBook-Air-2 panasyn % ";
p.advance(prompt.as_bytes(), &mut terminal);
p.advance("u\x08unicode: café 👍🏽 你好".as_bytes(), &mut terminal);
let delete_steps: &[&[u8]] = &[
b"\x08\x08 \x08\x08", b"\x08\x08 \x08\x08", b"\x08", b"\x08\x08 \x08\x08", b"\x08\x08 \x08\x08", b"\x08", b"\x08 \x08",
b"\x08 \x08",
b"\x08 \x08",
b"\x08 \x08",
b"\x08",
b"\x08 \x08",
b"\x08 \x08",
b"\x08 \x08",
b"\x08 \x08",
b"\x08 \x08",
b"\x08 \x08",
b"\x08\x08u \x08",
b"\x08 \x08",
];
for (idx, bytes) in delete_steps.iter().enumerate() {
p.advance(bytes, &mut terminal);
if idx == 3 {
let row = terminal.grid.row_text(0);
let expected = format!("{prompt}unicode: café 👍 ");
assert!(row.starts_with(&expected), "row: {row:?}");
}
}
let row = terminal.grid.row_text(0);
assert!(row.starts_with(prompt), "row: {row:?}");
assert!(row[prompt.len()..].trim().is_empty(), "row: {row:?}");
assert_eq!(terminal.grid.cursor_col(), prompt.len());
}
#[test]
fn test_sgr_persists_across_chunks() {
let mut p = Parser::new();
let mut terminal = TerminalState::new(10, 3, 10_000);
p.advance(b"\x1b[31m", &mut terminal);
p.advance(b"R", &mut terminal);
assert_eq!(cell::color_type(terminal.grid.cell(0, 0).unwrap().fg), 1);
assert_eq!(cell::color_value(terminal.grid.cell(0, 0).unwrap().fg), 1);
}
#[test]
fn test_alt_screen_switches_before_following_text() {
let mut p = Parser::new();
let mut terminal = TerminalState::new(10, 3, 10_000);
p.advance(b"main\x1b[?1049halt", &mut terminal);
assert!(terminal.in_alt_screen());
assert!(terminal.grid.row_text(0).starts_with("alt"));
p.advance(b"\x1b[?1049l", &mut terminal);
assert!(terminal.grid.row_text(0).starts_with("main"));
}
#[test]
fn test_combined_private_modes_apply_all_params() {
let mut p = Parser::new();
let mut terminal = TerminalState::new(10, 3, 10_000);
p.advance(b"\x1b[?1;1049;2004h", &mut terminal);
assert!(terminal.app_cursor());
assert!(terminal.in_alt_screen());
assert!(terminal.bracketed_paste());
p.advance(b"\x1b[?1;1049;2004l", &mut terminal);
assert!(!terminal.app_cursor());
assert!(!terminal.in_alt_screen());
assert!(!terminal.bracketed_paste());
}
}