use std::collections::{BTreeSet, VecDeque}; use std::time::{Duration, Instant}; use unicode_segmentation::UnicodeSegmentation; use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; use super::{ MAX_TERMINAL_COLS, MAX_TERMINAL_GRAPHEME_BYTES, MAX_TERMINAL_HISTORY_CELLS, MAX_TERMINAL_METADATA_BYTES, MAX_TERMINAL_ROWS, MAX_TERMINAL_VISIBLE_CELLS, }; use crate::ansi::{ AlternateScreenMode, AnsiEvent, CharacterSet, CharacterSetSlot, DeviceRequest, EraseMode, TerminalMode, }; use crate::cell::{Cell, CellCoord, CellSize, Glyph, Style}; /// Maximum time a child may hold synchronized-output publication. pub const SYNCHRONIZED_OUTPUT_WATCHDOG: Duration = Duration::from_secs(1); #[allow(missing_docs)] #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ScreenError { InvalidSize(CellSize), } impl std::fmt::Display for ScreenError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::InvalidSize(size) => { write!(f, "invalid terminal size {}x{}", size.rows, size.cols) } } } } impl std::error::Error for ScreenError {} #[allow(missing_docs)] #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum MouseTrackingMode { Off, X10, Button, Any, } #[allow(missing_docs, clippy::struct_excessive_bools)] #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct TerminalModes { pub insert: bool, pub origin: bool, pub autowrap: bool, pub application_cursor: bool, pub application_keypad: bool, pub cursor_visible: bool, pub bracketed_paste: bool, pub focus_reporting: bool, pub synchronized_output: bool, pub mouse_tracking: MouseTrackingMode, pub mouse_sgr: bool, } impl Default for TerminalModes { fn default() -> Self { Self { insert: false, origin: false, autowrap: true, application_cursor: false, application_keypad: false, cursor_visible: true, bracketed_paste: false, focus_reporting: false, synchronized_output: false, mouse_tracking: MouseTrackingMode::Off, mouse_sgr: false, } } } #[allow(missing_docs)] #[derive(Clone, Debug, PartialEq, Eq)] pub struct TerminalRow { pub cells: Vec, pub logical_line_id: u64, pub cell_offset: u32, pub soft_wrapped: bool, } #[allow(missing_docs)] #[derive(Clone, Debug, PartialEq, Eq)] pub struct ScreenSnapshot { pub size: CellSize, pub cells: Vec, pub cursor: Option, pub title: Option, pub generation: u64, } /// Owned row projection published atomically to terminal views. #[derive(Clone, Debug, PartialEq, Eq)] pub struct ScreenProjection { /// Terminal grid dimensions. pub size: CellSize, /// Whether the projected visible rows belong to the alternate screen. pub alternate_active: bool, /// Retained main-screen history, empty while the alternate screen is active. pub history: Vec, /// Active visible rows, including logical-line and soft-wrap metadata. pub visible_rows: Vec, /// Published cursor position when visible. pub cursor: Option, /// Published terminal title. pub title: Option, /// Screen generation represented by this projection. pub generation: u64, } /// Borrowed, publication-consistent row projection for in-process views. #[allow(missing_docs)] #[derive(Clone, Copy)] pub(crate) struct BorrowedScreenProjection<'a> { pub size: CellSize, pub alternate_active: bool, pub history_head: &'a [TerminalRow], pub history_tail: &'a [TerminalRow], pub visible_rows: &'a [TerminalRow], pub cursor: Option, pub title: Option<&'a str>, pub generation: u64, } impl BorrowedScreenProjection<'_> { pub(crate) fn history_len(self) -> usize { self.history_head.len() + self.history_tail.len() } } impl ScreenProjection { pub(crate) fn as_borrowed(&self) -> BorrowedScreenProjection<'_> { BorrowedScreenProjection { size: self.size, alternate_active: self.alternate_active, history_head: &self.history, history_tail: &[], visible_rows: &self.visible_rows, cursor: self.cursor, title: self.title.as_deref(), generation: self.generation, } } } #[derive(Clone, Copy, Debug, Default)] struct Cursor { row: usize, col: usize, pending_wrap: bool, } #[derive(Clone, Copy, Debug)] struct SavedCursor { cursor: Cursor, style: Style, g0: CharacterSet, g1: CharacterSet, use_g1: bool, } #[derive(Clone, Debug)] struct Grid { rows: Vec, history: VecDeque, } #[allow(missing_docs, clippy::struct_excessive_bools)] pub struct TerminalScreen { size: CellSize, main: Grid, alt: Grid, alt_active: bool, cursor: Cursor, saved_main_cursor: Option, saved_alt_cursor: Option, saved_main_1049: Option, inactive_main_cursor: Cursor, inactive_alt_cursor: Cursor, style: Style, modes: TerminalModes, scroll_top: usize, scroll_bottom: usize, tab_stops: BTreeSet, title: Option, generation: u64, published: ScreenProjection, sync_started: Option, next_line_id: u64, scrollback_rows: usize, g0: CharacterSet, g1: CharacterSet, use_g1: bool, bell_count: u64, mouse_x10: bool, mouse_button: bool, mouse_any: bool, last_grapheme: Option<(usize, usize)>, } #[allow(missing_docs)] impl TerminalScreen { pub fn new(size: CellSize, scrollback_rows: usize) -> Result { validate_size(size)?; let mut next_line_id = 1; let main = Grid::new(size, &mut next_line_id); let alt = Grid::new(size, &mut next_line_id); let published = ScreenProjection { size, alternate_active: false, history: Vec::new(), visible_rows: main.rows.clone(), cursor: Some(CellCoord::new(0, 0)), title: None, generation: 0, }; Ok(Self { size, main, alt, alt_active: false, cursor: Cursor::default(), saved_main_cursor: None, saved_alt_cursor: None, saved_main_1049: None, inactive_main_cursor: Cursor::default(), inactive_alt_cursor: Cursor::default(), style: Style::default(), modes: TerminalModes::default(), scroll_top: 0, scroll_bottom: size.rows as usize - 1, tab_stops: default_tab_stops(size.cols as usize), title: None, generation: 0, published, sync_started: None, next_line_id, scrollback_rows, g0: CharacterSet::Ascii, g1: CharacterSet::Ascii, use_g1: false, bell_count: 0, mouse_x10: false, mouse_button: false, mouse_any: false, last_grapheme: None, }) } /// Apply one parsed terminal operation and return an optional fixed device /// reply for the session manager to queue to the child. #[allow(clippy::too_many_lines, clippy::let_and_return, clippy::cast_lossless)] pub fn apply_event(&mut self, event: AnsiEvent) -> Option> { if !matches!(&event, AnsiEvent::Text(_)) { self.last_grapheme = None; } let reply = match event { AnsiEvent::Text(text) => { self.write_text(&text); None } AnsiEvent::SetStyle(style) => { self.style = style; self.changed(); None } AnsiEvent::CarriageReturn => { self.cursor.col = 0; self.cursor.pending_wrap = false; self.changed(); None } AnsiEvent::Backspace => { self.cursor.col = self.cursor.col.saturating_sub(1); self.cursor.pending_wrap = false; self.changed(); None } AnsiEvent::Bell => { self.bell_count = self.bell_count.saturating_add(1); self.changed(); None } AnsiEvent::LineFeed | AnsiEvent::Index => { self.line_feed(); None } AnsiEvent::NextLine => { self.cursor.col = 0; self.line_feed(); None } AnsiEvent::ReverseIndex => { self.reverse_index(); None } AnsiEvent::HorizontalTab => { self.horizontal_tab(); None } AnsiEvent::SetTabStop => { self.tab_stops.insert(self.cursor.col); self.changed(); None } AnsiEvent::ClearTabStop => { self.tab_stops.remove(&self.cursor.col); self.changed(); None } AnsiEvent::ClearAllTabStops => { self.tab_stops.clear(); self.changed(); None } AnsiEvent::CursorUp(n) => { self.move_vertical(-i64::from(n)); None } AnsiEvent::CursorDown(n) => { self.move_vertical(i64::from(n)); None } AnsiEvent::CursorForward(n) => { self.move_horizontal(i64::from(n)); None } AnsiEvent::CursorBackward(n) => { self.move_horizontal(-i64::from(n)); None } AnsiEvent::CursorNextLine(n) => { self.move_vertical(i64::from(n)); self.cursor.col = 0; None } AnsiEvent::CursorPreviousLine(n) => { self.move_vertical(-i64::from(n)); self.cursor.col = 0; None } AnsiEvent::CursorHorizontalAbsolute(col) => { self.set_col(col); None } AnsiEvent::CursorVerticalAbsolute(row) => { self.set_row(row); None } AnsiEvent::CursorPosition { row, col } => { self.set_position(row, col); None } AnsiEvent::EraseDisplay(mode) => { self.erase_display(mode); None } AnsiEvent::EraseLineMode(mode) => { self.erase_line(mode); None } AnsiEvent::EraseCharacters(n) => { self.erase_characters(n); None } AnsiEvent::InsertCharacters(n) => { self.insert_characters(n); None } AnsiEvent::DeleteCharacters(n) => { self.delete_characters(n); None } AnsiEvent::InsertLines(n) => { self.insert_lines(n); None } AnsiEvent::DeleteLines(n) => { self.delete_lines(n); None } AnsiEvent::ScrollUp(n) => { self.scroll_up(n as usize); None } AnsiEvent::ScrollDown(n) => { self.scroll_down(n as usize); None } AnsiEvent::SetScrollingRegion { top, bottom } => { self.set_scrolling_region(top, bottom); None } AnsiEvent::SaveCursor => { let saved = Some(self.capture_cursor()); if self.alt_active { self.saved_alt_cursor = saved; } else { self.saved_main_cursor = saved; } None } AnsiEvent::RestoreCursor => { let saved = if self.alt_active { self.saved_alt_cursor } else { self.saved_main_cursor }; if let Some(saved) = saved { self.restore_cursor(saved); } None } AnsiEvent::AlternateScreen { mode, enabled } => { self.set_alternate(mode, enabled); None } AnsiEvent::SetMode { mode, enabled } => { self.set_mode(mode, enabled); None } AnsiEvent::DesignateCharacterSet { slot, charset } => { match slot { CharacterSetSlot::G0 => self.g0 = charset, CharacterSetSlot::G1 => self.g1 = charset, } self.changed(); None } AnsiEvent::ShiftOut => { self.use_g1 = true; self.changed(); None } AnsiEvent::ShiftIn => { self.use_g1 = false; self.changed(); None } AnsiEvent::DeviceRequest(request) => Some(self.device_reply(request)), AnsiEvent::SetTitle(title) => { self.title = Some(sanitize_title(&title)); self.changed(); None } AnsiEvent::EraseToEol => { self.erase_line(EraseMode::ToEnd); None } AnsiEvent::EraseLine => { self.erase_line(EraseMode::All); None } AnsiEvent::AlternateScreenEnter => { self.set_alternate(AlternateScreenMode::Mode1049, true); None } AnsiEvent::AlternateScreenExit => { self.set_alternate(AlternateScreenMode::Mode1049, false); None } AnsiEvent::BracketedPasteBegin | AnsiEvent::BracketedPasteEnd | AnsiEvent::PromptStart | AnsiEvent::PromptEnd | AnsiEvent::CommandStart | AnsiEvent::OutputStart => None, }; reply } /// Release a synchronized-output batch at EOF or session completion. pub fn finish_output(&mut self) { if self.modes.synchronized_output { self.modes.synchronized_output = false; self.sync_started = None; self.publish(); } } pub fn synchronized_watchdog_expired(&mut self, now: Instant) -> bool { if self.sync_started.is_some_and(|start| { now.saturating_duration_since(start) >= SYNCHRONIZED_OUTPUT_WATCHDOG }) { self.modes.synchronized_output = false; self.sync_started = None; self.publish(); true } else { false } } pub fn append_process_annotation(&mut self, annotation: &str) { self.last_grapheme = None; let needs_newline = self.cursor.col != 0 || self.cursor.pending_wrap || self.active().rows[self.cursor.row] .cells .iter() .any(|c| !is_blank(c)); self.style = Style::default(); if needs_newline { self.cursor.pending_wrap = false; self.cursor.col = 0; self.line_feed(); } self.write_text(annotation); self.cursor.pending_wrap = false; let row = self.cursor.row; self.active_mut().rows[row].soft_wrapped = false; self.break_chain_after(row); self.finish_output(); } /// Resize the main screen with soft-wrap reflow and clip/pad the alternate /// screen, preserving cursor, history, and application tab-stop state. pub fn resize(&mut self, size: CellSize) -> Result<(), ScreenError> { validate_size(size)?; if size == self.size { return Ok(()); } self.last_grapheme = None; let old_size = self.size; self.reflow_main(size); resize_grid_clip( &mut self.alt, old_size, size, &mut self.next_line_id, self.style, ); self.size = size; self.scroll_top = 0; self.scroll_bottom = size.rows as usize - 1; self.cursor.row = self.cursor.row.min(self.scroll_bottom); self.cursor.col = self.cursor.col.min(size.cols as usize - 1); self.cursor.pending_wrap = false; self.tab_stops.retain(|&col| col < size.cols as usize); if size.cols > old_size.cols { let first_new_default = (old_size.cols as usize).div_ceil(8) * 8; for col in (first_new_default..size.cols as usize).step_by(8) { self.tab_stops.insert(col); } } self.enforce_history_budget(); self.changed(); Ok(()) } pub fn snapshot(&self) -> ScreenSnapshot { if self.modes.synchronized_output { snapshot_from_projection(&self.published) } else { self.current_snapshot() } } /// Return one owned, publication-consistent row projection. #[must_use] pub fn projection(&self) -> ScreenProjection { if self.modes.synchronized_output { self.published.clone() } else { self.current_projection() } } /// Borrow one publication-consistent row projection without cloning cells. pub(crate) fn projection_ref(&self) -> BorrowedScreenProjection<'_> { if self.modes.synchronized_output { return self.published.as_borrowed(); } let (history_head, history_tail) = if self.alt_active { (&[][..], &[][..]) } else { self.main.history.as_slices() }; BorrowedScreenProjection { size: self.size, alternate_active: self.alt_active, history_head, history_tail, visible_rows: &self.active().rows, cursor: self .modes .cursor_visible .then(|| CellCoord::new(self.cursor.row as u32, self.cursor.col as u32)), title: self.title.as_deref(), generation: self.generation, } } pub fn modes(&self) -> TerminalModes { self.modes } /// Return whether the published active screen is alternate. #[must_use] pub fn alternate_active(&self) -> bool { if self.modes.synchronized_output { self.published.alternate_active } else { self.alt_active } } pub fn bell_count(&self) -> u64 { self.bell_count } pub fn history(&self) -> &VecDeque { &self.main.history } pub fn visible_rows(&self) -> &[TerminalRow] { &self.active().rows } fn active(&self) -> &Grid { if self.alt_active { &self.alt } else { &self.main } } fn active_mut(&mut self) -> &mut Grid { if self.alt_active { &mut self.alt } else { &mut self.main } } fn capture_cursor(&self) -> SavedCursor { SavedCursor { cursor: self.cursor, style: self.style, g0: self.g0, g1: self.g1, use_g1: self.use_g1, } } fn restore_cursor(&mut self, saved: SavedCursor) { self.cursor = saved.cursor; self.cursor.row = self.cursor.row.min(self.size.rows as usize - 1); self.cursor.col = self.cursor.col.min(self.size.cols as usize - 1); self.cursor.pending_wrap = false; self.style = saved.style; self.g0 = saved.g0; self.g1 = saved.g1; self.use_g1 = saved.use_g1; self.changed(); } fn write_text(&mut self, text: &str) { for ch in text.chars() { let ch = self.map_character(ch); if ch.is_control() { continue; } if self.try_extend_previous_grapheme(ch) { continue; } let width = UnicodeWidthChar::width(ch).unwrap_or(0).min(2); if width == 0 { self.write_leading_combining(ch); } else { self.write_character(ch, width); } } } fn try_extend_previous_grapheme(&mut self, ch: char) -> bool { let Some((row, lead)) = self.last_grapheme else { return false; }; let previous = glyph_bytes(&self.active().rows[row].cells[lead].glyph); let Ok(previous_text) = std::str::from_utf8(&previous) else { return false; }; let mut encoded = [0; 4]; let addition = ch.encode_utf8(&mut encoded); let mut candidate = String::with_capacity(previous.len() + addition.len()); candidate.push_str(previous_text); candidate.push_str(addition); let mut graphemes = candidate.graphemes(true); let joins_previous = graphemes.next() == Some(candidate.as_str()) && graphemes.next().is_none(); if !joins_previous { return false; } if candidate.len() > MAX_TERMINAL_GRAPHEME_BYTES { return true; } self.replace_previous_grapheme(row, lead, candidate); true } fn replace_previous_grapheme(&mut self, row: usize, lead: usize, cluster: String) { let cols = self.size.cols as usize; let width = UnicodeWidthStr::width(cluster.as_str()).clamp(1, 2); let old_width = glyph_width(&self.active().rows[row].cells[lead].glyph); let style = self.active().rows[row].cells[lead].style; if width == 2 && cols == 1 { self.active_mut().rows[row].cells[lead] = cell(Glyph::Char('\u{fffd}'), style); self.cursor.row = row; self.cursor.col = 0; self.cursor.pending_wrap = self.modes.autowrap; self.last_grapheme = Some((row, lead)); self.changed(); return; } if width == 2 && lead + 1 >= cols && !self.modes.autowrap { self.active_mut().rows[row].cells[lead] = cell(Glyph::Char('\u{fffd}'), style); self.cursor.row = row; self.cursor.col = lead; self.cursor.pending_wrap = false; self.last_grapheme = Some((row, lead)); self.changed(); return; } if width == 2 && lead + 1 >= cols { self.active_mut().rows[row].cells[lead] = blank(self.style); self.cursor.row = row; self.cursor.col = cols - 1; self.cursor.pending_wrap = true; self.soft_wrap(); let new_row = self.cursor.row; self.active_mut().rows[new_row].cells[0] = cell( Glyph::Cluster(cluster.into_bytes().into_boxed_slice()), style, ); self.active_mut().rows[new_row].cells[1] = cell(Glyph::Continuation, style); if cols == 2 { self.cursor.col = 1; self.cursor.pending_wrap = self.modes.autowrap; } else { self.cursor.col = 2; self.cursor.pending_wrap = false; } self.last_grapheme = Some((new_row, 0)); self.changed(); return; } self.active_mut().rows[row].cells[lead] = cell( Glyph::Cluster(cluster.into_bytes().into_boxed_slice()), style, ); if width == 2 { self.active_mut().rows[row].cells[lead + 1] = cell(Glyph::Continuation, style); } else if old_width == 2 && lead + 1 < cols { self.active_mut().rows[row].cells[lead + 1] = blank(self.style); } self.cursor.row = row; if lead + width >= cols { self.cursor.col = cols - 1; self.cursor.pending_wrap = self.modes.autowrap; } else { self.cursor.col = lead + width; self.cursor.pending_wrap = false; } self.last_grapheme = Some((row, lead)); self.changed(); } fn write_leading_combining(&mut self, ch: char) { let row = self.cursor.row; let col = self.cursor.col; self.clear_wide_at(row, col); let mut cluster = String::from(" "); cluster.push(ch); if cluster.len() <= MAX_TERMINAL_GRAPHEME_BYTES { self.active_mut().rows[row].cells[col] = cell( Glyph::Cluster(cluster.into_bytes().into_boxed_slice()), self.style, ); self.last_grapheme = Some((row, col)); self.changed(); } } fn map_character(&self, ch: char) -> char { let charset = if self.use_g1 { self.g1 } else { self.g0 }; if charset == CharacterSet::DecSpecialGraphics { dec_special_graphics(ch) } else { ch } } fn write_character(&mut self, mut ch: char, mut width: usize) { let cols = self.size.cols as usize; if width == 2 && cols == 1 { ch = '\u{fffd}'; width = 1; } if width == 2 && self.cursor.col + 1 >= cols && !self.modes.autowrap { ch = '\u{fffd}'; width = 1; } else if self.cursor.pending_wrap || (width == 2 && self.cursor.col + 1 >= cols) { self.soft_wrap(); } if self.modes.insert { self.insert_characters(width as u32); } self.clear_wide_at(self.cursor.row, self.cursor.col); if width == 2 { self.clear_wide_at(self.cursor.row, self.cursor.col + 1); } let style = self.style; let row = self.cursor.row; let col = self.cursor.col; let grid = self.active_mut(); grid.rows[row].cells[col] = cell(Glyph::Char(ch), style); if width == 2 { grid.rows[row].cells[col + 1] = cell(Glyph::Continuation, style); } if col + width >= cols { self.cursor.col = cols - 1; self.cursor.pending_wrap = self.modes.autowrap; } else { self.cursor.col += width; self.cursor.pending_wrap = false; } self.last_grapheme = Some((row, col)); self.changed(); } fn soft_wrap(&mut self) { let row = self.cursor.row; let id = self.active().rows[row].logical_line_id; let offset = self.active().rows[row].cell_offset + self.size.cols; self.active_mut().rows[row].soft_wrapped = true; self.cursor.col = 0; self.cursor.pending_wrap = false; if row == self.scroll_bottom { self.scroll_up_internal(1, Some((id, offset))); } else { self.cursor.row += 1; let next_row = self.cursor.row; let next = &mut self.active_mut().rows[next_row]; next.logical_line_id = id; next.cell_offset = offset; } self.changed(); } fn line_feed(&mut self) { self.cursor.pending_wrap = false; let row = self.cursor.row; self.active_mut().rows[row].soft_wrapped = false; self.break_chain_after(row); if row == self.scroll_bottom { self.scroll_up_internal(1, None); } else if row + 1 < self.size.rows as usize { self.cursor.row += 1; } self.changed(); } fn reverse_index(&mut self) { self.cursor.pending_wrap = false; if self.cursor.row == self.scroll_top { self.scroll_down(1); } else if self.cursor.row > 0 { self.cursor.row -= 1; self.changed(); } } fn horizontal_tab(&mut self) { let cols = self.size.cols as usize; self.cursor.col = self .tab_stops .range((self.cursor.col + 1)..) .next() .copied() .unwrap_or(cols - 1); self.cursor.pending_wrap = false; self.changed(); } fn move_vertical(&mut self, delta: i64) { let (lo, hi) = if self.modes.origin { (self.scroll_top, self.scroll_bottom) } else { (0, self.size.rows as usize - 1) }; let distance = usize::try_from(delta.unsigned_abs()).unwrap_or(usize::MAX); let moved = if delta.is_negative() { self.cursor.row.saturating_sub(distance) } else { self.cursor.row.saturating_add(distance) }; self.cursor.row = moved.clamp(lo, hi); self.cursor.pending_wrap = false; self.changed(); } fn move_horizontal(&mut self, delta: i64) { let distance = usize::try_from(delta.unsigned_abs()).unwrap_or(usize::MAX); let moved = if delta.is_negative() { self.cursor.col.saturating_sub(distance) } else { self.cursor.col.saturating_add(distance) }; self.cursor.col = moved.min(self.size.cols as usize - 1); self.cursor.pending_wrap = false; self.changed(); } fn set_col(&mut self, col: u32) { self.cursor.col = col.saturating_sub(1).min(self.size.cols - 1) as usize; self.cursor.pending_wrap = false; self.changed(); } fn set_row(&mut self, row: u32) { let base = if self.modes.origin { self.scroll_top } else { 0 }; let hi = if self.modes.origin { self.scroll_bottom } else { self.size.rows as usize - 1 }; self.cursor.row = (base + row.saturating_sub(1) as usize).min(hi); self.cursor.pending_wrap = false; self.changed(); } fn set_position(&mut self, row: u32, col: u32) { self.set_row(row); self.set_col(col); } fn erase_display(&mut self, mode: EraseMode) { match mode { EraseMode::ToEnd => { self.erase_line(EraseMode::ToEnd); for row in self.cursor.row + 1..self.size.rows as usize { self.clear_row(row); } } EraseMode::ToStart => { for row in 0..self.cursor.row { self.clear_row(row); } self.erase_line(EraseMode::ToStart); } EraseMode::All => { for row in 0..self.size.rows as usize { self.clear_row(row); } } EraseMode::Saved => { if !self.alt_active { self.main.history.clear(); } } } self.changed(); } fn erase_line(&mut self, mode: EraseMode) { let cols = self.size.cols as usize; match mode { EraseMode::All | EraseMode::Saved => self.clear_row(self.cursor.row), EraseMode::ToEnd => self.clear_range(self.cursor.row, self.cursor.col, cols), EraseMode::ToStart => self.clear_range(self.cursor.row, 0, self.cursor.col + 1), } self.cursor.pending_wrap = false; self.changed(); } fn erase_characters(&mut self, n: u32) { let end = (self.cursor.col + n.max(1) as usize).min(self.size.cols as usize); self.clear_range(self.cursor.row, self.cursor.col, end); self.changed(); } fn insert_characters(&mut self, n: u32) { let cols = self.size.cols as usize; let cursor_col = self.cursor.col; let cursor_row = self.cursor.row; let style = self.style; let n = (n.max(1) as usize).min(cols - cursor_col); self.normalize_row(cursor_row); let row = &mut self.active_mut().rows[cursor_row].cells; row[cursor_col..].rotate_right(n); row[cursor_col..cursor_col + n].fill(blank(style)); sanitize_row(row, style); self.cursor.pending_wrap = false; self.changed(); } fn delete_characters(&mut self, n: u32) { let cols = self.size.cols as usize; let cursor_col = self.cursor.col; let cursor_row = self.cursor.row; let style = self.style; let n = (n.max(1) as usize).min(cols - cursor_col); self.normalize_row(cursor_row); let row = &mut self.active_mut().rows[cursor_row].cells; row[cursor_col..].rotate_left(n); row[cols - n..].fill(blank(style)); sanitize_row(row, style); self.cursor.pending_wrap = false; self.changed(); } fn insert_lines(&mut self, n: u32) { if self.cursor.row < self.scroll_top || self.cursor.row > self.scroll_bottom { return; } let bottom = self.scroll_bottom; let cursor_row = self.cursor.row; let count = (n.max(1) as usize).min(bottom - cursor_row + 1); for _ in 0..count { self.active_mut().rows.remove(bottom); let row = self.new_row(); self.active_mut().rows.insert(cursor_row, row); } self.changed(); } fn delete_lines(&mut self, n: u32) { if self.cursor.row < self.scroll_top || self.cursor.row > self.scroll_bottom { return; } let bottom = self.scroll_bottom; let cursor_row = self.cursor.row; let count = (n.max(1) as usize).min(bottom - cursor_row + 1); for _ in 0..count { self.active_mut().rows.remove(cursor_row); let row = self.new_row(); self.active_mut().rows.insert(bottom, row); } self.changed(); } fn scroll_up(&mut self, n: usize) { let cursor = self.cursor; self.scroll_up_internal(n.max(1), None); self.cursor = cursor; self.changed(); } fn scroll_down(&mut self, n: usize) { let top = self.scroll_top; let bottom = self.scroll_bottom; let count = n.max(1).min(bottom - top + 1); for _ in 0..count { self.active_mut().rows.remove(bottom); let row = self.new_row(); self.active_mut().rows.insert(top, row); } self.changed(); } fn scroll_up_internal(&mut self, n: usize, continuation: Option<(u64, u32)>) { let top = self.scroll_top; let bottom = self.scroll_bottom; let count = n.min(bottom - top + 1); for index in 0..count { let removed = self.active_mut().rows.remove(top); if !self.alt_active && top == 0 && bottom + 1 == self.size.rows as usize { self.main.history.push_back(removed); } let mut row = self.new_row(); if index + 1 == count && let Some((id, offset)) = continuation { row.logical_line_id = id; row.cell_offset = offset; } self.active_mut().rows.insert(bottom, row); } self.cursor.row = bottom; self.enforce_history_budget(); } fn set_scrolling_region(&mut self, top: u32, bottom: Option) { let top = top.saturating_sub(1) as usize; let bottom = bottom.unwrap_or(self.size.rows).saturating_sub(1) as usize; if top < bottom && bottom < self.size.rows as usize { self.scroll_top = top; self.scroll_bottom = bottom; self.cursor.row = if self.modes.origin { top } else { 0 }; self.cursor.col = 0; self.cursor.pending_wrap = false; self.changed(); } } fn set_alternate(&mut self, mode: AlternateScreenMode, enabled: bool) { if enabled == self.alt_active { return; } if enabled { self.inactive_main_cursor = self.cursor; if mode == AlternateScreenMode::Mode1049 { self.saved_main_1049 = Some(self.capture_cursor()); self.alt = Grid::new(self.size, &mut self.next_line_id); self.cursor = Cursor::default(); } else if mode == AlternateScreenMode::Mode1047 { self.alt = Grid::new(self.size, &mut self.next_line_id); self.cursor = Cursor::default(); } else { self.cursor = self.inactive_alt_cursor; } self.alt_active = true; } else { self.inactive_alt_cursor = self.cursor; self.alt_active = false; if mode == AlternateScreenMode::Mode1049 { if let Some(saved) = self.saved_main_1049.take() { self.restore_cursor(saved); } else { self.cursor = self.inactive_main_cursor; } } else { self.cursor = self.inactive_main_cursor; } } self.scroll_top = 0; self.scroll_bottom = self.size.rows as usize - 1; self.cursor.row = self.cursor.row.min(self.scroll_bottom); self.cursor.col = self.cursor.col.min(self.size.cols as usize - 1); self.cursor.pending_wrap = false; self.changed(); } fn set_mode(&mut self, mode: TerminalMode, enabled: bool) { match mode { TerminalMode::Insert => self.modes.insert = enabled, TerminalMode::Origin => { self.modes.origin = enabled; self.cursor.row = if enabled { self.scroll_top } else { 0 }; self.cursor.col = 0; } TerminalMode::AutoWrap => self.modes.autowrap = enabled, TerminalMode::ApplicationCursor => self.modes.application_cursor = enabled, TerminalMode::ApplicationKeypad => self.modes.application_keypad = enabled, TerminalMode::CursorVisible => self.modes.cursor_visible = enabled, TerminalMode::BracketedPaste => self.modes.bracketed_paste = enabled, TerminalMode::FocusReporting => self.modes.focus_reporting = enabled, TerminalMode::SynchronizedOutput => { if enabled && !self.modes.synchronized_output { self.publish(); self.sync_started = Some(Instant::now()); } self.modes.synchronized_output = enabled; if !enabled { self.sync_started = None; self.publish(); } } TerminalMode::MouseX10 => { self.mouse_x10 = enabled; self.resolve_mouse_tracking(); } TerminalMode::MouseButton => { self.mouse_button = enabled; self.resolve_mouse_tracking(); } TerminalMode::MouseAny => { self.mouse_any = enabled; self.resolve_mouse_tracking(); } TerminalMode::MouseSgr => self.modes.mouse_sgr = enabled, } self.cursor.pending_wrap = false; self.changed(); } fn resolve_mouse_tracking(&mut self) { self.modes.mouse_tracking = if self.mouse_any { MouseTrackingMode::Any } else if self.mouse_button { MouseTrackingMode::Button } else if self.mouse_x10 { MouseTrackingMode::X10 } else { MouseTrackingMode::Off }; } fn device_reply(&self, request: DeviceRequest) -> Vec { match request { DeviceRequest::PrimaryAttributes => b"\x1b[?1;2c".to_vec(), DeviceRequest::SecondaryAttributes => b"\x1b[>0;0;0c".to_vec(), DeviceRequest::OperatingStatus => b"\x1b[0n".to_vec(), DeviceRequest::CursorPosition => { let row = if self.modes.origin { self.cursor.row.saturating_sub(self.scroll_top) + 1 } else { self.cursor.row + 1 }; format!("\x1b[{row};{}R", self.cursor.col + 1).into_bytes() } } } fn clear_range(&mut self, row: usize, start: usize, end: usize) { if start >= end { return; } self.clear_wide_at(row, start); self.clear_wide_at(row, end.saturating_sub(1)); let fill = blank(self.style); self.active_mut().rows[row].cells[start..end].fill(fill); } fn clear_row(&mut self, row: usize) { if row > 0 && self.active().rows[row - 1].soft_wrapped && self.active().rows[row - 1].logical_line_id == self.active().rows[row].logical_line_id { self.active_mut().rows[row - 1].soft_wrapped = false; self.break_chain_after(row - 1); } let fill = blank(self.style); self.active_mut().rows[row].cells.fill(fill); self.active_mut().rows[row].soft_wrapped = false; self.break_chain_after(row); } fn clear_wide_at(&mut self, row: usize, col: usize) { let cols = self.size.cols as usize; if col >= cols { return; } let lead = self.wide_lead(row, col); let width = matches!( self.active().rows[row] .cells .get(lead + 1) .map(|c| &c.glyph), Some(Glyph::Continuation) ); let fill = blank(self.style); self.active_mut().rows[row].cells[lead] = fill.clone(); if width { self.active_mut().rows[row].cells[lead + 1] = fill; } } fn wide_lead(&self, row: usize, col: usize) -> usize { if matches!( self.active().rows[row].cells[col].glyph, Glyph::Continuation ) && col > 0 { col - 1 } else { col } } fn normalize_row(&mut self, row: usize) { let style = self.style; sanitize_row(&mut self.active_mut().rows[row].cells, style); } fn break_chain_after(&mut self, row: usize) { let old_id = self.active().rows[row].logical_line_id; if row + 1 >= self.active().rows.len() || self.active().rows[row + 1].logical_line_id != old_id { return; } let cols = self.size.cols; let mut assignments = Vec::new(); let mut id = self.next_line_id; self.next_line_id = self.next_line_id.saturating_add(1); let mut offset = 0u32; for index in row + 1..self.active().rows.len() { let next = &self.active().rows[index]; if next.logical_line_id != old_id { break; } assignments.push((index, id, offset)); if next.soft_wrapped { offset = offset.saturating_add(cols); } else { id = self.next_line_id; self.next_line_id = self.next_line_id.saturating_add(1); offset = 0; } } let grid = self.active_mut(); for (index, id, offset) in assignments { grid.rows[index].logical_line_id = id; grid.rows[index].cell_offset = offset; } } fn new_row(&mut self) -> TerminalRow { let id = self.next_line_id; self.next_line_id = self.next_line_id.saturating_add(1); TerminalRow::new(self.size.cols as usize, id, self.style) } fn enforce_history_budget(&mut self) { let cols = self.size.cols as usize; while self.main.history.len() > self.scrollback_rows || self.main.history.len().saturating_mul(cols) > MAX_TERMINAL_HISTORY_CELLS { self.main.history.pop_front(); } } #[allow(clippy::too_many_lines)] fn reflow_main(&mut self, size: CellSize) { let old_cols = self.size.cols as usize; let new_cols = size.cols as usize; let main_cursor = if self.alt_active { self.inactive_main_cursor } else { self.cursor }; let cursor_row = main_cursor.row.min(self.main.rows.len() - 1); let cursor_id = self.main.rows[cursor_row].logical_line_id; let cursor_offset = self.main.rows[cursor_row].cell_offset as usize + main_cursor.col; let mut all: Vec = self.main.history.drain(..).collect(); all.append(&mut self.main.rows); let mut logical: Vec<(u64, usize, Vec)> = Vec::new(); let mut previous_soft_wrapped = false; for row in all { let cursor_used = if row.logical_line_id == cursor_id && cursor_offset >= row.cell_offset as usize && cursor_offset < row.cell_offset as usize + old_cols { cursor_offset - row.cell_offset as usize + 1 } else { 0 }; let used = if row.soft_wrapped { old_cols } else { last_nonblank(&row.cells).max(cursor_used) }; if previous_soft_wrapped && let Some((id, base, cells)) = logical.last_mut() && *id == row.logical_line_id { let start = row.cell_offset as usize - *base; cells.resize(start, blank(self.style)); cells.extend_from_slice(&row.cells[..used]); } else { logical.push(( row.logical_line_id, row.cell_offset as usize, row.cells[..used].to_vec(), )); } previous_soft_wrapped = row.soft_wrapped; } let mut rows = Vec::new(); let mut mapped_cursor_offset = cursor_offset; for (id, base, mut cells) in logical { sanitize_row(&mut cells, self.style); if cells.is_empty() { rows.push(TerminalRow::new(new_cols, id, self.style)); continue; } let cursor_source = (id == cursor_id).then(|| cursor_offset.saturating_sub(base)); let mut source = 0usize; let mut physical = 0usize; let mut col = 0usize; let mut part = vec![blank(self.style); new_cols]; while source < cells.len() { if matches!(cells[source].glyph, Glyph::Continuation) { source += 1; continue; } let source_width = glyph_width(&cells[source].glyph).max(1); let mut display_width = source_width.min(2); let mut glyph = cells[source].glyph.clone(); if display_width == 2 && new_cols == 1 { glyph = Glyph::Char('\u{fffd}'); display_width = 1; } if display_width == 2 && col + 2 > new_cols { rows.push(TerminalRow { cells: part, logical_line_id: id, cell_offset: (base + physical) as u32, soft_wrapped: true, }); physical += new_cols; col = 0; part = vec![blank(self.style); new_cols]; } if let Some(cursor_source) = cursor_source && cursor_source >= source && cursor_source < source + source_width { mapped_cursor_offset = base + physical + col + (cursor_source - source).min(display_width - 1); } part[col] = cell(glyph, cells[source].style); if display_width == 2 { part[col + 1] = cell(Glyph::Continuation, cells[source].style); } col += display_width; source += source_width; if col == new_cols && source < cells.len() { rows.push(TerminalRow { cells: part, logical_line_id: id, cell_offset: (base + physical) as u32, soft_wrapped: true, }); physical += new_cols; col = 0; part = vec![blank(self.style); new_cols]; } } rows.push(TerminalRow { cells: part, logical_line_id: id, cell_offset: (base + physical) as u32, soft_wrapped: false, }); } while rows.len() < size.rows as usize { let id = self.next_line_id; self.next_line_id = self.next_line_id.saturating_add(1); rows.push(TerminalRow::new(new_cols, id, self.style)); } let split = rows.len().saturating_sub(size.rows as usize); self.main.history = rows.drain(..split).collect(); self.main.rows = rows; let mapped = self .main .rows .iter() .enumerate() .find_map(|(idx, row)| { if row.logical_line_id == cursor_id && mapped_cursor_offset >= row.cell_offset as usize && mapped_cursor_offset < row.cell_offset as usize + new_cols { Some(Cursor { row: idx, col: mapped_cursor_offset - row.cell_offset as usize, pending_wrap: false, }) } else { None } }) .unwrap_or(Cursor { row: size.rows as usize - 1, col: 0, pending_wrap: false, }); if self.alt_active { self.inactive_main_cursor = mapped; } else { self.cursor = mapped; } } fn changed(&mut self) { self.generation = self.generation.saturating_add(1); } fn current_snapshot(&self) -> ScreenSnapshot { ScreenSnapshot { size: self.size, cells: flatten(&self.active().rows), cursor: self .modes .cursor_visible .then(|| CellCoord::new(self.cursor.row as u32, self.cursor.col as u32)), title: self.title.clone(), generation: self.generation, } } fn current_projection(&self) -> ScreenProjection { ScreenProjection { size: self.size, alternate_active: self.alt_active, history: if self.alt_active { Vec::new() } else { self.main.history.iter().cloned().collect() }, visible_rows: self.active().rows.clone(), cursor: self .modes .cursor_visible .then(|| CellCoord::new(self.cursor.row as u32, self.cursor.col as u32)), title: self.title.clone(), generation: self.generation, } } fn publish(&mut self) { self.published = self.current_projection(); } } impl Grid { fn new(size: CellSize, next_id: &mut u64) -> Self { let mut rows = Vec::with_capacity(size.rows as usize); for _ in 0..size.rows { rows.push(TerminalRow::new( size.cols as usize, *next_id, Style::default(), )); *next_id = next_id.saturating_add(1); } Self { rows, history: VecDeque::new(), } } } impl TerminalRow { fn new(cols: usize, logical_line_id: u64, style: Style) -> Self { Self { cells: vec![blank(style); cols], logical_line_id, cell_offset: 0, soft_wrapped: false, } } } fn validate_size(size: CellSize) -> Result<(), ScreenError> { let area = size.rows as usize * size.cols as usize; if size.rows == 0 || size.cols == 0 || size.rows > u32::from(MAX_TERMINAL_ROWS) || size.cols > u32::from(MAX_TERMINAL_COLS) || area > MAX_TERMINAL_VISIBLE_CELLS { Err(ScreenError::InvalidSize(size)) } else { Ok(()) } } fn default_tab_stops(cols: usize) -> BTreeSet { (8..cols).step_by(8).collect() } fn blank(style: Style) -> Cell { cell(Glyph::Char(' '), style) } fn cell(glyph: Glyph, style: Style) -> Cell { Cell { glyph, style, attachment: None, } } fn is_blank(cell: &Cell) -> bool { matches!(cell.glyph, Glyph::Char(' ')) && cell.style == Style::default() && cell.attachment.is_none() } fn flatten(rows: &[TerminalRow]) -> Vec { rows.iter() .flat_map(|row| row.cells.iter().cloned()) .collect() } fn last_nonblank(cells: &[Cell]) -> usize { cells .iter() .rposition(|c| !is_blank(c)) .map_or(0, |i| i + 1) } fn glyph_bytes(glyph: &Glyph) -> Vec { match glyph { Glyph::Char(ch) => { let mut buf = [0; 4]; ch.encode_utf8(&mut buf).as_bytes().to_vec() } Glyph::Cluster(bytes) => bytes.to_vec(), Glyph::Continuation => Vec::new(), } } fn sanitize_row(cells: &mut [Cell], style: Style) { for i in 0..cells.len() { if matches!(cells[i].glyph, Glyph::Continuation) && (i == 0 || glyph_width(&cells[i - 1].glyph) != 2) { cells[i] = blank(style); } } for i in 0..cells.len() { if glyph_width(&cells[i].glyph) == 2 && (i + 1 == cells.len() || !matches!(cells[i + 1].glyph, Glyph::Continuation)) { cells[i] = blank(style); } } } fn glyph_width(glyph: &Glyph) -> usize { match glyph { Glyph::Char(ch) => UnicodeWidthChar::width(*ch).unwrap_or(0).min(2), Glyph::Cluster(bytes) => std::str::from_utf8(bytes) .ok() .map_or(1, |cluster| UnicodeWidthStr::width(cluster).min(2)), Glyph::Continuation => 0, } } fn snapshot_from_projection(projection: &ScreenProjection) -> ScreenSnapshot { ScreenSnapshot { size: projection.size, cells: flatten(&projection.visible_rows), cursor: projection.cursor, title: projection.title.clone(), generation: projection.generation, } } fn resize_grid_clip( grid: &mut Grid, old: CellSize, new: CellSize, next_id: &mut u64, style: Style, ) { let new_cols = new.cols as usize; for row in &mut grid.rows { row.cells.truncate(new_cols); row.cells.resize(new_cols, blank(style)); sanitize_row(&mut row.cells, style); } grid.rows.truncate(new.rows as usize); while grid.rows.len() < new.rows as usize { grid.rows.push(TerminalRow::new(new_cols, *next_id, style)); *next_id += 1; } grid.history.clear(); let _ = old; } fn sanitize_title(value: &str) -> String { let mut value: String = value .chars() .map(|ch| if ch.is_control() { ' ' } else { ch }) .collect(); if value.len() > MAX_TERMINAL_METADATA_BYTES { let mut end = MAX_TERMINAL_METADATA_BYTES; while !value.is_char_boundary(end) { end -= 1; } value.truncate(end); } value } fn dec_special_graphics(ch: char) -> char { match ch { '`' => '◆', 'a' => '▒', 'f' => '°', 'g' => '±', 'j' => '┘', 'k' => '┐', 'l' => '┌', 'm' => '└', 'n' => '┼', 'o' => '⎺', 'p' => '⎻', 'q' => '─', 'r' => '⎼', 's' => '⎽', 't' => '├', 'u' => '┤', 'v' => '┴', 'w' => '┬', 'x' => '│', 'y' => '≤', 'z' => '≥', '{' => 'π', '|' => '≠', '}' => '£', '~' => '·', _ => ch, } } #[cfg(test)] mod tests { use super::*; use crate::ansi::{AnsiParser, AnsiParserProfile}; fn screen(rows: u32, cols: u32) -> TerminalScreen { TerminalScreen::new(CellSize::new(rows, cols), 10_000).unwrap() } fn text(snapshot: &ScreenSnapshot) -> String { snapshot .cells .iter() .map(|c| match &c.glyph { Glyph::Char(ch) => *ch, Glyph::Cluster(bytes) => { std::str::from_utf8(bytes).unwrap().chars().next().unwrap() } Glyph::Continuation => '·', }) .collect() } #[test] fn parser_split_points_produce_identical_screen() { let bytes = b"ab\x1b[2;3Hc\x1b[31mD\x1b[2J\x1b[Hdone\x1bE\x1bM\x1bD"; let mut whole_parser = AnsiParser::with_profile(AnsiParserProfile::FullScreen); let mut whole = screen(3, 8); for event in whole_parser.feed(bytes) { whole.apply_event(event); } assert_eq!(whole.snapshot().cursor, Some(CellCoord::new(1, 0))); for split in 0..=bytes.len() { let mut parser = AnsiParser::with_profile(AnsiParserProfile::FullScreen); let mut split_screen = screen(3, 8); for event in parser .feed(&bytes[..split]) .into_iter() .chain(parser.feed(&bytes[split..])) { split_screen.apply_event(event); } assert_eq!(split_screen.snapshot(), whole.snapshot(), "split {split}"); } } #[test] fn wide_combining_and_overwrite_keep_invariants() { let mut s = screen(2, 4); s.apply_event(AnsiEvent::Text("中e\u{301}".into())); assert!(matches!(s.snapshot().cells[1].glyph, Glyph::Continuation)); assert!(matches!(s.snapshot().cells[2].glyph, Glyph::Cluster(_))); s.apply_event(AnsiEvent::CursorPosition { row: 1, col: 2 }); s.apply_event(AnsiEvent::Text("x".into())); assert!(matches!(s.snapshot().cells[0].glyph, Glyph::Char(' '))); assert!(matches!(s.snapshot().cells[1].glyph, Glyph::Char('x'))); } #[test] fn control_characters_never_enter_cells() { let mut s = screen(2, 4); let before = s.snapshot(); s.apply_event(AnsiEvent::Text("\u{9b}\n\0".into())); assert_eq!(s.snapshot(), before); } #[test] fn alternate_screen_preserves_main_and_has_no_history() { let mut s = screen(2, 4); assert!(!s.alternate_active()); s.apply_event(AnsiEvent::Text("main".into())); let main = s.snapshot(); s.apply_event(AnsiEvent::AlternateScreen { mode: AlternateScreenMode::Mode1049, enabled: true, }); assert!(s.alternate_active()); s.apply_event(AnsiEvent::Text("alt\nmore".into())); assert!(s.history().is_empty()); s.apply_event(AnsiEvent::AlternateScreen { mode: AlternateScreenMode::Mode1049, enabled: false, }); assert!(!s.alternate_active()); assert_eq!(&s.snapshot().cells[..4], &main.cells[..4]); } #[test] fn acs_and_device_replies_are_exact() { let mut s = screen(4, 8); s.apply_event(AnsiEvent::DesignateCharacterSet { slot: CharacterSetSlot::G0, charset: CharacterSet::DecSpecialGraphics, }); s.apply_event(AnsiEvent::Text("lqk".into())); assert!(text(&s.snapshot()).starts_with("┌─┐")); assert_eq!( s.apply_event(AnsiEvent::DeviceRequest(DeviceRequest::PrimaryAttributes)), Some(b"\x1b[?1;2c".to_vec()) ); assert_eq!( s.apply_event(AnsiEvent::DeviceRequest(DeviceRequest::SecondaryAttributes)), Some(b"\x1b[>0;0;0c".to_vec()) ); assert_eq!( s.apply_event(AnsiEvent::DeviceRequest(DeviceRequest::OperatingStatus)), Some(b"\x1b[0n".to_vec()) ); assert_eq!( s.apply_event(AnsiEvent::DeviceRequest(DeviceRequest::CursorPosition)), Some(b"\x1b[1;4R".to_vec()) ); } #[test] fn synchronized_output_gates_snapshot_and_row_projection_until_release() { let mut s = screen(2, 4); s.apply_event(AnsiEvent::Text("main".into())); s.apply_event(AnsiEvent::LineFeed); s.apply_event(AnsiEvent::LineFeed); let before_snapshot = s.snapshot(); let before_projection = s.projection(); assert_eq!(before_projection.history.len(), 1); assert!(!before_projection.alternate_active); s.apply_event(AnsiEvent::SetMode { mode: TerminalMode::SynchronizedOutput, enabled: true, }); s.apply_event(AnsiEvent::AlternateScreen { mode: AlternateScreenMode::Mode1049, enabled: true, }); s.apply_event(AnsiEvent::Text("alt".into())); assert_eq!(s.snapshot(), before_snapshot); assert_eq!(s.projection(), before_projection); assert!(!s.alternate_active()); s.finish_output(); let released = s.projection(); assert!(released.alternate_active); assert!(released.history.is_empty()); assert_eq!(s.snapshot().cells, flatten(&released.visible_rows)); assert!(s.alternate_active()); } #[test] fn borrowed_projection_reuses_live_and_published_row_storage() { let mut s = screen(2, 4); s.apply_event(AnsiEvent::Text("main".into())); s.apply_event(AnsiEvent::LineFeed); s.apply_event(AnsiEvent::LineFeed); let (live_history, _) = s.main.history.as_slices(); let live_history_ptr = live_history.as_ptr(); let live_visible_ptr = s.main.rows.as_ptr(); let projection = s.projection_ref(); assert_eq!(projection.history_head.as_ptr(), live_history_ptr); assert_eq!(projection.visible_rows.as_ptr(), live_visible_ptr); s.apply_event(AnsiEvent::SetMode { mode: TerminalMode::SynchronizedOutput, enabled: true, }); let published_history_ptr = s.published.history.as_ptr(); let published_visible_ptr = s.published.visible_rows.as_ptr(); let projection = s.projection_ref(); assert_eq!(projection.history_head.as_ptr(), published_history_ptr); assert_eq!(projection.visible_rows.as_ptr(), published_visible_ptr); } #[test] fn main_reflows_soft_wrap_and_alt_clips() { let mut s = screen(2, 4); s.apply_event(AnsiEvent::Text("abcdef".into())); let id = s.visible_rows()[0].logical_line_id; s.resize(CellSize::new(3, 3)).unwrap(); assert_eq!(s.visible_rows()[0].logical_line_id, id); assert_eq!(s.visible_rows()[1].logical_line_id, id); assert!(s.visible_rows()[0].soft_wrapped); s.apply_event(AnsiEvent::AlternateScreen { mode: AlternateScreenMode::Mode1049, enabled: true, }); s.apply_event(AnsiEvent::Text("abcdef".into())); s.resize(CellSize::new(3, 2)).unwrap(); assert_eq!(text(&s.snapshot())[..4].to_string(), "abde"); } #[test] fn history_obeys_row_and_cell_caps() { let mut s = TerminalScreen::new(CellSize::new(2, 512), 2).unwrap(); for _ in 0..6 { s.apply_event(AnsiEvent::LineFeed); } assert_eq!(s.history().len(), 2); let mut budget = TerminalScreen::new(CellSize::new(2, 512), 20_000).unwrap(); for _ in 0..8_000 { budget.apply_event(AnsiEvent::LineFeed); } assert!(budget.history().len() * 512 <= MAX_TERMINAL_HISTORY_CELLS); } #[test] fn index_next_line_and_reverse_index_respect_scrolling_margins() { let mut s = screen(4, 4); for (row, value) in ["aaaa", "bbbb", "cccc", "dddd"].into_iter().enumerate() { s.apply_event(AnsiEvent::CursorPosition { row: row as u32 + 1, col: 1, }); s.apply_event(AnsiEvent::Text(value.into())); } s.apply_event(AnsiEvent::SetScrollingRegion { top: 2, bottom: Some(3), }); s.apply_event(AnsiEvent::CursorPosition { row: 2, col: 3 }); s.apply_event(AnsiEvent::ReverseIndex); assert_eq!(&text(&s.snapshot()), "aaaa bbbbdddd"); assert_eq!(s.snapshot().cursor, Some(CellCoord::new(1, 2))); s.apply_event(AnsiEvent::Index); assert_eq!(s.snapshot().cursor, Some(CellCoord::new(2, 2))); s.apply_event(AnsiEvent::NextLine); assert_eq!(&text(&s.snapshot()), "aaaabbbb dddd"); assert_eq!(s.snapshot().cursor, Some(CellCoord::new(2, 0))); } #[test] fn resize_only_adds_default_tab_stops_in_new_columns() { let mut s = screen(2, 16); s.apply_event(AnsiEvent::ClearAllTabStops); s.apply_event(AnsiEvent::CursorHorizontalAbsolute(4)); s.apply_event(AnsiEvent::SetTabStop); s.resize(CellSize::new(2, 32)).unwrap(); s.apply_event(AnsiEvent::CursorHorizontalAbsolute(1)); s.apply_event(AnsiEvent::HorizontalTab); assert_eq!(s.snapshot().cursor, Some(CellCoord::new(0, 3))); s.apply_event(AnsiEvent::HorizontalTab); assert_eq!(s.snapshot().cursor, Some(CellCoord::new(0, 16))); } #[test] fn annotation_is_default_style_hard_line() { let mut s = screen(3, 20); s.apply_event(AnsiEvent::Text("partial".into())); s.append_process_annotation("Process 1 exited normally with code 0"); let snapshot = s.snapshot(); assert!(text(&snapshot).contains("Process 1 exited")); assert!(!s.visible_rows()[2].soft_wrapped); assert_eq!(snapshot.cursor.unwrap().row, 2); assert!(matches!( snapshot.cells[2 * 20 + 16].glyph, Glyph::Char('0') )); } #[test] fn annotation_remains_visible_without_trailing_scroll_on_one_row() { let mut s = screen(1, 64); s.append_process_annotation("Process 7 exited normally with code 0"); let snapshot = s.snapshot(); assert!(text(&snapshot).starts_with("Process 7 exited normally with code 0")); assert_eq!(snapshot.cursor, Some(CellCoord::new(0, 37))); assert!(s.history().is_empty()); assert!(!s.visible_rows()[0].soft_wrapped); } #[test] fn invalid_resize_is_atomic() { let mut s = screen(2, 2); let before = s.snapshot(); assert!(s.resize(CellSize::new(0, 2)).is_err()); assert_eq!(s.snapshot(), before); } #[test] fn cursor_erase_insert_delete_scroll_and_margins_mutate_exact_regions() { let mut s = screen(4, 6); s.apply_event(AnsiEvent::Text("abcdef".into())); s.apply_event(AnsiEvent::CursorPosition { row: 1, col: 3 }); s.apply_event(AnsiEvent::DeleteCharacters(2)); assert!(text(&s.snapshot()).starts_with("abef ")); s.apply_event(AnsiEvent::InsertCharacters(2)); assert!(text(&s.snapshot()).starts_with("ab ef")); s.apply_event(AnsiEvent::EraseLineMode(EraseMode::ToStart)); assert!(text(&s.snapshot()).starts_with(" ef")); s.apply_event(AnsiEvent::SetScrollingRegion { top: 2, bottom: Some(3), }); s.apply_event(AnsiEvent::CursorPosition { row: 2, col: 1 }); s.apply_event(AnsiEvent::InsertLines(1)); assert_eq!(s.snapshot().cells.len(), 24); s.apply_event(AnsiEvent::ScrollUp(1)); assert_eq!(s.snapshot().cells.len(), 24); assert_eq!( s.history().len(), 0, "partial-region scroll never enters history" ); } #[test] fn alternate_and_dec_saved_cursors_are_independent() { let mut s = screen(4, 8); s.apply_event(AnsiEvent::CursorPosition { row: 3, col: 4 }); s.apply_event(AnsiEvent::SaveCursor); s.apply_event(AnsiEvent::AlternateScreen { mode: AlternateScreenMode::Mode47, enabled: true, }); s.apply_event(AnsiEvent::CursorPosition { row: 2, col: 2 }); s.apply_event(AnsiEvent::SaveCursor); s.apply_event(AnsiEvent::CursorPosition { row: 4, col: 8 }); s.apply_event(AnsiEvent::RestoreCursor); assert_eq!(s.snapshot().cursor, Some(CellCoord::new(1, 1))); s.apply_event(AnsiEvent::AlternateScreen { mode: AlternateScreenMode::Mode47, enabled: false, }); s.apply_event(AnsiEvent::RestoreCursor); assert_eq!(s.snapshot().cursor, Some(CellCoord::new(2, 3))); } #[test] fn watchdog_releases_sync_and_mouse_mode_resets_do_not_clobber_stronger_mode() { let mut s = screen(2, 4); s.apply_event(AnsiEvent::SetMode { mode: TerminalMode::MouseAny, enabled: true, }); s.apply_event(AnsiEvent::SetMode { mode: TerminalMode::MouseX10, enabled: true, }); s.apply_event(AnsiEvent::SetMode { mode: TerminalMode::MouseX10, enabled: false, }); assert_eq!(s.modes().mouse_tracking, MouseTrackingMode::Any); s.apply_event(AnsiEvent::SetMode { mode: TerminalMode::SynchronizedOutput, enabled: true, }); s.apply_event(AnsiEvent::Text("x".into())); let start = s.sync_started.unwrap(); assert!(s.synchronized_watchdog_expired(start + SYNCHRONIZED_OUTPUT_WATCHDOG)); assert!(!s.modes().synchronized_output); assert!(text(&s.snapshot()).starts_with('x')); } #[test] fn split_zwj_cluster_is_bounded_and_keeps_wide_continuation() { let mut s = screen(2, 8); s.apply_event(AnsiEvent::Text("👩\u{200d}".into())); s.apply_event(AnsiEvent::Text("💻".into())); let snapshot = s.snapshot(); assert!(matches!(snapshot.cells[0].glyph, Glyph::Cluster(_))); assert!(matches!(snapshot.cells[1].glyph, Glyph::Continuation)); for _ in 0..MAX_TERMINAL_GRAPHEME_BYTES { s.apply_event(AnsiEvent::Text("\u{301}".into())); } if let Glyph::Cluster(bytes) = &s.snapshot().cells[0].glyph { assert!(bytes.len() <= MAX_TERMINAL_GRAPHEME_BYTES); } else { panic!("cluster expected"); } } #[test] fn split_regional_indicator_modifier_zwj_and_variation_sequences_are_single_cells() { let cases = ["🇺🇸", "👍🏽", "👩\u{200d}💻", "❤\u{fe0f}"]; for sequence in cases { let mut s = screen(2, 8); for ch in sequence.chars() { s.apply_event(AnsiEvent::Text(ch.to_string())); } let snapshot = s.snapshot(); let Glyph::Cluster(bytes) = &snapshot.cells[0].glyph else { panic!("cluster expected for {sequence}"); }; assert_eq!(std::str::from_utf8(bytes).unwrap(), sequence); assert_eq!(glyph_width(&snapshot.cells[0].glyph), 2); assert!(matches!(snapshot.cells[1].glyph, Glyph::Continuation)); assert!( !snapshot.cells[2..] .iter() .any(|cell| matches!(cell.glyph, Glyph::Continuation)) ); } } #[test] fn reflow_carries_wide_glyph_whole_across_new_boundary() { let mut s = screen(2, 6); s.apply_event(AnsiEvent::Text("ab中c".into())); s.apply_event(AnsiEvent::CursorPosition { row: 1, col: 5 }); s.resize(CellSize::new(2, 3)).unwrap(); let snapshot = s.snapshot(); assert!(matches!(snapshot.cells[0].glyph, Glyph::Char('中'))); assert!(matches!(snapshot.cells[1].glyph, Glyph::Continuation)); assert!(matches!(snapshot.cells[2].glyph, Glyph::Char('c'))); assert_eq!(s.history().back().unwrap().cells[0].glyph, Glyph::Char('a')); assert_eq!(s.history().back().unwrap().cells[1].glyph, Glyph::Char('b')); } #[test] fn reflow_preserves_cursor_in_trailing_blank_cells() { let mut s = screen(2, 6); let line_id = s.visible_rows()[0].logical_line_id; s.apply_event(AnsiEvent::CursorPosition { row: 1, col: 5 }); s.resize(CellSize::new(2, 3)).unwrap(); assert_eq!(s.snapshot().cursor, Some(CellCoord::new(0, 1))); assert_eq!(s.visible_rows()[0].logical_line_id, line_id); s.resize(CellSize::new(2, 6)).unwrap(); assert_eq!(s.snapshot().cursor, Some(CellCoord::new(0, 4))); assert_eq!(s.visible_rows()[0].logical_line_id, line_id); } #[test] fn no_autowrap_wide_at_right_edge_never_scrolls_or_orphans() { let mut s = screen(2, 4); s.apply_event(AnsiEvent::SetMode { mode: TerminalMode::AutoWrap, enabled: false, }); s.apply_event(AnsiEvent::CursorPosition { row: 1, col: 4 }); s.apply_event(AnsiEvent::Text("中".into())); let snapshot = s.snapshot(); assert_eq!(snapshot.cursor, Some(CellCoord::new(0, 3))); assert!(matches!(snapshot.cells[3].glyph, Glyph::Char('\u{fffd}'))); assert!(s.history().is_empty()); assert!( !snapshot .cells .iter() .any(|cell| matches!(cell.glyph, Glyph::Continuation)) ); } #[test] fn variation_selector_width_change_at_right_edge_wraps_atomically() { let mut s = screen(2, 4); s.apply_event(AnsiEvent::Text("abc❤".into())); s.apply_event(AnsiEvent::Text("\u{fe0f}".into())); let snapshot = s.snapshot(); assert!(matches!(snapshot.cells[3].glyph, Glyph::Char(' '))); assert!(matches!(snapshot.cells[4].glyph, Glyph::Cluster(_))); assert!(matches!(snapshot.cells[5].glyph, Glyph::Continuation)); assert_eq!(snapshot.cursor, Some(CellCoord::new(1, 2))); } #[test] fn title_is_single_line_control_free_and_utf8_bounded() { let mut s = screen(2, 4); s.apply_event(AnsiEvent::SetTitle(format!( "a\r\nb\x1bc{}", "é".repeat(600) ))); let title = s.snapshot().title.unwrap(); assert_eq!(&title[..6], "a b c"); assert!(!title.chars().any(char::is_control)); assert!(title.len() <= MAX_TERMINAL_METADATA_BYTES); assert!(title.is_char_boundary(title.len())); } #[test] fn hard_break_splits_a_previous_soft_wrap_chain_before_reflow() { let mut s = screen(3, 4); s.apply_event(AnsiEvent::Text("abcdefgh".into())); let wrapped_id = s.visible_rows()[0].logical_line_id; assert_eq!(s.visible_rows()[1].logical_line_id, wrapped_id); s.apply_event(AnsiEvent::CursorPosition { row: 1, col: 1 }); s.apply_event(AnsiEvent::LineFeed); assert!(!s.visible_rows()[0].soft_wrapped); assert_ne!(s.visible_rows()[1].logical_line_id, wrapped_id); let hard_id = s.visible_rows()[1].logical_line_id; s.resize(CellSize::new(3, 8)).unwrap(); assert_eq!(s.visible_rows()[0].logical_line_id, wrapped_id); assert_eq!(s.visible_rows()[1].logical_line_id, hard_id); assert_eq!(&text(&s.snapshot())[..12], "abcd efgh"); } #[test] fn styled_trailing_blanks_survive_reflow_and_count_as_content() { let mut s = screen(2, 4); let style = Style { bg: crate::cell::Color::Indexed(4), underline: crate::cell::UnderlineStyle::Single, ..Style::default() }; s.apply_event(AnsiEvent::SetStyle(style)); s.apply_event(AnsiEvent::Text(" ".into())); s.apply_event(AnsiEvent::CursorPosition { row: 1, col: 2 }); s.resize(CellSize::new(2, 2)).unwrap(); assert_eq!(s.snapshot().cells[0].style, style); assert_eq!(s.snapshot().cells[1].style, style); s.resize(CellSize::new(2, 4)).unwrap(); assert_eq!(s.snapshot().cells[0].style, style); assert_eq!(s.snapshot().cells[1].style, style); s.apply_event(AnsiEvent::CursorPosition { row: 1, col: 1 }); s.apply_event(AnsiEvent::EraseLineMode(EraseMode::All)); s.append_process_annotation("exit"); assert!(text(&s.snapshot()).contains("exit")); assert_eq!(s.snapshot().cursor.unwrap().row, 1); } }