use std::collections::VecDeque;
use std::io::Write;
use std::path::Path;
use std::sync::mpsc::{self, Receiver};
use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize};
use crate::event::{Key, MouseButton, NamedKey};
use crate::primitives::terminal::{Terminal, TerminalCell, TerminalCursorShape, TerminalScrollbar};
use crate::types::{Color, Modifiers, WidgetId};
#[derive(Clone)]
struct HistCell {
text: String,
fg: vt100::Color,
bg: vt100::Color,
bold: bool,
italic: bool,
underline: bool,
dim: bool,
}
impl Default for HistCell {
fn default() -> Self {
HistCell {
text: " ".to_string(),
fg: vt100::Color::Default,
bg: vt100::Color::Default,
bold: false,
italic: false,
underline: false,
dim: false,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TerminalSelection {
pub start_row: u16,
pub start_col: u16,
pub end_row: u16,
pub end_col: u16,
}
fn normalize_selection(sel: &TerminalSelection) -> (u16, u16, u16, u16) {
if (sel.start_row, sel.start_col) <= (sel.end_row, sel.end_col) {
(sel.start_row, sel.start_col, sel.end_row, sel.end_col)
} else {
(sel.end_row, sel.end_col, sel.start_row, sel.start_col)
}
}
fn map_vt100_color(color: vt100::Color, is_bg: bool) -> (u8, u8, u8) {
match color {
vt100::Color::Default => {
if is_bg {
(30, 30, 30) } else {
(229, 229, 229) }
}
vt100::Color::Rgb(r, g, b) => (r, g, b),
vt100::Color::Idx(n) => xterm_256_color(n),
}
}
fn xterm_256_color(n: u8) -> (u8, u8, u8) {
const SYSTEM: [(u8, u8, u8); 16] = [
(0, 0, 0),
(128, 0, 0),
(0, 128, 0),
(128, 128, 0),
(0, 0, 128),
(128, 0, 128),
(0, 128, 128),
(192, 192, 192),
(128, 128, 128),
(255, 0, 0),
(0, 255, 0),
(255, 255, 0),
(0, 0, 255),
(255, 0, 255),
(0, 255, 255),
(255, 255, 255),
];
if n < 16 {
return SYSTEM[n as usize];
}
if n < 232 {
let idx = n - 16;
let b = idx % 6;
let g = (idx / 6) % 6;
let r = idx / 36;
let to_byte = |v: u8| if v == 0 { 0 } else { 55 + v * 40 };
return (to_byte(r), to_byte(g), to_byte(b));
}
let gray = 8 + (n - 232) * 10;
(gray, gray, gray)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TerminalMouseKind {
Press,
Release,
Move,
WheelUp,
WheelDown,
}
pub fn encode_mouse_sgr(
kind: TerminalMouseKind,
button: MouseButton,
col: u16,
row: u16,
modifiers: Modifiers,
) -> Vec<u8> {
let mut cb: u32 = match kind {
TerminalMouseKind::WheelUp => 64,
TerminalMouseKind::WheelDown => 65,
TerminalMouseKind::Press | TerminalMouseKind::Release | TerminalMouseKind::Move => {
match button {
MouseButton::Left => 0,
MouseButton::Middle => 1,
MouseButton::Right => 2,
MouseButton::X1 => 128,
MouseButton::X2 => 129,
MouseButton::Other(n) => n as u32,
}
}
};
if matches!(kind, TerminalMouseKind::Move) {
cb |= 32; }
if modifiers.shift {
cb |= 4;
}
if modifiers.alt {
cb |= 8;
}
if modifiers.ctrl {
cb |= 16;
}
let terminator = match kind {
TerminalMouseKind::Release => b'm',
_ => b'M',
};
let cx = col.saturating_add(1);
let cy = row.saturating_add(1);
format!("\x1b[<{cb};{cx};{cy}{}", terminator as char).into_bytes()
}
fn encode_paste(text: &str, bracketed: bool) -> Vec<u8> {
if bracketed {
let mut bytes = Vec::with_capacity(text.len() + 12);
bytes.extend_from_slice(b"\x1b[200~");
bytes.extend_from_slice(text.as_bytes());
bytes.extend_from_slice(b"\x1b[201~");
bytes
} else {
text.as_bytes().to_vec()
}
}
pub fn key_to_pty_bytes(key: Key, mods: Modifiers, app_cursor: bool) -> Option<Vec<u8>> {
match key {
Key::Char(ch) => {
if mods.ctrl {
let c = ch.to_ascii_uppercase();
if c.is_ascii_alphabetic() {
return Some(vec![c as u8 - b'@']);
}
match ch {
'[' => return Some(vec![0x1b]),
'\\' => return Some(vec![0x1c]),
']' => return Some(vec![0x1d]),
'^' => return Some(vec![0x1e]),
'_' => return Some(vec![0x1f]),
_ => {}
}
}
let mut buf = [0u8; 4];
let s = ch.encode_utf8(&mut buf);
Some(s.as_bytes().to_vec())
}
Key::Named(named) => named_key_bytes(named, mods, app_cursor),
}
}
fn named_key_bytes(key: NamedKey, mods: Modifiers, app_cursor: bool) -> Option<Vec<u8>> {
let mod_param = modifier_param(mods);
match key {
NamedKey::Enter => Some(b"\r".to_vec()),
NamedKey::Tab => {
if mods.shift {
Some(b"\x1b[Z".to_vec()) } else {
Some(b"\t".to_vec())
}
}
NamedKey::BackTab => Some(b"\x1b[Z".to_vec()),
NamedKey::Backspace => Some(b"\x7f".to_vec()),
NamedKey::Delete => Some(xterm_seq(b"3", mod_param)),
NamedKey::Escape => Some(b"\x1b".to_vec()),
NamedKey::Up => {
if app_cursor && mod_param.is_none() {
Some(ss3_seq(b'A'))
} else {
Some(xterm_cursor_seq(b"A", mod_param))
}
}
NamedKey::Down => {
if app_cursor && mod_param.is_none() {
Some(ss3_seq(b'B'))
} else {
Some(xterm_cursor_seq(b"B", mod_param))
}
}
NamedKey::Right => {
if app_cursor && mod_param.is_none() {
Some(ss3_seq(b'C'))
} else {
Some(xterm_cursor_seq(b"C", mod_param))
}
}
NamedKey::Left => {
if app_cursor && mod_param.is_none() {
Some(ss3_seq(b'D'))
} else {
Some(xterm_cursor_seq(b"D", mod_param))
}
}
NamedKey::Home => {
if app_cursor && mod_param.is_none() {
Some(ss3_seq(b'H'))
} else {
Some(xterm_seq(b"1", mod_param))
}
}
NamedKey::End => {
if app_cursor && mod_param.is_none() {
Some(ss3_seq(b'F'))
} else {
Some(xterm_seq(b"4", mod_param))
}
}
NamedKey::Insert => Some(xterm_seq(b"2", mod_param)),
NamedKey::PageUp => Some(xterm_seq(b"5", mod_param)),
NamedKey::PageDown => Some(xterm_seq(b"6", mod_param)),
NamedKey::F(n) => f_key_bytes(n, mod_param),
NamedKey::CapsLock | NamedKey::NumLock | NamedKey::ScrollLock | NamedKey::Menu => None,
}
}
fn ss3_seq(letter: u8) -> Vec<u8> {
vec![0x1b, b'O', letter]
}
fn modifier_param(mods: Modifiers) -> Option<u8> {
let n: u8 = 1
+ if mods.shift { 1 } else { 0 }
+ if mods.alt { 2 } else { 0 }
+ if mods.ctrl { 4 } else { 0 };
if n == 1 {
None
} else {
Some(n)
}
}
fn xterm_seq(code: &[u8], mod_param: Option<u8>) -> Vec<u8> {
let mut v = b"\x1b[".to_vec();
v.extend_from_slice(code);
if let Some(m) = mod_param {
v.push(b';');
v.push(b'0' + m);
}
v.push(b'~');
v
}
fn xterm_cursor_seq(letter: &[u8], mod_param: Option<u8>) -> Vec<u8> {
match mod_param {
None => {
let mut v = b"\x1b[".to_vec();
v.extend_from_slice(letter);
v
}
Some(m) => {
let mut v = b"\x1b[1;".to_vec();
v.push(b'0' + m);
v.extend_from_slice(letter);
v
}
}
}
fn f_key_bytes(n: u8, mod_param: Option<u8>) -> Option<Vec<u8>> {
let bytes = match n {
1 => {
if mod_param.is_none() {
b"\x1bOP".to_vec()
} else {
xterm_cursor_seq(b"P", mod_param)
}
}
2 => {
if mod_param.is_none() {
b"\x1bOQ".to_vec()
} else {
xterm_cursor_seq(b"Q", mod_param)
}
}
3 => {
if mod_param.is_none() {
b"\x1bOR".to_vec()
} else {
xterm_cursor_seq(b"R", mod_param)
}
}
4 => {
if mod_param.is_none() {
b"\x1bOS".to_vec()
} else {
xterm_cursor_seq(b"S", mod_param)
}
}
5 => xterm_seq(b"15", mod_param),
6 => xterm_seq(b"17", mod_param),
7 => xterm_seq(b"18", mod_param),
8 => xterm_seq(b"19", mod_param),
9 => xterm_seq(b"20", mod_param),
10 => xterm_seq(b"21", mod_param),
11 => xterm_seq(b"23", mod_param),
12 => xterm_seq(b"24", mod_param),
_ => return None, };
Some(bytes)
}
const RESIZE_SETTLE_FIRST: std::time::Duration = std::time::Duration::from_millis(80);
const RESIZE_SETTLE_IDLE: std::time::Duration = std::time::Duration::from_millis(8);
const RESIZE_SETTLE_MAX: std::time::Duration = std::time::Duration::from_millis(120);
fn collect_post_resize_output(rx: &Receiver<Vec<u8>>) -> Vec<Vec<u8>> {
use std::sync::mpsc::RecvTimeoutError;
use std::time::Instant;
let start = Instant::now();
let hard_deadline = start + RESIZE_SETTLE_MAX;
let first_deadline = start + RESIZE_SETTLE_FIRST;
let mut chunks: Vec<Vec<u8>> = Vec::new();
loop {
let now = Instant::now();
let cap = hard_deadline.saturating_duration_since(now);
if cap.is_zero() {
break;
}
let wait = if chunks.is_empty() {
first_deadline.saturating_duration_since(now)
} else {
RESIZE_SETTLE_IDLE
}
.min(cap);
if wait.is_zero() {
break;
}
match rx.recv_timeout(wait) {
Ok(data) => chunks.push(data),
Err(RecvTimeoutError::Timeout) | Err(RecvTimeoutError::Disconnected) => break,
}
}
chunks
}
fn reflow_screen<CB: vt100::Callbacks>(parser: &mut vt100::Parser<CB>, rows: u16, cols: u16) {
let (cur_rows, cur_cols) = parser.screen().size();
if (rows, cols) == (cur_rows, cur_cols) {
return;
}
if cols == cur_cols || parser.screen().alternate_screen() {
set_size_without_orphaning_wide_cells(parser, rows, cols);
return;
}
let dump = parser.screen().contents_formatted();
set_size_without_orphaning_wide_cells(parser, rows, cols);
parser.process(&dump);
}
fn set_size_without_orphaning_wide_cells<CB: vt100::Callbacks>(
parser: &mut vt100::Parser<CB>,
rows: u16,
cols: u16,
) {
let (cur_rows, cur_cols) = parser.screen().size();
if let Some(last_col) = cols.checked_sub(1).filter(|_| cols < cur_cols) {
let mut scrub = Vec::new();
for row in 0..cur_rows {
let straddles = parser
.screen()
.cell(row, last_col)
.is_some_and(vt100::Cell::is_wide);
if straddles {
scrub.extend_from_slice(format!("\x1b[{};{}H\x1b[X", row + 1, cols).as_bytes());
}
}
if !scrub.is_empty() {
let mut bytes = b"\x1b7".to_vec();
bytes.append(&mut scrub);
bytes.extend_from_slice(b"\x1b8");
parser.process(&bytes);
}
}
parser.screen_mut().set_size(rows, cols);
}
const MIN_VT100_ROWS: u16 = 2;
const MIN_VT100_COLS: u16 = 2;
fn clamp_vt100_size(cols: u16, rows: u16) -> (u16, u16) {
(cols.max(MIN_VT100_COLS), rows.max(MIN_VT100_ROWS))
}
#[derive(Debug, Default)]
struct EngineCallbacks {
title: Option<String>,
changed: bool,
cursor_shape: TerminalCursorShape,
cursor_blinking: bool,
}
impl vt100::Callbacks for EngineCallbacks {
fn set_window_title(&mut self, _screen: &mut vt100::Screen, title: &[u8]) {
let title = String::from_utf8_lossy(title).into_owned();
if self.title.as_deref() != Some(title.as_str()) {
self.title = Some(title);
self.changed = true;
}
}
fn unhandled_csi(
&mut self,
_screen: &mut vt100::Screen,
i1: Option<u8>,
_i2: Option<u8>,
params: &[&[u16]],
c: char,
) {
if i1 == Some(b' ') && c == 'q' {
let ps = params.first().and_then(|p| p.first()).copied().unwrap_or(0);
if let Some((shape, blinking)) = decscusr_shape(ps) {
self.cursor_shape = shape;
self.cursor_blinking = blinking;
}
}
}
}
fn decscusr_shape(ps: u16) -> Option<(TerminalCursorShape, bool)> {
match ps {
0 | 1 => Some((TerminalCursorShape::Block, true)),
2 => Some((TerminalCursorShape::Block, false)),
3 => Some((TerminalCursorShape::Underline, true)),
4 => Some((TerminalCursorShape::Underline, false)),
5 => Some((TerminalCursorShape::Bar, true)),
6 => Some((TerminalCursorShape::Bar, false)),
_ => None,
}
}
fn terminal_query_replies(data: &[u8], cursor: (u16, u16)) -> Vec<u8> {
let (row, col) = cursor;
let mut reply = Vec::new();
let mut i = 0;
while i < data.len() {
if data[i] != 0x1b || i + 1 >= data.len() || data[i + 1] != b'[' {
i += 1;
continue;
}
let mut j = i + 2;
while j < data.len() && matches!(data[j], b'0'..=b'9' | b';' | b'?' | b'>') {
j += 1;
}
let Some(&final_byte) = data.get(j) else {
break;
};
let params = &data[i + 2..j];
match (final_byte, params) {
(b'n', b"6") => {
reply.extend(format!("\x1b[{};{}R", row + 1, col + 1).into_bytes());
}
(b'c', b"" | b"0") => {
reply.extend_from_slice(b"\x1b[?1;0c");
}
_ => {}
}
i = j + 1;
}
reply
}
pub struct TerminalSession {
parser: vt100::Parser<EngineCallbacks>,
writer: Box<dyn Write + Send>,
master: Box<dyn MasterPty + Send>,
child: Box<dyn Child + Send + Sync>,
rx: Receiver<Vec<u8>>,
cols: u16,
rows: u16,
pub selection: Option<TerminalSelection>,
exited: bool,
exit_code: Option<u32>,
scroll_offset: usize,
history: VecDeque<Vec<HistCell>>,
history_capacity: usize,
}
impl TerminalSession {
pub fn spawn(
cols: u16,
rows: u16,
shell: &str,
cwd: &Path,
history_capacity: usize,
) -> Result<Self, Box<dyn std::error::Error>> {
Self::spawn_with_args(cols, rows, shell, &[], cwd, history_capacity)
}
pub(crate) fn spawn_with_args(
cols: u16,
rows: u16,
shell: &str,
args: &[&str],
cwd: &Path,
history_capacity: usize,
) -> Result<Self, Box<dyn std::error::Error>> {
let (cols, rows) = clamp_vt100_size(cols, rows);
let pty_system = native_pty_system();
let pair = pty_system.openpty(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
})?;
let mut cmd = CommandBuilder::new(shell);
cmd.args(args);
cmd.env("TERM", "xterm-256color");
cmd.env_remove("TMUX");
cmd.env_remove("TMUX_PANE");
cmd.cwd(cwd);
let child = pair.slave.spawn_command(cmd)?;
let writer = pair.master.take_writer()?;
let reader = pair.master.try_clone_reader()?;
let master = pair.master;
let (tx, rx) = mpsc::channel::<Vec<u8>>();
std::thread::spawn(move || {
use std::io::Read;
let mut reader = reader;
let mut buf = [0u8; 4096];
loop {
match reader.read(&mut buf) {
Ok(0) | Err(_) => break,
Ok(n) => {
if tx.send(buf[..n].to_vec()).is_err() {
break; }
}
}
}
});
let parser =
vt100::Parser::new_with_callbacks(rows, cols, 1000, EngineCallbacks::default());
Ok(Self {
parser,
writer,
master,
child,
rx,
cols,
rows,
selection: None,
exited: false,
exit_code: None,
scroll_offset: 0,
history: VecDeque::new(),
history_capacity,
})
}
pub fn poll(&mut self) -> bool {
let mut changed = false;
while let Ok(data) = self.rx.try_recv() {
changed = true;
self.ingest(&data);
}
if !self.exited {
if let Ok(Some(status)) = self.child.try_wait() {
self.exited = true;
self.exit_code = Some(status.exit_code());
changed = true;
}
}
changed
}
fn respond_to_terminal_queries(&mut self, data: &[u8]) {
let cursor = self.parser.screen().cursor_position();
let reply = terminal_query_replies(data, cursor);
if !reply.is_empty() {
let _ = self.writer.write_all(&reply);
let _ = self.writer.flush();
}
}
fn ingest(&mut self, data: &[u8]) {
self.respond_to_terminal_queries(data);
self.process_with_capture(data);
}
pub fn write_input(&mut self, data: &[u8]) {
let _ = self.writer.write_all(data);
let _ = self.writer.flush();
}
pub fn send_str(&mut self, s: &str) {
self.write_input(s.as_bytes());
}
#[doc(hidden)]
pub fn feed_for_bench(&mut self, data: &[u8]) {
self.process_with_capture(data);
}
pub fn cols(&self) -> u16 {
self.cols
}
pub fn rows(&self) -> u16 {
self.rows
}
pub fn is_exited(&self) -> bool {
self.exited
}
pub fn scroll_offset(&self) -> usize {
self.scroll_offset
}
pub fn exit_code(&self) -> Option<u32> {
self.exit_code
}
pub fn title(&self) -> Option<&str> {
self.parser.callbacks().title.as_deref()
}
pub fn take_title_changed(&mut self) -> bool {
std::mem::take(&mut self.parser.callbacks_mut().changed)
}
pub fn cursor_visible(&self) -> bool {
!self.exited && self.scroll_offset == 0
}
pub fn cursor_shape(&self) -> TerminalCursorShape {
self.parser.callbacks().cursor_shape
}
pub fn cursor_blinking(&self) -> bool {
self.parser.callbacks().cursor_blinking
}
pub fn screen_text(&self) -> String {
let screen = self.parser.screen();
let mut lines: Vec<String> = (0..self.rows)
.map(|r| {
let mut line = String::new();
for c in 0..self.cols {
if let Some(cell) = screen.cell(r, c) {
let s = cell.contents();
if s.is_empty() {
line.push(' ');
} else {
line.push_str(s);
}
} else {
line.push(' ');
}
}
line.trim_end().to_string()
})
.collect();
while lines.last().is_some_and(|l: &String| l.is_empty()) {
lines.pop();
}
lines.join("\n")
}
pub fn bracketed_paste_enabled(&self) -> bool {
self.parser.screen().bracketed_paste()
}
pub fn paste(&mut self, text: &str) {
self.scroll_reset();
let bytes = encode_paste(text, self.bracketed_paste_enabled());
self.write_input(&bytes);
}
pub fn application_cursor_keys(&self) -> bool {
self.parser.screen().application_cursor()
}
pub fn encode_key(&self, key: Key, mods: Modifiers) -> Option<Vec<u8>> {
key_to_pty_bytes(key, mods, self.application_cursor_keys())
}
pub fn write_key(&mut self, key: Key, mods: Modifiers) -> bool {
match self.encode_key(key, mods) {
Some(bytes) => {
self.scroll_reset();
self.write_input(&bytes);
true
}
None => false,
}
}
pub fn on_alt_screen(&self) -> bool {
self.parser.screen().alternate_screen()
}
pub fn mouse_reporting_enabled(&self) -> bool {
self.parser.screen().mouse_protocol_mode() != vt100::MouseProtocolMode::None
}
pub fn should_forward_wheel(&self) -> bool {
self.mouse_reporting_enabled() || self.on_alt_screen()
}
pub fn encode_mouse(
&self,
kind: TerminalMouseKind,
button: MouseButton,
col: u16,
row: u16,
modifiers: Modifiers,
) -> Option<Vec<u8>> {
let allow = match kind {
TerminalMouseKind::WheelUp | TerminalMouseKind::WheelDown => {
self.should_forward_wheel()
}
_ => self.mouse_reporting_enabled(),
};
if !allow {
return None;
}
Some(encode_mouse_sgr(kind, button, col, row, modifiers))
}
pub fn forward_mouse(
&mut self,
kind: TerminalMouseKind,
button: MouseButton,
col: u16,
row: u16,
modifiers: Modifiers,
) -> bool {
match self.encode_mouse(kind, button, col, row, modifiers) {
Some(bytes) => {
self.write_input(&bytes);
true
}
None => false,
}
}
pub fn handle_wheel(&mut self, up: bool, step: usize, col: u16, row: u16) -> bool {
let kind = if up {
TerminalMouseKind::WheelUp
} else {
TerminalMouseKind::WheelDown
};
let forwarded = self.forward_mouse(kind, MouseButton::Left, col, row, Modifiers::default());
if !forwarded {
if up {
self.scroll_up(step);
} else {
self.scroll_down(step);
}
}
forwarded
}
pub fn scrollback_text(&self) -> String {
let mut lines: Vec<String> = self
.history
.iter()
.map(|row| {
let mut line = String::new();
for hc in row {
line.push_str(&hc.text);
}
line.trim_end().to_string()
})
.collect();
while lines.last().is_some_and(|l: &String| l.is_empty()) {
lines.pop();
}
lines.join("\n")
}
pub fn full_text(&self) -> String {
let hist = self.scrollback_text();
let live = self.screen_text();
match (hist.is_empty(), live.is_empty()) {
(true, _) => live,
(_, true) => hist,
_ => format!("{hist}\n{live}"),
}
}
pub fn resize(&mut self, cols: u16, rows: u16) {
let (cols, rows) = clamp_vt100_size(cols, rows);
if cols == self.cols && rows == self.rows {
return;
}
while let Ok(data) = self.rx.try_recv() {
self.ingest(&data);
}
self.cols = cols;
self.rows = rows;
reflow_screen(&mut self.parser, rows, cols);
let _ = self.master.resize(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
});
self.settle_after_resize();
}
fn settle_after_resize(&mut self) {
for chunk in collect_post_resize_output(&self.rx) {
self.ingest(&chunk);
}
}
pub fn history_len(&self) -> usize {
self.history.len()
}
pub fn set_scroll_offset(&mut self, offset: usize) {
self.scroll_offset = offset.min(self.history.len());
self.parser.screen_mut().set_scrollback(0);
}
pub fn scroll_up(&mut self, n: usize) {
let new = self.scroll_offset.saturating_add(n);
self.set_scroll_offset(new);
}
pub fn scroll_down(&mut self, n: usize) {
let new = self.scroll_offset.saturating_sub(n);
self.set_scroll_offset(new);
}
pub fn scroll_reset(&mut self) {
self.set_scroll_offset(0);
}
pub fn selected_text(&self) -> Option<String> {
let sel = self.selection.as_ref()?;
let (r0, c0, r1, c1) = normalize_selection(sel);
let mut lines: Vec<String> = Vec::new();
for row in r0..=r1 {
let mut line = String::new();
let col_start = if row == r0 { c0 } else { 0 };
let col_end = if row == r1 {
c1
} else {
self.cols.saturating_sub(1)
};
for col in col_start..=col_end {
line.push_str(&self.cell_content_at_display_row(row as usize, col));
}
lines.push(line.trim_end().to_string());
}
Some(lines.join("\n"))
}
fn cell_content_at_display_row(&self, display_r: usize, col: u16) -> String {
let scroll_offset = self.scroll_offset;
let hist_len = self.history.len();
if display_r < scroll_offset {
let hist_idx_signed = hist_len as isize - scroll_offset as isize + display_r as isize;
if hist_idx_signed >= 0 {
if let Some(hist_row) = self.history.get(hist_idx_signed as usize) {
return hist_row.get(col as usize).cloned().unwrap_or_default().text;
}
}
" ".to_string()
} else {
let live_r = (display_r - scroll_offset) as u16;
let screen = self.parser.screen();
if let Some(cell) = screen.cell(live_r, col) {
let s = cell.contents();
if s.is_empty() {
" ".to_string()
} else {
s.to_string()
}
} else {
" ".to_string()
}
}
}
pub fn to_terminal(&self, id: WidgetId, scrollbar: Option<TerminalScrollbar>) -> Terminal {
Terminal {
id,
cells: self.build_rows(true),
scrollbar,
}
}
pub fn scrollbar_state(&self, scrollbar_width: Option<u16>) -> TerminalScrollbar {
let total = self.history.len() + self.rows as usize;
TerminalScrollbar {
total_lines: total,
visible_lines: self.rows as usize,
scroll_offset: self.scroll_offset,
inverted: true,
width: scrollbar_width,
}
}
fn process_with_capture(&mut self, data: &[u8]) {
let max_nl = self.rows as usize;
let mut start = 0;
let mut nl_count = 0;
for (i, &b) in data.iter().enumerate() {
if b == b'\n' {
nl_count += 1;
if nl_count >= max_nl {
let chunk = &data[start..=i];
if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
self.parser.process(chunk);
}))
.is_err()
{
crate::diagnostics::emit(
"quadraui: vt100 parser panic in process_with_capture; \
dropping chunk, session kept alive",
);
}
self.capture_scrolled_rows(nl_count);
start = i + 1;
nl_count = 0;
}
}
}
if start < data.len() {
let chunk = &data[start..];
let remaining_nl = chunk.iter().filter(|&&b| b == b'\n').count();
if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
self.parser.process(chunk);
}))
.is_err()
{
crate::diagnostics::emit(
"quadraui: vt100 parser panic in process_with_capture; \
dropping chunk, session kept alive",
);
}
if remaining_nl > 0 {
self.capture_scrolled_rows(remaining_nl);
}
}
}
fn capture_scrolled_rows(&mut self, n_newlines: usize) {
if self.parser.screen().alternate_screen() {
return;
}
let to_capture = n_newlines.min(self.rows as usize);
self.parser.screen_mut().set_scrollback(to_capture);
{
let screen = self.parser.screen();
for r in 0..to_capture as u16 {
let row: Vec<HistCell> = (0..self.cols)
.map(|c| match screen.cell(r, c) {
Some(cell) => {
let raw = cell.contents();
HistCell {
text: if raw.is_empty() {
" ".to_string()
} else {
raw.to_string()
},
fg: cell.fgcolor(),
bg: cell.bgcolor(),
bold: cell.bold(),
italic: cell.italic(),
underline: cell.underline(),
dim: cell.dim(),
}
}
None => HistCell::default(),
})
.collect();
if self.history_capacity > 0 && self.history.len() >= self.history_capacity {
self.history.pop_front();
}
self.history.push_back(row);
}
}
self.parser.screen_mut().set_scrollback(0);
}
fn build_rows(&self, cursor_active: bool) -> Vec<Vec<TerminalCell>> {
let screen = self.parser.screen();
let (cursor_row, cursor_col) = screen.cursor_position();
let rows_count = self.rows as usize;
let cols_count = self.cols as usize;
let scroll_offset = self.scroll_offset;
let hist_len = self.history.len();
let cur_shape = self.cursor_shape();
let cur_blinking = self.cursor_blinking();
let sel_bounds = self.selection.as_ref().map(normalize_selection);
let is_selected = |display_r: usize, cu: u16| -> bool {
sel_bounds.is_some_and(|(r0, c0, r1, c1)| {
let dr = display_r as u16;
if r0 == r1 {
dr == r0 && cu >= c0 && cu <= c1
} else if dr == r0 {
cu >= c0
} else if dr == r1 {
cu <= c1
} else {
dr > r0 && dr < r1
}
})
};
(0..rows_count)
.map(|display_r| {
(0..cols_count)
.map(|c| {
let cu = c as u16;
let (text, fg, bg, bold, italic, underline, dim, is_cursor, selected) =
if display_r < scroll_offset {
let hist_idx_signed =
hist_len as isize - scroll_offset as isize + display_r as isize;
if hist_idx_signed >= 0 {
if let Some(hist_row) =
self.history.get(hist_idx_signed as usize)
{
let hc = hist_row.get(c).cloned().unwrap_or_default();
(
hc.text,
map_vt100_color(hc.fg, false),
map_vt100_color(hc.bg, true),
hc.bold,
hc.italic,
hc.underline,
hc.dim,
false,
is_selected(display_r, cu),
)
} else {
(
" ".to_string(),
(229, 229, 229),
(30, 30, 30),
false,
false,
false,
false,
false,
is_selected(display_r, cu),
)
}
} else {
(
" ".to_string(),
(229, 229, 229),
(30, 30, 30),
false,
false,
false,
false,
false,
false,
)
}
} else {
let live_r = (display_r - scroll_offset) as u16;
let (text, fg, bg, bold, italic, underline, dim) =
if let Some(cell) = screen.cell(live_r, cu) {
let contents = cell.contents();
let text = if contents.is_empty() {
" ".to_string()
} else {
contents.to_string()
};
(
text,
map_vt100_color(cell.fgcolor(), false),
map_vt100_color(cell.bgcolor(), true),
cell.bold(),
cell.italic(),
cell.underline(),
cell.dim(),
)
} else {
(
" ".to_string(),
(229, 229, 229),
(30, 30, 30),
false,
false,
false,
false,
)
};
let is_cursor = !self.exited
&& scroll_offset == 0
&& cursor_active
&& live_r == cursor_row
&& cu == cursor_col;
(
text,
fg,
bg,
bold,
italic,
underline,
dim,
is_cursor,
is_selected(display_r, cu),
)
};
TerminalCell {
text,
fg: Color::rgb(fg.0, fg.1, fg.2),
bg: Color::rgb(bg.0, bg.1, bg.2),
bold,
italic,
underline,
dim,
selected,
is_cursor,
is_find_match: false,
is_find_active: false,
cursor_shape: if is_cursor {
cur_shape
} else {
TerminalCursorShape::default()
},
cursor_blinking: is_cursor && cur_blinking,
}
})
.collect()
})
.collect()
}
}
pub struct TerminalManager {
pub sessions: Vec<TerminalSession>,
pub active: usize,
}
impl TerminalManager {
pub fn new() -> Self {
Self {
sessions: Vec::new(),
active: 0,
}
}
pub fn active_session(&self) -> Option<&TerminalSession> {
self.sessions.get(self.active)
}
pub fn active_session_mut(&mut self) -> Option<&mut TerminalSession> {
self.sessions.get_mut(self.active)
}
pub fn new_session(
&mut self,
cols: u16,
rows: u16,
shell: &str,
cwd: &Path,
history_capacity: usize,
) -> Result<usize, Box<dyn std::error::Error>> {
let session = TerminalSession::spawn(cols, rows, shell, cwd, history_capacity)?;
self.sessions.push(session);
self.active = self.sessions.len() - 1;
Ok(self.active)
}
pub fn close_active(&mut self) {
if self.sessions.is_empty() {
return;
}
self.sessions.remove(self.active);
if self.sessions.is_empty() {
self.active = 0;
} else {
self.active = self.active.min(self.sessions.len() - 1);
}
}
pub fn switch_to(&mut self, idx: usize) {
if !self.sessions.is_empty() {
self.active = idx.min(self.sessions.len() - 1);
}
}
pub fn poll_all(&mut self) -> bool {
self.sessions.iter_mut().fold(false, |acc, s| {
let changed = s.poll();
acc || changed
})
}
pub fn len(&self) -> usize {
self.sessions.len()
}
pub fn is_empty(&self) -> bool {
self.sessions.is_empty()
}
}
impl Default for TerminalManager {
fn default() -> Self {
Self::new()
}
}
pub fn default_shell() -> String {
#[cfg(target_os = "windows")]
{
default_shell_for(ShellPlatform::Windows, None)
}
#[cfg(not(target_os = "windows"))]
{
default_shell_for(ShellPlatform::Unix, std::env::var("SHELL").ok())
}
}
fn default_shell_for(platform: ShellPlatform, env_shell: Option<String>) -> String {
match platform {
ShellPlatform::Windows => "powershell.exe".to_string(),
ShellPlatform::Unix => env_shell.unwrap_or_else(|| "/bin/bash".to_string()),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ShellPlatform {
#[cfg_attr(all(not(test), target_os = "windows"), allow(dead_code))]
Unix,
#[cfg_attr(not(any(test, target_os = "windows")), allow(dead_code))]
Windows,
}
fn shell_command_for(platform: ShellPlatform, env_shell: Option<String>) -> (String, String) {
match platform {
ShellPlatform::Windows => (
env_shell.unwrap_or_else(|| "cmd".to_string()),
"/C".to_string(),
),
ShellPlatform::Unix => (
env_shell.unwrap_or_else(|| "sh".to_string()),
"-c".to_string(),
),
}
}
pub fn shell_command() -> (String, String) {
#[cfg(target_os = "windows")]
{
shell_command_for(ShellPlatform::Windows, std::env::var("COMSPEC").ok())
}
#[cfg(not(target_os = "windows"))]
{
shell_command_for(ShellPlatform::Unix, std::env::var("SHELL").ok())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn vt100_alt_screen_resize_cursor_clamp_no_panic() {
let mut p = vt100::Parser::new(40, 80, 0);
p.process(b"\x1b[36;1H");
let (row, col) = p.screen().cursor_position();
assert_eq!(row, 35, "cursor must be at row 35 before alt-screen enter");
assert_eq!(col, 0);
p.process(b"\x1b[?1049h");
assert!(p.screen().alternate_screen(), "must be on alternate screen");
p.screen_mut().set_size(20, 80);
p.process(b"\x1b[?1049l");
assert!(
!p.screen().alternate_screen(),
"must have left alternate screen"
);
p.process(b"X");
let (restored_row, _) = p.screen().cursor_position();
assert!(
restored_row < 20,
"restored cursor row {restored_row} must be < 20 (new grid height)"
);
}
#[test]
#[cfg(unix)]
fn session_alt_screen_resize_cursor_clamp_no_panic() {
let cwd = std::env::temp_dir();
let mut sess =
TerminalSession::spawn(80, 40, "/bin/sh", &cwd, 1000).expect("failed to spawn /bin/sh");
sess.send_str("printf '\\033[36;1H\\033[?1049h'\n");
assert!(
poll_until(&mut sess, 5000, |s| s.on_alt_screen()),
"child should have entered the alternate screen"
);
sess.resize(80, 20);
assert_eq!(sess.rows(), 20, "resize should have taken effect");
sess.send_str("printf '\\033[?1049lX'\n");
assert!(
poll_until(&mut sess, 5000, |s| !s.on_alt_screen()),
"child should have left the alternate screen"
);
sess.send_str("echo still-alive\n");
assert!(
poll_until(&mut sess, 5000, |s| s.screen_text().contains("still-alive")),
"session must still process input after the alt-screen resize round-trip"
);
assert!(!sess.is_exited(), "session must not have died");
sess.send_str("exit\n");
}
#[test]
fn vt100_wide_char_column_boundary_no_panic() {
let wide = "日";
let narrow = "x";
{
let mut p = vt100::Parser::new(3, 10, 0);
let row: String = wide.repeat(5); p.process(row.as_bytes());
p.process(b"\r\n");
p.process(row.as_bytes());
let _ = p.screen().cell(0, 0);
}
{
let mut p = vt100::Parser::new(3, 10, 0);
let four_wide: String = wide.repeat(4); p.process(four_wide.as_bytes());
p.process(narrow.as_bytes()); p.process(wide.as_bytes()); let _ = p.screen().cell(0, 0);
}
{
let mut p = vt100::Parser::new(24, 80, 0);
let full_row: String = wide.repeat(40);
for _ in 0..50 {
p.process(full_row.as_bytes());
p.process(b"\r\n");
}
let boundary_line = format!("{}{}{}", wide.repeat(39), narrow, wide);
p.process(boundary_line.as_bytes()); let _ = p.screen().cell(0, 0);
}
}
#[test]
fn clamp_vt100_size_floors_degenerate_dimensions() {
assert_eq!(
clamp_vt100_size(0, 0),
(MIN_VT100_COLS, MIN_VT100_ROWS),
"a zero-sized pane must be floored on both axes"
);
assert_eq!(clamp_vt100_size(80, 1), (80, MIN_VT100_ROWS));
assert_eq!(clamp_vt100_size(1, 24), (MIN_VT100_COLS, 24));
assert_eq!(
clamp_vt100_size(MIN_VT100_COLS, MIN_VT100_ROWS),
(MIN_VT100_COLS, MIN_VT100_ROWS)
);
assert_eq!(clamp_vt100_size(200, 60), (200, 60));
}
#[test]
fn vt100_survives_at_the_clamped_floor() {
let (cols, rows) = clamp_vt100_size(0, 0);
let mut p = vt100::Parser::new(rows, cols, 0);
p.process("あéa\r\n\u{1b}[31mx".as_bytes());
p.screen_mut().set_size(rows, cols);
p.process("あ".as_bytes());
assert_eq!(
p.screen().size(),
(MIN_VT100_ROWS, MIN_VT100_COLS),
"the floored grid must survive construction, resize and wide-char input"
);
}
#[test]
#[cfg(unix)]
fn spawn_with_a_single_row_does_not_panic() {
let cwd = std::env::current_dir().expect("cwd");
let mut sess = TerminalSession::spawn(80, 1, "/bin/sh", &cwd, 1000)
.expect("spawn must succeed even with rows clamped internally");
assert_eq!(sess.rows(), MIN_VT100_ROWS);
sess.send_str("exit\n");
}
#[test]
#[cfg(unix)]
fn spawn_with_a_single_column_does_not_panic() {
let cwd = std::env::current_dir().expect("cwd");
let mut sess = TerminalSession::spawn(1, 24, "/bin/sh", &cwd, 1000)
.expect("spawn must succeed even with cols clamped internally");
assert_eq!(sess.cols(), MIN_VT100_COLS);
sess.send_str("exit\n");
}
#[test]
#[cfg(unix)]
fn resize_to_a_single_row_does_not_panic() {
let cwd = std::env::current_dir().expect("cwd");
let mut sess = TerminalSession::spawn(80, 24, "/bin/sh", &cwd, 1000).expect("spawn failed");
sess.resize(80, 1);
assert_eq!(sess.rows(), MIN_VT100_ROWS);
sess.send_str("exit\n");
}
#[test]
#[cfg(unix)]
fn resize_to_a_single_column_does_not_panic() {
let cwd = std::env::current_dir().expect("cwd");
let mut sess = TerminalSession::spawn(80, 24, "/bin/sh", &cwd, 1000).expect("spawn failed");
sess.resize(1, 24);
assert_eq!(sess.cols(), MIN_VT100_COLS);
sess.send_str("exit\n");
}
fn screen_lines(p: &vt100::Parser) -> Vec<String> {
let (rows, _cols) = p.screen().size();
(0..rows)
.map(|r| {
p.screen()
.rows(0, p.screen().size().1)
.nth(r as usize)
.unwrap_or_default()
.trim_end()
.to_string()
})
.collect()
}
#[test]
fn resize_height_only_preserves_content() {
let mut p = vt100::Parser::new(24, 80, 1000);
p.process(b"hello world\r\nsecond line");
p.screen_mut().set_size(40, 80); let lines = screen_lines(&p);
assert!(lines.iter().any(|l| l == "hello world"));
assert!(lines.iter().any(|l| l == "second line"));
}
#[test]
fn resize_no_op_when_width_unchanged() {
let mut p = vt100::Parser::new(24, 80, 1000);
p.process(b"hello world\r\nsecond line");
reflow_screen(&mut p, 40, 80); let lines = screen_lines(&p);
assert!(lines.iter().any(|l| l == "hello world"));
assert!(lines.iter().any(|l| l == "second line"));
}
#[test]
fn resize_shrink_wraps_without_losing_tail() {
let mut p = vt100::Parser::new(24, 100, 1000);
let long = "0123456789ABCDEFGHIJ0123456789ABCDEFGHIJ0123456789ABCDEFGHIJ";
p.process(long.as_bytes());
reflow_screen(&mut p, 24, 20);
let joined: String = screen_lines(&p).join("");
assert!(
joined.contains("GHIJ0123456789ABCDEFGHIJ"),
"tail of long line lost on shrink: {joined:?}"
);
reflow_screen(&mut p, 24, 100);
assert!(
screen_lines(&p).iter().any(|l| l == long),
"long line not restored on expand: {:?}",
screen_lines(&p)
);
}
#[test]
fn resize_preserves_wide_chars() {
let mut p = vt100::Parser::new(24, 100, 1000);
let line = "日本語テスト-CJK-日本語テスト-CJK-日本語テスト";
p.process(line.as_bytes());
reflow_screen(&mut p, 24, 30);
reflow_screen(&mut p, 24, 100);
assert!(
screen_lines(&p).iter().any(|l| l == line),
"wide-char line not restored across resize: {:?}",
screen_lines(&p)
);
}
#[test]
fn reflow_shrink_does_not_orphan_a_wide_glyph_at_the_boundary() {
let mut p = vt100::Parser::new(2, 65, 0);
let mut before = vec![b'\t'];
before.extend_from_slice(&[b' '; 24]);
before.extend_from_slice("\u{3000}".as_bytes());
p.process(&before);
assert!(
p.screen().cell(0, 32).is_some_and(vt100::Cell::is_wide),
"fixture drifted: the wide glyph must sit on the truncation boundary"
);
reflow_screen(&mut p, 2, 33);
let (_, cols) = p.screen().size();
assert_eq!(cols, 33);
for row in 0..2 {
assert!(
!p.screen()
.cell(row, cols - 1)
.is_some_and(vt100::Cell::is_wide),
"row {row} still ends in an orphaned wide first half"
);
}
p.process(b"\x1b[1;33H ");
}
#[test]
fn reflow_shrink_does_not_orphan_a_wide_glyph_on_the_alternate_screen() {
let mut p = vt100::Parser::new(2, 65, 0);
p.process(b"\x1b[?1049h");
let mut before = vec![b'\t'];
before.extend_from_slice(&[b' '; 24]);
before.extend_from_slice("\u{3000}".as_bytes());
p.process(&before);
assert!(p.screen().alternate_screen());
reflow_screen(&mut p, 2, 33);
let (_, cols) = p.screen().size();
assert!(
!p.screen()
.cell(0, cols - 1)
.is_some_and(vt100::Cell::is_wide),
"alternate screen still ends in an orphaned wide first half"
);
p.process(b"\x1b[1;33H ");
}
#[test]
fn reflow_shrink_scrub_restores_the_cursor() {
let mut p = vt100::Parser::new(4, 65, 0);
let mut before = vec![b'\t'];
before.extend_from_slice(&[b' '; 24]);
before.extend_from_slice("\u{3000}".as_bytes());
p.process(&before);
p.process(b"\x1b[3;5H");
assert_eq!(p.screen().cursor_position(), (2, 4));
reflow_screen(&mut p, 4, 33);
assert_eq!(
p.screen().cursor_position(),
(2, 4),
"resize must not move the cursor"
);
}
#[test]
fn resize_shrink_then_expand_preserves_content() {
let mut p = vt100::Parser::new(24, 100, 1000);
for i in 1..=6 {
let line = format!("ROW{i:02}-abcdefghijklmnopqrstuvwxyz-0123456789-END\r\n");
p.process(line.as_bytes());
}
for i in 1..=6 {
let needle = format!("ROW{i:02}-abcdefghijklmnopqrstuvwxyz-0123456789-END");
assert!(
screen_lines(&p).iter().any(|l| l == &needle),
"line {i} missing before resize"
);
}
reflow_screen(&mut p, 20, 40);
reflow_screen(&mut p, 24, 100);
for i in 1..=6 {
let needle = format!("ROW{i:02}-abcdefghijklmnopqrstuvwxyz-0123456789-END");
assert!(
screen_lines(&p).iter().any(|l| l == &needle),
"line {i} lost across shrink→expand round-trip: {:?}",
screen_lines(&p)
);
}
}
#[test]
fn resize_multistep_drag_leaves_no_ghost_rows() {
let mut p = vt100::Parser::new(14, 40, 1000);
for i in 1..=4 {
p.process(format!("unique_line_{i:02}_content\r\n").as_bytes());
}
p.process(b"prompt$ "); let before = screen_lines(&p);
for (rows, cols) in [(11, 30), (10, 22), (10, 20), (12, 26), (13, 34), (14, 40)] {
reflow_screen(&mut p, rows, cols);
}
let after = screen_lines(&p);
assert_eq!(
before, after,
"grid changed across a shrink→expand drag round-trip (ghost rows)"
);
for i in 1..=4 {
let needle = format!("unique_line_{i:02}_content");
let count = after.iter().filter(|l| l.contains(&needle)).count();
assert_eq!(count, 1, "line {i} duplicated across resize: {after:?}");
}
}
#[test]
fn resize_drains_queued_output_before_reflow() {
use std::thread::sleep;
use std::time::{Duration, Instant};
let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("/"));
let mut sess = match TerminalSession::spawn(80, 24, "/bin/bash", &cwd, 1000) {
Ok(s) => s,
Err(_) => return,
};
fn wait_for(sess: &mut TerminalSession, needle: &str, ms: u64) -> bool {
let deadline = Instant::now() + Duration::from_millis(ms);
loop {
sess.poll();
if sess.screen_text().contains(needle) {
return true;
}
if Instant::now() >= deadline {
return false;
}
sleep(Duration::from_millis(5));
}
}
sess.write_input(b"PS1='RDYMARK$ '\n");
if !wait_for(&mut sess, "RDYMARK$", 4000) {
return; }
sess.write_input(b"echo SENTINEL_QZX_9137\n");
sleep(Duration::from_millis(400));
let queued = !sess.screen_text().contains("SENTINEL_QZX_9137");
sess.resize(50, 18);
if queued {
assert!(
sess.screen_text().contains("SENTINEL_QZX_9137"),
"resize() must drain queued PTY output before reflowing so \
in-flight redraws land on the width they were computed for \
(quadraui#437); sentinel was still queued yet resize() did not \
process it.\nscreen:\n{}",
sess.screen_text()
);
}
sess.write_input(b"exit\n");
}
#[test]
fn resize_noop_same_size_is_cheap() {
let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("/"));
let mut sess = match TerminalSession::spawn(80, 24, "/bin/bash", &cwd, 100) {
Ok(s) => s,
Err(_) => return,
};
assert_eq!((sess.cols(), sess.rows()), (80, 24));
sess.resize(80, 24); assert_eq!((sess.cols(), sess.rows()), (80, 24));
sess.resize(100, 30);
assert_eq!((sess.cols(), sess.rows()), (100, 30));
}
#[test]
fn resize_rapid_multistep_drag_leaves_no_ghosts() {
use std::thread::sleep;
use std::time::{Duration, Instant};
let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("/"));
let mut sess = match TerminalSession::spawn(80, 24, "/bin/bash", &cwd, 2000) {
Ok(s) => s,
Err(_) => return, };
fn wait_for(sess: &mut TerminalSession, needle: &str, ms: u64) -> bool {
let deadline = Instant::now() + Duration::from_millis(ms);
loop {
sess.poll();
if sess.screen_text().contains(needle) {
return true;
}
if Instant::now() >= deadline {
return false;
}
sleep(Duration::from_millis(5));
}
}
sess.write_input(b"PS1='GHOSTMK> '\n");
if !wait_for(&mut sess, "GHOSTMK>", 4000) {
return; }
let needle = "GHOST_5571";
sess.write_input(b"printf 'GHO%s\\n' ST_5571\n");
if !wait_for(&mut sess, needle, 4000) {
return;
}
for (cols, rows) in [(60, 20), (48, 16), (40, 14), (52, 18), (68, 22), (80, 24)] {
sess.resize(cols, rows);
}
for _ in 0..40 {
sess.poll();
sleep(Duration::from_millis(10));
}
let screen = sess.screen_text();
let count = screen.matches(needle).count();
assert!(
count <= 1,
"rapid multi-step resize drag duplicated a unique line \
({count} copies) — the #437 resize ghost. screen:\n{screen}"
);
assert_eq!((sess.cols(), sess.rows()), (80, 24));
sess.write_input(b"exit\n");
}
#[test]
fn settle_waits_past_idle_gap_for_a_delayed_redraw() {
use std::sync::mpsc::channel;
use std::time::Duration;
let child_delay = Duration::from_millis(20);
assert!(
RESIZE_SETTLE_IDLE < child_delay,
"delay must exceed the idle gap"
);
assert!(
child_delay * 3 < RESIZE_SETTLE_FIRST,
"delay needs headroom below the first-byte window"
);
let (tx, rx) = channel::<Vec<u8>>();
let sender = std::thread::spawn(move || {
std::thread::sleep(child_delay);
let _ = tx.send(b"post-winch-redraw".to_vec());
std::thread::sleep(Duration::from_millis(40));
});
let chunks = collect_post_resize_output(&rx);
sender.join().unwrap();
assert_eq!(
chunks.concat(),
b"post-winch-redraw",
"settle must wait past the initial idle gap for a child that reacts \
to SIGWINCH slowly — the blocking #2 ghost bug"
);
}
#[test]
fn settle_returns_bounded_when_child_stays_silent() {
use std::sync::mpsc::channel;
use std::time::{Duration, Instant};
let (_tx, rx) = channel::<Vec<u8>>();
let start = Instant::now();
let chunks = collect_post_resize_output(&rx);
let elapsed = start.elapsed();
assert!(chunks.is_empty(), "silent child must yield no chunks");
assert!(
elapsed >= RESIZE_SETTLE_FIRST.saturating_sub(Duration::from_millis(10)),
"settle must actually wait ~RESIZE_SETTLE_FIRST for a first byte \
(waited {elapsed:?})"
);
assert!(
elapsed <= RESIZE_SETTLE_MAX + Duration::from_millis(80),
"settle must stay bounded and not hang (waited {elapsed:?})"
);
}
#[test]
fn settle_drains_a_multi_chunk_redraw_then_stops() {
use std::sync::mpsc::channel;
use std::time::Duration;
let (tx, rx) = channel::<Vec<u8>>();
let sender = std::thread::spawn(move || {
let _ = tx.send(b"aaa".to_vec());
let _ = tx.send(b"bbb".to_vec());
let _ = tx.send(b"ccc".to_vec());
std::thread::sleep(Duration::from_millis(40));
});
let chunks = collect_post_resize_output(&rx);
sender.join().unwrap();
assert_eq!(
chunks.concat(),
b"aaabbbccc",
"settle must drain the whole multi-chunk redraw in arrival order"
);
}
#[test]
fn xterm_256_system_colors() {
assert_eq!(xterm_256_color(0), (0, 0, 0));
assert_eq!(xterm_256_color(15), (255, 255, 255));
}
#[test]
fn xterm_256_cube_first() {
assert_eq!(xterm_256_color(16), (0, 0, 0));
}
#[test]
fn xterm_256_cube_pure_red() {
let (r, g, b) = xterm_256_color(196);
assert_eq!(r, 55 + 5 * 40); assert_eq!(g, 0);
assert_eq!(b, 0);
}
#[test]
fn xterm_256_greyscale() {
assert_eq!(xterm_256_color(232), (8, 8, 8));
assert_eq!(xterm_256_color(255), (238, 238, 238));
}
#[test]
fn map_vt100_default_colors() {
let (r, g, b) = map_vt100_color(vt100::Color::Default, false);
assert_eq!((r, g, b), (229, 229, 229)); let (r, g, b) = map_vt100_color(vt100::Color::Default, true);
assert_eq!((r, g, b), (30, 30, 30)); }
#[test]
fn map_vt100_rgb() {
let (r, g, b) = map_vt100_color(vt100::Color::Rgb(1, 2, 3), false);
assert_eq!((r, g, b), (1, 2, 3));
}
#[test]
fn normalize_selection_already_ordered() {
let sel = TerminalSelection {
start_row: 1,
start_col: 5,
end_row: 3,
end_col: 10,
};
assert_eq!(normalize_selection(&sel), (1, 5, 3, 10));
}
#[test]
fn normalize_selection_reversed() {
let sel = TerminalSelection {
start_row: 3,
start_col: 10,
end_row: 1,
end_col: 5,
};
assert_eq!(normalize_selection(&sel), (1, 5, 3, 10));
}
#[test]
fn terminal_manager_new_is_empty() {
let mgr = TerminalManager::new();
assert!(mgr.is_empty());
assert_eq!(mgr.len(), 0);
assert!(mgr.active_session().is_none());
}
#[test]
fn terminal_manager_close_active_on_empty_is_noop() {
let mut mgr = TerminalManager::new();
mgr.close_active(); assert!(mgr.is_empty());
}
#[test]
fn terminal_manager_switch_to_empty_is_noop() {
let mut mgr = TerminalManager::new();
mgr.switch_to(5); assert_eq!(mgr.active, 0);
}
#[test]
fn default_shell_is_nonempty() {
let shell = default_shell();
assert!(!shell.is_empty());
}
#[test]
fn default_shell_for_unix_honours_shell_env_var() {
let shell = default_shell_for(ShellPlatform::Unix, Some("/bin/zsh".to_string()));
assert_eq!(shell, "/bin/zsh");
}
#[test]
fn default_shell_for_unix_falls_back_when_shell_env_var_unset() {
let shell = default_shell_for(ShellPlatform::Unix, None);
assert_eq!(shell, "/bin/bash");
}
#[test]
fn default_shell_for_windows_ignores_a_unix_style_shell_value() {
let shell = default_shell_for(ShellPlatform::Windows, Some("/bin/bash".to_string()));
assert_eq!(shell, "powershell.exe");
}
#[test]
fn default_shell_for_windows_is_powershell_with_no_env_shell() {
let shell = default_shell_for(ShellPlatform::Windows, None);
assert_eq!(shell, "powershell.exe");
}
#[test]
fn shell_command_for_unix_honours_shell_env_var() {
let (shell, flag) = shell_command_for(ShellPlatform::Unix, Some("/bin/zsh".to_string()));
assert_eq!(shell, "/bin/zsh");
assert_eq!(flag, "-c");
}
#[test]
fn shell_command_for_unix_falls_back_when_shell_env_var_unset() {
let (shell, flag) = shell_command_for(ShellPlatform::Unix, None);
assert_eq!(shell, "sh");
assert_eq!(flag, "-c");
}
#[test]
fn shell_command_for_windows_honours_comspec_env_var() {
let (shell, flag) = shell_command_for(
ShellPlatform::Windows,
Some(r"C:\Windows\System32\cmd.exe".to_string()),
);
assert_eq!(shell, r"C:\Windows\System32\cmd.exe");
assert_eq!(flag, "/C");
}
#[test]
fn shell_command_for_windows_falls_back_when_comspec_env_var_unset() {
let (shell, flag) = shell_command_for(ShellPlatform::Windows, None);
assert_eq!(shell, "cmd");
assert_eq!(flag, "/C");
}
#[test]
fn shell_command_returns_nonempty_pair() {
let (shell, flag) = shell_command();
assert!(!shell.is_empty());
assert!(!flag.is_empty());
}
#[test]
fn map_vt100_idx_196_is_pure_red() {
let (r, g, b) = map_vt100_color(vt100::Color::Idx(196), false);
assert_eq!((r, g, b), (255, 0, 0));
}
#[test]
#[cfg(unix)]
fn cursor_hidden_after_exit() {
let cwd = std::env::temp_dir();
let mut sess = TerminalSession::spawn(80, 24, "/bin/sh", &cwd, 1000).expect("spawn failed");
assert!(
sess.cursor_visible(),
"cursor should be visible before exit"
);
sess.send_str("exit 0\n");
let exited = poll_until(&mut sess, 5000, |s| s.exited);
assert!(exited, "shell did not exit within timeout");
assert!(
!sess.cursor_visible(),
"cursor should be hidden after shell exits"
);
}
#[test]
#[cfg(unix)]
fn cursor_hidden_when_scrolled() {
let cwd = std::env::temp_dir();
let mut sess = TerminalSession::spawn(80, 24, "/bin/sh", &cwd, 1000).expect("spawn failed");
for _ in 0..30 {
sess.send_str("echo line\n");
}
let _ = poll_until(&mut sess, 5000, |s| s.history_len() > 0);
assert!(
sess.cursor_visible(),
"cursor should be visible at live view"
);
sess.scroll_up(5);
assert!(
!sess.cursor_visible(),
"cursor should be hidden when scrolled into history"
);
sess.scroll_reset();
assert!(
sess.cursor_visible(),
"cursor should be visible again after scroll_reset"
);
sess.send_str("exit\n");
}
#[test]
#[cfg(unix)]
fn paste_resets_scroll_to_live_view() {
let cwd = std::env::temp_dir();
let mut sess = TerminalSession::spawn(80, 24, "/bin/sh", &cwd, 1000).expect("spawn failed");
for _ in 0..30 {
sess.send_str("echo line\n");
}
let _ = poll_until(&mut sess, 5000, |s| s.history_len() > 0);
sess.scroll_up(5);
assert!(
!sess.cursor_visible(),
"precondition: scrolled into history should hide the cursor"
);
sess.paste("hello");
assert!(
sess.cursor_visible(),
"paste should reset scroll back to the live view"
);
sess.send_str("exit\n");
}
#[test]
#[cfg(unix)]
fn bracketed_paste_enabled_tracks_mode_2004() {
let cwd = std::env::temp_dir();
let mut sess =
TerminalSession::spawn(80, 24, "/bin/sh", &cwd, 1000).expect("failed to spawn /bin/sh");
assert!(!sess.bracketed_paste_enabled());
sess.send_str("printf '\\033[?2004h'\n");
assert!(
poll_until(&mut sess, 5000, |s| s.bracketed_paste_enabled()),
"bracketed paste should be enabled after the child emits ESC[?2004h"
);
sess.send_str("printf '\\033[?2004l'\n");
assert!(
poll_until(&mut sess, 5000, |s| !s.bracketed_paste_enabled()),
"bracketed paste should be disabled after the child emits ESC[?2004l"
);
sess.send_str("exit\n");
}
#[test]
#[cfg(unix)]
fn title_tracks_osc_0_and_take_title_changed_is_one_shot() {
let cwd = std::env::temp_dir();
let mut sess =
TerminalSession::spawn(80, 24, "/bin/sh", &cwd, 1000).expect("failed to spawn /bin/sh");
assert_eq!(sess.title(), None);
assert!(!sess.take_title_changed());
sess.send_str("printf '\\033]0;hello-quadraui\\007'\n");
assert!(
poll_until(&mut sess, 5000, |s| s.title() == Some("hello-quadraui")),
"title() should track the child's OSC 0 sequence"
);
assert!(
sess.take_title_changed(),
"take_title_changed should report the change once"
);
assert!(
!sess.take_title_changed(),
"take_title_changed should not re-fire until the title changes again"
);
sess.send_str("printf '\\033]2;second-title\\007'\n");
assert!(
poll_until(&mut sess, 5000, |s| s.title() == Some("second-title")),
"title() should track a later OSC 2 sequence too"
);
assert!(sess.take_title_changed());
sess.send_str("exit\n");
}
#[cfg(any(unix, windows))]
fn poll_until(
sess: &mut TerminalSession,
max_ms: u64,
predicate: impl Fn(&TerminalSession) -> bool,
) -> bool {
let start = std::time::Instant::now();
let limit = std::time::Duration::from_millis(max_ms);
while start.elapsed() < limit {
sess.poll();
if predicate(sess) {
return true;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
false
}
#[test]
#[cfg(unix)]
fn session_send_str_screen_text_and_exit_code() {
let cwd = std::env::temp_dir();
let mut sess =
TerminalSession::spawn(80, 24, "/bin/sh", &cwd, 1000).expect("failed to spawn /bin/sh");
sess.send_str("echo MARK_$((1+1))_END\nexit 0\n");
let found = poll_until(&mut sess, 5000, |s| {
s.full_text().contains("MARK_2_END") || s.exited
});
assert!(
found,
"marker 'MARK_2_END' never appeared in terminal output"
);
let exited = poll_until(&mut sess, 3000, |s| s.exited);
assert!(exited, "process did not exit within timeout");
assert_eq!(sess.exit_code(), Some(0), "expected exit code 0");
let text = sess.full_text();
assert!(
text.contains("MARK_2_END"),
"full_text() does not contain 'MARK_2_END'; got: {text:?}"
);
}
#[cfg(target_os = "windows")]
fn windows_conpty_spawn_echo_exit(
shell: &str,
args: &[&str],
send_line: &str,
expected_marker: &str,
timeout_ms: u64,
) {
let cwd = std::env::temp_dir();
let mut sess = TerminalSession::spawn_with_args(80, 24, shell, args, &cwd, 1000)
.unwrap_or_else(|e| panic!("failed to spawn {shell}: {e}"));
let ready = poll_until(&mut sess, timeout_ms, |s| {
s.full_text().contains('>') || s.exited
});
assert!(
ready,
"{shell} did not show a ready prompt within {timeout_ms}ms"
);
sess.send_str(send_line);
let found = poll_until(&mut sess, timeout_ms, |s| {
s.full_text().contains(expected_marker) || s.exited
});
assert!(
found,
"marker {expected_marker:?} never appeared in {shell}'s terminal output within {timeout_ms}ms"
);
sess.send_str("exit 0\r\n");
let exited = poll_until(&mut sess, timeout_ms, |s| s.is_exited());
assert!(exited, "{shell} did not exit within {timeout_ms}ms");
assert_eq!(
sess.exit_code(),
Some(0),
"expected exit code 0 from {shell}"
);
let text = sess.full_text();
assert!(
text.contains(expected_marker),
"full_text() does not contain {expected_marker:?}; got: {text:?}"
);
}
#[test]
#[cfg(target_os = "windows")]
fn windows_cmd_session_send_str_screen_text_and_exit_code() {
let arch = std::env::var("PROCESSOR_ARCHITECTURE").unwrap_or_else(|_| "AMD64".to_string());
let marker = format!("MARK_{arch}_END");
windows_conpty_spawn_echo_exit(
"cmd.exe",
&[],
"echo MARK_%PROCESSOR_ARCHITECTURE%_END\r\n",
&marker,
5_000,
);
}
#[test]
#[cfg(target_os = "windows")]
fn windows_powershell_session_send_str_screen_text_and_exit_code() {
let arch = std::env::var("PROCESSOR_ARCHITECTURE").unwrap_or_else(|_| "AMD64".to_string());
let marker = format!("MARK_{arch}_END");
windows_conpty_spawn_echo_exit(
"powershell.exe",
&["-NoLogo", "-NoProfile"],
"echo \"MARK_$($env:PROCESSOR_ARCHITECTURE)_END\"\r\n",
&marker,
30_000,
);
}
#[test]
#[cfg(target_os = "windows")]
fn windows_resize_before_first_output_still_answers_startup_handshake() {
let cwd = std::env::temp_dir();
let mut sess = TerminalSession::spawn_with_args(80, 24, "cmd.exe", &[], &cwd, 1000)
.unwrap_or_else(|e| panic!("failed to spawn cmd.exe: {e}"));
sess.resize(78, 21);
let ready = poll_until(&mut sess, 30_000, |s| {
s.full_text().contains('>') || s.exited
});
assert!(
ready && !sess.is_exited(),
"cmd.exe never showed a prompt after an early resize: {:?}",
sess.full_text()
);
}
#[test]
#[cfg(unix)]
fn feed_for_bench_drives_process_with_capture_without_pty() {
let cwd = std::env::temp_dir();
let mut sess =
TerminalSession::spawn(20, 3, "/bin/sh", &cwd, 100).expect("failed to spawn /bin/sh");
sess.feed_for_bench(b"line1\nline2\nline3\nline4\nline5\n");
assert!(
sess.full_text().contains("line1"),
"feed_for_bench should have scrolled 'line1' into history; got: {:?}",
sess.full_text()
);
assert!(
sess.full_text().contains("line5"),
"feed_for_bench should have left 'line5' on the live screen; got: {:?}",
sess.full_text()
);
assert!(
sess.history_len() > 0,
"lines scrolled off a 3-row screen must land in scrollback history"
);
}
#[test]
#[cfg(unix)]
fn send_str_is_write_input_for_ascii() {
let cwd = std::env::temp_dir();
let mut sess =
TerminalSession::spawn(80, 10, "/bin/sh", &cwd, 100).expect("failed to spawn /bin/sh");
sess.send_str("echo ok\n");
let _ = poll_until(&mut sess, 2000, |s| {
s.full_text().contains("ok") || s.exited
});
assert!(sess.cols > 0 && sess.rows > 0);
sess.send_str("exit\n");
}
#[test]
fn encode_mouse_sgr_left_press_origin() {
let bytes = encode_mouse_sgr(
TerminalMouseKind::Press,
MouseButton::Left,
0,
0,
Modifiers::default(),
);
assert_eq!(bytes, b"\x1b[<0;1;1M");
}
#[test]
fn encode_mouse_sgr_left_release_uses_lowercase_m() {
let bytes = encode_mouse_sgr(
TerminalMouseKind::Release,
MouseButton::Left,
4,
9,
Modifiers::default(),
);
assert_eq!(bytes, b"\x1b[<0;5;10m");
}
#[test]
fn encode_mouse_sgr_right_press_with_shift() {
let bytes = encode_mouse_sgr(
TerminalMouseKind::Press,
MouseButton::Right,
11,
4,
Modifiers {
shift: true,
..Default::default()
},
);
assert_eq!(bytes, b"\x1b[<6;12;5M");
}
#[test]
fn encode_mouse_sgr_wheel_up() {
let bytes = encode_mouse_sgr(
TerminalMouseKind::WheelUp,
MouseButton::Left,
0,
0,
Modifiers::default(),
);
assert_eq!(bytes, b"\x1b[<64;1;1M");
}
#[test]
fn encode_mouse_sgr_wheel_down_with_ctrl() {
let bytes = encode_mouse_sgr(
TerminalMouseKind::WheelDown,
MouseButton::Left,
0,
0,
Modifiers {
ctrl: true,
..Default::default()
},
);
assert_eq!(bytes, b"\x1b[<81;1;1M");
}
#[test]
fn encode_mouse_sgr_wheel_up_encodes_nonzero_position() {
let bytes = encode_mouse_sgr(
TerminalMouseKind::WheelUp,
MouseButton::Left,
5,
10,
Modifiers::default(),
);
assert_eq!(bytes, b"\x1b[<64;6;11M");
}
#[test]
fn encode_mouse_sgr_motion_with_left() {
let bytes = encode_mouse_sgr(
TerminalMouseKind::Move,
MouseButton::Left,
7,
2,
Modifiers::default(),
);
assert_eq!(bytes, b"\x1b[<32;8;3M");
}
#[test]
fn encode_mouse_sgr_x1_press() {
let bytes = encode_mouse_sgr(
TerminalMouseKind::Press,
MouseButton::X1,
0,
0,
Modifiers::default(),
);
assert_eq!(bytes, b"\x1b[<128;1;1M");
}
#[test]
fn encode_mouse_sgr_x2_press() {
let bytes = encode_mouse_sgr(
TerminalMouseKind::Press,
MouseButton::X2,
0,
0,
Modifiers::default(),
);
assert_eq!(bytes, b"\x1b[<129;1;1M");
}
#[test]
fn encode_mouse_sgr_middle_press() {
let bytes = encode_mouse_sgr(
TerminalMouseKind::Press,
MouseButton::Middle,
4,
2,
Modifiers::default(),
);
assert_eq!(bytes, b"\x1b[<1;5;3M");
}
#[test]
fn encode_mouse_sgr_middle_release() {
let bytes = encode_mouse_sgr(
TerminalMouseKind::Release,
MouseButton::Middle,
4,
2,
Modifiers::default(),
);
assert_eq!(bytes, b"\x1b[<1;5;3m");
}
#[test]
fn encode_paste_wraps_when_bracketed() {
let bytes = encode_paste("hello", true);
assert_eq!(bytes, b"\x1b[200~hello\x1b[201~");
}
#[test]
fn encode_paste_is_raw_when_not_bracketed() {
let bytes = encode_paste("hello", false);
assert_eq!(bytes, b"hello");
}
#[test]
fn encode_paste_wraps_multiline_text_once() {
let bytes = encode_paste("echo one\necho two\n", true);
assert_eq!(bytes, b"\x1b[200~echo one\necho two\n\x1b[201~");
}
#[test]
fn encode_paste_preserves_utf8() {
let bytes = encode_paste("日本語 🎉", true);
let mut expected = b"\x1b[200~".to_vec();
expected.extend_from_slice("日本語 🎉".as_bytes());
expected.extend_from_slice(b"\x1b[201~");
assert_eq!(bytes, expected);
}
#[test]
fn encode_paste_wraps_empty_text() {
let bytes = encode_paste("", true);
assert_eq!(bytes, b"\x1b[200~\x1b[201~");
}
#[test]
fn ctrl_c_is_etx() {
let bytes = key_to_pty_bytes(
Key::Char('c'),
Modifiers {
ctrl: true,
..Default::default()
},
false,
);
assert_eq!(bytes, Some(vec![0x03])); }
#[test]
fn ctrl_d_is_eot() {
let bytes = key_to_pty_bytes(
Key::Char('d'),
Modifiers {
ctrl: true,
..Default::default()
},
false,
);
assert_eq!(bytes, Some(vec![0x04])); }
#[test]
fn printable_char_passes_through() {
let bytes = key_to_pty_bytes(Key::Char('a'), Modifiers::default(), false).unwrap();
assert_eq!(bytes, b"a");
}
#[test]
fn enter_is_cr() {
let bytes =
key_to_pty_bytes(Key::Named(NamedKey::Enter), Modifiers::default(), false).unwrap();
assert_eq!(bytes, b"\r");
}
#[test]
fn up_arrow_plain() {
let bytes =
key_to_pty_bytes(Key::Named(NamedKey::Up), Modifiers::default(), false).unwrap();
assert_eq!(bytes, b"\x1b[A");
}
#[test]
fn up_arrow_ctrl() {
let bytes = key_to_pty_bytes(
Key::Named(NamedKey::Up),
Modifiers {
ctrl: true,
..Default::default()
},
false,
)
.unwrap();
assert_eq!(bytes, b"\x1b[1;5A");
}
#[test]
fn f1_plain() {
let bytes =
key_to_pty_bytes(Key::Named(NamedKey::F(1)), Modifiers::default(), false).unwrap();
assert_eq!(bytes, b"\x1bOP");
}
#[test]
fn f2_plain() {
let bytes =
key_to_pty_bytes(Key::Named(NamedKey::F(2)), Modifiers::default(), false).unwrap();
assert_eq!(bytes, b"\x1bOQ");
}
#[test]
fn f3_plain() {
let bytes =
key_to_pty_bytes(Key::Named(NamedKey::F(3)), Modifiers::default(), false).unwrap();
assert_eq!(bytes, b"\x1bOR");
}
#[test]
fn f4_plain() {
let bytes =
key_to_pty_bytes(Key::Named(NamedKey::F(4)), Modifiers::default(), false).unwrap();
assert_eq!(bytes, b"\x1bOS");
}
#[test]
fn f1_ctrl_uses_csi() {
let bytes = key_to_pty_bytes(
Key::Named(NamedKey::F(1)),
Modifiers {
ctrl: true,
..Default::default()
},
false,
)
.unwrap();
assert_eq!(bytes, b"\x1b[1;5P");
}
#[test]
fn delete_plain() {
let bytes =
key_to_pty_bytes(Key::Named(NamedKey::Delete), Modifiers::default(), false).unwrap();
assert_eq!(bytes, b"\x1b[3~");
}
#[test]
fn page_up_plain() {
let bytes =
key_to_pty_bytes(Key::Named(NamedKey::PageUp), Modifiers::default(), false).unwrap();
assert_eq!(bytes, b"\x1b[5~");
}
#[test]
fn delete_ctrl() {
let bytes = key_to_pty_bytes(
Key::Named(NamedKey::Delete),
Modifiers {
ctrl: true,
..Default::default()
},
false,
)
.unwrap();
assert_eq!(bytes, b"\x1b[3;5~");
}
#[test]
fn page_up_ctrl() {
let bytes = key_to_pty_bytes(
Key::Named(NamedKey::PageUp),
Modifiers {
ctrl: true,
..Default::default()
},
false,
)
.unwrap();
assert_eq!(bytes, b"\x1b[5;5~");
}
#[test]
fn home_shift() {
let bytes = key_to_pty_bytes(
Key::Named(NamedKey::Home),
Modifiers {
shift: true,
..Default::default()
},
false,
)
.unwrap();
assert_eq!(bytes, b"\x1b[1;2~");
}
#[test]
fn end_alt() {
let bytes = key_to_pty_bytes(
Key::Named(NamedKey::End),
Modifiers {
alt: true,
..Default::default()
},
false,
)
.unwrap();
assert_eq!(bytes, b"\x1b[4;3~");
}
#[test]
fn f5_ctrl() {
let bytes = key_to_pty_bytes(
Key::Named(NamedKey::F(5)),
Modifiers {
ctrl: true,
..Default::default()
},
false,
)
.unwrap();
assert_eq!(bytes, b"\x1b[15;5~");
}
#[test]
fn page_down_shift_ctrl() {
let bytes = key_to_pty_bytes(
Key::Named(NamedKey::PageDown),
Modifiers {
shift: true,
ctrl: true,
..Default::default()
},
false,
)
.unwrap();
assert_eq!(bytes, b"\x1b[6;6~");
}
#[test]
fn caps_lock_is_none() {
let bytes = key_to_pty_bytes(Key::Named(NamedKey::CapsLock), Modifiers::default(), false);
assert!(bytes.is_none());
}
#[test]
fn app_cursor_up_plain_is_ss3() {
let bytes = key_to_pty_bytes(Key::Named(NamedKey::Up), Modifiers::default(), true).unwrap();
assert_eq!(bytes, b"\x1bOA");
}
#[test]
fn app_cursor_down_plain_is_ss3() {
let bytes =
key_to_pty_bytes(Key::Named(NamedKey::Down), Modifiers::default(), true).unwrap();
assert_eq!(bytes, b"\x1bOB");
}
#[test]
fn app_cursor_right_plain_is_ss3() {
let bytes =
key_to_pty_bytes(Key::Named(NamedKey::Right), Modifiers::default(), true).unwrap();
assert_eq!(bytes, b"\x1bOC");
}
#[test]
fn app_cursor_left_plain_is_ss3() {
let bytes =
key_to_pty_bytes(Key::Named(NamedKey::Left), Modifiers::default(), true).unwrap();
assert_eq!(bytes, b"\x1bOD");
}
#[test]
fn app_cursor_home_plain_is_ss3() {
let bytes =
key_to_pty_bytes(Key::Named(NamedKey::Home), Modifiers::default(), true).unwrap();
assert_eq!(bytes, b"\x1bOH");
}
#[test]
fn app_cursor_end_plain_is_ss3() {
let bytes =
key_to_pty_bytes(Key::Named(NamedKey::End), Modifiers::default(), true).unwrap();
assert_eq!(bytes, b"\x1bOF");
}
#[test]
fn app_cursor_up_ctrl_falls_back_to_csi() {
let bytes = key_to_pty_bytes(
Key::Named(NamedKey::Up),
Modifiers {
ctrl: true,
..Default::default()
},
true,
)
.unwrap();
assert_eq!(bytes, b"\x1b[1;5A");
}
#[test]
fn app_cursor_up_shift_falls_back_to_csi() {
let bytes = key_to_pty_bytes(
Key::Named(NamedKey::Up),
Modifiers {
shift: true,
..Default::default()
},
true,
)
.unwrap();
assert_eq!(bytes, b"\x1b[1;2A");
}
#[test]
fn app_cursor_home_shift_falls_back_to_csi() {
let bytes = key_to_pty_bytes(
Key::Named(NamedKey::Home),
Modifiers {
shift: true,
..Default::default()
},
true,
)
.unwrap();
assert_eq!(bytes, b"\x1b[1;2~");
}
#[test]
fn app_cursor_page_up_unchanged() {
let bytes =
key_to_pty_bytes(Key::Named(NamedKey::PageUp), Modifiers::default(), true).unwrap();
assert_eq!(bytes, b"\x1b[5~");
}
#[test]
fn app_cursor_page_down_unchanged() {
let bytes =
key_to_pty_bytes(Key::Named(NamedKey::PageDown), Modifiers::default(), true).unwrap();
assert_eq!(bytes, b"\x1b[6~");
}
#[test]
#[cfg(unix)]
fn encode_key_reads_app_cursor_from_session() {
let cwd = std::env::temp_dir();
let sess =
TerminalSession::spawn(80, 10, "/bin/sh", &cwd, 100).expect("failed to spawn /bin/sh");
assert!(!sess.application_cursor_keys());
let bytes = sess
.encode_key(Key::Named(NamedKey::Up), Modifiers::default())
.unwrap();
assert_eq!(bytes, b"\x1b[A");
}
#[test]
#[cfg(unix)]
fn no_forward_without_reporting_or_alt_screen() {
let cwd = std::env::temp_dir();
let mut sess =
TerminalSession::spawn(80, 10, "/bin/sh", &cwd, 100).expect("failed to spawn /bin/sh");
assert!(!sess.mouse_reporting_enabled());
assert!(!sess.on_alt_screen());
assert!(!sess.should_forward_wheel());
assert!(sess
.encode_mouse(
TerminalMouseKind::WheelUp,
MouseButton::Left,
0,
0,
Modifiers::default()
)
.is_none());
assert!(!sess.forward_mouse(
TerminalMouseKind::Press,
MouseButton::Left,
0,
0,
Modifiers::default()
));
sess.send_str("exit\n");
}
#[test]
#[cfg(unix)]
fn forwards_after_mouse_reporting_enabled() {
let cwd = std::env::temp_dir();
let mut sess =
TerminalSession::spawn(80, 10, "/bin/sh", &cwd, 100).expect("failed to spawn /bin/sh");
sess.send_str("printf '\\033[?1000h'\n");
assert!(
poll_until(&mut sess, 5000, |s| s.mouse_reporting_enabled()),
"mouse reporting did not turn on"
);
assert!(sess.should_forward_wheel());
let bytes = sess.encode_mouse(
TerminalMouseKind::WheelDown,
MouseButton::Left,
0,
0,
Modifiers::default(),
);
assert_eq!(bytes.as_deref(), Some(&b"\x1b[<65;1;1M"[..]));
sess.send_str("printf '\\033[?1000l'\n");
assert!(
poll_until(&mut sess, 5000, |s| !s.mouse_reporting_enabled()),
"mouse reporting did not turn off"
);
assert!(!sess.should_forward_wheel());
sess.send_str("exit\n");
}
#[test]
#[cfg(unix)]
fn wheel_forwards_on_alt_screen_even_without_mouse_reporting() {
let cwd = std::env::temp_dir();
let mut sess =
TerminalSession::spawn(80, 10, "/bin/sh", &cwd, 100).expect("failed to spawn /bin/sh");
sess.send_str("printf '\\033[?1049h'\n");
assert!(
poll_until(&mut sess, 5000, |s| s.on_alt_screen()),
"did not enter alt-screen"
);
assert!(!sess.mouse_reporting_enabled()); assert!(sess.should_forward_wheel());
assert!(sess
.encode_mouse(
TerminalMouseKind::Press,
MouseButton::Left,
0,
0,
Modifiers::default()
)
.is_none());
assert!(sess
.encode_mouse(
TerminalMouseKind::WheelUp,
MouseButton::Left,
0,
0,
Modifiers::default()
)
.is_some());
sess.send_str("printf '\\033[?1049l'\n");
let _ = poll_until(&mut sess, 5000, |s| !s.on_alt_screen());
sess.send_str("exit\n");
}
#[test]
#[cfg(unix)]
fn handle_wheel_scrolls_locally_without_forwarding() {
let cwd = std::env::temp_dir();
let mut sess =
TerminalSession::spawn(80, 10, "/bin/sh", &cwd, 100).expect("failed to spawn /bin/sh");
for _ in 0..20 {
sess.send_str("echo line\n");
}
let _ = poll_until(&mut sess, 2000, |s| s.history_len() >= 10);
assert!(!sess.should_forward_wheel());
let forwarded = sess.handle_wheel(true, 3, 12, 4);
assert!(!forwarded);
assert_eq!(sess.scroll_offset(), 3);
let forwarded = sess.handle_wheel(false, 2, 7, 9);
assert!(!forwarded);
assert_eq!(sess.scroll_offset(), 1);
sess.send_str("exit\n");
}
#[test]
#[cfg(unix)]
fn handle_wheel_forwards_when_child_owns_it() {
let cwd = std::env::temp_dir();
let mut sess =
TerminalSession::spawn(80, 10, "/bin/sh", &cwd, 100).expect("failed to spawn /bin/sh");
sess.send_str("printf '\\033[?1000h'\n");
assert!(
poll_until(&mut sess, 5000, |s| s.mouse_reporting_enabled()),
"mouse reporting did not turn on"
);
let before = sess.scroll_offset();
let forwarded = sess.handle_wheel(true, 3, 12, 4);
assert!(forwarded);
assert_eq!(sess.scroll_offset(), before);
sess.send_str("exit\n");
}
#[test]
#[cfg(unix)]
fn scrollback_skipped_on_alt_screen_resumes_on_primary() {
let cwd = std::env::temp_dir();
let mut sess =
TerminalSession::spawn(40, 5, "/bin/sh", &cwd, 1000).expect("failed to spawn /bin/sh");
let _ = poll_until(&mut sess, 1000, |_| false);
sess.send_str("printf '\\033[?1049h'\n");
assert!(
poll_until(&mut sess, 5000, |s| s.on_alt_screen()),
"did not enter alt-screen"
);
let baseline = sess.history_len();
for _ in 0..40 {
sess.send_str("echo alt_content\n");
}
let _ = poll_until(&mut sess, 2000, |_| false);
assert_eq!(
sess.history_len(),
baseline,
"history grew while on alt-screen — alt-screen churn must not pollute shell scrollback"
);
sess.send_str("printf '\\033[?1049l'\n");
assert!(
poll_until(&mut sess, 5000, |s| !s.on_alt_screen()),
"did not exit alt-screen"
);
for _ in 0..40 {
sess.send_str("echo primary_content\n");
}
assert!(
poll_until(&mut sess, 5000, |s| s.history_len() > baseline + 5),
"history did not resume growing after returning to primary screen"
);
sess.send_str("exit\n");
}
#[test]
#[cfg(unix)]
fn application_cursor_keys_tracks_decckm() {
let cwd = std::env::temp_dir();
let mut sess =
TerminalSession::spawn(80, 24, "/bin/sh", &cwd, 1000).expect("failed to spawn /bin/sh");
assert!(!sess.application_cursor_keys());
sess.send_str("printf '\\033[?1h'\n");
assert!(
poll_until(&mut sess, 5000, |s| s.application_cursor_keys()),
"DECCKM should be enabled after ESC[?1h"
);
sess.send_str("printf '\\033[?1l'\n");
assert!(
poll_until(&mut sess, 5000, |s| !s.application_cursor_keys()),
"DECCKM should be disabled after ESC[?1l"
);
sess.send_str("exit\n");
}
#[test]
fn decscusr_shape_maps_all_standard_ps_values() {
assert_eq!(
decscusr_shape(0),
Some((TerminalCursorShape::Block, true)),
"Ps=0 (omitted) aliases Ps=1 per xterm"
);
assert_eq!(decscusr_shape(1), Some((TerminalCursorShape::Block, true)));
assert_eq!(decscusr_shape(2), Some((TerminalCursorShape::Block, false)));
assert_eq!(
decscusr_shape(3),
Some((TerminalCursorShape::Underline, true))
);
assert_eq!(
decscusr_shape(4),
Some((TerminalCursorShape::Underline, false))
);
assert_eq!(decscusr_shape(5), Some((TerminalCursorShape::Bar, true)));
assert_eq!(decscusr_shape(6), Some((TerminalCursorShape::Bar, false)));
}
#[test]
fn decscusr_shape_ignores_out_of_range_ps() {
assert_eq!(decscusr_shape(7), None);
assert_eq!(decscusr_shape(999), None);
}
#[test]
fn terminal_query_replies_answers_cursor_position_query() {
let reply = terminal_query_replies(b"\x1b[6n", (0, 0));
assert_eq!(reply, b"\x1b[1;1R");
}
#[test]
fn terminal_query_replies_cursor_position_uses_the_given_cursor() {
let reply = terminal_query_replies(b"\x1b[6n", (4, 9));
assert_eq!(reply, b"\x1b[5;10R");
}
#[test]
fn terminal_query_replies_answers_primary_device_attributes_query() {
assert_eq!(terminal_query_replies(b"\x1b[c", (0, 0)), b"\x1b[?1;0c");
assert_eq!(terminal_query_replies(b"\x1b[0c", (0, 0)), b"\x1b[?1;0c");
}
#[test]
fn terminal_query_replies_ignores_unrelated_csi_sequences() {
assert!(terminal_query_replies(b"\x1b[2J", (0, 0)).is_empty());
}
#[test]
fn terminal_query_replies_does_not_answer_secondary_device_attributes() {
assert!(terminal_query_replies(b"\x1b[>c", (0, 0)).is_empty());
}
#[test]
fn terminal_query_replies_ignores_plain_text_with_no_escape_sequences() {
assert!(terminal_query_replies(b"hello, world\r\n", (0, 0)).is_empty());
}
#[test]
fn terminal_query_replies_finds_a_query_mixed_with_other_output() {
let mut data = b"some prompt text ".to_vec();
data.extend_from_slice(b"\x1b[6n");
data.extend_from_slice(b" more text");
assert_eq!(terminal_query_replies(&data, (0, 0)), b"\x1b[1;1R");
}
#[test]
fn terminal_query_replies_answers_multiple_queries_in_one_chunk() {
let mut data = b"\x1b[6n".to_vec();
data.extend_from_slice(b"\x1b[c");
let mut expected = b"\x1b[1;1R".to_vec();
expected.extend_from_slice(b"\x1b[?1;0c");
assert_eq!(terminal_query_replies(&data, (0, 0)), expected);
}
#[test]
fn terminal_query_replies_handles_a_truncated_sequence_without_panicking() {
assert!(terminal_query_replies(b"\x1b[6", (0, 0)).is_empty());
assert!(terminal_query_replies(b"\x1b[", (0, 0)).is_empty());
assert!(terminal_query_replies(b"\x1b", (0, 0)).is_empty());
}
#[test]
fn terminal_query_replies_handles_a_lone_escape_byte_without_panicking() {
assert!(terminal_query_replies(&[0x1b], (0, 0)).is_empty());
assert!(terminal_query_replies(&[], (0, 0)).is_empty());
}
#[test]
fn terminal_query_replies_does_not_answer_a_6n_with_a_private_marker() {
assert!(terminal_query_replies(b"\x1b[?6n", (0, 0)).is_empty());
}
#[test]
#[cfg(unix)]
fn cursor_shape_defaults_to_steady_block() {
let cwd = std::env::temp_dir();
let sess =
TerminalSession::spawn(80, 24, "/bin/sh", &cwd, 1000).expect("failed to spawn /bin/sh");
assert_eq!(sess.cursor_shape(), TerminalCursorShape::Block);
assert!(!sess.cursor_blinking());
}
#[test]
#[cfg(unix)]
fn cursor_shape_tracks_decscusr() {
let cwd = std::env::temp_dir();
let mut sess =
TerminalSession::spawn(80, 24, "/bin/sh", &cwd, 1000).expect("failed to spawn /bin/sh");
sess.send_str("printf '\\033[6 q'\n");
assert!(
poll_until(&mut sess, 5000, |s| s.cursor_shape()
== TerminalCursorShape::Bar),
"cursor shape should become Bar after ESC[6 q"
);
assert!(!sess.cursor_blinking(), "Ps=6 is the steady bar variant");
sess.send_str("printf '\\033[4 q'\n");
assert!(
poll_until(&mut sess, 5000, |s| s.cursor_shape()
== TerminalCursorShape::Underline),
"cursor shape should become Underline after ESC[4 q"
);
sess.send_str("printf '\\033[1 q'\n");
assert!(
poll_until(&mut sess, 5000, |s| s.cursor_shape()
== TerminalCursorShape::Block
&& s.cursor_blinking()),
"cursor shape should become blinking Block after ESC[1 q"
);
sess.send_str("exit\n");
}
#[test]
#[cfg(unix)]
fn build_rows_stamps_cursor_shape_only_on_the_cursor_cell() {
let cwd = std::env::temp_dir();
let mut sess =
TerminalSession::spawn(10, 3, "/bin/sh", &cwd, 100).expect("failed to spawn /bin/sh");
sess.parser.callbacks_mut().cursor_shape = TerminalCursorShape::Bar;
sess.parser.callbacks_mut().cursor_blinking = true;
let snapshot = sess.to_terminal(WidgetId::new("t"), None);
let (cursor_row, cursor_col) = sess.parser.screen().cursor_position();
for (r, row) in snapshot.cells.iter().enumerate() {
for (c, cell) in row.iter().enumerate() {
if cell.is_cursor {
assert_eq!(r as u16, cursor_row);
assert_eq!(c as u16, cursor_col);
assert_eq!(cell.cursor_shape, TerminalCursorShape::Bar);
assert!(cell.cursor_blinking);
} else {
assert_eq!(cell.cursor_shape, TerminalCursorShape::Block);
assert!(!cell.cursor_blinking);
}
}
}
}
#[test]
#[cfg(unix)]
fn screen_text_strips_trailing_blanks() {
let cwd = std::env::temp_dir();
let mut sess =
TerminalSession::spawn(80, 10, "/bin/sh", &cwd, 100).expect("failed to spawn /bin/sh");
let _ = poll_until(&mut sess, 3000, |s| !s.screen_text().is_empty());
let text = sess.screen_text();
assert!(
!text.ends_with('\n'),
"screen_text() should not end with a newline; got: {text:?}"
);
sess.send_str("exit\n");
}
#[cfg(unix)]
fn make_hist_row_content(text: &str, cols: u16) -> Vec<HistCell> {
let chars: Vec<char> = text.chars().collect();
(0..cols as usize)
.map(|i| HistCell {
text: chars
.get(i)
.map_or_else(|| " ".to_string(), |c| c.to_string()),
..Default::default()
})
.collect()
}
#[test]
#[cfg(unix)]
fn selected_text_from_pure_history() {
let cwd = std::env::temp_dir();
let mut sess = TerminalSession::spawn(10, 4, "/bin/sh", &cwd, 100).expect("spawn failed");
sess.history
.push_back(make_hist_row_content("AAAA", sess.cols));
sess.history
.push_back(make_hist_row_content("BBBB", sess.cols));
sess.history
.push_back(make_hist_row_content("CCCC", sess.cols));
sess.set_scroll_offset(3);
sess.selection = Some(TerminalSelection {
start_row: 1,
start_col: 0,
end_row: 1,
end_col: 3,
});
let text = sess
.selected_text()
.expect("selected_text() must be Some when scrolled");
assert_eq!(
text, "BBBB",
"expected 'BBBB' from history row 1, got: {text:?}"
);
sess.send_str("exit\n");
}
#[test]
#[cfg(unix)]
fn selected_text_multi_row_in_history() {
let cwd = std::env::temp_dir();
let mut sess = TerminalSession::spawn(10, 5, "/bin/sh", &cwd, 100).expect("spawn failed");
sess.history
.push_back(make_hist_row_content("LINE0", sess.cols));
sess.history
.push_back(make_hist_row_content("LINE1", sess.cols));
sess.history
.push_back(make_hist_row_content("LINE2", sess.cols));
sess.set_scroll_offset(3);
sess.selection = Some(TerminalSelection {
start_row: 0,
start_col: 0,
end_row: 2,
end_col: 4,
});
let text = sess.selected_text().expect("selected_text() must be Some");
let lines: Vec<&str> = text.split('\n').collect();
assert_eq!(lines.len(), 3, "expected 3 lines, got: {lines:?}");
assert_eq!(lines[0], "LINE0", "row 0: got {:?}", lines[0]);
assert_eq!(lines[1], "LINE1", "row 1: got {:?}", lines[1]);
assert_eq!(lines[2], "LINE2", "row 2: got {:?}", lines[2]);
sess.send_str("exit\n");
}
#[test]
#[cfg(unix)]
fn build_rows_highlights_selection_in_history() {
let cwd = std::env::temp_dir();
let mut sess = TerminalSession::spawn(6, 4, "/bin/sh", &cwd, 100).expect("spawn failed");
sess.history
.push_back(make_hist_row_content("HIST", sess.cols));
sess.history
.push_back(make_hist_row_content("XXXX", sess.cols));
sess.set_scroll_offset(2);
sess.selection = Some(TerminalSelection {
start_row: 0,
start_col: 1,
end_row: 0,
end_col: 3,
});
let rows = sess.build_rows(false);
let row0 = &rows[0];
assert!(!row0[0].selected, "col 0 should NOT be selected");
assert!(row0[1].selected, "col 1 should be selected");
assert!(row0[2].selected, "col 2 should be selected");
assert!(row0[3].selected, "col 3 should be selected");
assert!(!row0[4].selected, "col 4 should NOT be selected");
let row1 = &rows[1];
assert!(
row1.iter().all(|c| !c.selected),
"row 1 should have no selection"
);
sess.send_str("exit\n");
}
#[test]
#[cfg(unix)]
fn build_rows_live_screen_preserves_combining_mark_grapheme() {
let cwd = std::env::temp_dir();
let mut sess = TerminalSession::spawn(10, 4, "/bin/sh", &cwd, 100).expect("spawn failed");
sess.parser.process("e\u{0301}".as_bytes());
let rows = sess.build_rows(false);
assert_eq!(
rows[0][0].text, "e\u{0301}",
"combining mark must survive into the cell's text, not just 'e'"
);
assert_eq!(
rows[0][0].cell_width(),
1,
"base char + combining accent is still one column wide"
);
assert_eq!(rows[0][1].text, " ");
sess.send_str("exit\n");
}
#[test]
#[cfg(unix)]
fn build_rows_live_screen_wide_char_reserves_continuation_column() {
let cwd = std::env::temp_dir();
let mut sess = TerminalSession::spawn(10, 4, "/bin/sh", &cwd, 100).expect("spawn failed");
sess.parser.process("日B".as_bytes());
let rows = sess.build_rows(false);
assert_eq!(rows[0][0].text, "日");
assert_eq!(rows[0][0].cell_width(), 2);
assert_eq!(
rows[0][1].text, " ",
"wide glyph's continuation column must be blank, not a second copy"
);
assert_eq!(rows[0][2].text, "B");
assert_eq!(rows[0][2].cell_width(), 1);
sess.send_str("exit\n");
}
#[test]
#[cfg(unix)]
fn build_rows_live_screen_carries_dim_attribute() {
let cwd = std::env::temp_dir();
let mut sess = TerminalSession::spawn(10, 4, "/bin/sh", &cwd, 100).expect("spawn failed");
sess.parser.process(b"\x1b[2mD\x1b[0mN");
let rows = sess.build_rows(false);
assert!(
rows[0][0].dim,
"cell painted under SGR 2 must carry dim = true"
);
assert!(
!rows[0][1].dim,
"cell painted after SGR 0 reset must not be dim"
);
sess.send_str("exit\n");
}
#[test]
#[cfg(unix)]
fn build_rows_history_carries_dim_attribute() {
let cwd = std::env::temp_dir();
let mut sess = TerminalSession::spawn(6, 4, "/bin/sh", &cwd, 100).expect("spawn failed");
let mut row = make_hist_row_content("DIM ", sess.cols);
row[0].dim = true;
sess.history.push_back(row);
sess.set_scroll_offset(1);
let rows = sess.build_rows(false);
assert!(
rows[0][0].dim,
"history cell's dim flag must survive into build_rows output"
);
assert!(!rows[0][1].dim, "non-dim history cells must stay non-dim");
sess.send_str("exit\n");
}
#[test]
#[cfg(unix)]
fn capture_scrolled_rows_preserves_combining_mark_grapheme() {
let cwd = std::env::temp_dir();
let mut sess = TerminalSession::spawn(10, 1, "/bin/sh", &cwd, 100).expect("spawn failed");
sess.process_with_capture("e\u{0301}\n".as_bytes());
assert_eq!(sess.history.len(), 1, "one row should have scrolled off");
assert_eq!(
sess.history[0][0].text, "e\u{0301}",
"combining mark must survive scrollback capture, not just 'e'"
);
sess.send_str("exit\n");
}
#[test]
#[cfg(unix)]
fn capture_scrolled_rows_preserves_dim_attribute() {
let cwd = std::env::temp_dir();
let mut sess = TerminalSession::spawn(10, 1, "/bin/sh", &cwd, 100).expect("spawn failed");
sess.process_with_capture(b"\x1b[2mD\x1b[0m\n");
assert_eq!(sess.history.len(), 1, "one row should have scrolled off");
assert!(
sess.history[0][0].dim,
"faint cell must survive scrollback capture as dim = true"
);
sess.send_str("exit\n");
}
#[test]
#[cfg(unix)]
fn selected_text_live_view_no_regression() {
let cwd = std::env::temp_dir();
let mut sess = TerminalSession::spawn(10, 4, "/bin/sh", &cwd, 100).expect("spawn failed");
assert!(sess.selection.is_none());
assert!(sess.selected_text().is_none());
sess.selection = Some(TerminalSelection {
start_row: 0,
start_col: 0,
end_row: 0,
end_col: 4,
});
assert!(
sess.selected_text().is_some(),
"selected_text() must be Some when selection is set at live view"
);
sess.send_str("exit\n");
}
#[test]
#[cfg(unix)]
fn selected_text_spans_history_live_boundary() {
let cwd = std::env::temp_dir();
let mut sess = TerminalSession::spawn(10, 5, "/bin/sh", &cwd, 100).expect("spawn failed");
sess.history
.push_back(make_hist_row_content("HIST0", sess.cols));
sess.history
.push_back(make_hist_row_content("HIST1", sess.cols));
sess.set_scroll_offset(2);
sess.selection = Some(TerminalSelection {
start_row: 1,
start_col: 0,
end_row: 2,
end_col: 4,
});
let text = sess
.selected_text()
.expect("selected_text() must be Some at boundary");
let lines: Vec<&str> = text.split('\n').collect();
assert_eq!(
lines.len(),
2,
"expected 2 lines (history + live); got: {lines:?}"
);
assert_eq!(
lines[0], "HIST1",
"first line must come from history; got: {:?}",
lines[0]
);
sess.send_str("exit\n");
}
}
#[cfg(test)]
mod proptests {
use super::{reflow_screen, MIN_VT100_COLS, MIN_VT100_ROWS};
use proptest::prelude::*;
proptest! {
#[test]
fn vt100_parser_never_panics(
rows in MIN_VT100_ROWS..60,
cols in MIN_VT100_COLS..200,
bytes in prop::collection::vec(any::<u8>(), 0..2000),
) {
let mut parser = vt100::Parser::new(rows, cols, 0);
parser.process(&bytes);
}
#[test]
fn vt100_parser_never_panics_on_arbitrary_utf8(
rows in MIN_VT100_ROWS..60,
cols in MIN_VT100_COLS..200,
s in ".{0,2000}",
) {
let mut parser = vt100::Parser::new(rows, cols, 0);
parser.process(s.as_bytes());
}
#[test]
fn vt100_parser_never_panics_across_a_resize(
rows in MIN_VT100_ROWS..60,
cols in MIN_VT100_COLS..200,
new_rows in MIN_VT100_ROWS..60,
new_cols in MIN_VT100_COLS..200,
alternate in any::<bool>(),
before in prop::collection::vec(any::<u8>(), 0..1000),
after in prop::collection::vec(any::<u8>(), 0..1000),
) {
let mut parser = vt100::Parser::new(rows, cols, 0);
if alternate {
parser.process(b"\x1b[?1049h");
}
parser.process(&before);
reflow_screen(&mut parser, new_rows, new_cols);
parser.process(&after);
}
#[test]
fn reflow_survives_a_wide_glyph_split_by_the_new_width(
rows in MIN_VT100_ROWS..8,
new_cols in 4u16..40,
extra in 0u16..12,
alternate in any::<bool>(),
) {
let cols = new_cols + 1 + extra;
let mut parser = vt100::Parser::new(rows, cols, 0);
if alternate {
parser.process(b"\x1b[?1049h");
}
let mut bytes = vec![b' '; usize::from(new_cols) - 1];
bytes.extend_from_slice("\u{3000}".as_bytes());
parser.process(&bytes);
prop_assert!(
parser.screen().cell(0, new_cols - 1).is_some_and(vt100::Cell::is_wide),
"fixture must park a wide first half on the truncation boundary"
);
reflow_screen(&mut parser, rows, new_cols);
parser.process(b"\x1b[1;1H");
parser.process(&vec![b'x'; usize::from(new_cols) * 2]);
}
}
}