//! Terminal grid + cursor + alt-screen for shop. //! //! Alt-screen support is here because vim without it makes a mess of //! scrollback on every `:q`. //! //! Implements [`vte::Perform`], so the binary can pipe PTY bytes through a //! `vte::Parser` straight into the grid. mod cell; mod edit; mod history; mod mouse; pub mod oracle; mod perform; mod ring; mod selection; mod sgr; mod text; #[cfg(test)] mod testutil; pub use cell::{Attrs, Cell, Color}; pub use mouse::{MouseAction, MouseButton, MouseEncoding, MouseMods, MouseReport, MouseTracking}; pub use selection::{Point, Selection, SelectionMode, SelectionSpan}; use cell::{MAX_MARKS, MarkTable, char_cols, encode_attrs, encode_color}; use std::collections::VecDeque; /// Shape hint from DECSCUSR (`CSI Ps SP q`). Blink flag is ignored — MVP /// renders all as steady. #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub enum CursorShape { Block, Underline, Bar, } /// State delta since the last [`Grid::take_damage`] — the renderer's cue for /// which per-row instance caches to rotate, rebuild, or invalidate. #[derive(Debug, Clone, Default)] pub struct Damage { /// Rows the full screen scrolled up (positive) or down (negative). Only /// nonzero when the scroll region covered the entire screen — the /// renderer can rotate its per-row cache by this amount and skip /// re-emitting the shifted rows. Partial-region scrolls mark all affected /// rows dirty instead. pub scroll: i16, /// Rows whose cell contents changed. Includes the blank rows exposed by /// a scroll (i.e. after scroll-up-by-N the bottom N rows are dirty). pub dirty_rows: Vec, /// Alt-screen state toggled. The renderer should throw away its whole /// per-row cache and rebuild from `dirty_rows`. pub screen_swapped: bool, /// Grid was resized. Same effect as `screen_swapped` on the renderer. pub resized: bool, /// The viewport moved into or within scrollback, or content arrived under /// it. Same effect as `screen_swapped` on the renderer: the cache describes /// rows that are no longer the rows on screen. `dirty_rows` carries the /// whole screen when this is set, and `scroll` is zero — a cache rotation /// would be wrong, since the visible rows did not shift by a knowable /// amount. pub view_moved: bool, } /// What the terminal answers about itself. /// /// Every field here is something the grid cannot work out and a program can /// ask for: the renderer owns cell geometry, the theme owns the default /// colours, and the binary owns its own name. The grid holds them only so the /// query arms have something true to say, and the binary keeps them current /// through [`Grid::set_identity`]. /// /// The defaults are shop's own, so a grid nobody configured still answers /// plausibly rather than answering zero. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Identity { /// Reported by XTVERSION, as `name(version)`. pub name: String, pub version: String, /// One cell in physical pixels, width then height. Follows the output /// scale, so it changes when the window moves between displays. pub cell_px: (u16, u16), /// Default foreground and background, sRGB, as OSC 10 and 11 report them. pub fg: [u8; 3], pub bg: [u8; 3], } impl Default for Identity { /// A placeholder, and only ever that. /// /// `shop` overwrites all of it at startup and again on every resize, so /// nothing a user sees comes from here. `cell_px` is deliberately not the /// cell of any face, so a reply carrying it is obviously unconfigured /// rather than plausibly stale. The real one is measured off the bundled /// face by `shop_render::CellMetrics`. fn default() -> Self { Self { name: "shop".into(), version: "0".into(), cell_px: (0, 0), fg: [0xe6, 0xde, 0xd3], bg: [0x25, 0x23, 0x1f], } } } /// Cursor state (position + deferred-wrap flag). #[derive(Copy, Clone, Debug, Default)] pub struct Cursor { pub row: u16, pub col: u16, pub visible: bool, /// Deferred wrap: after writing to the rightmost column, the next /// printable char wraps to the next line. This mirrors DEC/xterm /// behavior and matters for vim's line drawing. pub wrap_next: bool, } /// One row that has scrolled off the top of the main screen. /// /// Always exactly the grid's current width: `resize` rewraps the whole buffer /// to the new one — see [`Grid::history`]. #[derive(Clone, Debug)] struct HistoryRow { cells: Vec, /// Whether it ran off the right edge and continued on the row below, so a /// copy spanning the two joins them without a newline. /// /// Also the wrap-point record [`Grid::rewrap_history`] reads: a maximal run /// of these plus the row that ends it is one logical line. wrapped: bool, } /// Rows of scrollback a grid keeps unless told otherwise. /// /// Ten thousand is the common default and costs `lines * cols * 12` bytes — /// about 24 MB at 200 columns. [`Grid::set_history_limit`] is what a config /// key drives. pub const DEFAULT_HISTORY_LIMIT: usize = 10_000; pub struct Grid { cols: u16, rows: u16, // Row-major cells with a ring-buffer row layout: logical row `r` lives at // physical row `(origin + r) mod rows`. Fullscreen scrolls advance the // origin instead of memcpy'ing rows — a 2.4 GB/s cost on the vtebench // scrolling workload with the naive layout, near-free with the ring. // Partial-region scrolls fall back to memcpy through ring-mapped indices. main: Vec, alt: Vec, // One flag per PHYSICAL row: "this row ran off the right edge and // continues on the next one". Physical indexing is what makes the ring // scroll carry the flags for free — an origin bump moves rows and their // wrap state together, with no second pass. // // Only the deferred wrap in `place_char` sets a flag. A row that happens // to fill exactly and is then ended with CR/LF is not wrapped, which is // the distinction that decides whether copied text gets a newline here. main_wrapped: Vec, alt_wrapped: Vec, /// Rows that have scrolled off the top of the main screen, oldest first. /// /// Main only: the alt screen is a fixed canvas an application repaints, so /// a row leaving its top is overdraw rather than history, and every /// terminal that keeps scrollback keeps none for it. /// /// Every row here is exactly `cols` wide. `resize` rewraps the whole buffer /// to the new width and re-materializes it at that width, so the invariant /// every reader depends on — `row()` yields `cols` cells — holds for history /// rows as much as for live ones. history: VecDeque, /// How many rows `history` keeps before dropping its oldest. history_limit: usize, /// The combining marks that cells refer to by id. /// /// Grid-wide rather than per-screen, so a cell can be copied between the /// live screen, the alt screen and history without its marks needing to /// travel or be rewritten. That is the point of storing an id: a cell stays /// a value, and every structural move of cells in this file stays a memcpy. marks: MarkTable, /// How far back the viewport sits, in rows. Zero is live. Never exceeds /// `history.len()`, and forced to zero on the alt screen. view_offset: u16, /// The viewport moved, or moved under content, since the last damage /// drain. Every cached row is suspect, so the renderer rebuilds. view_dirty: bool, on_alt: bool, main_origin: u16, alt_origin: u16, /// Rotation within the current scroll region. Independent from /// main/alt_origin. Non-zero only when the active screen is in a /// non-fullscreen region (DECSTBM); enforced by unroll on transition. region_origin: u16, cursor: Cursor, cursor_shape: CursorShape, /// Where each screen's cursor was when the other took over, indexed by /// `on_alt`. The alt-screen swap's own bookkeeping, and nothing else's. swap_saved: [Cursor; 2], /// DECSC's slot, one per screen, indexed by `on_alt`. /// /// Separate from `swap_saved` because they answer to different owners and /// sharing one slot loses saves. They shared one until this was written: /// `ESC 7` on the alt screen wrote the slot that leaving alt restored /// from, so a full-screen program that saved its cursor moved the shell's /// on the way out. Per-screen because that is what xterm does, and an /// `ESC 7` in vim has nothing to say about where the shell was. dec_saved: [Cursor; 2], scroll_top: u16, // 0-indexed, inclusive scroll_bottom: u16, // 0-indexed, inclusive pending_fg: Color, pending_bg: Color, pending_attrs: Attrs, // Pre-encoded pending style, ready to store into a Cell's words. Kept in // sync with pending_{fg,bg,attrs} by [`Grid::recompute_style_words`], // called from apply_sgr. Lets the hot `place_char` skip encode_color + // encode_attrs on every glyph. pending_fg_word: u32, pending_bg_word: u32, // Cached byte offset of the current cursor row's start in the active // buffer, or `u32::MAX` when invalid. Fast `place_char` populates it on // first use of a print run; any non-print Perform entry point (execute / // csi / esc / osc), newline, screen-swap, DECSTBM, or resize invalidates. cur_row_start: u32, // DECSET 2026: true between `\e[?2026h` and `\e[?2026l`. Purely // reflected via [`Grid::sync_update`] — the binary decides whether/how // long to defer redraws (typical timeout is ~150ms). sync_update: bool, // DECSET 2004: the program has asked to be told that text arrived by // paste rather than by typing, so it can decline to act on it. Reflected // via [`Grid::bracketed_paste`]; wrapping the payload is the binary's job. bracketed_paste: bool, // DECCKM (`CSI ? 1 h`) and DECKPAM (`ESC =`). Both change what the // KEYBOARD sends, not what the screen shows, so the grid only records // them — shop-xkb is what reads them. cursor_keys_application: bool, keypad_application: bool, // DECSET 1007: alternate scroll. On the alt screen there is no history to // move through, so the wheel is translated into cursor keys instead — // which is what makes `less` and `man` scroll. On by default, and a // program that wants the wheel to mean something else clears it. Same // division as 2004: the grid tracks the mode, the binary acts on it. alternate_scroll: bool, // DECSET 9/1000/1002/1003 and 1006: how much of the mouse the program // wants, and in which encoding it wants it. Same division again — the grid // records what was asked for and the binary, which is the only half that // sees a pointer, does the sending. mouse_tracking: MouseTracking, mouse_encoding: MouseEncoding, pending_title: Option, identity: Identity, // Bytes the terminal owes the program, from queries it answered. The grid // has no handle on the PTY, so it queues and the binary drains after every // parse, the way it already does for the title. // // A query with no answer is not a no-op: the asking program waits out its // timeout first. yazi gives DA1 three seconds before deciding the terminal // cannot draw, so silence here costs three seconds on every launch of it. pending_replies: Vec, // Damage tracking — accumulated between take_damage() calls. row_dirty: Vec, pending_scroll: i16, pending_screen_swap: bool, pending_resize: bool, } const CUR_ROW_INVALID: u32 = u32::MAX; impl Grid { pub fn new(cols: u16, rows: u16) -> Self { let cols = cols.max(1); let rows = rows.max(1); let cell_count = cols as usize * rows as usize; Self { cols, rows, main: vec![Cell::default(); cell_count], alt: vec![Cell::default(); cell_count], main_wrapped: vec![false; rows as usize], alt_wrapped: vec![false; rows as usize], history: VecDeque::new(), history_limit: DEFAULT_HISTORY_LIMIT, marks: MarkTable::default(), view_offset: 0, view_dirty: false, on_alt: false, main_origin: 0, alt_origin: 0, region_origin: 0, cursor: Cursor { visible: true, ..Cursor::default() }, cursor_shape: CursorShape::Block, swap_saved: [Cursor::default(); 2], dec_saved: [Cursor::default(); 2], scroll_top: 0, scroll_bottom: rows - 1, pending_fg: Color::Default, pending_bg: Color::Default, pending_attrs: Attrs::default(), pending_fg_word: 0, pending_bg_word: 0, cur_row_start: CUR_ROW_INVALID, sync_update: false, bracketed_paste: false, cursor_keys_application: false, keypad_application: false, alternate_scroll: true, mouse_tracking: MouseTracking::Off, mouse_encoding: MouseEncoding::X10, pending_title: None, identity: Identity::default(), pending_replies: Vec::new(), // Initial state: everything dirty so first render populates the // per-row cache. row_dirty: vec![true; rows as usize], pending_scroll: 0, pending_screen_swap: false, pending_resize: true, } } /// Drain accumulated changes since the last call. The renderer applies /// them (rotate cache, rebuild dirty rows, wipe on swap/resize) before /// emitting the frame. pub fn take_damage(&mut self) -> Damage { let scroll = std::mem::take(&mut self.pending_scroll); let screen_swapped = std::mem::take(&mut self.pending_screen_swap); let resized = std::mem::take(&mut self.pending_resize); let view_dirty = std::mem::take(&mut self.view_dirty); let mut dirty_rows: Vec = Vec::new(); for (i, d) in self.row_dirty.iter_mut().enumerate() { if *d { dirty_rows.push(i as u16); *d = false; } } // A viewport back in history breaks every assumption the incremental // path makes: `scroll` describes the live screen moving, and a dirty // logical row is not the visible row it would be at offset zero. So // once the user is reading history, any change at all rebuilds the // screen. That is affordable precisely because it is not the hot path // — the throughput case is a viewport pinned to the bottom. let stale = view_dirty || (self.view_offset > 0 && (scroll != 0 || !dirty_rows.is_empty() || screen_swapped)); if stale && !resized { return Damage { scroll: 0, dirty_rows: (0..self.rows).collect(), screen_swapped, resized, view_moved: true, }; } Damage { scroll, dirty_rows, screen_swapped, resized, view_moved: false, } } fn mark_row_dirty(&mut self, row: u16) { if let Some(slot) = self.row_dirty.get_mut(row as usize) { *slot = true; } } fn mark_all_rows_dirty(&mut self) { for d in &mut self.row_dirty { *d = true; } } /// Report every row as damaged, for a renderer that lost its own cache. /// /// Damage is a diff, and a diff only works while both ends agree on what /// the other has. The renderer keeps a per-row glyph cache and rebuilds /// only the rows damage names, so anything that throws that cache away — /// rebuilding the renderer on a scale change is the live case — leaves the /// two ends disagreeing: the grid has nothing new to report, the renderer /// has nothing at all, and the window stays empty until the program on the /// pty happens to print. A shell prints on the next keystroke and hides /// this; a command that writes once and waits does not. /// /// So this is not a redraw request but the renderer saying it forgot, and /// the grid answering with the whole screen. pub fn invalidate_render(&mut self) { self.mark_all_rows_dirty(); self.view_dirty = true; } pub fn cursor_shape(&self) -> CursorShape { self.cursor_shape } /// True while a DECSET 2026 synchronized-update batch is open. The /// renderer should hold off on presenting a frame until this clears /// (matching `\e[?2026l`) or its own safety timeout expires. pub fn sync_update(&self) -> bool { self.sync_update } /// True while the program has DECSET 2004 on and wants pasted text /// wrapped in `\e[200~` / `\e[201~`. /// /// A shell that knows the difference will not run a pasted command until /// the user presses Enter, which is the whole point of the mode: pasting /// something with a newline in it stops being an accidental execution. pub fn bracketed_paste(&self) -> bool { self.bracketed_paste } /// DECCKM: cursor keys send `SS3 A` rather than `CSI A`. /// /// vim and readline both set it, and it is the difference between an /// arrow key moving the cursor and typing `[A` into the buffer. pub fn cursor_keys_application(&self) -> bool { self.cursor_keys_application } /// DECKPAM: the keypad sends function sequences rather than digits. pub fn keypad_application(&self) -> bool { self.keypad_application } /// DECSET 1007: the wheel may be translated into cursor keys on the alt /// screen. On unless a program clears it, and only consulted there — off /// the alt screen the wheel has real history to move through. pub fn alternate_scroll(&self) -> bool { self.alternate_scroll } /// Whether the alt screen is the active one. /// /// The alt screen keeps no history, which is why anything deciding what a /// scroll means has to ask. pub fn on_alt(&self) -> bool { self.on_alt } /// Consume any window title set by the shell via OSC 0/2 since the last /// call. Binary polls after each `parser.advance` and forwards to /// `xdg_window.set_title`. pub fn take_pending_title(&mut self) -> Option { self.pending_title.take() } /// Consume any bytes owed to the program in answer to its queries. Binary /// polls after each `parser.advance` and writes them to the PTY. /// /// Empty on almost every call: programs ask once, at startup. pub fn take_pending_replies(&mut self) -> Vec { std::mem::take(&mut self.pending_replies) } fn reply(&mut self, bytes: &[u8]) { self.pending_replies.extend_from_slice(bytes); } /// Tell the grid what to say about itself. Call at startup and whenever /// any of it moves: cell size follows the output scale, and the colours /// follow the theme. pub fn set_identity(&mut self, identity: Identity) { self.identity = identity; } pub fn cols(&self) -> u16 { self.cols } pub fn rows(&self) -> u16 { self.rows } pub fn cursor(&self) -> Cursor { self.cursor } /// Where the cursor sits on screen, or `None` when the viewport is far /// enough back that it has scrolled off the bottom. /// /// The cursor's own row is a live row. Drawing it at that row while the /// user reads history would put a blinking block on unrelated text. pub fn cursor_view_row(&self) -> Option { let r = self.cursor.row.checked_add(self.view_offset)?; (r < self.rows).then_some(r) } /// The cells of visible row `r`, always exactly `cols` of them. /// /// While the viewport sits back in history the top `view_offset` rows come /// from [`Grid::history`] and the rest from the live screen, so every /// reader — the renderer, selection, word boundaries — sees one flat /// screen and needs to know nothing about where it came from. pub fn row(&self, r: u16) -> &[Cell] { if let Some(h) = self.history_row(r) { return &h.cells; } let start = self.row_start(self.live_row(r)); let end = start + self.cols as usize; &self.active_cells()[start..end] } /// Append a cell's text — base character then any combining marks — to /// `out`. /// /// The seam between "what a cell looks like" and "what a cell is". The /// renderer and word boundaries want the base and use [`Cell::c`]; anything /// producing text a human or another program will read wants the cluster /// and comes here, because an accent dropped on the way to the clipboard is /// a path that no longer names the file it came from. pub(crate) fn push_cell_text(&self, cell: &Cell, out: &mut String) { out.push(match cell.c() { '\0' => ' ', c => c, }); out.extend(self.marks.get(cell.marks_id())); } /// A cell's combining marks, in the order they arrived, empty for almost /// every cell. /// /// The renderer needs these to draw a cluster; nothing else outside this /// crate does, because [`Cell::c`] already answers what the cell is and how /// wide it is. pub fn marks(&self, cell: &Cell) -> &[char] { self.marks.get(cell.marks_id()) } /// The column a pointer at `col` was aiming at. /// /// Half a wide character is not a thing anyone can mean to click on, so a /// click on the second column of one reads as a click on the character. pub fn snap_col(&self, row: u16, col: u16) -> u16 { let cells = self.row(row); match cells.get(col as usize) { Some(c) if c.is_spacer() && col > 0 => col - 1, _ => col, } } /// How many columns the cursor covers, so a block cursor over a wide /// character is drawn the width of the character rather than half of it. pub fn cursor_cols(&self) -> u16 { self.cursor_view_row() .and_then(|r| self.row(r).get(self.cursor.col as usize).map(Cell::cols)) .unwrap_or(1) } #[inline] fn invalidate_cur_row(&mut self) { self.cur_row_start = CUR_ROW_INVALID; } #[inline] fn recompute_style_words(&mut self) { self.pending_fg_word = encode_color(self.pending_fg) | encode_attrs(self.pending_attrs); self.pending_bg_word = encode_color(self.pending_bg); } fn place_char(&mut self, c: char) { // A wide character needs two columns, so on a one-column grid there is // no such thing and the pair never forms. let width = if self.cols < 2 { 1 } else { char_cols(c) }; if width == 0 { // A combining mark is not placed anywhere. It amends the character // already on screen and leaves the cursor where it was, which is // what the application counting its own columns did too. self.amend_with_mark(c); return; } // Deferred wrap: if the previous print landed on the rightmost cell, // the next visible char starts a new line. if self.cursor.wrap_next { // Record the continuation before newline() moves us off the row // (or scrolls the ring out from under it). self.set_row_wrapped(self.cursor.row, true); self.newline(); self.cursor.col = 0; self.cursor.wrap_next = false; } else if width == 2 && self.cursor.col + 1 >= self.cols { // A wide character with one column left does not fit, and is not // split to make it fit: the application counted two columns and has // already moved to the next line. The column it could not use is // left as a spacer, so it holds the width it is owed on screen and // is skipped by anything reading the buffer back as text. let pad = self.cursor.col; self.set_row_wrapped(self.cursor.row, true); self.write_pad(pad); self.newline(); self.cursor.col = 0; } // Cache the row start on first print of a run; non-print Perform // entry points and newline invalidate it back to CUR_ROW_INVALID. let start = if self.cur_row_start == CUR_ROW_INVALID { let s = self.row_start(self.cursor.row) as u32; self.cur_row_start = s; s as usize } else { self.cur_row_start as usize }; let col = self.cursor.col as usize; let row_idx = self.cursor.row as usize; // Landing on either half of a wide pair destroys it, and the other half // has to go with it or it is left claiming a width it no longer has. // Off the hot path: the test is one mask against a cell already in // cache, and it fails for every character in ordinary output. self.heal_pair(start, col, width as usize); let cells: &mut [Cell] = if self.on_alt { &mut self.alt } else { &mut self.main }; if width == 2 { // Two stores, bounds-checked. `col + 1 < cols` holds because the // no-room case above moved to the next row, and a wide character is // rare enough that the hot path below is the one worth the unsafe. let (lead, spacer) = Cell::wide_pair(c, self.pending_fg_word, self.pending_bg_word); cells[start + col] = lead; cells[start + col + 1] = spacer; self.row_dirty[row_idx] = true; } else { // Bounds are enforced structurally: start is a valid row start (a // multiple of cols within cells.len()), col < cols, row < rows. debug_assert!(start + col < cells.len()); debug_assert!(row_idx < self.row_dirty.len()); // SAFETY: the debug_asserts above encode the invariants — start is // computed from row_start() (multiple of cols, < cells.len()), col is // clamped to cols on entry, and row_dirty has one entry per row. // Bounds-check elimination matters here: this is the innermost store // for every printed glyph, called at PTY-drain rate on cell-dense // workloads (millions/sec on `cat` of a wide buffer). #[allow(unsafe_code)] unsafe { let cell = cells.get_unchecked_mut(start + col); cell.c_raw = c as u32; cell.fg_word = self.pending_fg_word; // Clears both width flags along with the colour, so a cell // taken over by a narrow character stops being half a pair. cell.bg_word = self.pending_bg_word; *self.row_dirty.get_unchecked_mut(row_idx) = true; } } // The cursor ends on the last column the character covered, so the // deferred wrap fires off the same test whatever the width was. let last = self.cursor.col + width - 1; if last + 1 >= self.cols { self.cursor.col = last; self.cursor.wrap_next = true; } else { self.cursor.col = last + 1; } } /// Blank the far half of any wide pair that a write of `width` columns at /// `col` lands on, so no cell is left as half of a character. fn heal_pair(&mut self, start: usize, col: usize, width: usize) { let cols = self.cols as usize; let cells = self.active_cells_mut(); // The pair the write starts inside: either this cell is a spacer whose // lead sits behind it, or it is a lead whose spacer the write does not // reach. // // The `is_wide` test on the cell behind is what makes the first case // safe. Not every spacer has a lead: `write_pad` leaves one at the // right edge for a wide character that did not fit, and without the // test a narrow write over that pad blanks whatever sits two columns // back — a real character, and if it is itself a wide lead the row is // left holding exactly the half pair this function exists to prevent. // Found by the soak oracle, 2026-08-29. if cells[start + col].is_spacer() && col > 0 && cells[start + col - 1].is_wide() { cells[start + col - 1] = Cell::default(); } else if width == 1 && cells[start + col].is_wide() && col + 1 < cols { cells[start + col + 1] = Cell::default(); } // A two-column write also covers the cell after it, which may be the // lead of the next pair along. if width == 2 && col + 1 < cols && cells[start + col + 1].is_wide() && col + 2 < cols { cells[start + col + 2] = Cell::default(); } } /// Attach a combining mark to the character the cursor last passed over. /// /// The base is found from the cursor rather than remembered, so nothing has /// to survive a scroll between the base arriving and its mark: it is the /// cell the cursor is about to move past, stepped back once more when that /// lands on a wide character's second column. fn amend_with_mark(&mut self, mark: char) { // With the deferred wrap pending, the cursor is still ON the last // character it wrote rather than after it. let Some(col) = (if self.cursor.wrap_next { Some(self.cursor.col) } else { self.cursor.col.checked_sub(1) }) else { // A mark with nothing before it on this row. Malformed — there is // no base for it to change, and inventing a cell for it would be // the column error this whole thing exists to remove. return; }; let start = self.row_start(self.cursor.row); let cells = self.active_cells(); let col = if cells[start + col as usize].is_spacer() && col > 0 { col - 1 } else { col }; let base = cells[start + col as usize]; let mut seq: Vec = self.marks.get(base.marks_id()).to_vec(); if seq.len() >= MAX_MARKS { return; } seq.push(mark); let Some(id) = self.marks.intern(&seq) else { // The id space is spent. Dropping the mark leaves the cell holding // what it held, which is the only outcome here that is not a lie. return; }; let cells = self.active_cells_mut(); cells[start + col as usize] = base.with_marks(id); self.mark_row_dirty(self.cursor.row); } /// Leave column `col` of the cursor's row as a blank the width of one cell /// that is not a character: the column a wide character could not fit into. fn write_pad(&mut self, col: u16) { let start = self.row_start(self.cursor.row); let cells = self.active_cells_mut(); cells[start + col as usize] = Cell::pad(); self.mark_row_dirty(self.cursor.row); } } #[cfg(test)] mod tests { use crate::testutil::{assert_cursor, feed, history_text, row_str}; use crate::*; /// A pad at the right edge is a spacer with no lead, and a narrow write /// over it must not reach back for one. /// /// The row here ends `..日日`: the last wide character did not fit in /// the final column, so `write_pad` left a spacer there and the character /// went to the next row. Writing a narrow character over that pad must not /// blank the cell two columns back, which is the spacer of a real pair: /// that leaves its lead on screen claiming a width it does not have. #[test] fn a_pad_at_the_right_edge_is_not_half_a_pair() { let mut g = Grid::new(10, 4); // The first wide character takes columns 7 and 8. The second has only // column 9 left, so it goes to the next row and leaves a pad behind. feed(&mut g, "\x1b[1;8H日日".as_bytes()); assert!(g.row(0)[7].is_wide()); assert!(g.row(0)[8].is_spacer()); assert!( g.row(0)[9].is_spacer(), "the column the wide character could not use" ); assert!(!g.row(0)[9].is_wide()); feed(&mut g, b"\x1b[1;10Hx"); assert_eq!(g.row(0)[9].c(), 'x'); assert_eq!( g.row(0)[7].c(), '日', "the pair two columns back was blanked" ); assert!(g.row(0)[7].is_wide()); assert!(g.row(0)[8].is_spacer(), "its spacer went with it"); } // ---- basics -------------------------------------------------------- #[test] fn new_grid_is_all_spaces() { let g = Grid::new(10, 3); for r in 0..3 { assert_eq!(row_str(&g, r), ""); } assert_cursor(&g, 0, 0); } #[test] fn print_advances_cursor() { let mut g = Grid::new(20, 3); feed(&mut g, b"hello"); assert_eq!(row_str(&g, 0), "hello"); assert_cursor(&g, 0, 5); } #[test] fn cr_lf_move_to_next_row_col_zero() { let mut g = Grid::new(20, 3); feed(&mut g, b"one\r\ntwo"); assert_eq!(row_str(&g, 0), "one"); assert_eq!(row_str(&g, 1), "two"); assert_cursor(&g, 1, 3); } #[test] fn backspace_moves_cursor_back() { let mut g = Grid::new(20, 3); feed(&mut g, b"abc\x08"); assert_cursor(&g, 0, 2); // BS is non-destructive — 'c' still there. assert_eq!(row_str(&g, 0), "abc"); } #[test] fn tab_advances_to_next_multiple_of_eight() { let mut g = Grid::new(40, 3); feed(&mut g, b"ab\t"); assert_cursor(&g, 0, 8); feed(&mut g, b"c"); assert_cursor(&g, 0, 9); } // ---- wrap ---------------------------------------------------------- #[test] fn deferred_wrap_on_rightmost_cell() { let mut g = Grid::new(4, 3); feed(&mut g, b"ABCD"); // After 4 chars in a 4-wide grid, cursor is at col 3 with wrap_next. assert_eq!(row_str(&g, 0), "ABCD"); let c = g.cursor(); assert!(c.wrap_next, "should have wrap_next set"); feed(&mut g, b"E"); // Next print wraps to row 1 col 0. assert_eq!(row_str(&g, 1), "E"); } // ---- CSI cursor motion -------------------------------------------- #[test] fn cup_moves_cursor_one_indexed() { let mut g = Grid::new(20, 5); feed(&mut g, b"\x1b[3;5H"); assert_cursor(&g, 2, 4); } #[test] fn cup_defaults_to_top_left() { let mut g = Grid::new(20, 5); feed(&mut g, b"aaa\r\nbbb\r\nccc\x1b[H"); assert_cursor(&g, 0, 0); } #[test] fn cuu_cud_cuf_cub_clamp_at_edges() { let mut g = Grid::new(20, 5); feed(&mut g, b"\x1b[100A"); // way up — should clamp at 0 assert_cursor(&g, 0, 0); feed(&mut g, b"\x1b[100B"); // way down — clamp at rows-1 assert_cursor(&g, 4, 0); feed(&mut g, b"\x1b[100C"); // way right — clamp at cols-1 assert_cursor(&g, 4, 19); feed(&mut g, b"\x1b[100D"); // way left — clamp at 0 assert_cursor(&g, 4, 0); } #[test] fn cha_and_vpa_position_absolutely() { let mut g = Grid::new(20, 5); feed(&mut g, b"\x1b[10G\x1b[3d"); assert_cursor(&g, 2, 9); } // -- character width --------------------------------------------------- // // How many columns a character takes is an agreement with the application, // not a rendering choice: a program lays its own output out by the same // table and computes its cursor moves from it. These pin the grid to that // table, because the failure they guard is not a smudged glyph — it is the // grid and the application disagreeing about which column the cursor is in // and every absolute move after it landing somewhere else. #[test] fn a_wide_character_takes_two_columns() { let mut g = Grid::new(8, 2); feed(&mut g, "日x".as_bytes()); let cells = g.row(0); assert!( cells[0].is_wide(), "the lead does not claim its second column" ); assert!(cells[1].is_spacer(), "the second column is not held"); assert_eq!(cells[2].c(), 'x', "the next character overlapped the pair"); assert_eq!( g.cursor().col, 3, "the cursor is not where the program thinks" ); } #[test] fn a_wide_character_reads_back_as_one_character() { // The spacer holds a column, not a character. Emitting it would put a // space inside every CJK word that reached the clipboard. let mut g = Grid::new(8, 2); feed(&mut g, "日本語".as_bytes()); assert_eq!(g.text_range(0, 1), "日本語\n"); } #[test] fn a_wide_character_that_does_not_fit_moves_to_the_next_row_whole() { // One column left and a two-column character: it goes to the next row // rather than being split, and the line still reads as one line. let mut g = Grid::new(5, 3); feed(&mut g, "abcd日".as_bytes()); assert_eq!(row_str(&g, 0), "abcd", "the odd column was written into"); assert!( g.row(1)[0].is_wide(), "the character did not move down whole" ); assert!(g.row_wrapped(0), "the line stopped continuing"); assert_eq!( g.text_range(0, 2), "abcd日\n", "the column it could not use came out as a space" ); } #[test] fn overwriting_half_a_wide_character_takes_the_other_half_with_it() { // vim redrawing one column of a line it previously drew CJK into. The // orphaned half would keep claiming a width it no longer has. let mut g = Grid::new(6, 2); feed(&mut g, "日本".as_bytes()); feed(&mut g, b"\x1b[1;1Hx"); // onto the first lead assert_eq!(row_str(&g, 0), "x 本", "the orphaned spacer survived"); assert!(!g.row(0)[1].is_spacer()); let mut g = Grid::new(6, 2); feed(&mut g, "日本".as_bytes()); feed(&mut g, b"\x1b[1;2Hx"); // onto the first spacer assert_eq!(row_str(&g, 0), " x本", "the orphaned lead survived"); assert!(!g.row(0)[0].is_wide()); } #[test] fn a_wide_character_written_over_a_pair_clears_the_pair_it_overlaps() { let mut g = Grid::new(6, 2); feed(&mut g, "日本".as_bytes()); // Starting one column in covers the first spacer and the second lead. feed(&mut g, "\x1b[1;2H語".as_bytes()); assert_eq!(row_str(&g, 0), " 語", "a half of the old pair survived"); assert!(!g.row(0)[0].is_wide()); assert!(!g.row(0)[3].is_spacer()); } #[test] fn erasing_through_half_a_wide_character_takes_the_other_half() { let mut g = Grid::new(6, 2); feed(&mut g, "ab日x".as_bytes()); // Erase from column 3 (the spacer) to the end of the line. feed(&mut g, b"\x1b[1;4H\x1b[K"); assert_eq!( row_str(&g, 0), "ab", "the lead was left claiming two columns" ); assert!(!g.row(0)[2].is_wide()); } #[test] fn a_one_column_grid_holds_a_wide_character_as_one_column() { // Degenerate, and it must terminate rather than loop looking for room // that a one-column grid can never have. let mut g = Grid::new(1, 2); feed(&mut g, "日".as_bytes()); assert_eq!(g.row(0)[0].c(), '日'); assert!(!g.row(0)[0].is_wide()); } #[test] fn narrowing_then_widening_gives_wide_characters_back() { // The round-trip property the rewrap already had, over the characters // it now has to keep together. let mut g = Grid::new(10, 2); feed(&mut g, "日本語です\r\nabc\r\nx".as_bytes()); let before = history_text(&g); g.resize(4, 2); g.resize(10, 2); assert_eq!(history_text(&g), before); } #[test] fn a_rewrap_does_not_split_a_wide_character() { // Five columns of CJK re-split at four: the character straddling the // boundary has to move down whole, leaving the odd column as a pad. let mut g = Grid::new(10, 2); feed(&mut g, "日本語です\r\nx\r\ny".as_bytes()); g.resize(5, 2); for r in 0..g.history_len() as u16 { let cells = g.row(r); assert!( !cells[cells.len() - 1].is_wide(), "row {r} ends on half a character" ); assert!(!cells[0].is_spacer(), "row {r} starts on half a character"); } assert!( history_text(&g).starts_with("日本語です"), "the line did not survive the rewrap: {:?}", history_text(&g) ); } #[test] fn a_rewrap_does_not_carry_the_old_widths_pad_columns() { // The column a wide character could not fit into is a fact about the // old width. Carried through, it would wedge a blank into the middle of // the line at every width after this one. let mut g = Grid::new(5, 2); feed(&mut g, "abcd日本\r\nx\r\ny".as_bytes()); g.resize(6, 2); assert!( history_text(&g).starts_with("abcd日本"), "a stale pad column survived: {:?}", history_text(&g) ); } #[test] fn narrowing_the_live_screen_does_not_leave_half_a_character() { // resize_buf clips the live screen rather than reflowing it, and the // clip can land between a lead and its spacer. let mut g = Grid::new(6, 2); feed(&mut g, "ab日".as_bytes()); g.resize(3, 2); assert!(!g.row(0)[2].is_wide(), "a lead survived without its spacer"); } #[test] fn a_wide_character_is_one_word_for_a_double_click() { // The spacer holds a blank, and a blank is a word delimiter, so reading // it literally would end the word on the first CJK character. let g = { let mut g = Grid::new(10, 2); feed(&mut g, "日本語 x".as_bytes()); g }; let sel = Selection::new(SelectionMode::Word, Point::new(0, 0)); assert_eq!(g.selection_text(&sel), "日本語"); } #[test] fn a_selection_stopping_on_half_a_character_still_copies_the_character() { let mut g = Grid::new(10, 2); feed(&mut g, "日本".as_bytes()); // Columns 0..=2 — the second lead's spacer is outside the drag. let mut sel = Selection::new(SelectionMode::Char, Point::new(0, 0)); sel.drag_to(Point::new(0, 2)); assert_eq!(g.selection_text(&sel), "日本"); } // -- combining marks --------------------------------------------------- // // A mark modifies the character before it and occupies no column. The cell // keeps its base inline and refers to the marks by id, so everything that // only wants to know what a cell LOOKS like is unchanged and only the paths // producing text have to reassemble the cluster. #[test] fn a_combining_mark_takes_no_column_of_its_own() { let mut g = Grid::new(8, 2); feed(&mut g, "e\u{301}x".as_bytes()); assert_eq!(g.cursor().col, 2, "the mark consumed a column"); assert_eq!(g.row(0)[0].c(), 'e', "the base is not inline any more"); assert_eq!( g.row(0)[1].c(), 'x', "the mark displaced the next character" ); } #[test] fn a_combining_mark_copies_back_with_its_base() { // The whole point: a path off a Mac-formatted volume has to paste back // as the path it came from, accents and all. let mut g = Grid::new(20, 2); feed(&mut g, "Jose\u{301}/".as_bytes()); assert_eq!(g.text_range(0, 1), "Jose\u{301}/\n"); } #[test] fn several_marks_stack_on_one_base() { let mut g = Grid::new(8, 2); feed(&mut g, "o\u{323}\u{302}".as_bytes()); assert_eq!(g.cursor().col, 1); assert_eq!(g.text_range(0, 1), "o\u{323}\u{302}\n"); } #[test] fn a_mark_after_a_wide_character_lands_on_the_character_not_its_spacer() { let mut g = Grid::new(8, 2); feed(&mut g, "日\u{301}".as_bytes()); assert_eq!(g.row(0)[0].marks_id(), 1, "the mark missed the lead"); assert_eq!(g.row(0)[1].marks_id(), 0, "the spacer took the mark"); assert_eq!(g.text_range(0, 1), "日\u{301}\n"); } #[test] fn a_mark_at_the_right_edge_amends_the_character_still_under_the_cursor() { // The deferred wrap leaves the cursor ON the last character it wrote // rather than after it, so the base is found differently there. let mut g = Grid::new(4, 2); feed(&mut g, "abcd\u{301}".as_bytes()); assert_eq!(g.text_range(0, 1), "abcd\u{301}\n"); assert_eq!(g.cursor().col, 3, "the mark moved the cursor off the edge"); } #[test] fn a_mark_with_nothing_before_it_is_dropped() { // Malformed. Giving it a cell would be exactly the column error this // is here to remove. let mut g = Grid::new(8, 2); feed(&mut g, "\u{301}x".as_bytes()); assert_eq!(g.cursor().col, 1); assert_eq!(g.text_range(0, 1), "x\n"); } #[test] fn marks_survive_a_rewrap() { // The id rides in the cell, so every structural move of cells in this // file carries the marks with no help. This is the assertion that says // so out loud. let mut g = Grid::new(10, 2); feed(&mut g, "abcde\u{301}fghij\r\nx\r\ny".as_bytes()); let before = history_text(&g); assert!(before.contains('\u{301}')); g.resize(4, 2); g.resize(10, 2); assert_eq!(history_text(&g), before); } #[test] fn overwriting_a_base_drops_the_marks_that_were_on_it() { // The marks belonged to the character that was there, not to the cell. let mut g = Grid::new(8, 2); feed(&mut g, "e\u{301}".as_bytes()); feed(&mut g, b"\x1b[1;1Hx"); assert_eq!(g.text_range(0, 1), "x\n"); assert_eq!(g.row(0)[0].marks_id(), 0); } #[test] fn the_same_mark_sequence_is_interned_once() { // What keeps the table in the dozens however much text goes past: it // is the bases that vary, not the sequences attached to them. let mut g = Grid::new(20, 2); feed( &mut g, "a\u{301}e\u{301}i\u{301}o\u{301}u\u{301}".as_bytes(), ); let ids: Vec = (0..5).map(|c| g.row(0)[c].marks_id()).collect(); assert_eq!(ids, vec![1; 5], "one sequence took five ids"); } #[test] fn a_cell_stops_taking_marks_at_the_cap() { // Unbounded input. Past the cap the mark is dropped and the cell keeps // what it had, rather than the cell being rewritten or the id space // being spent on a cluster nobody is reading. let mut g = Grid::new(8, 2); let mut bytes = String::from("e"); for _ in 0..MAX_MARKS + 5 { bytes.push('\u{301}'); } feed(&mut g, bytes.as_bytes()); let text = g.text_range(0, 1); assert_eq!( text.chars().filter(|c| *c == '\u{301}').count(), MAX_MARKS, "the cap did not hold" ); } #[test] fn a_marked_character_is_one_word_for_a_double_click() { // Word boundaries read the base and never learn the table exists. let mut g = Grid::new(20, 2); feed(&mut g, "Jose\u{301} x".as_bytes()); let sel = Selection::new(SelectionMode::Word, Point::new(0, 0)); assert_eq!(g.selection_text(&sel), "Jose\u{301}"); } #[test] fn invalidate_render_reports_every_row_however_quiet_the_grid_is() { // The renderer rebuilt itself and lost its cache. Nothing about the // grid changed, which is exactly why it cannot be left to report the // difference: there isn't one, and the window would stay empty until // the program on the pty happened to print. let mut g = Grid::new(8, 4); feed(&mut g, b"one\r\ntwo"); let _ = g.take_damage(); assert!( g.take_damage().dirty_rows.is_empty(), "a quiet grid still had damage to report" ); g.invalidate_render(); let d = g.take_damage(); assert_eq!(d.dirty_rows.len(), 4, "not every row came back"); assert!(d.view_moved, "the viewport was not invalidated with them"); } }