//! 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>, /// OSC 8 hyperlink: a 1-based index into the grid's link table, or `None`. pub link: Option, } 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, wrapped: bool, /// OSC 133 mark attached to this (logical) line, if any. prompt: Option, } 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, 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, /// Saved primary screen while the alternate screen is active. alt_saved: Option>, /// Lines that have scrolled off the top of the main screen, newest last. scrollback: VecDeque, /// 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, /// 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>, } fn default_tabs(cols: usize) -> Vec { (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) { 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::new(); let mut logical_marks: Vec> = Vec::new(); let mut acc: Vec = Vec::new(); let mut acc_mark: Option = 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 = 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 = 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 { 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| 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 { let total = self.total_lines(); let start = (0..total) .rev() .find(|&r| self.abs_prompt(r) == Some(PromptKind::OutputStart))?; let mut lines: Vec = 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::() .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), ""); } }