use std::{
sync::{Arc, Mutex},
time::Instant,
};
use alacritty_terminal::{
Term,
event::{Event, EventListener},
grid::{Dimensions, Row, Scroll},
index::{Column, Line},
term::{
Config, TermMode,
cell::{Cell, Flags},
},
vte::ansi::{self as vt, Handler, Processor},
};
use base64::{Engine as _, engine::general_purpose::STANDARD as B64};
use crate::{format::prefix_bytes, protocol::ClipboardKind};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum MouseProtocolMode {
None,
PressRelease,
ButtonMotion,
AnyMotion,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum MouseProtocolEncoding {
Default,
Utf8,
Sgr,
}
pub(crate) const CLIPBOARD_STORE_MAX_BYTES: usize = 1024 * 1024;
const _: () = assert!(
CLIPBOARD_STORE_MAX_BYTES.div_ceil(3) * 4 + 64 * 1024 <= crate::frame::MAX_FRAME as usize,
"CLIPBOARD_STORE_MAX_BYTES must base64-encode to under frame::MAX_FRAME"
);
#[derive(Debug, Default)]
pub struct ClipboardStores {
pub stores: Vec<(ClipboardKind, String)>,
pub oversized_len: Option<usize>,
}
pub struct ProbeSink {
responses: Arc<Mutex<Vec<String>>>,
}
impl EventListener for ProbeSink {
fn send_event(&self, event: Event) {
if let Event::PtyWrite(text) = event {
let mut buf = self
.responses
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
buf.push(text);
}
}
}
struct GridSize {
lines: usize,
columns: usize,
}
impl Dimensions for GridSize {
fn total_lines(&self) -> usize {
self.lines
}
fn screen_lines(&self) -> usize {
self.lines
}
fn columns(&self) -> usize {
self.columns
}
}
fn allowed_probe_response(resp: &str) -> bool {
let Some(body) = resp.strip_prefix("\x1b[") else {
return false;
};
if body == "0n" {
return true;
}
if let Some(params) = body.strip_suffix('R') {
let mut fields = params.split(';');
return matches!(
(fields.next(), fields.next(), fields.next()),
(Some(row), Some(col), None) if is_digits(row) && is_digits(col)
);
}
if let Some(params) = body.strip_prefix('?').and_then(|b| b.strip_suffix('c')) {
return !params.is_empty() && params.bytes().all(|b| b.is_ascii_digit() || b == b';');
}
false
}
fn is_digits(s: &str) -> bool {
!s.is_empty() && s.bytes().all(|b| b.is_ascii_digit())
}
const TITLE_MAX_BYTES: usize = 512;
fn is_bidi_control(c: char) -> bool {
matches!(
c,
'\u{061C}' | '\u{200E}' | '\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}'
)
}
fn sanitize_title(raw: &str) -> String {
let mut out = String::new();
for c in raw.chars() {
if c.is_control() || is_bidi_control(c) {
continue;
}
if c.is_whitespace() {
if !out.is_empty() && !out.ends_with(' ') {
out.push(' ');
}
continue;
}
out.push(c);
}
let cut = prefix_bytes(&out, TITLE_MAX_BYTES).len();
out.truncate(cut);
if out.ends_with(' ') {
out.pop();
}
out
}
struct CapturedTitle {
text: String,
alt_epoch: u64,
}
const MAX_ZEROWIDTH: usize = 16;
const SWEEP_INTERVAL_BYTES: usize = 256 * 1024;
pub struct Emulator {
term: Term<ProbeSink>,
parser: Processor,
responses: Arc<Mutex<Vec<String>>>,
clipboard: ClipboardStores,
alt: AltScreen,
revision: u64,
bytes_since_sweep: usize,
}
impl Emulator {
fn drain_allowed(&mut self) -> Vec<String> {
let mut buf = self
.responses
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
buf.drain(..)
.filter(|r| allowed_probe_response(r))
.collect()
}
pub fn drain_clipboard(&mut self) -> ClipboardStores {
std::mem::take(&mut self.clipboard)
}
fn sweep_zerowidth(&mut self) {
let grid = self.term.grid_mut();
let top = -(grid.history_size() as i32);
let bottom = grid.screen_lines() as i32 - 1;
for line in top..=bottom {
for cell in &mut grid[Line(line)][..] {
if !cell.zerowidth().is_some_and(|z| z.len() > MAX_ZEROWIDTH) {
continue;
}
let mut rebuilt = Cell {
c: cell.c,
fg: cell.fg,
bg: cell.bg,
flags: cell.flags,
extra: None,
};
if let Some(z) = cell.zerowidth() {
for &mark in &z[..MAX_ZEROWIDTH] {
rebuilt.push_zerowidth(mark);
}
}
rebuilt.set_underline_color(cell.underline_color());
rebuilt.set_hyperlink(cell.hyperlink());
*cell = rebuilt;
}
}
}
pub fn new(rows: u16, cols: u16, scrollback: usize) -> Self {
let responses = Arc::new(Mutex::new(Vec::new()));
let config = Config {
scrolling_history: scrollback,
..Config::default()
};
let term = Term::new(
config,
&GridSize {
lines: rows as usize,
columns: cols as usize,
},
ProbeSink {
responses: Arc::clone(&responses),
},
);
Self {
term,
parser: Processor::new(),
responses,
clipboard: ClipboardStores::default(),
alt: AltScreen::default(),
revision: 0,
bytes_since_sweep: 0,
}
}
fn observe_advance(&mut self) {
self.revision += 1;
}
pub fn process(&mut self, bytes: &[u8]) -> Vec<String> {
let mut observed = ObservedTerm {
term: &mut self.term,
alt: &mut self.alt,
clipboard: &mut self.clipboard,
};
self.parser.advance(&mut observed, bytes);
self.observe_advance();
self.bytes_since_sweep = self.bytes_since_sweep.saturating_add(bytes.len());
if self.bytes_since_sweep >= SWEEP_INTERVAL_BYTES {
self.bytes_since_sweep = 0;
self.sweep_zerowidth();
}
self.drain_allowed()
}
fn land_sync_frame(&mut self) -> Vec<String> {
let mut observed = ObservedTerm {
term: &mut self.term,
alt: &mut self.alt,
clipboard: &mut self.clipboard,
};
self.parser.stop_sync(&mut observed);
self.observe_advance();
self.drain_allowed()
}
pub fn flush_expired_sync(&mut self) -> Vec<String> {
let expired = self
.parser
.sync_timeout()
.sync_timeout()
.is_some_and(|deadline| deadline <= Instant::now());
if !expired {
return Vec::new();
}
self.land_sync_frame()
}
pub fn finish_output(&mut self) -> Vec<String> {
if self.parser.sync_timeout().sync_timeout().is_none() {
return Vec::new();
}
self.land_sync_frame()
}
pub fn formatted(&self) -> (Vec<u8>, (u16, u16), bool) {
crate::ansi::formatted(&self.term)
}
pub fn contents(&self) -> String {
crate::ansi::contents(&self.term)
}
pub fn text_with_history(&self) -> String {
let grid = self.term.grid();
let top = -(grid.history_size() as i32);
let bottom = grid.screen_lines() as i32 - 1;
let last_col = grid.columns() - 1;
let mut out = String::new();
for row in top..=bottom {
let row_start = out.len();
let line = &grid[Line(row)];
push_row_glyphs(&mut out, line);
if line[Column(last_col)].flags.contains(Flags::WRAPLINE) {
continue;
}
while out.len() > row_start && out.ends_with(' ') {
out.pop();
}
if row < bottom {
out.push('\n');
}
}
out
}
pub fn mouse_protocol_mode(&self) -> MouseProtocolMode {
let mode = self.term.mode();
if mode.contains(TermMode::MOUSE_MOTION) {
MouseProtocolMode::AnyMotion
} else if mode.contains(TermMode::MOUSE_DRAG) {
MouseProtocolMode::ButtonMotion
} else if mode.contains(TermMode::MOUSE_REPORT_CLICK) {
MouseProtocolMode::PressRelease
} else {
MouseProtocolMode::None
}
}
pub fn mouse_protocol_encoding(&self) -> MouseProtocolEncoding {
let mode = self.term.mode();
if mode.contains(TermMode::SGR_MOUSE) {
MouseProtocolEncoding::Sgr
} else if mode.contains(TermMode::UTF8_MOUSE) {
MouseProtocolEncoding::Utf8
} else {
MouseProtocolEncoding::Default
}
}
pub fn alternate_screen(&self) -> bool {
self.term.mode().contains(TermMode::ALT_SCREEN)
}
pub fn alternate_scroll(&self) -> bool {
self.term
.mode()
.contains(TermMode::ALT_SCREEN | TermMode::ALTERNATE_SCROLL)
}
pub fn application_cursor(&self) -> bool {
self.term.mode().contains(TermMode::APP_CURSOR)
}
pub fn bracketed_paste(&self) -> bool {
self.term.mode().contains(TermMode::BRACKETED_PASTE)
}
pub fn scrollback(&self) -> usize {
self.term.grid().display_offset()
}
pub fn set_scrollback(&mut self, rows: usize) {
let grid = self.term.grid();
let target = rows.min(grid.history_size());
let delta = target as i32 - grid.display_offset() as i32;
self.term.scroll_display(Scroll::Delta(delta));
}
pub fn resize(&mut self, rows: u16, cols: u16) {
let untouched = self
.alt
.leave_floor
.as_deref()
.is_some_and(|snapshot| live_floor_of(&self.term) == snapshot);
self.term.resize(GridSize {
lines: rows as usize,
columns: cols as usize,
});
if untouched {
self.alt.leave_floor = Some(live_floor_of(&self.term));
}
self.revision += 1;
}
#[cfg(test)]
pub fn size(&self) -> (u16, u16) {
(
self.term.grid().screen_lines() as u16,
self.term.grid().columns() as u16,
)
}
}
impl Emulator {
pub fn revision(&self) -> u64 {
self.revision
}
pub fn alt_epoch(&self) -> u64 {
self.alt.epoch
}
pub fn alt_leave_floor(&self) -> Option<&str> {
self.alt.leave_floor.as_deref()
}
pub fn title(&self) -> Option<&str> {
if !self.alternate_screen()
&& let Some(staged) = self.alt.staged_title.as_deref()
{
return Some(staged);
}
self.alt
.title
.as_ref()
.filter(|t| t.alt_epoch == self.alt.epoch)
.map(|t| t.text.as_str())
}
pub fn primary_title(&self) -> Option<&str> {
self.alt.primary_title.as_deref()
}
pub fn live_floor(&self) -> String {
live_floor_of(&self.term)
}
pub fn live_rows(&self) -> Vec<String> {
(0..self.term.grid().screen_lines() as i32)
.map(|row| live_row_text_of(&self.term, row))
.collect()
}
}
fn live_floor_of(term: &Term<ProbeSink>) -> String {
for row in (0..term.grid().screen_lines() as i32).rev() {
let text = live_row_text_of(term, row);
if !text.is_empty() {
return text;
}
}
String::new()
}
fn push_row_glyphs(out: &mut String, row: &Row<Cell>) {
for cell in row {
if cell
.flags
.intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER)
{
continue;
}
out.push(if cell.c == '\t' { ' ' } else { cell.c });
if let Some(zerowidth) = cell.zerowidth() {
out.extend(zerowidth.iter());
}
}
}
fn live_row_text_of(term: &Term<ProbeSink>, row: i32) -> String {
let grid = term.grid();
let line = &grid[Line(row)];
let mut text = String::new();
push_row_glyphs(&mut text, line);
while text.ends_with(' ') {
text.pop();
}
text
}
#[derive(Default)]
struct AltScreen {
epoch: u64,
last_alt: bool,
leave_floor: Option<String>,
title: Option<CapturedTitle>,
staged_title: Option<String>,
primary_title: Option<String>,
raw_title: Option<String>,
title_stack: Vec<Option<String>>,
}
const TITLE_STACK_SHADOW_MAX: usize = 4096;
struct ObservedTerm<'a> {
term: &'a mut Term<ProbeSink>,
alt: &'a mut AltScreen,
clipboard: &'a mut ClipboardStores,
}
impl ObservedTerm<'_> {
fn observe_alt(&mut self) {
let alt = self.term.mode().contains(TermMode::ALT_SCREEN);
if alt && !self.alt.last_alt {
self.alt.epoch += 1;
if let Some(text) = self.alt.staged_title.take() {
self.alt.title = Some(CapturedTitle {
text,
alt_epoch: self.alt.epoch,
});
}
}
if !alt && self.alt.last_alt {
self.alt.leave_floor = Some(live_floor_of(self.term));
}
self.alt.last_alt = alt;
}
fn observe_title(&mut self, title: Option<String>) {
self.alt.raw_title.clone_from(&title);
let text = title
.map(|raw| sanitize_title(&raw))
.filter(|text| !text.is_empty());
let Some(text) = text else {
self.alt.title = None;
self.alt.staged_title = None;
self.alt.primary_title = None;
return;
};
if self.term.mode().contains(TermMode::ALT_SCREEN) {
self.alt.title = Some(CapturedTitle {
text,
alt_epoch: self.alt.epoch,
});
} else {
self.alt.staged_title = Some(text.clone());
self.alt.primary_title = Some(text);
}
}
}
macro_rules! delegate {
($($name:ident($($arg:ident: $ty:ty),*);)+) => {
$(
fn $name(&mut self, $($arg: $ty),*) {
self.term.$name($($arg),*);
}
)+
};
}
impl Handler for ObservedTerm<'_> {
fn set_title(&mut self, a0: Option<String>) {
self.term.set_title(a0.clone());
self.observe_title(a0);
}
delegate! {
set_cursor_style(a0: Option<vt::CursorStyle>);
set_cursor_shape(a0: vt::CursorShape);
}
fn input(&mut self, a0: char) {
self.term.input(a0);
if self.alt.staged_title.is_some() {
self.alt.staged_title = None;
}
}
delegate! {
goto(a0: i32, a1: usize);
goto_line(a0: i32);
goto_col(a0: usize);
insert_blank(a0: usize);
move_up(a0: usize);
move_down(a0: usize);
identify_terminal(a0: Option<char>);
device_status(a0: usize);
move_forward(a0: usize);
move_backward(a0: usize);
move_down_and_cr(a0: usize);
move_up_and_cr(a0: usize);
put_tab(a0: u16);
backspace();
carriage_return();
linefeed();
bell();
substitute();
newline();
set_horizontal_tabstop();
scroll_up(a0: usize);
scroll_down(a0: usize);
insert_blank_lines(a0: usize);
delete_lines(a0: usize);
erase_chars(a0: usize);
delete_chars(a0: usize);
move_backward_tabs(a0: u16);
move_forward_tabs(a0: u16);
save_cursor_position();
restore_cursor_position();
clear_line(a0: vt::LineClearMode);
clear_screen(a0: vt::ClearMode);
clear_tabs(a0: vt::TabulationClearMode);
set_tabs(a0: u16);
}
fn reset_state(&mut self) {
self.term.reset_state();
self.observe_alt();
self.alt.raw_title = None;
self.alt.title_stack.clear();
self.alt.staged_title = None;
self.alt.primary_title = None;
}
delegate! {
reverse_index();
terminal_attribute(a0: vt::Attr);
set_mode(a0: vt::Mode);
unset_mode(a0: vt::Mode);
report_mode(a0: vt::Mode);
}
fn set_private_mode(&mut self, a0: vt::PrivateMode) {
self.term.set_private_mode(a0);
self.observe_alt();
}
fn unset_private_mode(&mut self, a0: vt::PrivateMode) {
self.term.unset_private_mode(a0);
self.observe_alt();
}
delegate! {
report_private_mode(a0: vt::PrivateMode);
set_scrolling_region(a0: usize, a1: Option<usize>);
set_keypad_application_mode();
unset_keypad_application_mode();
set_active_charset(a0: vt::CharsetIndex);
configure_charset(a0: vt::CharsetIndex, a1: vt::StandardCharset);
set_color(a0: usize, a1: vt::Rgb);
dynamic_color_sequence(a0: String, a1: usize, a2: &str);
reset_color(a0: usize);
}
fn clipboard_store(&mut self, a0: u8, a1: &[u8]) {
let Some(selector) = ClipboardKind::from_selector(&[a0]) else {
return;
};
let Ok(bytes) = B64.decode(a1) else { return };
let Ok(text) = String::from_utf8(bytes) else {
return;
};
self.clipboard.stores.retain(|(k, _)| *k != selector);
if text.len() > CLIPBOARD_STORE_MAX_BYTES {
self.clipboard.oversized_len = Some(text.len());
return;
}
self.clipboard.stores.push((selector, text));
}
fn clipboard_load(&mut self, a0: u8, a1: &str) {
self.term.clipboard_load(a0, a1);
}
fn decaln(&mut self) {
self.term.decaln();
}
fn push_title(&mut self) {
self.term.push_title();
if self.alt.title_stack.len() >= TITLE_STACK_SHADOW_MAX {
self.alt.title_stack.remove(0);
}
self.alt.title_stack.push(self.alt.raw_title.clone());
}
fn pop_title(&mut self) {
self.term.pop_title();
if let Some(popped) = self.alt.title_stack.pop() {
self.observe_title(popped);
}
}
delegate! {
text_area_size_pixels();
text_area_size_chars();
set_hyperlink(a0: Option<vt::Hyperlink>);
set_mouse_cursor_icon(a0: vt::cursor_icon::CursorIcon);
report_keyboard_mode();
push_keyboard_mode(a0: vt::KeyboardModes);
pop_keyboard_modes(a0: u16);
set_keyboard_mode(a0: vt::KeyboardModes, a1: vt::KeyboardModesApplyBehavior);
set_modify_other_keys(a0: vt::ModifyOtherKeys);
report_modify_other_keys();
set_scp(a0: vt::ScpCharPath, a1: vt::ScpUpdateMode);
}
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use alacritty_terminal::{
index::Column,
term::cell::Flags,
vte::ansi::{Color, NamedColor},
};
use super::*;
#[test]
fn probe_allowlist_forwards_only_the_advertised_shapes() {
assert!(allowed_probe_response("\x1b[1;1R"));
assert!(allowed_probe_response("\x1b[24;80R"));
assert!(allowed_probe_response("\x1b[0n"));
assert!(allowed_probe_response("\x1b[?6c"));
assert!(allowed_probe_response("\x1b[?62;22c"));
assert!(!allowed_probe_response("\x1b[>0;2606;1c")); assert!(!allowed_probe_response("\x1b[?1u")); assert!(!allowed_probe_response("\x1b[?2026;2$y")); assert!(!allowed_probe_response("\x1b[4;2$y")); assert!(!allowed_probe_response("\x1b[8;40;120t"));
assert!(!allowed_probe_response("\x1b]4;1;rgb:aa/bb/cc\x1b\\"));
assert!(!allowed_probe_response("\x1b]52;c;aGk=\x07"));
assert!(!allowed_probe_response("\x1bP>|term 1.0\x1b\\"));
assert!(!allowed_probe_response("\x1b[1R")); assert!(!allowed_probe_response("\x1b[1;2;3R"));
assert!(!allowed_probe_response("\x1b[;1R"));
assert!(!allowed_probe_response("\x1b[?c")); assert!(!allowed_probe_response("\x1b[?6xc"));
assert!(!allowed_probe_response("\x1b[?9999;42z"));
assert!(!allowed_probe_response("unrecognized"));
assert!(!allowed_probe_response(""));
}
#[test]
fn allowed_probe_queries_are_answered() {
let mut emu = Emulator::new(24, 80, 0);
assert!(emu.process(b"ab").is_empty());
assert_eq!(emu.process(b"\x1b[6n"), vec!["\x1b[1;3R".to_string()]);
assert_eq!(emu.process(b"\x1b[5n"), vec!["\x1b[0n".to_string()]);
assert_eq!(emu.process(b"\x1b[c"), vec!["\x1b[?6c".to_string()]);
assert_eq!(emu.process(b"\x1b[0c"), vec!["\x1b[?6c".to_string()]);
}
#[test]
fn denied_probe_queries_are_silenced() {
let mut emu = Emulator::new(24, 80, 0);
assert!(emu.process(b"\x1b[>c").is_empty());
assert!(emu.process(b"\x1b[?2026$p").is_empty());
assert!(emu.process(b"\x1b[4$p").is_empty());
assert!(emu.process(b"\x1b[18t").is_empty());
assert!(emu.process(b"\x1b[?u").is_empty());
}
#[test]
fn osc52_store_is_captured_and_drains_once() {
let mut emu = Emulator::new(4, 20, 0);
assert!(
emu.process(b"\x1b]52;c;aGVsbG8=\x07").is_empty(),
"a store is not a probe reply"
);
let drained = emu.drain_clipboard();
assert_eq!(
drained.stores,
vec![(ClipboardKind::Clipboard, "hello".to_string())]
);
assert_eq!(drained.oversized_len, None);
let again = emu.drain_clipboard();
assert!(again.stores.is_empty(), "a drain empties the buffer");
assert_eq!(again.oversized_len, None);
}
#[test]
fn osc52_p_and_s_selectors_stay_distinct() {
let mut emu = Emulator::new(4, 20, 0);
emu.process(b"\x1b]52;p;YQ==\x07\x1b]52;s;Yg==\x07");
assert_eq!(
emu.drain_clipboard().stores,
vec![
(ClipboardKind::Primary, "a".to_string()),
(ClipboardKind::Selection, "b".to_string()),
]
);
}
#[test]
fn osc52_empty_selector_defaults_to_clipboard() {
let mut emu = Emulator::new(4, 20, 0);
emu.process(b"\x1b]52;;aGk=\x07");
assert_eq!(
emu.drain_clipboard().stores,
vec![(ClipboardKind::Clipboard, "hi".to_string())]
);
}
#[test]
fn osc52_unknown_selectors_drop() {
let mut emu = Emulator::new(4, 20, 0);
emu.process(b"\x1b]52;q;aGk=\x07");
emu.process(b"\x1b]52;0;aGk=\x07");
let drained = emu.drain_clipboard();
assert!(drained.stores.is_empty());
assert_eq!(drained.oversized_len, None);
}
#[test]
fn osc52_last_store_wins_per_kind() {
let mut emu = Emulator::new(4, 20, 0);
emu.process(b"\x1b]52;c;Zmlyc3Q=\x07\x1b]52;c;c2Vjb25k\x07");
assert_eq!(
emu.drain_clipboard().stores,
vec![(ClipboardKind::Clipboard, "second".to_string())]
);
}
#[test]
fn osc52_kinds_buffer_independently() {
let mut emu = Emulator::new(4, 20, 0);
emu.process(b"\x1b]52;s;c2Vs\x07\x1b]52;p;cHJp\x07\x1b]52;c;Y2xpcA==\x07");
assert_eq!(
emu.drain_clipboard().stores,
vec![
(ClipboardKind::Selection, "sel".to_string()),
(ClipboardKind::Primary, "pri".to_string()),
(ClipboardKind::Clipboard, "clip".to_string()),
]
);
}
#[test]
fn osc52_invalid_base64_and_clear_buffer_nothing() {
let mut emu = Emulator::new(4, 20, 0);
emu.process(b"\x1b]52;c;%%%\x07");
emu.process(b"\x1b]52;c;!\x07");
emu.process(b"\x1b]52;c;/w==\x07");
let drained = emu.drain_clipboard();
assert!(drained.stores.is_empty());
assert_eq!(drained.oversized_len, None);
}
#[test]
fn osc52_query_is_denied_without_a_reply() {
let mut emu = Emulator::new(4, 20, 0);
assert!(emu.process(b"\x1b]52;c;?\x07").is_empty());
let drained = emu.drain_clipboard();
assert!(drained.stores.is_empty());
assert_eq!(drained.oversized_len, None);
}
#[test]
fn osc52_st_terminated_store_is_captured() {
let mut emu = Emulator::new(4, 20, 0);
emu.process(b"\x1b]52;c;aGVsbG8=\x1b\\");
assert_eq!(
emu.drain_clipboard().stores,
vec![(ClipboardKind::Clipboard, "hello".to_string())]
);
}
#[test]
fn osc52_oversized_store_supersedes_its_kind_and_records_length() {
let mut emu = Emulator::new(4, 20, 0);
emu.process(b"\x1b]52;c;aGVsbG8=\x07");
emu.process(b"\x1b]52;s;c2Vs\x07");
emu.process(b"\x1b]52;p;cHJp\x07");
let reps = CLIPBOARD_STORE_MAX_BYTES / 3 + 1;
let payload = "YWFh".repeat(reps);
emu.process(format!("\x1b]52;c;{payload}\x07").as_bytes());
let drained = emu.drain_clipboard();
assert_eq!(
drained.stores,
vec![
(ClipboardKind::Selection, "sel".to_string()),
(ClipboardKind::Primary, "pri".to_string()),
],
"the drop clears its own selector's slot and no other"
);
assert_eq!(drained.oversized_len, Some(reps * 3));
}
#[test]
fn stalled_sync_update_flushes_after_timeout() {
let mut emu = Emulator::new(4, 20, 0);
emu.process(b"before\x1b[?2026hafter");
assert!(emu.contents().contains("before"));
assert!(
!emu.contents().contains("after"),
"sync update must buffer the frame"
);
assert!(emu.flush_expired_sync().is_empty());
assert!(!emu.contents().contains("after"));
std::thread::sleep(Duration::from_millis(200));
emu.flush_expired_sync();
assert!(
emu.contents().contains("after"),
"expired sync must flush the buffered frame"
);
emu.process(b" and on");
assert!(emu.contents().contains("and on"));
}
#[test]
fn finish_output_lands_an_open_sync_frame() {
let mut emu = Emulator::new(4, 20, 0);
assert!(
emu.finish_output().is_empty(),
"no open frame: the parser must not be touched"
);
emu.process(b"before\x1b[?2026hafter");
assert!(
!emu.text_with_history().contains("after"),
"premise: the unclosed frame buffers the text"
);
emu.finish_output();
assert!(
emu.text_with_history().contains("after"),
"finish_output must land the frame with the timeout still pending"
);
emu.process(b" and on");
assert!(emu.contents().contains("and on"));
}
#[test]
fn mouse_modes_map_to_termmode_bits() {
let mut emu = Emulator::new(24, 80, 0);
assert_eq!(emu.mouse_protocol_mode(), MouseProtocolMode::None);
emu.process(b"\x1b[?1000h");
assert_eq!(emu.mouse_protocol_mode(), MouseProtocolMode::PressRelease);
emu.process(b"\x1b[?1002h");
assert_eq!(emu.mouse_protocol_mode(), MouseProtocolMode::ButtonMotion);
emu.process(b"\x1b[?1003h");
assert_eq!(emu.mouse_protocol_mode(), MouseProtocolMode::AnyMotion);
emu.process(b"\x1b[?1003l");
assert_eq!(emu.mouse_protocol_mode(), MouseProtocolMode::None);
assert_eq!(
emu.mouse_protocol_encoding(),
MouseProtocolEncoding::Default
);
emu.process(b"\x1b[?1005h");
assert_eq!(emu.mouse_protocol_encoding(), MouseProtocolEncoding::Utf8);
emu.process(b"\x1b[?1006h");
assert_eq!(emu.mouse_protocol_encoding(), MouseProtocolEncoding::Sgr);
emu.process(b"\x1b[?1006l");
assert_eq!(
emu.mouse_protocol_encoding(),
MouseProtocolEncoding::Default
);
}
#[test]
fn x10_decset9_is_not_modeled() {
let mut emu = Emulator::new(24, 80, 0);
emu.process(b"\x1b[?9h");
assert_eq!(emu.mouse_protocol_mode(), MouseProtocolMode::None);
}
#[test]
fn alternate_scroll_requires_alt_screen_and_1007() {
let mut emu = Emulator::new(24, 80, 0);
assert!(!emu.alternate_scroll(), "primary screen never gates open");
emu.process(b"\x1b[?1049h");
assert!(emu.alternate_scroll(), "1007 defaults on");
emu.process(b"\x1b[?1007l");
assert!(!emu.alternate_scroll(), "the child's veto must stick");
emu.process(b"\x1b[?1007h");
assert!(emu.alternate_scroll());
emu.process(b"\x1b[?1049l");
assert!(!emu.alternate_scroll(), "leaving the alt screen closes it");
}
#[test]
fn scrollback_clamps_to_retained_history() {
let mut emu = Emulator::new(4, 10, 100);
for i in 0..12 {
emu.process(format!("l{i}\r\n").as_bytes());
}
emu.set_scrollback(usize::MAX);
assert_eq!(emu.scrollback(), 9);
assert!(
emu.contents().starts_with("l0"),
"the oldest stored row must be displayed"
);
emu.set_scrollback(3);
assert_eq!(emu.scrollback(), 3);
emu.set_scrollback(10_000);
assert_eq!(emu.scrollback(), 9, "over-scroll clamps at history");
emu.set_scrollback(0);
assert_eq!(emu.scrollback(), 0);
}
#[test]
fn text_with_history_includes_scrolled_off_rows() {
let mut emu = Emulator::new(4, 10, 100);
for i in 0..12 {
emu.process(format!("l{i}\r\n").as_bytes());
}
assert!(!emu.contents().contains("l0"));
let full = emu.text_with_history();
assert!(full.starts_with("l0"), "oldest history row leads");
assert!(full.contains("l11"), "the live screen is included");
assert_eq!(full.split('\n').count(), 13);
emu.set_scrollback(usize::MAX);
assert_eq!(emu.text_with_history(), full);
}
#[test]
fn text_with_history_joins_soft_wrapped_rows() {
let mut emu = Emulator::new(6, 20, 100);
let hint = "claude --resume 123e4567-e89b-42d3-a456-426614174000";
emu.process(format!("before\r\n{hint}\r\nafter").as_bytes());
let full = emu.text_with_history();
assert!(
full.contains(hint),
"52 chars over 3 rows at 20 columns must come back unbroken: {full:?}"
);
assert!(full.contains(&format!("before\n{hint}\nafter")));
}
#[test]
fn text_with_history_joins_codex_hint_across_rows() {
let mut emu = Emulator::new(8, 40, 100);
let hint = "To continue this session, run codex resume, then select \
mythic-otter (123e4567-e89b-42d3-a456-426614174000)";
emu.process(hint.as_bytes());
assert!(
emu.text_with_history().contains(hint),
"the hint spans 3 rows at 40 columns and must join unbroken"
);
}
#[test]
fn text_with_history_joins_wrapped_rows_in_scrollback() {
let mut emu = Emulator::new(4, 20, 100);
let hint = "claude --resume 123e4567-e89b-42d3-a456-426614174000";
emu.process(format!("{hint}\r\n").as_bytes());
for i in 0..6 {
emu.process(format!("pad {i}\r\n").as_bytes());
}
let full = emu.text_with_history();
assert!(
!emu.contents().contains("claude"),
"premise: the hint scrolled fully into history"
);
assert!(
full.contains(hint),
"history rows keep their wrap markers: {full:?}"
);
}
#[test]
fn region_scrolled_history_survives_resize() {
let mut emu = Emulator::new(40, 120, 2000);
for i in 1..=20 {
emu.process(format!("\x1b[{i};1Hseed {i:02}").as_bytes());
}
emu.process(b"\x1b[1;20r\x1b[20;1H");
for i in 1..=30 {
emu.process(format!("\r\nhist {i:02}").as_bytes());
}
emu.process(b"\x1b[r");
emu.set_scrollback(usize::MAX);
assert_eq!(emu.scrollback(), 30);
assert!(
emu.contents().starts_with("seed 01"),
"oldest region-scrolled row heads the history"
);
emu.set_scrollback(0);
emu.resize(30, 100);
emu.process(b"\x1b[1;15r\x1b[15;1H");
for i in 1..=20 {
emu.process(format!("\r\nmore {i:02}").as_bytes());
}
emu.process(b"\x1b[r");
emu.set_scrollback(usize::MAX);
let after_shrink = emu.scrollback();
assert!(
after_shrink >= 50,
"history keeps accumulating at the new size: {after_shrink}"
);
assert!(
emu.contents().starts_with("seed 01"),
"pre-resize history remains reachable"
);
emu.set_scrollback(0);
emu.resize(40, 120);
emu.set_scrollback(usize::MAX);
assert!(
emu.contents().contains("seed 01"),
"history survives the round trip"
);
let live = {
emu.set_scrollback(0);
emu.contents()
};
assert!(
live.contains("more 20"),
"the newest insertion is on the live screen"
);
}
fn total_zerowidth(emu: &Emulator) -> usize {
let grid = emu.term.grid();
let top = -(grid.history_size() as i32);
let bottom = grid.screen_lines() as i32 - 1;
(top..=bottom)
.flat_map(|line| grid[Line(line)][..].iter())
.map(|cell| cell.zerowidth().map_or(0, <[char]>::len))
.sum()
}
#[test]
fn zerowidth_spam_on_one_cell_is_capped() {
let mut emu = Emulator::new(4, 10, 0);
emu.process(b"a");
let chunk = "\u{0301}".repeat(SWEEP_INTERVAL_BYTES / 2);
emu.process(chunk.as_bytes());
let len = emu.term.grid()[Line(0)][Column(0)]
.zerowidth()
.map_or(0, <[char]>::len);
assert!(
len <= MAX_ZEROWIDTH,
"hot cell retains {len} marks after process returned"
);
emu.process(chunk.as_bytes());
assert!(
total_zerowidth(&emu) <= MAX_ZEROWIDTH,
"marks retained beyond the single spammed cell"
);
}
#[test]
fn zerowidth_spray_across_cells_is_capped() {
let mut emu = Emulator::new(4, 80, 0);
let marks = "\u{0301}".repeat(2048);
let mut payload = String::new();
for col in 1..=80 {
payload.push_str(&format!("\x1b[2;{col}Hx"));
payload.push_str(&marks);
}
assert!(
payload.len() >= SWEEP_INTERVAL_BYTES,
"payload must cross the sweep interval in one call"
);
emu.process(payload.as_bytes());
let grid = emu.term.grid();
for col in 0..80 {
let z = grid[Line(1)][Column(col)]
.zerowidth()
.expect("sprayed cell lost its marks entirely");
assert_eq!(z.len(), MAX_ZEROWIDTH, "column {col}");
assert!(z.iter().all(|&m| m == '\u{0301}'));
}
assert!(total_zerowidth(&emu) <= 80 * MAX_ZEROWIDTH);
}
#[test]
fn legitimate_cluster_survives_sweep_untouched() {
let mut emu = Emulator::new(4, 80, 0);
let cluster = "\x1b]8;;https://example.com\x1b\\\
\x1b[1;4;31;44m\x1b[58;5;42m\
e\u{0301}\u{0302}\u{0304}\
\x1b[0m\x1b]8;;\x1b\\";
emu.process(cluster.as_bytes());
let filler = format!("\x1b[2;1H{}", "x".repeat(64)).repeat(1024);
let mut fed = cluster.len();
while fed < SWEEP_INTERVAL_BYTES {
emu.process(filler.as_bytes());
fed += filler.len();
}
let cell = &emu.term.grid()[Line(0)][Column(0)];
assert_eq!(cell.c, 'e');
assert_eq!(
cell.zerowidth(),
Some(&['\u{0301}', '\u{0302}', '\u{0304}'][..])
);
assert_eq!(cell.fg, Color::Named(NamedColor::Red));
assert_eq!(cell.bg, Color::Named(NamedColor::Blue));
assert!(cell.flags.contains(Flags::BOLD | Flags::UNDERLINE));
assert_eq!(cell.underline_color(), Some(Color::Indexed(42)));
assert_eq!(
cell.hyperlink().map(|h| h.uri().to_owned()),
Some("https://example.com".to_owned())
);
}
#[test]
fn history_cells_are_swept() {
let mut emu = Emulator::new(4, 10, 100);
let spam = format!("h{}", "\u{0301}".repeat(4096));
emu.process(spam.as_bytes());
emu.process(b"\r\n\r\n\r\n\r\n\r\n\r\n");
let find_h = |emu: &Emulator| -> (i32, usize) {
let grid = emu.term.grid();
let top = -(grid.history_size() as i32);
(top..0)
.find_map(|line| {
let cell = &grid[Line(line)][Column(0)];
(cell.c == 'h').then(|| (line, cell.zerowidth().map_or(0, <[char]>::len)))
})
.expect("spammed row must be in history")
};
let (line, len) = find_h(&emu);
assert!(line < 0);
assert_eq!(len, 4096, "excess must predate the sweep");
let filler = "\x1b[4;1Hxxxxxxxx".repeat(1024);
let mut fed = spam.len() + 12;
while fed < SWEEP_INTERVAL_BYTES {
emu.process(filler.as_bytes());
fed += filler.len();
}
let (line_after, len_after) = find_h(&emu);
assert_eq!(line_after, line, "row must not have moved");
assert_eq!(len_after, MAX_ZEROWIDTH);
}
#[test]
fn sanitize_strips_c0_and_c1_controls() {
assert_eq!(sanitize_title("a\x07b\x1bc\u{7f}d\u{9b}e"), "abcde");
assert_eq!(sanitize_title("\x01\x02\x03"), "");
}
#[test]
fn sanitize_strips_each_bidi_control() {
let bidi = [
'\u{061C}', '\u{200E}', '\u{200F}', '\u{202A}', '\u{202B}', '\u{202C}', '\u{202D}',
'\u{202E}', '\u{2066}', '\u{2067}', '\u{2068}', '\u{2069}',
];
for c in bidi {
assert_eq!(
sanitize_title(&format!("a{c}b")),
"ab",
"U+{:04X}",
c as u32
);
}
}
#[test]
fn sanitize_preserves_zwj_sequences() {
let technologist = "\u{1F469}\u{200D}\u{1F4BB}";
assert_eq!(sanitize_title(technologist), technologist);
}
#[test]
fn sanitize_collapses_and_trims_whitespace() {
assert_eq!(sanitize_title(" a \t\r\n b "), "a b");
assert_eq!(sanitize_title(" \t "), "");
}
#[test]
fn sanitize_caps_on_a_char_boundary() {
let long = "\u{20AC}".repeat(200); let out = sanitize_title(&long);
assert!(out.len() <= TITLE_MAX_BYTES);
assert_eq!(out.len(), 510);
assert_eq!(out.chars().count(), 170);
}
#[test]
fn empty_title_and_reset_unset_the_capture() {
let mut emu = Emulator::new(4, 20, 0);
emu.process(b"\x1b]0;first\x07\x1b]0;second\x07");
assert_eq!(emu.title(), Some("second"), "last event of a chunk wins");
emu.process(b"\x1b]0;\x07");
assert_eq!(emu.title(), None, "an empty title clears, never blanks");
let mut emu = Emulator::new(4, 20, 0);
emu.process(b"\x1b[22t");
emu.process(b"\x1b]0;named\x07");
assert_eq!(emu.title(), Some("named"));
emu.process(b"\x1b[23t");
assert_eq!(emu.title(), None, "ResetTitle must unset the capture");
}
#[test]
fn title_entering_alt_in_one_chunk_is_honored() {
let mut emu = Emulator::new(4, 20, 0);
emu.process(b"\x1b]0;app\x07\x1b[?1049h");
assert_eq!(emu.alt_epoch(), 1);
assert_eq!(emu.title(), Some("app"));
}
#[test]
fn title_before_alt_entry_in_a_prior_chunk_expires() {
let mut emu = Emulator::new(4, 20, 0);
emu.process(b"\x1b]0;shell\x07");
assert_eq!(emu.title(), Some("shell"));
emu.process(b"$ make\r\n");
emu.process(b"\x1b[?1049h");
assert_eq!(
emu.title(),
None,
"printed output disclaimed the staged title"
);
}
#[test]
fn in_alt_title_expires_across_a_bounce_on_any_read_boundary() {
for split in [false, true] {
let mut emu = Emulator::new(4, 20, 0);
emu.process(b"\x1b[?1049hui");
assert_eq!(emu.alt_epoch(), 1);
if split {
emu.process(b"\x1b]0;first\x07");
emu.process(b"\x1b[?1049l\x1b[?1049h");
} else {
emu.process(b"\x1b]0;first\x07\x1b[?1049l\x1b[?1049h");
}
assert_eq!(emu.alt_epoch(), 2, "split={split}");
assert_eq!(
emu.title(),
None,
"split={split}: A's title must not label B"
);
}
}
#[test]
fn staged_title_survives_a_control_only_gap_into_the_entry() {
for split in [false, true] {
let mut emu = Emulator::new(4, 20, 0);
if split {
emu.process(b"\x1b]0;grok\x07\x1b[2J\x1b[H");
emu.process(b"\x1b[?1049h");
} else {
emu.process(b"\x1b]0;grok\x07\x1b[2J\x1b[H\x1b[?1049h");
}
assert_eq!(emu.alt_epoch(), 1, "split={split}");
assert_eq!(emu.title(), Some("grok"), "split={split}");
}
}
#[test]
fn staged_title_is_disclaimed_by_a_single_glyph() {
let mut emu = Emulator::new(4, 20, 0);
emu.process(b"\x1b]0;shell\x07x\x1b[?1049h");
assert_eq!(emu.alt_epoch(), 1);
assert_eq!(emu.title(), None);
}
#[test]
fn inter_app_title_with_no_glyphs_stages_into_the_next_app() {
let mut emu = Emulator::new(4, 20, 0);
emu.process(b"\x1b[?1049hui A");
emu.process(b"\x1b[?1049l\x1b]0;handoff\x07\x1b[?1049h");
assert_eq!(emu.alt_epoch(), 2);
assert_eq!(emu.title(), Some("handoff"));
}
#[test]
fn each_alt_entry_advances_the_epoch() {
let mut emu = Emulator::new(4, 20, 0);
emu.process(b"\x1b]0;one\x07\x1b[?1049h");
assert_eq!(emu.alt_epoch(), 1);
emu.process(b"\x1b[?1049l");
assert_eq!(emu.alt_epoch(), 1, "leaving must not advance the epoch");
assert_eq!(emu.title(), Some("one"), "epoch still current after exit");
emu.process(b"\x1b[?1049h");
assert_eq!(emu.alt_epoch(), 2);
assert_eq!(emu.title(), None, "re-entry expires the previous title");
}
#[test]
fn title_just_before_alt_exit_stays_honored() {
let mut emu = Emulator::new(4, 20, 0);
emu.process(b"\x1b[?1049h");
assert_eq!(emu.alt_epoch(), 1);
emu.process(b"\x1b]0;done\x07\x1b[?1049l");
assert!(!emu.alternate_screen());
assert_eq!(emu.title(), Some("done"));
}
#[test]
fn primary_title_survives_the_printable_output_that_disclaims_staging() {
let mut emu = Emulator::new(4, 20, 0);
emu.process(b"\x1b]0;omp\x07");
assert_eq!(emu.title(), Some("omp"), "premise: the announce staged");
assert_eq!(emu.primary_title(), Some("omp"));
emu.process(b"$ ls\r\n");
assert_eq!(emu.title(), None, "staged: disclaimed by printed output");
assert_eq!(emu.primary_title(), Some("omp"), "retained: survives it");
}
#[test]
fn primary_title_is_overwritten_by_a_newer_announce() {
let mut emu = Emulator::new(4, 20, 0);
emu.process("\x1b]0;\u{3c0} >\x07build output\r\n".as_bytes());
assert_eq!(emu.primary_title(), Some("\u{3c0} >"));
emu.process("\x1b]0;\u{3c0} > check\x07".as_bytes());
assert_eq!(emu.primary_title(), Some("\u{3c0} > check"));
}
#[test]
fn empty_announce_and_reset_clear_the_primary_title() {
let mut emu = Emulator::new(4, 20, 0);
emu.process(b"\x1b]0;codex\x07");
assert_eq!(emu.primary_title(), Some("codex"));
emu.process(b"\x1b]0;\x07");
assert_eq!(emu.primary_title(), None, "an empty announce clears");
let mut emu = Emulator::new(4, 20, 0);
emu.process(b"\x1b]0;codex\x07");
emu.process(b"\x1bc");
assert_eq!(emu.primary_title(), None, "RIS clears");
}
#[test]
fn primary_title_is_unaffected_by_an_alt_round_trip() {
let mut emu = Emulator::new(4, 20, 0);
emu.process(b"\x1b]0;shell\x07\x1b[?1049h");
assert_eq!(emu.alt_epoch(), 1);
assert_eq!(emu.title(), Some("shell"), "premise: entry claimed staging");
emu.process(b"\x1b]0;altapp\x07");
assert_eq!(emu.title(), Some("altapp"));
assert_eq!(
emu.primary_title(),
Some("shell"),
"an in-alt announce must not touch the slot"
);
emu.process(b"\x1b[?1049l");
assert_eq!(emu.primary_title(), Some("shell"));
}
#[test]
fn finish_output_runs_the_advance_bookkeeping() {
let mut emu = Emulator::new(4, 20, 0);
emu.process(b"\x1b[?2026h\x1b]0;app\x07\x1b[?1049hui");
let rev = emu.revision();
assert_eq!(emu.alt_epoch(), 0, "premise: the open frame buffers 1049h");
assert_eq!(emu.title(), None, "premise: the open frame buffers OSC 0");
emu.finish_output();
assert_eq!(emu.revision(), rev + 1, "the landing is a grid advance");
assert_eq!(emu.alt_epoch(), 1);
assert_eq!(emu.title(), Some("app"));
}
#[test]
fn flush_expired_sync_runs_the_advance_bookkeeping() {
let mut emu = Emulator::new(4, 20, 0);
emu.process(b"\x1b[?2026h\x1b]0;app\x07\x1b[?1049hui");
let rev = emu.revision();
std::thread::sleep(Duration::from_millis(200));
emu.flush_expired_sync();
assert_eq!(emu.revision(), rev + 1, "the landing is a grid advance");
assert_eq!(emu.alt_epoch(), 1);
assert_eq!(emu.title(), Some("app"));
}
#[test]
fn revision_is_monotonic_and_noop_landings_do_not_bump() {
let mut emu = Emulator::new(4, 20, 0);
assert_eq!(emu.revision(), 0);
emu.process(b"a");
assert_eq!(emu.revision(), 1);
emu.process(b"b");
assert_eq!(emu.revision(), 2);
emu.flush_expired_sync();
assert_eq!(emu.revision(), 2, "no open frame: nothing advanced");
emu.finish_output();
assert_eq!(emu.revision(), 2, "no open frame: nothing advanced");
}
#[test]
fn resize_bumps_the_revision_without_bytes() {
let mut emu = Emulator::new(4, 20, 0);
emu.process(b"hello\r\nworld");
let before = emu.revision();
emu.resize(6, 30);
assert_eq!(emu.revision(), before + 1);
}
#[test]
fn alt_leave_floor_snapshots_the_restore() {
let mut emu = Emulator::new(4, 20, 0);
emu.process(b"junk\r\n");
assert_eq!(emu.alt_leave_floor(), None, "no exit yet");
emu.process(b"\x1b[?1049halt body");
emu.process(b"\x1b[?1049l");
assert_eq!(emu.alt_leave_floor(), Some("junk"));
emu.process(b"done\r\n");
assert_eq!(
emu.alt_leave_floor(),
Some("junk"),
"later chunks leave the snapshot alone"
);
assert_eq!(emu.live_floor(), "done");
emu.process(b"\x1b[?1049halt again");
emu.process(b"\x1b[?1049lcoalesced\r\n");
assert_eq!(
emu.alt_leave_floor(),
Some("done"),
"a coalesced read still snapshots at the mode event"
);
assert_eq!(emu.live_floor(), "coalesced");
}
#[test]
fn same_read_alt_bounce_advances_the_epoch_and_expires_the_title() {
let mut emu = Emulator::new(4, 20, 0);
emu.process(b"\x1b[?1049h\x1b]0;first app\x07ui");
assert_eq!(emu.alt_epoch(), 1);
assert_eq!(emu.title(), Some("first app"), "premise: title honored");
emu.process(b"\x1b[?1049l\x1b[?1049h");
assert_eq!(emu.alt_epoch(), 2, "the bounce is two transitions");
assert_eq!(
emu.title(),
None,
"the old app's title must not survive the swap"
);
}
#[test]
fn live_floor_ignores_the_scrollback_offset() {
let mut emu = Emulator::new(4, 10, 100);
for i in 0..12 {
emu.process(format!("l{i}\r\n").as_bytes());
}
emu.process(b"latest");
assert_eq!(emu.live_floor(), "latest");
emu.set_scrollback(usize::MAX);
assert!(
emu.contents().starts_with("l0"),
"premise: the view shows history"
);
assert!(!emu.contents().contains("latest"));
assert_eq!(
emu.live_floor(),
"latest",
"the floor must not follow the view"
);
}
#[test]
fn live_floor_of_a_blank_screen_is_empty() {
let emu = Emulator::new(4, 10, 0);
assert_eq!(emu.live_floor(), "");
}
}