forked from NotAShelf/beer
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I9cace0b7c6995c0fca21ff2cf465ae1f6a6a6964
1457 lines
45 KiB
Rust
1457 lines
45 KiB
Rust
//! The terminal screen: a grid of styled cells, a cursor, and the editing
|
|
//! operations the VT parser drives.
|
|
|
|
mod links;
|
|
mod search;
|
|
mod selection;
|
|
|
|
use std::collections::VecDeque;
|
|
use std::num::NonZeroU16;
|
|
|
|
use unicode_width::UnicodeWidthChar;
|
|
|
|
pub use links::UrlHit;
|
|
use search::SearchState;
|
|
|
|
/// Maximum scrollback lines retained for the main screen.
|
|
const SCROLLBACK_CAP: usize = 10_000;
|
|
|
|
/// A cell colour: terminal default, a palette index, or direct RGB.
|
|
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
|
|
pub enum Color {
|
|
#[default]
|
|
Default,
|
|
Indexed(u8),
|
|
Rgb(u8, u8, u8),
|
|
}
|
|
|
|
/// Per-cell style flags, packed into a `u16`.
|
|
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
|
|
pub struct Flags(u16);
|
|
|
|
impl Flags {
|
|
pub const BOLD: Self = Self(1 << 0);
|
|
pub const DIM: Self = Self(1 << 1);
|
|
pub const ITALIC: Self = Self(1 << 2);
|
|
pub const BLINK: Self = Self(1 << 4);
|
|
pub const REVERSE: Self = Self(1 << 5);
|
|
pub const HIDDEN: Self = Self(1 << 6);
|
|
pub const STRIKE: Self = Self(1 << 7);
|
|
/// Trailing column of a double-width glyph; holds no character of its own.
|
|
pub const WIDE_CONT: Self = Self(1 << 8);
|
|
pub const OVERLINE: Self = Self(1 << 9);
|
|
|
|
pub const fn empty() -> Self {
|
|
Self(0)
|
|
}
|
|
|
|
pub const fn union(self, other: Self) -> Self {
|
|
Self(self.0 | other.0)
|
|
}
|
|
|
|
pub fn contains(self, other: Self) -> bool {
|
|
self.0 & other.0 == other.0
|
|
}
|
|
|
|
pub fn insert(&mut self, other: Self) {
|
|
self.0 |= other.0;
|
|
}
|
|
|
|
pub fn remove(&mut self, other: Self) {
|
|
self.0 &= !other.0;
|
|
}
|
|
}
|
|
|
|
/// Underline style (SGR 4 / 4:x / 21).
|
|
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
|
|
pub enum Underline {
|
|
#[default]
|
|
None,
|
|
Single,
|
|
Double,
|
|
Curly,
|
|
Dotted,
|
|
Dashed,
|
|
}
|
|
|
|
/// Cursor shape (DECSCUSR).
|
|
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
|
|
pub enum CursorShape {
|
|
#[default]
|
|
Block,
|
|
Underline,
|
|
Beam,
|
|
}
|
|
|
|
/// Which mouse events the application has asked to receive (DECSET 9/1000-1003).
|
|
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
|
|
pub enum MouseProtocol {
|
|
/// No reporting; the pointer drives local selection/scroll.
|
|
#[default]
|
|
Off,
|
|
/// X10 (9): button presses only.
|
|
X10,
|
|
/// Normal (1000): button press and release.
|
|
Normal,
|
|
/// Button-event (1002): press, release, and motion while a button is held.
|
|
Button,
|
|
/// Any-event (1003): press, release, and all pointer motion.
|
|
Any,
|
|
}
|
|
|
|
/// How mouse events are framed on the wire (default byte form, UTF-8, or SGR).
|
|
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
|
|
pub enum MouseEncoding {
|
|
/// Legacy `CSI M Cb Cx Cy`, each value a byte offset by 32 (≤ 223).
|
|
#[default]
|
|
X10,
|
|
/// As X10 but coordinates above 95 are UTF-8 encoded (DECSET 1005).
|
|
Utf8,
|
|
/// `CSI < Cb ; Cx ; Cy M/m`, decimal and unbounded (DECSET 1006).
|
|
Sgr,
|
|
}
|
|
|
|
/// One grid cell: a character plus its rendering style.
|
|
#[derive(Clone, PartialEq, Eq, Debug)]
|
|
pub struct Cell {
|
|
pub c: char,
|
|
pub fg: Color,
|
|
pub bg: Color,
|
|
pub flags: Flags,
|
|
pub underline: Underline,
|
|
/// Underline colour; `Default` means "follow the foreground".
|
|
pub underline_color: Color,
|
|
/// Zero-width combining marks attached to `c`, in arrival order. `None` for
|
|
/// the common case; the renderer stacks each over the base glyph. This is
|
|
/// the grapheme cluster a future shaper (HarfBuzz) would consume.
|
|
pub combining: Option<Box<str>>,
|
|
/// OSC 8 hyperlink: a 1-based index into the grid's link table, or `None`.
|
|
pub link: Option<NonZeroU16>,
|
|
}
|
|
|
|
impl Default for Cell {
|
|
fn default() -> Self {
|
|
Self {
|
|
c: ' ',
|
|
fg: Color::Default,
|
|
bg: Color::Default,
|
|
flags: Flags::empty(),
|
|
underline: Underline::None,
|
|
underline_color: Color::Default,
|
|
combining: None,
|
|
link: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Default)]
|
|
struct Cursor {
|
|
x: usize,
|
|
y: usize,
|
|
}
|
|
|
|
/// Shell-integration prompt mark on a line (OSC 133): the start of a prompt,
|
|
/// the start of typed command input, the start of command output, or the line
|
|
/// where the command finished.
|
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
|
pub enum PromptKind {
|
|
PromptStart,
|
|
CmdStart,
|
|
OutputStart,
|
|
CmdEnd,
|
|
}
|
|
|
|
/// One screen/scrollback row: its cells plus whether it soft-wrapped into the
|
|
/// next row (autowrap continuation, as opposed to a hard line break). The flag
|
|
/// is what lets resize rejoin and rewrap paragraphs.
|
|
#[derive(Clone, PartialEq, Eq, Debug)]
|
|
struct Line {
|
|
cells: Vec<Cell>,
|
|
wrapped: bool,
|
|
/// OSC 133 mark attached to this (logical) line, if any.
|
|
prompt: Option<PromptKind>,
|
|
}
|
|
|
|
impl Line {
|
|
fn blank(cols: usize) -> Self {
|
|
Self {
|
|
cells: vec![Cell::default(); cols],
|
|
wrapped: false,
|
|
prompt: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A point in the combined scrollback+live coordinate space: `row` indexes
|
|
/// scrollback lines first (oldest at 0), then the live screen.
|
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
|
pub struct Point {
|
|
pub row: usize,
|
|
pub col: usize,
|
|
}
|
|
|
|
/// The active screen plus cursor, scroll region, and current pen.
|
|
#[derive(Debug)]
|
|
pub struct Grid {
|
|
cols: usize,
|
|
rows: usize,
|
|
lines: Vec<Line>,
|
|
cursor: Cursor,
|
|
saved: Cursor,
|
|
/// Inclusive top/bottom rows of the scroll region.
|
|
top: usize,
|
|
bottom: usize,
|
|
/// Template cell carrying the current SGR colours/flags.
|
|
pen: Cell,
|
|
autowrap: bool,
|
|
origin: bool,
|
|
insert: bool,
|
|
/// Cursor parked past the last column, awaiting the next print to wrap.
|
|
wrap_pending: bool,
|
|
tabs: Vec<bool>,
|
|
/// Saved primary screen while the alternate screen is active.
|
|
alt_saved: Option<Vec<Line>>,
|
|
/// Lines that have scrolled off the top of the main screen, newest last.
|
|
scrollback: VecDeque<Line>,
|
|
/// How many lines the viewport is scrolled back from the live bottom.
|
|
view_offset: usize,
|
|
cursor_shape: CursorShape,
|
|
/// Whether the cursor shape is a blinking variant (DECSCUSR odd codes).
|
|
cursor_blink: bool,
|
|
cursor_visible: bool,
|
|
/// Application cursor-keys mode (DECCKM): arrows send SS3 instead of CSI.
|
|
app_cursor: bool,
|
|
/// Cursor colour from OSC 12; `None` follows the cell under the cursor.
|
|
cursor_color: Option<(u8, u8, u8)>,
|
|
/// Active mouse selection as (anchor, head) in absolute coordinates.
|
|
selection: Option<(Point, Point)>,
|
|
/// Whether the selection is a rectangular block rather than linear flow.
|
|
selection_block: bool,
|
|
/// Bracketed paste mode (DECSET 2004): wrap pasted text in `ESC[200~`/`201~`.
|
|
bracketed_paste: bool,
|
|
/// Synchronized output (DECSET 2026): hold presentation while a frame is
|
|
/// being assembled, so the screen never shows a half-drawn update.
|
|
sync: bool,
|
|
/// Which mouse events the application wants reported.
|
|
mouse_protocol: MouseProtocol,
|
|
/// Wire framing for those reports.
|
|
mouse_encoding: MouseEncoding,
|
|
/// Focus in/out reporting (DECSET 1004).
|
|
focus_events: bool,
|
|
/// Active incremental scrollback search, if any.
|
|
search: Option<SearchState>,
|
|
/// Characters that break a word for double-click selection.
|
|
word_delimiters: String,
|
|
/// History retention cap for the main screen.
|
|
scrollback_cap: usize,
|
|
/// Position of the last printed base cell, so a following zero-width
|
|
/// combining mark can attach to it.
|
|
last_base: Option<(usize, usize)>,
|
|
/// OSC 8 hyperlink URIs; a cell's `link` is a 1-based index into this.
|
|
links: Vec<Box<str>>,
|
|
}
|
|
|
|
fn default_tabs(cols: usize) -> Vec<bool> {
|
|
(0..cols).map(|i| i % 8 == 0 && i != 0).collect()
|
|
}
|
|
|
|
/// Default characters that terminate a word for double-click selection (the
|
|
/// `word-delimiters` config key overrides this). `_`, `-`, `.`, `/`, `:`, `~`
|
|
/// are deliberately *not* delimiters so paths, URLs, and option flags select as
|
|
/// one unit.
|
|
const WORD_DELIMITERS: &str = " \t`!@#$%^&*()+=[]{}\\|;'\",<>?";
|
|
|
|
/// Whether `c` is part of a word (not whitespace, not in `delims`).
|
|
fn is_word(c: char, delims: &str) -> bool {
|
|
!c.is_whitespace() && !delims.contains(c)
|
|
}
|
|
|
|
impl Grid {
|
|
pub fn new(cols: usize, rows: usize) -> Self {
|
|
let cols = cols.max(1);
|
|
let rows = rows.max(1);
|
|
Self {
|
|
cols,
|
|
rows,
|
|
lines: vec![Line::blank(cols); rows],
|
|
cursor: Cursor::default(),
|
|
saved: Cursor::default(),
|
|
top: 0,
|
|
bottom: rows - 1,
|
|
pen: Cell::default(),
|
|
autowrap: true,
|
|
origin: false,
|
|
insert: false,
|
|
wrap_pending: false,
|
|
tabs: default_tabs(cols),
|
|
alt_saved: None,
|
|
scrollback: VecDeque::new(),
|
|
view_offset: 0,
|
|
cursor_shape: CursorShape::default(),
|
|
cursor_blink: false,
|
|
cursor_visible: true,
|
|
cursor_color: None,
|
|
app_cursor: false,
|
|
selection: None,
|
|
selection_block: false,
|
|
bracketed_paste: false,
|
|
sync: false,
|
|
mouse_protocol: MouseProtocol::Off,
|
|
mouse_encoding: MouseEncoding::X10,
|
|
focus_events: false,
|
|
search: None,
|
|
word_delimiters: WORD_DELIMITERS.to_string(),
|
|
scrollback_cap: SCROLLBACK_CAP,
|
|
last_base: None,
|
|
links: Vec::new(),
|
|
}
|
|
}
|
|
|
|
/// Override the word-delimiter set; `None` keeps the built-in default.
|
|
pub fn set_word_delimiters(&mut self, delims: Option<String>) {
|
|
if let Some(d) = delims {
|
|
self.word_delimiters = d;
|
|
}
|
|
}
|
|
|
|
/// Set the scrollback retention cap, trimming history if it shrank.
|
|
pub fn set_scrollback_cap(&mut self, cap: usize) {
|
|
self.scrollback_cap = cap;
|
|
while self.scrollback.len() > cap {
|
|
self.scrollback.pop_front();
|
|
}
|
|
self.view_offset = self.view_offset.min(self.scrollback.len());
|
|
}
|
|
|
|
pub fn cols(&self) -> usize {
|
|
self.cols
|
|
}
|
|
|
|
pub fn rows(&self) -> usize {
|
|
self.rows
|
|
}
|
|
|
|
/// Resize the screen. On the main screen this reflows: soft-wrapped runs are
|
|
/// rejoined into logical lines and rewrapped to the new width, across both
|
|
/// scrollback and the live screen, keeping the cursor on its content. The
|
|
/// alternate screen just clips, since its apps repaint on resize.
|
|
pub fn resize(&mut self, cols: usize, rows: usize) {
|
|
let cols = cols.max(1);
|
|
let rows = rows.max(1);
|
|
if self.alt_saved.is_some() {
|
|
self.clip_resize(cols, rows);
|
|
} else {
|
|
self.reflow_resize(cols, rows);
|
|
}
|
|
self.cols = cols;
|
|
self.rows = rows;
|
|
self.top = 0;
|
|
self.bottom = rows - 1;
|
|
self.tabs = default_tabs(cols);
|
|
self.cursor.x = self.cursor.x.min(cols - 1);
|
|
self.cursor.y = self.cursor.y.min(rows - 1);
|
|
self.wrap_pending = false;
|
|
self.view_offset = 0;
|
|
}
|
|
|
|
/// Clip-resize the live screen and the saved primary (alternate screen).
|
|
fn clip_resize(&mut self, cols: usize, rows: usize) {
|
|
for line in &mut self.lines {
|
|
line.cells.resize(cols, Cell::default());
|
|
}
|
|
self.lines.resize(rows, Line::blank(cols));
|
|
if let Some(saved) = self.alt_saved.as_mut() {
|
|
for line in saved.iter_mut() {
|
|
line.cells.resize(cols, Cell::default());
|
|
}
|
|
saved.resize(rows, Line::blank(cols));
|
|
}
|
|
self.clear_selection();
|
|
self.clear_search();
|
|
}
|
|
|
|
/// Reflow scrollback + live content to a new width, rewrapping soft-wrapped
|
|
/// paragraphs and repositioning the cursor onto its character.
|
|
fn reflow_resize(&mut self, cols: usize, rows: usize) {
|
|
let cursor_abs = self.scrollback.len() + self.cursor.y;
|
|
let total = self.scrollback.len() + self.lines.len();
|
|
|
|
// 1. Rejoin soft-wrapped rows into logical lines. Track which logical
|
|
// line the cursor falls in and its offset within that line.
|
|
let mut logicals: Vec<Vec<Cell>> = Vec::new();
|
|
let mut logical_marks: Vec<Option<PromptKind>> = Vec::new();
|
|
let mut acc: Vec<Cell> = Vec::new();
|
|
let mut acc_mark: Option<PromptKind> = None;
|
|
let mut cur_logical = 0usize;
|
|
let mut cur_off = 0usize;
|
|
for abs in 0..total {
|
|
if abs == cursor_abs {
|
|
cur_logical = logicals.len();
|
|
cur_off = acc.len() + self.cursor.x;
|
|
}
|
|
let line = if abs < self.scrollback.len() {
|
|
&self.scrollback[abs]
|
|
} else {
|
|
&self.lines[abs - self.scrollback.len()]
|
|
};
|
|
// The mark on the first physical row of a logical line carries over.
|
|
if acc.is_empty() {
|
|
acc_mark = line.prompt;
|
|
}
|
|
acc.extend_from_slice(&line.cells);
|
|
if !line.wrapped {
|
|
logicals.push(std::mem::take(&mut acc));
|
|
logical_marks.push(acc_mark.take());
|
|
}
|
|
}
|
|
if !acc.is_empty() {
|
|
logicals.push(acc);
|
|
logical_marks.push(acc_mark);
|
|
}
|
|
// Drop trailing all-blank lines (empty screen below the content), but
|
|
// never above the cursor's line, so the cursor keeps its row.
|
|
let last_content = logicals
|
|
.iter()
|
|
.rposition(|l| l.iter().any(|c| *c != Cell::default()))
|
|
.unwrap_or(0);
|
|
logicals.truncate(last_content.max(cur_logical) + 1);
|
|
logical_marks.truncate(last_content.max(cur_logical) + 1);
|
|
|
|
// 2. Rewrap each logical line to the new width, recording where the
|
|
// cursor lands. Trailing blanks are dropped so a hard line does not
|
|
// rewrap its padding onto extra rows.
|
|
let mut new_lines: Vec<Line> = Vec::new();
|
|
let mut new_cursor_abs = 0usize;
|
|
let mut new_cursor_col = 0usize;
|
|
for (li, mut logical) in logicals.into_iter().enumerate() {
|
|
let trim = logical
|
|
.iter()
|
|
.rposition(|c| *c != Cell::default())
|
|
.map_or(0, |p| p + 1);
|
|
logical.truncate(trim);
|
|
let first = new_lines.len();
|
|
let chunks = logical.len().div_ceil(cols).max(1);
|
|
for ci in 0..chunks {
|
|
let start = ci * cols;
|
|
let end = (start + cols).min(logical.len());
|
|
let mut cells = logical.get(start..end).unwrap_or(&[]).to_vec();
|
|
cells.resize(cols, Cell::default());
|
|
new_lines.push(Line {
|
|
cells,
|
|
wrapped: ci + 1 < chunks,
|
|
// The mark belongs to the first physical row of the line.
|
|
prompt: if ci == 0 { logical_marks[li] } else { None },
|
|
});
|
|
}
|
|
if li == cur_logical {
|
|
let off = cur_off.min(logical.len());
|
|
let chunk = (off / cols).min(chunks - 1);
|
|
new_cursor_abs = first + chunk;
|
|
new_cursor_col = (off - chunk * cols).min(cols - 1);
|
|
}
|
|
}
|
|
|
|
// 3. The last `rows` lines are the live screen; the rest is scrollback.
|
|
let live_start = new_lines.len().saturating_sub(rows);
|
|
let mut scrollback: VecDeque<Line> = new_lines.drain(0..live_start).collect();
|
|
let mut live = new_lines;
|
|
while live.len() < rows {
|
|
live.push(Line::blank(cols));
|
|
}
|
|
while scrollback.len() > self.scrollback_cap {
|
|
scrollback.pop_front();
|
|
}
|
|
|
|
self.cursor.y = new_cursor_abs.saturating_sub(live_start).min(rows - 1);
|
|
self.cursor.x = new_cursor_col;
|
|
self.lines = live;
|
|
self.scrollback = scrollback;
|
|
self.clear_selection();
|
|
self.clear_search();
|
|
}
|
|
|
|
pub fn cursor(&self) -> (usize, usize) {
|
|
(self.cursor.x, self.cursor.y)
|
|
}
|
|
|
|
// --- pen / attributes ---
|
|
|
|
pub fn pen_mut(&mut self) -> &mut Cell {
|
|
&mut self.pen
|
|
}
|
|
|
|
pub fn reset_pen(&mut self) {
|
|
self.pen = Cell::default();
|
|
}
|
|
|
|
pub fn set_autowrap(&mut self, on: bool) {
|
|
self.autowrap = on;
|
|
}
|
|
|
|
pub fn set_origin(&mut self, on: bool) {
|
|
self.origin = on;
|
|
self.move_to(0, 0);
|
|
}
|
|
|
|
pub fn set_insert(&mut self, on: bool) {
|
|
self.insert = on;
|
|
}
|
|
|
|
pub fn autowrap(&self) -> bool {
|
|
self.autowrap
|
|
}
|
|
|
|
pub fn origin(&self) -> bool {
|
|
self.origin
|
|
}
|
|
|
|
pub fn insert(&self) -> bool {
|
|
self.insert
|
|
}
|
|
|
|
pub fn alt_active(&self) -> bool {
|
|
self.alt_saved.is_some()
|
|
}
|
|
|
|
pub fn set_cursor_shape(&mut self, shape: CursorShape) {
|
|
self.cursor_shape = shape;
|
|
}
|
|
|
|
pub fn cursor_shape(&self) -> CursorShape {
|
|
self.cursor_shape
|
|
}
|
|
|
|
pub fn set_cursor_blink(&mut self, blink: bool) {
|
|
self.cursor_blink = blink;
|
|
}
|
|
|
|
pub fn cursor_blink(&self) -> bool {
|
|
self.cursor_blink
|
|
}
|
|
|
|
pub fn set_cursor_visible(&mut self, visible: bool) {
|
|
self.cursor_visible = visible;
|
|
}
|
|
|
|
pub fn cursor_visible(&self) -> bool {
|
|
self.cursor_visible
|
|
}
|
|
|
|
pub fn set_cursor_color(&mut self, color: Option<(u8, u8, u8)>) {
|
|
self.cursor_color = color;
|
|
}
|
|
|
|
pub fn cursor_color(&self) -> Option<(u8, u8, u8)> {
|
|
self.cursor_color
|
|
}
|
|
|
|
pub fn set_app_cursor(&mut self, on: bool) {
|
|
self.app_cursor = on;
|
|
}
|
|
|
|
pub fn app_cursor(&self) -> bool {
|
|
self.app_cursor
|
|
}
|
|
|
|
// --- printing ---
|
|
|
|
/// Place a printable character at the cursor, honouring width and autowrap.
|
|
pub fn print(&mut self, c: char) {
|
|
let width = c.width().unwrap_or(0);
|
|
if width == 0 {
|
|
// A zero-width combining mark attaches to the last base cell.
|
|
self.add_combining(c);
|
|
return;
|
|
}
|
|
if self.wrap_pending {
|
|
self.cursor.x = 0;
|
|
self.lines[self.cursor.y].wrapped = true;
|
|
self.line_feed();
|
|
self.wrap_pending = false;
|
|
}
|
|
if width == 2 && self.cursor.x + 1 >= self.cols {
|
|
// A double-width glyph cannot straddle the right edge: wrap first.
|
|
if self.autowrap {
|
|
self.cursor.x = 0;
|
|
self.lines[self.cursor.y].wrapped = true;
|
|
self.line_feed();
|
|
} else {
|
|
return;
|
|
}
|
|
}
|
|
if self.insert {
|
|
self.shift_right(width);
|
|
}
|
|
|
|
let (x, y) = (self.cursor.x, self.cursor.y);
|
|
let mut cell = self.pen.clone();
|
|
cell.c = c;
|
|
cell.combining = None;
|
|
cell.flags.remove(Flags::WIDE_CONT);
|
|
self.lines[y].cells[x] = cell;
|
|
self.last_base = Some((x, y));
|
|
if width == 2 && x + 1 < self.cols {
|
|
let mut cont = self.pen.clone();
|
|
cont.c = ' ';
|
|
cont.combining = None;
|
|
cont.flags.insert(Flags::WIDE_CONT);
|
|
self.lines[y].cells[x + 1] = cont;
|
|
}
|
|
|
|
let advance = width;
|
|
if self.cursor.x + advance >= self.cols {
|
|
if self.autowrap {
|
|
self.cursor.x = self.cols - 1;
|
|
self.wrap_pending = true;
|
|
} else {
|
|
self.cursor.x = self.cols - 1;
|
|
}
|
|
} else {
|
|
self.cursor.x += advance;
|
|
}
|
|
}
|
|
|
|
/// Attach a zero-width combining mark to the most recently printed base
|
|
/// cell. Capped so a malicious stream of marks cannot grow a cell unbounded.
|
|
fn add_combining(&mut self, mark: char) {
|
|
const MAX_MARKS: usize = 8;
|
|
let Some((x, y)) = self.last_base else {
|
|
return;
|
|
};
|
|
let Some(cell) = self.lines.get_mut(y).and_then(|l| l.cells.get_mut(x)) else {
|
|
return;
|
|
};
|
|
let mut s: String = cell.combining.take().map(String::from).unwrap_or_default();
|
|
if s.chars().count() < MAX_MARKS {
|
|
s.push(mark);
|
|
}
|
|
cell.combining = Some(s.into_boxed_str());
|
|
}
|
|
|
|
fn shift_right(&mut self, n: usize) {
|
|
let (x, y) = (self.cursor.x, self.cursor.y);
|
|
let end = self.cols;
|
|
let blank = self.pen_blank();
|
|
let row = &mut self.lines[y].cells;
|
|
for i in (x + n..end).rev() {
|
|
row[i] = row[i - n].clone();
|
|
}
|
|
for cell in &mut row[x..(x + n).min(end)] {
|
|
*cell = blank.clone();
|
|
}
|
|
}
|
|
|
|
fn pen_blank(&self) -> Cell {
|
|
// A space carrying only the current background (back-colour erase).
|
|
Cell {
|
|
bg: self.pen.bg,
|
|
..Cell::default()
|
|
}
|
|
}
|
|
|
|
// --- cursor movement ---
|
|
|
|
fn region(&self) -> (usize, usize) {
|
|
if self.origin {
|
|
(self.top, self.bottom)
|
|
} else {
|
|
(0, self.rows - 1)
|
|
}
|
|
}
|
|
|
|
pub fn move_to(&mut self, x: usize, y: usize) {
|
|
let (rt, rb) = self.region();
|
|
self.cursor.x = x.min(self.cols - 1);
|
|
self.cursor.y = (y + rt).min(rb).max(rt);
|
|
self.wrap_pending = false;
|
|
}
|
|
|
|
pub fn move_to_col(&mut self, x: usize) {
|
|
self.cursor.x = x.min(self.cols - 1);
|
|
self.wrap_pending = false;
|
|
}
|
|
|
|
pub fn move_to_row(&mut self, y: usize) {
|
|
let (rt, rb) = self.region();
|
|
self.cursor.y = (y + rt).min(rb).max(rt);
|
|
self.wrap_pending = false;
|
|
}
|
|
|
|
pub fn cursor_up(&mut self, n: usize) {
|
|
let (rt, _) = self.region();
|
|
self.cursor.y = self.cursor.y.saturating_sub(n).max(rt);
|
|
self.wrap_pending = false;
|
|
}
|
|
|
|
pub fn cursor_down(&mut self, n: usize) {
|
|
let (_, rb) = self.region();
|
|
self.cursor.y = (self.cursor.y + n).min(rb);
|
|
self.wrap_pending = false;
|
|
}
|
|
|
|
pub fn cursor_fwd(&mut self, n: usize) {
|
|
self.cursor.x = (self.cursor.x + n).min(self.cols - 1);
|
|
self.wrap_pending = false;
|
|
}
|
|
|
|
pub fn cursor_back(&mut self, n: usize) {
|
|
self.cursor.x = self.cursor.x.saturating_sub(n);
|
|
self.wrap_pending = false;
|
|
}
|
|
|
|
pub fn save_cursor(&mut self) {
|
|
self.saved = self.cursor;
|
|
}
|
|
|
|
pub fn restore_cursor(&mut self) {
|
|
self.cursor = self.saved;
|
|
self.cursor.x = self.cursor.x.min(self.cols - 1);
|
|
self.cursor.y = self.cursor.y.min(self.rows - 1);
|
|
self.wrap_pending = false;
|
|
}
|
|
|
|
// --- line discipline ---
|
|
|
|
pub fn carriage_return(&mut self) {
|
|
self.cursor.x = 0;
|
|
self.wrap_pending = false;
|
|
}
|
|
|
|
pub fn backspace(&mut self) {
|
|
self.cursor.x = self.cursor.x.saturating_sub(1);
|
|
self.wrap_pending = false;
|
|
}
|
|
|
|
/// LF/VT/FF: move down one row, scrolling at the region bottom.
|
|
pub fn line_feed(&mut self) {
|
|
if self.cursor.y == self.bottom {
|
|
self.scroll_up(1);
|
|
} else if self.cursor.y < self.rows - 1 {
|
|
self.cursor.y += 1;
|
|
}
|
|
self.wrap_pending = false;
|
|
}
|
|
|
|
/// RI: move up one row, scrolling down at the region top.
|
|
pub fn reverse_index(&mut self) {
|
|
if self.cursor.y == self.top {
|
|
self.scroll_down(1);
|
|
} else if self.cursor.y > 0 {
|
|
self.cursor.y -= 1;
|
|
}
|
|
self.wrap_pending = false;
|
|
}
|
|
|
|
/// NEL: carriage return plus line feed.
|
|
pub fn next_line(&mut self) {
|
|
self.carriage_return();
|
|
self.line_feed();
|
|
}
|
|
|
|
// --- tab stops ---
|
|
|
|
pub fn tab(&mut self) {
|
|
let mut x = self.cursor.x + 1;
|
|
while x < self.cols && !self.tabs[x] {
|
|
x += 1;
|
|
}
|
|
self.cursor.x = x.min(self.cols - 1);
|
|
self.wrap_pending = false;
|
|
}
|
|
|
|
pub fn set_tab(&mut self) {
|
|
if self.cursor.x < self.cols {
|
|
self.tabs[self.cursor.x] = true;
|
|
}
|
|
}
|
|
|
|
pub fn clear_tab(&mut self) {
|
|
if self.cursor.x < self.cols {
|
|
self.tabs[self.cursor.x] = false;
|
|
}
|
|
}
|
|
|
|
pub fn clear_all_tabs(&mut self) {
|
|
self.tabs.iter_mut().for_each(|t| *t = false);
|
|
}
|
|
|
|
// --- scrolling within the region ---
|
|
|
|
pub fn set_scroll_region(&mut self, top: usize, bottom: usize) {
|
|
if top < bottom && bottom < self.rows {
|
|
self.top = top;
|
|
self.bottom = bottom;
|
|
} else {
|
|
self.top = 0;
|
|
self.bottom = self.rows - 1;
|
|
}
|
|
self.move_to(0, 0);
|
|
}
|
|
|
|
pub fn scroll_up(&mut self, n: usize) {
|
|
let n = n.min(self.bottom - self.top + 1);
|
|
// Lines leaving the top of the *whole* main screen become scrollback;
|
|
// a DECSTBM region scroll (top > 0) or the alt screen does not.
|
|
if self.top == 0 && self.alt_saved.is_none() {
|
|
for y in 0..n {
|
|
let line = std::mem::replace(&mut self.lines[y], Line::blank(self.cols));
|
|
self.scrollback.push_back(line);
|
|
}
|
|
let mut evicted = 0;
|
|
while self.scrollback.len() > self.scrollback_cap {
|
|
self.scrollback.pop_front();
|
|
evicted += 1;
|
|
}
|
|
if evicted > 0 {
|
|
self.shift_selection(evicted);
|
|
self.shift_search(evicted);
|
|
}
|
|
// Keep a scrolled-back viewport anchored to the same content.
|
|
if self.view_offset > 0 {
|
|
self.view_offset = (self.view_offset + n).min(self.scrollback.len());
|
|
}
|
|
}
|
|
for y in self.top..=self.bottom {
|
|
if y + n <= self.bottom {
|
|
self.lines.swap(y, y + n);
|
|
}
|
|
}
|
|
for y in (self.bottom + 1 - n)..=self.bottom {
|
|
self.blank_row(y);
|
|
}
|
|
}
|
|
|
|
pub fn scroll_down(&mut self, n: usize) {
|
|
let n = n.min(self.bottom - self.top + 1);
|
|
for y in (self.top..=self.bottom).rev() {
|
|
if y >= self.top + n {
|
|
self.lines.swap(y, y - n);
|
|
}
|
|
}
|
|
for y in self.top..(self.top + n) {
|
|
self.blank_row(y);
|
|
}
|
|
}
|
|
|
|
fn blank_row(&mut self, y: usize) {
|
|
let blank = self.pen_blank();
|
|
let line = &mut self.lines[y];
|
|
for cell in &mut line.cells {
|
|
*cell = blank.clone();
|
|
}
|
|
line.wrapped = false;
|
|
}
|
|
|
|
// --- erase ---
|
|
|
|
/// ED: 0=below, 1=above, 2/3=all.
|
|
pub fn erase_display(&mut self, mode: u16) {
|
|
let (x, y) = (self.cursor.x, self.cursor.y);
|
|
match mode {
|
|
0 => {
|
|
self.erase_in_row(y, x, self.cols);
|
|
for r in (y + 1)..self.rows {
|
|
self.blank_row(r);
|
|
}
|
|
}
|
|
1 => {
|
|
for r in 0..y {
|
|
self.blank_row(r);
|
|
}
|
|
self.erase_in_row(y, 0, x + 1);
|
|
}
|
|
_ => {
|
|
for r in 0..self.rows {
|
|
self.blank_row(r);
|
|
}
|
|
}
|
|
}
|
|
self.wrap_pending = false;
|
|
}
|
|
|
|
/// EL: 0=right, 1=left, 2=line.
|
|
pub fn erase_line(&mut self, mode: u16) {
|
|
let (x, y) = (self.cursor.x, self.cursor.y);
|
|
match mode {
|
|
0 => self.erase_in_row(y, x, self.cols),
|
|
1 => self.erase_in_row(y, 0, x + 1),
|
|
_ => self.erase_in_row(y, 0, self.cols),
|
|
}
|
|
self.wrap_pending = false;
|
|
}
|
|
|
|
/// ECH: erase n characters from the cursor without moving it.
|
|
pub fn erase_chars(&mut self, n: usize) {
|
|
let (x, y) = (self.cursor.x, self.cursor.y);
|
|
self.erase_in_row(y, x, (x + n).min(self.cols));
|
|
}
|
|
|
|
fn erase_in_row(&mut self, y: usize, from: usize, to: usize) {
|
|
let blank = self.pen_blank();
|
|
for cell in &mut self.lines[y].cells[from..to.min(self.cols)] {
|
|
*cell = blank.clone();
|
|
}
|
|
}
|
|
|
|
// --- intra-line editing ---
|
|
|
|
/// ICH: insert n blanks at the cursor, shifting the rest right.
|
|
pub fn insert_chars(&mut self, n: usize) {
|
|
let saved = self.insert;
|
|
self.insert = true;
|
|
self.shift_right(n.min(self.cols));
|
|
self.insert = saved;
|
|
}
|
|
|
|
/// DCH: delete n characters at the cursor, shifting the rest left.
|
|
pub fn delete_chars(&mut self, n: usize) {
|
|
let (x, y) = (self.cursor.x, self.cursor.y);
|
|
let n = n.min(self.cols - x);
|
|
let blank = self.pen_blank();
|
|
let row = &mut self.lines[y].cells;
|
|
for i in x..self.cols {
|
|
row[i] = if i + n < self.cols {
|
|
row[i + n].clone()
|
|
} else {
|
|
blank.clone()
|
|
};
|
|
}
|
|
}
|
|
|
|
/// IL: insert n blank lines at the cursor row, within the scroll region.
|
|
pub fn insert_lines(&mut self, n: usize) {
|
|
if self.cursor.y < self.top || self.cursor.y > self.bottom {
|
|
return;
|
|
}
|
|
let n = n.min(self.bottom - self.cursor.y + 1);
|
|
for y in (self.cursor.y..=self.bottom).rev() {
|
|
if y >= self.cursor.y + n {
|
|
self.lines.swap(y, y - n);
|
|
}
|
|
}
|
|
for y in self.cursor.y..(self.cursor.y + n) {
|
|
self.blank_row(y);
|
|
}
|
|
}
|
|
|
|
/// DL: delete n lines at the cursor row, within the scroll region.
|
|
pub fn delete_lines(&mut self, n: usize) {
|
|
if self.cursor.y < self.top || self.cursor.y > self.bottom {
|
|
return;
|
|
}
|
|
let n = n.min(self.bottom - self.cursor.y + 1);
|
|
for y in self.cursor.y..=self.bottom {
|
|
if y + n <= self.bottom {
|
|
self.lines.swap(y, y + n);
|
|
}
|
|
}
|
|
for y in (self.bottom + 1 - n)..=self.bottom {
|
|
self.blank_row(y);
|
|
}
|
|
}
|
|
|
|
// --- alternate screen ---
|
|
|
|
pub fn enter_alt_screen(&mut self) {
|
|
if self.alt_saved.is_some() {
|
|
return;
|
|
}
|
|
self.view_offset = 0;
|
|
let blank = vec![Line::blank(self.cols); self.rows];
|
|
self.alt_saved = Some(std::mem::replace(&mut self.lines, blank));
|
|
}
|
|
|
|
pub fn leave_alt_screen(&mut self) {
|
|
if let Some(main) = self.alt_saved.take() {
|
|
self.lines = main;
|
|
}
|
|
}
|
|
|
|
// --- scrollback viewport ---
|
|
|
|
/// Scroll the viewport by `delta` lines: positive = back into history,
|
|
/// negative = toward the live screen. No-op on the alternate screen.
|
|
pub fn scroll_view(&mut self, delta: isize) {
|
|
if self.alt_saved.is_some() {
|
|
return;
|
|
}
|
|
let max = self.scrollback.len() as isize;
|
|
self.view_offset = (self.view_offset as isize + delta).clamp(0, max) as usize;
|
|
}
|
|
|
|
pub fn scroll_to_bottom(&mut self) {
|
|
self.view_offset = 0;
|
|
}
|
|
|
|
/// Whether the viewport is showing the live screen (not scrolled back).
|
|
pub fn view_at_bottom(&self) -> bool {
|
|
self.view_offset == 0
|
|
}
|
|
|
|
/// One page (a screenful) of lines, for page-scroll bindings.
|
|
pub fn page(&self) -> usize {
|
|
self.rows.max(1)
|
|
}
|
|
|
|
/// The cells shown at viewport row `y` (0 = top of the window), accounting
|
|
/// for the scrollback offset. May differ from `cols` in width if the line
|
|
/// predates a resize (no reflow yet), so callers must not assume length.
|
|
pub fn view_row(&self, y: usize) -> &[Cell] {
|
|
let start = self.scrollback.len() - self.view_offset;
|
|
let idx = start + y;
|
|
if idx < self.scrollback.len() {
|
|
&self.scrollback[idx].cells
|
|
} else {
|
|
&self.lines[idx - self.scrollback.len()].cells
|
|
}
|
|
}
|
|
|
|
// --- selection ---
|
|
|
|
/// The absolute row currently shown at viewport row `y`.
|
|
pub fn view_to_abs(&self, y: usize) -> usize {
|
|
self.scrollback.len() - self.view_offset + y
|
|
}
|
|
|
|
/// The line at an absolute row (scrollback first, then the live screen).
|
|
fn line_at_abs(&self, abs: usize) -> &Line {
|
|
if abs < self.scrollback.len() {
|
|
&self.scrollback[abs]
|
|
} else {
|
|
&self.lines[abs - self.scrollback.len()]
|
|
}
|
|
}
|
|
|
|
/// Cells of an absolute row (scrollback first, then the live screen).
|
|
fn abs_row(&self, row: usize) -> &[Cell] {
|
|
if row < self.scrollback.len() {
|
|
&self.scrollback[row].cells
|
|
} else {
|
|
&self.lines[row - self.scrollback.len()].cells
|
|
}
|
|
}
|
|
|
|
/// Total rows across scrollback and the live screen.
|
|
fn total_lines(&self) -> usize {
|
|
self.scrollback.len() + self.lines.len()
|
|
}
|
|
|
|
/// The OSC 133 mark on an absolute row, if any.
|
|
fn abs_prompt(&self, row: usize) -> Option<PromptKind> {
|
|
if row < self.scrollback.len() {
|
|
self.scrollback[row].prompt
|
|
} else {
|
|
self.lines
|
|
.get(row - self.scrollback.len())
|
|
.and_then(|l| l.prompt)
|
|
}
|
|
}
|
|
|
|
// --- shell integration (OSC 133) ---
|
|
|
|
/// Attach an OSC 133 prompt mark to the live line under the cursor.
|
|
pub fn set_prompt_mark(&mut self, kind: PromptKind) {
|
|
let y = self.cursor.y;
|
|
if let Some(line) = self.lines.get_mut(y) {
|
|
line.prompt = Some(kind);
|
|
}
|
|
}
|
|
|
|
/// Scroll the viewport to the previous (`up`) or next prompt, placing that
|
|
/// prompt line at the top of the window. No-op on the alternate screen or
|
|
/// when there is no prompt in that direction.
|
|
pub fn jump_prompt(&mut self, up: bool) {
|
|
if self.alt_saved.is_some() {
|
|
return;
|
|
}
|
|
let top = self.scrollback.len().saturating_sub(self.view_offset);
|
|
let total = self.total_lines();
|
|
let is_prompt = |k: Option<PromptKind>| k == Some(PromptKind::PromptStart);
|
|
let target = if up {
|
|
(0..top).rev().find(|&r| is_prompt(self.abs_prompt(r)))
|
|
} else {
|
|
((top + 1)..total).find(|&r| is_prompt(self.abs_prompt(r)))
|
|
};
|
|
if let Some(t) = target {
|
|
let offset = self.scrollback.len() as isize - t as isize;
|
|
self.view_offset = offset.clamp(0, self.scrollback.len() as isize) as usize;
|
|
}
|
|
}
|
|
|
|
/// Text of the most recent command's output: the rows from the last
|
|
/// output-start (OSC 133 C) up to the command-end (D) or next prompt.
|
|
pub fn last_command_output(&self) -> Option<String> {
|
|
let total = self.total_lines();
|
|
let start = (0..total)
|
|
.rev()
|
|
.find(|&r| self.abs_prompt(r) == Some(PromptKind::OutputStart))?;
|
|
let mut lines: Vec<String> = Vec::new();
|
|
for r in start..total {
|
|
if r > start
|
|
&& matches!(
|
|
self.abs_prompt(r),
|
|
Some(PromptKind::CmdEnd | PromptKind::PromptStart)
|
|
)
|
|
{
|
|
break;
|
|
}
|
|
lines.push(self.row_slice_text(r, 0, usize::MAX).trim_end().to_string());
|
|
}
|
|
// Drop trailing blank rows (e.g. the empty live screen below the output).
|
|
while lines.last().is_some_and(|l| l.is_empty()) {
|
|
lines.pop();
|
|
}
|
|
let mut out = lines.join("\n");
|
|
out.push('\n');
|
|
Some(out)
|
|
}
|
|
|
|
pub fn set_bracketed_paste(&mut self, on: bool) {
|
|
self.bracketed_paste = on;
|
|
}
|
|
|
|
pub fn bracketed_paste(&self) -> bool {
|
|
self.bracketed_paste
|
|
}
|
|
|
|
pub fn set_sync(&mut self, on: bool) {
|
|
self.sync = on;
|
|
}
|
|
|
|
pub fn sync_active(&self) -> bool {
|
|
self.sync
|
|
}
|
|
|
|
pub fn set_mouse_protocol(&mut self, protocol: MouseProtocol) {
|
|
self.mouse_protocol = protocol;
|
|
}
|
|
|
|
pub fn mouse_protocol(&self) -> MouseProtocol {
|
|
self.mouse_protocol
|
|
}
|
|
|
|
pub fn set_mouse_encoding(&mut self, encoding: MouseEncoding) {
|
|
self.mouse_encoding = encoding;
|
|
}
|
|
|
|
pub fn mouse_encoding(&self) -> MouseEncoding {
|
|
self.mouse_encoding
|
|
}
|
|
|
|
pub fn set_focus_events(&mut self, on: bool) {
|
|
self.focus_events = on;
|
|
}
|
|
|
|
pub fn focus_events(&self) -> bool {
|
|
self.focus_events
|
|
}
|
|
|
|
// --- inspection (logging + tests) ---
|
|
|
|
/// The visible text of one row, trailing blanks trimmed.
|
|
#[cfg(test)]
|
|
pub fn row_text(&self, y: usize) -> String {
|
|
self.lines[y]
|
|
.cells
|
|
.iter()
|
|
.filter(|c| !c.flags.contains(Flags::WIDE_CONT))
|
|
.map(|c| c.c)
|
|
.collect::<String>()
|
|
.trim_end()
|
|
.to_string()
|
|
}
|
|
|
|
pub fn cell(&self, x: usize, y: usize) -> &Cell {
|
|
&self.lines[y].cells[x]
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn prints_and_wraps() {
|
|
let mut g = Grid::new(4, 2);
|
|
for c in "abcde".chars() {
|
|
g.print(c);
|
|
}
|
|
assert_eq!(g.row_text(0), "abcd");
|
|
assert_eq!(g.row_text(1), "e");
|
|
assert_eq!(g.cursor(), (1, 1));
|
|
}
|
|
|
|
#[test]
|
|
fn combining_marks_attach_to_base_cell() {
|
|
let mut g = Grid::new(8, 1);
|
|
// "e" + COMBINING ACUTE ACCENT + "x": the mark joins the 'e' cell, the
|
|
// 'x' lands in the next cell (the mark advanced nothing).
|
|
for c in "e\u{0301}x".chars() {
|
|
g.print(c);
|
|
}
|
|
assert_eq!(g.cell(0, 0).c, 'e');
|
|
assert_eq!(g.cell(0, 0).combining.as_deref(), Some("\u{0301}"));
|
|
assert_eq!(g.cell(1, 0).c, 'x');
|
|
assert_eq!(g.cursor(), (2, 0));
|
|
// Copied text round-trips the full grapheme cluster.
|
|
g.start_selection(0, 0);
|
|
g.extend_selection(0, 1);
|
|
assert_eq!(g.selection_text().as_deref(), Some("e\u{0301}x"));
|
|
}
|
|
|
|
#[test]
|
|
fn detects_urls_trimming_trailing_punctuation() {
|
|
let mut g = Grid::new(60, 2);
|
|
for c in "see https://example.com/p?q=1, ok".chars() {
|
|
g.print(c);
|
|
}
|
|
let hits = g.visible_urls();
|
|
assert_eq!(hits.len(), 1);
|
|
assert_eq!(hits[0].url, "https://example.com/p?q=1");
|
|
assert_eq!((hits[0].row, hits[0].col), (0, 4));
|
|
}
|
|
|
|
#[test]
|
|
fn detects_url_across_a_soft_wrap() {
|
|
// 20 cols: the URL wraps, but autowrap sets the wrapped flag so it rejoins.
|
|
let mut g = Grid::new(20, 3);
|
|
for c in "x https://example.com/averylongpath".chars() {
|
|
g.print(c);
|
|
}
|
|
let hits = g.visible_urls();
|
|
assert_eq!(hits.len(), 1);
|
|
assert_eq!(hits[0].url, "https://example.com/averylongpath");
|
|
}
|
|
|
|
#[test]
|
|
fn prompt_mark_survives_reflow() {
|
|
let mut g = Grid::new(10, 4);
|
|
g.set_prompt_mark(PromptKind::OutputStart); // marks line 0
|
|
for c in "hello".chars() {
|
|
g.print(c);
|
|
}
|
|
g.resize(6, 4); // rewrap to a narrower width
|
|
// The mark followed its logical line, so the output is still found.
|
|
assert_eq!(g.last_command_output().as_deref(), Some("hello\n"));
|
|
}
|
|
|
|
#[test]
|
|
fn carriage_return_and_line_feed() {
|
|
let mut g = Grid::new(8, 4);
|
|
for c in "hi".chars() {
|
|
g.print(c);
|
|
}
|
|
g.carriage_return();
|
|
g.line_feed();
|
|
for c in "yo".chars() {
|
|
g.print(c);
|
|
}
|
|
assert_eq!(g.row_text(0), "hi");
|
|
assert_eq!(g.row_text(1), "yo");
|
|
}
|
|
|
|
#[test]
|
|
fn line_feed_scrolls_at_bottom() {
|
|
let mut g = Grid::new(4, 2);
|
|
g.print('a');
|
|
g.next_line();
|
|
g.print('b');
|
|
g.next_line(); // scrolls: row0 <- "b", row1 blank
|
|
assert_eq!(g.row_text(0), "b");
|
|
assert_eq!(g.row_text(1), "");
|
|
}
|
|
|
|
#[test]
|
|
fn erase_line_to_right() {
|
|
let mut g = Grid::new(6, 1);
|
|
for c in "abcdef".chars() {
|
|
g.print(c);
|
|
}
|
|
g.move_to(2, 0);
|
|
g.erase_line(0);
|
|
assert_eq!(g.row_text(0), "ab");
|
|
}
|
|
|
|
#[test]
|
|
fn delete_and_insert_chars() {
|
|
let mut g = Grid::new(6, 1);
|
|
for c in "abcdef".chars() {
|
|
g.print(c);
|
|
}
|
|
g.move_to(1, 0);
|
|
g.delete_chars(2);
|
|
assert_eq!(g.row_text(0), "adef");
|
|
g.move_to(1, 0);
|
|
g.insert_chars(2);
|
|
assert_eq!(g.row_text(0), "a def");
|
|
}
|
|
|
|
#[test]
|
|
fn scrollback_captures_scrolled_lines() {
|
|
let mut g = Grid::new(8, 2);
|
|
for c in ['1', '2', '3'] {
|
|
g.print(c);
|
|
g.next_line();
|
|
}
|
|
// Live screen: newest line on top, cleared line below.
|
|
assert_eq!(g.view_row(0)[0].c, '3');
|
|
assert!(g.view_at_bottom());
|
|
// Scroll back to reveal the two captured lines.
|
|
g.scroll_view(2);
|
|
assert!(!g.view_at_bottom());
|
|
assert_eq!(g.view_row(0)[0].c, '1');
|
|
assert_eq!(g.view_row(1)[0].c, '2');
|
|
g.scroll_to_bottom();
|
|
assert_eq!(g.view_row(0)[0].c, '3');
|
|
}
|
|
|
|
#[test]
|
|
fn wide_char_occupies_two_columns() {
|
|
let mut g = Grid::new(6, 1);
|
|
g.print('世');
|
|
g.print('x');
|
|
assert_eq!(g.cell(0, 0).c, '世');
|
|
assert!(g.cell(1, 0).flags.contains(Flags::WIDE_CONT));
|
|
assert_eq!(g.cell(2, 0).c, 'x');
|
|
}
|
|
|
|
#[test]
|
|
fn selection_extracts_text_across_rows() {
|
|
let mut g = Grid::new(8, 2);
|
|
for c in "abcd".chars() {
|
|
g.print(c);
|
|
}
|
|
g.carriage_return();
|
|
g.line_feed();
|
|
for c in "efgh".chars() {
|
|
g.print(c);
|
|
}
|
|
// Select "cd" on row 0 through "ef" on row 1 (rows are live: abs 0,1).
|
|
g.start_selection(0, 2);
|
|
g.extend_selection(1, 1);
|
|
assert!(g.is_selected(0, 3));
|
|
assert!(g.is_selected(1, 0));
|
|
assert!(!g.is_selected(1, 2));
|
|
assert_eq!(g.selection_text().as_deref(), Some("cd\nef"));
|
|
}
|
|
|
|
#[test]
|
|
fn select_word_spans_one_word() {
|
|
let mut g = Grid::new(16, 1);
|
|
for c in "foo bar baz".chars() {
|
|
g.print(c);
|
|
}
|
|
g.select_word(0, 5); // inside "bar"
|
|
assert_eq!(g.selection_text().as_deref(), Some("bar"));
|
|
}
|
|
|
|
#[test]
|
|
fn select_word_breaks_on_delimiters_but_keeps_paths() {
|
|
let mut g = Grid::new(32, 1);
|
|
for c in "run /usr/bin:next".chars() {
|
|
g.print(c);
|
|
}
|
|
// '/' and ':' are not delimiters, so the path selects whole.
|
|
g.select_word(0, 7); // inside "/usr/bin:next"
|
|
assert_eq!(g.selection_text().as_deref(), Some("/usr/bin:next"));
|
|
// '(' is a delimiter.
|
|
let mut g = Grid::new(16, 1);
|
|
for c in "f(arg)".chars() {
|
|
g.print(c);
|
|
}
|
|
g.select_word(0, 2); // inside "arg"
|
|
assert_eq!(g.selection_text().as_deref(), Some("arg"));
|
|
}
|
|
|
|
#[test]
|
|
fn block_selection_is_rectangular() {
|
|
let mut g = Grid::new(8, 3);
|
|
for line in ["abcd", "efgh", "ijkl"] {
|
|
for c in line.chars() {
|
|
g.print(c);
|
|
}
|
|
g.carriage_return();
|
|
g.line_feed();
|
|
}
|
|
// A block from (row0,col1) to (row2,col2) takes columns 1..=2 each row.
|
|
g.start_block_selection(0, 1);
|
|
g.extend_selection(2, 2);
|
|
assert!(g.is_selected(0, 1));
|
|
assert!(g.is_selected(2, 2));
|
|
assert!(!g.is_selected(1, 3));
|
|
assert!(!g.is_selected(1, 0));
|
|
assert_eq!(g.selection_text().as_deref(), Some("bc\nfg\njk"));
|
|
}
|
|
|
|
#[test]
|
|
fn search_finds_matches_across_history() {
|
|
let mut g = Grid::new(16, 2);
|
|
for line in ["alpha", "beta", "ALPHA", "gamma"] {
|
|
for c in line.chars() {
|
|
g.print(c);
|
|
}
|
|
g.carriage_return();
|
|
g.line_feed();
|
|
}
|
|
// Case-insensitive (no uppercase in query) matches both alphas.
|
|
g.set_search("alpha");
|
|
assert_eq!(g.search_count(), (2, 2)); // focus starts on the latest hit
|
|
let spans0 = g.search_spans_on(0);
|
|
assert_eq!(spans0, vec![(0, 4, false)]);
|
|
// Smart case: an uppercase letter restricts to the exact-case hit.
|
|
g.set_search("ALPHA");
|
|
assert_eq!(g.search_count(), (1, 1));
|
|
assert_eq!(g.search_spans_on(2), vec![(0, 4, true)]);
|
|
// Stepping wraps and the no-match query reports zero.
|
|
g.set_search("zzz");
|
|
assert_eq!(g.search_count(), (0, 0));
|
|
assert_eq!(g.search_query(), Some("zzz"));
|
|
g.clear_search();
|
|
assert_eq!(g.search_query(), None);
|
|
}
|
|
|
|
#[test]
|
|
fn reflow_rewraps_a_wrapped_paragraph() {
|
|
let mut g = Grid::new(4, 3);
|
|
for c in "abcdefgh".chars() {
|
|
g.print(c); // wraps: "abcd" | "efgh" | (cursor parked)
|
|
}
|
|
// Widen: the two wrapped rows rejoin into one logical line.
|
|
g.resize(8, 3);
|
|
assert_eq!(g.row_text(0), "abcdefgh");
|
|
assert_eq!(g.cursor(), (7, 0)); // cursor follows the content end
|
|
// Narrow back: it rewraps to the smaller width without losing text.
|
|
g.resize(4, 3);
|
|
assert_eq!(g.row_text(0), "abcd");
|
|
assert_eq!(g.row_text(1), "efgh");
|
|
}
|
|
|
|
#[test]
|
|
fn reflow_preserves_hard_breaks() {
|
|
let mut g = Grid::new(10, 3);
|
|
for c in "one".chars() {
|
|
g.print(c);
|
|
}
|
|
g.carriage_return();
|
|
g.line_feed();
|
|
for c in "two".chars() {
|
|
g.print(c);
|
|
}
|
|
// A hard newline must not be rejoined when the width changes.
|
|
g.resize(6, 3);
|
|
assert_eq!(g.row_text(0), "one");
|
|
assert_eq!(g.row_text(1), "two");
|
|
}
|
|
|
|
#[test]
|
|
fn scroll_region_limits_line_feed() {
|
|
let mut g = Grid::new(4, 4);
|
|
g.set_scroll_region(1, 2);
|
|
g.move_to(0, 2); // bottom of region (origin off: absolute row 2)
|
|
g.print('x');
|
|
g.move_to(0, 2);
|
|
g.line_feed(); // at region bottom: scroll [1,2] up, x moves to row 1
|
|
assert_eq!(g.row_text(1), "x");
|
|
assert_eq!(g.row_text(2), "");
|
|
}
|
|
}
|