pmacs/src/terminal/screen.rs

2191 lines
75 KiB
Rust

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<Cell>,
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<Cell>,
pub cursor: Option<CellCoord>,
pub title: Option<String>,
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<TerminalRow>,
/// Active visible rows, including logical-line and soft-wrap metadata.
pub visible_rows: Vec<TerminalRow>,
/// Published cursor position when visible.
pub cursor: Option<CellCoord>,
/// Published terminal title.
pub title: Option<String>,
/// 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<CellCoord>,
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<TerminalRow>,
history: VecDeque<TerminalRow>,
}
#[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<SavedCursor>,
saved_alt_cursor: Option<SavedCursor>,
saved_main_1049: Option<SavedCursor>,
inactive_main_cursor: Cursor,
inactive_alt_cursor: Cursor,
style: Style,
modes: TerminalModes,
scroll_top: usize,
scroll_bottom: usize,
tab_stops: BTreeSet<usize>,
title: Option<String>,
generation: u64,
published: ScreenProjection,
sync_started: Option<Instant>,
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<Self, ScreenError> {
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<Vec<u8>> {
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<TerminalRow> {
&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<u32>) {
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<u8> {
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<TerminalRow> = self.main.history.drain(..).collect();
all.append(&mut self.main.rows);
let mut logical: Vec<(u64, usize, Vec<Cell>)> = 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<usize> {
(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<Cell> {
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<u8> {
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);
}
}