2191 lines
75 KiB
Rust
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);
|
|
}
|
|
}
|