use std::collections::VecDeque;
use tear_types::pane_snapshot::{CellAttrs, Color, ansi_256_color, default_ansi_palette};
use tear_types::graphics::{Graphic, GraphicProtocol, GRAPHIC_PAYLOAD_MAX};
use tear_types::host_role::{HostRole, TearCaps};
use tear_types::modes::{
AltScreen, AutoWrap, BracketedPaste, CursorKeys, CursorVisible, FocusReporting, ModeSet,
MouseSgr, MouseTracking, SyncOutput,
};
use unicode_width::UnicodeWidthChar;
use vte::{Params, Parser, Perform};
pub use tear_types::pane_snapshot::{Cell, PaneSnapshot};
pub const DEFAULT_SCROLLBACK_ROWS: usize = usize::MAX;
pub struct PaneGrid {
parser: Parser,
pub(crate) state: GridState,
apc: ApcScanner,
}
#[derive(Debug, Default)]
struct ApcScanner {
state: ApcState,
buf: Vec<u8>,
cut: bool,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
enum ApcState {
#[default]
Idle,
Escape,
Inside,
InsideEscape,
}
impl ApcScanner {
fn split(&mut self, bytes: &[u8]) -> (Vec<u8>, Vec<(Vec<u8>, bool)>) {
let mut passthrough = Vec::with_capacity(bytes.len());
let mut done = Vec::new();
for &b in bytes {
match self.state {
ApcState::Idle => {
if b == 0x1b {
self.state = ApcState::Escape;
} else {
passthrough.push(b);
}
}
ApcState::Escape => {
if b == b'_' {
self.state = ApcState::Inside;
self.buf.clear();
self.cut = false;
} else {
passthrough.push(0x1b);
if b == 0x1b {
self.state = ApcState::Escape;
} else {
passthrough.push(b);
self.state = ApcState::Idle;
}
}
}
ApcState::Inside => match b {
0x1b => self.state = ApcState::InsideEscape,
0x07 => {
done.push((std::mem::take(&mut self.buf), self.cut));
self.state = ApcState::Idle;
}
_ => {
if self.buf.len() < GRAPHIC_PAYLOAD_MAX {
self.buf.push(b);
} else {
self.cut = true;
}
}
},
ApcState::InsideEscape => {
if b == b'\\' {
done.push((std::mem::take(&mut self.buf), self.cut));
self.state = ApcState::Idle;
} else {
if self.buf.len() < GRAPHIC_PAYLOAD_MAX {
self.buf.push(0x1b);
self.buf.push(b);
} else {
self.cut = true;
}
self.state = ApcState::Inside;
}
}
}
}
(passthrough, done)
}
}
pub(crate) struct GridState {
rows: usize,
cols: usize,
primary: VecDeque<Vec<Cell>>,
alternate: Vec<Vec<Cell>>,
alt_active: bool,
scrollback: VecDeque<Vec<Cell>>,
scrollback_cap: usize,
cursor_row: usize,
cursor_col: usize,
pen_fg: Color,
pen_bg: Color,
pen_attrs: CellAttrs,
saved: Option<SavedCursor>,
wrap_pending: bool,
scroll_top: usize,
scroll_bottom: usize,
palette: [Color; 16],
insert_mode: bool,
cursor_visible: bool,
cursor_keys_mode: bool,
last_printed: Option<char>,
role: HostRole,
autowrap: bool,
focus_reporting: bool,
bracketed_paste: bool,
sync_output: bool,
mouse: MouseTracking,
mouse_sgr: bool,
combining: Vec<Vec<char>>,
graphics: Vec<Graphic>,
sixel_in_flight: Option<Vec<u8>>,
pending_response: Vec<u8>,
title: Option<String>,
pub(crate) blocks: crate::blocks::BlockExtractor,
}
#[derive(Clone, Copy)]
struct SavedCursor {
row: usize,
col: usize,
fg: Color,
bg: Color,
attrs: CellAttrs,
}
impl GridState {
fn new(cols: usize, rows: usize, scrollback_cap: usize) -> Self {
Self {
rows,
cols,
primary: VecDeque::from(vec![vec![Cell::BLANK; cols]; rows]),
alternate: vec![vec![Cell::BLANK; cols]; rows],
alt_active: false,
scrollback: VecDeque::with_capacity(64.min(scrollback_cap)),
scrollback_cap,
cursor_row: 0,
cursor_col: 0,
pen_fg: Color::WHITE,
pen_bg: Color::BLACK,
pen_attrs: CellAttrs::NONE,
saved: None,
wrap_pending: false,
scroll_top: 0,
scroll_bottom: rows.saturating_sub(1),
palette: default_ansi_palette(),
insert_mode: false,
cursor_visible: true,
cursor_keys_mode: false,
last_printed: None,
role: HostRole::default(),
autowrap: true,
focus_reporting: false,
bracketed_paste: false,
sync_output: false,
mouse: MouseTracking::Off,
mouse_sgr: false,
combining: Vec::new(),
graphics: Vec::new(),
sixel_in_flight: None,
pending_response: Vec::new(),
title: None,
blocks: crate::blocks::BlockExtractor::default(),
}
}
fn active_cell_mut(&mut self, row: usize, col: usize) -> Option<&mut Cell> {
if self.alt_active {
self.alternate.get_mut(row).and_then(|r| r.get_mut(col))
} else {
self.primary.get_mut(row).and_then(|r| r.get_mut(col))
}
}
fn ingest_apc(&mut self, payload: &[u8], cut: bool) {
let Some((&b'G', rest)) = payload.split_first() else {
return;
};
let (params, data) = match rest.iter().position(|&b| b == b';') {
Some(i) => (&rest[..i], &rest[i + 1..]),
None => (rest, &[][..]),
};
self.push_graphic(
GraphicProtocol::Kitty,
String::from_utf8_lossy(params).into_owned(),
data.to_vec(),
cut,
);
}
fn push_graphic(
&mut self,
protocol: GraphicProtocol,
params: String,
mut data: Vec<u8>,
cut_upstream: bool,
) {
let truncated = cut_upstream || data.len() > GRAPHIC_PAYLOAD_MAX;
if data.len() > GRAPHIC_PAYLOAD_MAX {
data.truncate(GRAPHIC_PAYLOAD_MAX);
}
self.graphics.push(Graphic {
protocol,
params,
data,
at_row: self.cursor_row,
at_col: self.cursor_col,
truncated,
});
}
fn answer(&mut self, bytes: &[u8]) {
if self.role.answers_queries() {
self.pending_response.extend_from_slice(bytes);
}
}
fn active_cell_at(&self, row: usize, col: usize) -> Option<&Cell> {
if self.alt_active {
self.alternate.get(row).and_then(|r| r.get(col))
} else {
self.primary.get(row).and_then(|r| r.get(col))
}
}
fn active_row_mut(&mut self, row: usize) -> Option<&mut Vec<Cell>> {
if self.alt_active {
self.alternate.get_mut(row)
} else {
self.primary.get_mut(row)
}
}
fn active_rows(&self) -> impl Iterator<Item = &Vec<Cell>> + '_ {
if self.alt_active {
Box::new(self.alternate.iter()) as Box<dyn Iterator<Item = &Vec<Cell>>>
} else {
Box::new(self.primary.iter())
}
}
fn blank_cell(&self) -> Cell {
Cell {
ch: ' ',
fg: self.pen_fg,
bg: self.pen_bg,
attrs: CellAttrs::NONE,
width: 1,
combining: 0,
}
}
fn current_cell_for_print(&self, ch: char, w: u8) -> Cell {
Cell {
ch,
fg: self.pen_fg,
bg: self.pen_bg,
attrs: self.pen_attrs,
width: w,
combining: 0,
}
}
fn continuation_cell(&self) -> Cell {
Cell {
ch: ' ',
fg: self.pen_fg,
bg: self.pen_bg,
attrs: self.pen_attrs,
width: 0,
combining: 0,
}
}
fn scroll_region_up(&mut self) {
if self.scroll_top > self.scroll_bottom {
return;
}
let blank = vec![self.blank_cell(); self.cols];
let full_region = self.scroll_top == 0 && self.scroll_bottom == self.rows.saturating_sub(1);
if self.alt_active {
if self.scroll_top < self.alternate.len() {
self.alternate.remove(self.scroll_top);
self.alternate
.insert(self.scroll_bottom.min(self.alternate.len()), blank);
}
} else {
if full_region {
if let Some(top) = self.primary.pop_front() {
if self.scrollback_cap > 0 {
if self.scrollback.len() >= self.scrollback_cap {
self.scrollback.pop_front();
}
self.scrollback.push_back(top);
}
}
self.primary.push_back(blank);
} else if self.scroll_top < self.primary.len() {
self.primary.remove(self.scroll_top);
let insert_at = (self.scroll_bottom + 1).min(self.primary.len());
self.primary.insert(insert_at, blank);
}
}
}
fn advance_cursor_after_print(&mut self, w: usize) {
let adv = w.max(1);
if self.cursor_col + adv >= self.cols {
self.park_at_right_margin();
} else {
self.cursor_col += adv;
}
}
fn park_at_right_margin(&mut self) {
self.cursor_col = self.cols.saturating_sub(1);
self.wrap_pending = true;
}
fn clear_orphans_at(&mut self, row: usize, col: usize, w: usize) {
if col > 0 && self.active_cell_at(row, col).is_some_and(Cell::is_continuation) {
if let Some(lead) = self.active_cell_mut(row, col - 1) {
*lead = Cell::BLANK;
}
}
let last = col + w.saturating_sub(1);
if self.active_cell_at(row, last).is_some_and(|c| c.width == 2) && last + 1 < self.cols {
if let Some(cont) = self.active_cell_mut(row, last + 1) {
*cont = Cell::BLANK;
}
}
}
fn combine_into_previous(&mut self, c: char) {
let start = if self.wrap_pending {
self.cols.saturating_sub(1)
} else if self.cursor_col > 0 {
self.cursor_col - 1
} else {
return;
};
let row = self.cursor_row;
let col = self.lead_col_at(row, start);
if col >= self.cols || row >= self.rows {
return;
}
let existing = self
.active_cell_at(row, col)
.map_or(0, |cell| cell.combining);
if existing == 0 {
let Ok(next) = u16::try_from(self.combining.len() + 1) else {
return;
};
self.combining.push(vec![c]);
if let Some(cell) = self.active_cell_mut(row, col) {
cell.combining = next;
} else {
self.combining.pop();
}
} else if let Some(marks) = self.combining.get_mut(existing as usize - 1) {
marks.push(c);
}
}
fn lead_col_at(&self, row: usize, col: usize) -> usize {
if col > 0 && self.active_cell_at(row, col).is_some_and(Cell::is_continuation) {
col - 1
} else {
col
}
}
fn put_char(&mut self, c: char, w: usize) {
if self.wrap_pending {
self.wrap_pending = false;
self.cursor_col = 0;
self.linefeed();
}
if w == 2 && self.cursor_col + 1 >= self.cols {
self.cursor_col = 0;
self.linefeed();
}
let row = self.cursor_row;
let col = self.cursor_col;
let cell = self.current_cell_for_print(c, w as u8);
if self.insert_mode {
let cols = self.cols;
let cont = self.continuation_cell();
if let Some(r) = self.active_row_mut(row) {
if col < r.len() {
r.insert(col, cell);
if w == 2 && col + 1 <= r.len() {
r.insert(col + 1, cont);
}
r.truncate(cols);
}
}
} else {
self.clear_orphans_at(row, col, w);
if let Some(slot) = self.active_cell_mut(row, col) {
*slot = cell;
}
if w == 2 && col + 1 < self.cols {
let cont = self.continuation_cell();
if let Some(slot) = self.active_cell_mut(row, col + 1) {
*slot = cont;
}
}
}
self.last_printed = Some(c);
self.advance_cursor_after_print(w);
}
fn linefeed(&mut self) {
if self.cursor_row == self.scroll_bottom {
self.scroll_region_up();
} else if self.cursor_row + 1 < self.rows {
self.cursor_row += 1;
}
}
fn carriage_return(&mut self) {
self.cursor_col = 0;
}
fn backspace(&mut self) {
if self.cursor_col > 0 {
self.cursor_col -= 1;
}
}
fn tab_forward(&mut self) {
let next = ((self.cursor_col / 8) + 1) * 8;
self.cursor_col = next.min(self.cols.saturating_sub(1));
}
fn cursor_move_relative(&mut self, drow: isize, dcol: isize) {
let r = (self.cursor_row as isize + drow).max(0) as usize;
let c = (self.cursor_col as isize + dcol).max(0) as usize;
self.cursor_row = r.min(self.rows.saturating_sub(1));
self.cursor_col = c.min(self.cols.saturating_sub(1));
}
fn cursor_set(&mut self, row: usize, col: usize) {
self.cursor_row = row.min(self.rows.saturating_sub(1));
self.cursor_col = col.min(self.cols.saturating_sub(1));
}
fn erase_to_end_of_line(&mut self) {
let row = self.cursor_row;
let start = self.cursor_col;
let blank = self.blank_cell();
if let Some(r) = self.active_row_mut(row) {
for c in r.iter_mut().skip(start) {
*c = blank;
}
}
}
fn erase_from_start_of_line(&mut self) {
let row = self.cursor_row;
let stop = self.cursor_col + 1;
let blank = self.blank_cell();
if let Some(r) = self.active_row_mut(row) {
let stop = stop.min(r.len());
for c in r.iter_mut().take(stop) {
*c = blank;
}
}
}
fn erase_line(&mut self) {
let row = self.cursor_row;
let blank = self.blank_cell();
if let Some(r) = self.active_row_mut(row) {
for c in r.iter_mut() {
*c = blank;
}
}
}
fn erase_below_cursor(&mut self) {
self.erase_to_end_of_line();
let start = self.cursor_row + 1;
let end = self.rows;
let blank = self.blank_cell();
for r in start..end {
if let Some(row) = self.active_row_mut(r) {
for c in row.iter_mut() {
*c = blank;
}
}
}
}
fn erase_above_cursor(&mut self) {
let stop_row = self.cursor_row;
let blank = self.blank_cell();
for r in 0..stop_row {
if let Some(row) = self.active_row_mut(r) {
for c in row.iter_mut() {
*c = blank;
}
}
}
self.erase_from_start_of_line();
}
fn erase_all(&mut self) {
let blank = self.blank_cell();
let rows = self.rows;
for r in 0..rows {
if let Some(row) = self.active_row_mut(r) {
for c in row.iter_mut() {
*c = blank;
}
}
}
}
fn save_cursor(&mut self) {
self.saved = Some(SavedCursor {
row: self.cursor_row,
col: self.cursor_col,
fg: self.pen_fg,
bg: self.pen_bg,
attrs: self.pen_attrs,
});
}
fn restore_cursor(&mut self) {
if let Some(s) = self.saved {
self.cursor_row = s.row.min(self.rows.saturating_sub(1));
self.cursor_col = s.col.min(self.cols.saturating_sub(1));
self.pen_fg = s.fg;
self.pen_bg = s.bg;
self.pen_attrs = s.attrs;
}
}
fn enter_alt_screen(&mut self, clear: bool) {
if !self.alt_active {
self.alt_active = true;
}
if clear {
for row in &mut self.alternate {
for c in row.iter_mut() {
*c = Cell::BLANK;
}
}
self.cursor_row = 0;
self.cursor_col = 0;
}
}
fn leave_alt_screen(&mut self) {
self.alt_active = false;
}
fn apply_sgr(&mut self, params: &Params) {
let items: Vec<&[u16]> = params.iter().collect();
if items.is_empty() {
self.sgr_reset();
return;
}
let mut idx = 0;
while idx < items.len() {
let param = items[idx];
let Some(&code) = param.first() else {
idx += 1;
continue;
};
if param.len() > 1 {
self.apply_sgr_subparams(param);
idx += 1;
continue;
}
if matches!(code, 38 | 48 | 58) {
let (colour, consumed) = self.parse_extended_color_params(&items[idx..]);
match (code, colour) {
(38, Some(c)) => self.pen_fg = c,
(48, Some(c)) => self.pen_bg = c,
_ => {}
}
idx += consumed;
continue;
}
self.apply_sgr_code(code);
idx += 1;
}
}
fn apply_sgr_subparams(&mut self, param: &[u16]) {
match param[0] {
4 => {
if param[1] == 0 {
self.pen_attrs.remove(CellAttrs::UNDERLINE);
} else {
self.pen_attrs.insert(CellAttrs::UNDERLINE);
}
}
code @ (38 | 48 | 58) => {
let colour = match param[1] {
5 => param.get(2).map(|&n| ansi_256_color(n, &self.palette)),
2 => match param.len() {
n if n >= 6 => {
Some(Color::new(param[3] as u8, param[4] as u8, param[5] as u8))
}
5 => Some(Color::new(param[2] as u8, param[3] as u8, param[4] as u8)),
_ => None,
},
_ => None,
};
match (code, colour) {
(38, Some(c)) => self.pen_fg = c,
(48, Some(c)) => self.pen_bg = c,
_ => {}
}
}
other => self.apply_sgr_code(other),
}
}
fn parse_extended_color_params(&self, rest: &[&[u16]]) -> (Option<Color>, usize) {
let first = |i: usize| rest.get(i).and_then(|p| p.first().copied());
match first(1) {
Some(5) => match first(2) {
Some(n) => (Some(ansi_256_color(n, &self.palette)), 3),
None => (None, 2),
},
Some(2) => match (first(2), first(3), first(4)) {
(Some(r), Some(g), Some(b)) => (Some(Color::new(r as u8, g as u8, b as u8)), 5),
_ => (None, rest.len().min(5)),
},
_ => (None, 1),
}
}
fn apply_sgr_code(&mut self, code: u16) {
{
let p = code;
match p {
0 => self.sgr_reset(),
1 => self.pen_attrs.insert(CellAttrs::BOLD),
2 => self.pen_attrs.insert(CellAttrs::DIM),
3 => self.pen_attrs.insert(CellAttrs::ITALIC),
4 => self.pen_attrs.insert(CellAttrs::UNDERLINE),
5 | 6 => self.pen_attrs.insert(CellAttrs::BLINK),
7 => self.pen_attrs.insert(CellAttrs::INVERSE),
8 => self.pen_attrs.insert(CellAttrs::HIDDEN),
9 => self.pen_attrs.insert(CellAttrs::STRIKETHROUGH),
21 | 22 => {
self.pen_attrs.remove(CellAttrs::BOLD);
self.pen_attrs.remove(CellAttrs::DIM);
}
23 => self.pen_attrs.remove(CellAttrs::ITALIC),
24 => self.pen_attrs.remove(CellAttrs::UNDERLINE),
25 => self.pen_attrs.remove(CellAttrs::BLINK),
27 => self.pen_attrs.remove(CellAttrs::INVERSE),
28 => self.pen_attrs.remove(CellAttrs::HIDDEN),
29 => self.pen_attrs.remove(CellAttrs::STRIKETHROUGH),
30..=37 => self.pen_fg = self.palette[(p - 30) as usize],
39 => self.pen_fg = Color::WHITE,
40..=47 => self.pen_bg = self.palette[(p - 40) as usize],
49 => self.pen_bg = Color::BLACK,
90..=97 => self.pen_fg = self.palette[8 + (p - 90) as usize],
100..=107 => self.pen_bg = self.palette[8 + (p - 100) as usize],
_ => {} }
}
}
fn sgr_reset(&mut self) {
self.pen_fg = Color::WHITE;
self.pen_bg = Color::BLACK;
self.pen_attrs = CellAttrs::NONE;
}
}
impl Perform for GridState {
fn print(&mut self, c: char) {
self.blocks.on_print(c);
let w = UnicodeWidthChar::width(c).unwrap_or(1);
if w == 0 {
self.combine_into_previous(c);
return;
}
self.put_char(c, w);
}
fn hook(&mut self, _params: &Params, _intermediates: &[u8], _ignore: bool, action: char) {
if action == 'q' {
self.sixel_in_flight = Some(Vec::new());
}
}
fn put(&mut self, byte: u8) {
if let Some(buf) = self.sixel_in_flight.as_mut() {
if buf.len() < GRAPHIC_PAYLOAD_MAX {
buf.push(byte);
}
}
}
fn unhook(&mut self) {
if let Some(data) = self.sixel_in_flight.take() {
if !data.is_empty() {
let cut = data.len() >= GRAPHIC_PAYLOAD_MAX;
self.push_graphic(GraphicProtocol::Sixel, String::new(), data, cut);
}
}
}
fn execute(&mut self, byte: u8) {
self.wrap_pending = false;
match byte {
b'\n' => self.linefeed(),
b'\r' => self.carriage_return(),
b'\x08' => self.backspace(),
b'\t' => self.tab_forward(),
b'\x07' => {} _ => {}
}
}
fn csi_dispatch(&mut self, params: &Params, intermediates: &[u8], _ignore: bool, c: char) {
if c != 'm' {
self.wrap_pending = false;
}
let first = params
.iter()
.next()
.and_then(|p| p.first().copied())
.unwrap_or(0);
let n = first.max(1) as isize;
if let Some(prefix) = intermediates
.first()
.copied()
.filter(|b| (0x3C..=0x3F).contains(b))
{
if prefix == b'?' && (c == 'h' || c == 'l') {
let set = c == 'h';
for p in params.iter() {
if let Some(&code) = p.first() {
self.apply_dec_mode(code, set);
}
}
}
if prefix == b'>' && c == 'c' {
self.answer(TearCaps::SECONDARY_DA);
}
return;
}
match c {
'n' => match first {
5 => self.answer(TearCaps::STATUS_OK),
6 => {
let row = self.cursor_row + 1;
let col = self.cursor_col + 1;
let mut r = Vec::new();
r.extend_from_slice(b"\x1b[");
r.extend_from_slice(row.to_string().as_bytes());
r.push(b';');
r.extend_from_slice(col.to_string().as_bytes());
r.push(b'R');
self.answer(&r);
}
_ => {}
},
'c' => self.answer(TearCaps::PRIMARY_DA),
'A' => self.cursor_move_relative(-n, 0),
'B' => self.cursor_move_relative(n, 0),
'C' => self.cursor_move_relative(0, n),
'D' => self.cursor_move_relative(0, -n),
'E' => {
self.carriage_return();
self.cursor_move_relative(n, 0);
}
'F' => {
self.carriage_return();
self.cursor_move_relative(-n, 0);
}
'G' => {
let col = first.max(1) as usize - 1;
let row = self.cursor_row;
self.cursor_set(row, col);
}
'H' | 'f' => {
let mut it = params.iter();
let row = it
.next()
.and_then(|p| p.first().copied())
.unwrap_or(1)
.max(1) as usize;
let col = it
.next()
.and_then(|p| p.first().copied())
.unwrap_or(1)
.max(1) as usize;
self.cursor_set(row - 1, col - 1);
}
'J' => match first {
0 => self.erase_below_cursor(),
1 => self.erase_above_cursor(),
2 | 3 => self.erase_all(),
_ => {}
},
'K' => match first {
0 => self.erase_to_end_of_line(),
1 => self.erase_from_start_of_line(),
2 => self.erase_line(),
_ => {}
},
'L' => {
let blank = vec![self.blank_cell(); self.cols];
let row = self.cursor_row;
for _ in 0..n {
if self.alt_active {
if row < self.alternate.len() && row <= self.scroll_bottom {
self.alternate.insert(row, blank.clone());
if self.scroll_bottom + 1 < self.alternate.len() {
self.alternate.remove(self.scroll_bottom + 1);
}
}
} else if row < self.primary.len() && row <= self.scroll_bottom {
self.primary.insert(row, blank.clone());
if self.scroll_bottom + 1 < self.primary.len() {
self.primary.remove(self.scroll_bottom + 1);
}
}
}
}
'M' => {
let blank = vec![self.blank_cell(); self.cols];
let row = self.cursor_row;
for _ in 0..n {
if self.alt_active {
if row < self.alternate.len() && row <= self.scroll_bottom {
self.alternate.remove(row);
let insert_at = (self.scroll_bottom).min(self.alternate.len());
self.alternate.insert(insert_at, blank.clone());
}
} else if row < self.primary.len() && row <= self.scroll_bottom {
self.primary.remove(row);
let insert_at = (self.scroll_bottom).min(self.primary.len());
self.primary.insert(insert_at, blank.clone());
}
}
}
'@' => {
let blank = self.blank_cell();
let row = self.cursor_row;
let col = self.cursor_col;
let cols = self.cols;
if let Some(r) = self.active_row_mut(row) {
for _ in 0..n {
if col < r.len() {
r.insert(col, blank);
r.truncate(cols);
}
}
}
}
'P' => {
let blank = self.blank_cell();
let row = self.cursor_row;
let col = self.cursor_col;
let cols = self.cols;
if let Some(r) = self.active_row_mut(row) {
for _ in 0..n {
if col < r.len() {
r.remove(col);
r.push(blank);
if r.len() > cols {
r.truncate(cols);
}
}
}
}
}
'X' => {
let blank = self.blank_cell();
let row = self.cursor_row;
let col = self.cursor_col;
let n_usize = n as usize;
if let Some(r) = self.active_row_mut(row) {
for i in 0..n_usize {
if col + i < r.len() {
r[col + i] = blank;
}
}
}
}
'b' => {
if let Some(c) = self.last_printed {
for _ in 0..n {
Perform::print(self, c);
}
}
}
'h' => {
for p in params.iter() {
if p.first().copied() == Some(4) {
self.insert_mode = true;
}
}
}
'l' => {
for p in params.iter() {
if p.first().copied() == Some(4) {
self.insert_mode = false;
}
}
}
'S' => {
for _ in 0..n {
self.scroll_region_up();
}
}
'T' => {
for _ in 0..n {
let blank = vec![self.blank_cell(); self.cols];
if self.alt_active {
if self.scroll_top < self.alternate.len() {
self.alternate.insert(self.scroll_top, blank);
if self.scroll_bottom + 1 < self.alternate.len() {
self.alternate.remove(self.scroll_bottom + 1);
}
}
} else if self.scroll_top < self.primary.len() {
self.primary.insert(self.scroll_top, blank);
if self.scroll_bottom + 1 < self.primary.len() {
self.primary.remove(self.scroll_bottom + 1);
}
}
}
}
'd' => {
let row = first.max(1) as usize - 1;
let col = self.cursor_col;
self.cursor_set(row, col);
}
'm' => self.apply_sgr(params),
'r' => {
let mut it = params.iter();
let top = it
.next()
.and_then(|p| p.first().copied())
.unwrap_or(1)
.max(1) as usize
- 1;
let bottom = it
.next()
.and_then(|p| p.first().copied())
.unwrap_or(self.rows as u16)
.max(1) as usize
- 1;
self.scroll_top = top.min(self.rows.saturating_sub(1));
self.scroll_bottom = bottom.min(self.rows.saturating_sub(1));
self.cursor_set(0, 0);
}
's' => self.save_cursor(),
'u' => self.restore_cursor(),
_ => {}
}
}
fn osc_dispatch(&mut self, params: &[&[u8]], _bell_terminated: bool) {
let code = params.first().and_then(|p| std::str::from_utf8(p).ok());
if matches!(code, Some("0") | Some("1") | Some("2")) {
if let Some(t) = params.get(1).and_then(|p| std::str::from_utf8(p).ok()) {
self.title = Some(t.to_owned());
}
return;
}
if matches!(code, Some("7"))
&& let Some(payload) = params.get(1).and_then(|p| std::str::from_utf8(p).ok())
{
self.blocks.set_cwd_from_osc7(payload);
return;
}
if matches!(code, Some("133")) {
let marker: String = params
.iter()
.skip(1)
.filter_map(|p| std::str::from_utf8(p).ok())
.collect::<Vec<_>>()
.join(";");
self.blocks.on_osc_133(&marker);
}
}
fn esc_dispatch(&mut self, _intermediates: &[u8], _ignore: bool, byte: u8) {
match byte {
b'7' => self.save_cursor(),
b'8' => self.restore_cursor(),
b'D' => self.linefeed(),
b'E' => {
self.linefeed();
self.carriage_return();
}
b'M' => {
if self.cursor_row == self.scroll_top {
let blank = vec![self.blank_cell(); self.cols];
if self.alt_active {
if self.scroll_top < self.alternate.len() {
self.alternate.insert(self.scroll_top, blank);
if self.scroll_bottom + 1 < self.alternate.len() {
self.alternate.remove(self.scroll_bottom + 1);
}
}
} else {
self.primary.insert(self.scroll_top, blank);
if self.scroll_bottom + 1 < self.primary.len() {
self.primary.remove(self.scroll_bottom + 1);
}
}
} else if self.cursor_row > 0 {
self.cursor_row -= 1;
}
}
b'c' => {
self.sgr_reset();
self.erase_all();
self.cursor_set(0, 0);
self.scroll_top = 0;
self.scroll_bottom = self.rows.saturating_sub(1);
self.alt_active = false;
self.saved = None;
self.cursor_keys_mode = false;
self.cursor_visible = true;
self.title = None;
}
_ => {}
}
}
}
impl GridState {
fn apply_dec_mode(&mut self, code: u16, set: bool) {
match code {
47 => {
if set {
self.enter_alt_screen(false);
} else {
self.leave_alt_screen();
}
}
1047 => {
if set {
self.enter_alt_screen(true);
} else {
self.erase_all();
self.leave_alt_screen();
}
}
1049 => {
if set {
self.save_cursor();
self.enter_alt_screen(true);
} else {
self.erase_all();
self.leave_alt_screen();
self.restore_cursor();
}
}
1 => self.cursor_keys_mode = set, 25 => self.cursor_visible = set, 7 => self.autowrap = set, 1004 => self.focus_reporting = set, 2004 => self.bracketed_paste = set, 2026 => self.sync_output = set, 1000 => self.mouse = if set { MouseTracking::Click } else { MouseTracking::Off },
1002 => self.mouse = if set { MouseTracking::Drag } else { MouseTracking::Off },
1003 => self.mouse = if set { MouseTracking::Motion } else { MouseTracking::Off },
1006 => self.mouse_sgr = set, _ => {}
}
}
}
impl PaneGrid {
#[must_use]
pub(crate) fn new(cols: usize, rows: usize) -> Self {
Self::with_scrollback(cols, rows, DEFAULT_SCROLLBACK_ROWS)
}
#[must_use]
pub(crate) fn with_scrollback(cols: usize, rows: usize, scrollback_cap: usize) -> Self {
Self {
parser: Parser::new(),
state: GridState::new(cols, rows, scrollback_cap),
apc: ApcScanner::default(),
}
}
pub(crate) fn feed(&mut self, bytes: &[u8]) {
let (passthrough, apcs) = self.apc.split(bytes);
self.parser.advance(&mut self.state, &passthrough);
for (payload, cut) in apcs {
self.state.ingest_apc(&payload, cut);
}
}
#[must_use]
pub fn modes(&self) -> ModeSet {
let s = &self.state;
ModeSet {
bracketed_paste: BracketedPaste::new(s.bracketed_paste),
cursor_keys: CursorKeys::new(s.cursor_keys_mode),
focus_reporting: FocusReporting::new(s.focus_reporting),
sync_output: SyncOutput::new(s.sync_output),
mouse: s.mouse,
mouse_sgr: MouseSgr::new(s.mouse_sgr),
cursor_visible: CursorVisible::new(s.cursor_visible),
autowrap: AutoWrap::new(s.autowrap),
alt_screen: AltScreen::new(s.alt_active),
}
}
pub(crate) fn set_host_role(&mut self, role: HostRole) {
self.state.role = role;
}
#[must_use]
pub(crate) fn take_response(&mut self) -> Option<Vec<u8>> {
if self.state.pending_response.is_empty() {
None
} else {
Some(std::mem::take(&mut self.state.pending_response))
}
}
#[must_use]
pub fn snapshot(&self) -> PaneSnapshot {
let cells: Vec<Vec<Cell>> = self.state.active_rows().cloned().collect();
let scrollback: Vec<Vec<Cell>> = if self.state.alt_active {
Vec::new()
} else {
self.state.scrollback.iter().cloned().collect()
};
PaneSnapshot {
rows: self.state.rows,
cols: self.state.cols,
cells,
cursor_row: self.state.cursor_row,
cursor_col: self.state.cursor_col,
alt_screen_active: self.state.alt_active,
cursor_visible: self.state.cursor_visible,
title: self.state.title.clone(),
cursor_keys_mode: self.state.cursor_keys_mode,
scrollback,
combining: self.state.combining.clone(),
modes: self.modes(),
graphics: self.state.graphics.clone(),
}
}
#[must_use]
pub fn title(&self) -> Option<&str> {
self.state.title.as_deref()
}
pub fn stamp_yurai(&mut self, y: tear_types::Yurai) -> bool {
self.state.blocks.stamp_yurai(y)
}
#[must_use]
pub fn yurai(&self) -> &tear_types::Yurai {
self.state.blocks.yurai()
}
#[must_use]
pub fn cursor_keys_mode(&self) -> bool {
self.state.cursor_keys_mode
}
#[must_use]
pub fn scrollback_len(&self) -> usize {
self.state.scrollback.len()
}
pub fn resize(&mut self, cols: usize, rows: usize) {
let mut new_primary: VecDeque<Vec<Cell>> = VecDeque::with_capacity(rows);
for r in 0..rows {
let mut new_row = vec![Cell::BLANK; cols];
if let Some(existing) = self.state.primary.get(r) {
let n = existing.len().min(cols);
new_row[..n].copy_from_slice(&existing[..n]);
}
new_primary.push_back(new_row);
}
let mut new_alt = vec![vec![Cell::BLANK; cols]; rows];
for r in 0..rows.min(self.state.alternate.len()) {
let existing = &self.state.alternate[r];
let n = existing.len().min(cols);
new_alt[r][..n].copy_from_slice(&existing[..n]);
}
self.state.primary = new_primary;
self.state.alternate = new_alt;
self.state.rows = rows;
self.state.cols = cols;
self.state.cursor_row = self.state.cursor_row.min(rows.saturating_sub(1));
self.state.cursor_col = self.state.cursor_col.min(cols.saturating_sub(1));
self.state.scroll_top = 0;
self.state.scroll_bottom = rows.saturating_sub(1);
}
}
#[cfg(test)]
mod width_parity {
use super::*;
#[test]
fn wide_glyph_advances_two_columns() {
let mut g = PaneGrid::new(20, 3);
g.feed("你".as_bytes());
let s = g.snapshot();
assert_eq!(s.cells[0][0].ch, '你', "lead cell holds the glyph");
assert_eq!(s.cells[0][0].width, 2, "lead is marked double-width");
assert_eq!(s.cells[0][1].width, 0, "col 1 is a continuation cell");
assert_eq!(s.cursor_col, 2, "cursor advances by the glyph's WIDTH");
}
#[test]
fn later_cells_are_not_displaced_by_wide_glyphs() {
let mut g = PaneGrid::new(20, 3);
g.feed("你好X".as_bytes());
let s = g.snapshot();
assert_eq!(s.cells[0][0].ch, '你');
assert_eq!(s.cells[0][2].ch, '好', "second glyph starts at col 2, not 1");
assert_eq!(s.cells[0][4].ch, 'X', "ASCII lands at col 4, not 2");
assert_eq!(s.cursor_col, 5);
}
#[test]
fn wide_glyph_that_does_not_fit_wraps_whole() {
let mut g = PaneGrid::new(20, 3);
g.feed("A".repeat(19).as_bytes());
g.feed("你".as_bytes());
let s = g.snapshot();
assert_eq!(s.cells[0][19].ch, ' ', "last col of row 0 stays blank");
assert_eq!(s.cells[1][0].ch, '你', "glyph moved to the next row whole");
assert_eq!(s.cells[1][1].width, 0);
}
#[test]
fn wide_glyph_flush_to_margin_parks_at_last_column() {
let mut g = PaneGrid::new(20, 3);
g.feed("A".repeat(18).as_bytes());
g.feed("你".as_bytes());
let s = g.snapshot();
assert_eq!(s.cells[0][18].ch, '你');
assert_eq!(s.cells[0][19].width, 0);
assert_eq!(s.cursor_col, 19, "parked at the last column, not at 18");
}
#[test]
fn overwriting_a_wide_pair_clears_its_orphan() {
let mut g = PaneGrid::new(20, 3);
g.feed("你".as_bytes());
g.feed(b"\x1b[1;1H");
g.feed(b"X");
let s = g.snapshot();
assert_eq!(s.cells[0][0].ch, 'X');
assert_eq!(s.cells[0][0].width, 1);
assert_eq!(
s.cells[0][1].ch, ' ',
"the orphaned continuation is cleared, not left as a half-glyph"
);
assert_eq!(s.cells[0][1].width, 1);
}
#[test]
fn a_combining_mark_attaches_to_the_base_cell() {
let mut g = PaneGrid::new(20, 3);
g.feed("e\u{301}X".as_bytes()); let s = g.snapshot();
assert_eq!(s.cells[0][0].ch, 'e');
assert_eq!(
s.cells[0][0].marks(&s.combining),
&['\u{301}'],
"the mark belongs to the base cell"
);
assert_eq!(s.cells[0][1].ch, 'X', "X is at col 1, not col 2");
assert_eq!(s.cursor_col, 2, "a mark consumes no column");
}
#[test]
fn stacked_marks_accumulate_on_one_cell() {
let mut g = PaneGrid::new(20, 3);
g.feed("a\u{301}\u{308}".as_bytes());
let s = g.snapshot();
assert_eq!(s.cells[0][0].marks(&s.combining), &['\u{301}', '\u{308}']);
assert_eq!(s.cursor_col, 1);
}
#[test]
fn a_mark_after_a_margin_flush_wide_glyph_lands_on_the_lead() {
let mut g = PaneGrid::new(20, 3);
g.feed("A".repeat(18).as_bytes());
g.feed("你\u{301}".as_bytes());
let s = g.snapshot();
assert_eq!(s.cells[0][18].ch, '你', "lead at col 18");
assert_eq!(
s.cells[0][18].marks(&s.combining),
&['\u{301}'],
"the mark must attach to the LEAD, not the continuation"
);
assert!(
s.cells[0][19].marks(&s.combining).is_empty(),
"the continuation owns no marks"
);
}
#[test]
fn a_mark_at_column_zero_is_dropped() {
let mut g = PaneGrid::new(20, 3);
g.feed("\u{301}".as_bytes());
let s = g.snapshot();
assert_eq!(s.cursor_col, 0, "no column consumed");
assert!(s.combining.is_empty(), "no table entry minted");
assert_eq!(s.cells[0][0].ch, ' ');
}
#[test]
fn rep_after_a_mark_repeats_the_base_glyph() {
let mut g = PaneGrid::new(20, 3);
g.feed("e\u{301}".as_bytes());
g.feed(b"\x1b[2b");
let s = g.snapshot();
assert_eq!(s.cells[0][1].ch, 'e', "REP repeats the base, not the mark");
assert_eq!(s.cells[0][2].ch, 'e');
}
#[test]
fn marks_survive_a_to_ansi_round_trip() {
let mut a = PaneGrid::new(20, 3);
a.feed("e\u{301}X".as_bytes());
let first = a.snapshot();
let mut b = PaneGrid::new(20, 3);
b.feed(&first.to_ansi());
let second = b.snapshot();
assert_eq!(second.cells[0][0].ch, 'e');
assert_eq!(second.cells[0][0].marks(&second.combining), &['\u{301}']);
assert_eq!(second.cells[0][1].ch, 'X');
}
#[test]
fn to_ansi_round_trips_wide_glyphs_without_drift() {
let mut a = PaneGrid::new(20, 3);
a.feed("你好X".as_bytes());
let first = a.snapshot();
let mut b = PaneGrid::new(20, 3);
b.feed(&first.to_ansi());
let second = b.snapshot();
for col in 0..20 {
assert_eq!(
first.cells[0][col].ch, second.cells[0][col].ch,
"col {col} drifted across a to_ansi round-trip"
);
assert_eq!(
first.cells[0][col].width, second.cells[0][col].width,
"col {col} width drifted across a to_ansi round-trip"
);
}
}
}
#[cfg(test)]
mod graphics_rows {
use super::*;
#[test]
fn a_sixel_payload_reaches_the_snapshot() {
let mut g = PaneGrid::new(80, 24);
g.feed(b"\x1bPq#0;2;0;0;0#0~~@@vv@@~~@@~~$\x1b\\");
let s = g.snapshot();
assert_eq!(s.graphics.len(), 1, "the sixel must not vanish");
assert_eq!(s.graphics[0].protocol, GraphicProtocol::Sixel);
assert!(!s.graphics[0].data.is_empty());
assert!(!s.graphics[0].truncated);
}
#[test]
fn a_kitty_payload_reaches_the_snapshot_with_its_params_split_off() {
let mut g = PaneGrid::new(80, 24);
g.feed(b"\x1b_Ga=T,f=100,s=2,v=2;iVBORw0KGgo=\x1b\\");
let s = g.snapshot();
assert_eq!(s.graphics.len(), 1, "the kitty image must not vanish");
let img = &s.graphics[0];
assert_eq!(img.protocol, GraphicProtocol::Kitty);
assert_eq!(img.params, "a=T,f=100,s=2,v=2");
assert_eq!(img.data, b"iVBORw0KGgo=".to_vec());
}
#[test]
fn an_apc_split_across_feeds_reassembles() {
let whole = b"\x1b_Ga=T,f=100;PAYLOAD\x1b\\";
for cut in 1..whole.len() {
let mut g = PaneGrid::new(80, 24);
g.feed(&whole[..cut]);
g.feed(&whole[cut..]);
let s = g.snapshot();
assert_eq!(s.graphics.len(), 1, "lost the image when cut at {cut}");
assert_eq!(s.graphics[0].data, b"PAYLOAD".to_vec(), "cut at {cut}");
assert!(
s.to_text_rows().iter().all(|r| r.trim().is_empty()),
"APC bytes leaked into the grid when cut at {cut}"
);
}
}
#[test]
fn a_non_apc_escape_still_reaches_the_parser() {
let mut g = PaneGrid::new(80, 24);
g.feed(b"AB\x1b");
g.feed(b"[1;1HX");
let s = g.snapshot();
assert_eq!(
s.cells[0][0].ch, 'X',
"the CUP that followed a withheld ESC must still be honoured"
);
}
#[test]
fn an_apc_terminated_by_bel_is_accepted() {
let mut g = PaneGrid::new(80, 24);
g.feed(b"\x1b_Ga=T;DATA\x07");
assert_eq!(g.snapshot().graphics.len(), 1, "BEL terminates APC too");
}
#[test]
fn a_kitty_control_command_without_a_payload_is_kept() {
let mut g = PaneGrid::new(80, 24);
g.feed(b"\x1b_Ga=d,d=A\x1b\\");
let s = g.snapshot();
assert_eq!(s.graphics.len(), 1);
assert_eq!(s.graphics[0].params, "a=d,d=A");
assert!(s.graphics[0].data.is_empty());
}
#[test]
fn an_unrecognised_apc_is_dropped_without_reaching_the_grid() {
let mut g = PaneGrid::new(80, 24);
g.feed(b"\x1b_Zsomething-else\x1b\\after");
let s = g.snapshot();
assert!(s.graphics.is_empty(), "not a kitty payload");
assert_eq!(s.cells[0][0].ch, 'a', "the text after it still lands");
}
#[test]
fn an_oversized_payload_is_bounded_and_flagged() {
let mut g = PaneGrid::new(80, 24);
g.feed(b"\x1b_Ga=T;");
let chunk = vec![b'x'; 1024 * 1024];
for _ in 0..10 {
g.feed(&chunk);
}
g.feed(b"\x1b\\");
let s = g.snapshot();
assert_eq!(s.graphics.len(), 1);
assert!(s.graphics[0].truncated, "the cut must be visible");
assert!(
s.graphics[0].data.len() <= GRAPHIC_PAYLOAD_MAX + 1,
"payload not bounded: {}",
s.graphics[0].data.len()
);
}
#[test]
fn image_bytes_leave_no_residue_in_the_grid() {
let mut g = PaneGrid::new(80, 24);
g.feed(b"before|");
g.feed(b"\x1b_Ga=T,f=100;iVBORw0KGgo=\x1b\\");
g.feed(b"\x1bPq#0;2;0;0;0#0~~$\x1b\\");
g.feed(b"|after");
let row0 = g.snapshot().to_text_rows().into_iter().next().unwrap();
assert_eq!(row0.trim_end(), "before||after");
}
}
#[cfg(test)]
mod mode_rows {
use super::*;
#[test]
fn a_fresh_pane_reports_xterm_defaults() {
let g = PaneGrid::new(80, 24);
let m = g.modes();
assert!(m.autowrap.enabled(), "DECAWM is ON by default per xterm");
assert!(m.cursor_visible.enabled());
assert!(!m.bracketed_paste.enabled());
assert!(!m.sync_output.enabled());
assert!(!m.mouse.is_on());
}
#[test]
fn bracketed_paste_is_tracked() {
let mut g = PaneGrid::new(80, 24);
assert!(!g.modes().bracketed_paste.enabled());
g.feed(b"\x1b[?2004h");
assert!(g.modes().bracketed_paste.enabled(), "DEC 2004 set");
g.feed(b"\x1b[?2004l");
assert!(!g.modes().bracketed_paste.enabled(), "DEC 2004 reset");
}
#[test]
fn the_remaining_flag_modes_are_tracked() {
let mut g = PaneGrid::new(80, 24);
g.feed(b"\x1b[?1004h\x1b[?2026h\x1b[?1006h\x1b[?7l\x1b[?1h\x1b[?25l");
let m = g.modes();
assert!(m.focus_reporting.enabled(), "DEC 1004");
assert!(m.sync_output.enabled(), "DEC 2026");
assert!(m.mouse_sgr.enabled(), "DEC 1006");
assert!(!m.autowrap.enabled(), "DEC 7 reset");
assert!(m.cursor_keys.enabled(), "DEC 1 (DECCKM)");
assert!(!m.cursor_visible.enabled(), "DEC 25 reset");
}
#[test]
fn mouse_tracking_levels_replace_rather_than_accumulate() {
let mut g = PaneGrid::new(80, 24);
g.feed(b"\x1b[?1000h");
assert_eq!(g.modes().mouse, MouseTracking::Click);
g.feed(b"\x1b[?1003h");
assert_eq!(
g.modes().mouse,
MouseTracking::Motion,
"the later level replaces the earlier one"
);
g.feed(b"\x1b[?1003l");
assert_eq!(g.modes().mouse, MouseTracking::Off);
}
#[test]
fn alt_screen_is_reported_as_a_mode() {
let mut g = PaneGrid::new(80, 24);
assert!(!g.modes().alt_screen.enabled());
g.feed(b"\x1b[?1049h");
assert!(g.modes().alt_screen.enabled());
g.feed(b"\x1b[?1049l");
assert!(!g.modes().alt_screen.enabled());
}
}
#[cfg(test)]
mod host_role_rows {
use super::*;
#[test]
fn a_relay_answers_nothing_at_all() {
let mut g = PaneGrid::new(80, 24);
g.feed(b"\x1b[6n\x1b[5n\x1b[c\x1b[>c");
assert!(
g.take_response().is_none(),
"a Relay must stay byte-for-byte silent — otherwise the shipped \
mado+tear pair produces two answers per query"
);
}
#[test]
fn a_host_answers_cursor_position_one_based() {
let mut g = PaneGrid::new(80, 24);
g.set_host_role(HostRole::Host);
g.feed(b"hi\r\n");
g.feed(b"\x1b[6n");
let r = g.take_response().expect("host must answer CPR");
assert_eq!(r, b"\x1b[2;1R".to_vec());
}
#[test]
fn a_host_reports_the_clamped_column_after_a_margin_flush_wide_glyph() {
let mut g = PaneGrid::new(20, 3);
g.set_host_role(HostRole::Host);
g.feed("A".repeat(18).as_bytes());
g.feed("你".as_bytes());
g.feed(b"\x1b[6n");
let r = g.take_response().expect("host must answer CPR");
assert_eq!(r, b"\x1b[1;20R".to_vec(), "column is 1-based and clamped");
}
#[test]
fn a_host_answers_device_status_and_both_device_attributes() {
let mut g = PaneGrid::new(80, 24);
g.set_host_role(HostRole::Host);
g.feed(b"\x1b[5n");
assert_eq!(g.take_response().unwrap(), TearCaps::STATUS_OK.to_vec());
g.feed(b"\x1b[c");
assert_eq!(g.take_response().unwrap(), TearCaps::PRIMARY_DA.to_vec());
g.feed(b"\x1b[>c");
assert_eq!(g.take_response().unwrap(), TearCaps::SECONDARY_DA.to_vec());
}
#[test]
fn a_query_leaves_no_residue_in_the_rendered_grid() {
for role in [HostRole::Relay, HostRole::Host] {
let mut g = PaneGrid::new(80, 24);
g.set_host_role(role);
g.feed(b"before|");
g.feed(b"\x1b[6n");
g.feed(b"|after");
let row0 = g.snapshot().to_text_rows().into_iter().next().unwrap();
assert_eq!(
row0.trim_end(),
"before||after",
"query bytes must never reach the grid ({role:?})"
);
}
}
#[test]
fn taking_a_response_drains_it() {
let mut g = PaneGrid::new(80, 24);
g.set_host_role(HostRole::Host);
g.feed(b"\x1b[5n");
assert!(g.take_response().is_some());
assert!(g.take_response().is_none(), "a reply is delivered once");
}
}
#[cfg(test)]
mod perf_measurements {
use super::*;
use std::time::Instant;
#[test]
#[ignore = "measurement, not an assertion"]
fn snapshot_cost_by_scrollback_depth() {
for rows in [1_000usize, 10_000, 100_000] {
let mut g = PaneGrid::new(80, 24);
for i in 0..rows {
g.feed(format!("line {i} with some ordinary ascii payload\r\n").as_bytes());
}
let _ = g.snapshot();
let t = Instant::now();
const N: u32 = 10;
for _ in 0..N {
let s = g.snapshot();
std::hint::black_box(&s);
}
let per = t.elapsed() / N;
let sb = g.snapshot().scrollback.len();
println!("scrollback {sb:>7} rows -> snapshot {per:?} each");
}
}
#[test]
#[ignore = "measurement, not an assertion"]
fn snapshot_cost_with_and_without_combining_marks() {
let mut plain = PaneGrid::new(80, 24);
let mut marked = PaneGrid::new(80, 24);
for _ in 0..5_000 {
plain.feed(b"plain ascii line here\r\n");
marked.feed("ma\u{301}rked li\u{308}ne he\u{301}re\r\n".as_bytes());
}
for (name, g) in [("plain", &plain), ("marked", &marked)] {
let _ = g.snapshot();
let t = Instant::now();
const N: u32 = 10;
for _ in 0..N {
std::hint::black_box(g.snapshot());
}
let s = g.snapshot();
println!(
"{name:>7}: snapshot {:?} each, combining table {} entries",
t.elapsed() / N,
s.combining.len()
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tear_types::pane_snapshot::{CellAttrs, Color};
#[test]
fn print_plain_text() {
let mut g = PaneGrid::new(10, 3);
g.feed(b"hi");
let snap = g.snapshot();
assert_eq!(snap.cells[0][0].ch, 'h');
assert_eq!(snap.cells[0][1].ch, 'i');
assert_eq!(snap.cursor_row, 0);
assert_eq!(snap.cursor_col, 2);
}
#[test]
fn newline_advances_row() {
let mut g = PaneGrid::new(10, 3);
g.feed(b"hi\r\nworld");
let snap = g.snapshot();
assert_eq!(snap.cells[0][0].ch, 'h');
assert_eq!(snap.cells[1][0].ch, 'w');
assert_eq!(snap.cursor_row, 1);
assert_eq!(snap.cursor_col, 5);
}
#[test]
fn cursor_move_csi_cup() {
let mut g = PaneGrid::new(10, 5);
g.feed(b"\x1b[3;5H");
let snap = g.snapshot();
assert_eq!(snap.cursor_row, 2);
assert_eq!(snap.cursor_col, 4);
}
#[test]
fn erase_in_display_clear_all() {
let mut g = PaneGrid::new(5, 2);
g.feed(b"abcde\r\nfghij");
g.feed(b"\x1b[2J");
let snap = g.snapshot();
for row in snap.cells {
for cell in row {
assert_eq!(cell.ch, ' ');
}
}
}
#[test]
fn auto_wrap_overflows_to_next_row() {
let mut g = PaneGrid::new(3, 3);
g.feed(b"abcdef");
let snap = g.snapshot();
assert_eq!(snap.cells[0][2].ch, 'c');
assert_eq!(snap.cells[1][0].ch, 'd');
}
#[test]
fn scroll_into_scrollback_on_overflow() {
let mut g = PaneGrid::with_scrollback(3, 2, 100);
g.feed(b"a\r\nb\r\nc");
let snap = g.snapshot();
assert_eq!(snap.cells[0][0].ch, 'b');
assert_eq!(snap.cells[1][0].ch, 'c');
assert!(g.scrollback_len() >= 1);
}
#[test]
fn sgr_red_foreground_sticks_through_a_word() {
let mut g = PaneGrid::new(10, 1);
g.feed(b"\x1b[31mRED\x1b[0m");
let snap = g.snapshot();
let red = tear_types::pane_snapshot::ANSI_COLORS[1];
assert_eq!(snap.cells[0][0].ch, 'R');
assert_eq!(snap.cells[0][0].fg, red);
assert_eq!(snap.cells[0][1].fg, red);
assert_eq!(snap.cells[0][2].fg, red);
}
#[test]
fn sgr_truecolor_fg() {
let mut g = PaneGrid::new(10, 1);
g.feed(b"\x1b[38;2;200;100;50mORANGE");
let snap = g.snapshot();
assert_eq!(snap.cells[0][0].fg, Color::new(200, 100, 50));
assert_eq!(snap.cells[0][5].fg, Color::new(200, 100, 50));
}
#[test]
fn sgr_256_color_index() {
let mut g = PaneGrid::new(10, 1);
g.feed(b"\x1b[38;5;196mX");
let snap = g.snapshot();
assert!(snap.cells[0][0].fg.r > 200);
}
#[test]
fn sgr_bold_attr_sticks() {
let mut g = PaneGrid::new(10, 1);
g.feed(b"\x1b[1mBOLD");
let snap = g.snapshot();
assert!(snap.cells[0][0].attrs.contains(CellAttrs::BOLD));
}
#[test]
fn no_sgr_form_leaves_underline_stuck_on_the_pen() {
let cases: &[(&str, &[u8])] = &[
("4m then 24m", b"\x1b[4mU\x1b[24mX"),
("4m then 0m", b"\x1b[4mU\x1b[0mX"),
("4:3m then 4:0m", b"\x1b[4:3mU\x1b[4:0mX"),
("4:3m then 24m", b"\x1b[4:3mU\x1b[24mX"),
("21m (double-underline)", b"\x1b[21mX"),
("58:2::255:0:0 then 59m", b"\x1b[58:2::255:0:0mU\x1b[59mX"),
("fg truecolor semicolon", b"\x1b[38;2;177;185;249mX"),
("fg truecolor COLON", b"\x1b[38:2::177:185:249mX"),
("fg 256 semicolon", b"\x1b[38;5;4mX"),
("fg 256 COLON", b"\x1b[38:5:4mX"),
("bold+italic only", b"\x1b[1;3mX"),
];
let mut leaked = Vec::new();
for (name, bytes) in cases {
let mut g = PaneGrid::new(20, 1);
g.feed(bytes);
let snap = g.snapshot();
let marker = snap.cells[0]
.iter()
.rev()
.find(|c| c.ch == 'X')
.expect("marker X present");
if marker.attrs.contains(CellAttrs::UNDERLINE) {
leaked.push(*name);
}
}
assert!(
leaked.is_empty(),
"these SGR forms leave UNDERLINE stuck on the pen: {leaked:?}"
);
}
fn marker_attrs(seq: &[u8]) -> CellAttrs {
let mut g = PaneGrid::new(20, 1);
let mut buf = seq.to_vec();
buf.push(b'X');
g.feed(&buf);
g.snapshot().cells[0]
.iter()
.rev()
.find(|c| c.ch == 'X')
.expect("marker X present")
.attrs
}
#[test]
fn xtmodkeys_is_not_sgr() {
let attrs = marker_attrs(b"\x1b[>4;2m");
assert!(
!attrs.contains(CellAttrs::UNDERLINE),
"CSI >4;2m (XTMODKEYS) must not set UNDERLINE"
);
assert!(
!attrs.contains(CellAttrs::DIM),
"CSI >4;2m (XTMODKEYS) must not set DIM"
);
assert_eq!(attrs, CellAttrs::NONE, "XTMODKEYS must touch no attribute");
}
#[test]
fn private_parameter_csi_never_runs_the_standard_command() {
for seq in [
&b"\x1b[>4;2m"[..],
&b"\x1b[>1m"[..],
&b"\x1b[?4m"[..],
&b"\x1b[=4m"[..],
&b"\x1b[<4m"[..],
] {
assert_eq!(
marker_attrs(seq),
CellAttrs::NONE,
"private CSI {:?} must not act as SGR",
String::from_utf8_lossy(seq),
);
}
for seq in [
&b"\x1b[>5A"[..],
&b"\x1b[>5C"[..],
&b"\x1b[?5G"[..],
&b"\x1b[>2;3H"[..],
] {
let mut g = PaneGrid::new(20, 3);
g.feed(b"\x1b[H");
g.feed(seq);
let snap = g.snapshot();
assert_eq!(
(snap.cursor_row, snap.cursor_col),
(0, 0),
"private CSI {:?} must not move the cursor",
String::from_utf8_lossy(seq),
);
}
let mut g = PaneGrid::new(20, 1);
g.feed(b"keep\x1b[H\x1b[?2J\x1b[?0K");
let row: String = g.snapshot().cells[0].iter().map(|c| c.ch).collect();
assert!(
row.starts_with("keep"),
"private CSI ?J/?K must not erase; row was {row:?}"
);
}
fn marker_fg(seq: &[u8]) -> Color {
let mut g = PaneGrid::new(20, 1);
let mut buf = seq.to_vec();
buf.push(b'X');
g.feed(&buf);
g.snapshot().cells[0]
.iter()
.rev()
.find(|c| c.ch == 'X')
.expect("marker X present")
.fg
}
#[test]
fn semicolon_and_colon_extended_colour_agree() {
let cases: &[(&[u8], &[u8], Color)] = &[
(
b"\x1b[38;2;248;248;242m",
b"\x1b[38:2::248:248:242m",
Color::new(248, 248, 242),
),
(
b"\x1b[38;2;177;185;249m",
b"\x1b[38:2::177:185:249m",
Color::new(177, 185, 249),
),
(
b"\x1b[38;2;4;4;4m",
b"\x1b[38:2::4:4:4m",
Color::new(4, 4, 4),
),
];
for (semi, colon, want) in cases {
assert_eq!(marker_fg(semi), *want, "semicolon form {semi:?}");
assert_eq!(marker_fg(colon), *want, "COLON form {colon:?}");
}
assert_eq!(
marker_fg(b"\x1b[38:2:10:20:30m"),
Color::new(10, 20, 30),
"5-slot colon truecolor"
);
}
#[test]
fn extended_colour_never_leaks_an_attribute() {
let mut leaked = Vec::new();
for seq in [
&b"\x1b[38;2;4;4;4m"[..],
&b"\x1b[38:2::4:4:4m"[..],
&b"\x1b[48;2;4;4;4m"[..],
&b"\x1b[48:2::4:4:4m"[..],
&b"\x1b[38;5;4m"[..],
&b"\x1b[38:5:4m"[..],
&b"\x1b[48;5;4m"[..],
&b"\x1b[58;5;4m"[..],
&b"\x1b[58;2;4;4;4m"[..],
&b"\x1b[58:2::255:0:0m"[..],
&b"\x1b[59m"[..],
&b"\x1b[38m"[..],
&b"\x1b[38;2m"[..],
&b"\x1b[38;5m"[..],
] {
if marker_attrs(seq) != CellAttrs::NONE {
leaked.push(String::from_utf8_lossy(seq).replace('\x1b', "ESC"));
}
}
assert!(
leaked.is_empty(),
"these forms leaked an attribute: {leaked:?}"
);
}
#[test]
fn styled_underline_subparams() {
assert!(marker_attrs(b"\x1b[4:3m").contains(CellAttrs::UNDERLINE));
assert!(
!marker_attrs(b"\x1b[4:3m").contains(CellAttrs::ITALIC),
"4:3 is a curly underline, not underline + italic"
);
assert!(!marker_attrs(b"\x1b[4:0m").contains(CellAttrs::UNDERLINE));
assert!(!marker_attrs(b"\x1b[4mU\x1b[4:0m").contains(CellAttrs::UNDERLINE));
}
#[test]
fn plain_sgr_still_works() {
assert!(marker_attrs(b"\x1b[1m").contains(CellAttrs::BOLD));
assert!(marker_attrs(b"\x1b[3m").contains(CellAttrs::ITALIC));
assert!(marker_attrs(b"\x1b[1;3m").contains(CellAttrs::BOLD));
assert!(marker_attrs(b"\x1b[1;3m").contains(CellAttrs::ITALIC));
assert_eq!(marker_attrs(b"\x1b[1;3m\x1b[0m"), CellAttrs::NONE);
assert_eq!(
marker_attrs(b"\x1b[1m\x1b[m"),
CellAttrs::NONE,
"bare ESC[m resets"
);
assert_eq!(marker_fg(b"\x1b[31m"), default_ansi_palette()[1]);
assert_eq!(marker_fg(b"\x1b[31m\x1b[39m"), Color::WHITE);
}
#[test]
fn dec_private_modes_still_dispatch() {
let mut g = PaneGrid::new(20, 2);
g.feed(b"\x1b[?25l");
assert!(
!g.snapshot().cursor_visible,
"DECTCEM reset must hide cursor"
);
g.feed(b"\x1b[?25h");
assert!(g.snapshot().cursor_visible, "DECTCEM set must show cursor");
}
#[test]
fn sgr_reset_returns_default_pen() {
let mut g = PaneGrid::new(10, 1);
g.feed(b"\x1b[31m\x1b[0mX");
let snap = g.snapshot();
assert_eq!(snap.cells[0][0].fg, Color::WHITE);
}
#[test]
fn alt_screen_isolates_writes_and_preserves_primary() {
let mut g = PaneGrid::new(5, 2);
g.feed(b"AAAAA\r\nBBBBB");
g.feed(b"\x1b[?1049h");
let alt_snap = g.snapshot();
assert!(alt_snap.alt_screen_active);
assert_eq!(alt_snap.cells[0][0].ch, ' ');
g.feed(b"ZZZZZ");
g.feed(b"\x1b[?1049l");
let primary_snap = g.snapshot();
assert!(!primary_snap.alt_screen_active);
assert_eq!(primary_snap.cells[0][0].ch, 'A');
assert_eq!(primary_snap.cells[1][0].ch, 'B');
}
#[test]
fn save_restore_cursor_via_decsc_decrc() {
let mut g = PaneGrid::new(10, 5);
g.feed(b"\x1b[3;5H");
g.feed(b"\x1b7"); g.feed(b"\x1b[1;1H");
g.feed(b"\x1b8"); let snap = g.snapshot();
assert_eq!(snap.cursor_row, 2);
assert_eq!(snap.cursor_col, 4);
}
#[test]
fn snapshot_text_helpers() {
let mut g = PaneGrid::new(5, 2);
g.feed(b"hi\r\nbye");
let snap = g.snapshot();
let rows = snap.to_text_rows();
assert_eq!(rows[0], "hi ");
assert_eq!(rows[1], "bye ");
}
#[test]
fn osc_2_sets_window_title() {
let mut g = PaneGrid::new(10, 1);
g.feed(b"\x1b]2;hello world\x07");
assert_eq!(g.title(), Some("hello world"));
let snap = g.snapshot();
assert_eq!(snap.title.as_deref(), Some("hello world"));
}
#[test]
fn dec_25_hides_cursor() {
let mut g = PaneGrid::new(10, 1);
let snap_before = g.snapshot();
assert!(snap_before.cursor_visible);
g.feed(b"\x1b[?25l");
let snap_hidden = g.snapshot();
assert!(!snap_hidden.cursor_visible);
g.feed(b"\x1b[?25h");
let snap_back = g.snapshot();
assert!(snap_back.cursor_visible);
}
#[test]
fn ich_inserts_cells_and_shifts_right() {
let mut g = PaneGrid::new(6, 1);
g.feed(b"abcdef");
g.feed(b"\x1b[1;1H"); g.feed(b"\x1b[2@"); let snap = g.snapshot();
assert_eq!(snap.cells[0][0].ch, ' ');
assert_eq!(snap.cells[0][1].ch, ' ');
assert_eq!(snap.cells[0][2].ch, 'a');
assert_eq!(snap.cells[0][3].ch, 'b');
}
#[test]
fn dch_deletes_cells_and_shifts_left() {
let mut g = PaneGrid::new(6, 1);
g.feed(b"abcdef");
g.feed(b"\x1b[1;2H"); g.feed(b"\x1b[2P"); let snap = g.snapshot();
assert_eq!(snap.cells[0][0].ch, 'a');
assert_eq!(snap.cells[0][1].ch, 'd');
assert_eq!(snap.cells[0][2].ch, 'e');
assert_eq!(snap.cells[0][3].ch, 'f');
}
#[test]
fn ech_erases_in_place() {
let mut g = PaneGrid::new(6, 1);
g.feed(b"abcdef");
g.feed(b"\x1b[1;2H");
g.feed(b"\x1b[2X"); let snap = g.snapshot();
assert_eq!(snap.cells[0][0].ch, 'a');
assert_eq!(snap.cells[0][1].ch, ' ');
assert_eq!(snap.cells[0][2].ch, ' ');
assert_eq!(snap.cells[0][3].ch, 'd');
}
#[test]
fn il_dl_insert_delete_line() {
let mut g = PaneGrid::new(3, 4);
g.feed(b"AAA\r\nBBB\r\nCCC\r\nDDD");
g.feed(b"\x1b[2;1H"); g.feed(b"\x1b[1L"); let snap1 = g.snapshot();
assert_eq!(snap1.cells[0][0].ch, 'A');
assert_eq!(snap1.cells[1][0].ch, ' ');
assert_eq!(snap1.cells[2][0].ch, 'B');
g.feed(b"\x1b[1M"); let snap2 = g.snapshot();
assert_eq!(snap2.cells[1][0].ch, 'B');
}
#[test]
fn rep_repeats_last_printable_char() {
let mut g = PaneGrid::new(10, 1);
g.feed(b"X\x1b[5b"); let snap = g.snapshot();
for c in 0..6 {
assert_eq!(snap.cells[0][c].ch, 'X', "col {c}");
}
}
#[test]
fn irm_inserts_on_print() {
let mut g = PaneGrid::new(6, 1);
g.feed(b"abcdef");
g.feed(b"\x1b[1;1H"); g.feed(b"\x1b[4hZ"); let snap = g.snapshot();
assert_eq!(snap.cells[0][0].ch, 'Z');
assert_eq!(snap.cells[0][1].ch, 'a');
assert_eq!(snap.cells[0][2].ch, 'b');
}
#[test]
fn ri_scrolls_down_at_top_of_region() {
let mut g = PaneGrid::new(3, 3);
g.feed(b"a\r\nb\r\nc"); g.feed(b"\x1b[1;1H"); g.feed(b"\x1bM"); let snap = g.snapshot();
assert_eq!(snap.cells[0][0].ch, ' ');
assert_eq!(snap.cells[1][0].ch, 'a');
}
#[test]
fn resize_preserves_top_left_content() {
let mut g = PaneGrid::new(5, 3);
g.feed(b"HELLO\r\nWORLD\r\nTHERE");
g.resize(4, 2);
let snap = g.snapshot();
assert_eq!(snap.cols, 4);
assert_eq!(snap.rows, 2);
assert_eq!(snap.cells[0][0].ch, 'H');
assert_eq!(snap.cells[0][3].ch, 'L');
assert_eq!(snap.cells[1][0].ch, 'W');
assert_eq!(snap.cursor_row, 1);
assert_eq!(snap.cursor_col, 3);
}
#[test]
fn resize_grow_pads_with_blanks() {
let mut g = PaneGrid::new(3, 2);
g.feed(b"AB\r\nCD");
g.resize(5, 4);
let snap = g.snapshot();
assert_eq!(snap.cols, 5);
assert_eq!(snap.rows, 4);
assert_eq!(snap.cells[0][0].ch, 'A');
assert_eq!(snap.cells[0][3].ch, ' ');
assert_eq!(snap.cells[2][0].ch, ' ');
}
#[test]
fn scrollback_caps_at_configured_size() {
let mut g = PaneGrid::with_scrollback(3, 2, 3);
for i in 0..10u8 {
g.feed(&[b'a' + i, b'\r', b'\n']);
}
assert!(g.scrollback_len() <= 3);
}
#[test]
fn sgr_truecolor_with_missing_params_does_not_panic() {
let mut g = PaneGrid::new(5, 1);
g.feed(b"\x1b[38;2;200mX");
let snap = g.snapshot();
assert_eq!(snap.cells[0][0].ch, 'X');
}
#[test]
fn sgr_256_with_missing_index_does_not_panic() {
let mut g = PaneGrid::new(5, 1);
g.feed(b"\x1b[38;5mX"); let snap = g.snapshot();
assert_eq!(snap.cells[0][0].ch, 'X');
}
#[test]
fn sgr_unknown_param_is_ignored() {
let mut g = PaneGrid::new(5, 1);
g.feed(b"\x1b[999mX");
let snap = g.snapshot();
assert_eq!(snap.cells[0][0].ch, 'X');
assert_eq!(snap.cells[0][0].fg, Color::WHITE);
}
#[test]
fn sgr_empty_params_resets() {
let mut g = PaneGrid::new(5, 1);
g.feed(b"\x1b[31m"); g.feed(b"\x1b[m"); g.feed(b"X");
let snap = g.snapshot();
assert_eq!(snap.cells[0][0].fg, Color::WHITE);
}
#[test]
fn sgr_bright_bg_100_107() {
let mut g = PaneGrid::new(3, 1);
g.feed(b"\x1b[104mX"); let snap = g.snapshot();
let bright_blue = tear_types::pane_snapshot::ANSI_BRIGHT_COLORS[4];
assert_eq!(snap.cells[0][0].bg, bright_blue);
}
#[test]
fn sgr_disable_attrs_21_to_29() {
let mut g = PaneGrid::new(3, 1);
g.feed(b"\x1b[1;4;7m"); g.feed(b"\x1b[22;24;27m"); g.feed(b"X");
let snap = g.snapshot();
assert!(snap.cells[0][0].attrs.is_empty());
}
#[test]
fn ech_past_end_of_row_clamps() {
let mut g = PaneGrid::new(3, 1);
g.feed(b"abc");
g.feed(b"\x1b[1;2H"); g.feed(b"\x1b[100X"); let snap = g.snapshot();
assert_eq!(snap.cells[0][0].ch, 'a');
assert_eq!(snap.cells[0][1].ch, ' ');
assert_eq!(snap.cells[0][2].ch, ' ');
}
#[test]
fn ich_at_end_of_row_no_overflow() {
let mut g = PaneGrid::new(3, 1);
g.feed(b"abc");
g.feed(b"\x1b[1;3H"); g.feed(b"\x1b[5@"); let snap = g.snapshot();
assert_eq!(snap.cells[0][0].ch, 'a');
assert_eq!(snap.cells[0][1].ch, 'b');
assert_eq!(snap.cells[0][2].ch, ' ');
}
#[test]
fn dch_more_than_row_clamps() {
let mut g = PaneGrid::new(3, 1);
g.feed(b"abc");
g.feed(b"\x1b[1;1H");
g.feed(b"\x1b[100P"); let snap = g.snapshot();
for c in 0..3 {
assert_eq!(snap.cells[0][c].ch, ' ', "col {c}");
}
}
#[test]
fn osc_with_no_params_is_dropped() {
let mut g = PaneGrid::new(3, 1);
g.feed(b"\x1b]\x07"); let snap = g.snapshot();
assert!(snap.title.is_none());
}
#[test]
fn osc_very_long_title_works() {
let mut g = PaneGrid::new(3, 1);
let long_title: String = "x".repeat(1000);
let payload = format!("\x1b]2;{}\x07", long_title);
g.feed(payload.as_bytes());
assert_eq!(g.title().map(str::len), Some(1000));
}
#[test]
fn dec_1049_save_and_restore_cursor_around_alt_screen() {
let mut g = PaneGrid::new(10, 3);
g.feed(b"AAA\r\nBBB");
g.feed(b"\x1b[?1049h"); g.feed(b"\x1b[5;5H"); let alt = g.snapshot();
assert!(alt.alt_screen_active);
g.feed(b"\x1b[?1049l");
let back = g.snapshot();
assert!(!back.alt_screen_active);
assert_eq!(back.cursor_row, 1);
assert_eq!(back.cursor_col, 3);
assert_eq!(back.cells[0][0].ch, 'A');
assert_eq!(back.cells[1][0].ch, 'B');
}
#[test]
fn dec_25_cursor_visibility_round_trip() {
let mut g = PaneGrid::new(3, 1);
g.feed(b"\x1b[?25l"); assert!(!g.snapshot().cursor_visible);
g.feed(b"\x1b[?25h"); assert!(g.snapshot().cursor_visible);
g.feed(b"\x1b[?25l"); assert!(!g.snapshot().cursor_visible);
}
#[test]
fn bel_does_not_crash_or_consume_cell() {
let mut g = PaneGrid::new(3, 1);
g.feed(b"A\x07B"); let snap = g.snapshot();
assert_eq!(snap.cells[0][0].ch, 'A');
assert_eq!(snap.cells[0][1].ch, 'B');
}
#[test]
fn tab_aligns_to_next_multiple_of_8() {
let mut g = PaneGrid::new(20, 1);
g.feed(b"\tX"); let snap = g.snapshot();
assert_eq!(snap.cells[0][8].ch, 'X');
}
#[test]
fn resize_to_zero_clamps_safely() {
let mut g = PaneGrid::new(5, 3);
g.feed(b"hello");
g.resize(0, 0);
let snap = g.snapshot();
assert_eq!(snap.cursor_row, 0);
assert_eq!(snap.cursor_col, 0);
}
#[test]
fn ris_resets_pen_and_clears_screen() {
let mut g = PaneGrid::new(5, 2);
g.feed(b"\x1b[31m"); g.feed(b"AB\r\nCD");
g.feed(b"\x1bc"); let snap = g.snapshot();
for row in snap.cells {
for cell in row {
assert_eq!(cell.ch, ' ');
assert_eq!(cell.fg, Color::WHITE);
}
}
assert_eq!(snap.cursor_row, 0);
assert_eq!(snap.cursor_col, 0);
}
#[test]
fn cursor_keys_mode_defaults_to_false() {
let g = PaneGrid::new(5, 1);
assert!(!g.cursor_keys_mode());
assert!(!g.snapshot().cursor_keys_mode);
}
#[test]
fn decckm_set_via_csi_question_1_h() {
let mut g = PaneGrid::new(5, 1);
g.feed(b"\x1b[?1h"); assert!(g.cursor_keys_mode());
assert!(g.snapshot().cursor_keys_mode);
}
#[test]
fn decckm_reset_via_csi_question_1_l() {
let mut g = PaneGrid::new(5, 1);
g.feed(b"\x1b[?1h"); g.feed(b"\x1b[?1l"); assert!(!g.cursor_keys_mode());
assert!(!g.snapshot().cursor_keys_mode);
}
#[test]
fn decckm_survives_unrelated_modes() {
let mut g = PaneGrid::new(5, 1);
g.feed(b"\x1b[?1h"); g.feed(b"\x1b[?25l"); g.feed(b"\x1b[?1049h"); assert!(
g.cursor_keys_mode(),
"DECCKM must persist across cursor-visibility + alt-screen toggles"
);
}
#[test]
fn ris_resets_cursor_keys_mode() {
let mut g = PaneGrid::new(5, 1);
g.feed(b"\x1b[?1h"); assert!(g.cursor_keys_mode());
g.feed(b"\x1bc"); assert!(
!g.cursor_keys_mode(),
"RIS must reset DECCKM to normal mode"
);
}
#[test]
fn decckm_multi_param_csi() {
let mut g = PaneGrid::new(5, 1);
g.feed(b"\x1b[?25l"); g.feed(b"\x1b[?1;25h"); assert!(g.cursor_keys_mode());
assert!(g.snapshot().cursor_visible);
}
}
#[cfg(test)]
mod proptests {
use super::*;
use proptest::prelude::*;
proptest! {
#[test]
fn random_bytes_never_panic_and_cursor_stays_in_bounds(
cols in 1usize..=80,
rows in 1usize..=24,
bytes in proptest::collection::vec(any::<u8>(), 0..2048),
) {
let mut g = PaneGrid::new(cols, rows);
g.feed(&bytes);
let snap = g.snapshot();
prop_assert_eq!(snap.cols, cols);
prop_assert_eq!(snap.rows, rows);
prop_assert_eq!(snap.cells.len(), rows);
for row in &snap.cells {
prop_assert_eq!(row.len(), cols);
}
prop_assert!(snap.cursor_row < rows.max(1));
prop_assert!(snap.cursor_col < cols.max(1));
}
#[test]
fn printable_ascii_runs_fill_cells_in_order(
text in r"[A-Za-z0-9 ]{1,40}",
) {
let mut g = PaneGrid::new(40, 3);
g.feed(text.as_bytes());
let snap = g.snapshot();
for (i, c) in text.chars().enumerate() {
if i < snap.cols {
prop_assert_eq!(snap.cells[0][i].ch, c);
}
}
}
#[test]
fn snapshot_text_dimensions_match(
cols in 1usize..=120,
rows in 1usize..=40,
bytes in proptest::collection::vec(any::<u8>(), 0..1024),
) {
let mut g = PaneGrid::new(cols, rows);
g.feed(&bytes);
let snap = g.snapshot();
let text_rows = snap.to_text_rows();
prop_assert_eq!(text_rows.len(), rows);
for row in &text_rows {
prop_assert_eq!(row.chars().count(), cols);
}
}
#[test]
fn resize_keeps_cursor_in_bounds(
cols1 in 1usize..=60,
rows1 in 1usize..=20,
cols2 in 1usize..=60,
rows2 in 1usize..=20,
bytes in proptest::collection::vec(any::<u8>(), 0..512),
) {
let mut g = PaneGrid::new(cols1, rows1);
g.feed(&bytes);
g.resize(cols2, rows2);
let snap = g.snapshot();
prop_assert_eq!(snap.cols, cols2);
prop_assert_eq!(snap.rows, rows2);
prop_assert!(snap.cursor_row < rows2.max(1));
prop_assert!(snap.cursor_col < cols2.max(1));
}
}
}