From 0c0eb35c45a5b02831f30929f5019944bf8d4cdc Mon Sep 17 00:00:00 2001 From: EDToaster Date: Thu, 22 Oct 2026 01:48:42 -0700 Subject: [PATCH 01/11] term: private Row cells in a Vec, clear only the written prefix Row kept its cells in a SmallVec<[Cell; 16]>: ~330 bytes of inline storage per ring slot that real rows (one cell per column) always spill out of. A plain Vec is 24 bytes. The cells are now private (Row::cells / cells_mut) so the row can track `used`, an upper bound on the written prefix: every cell past it is Cell::BLANK. Row::clear, which runs on every bottom-of-screen linefeed on a row recycled from the far end of the scrollback ring (cold in cache), then blanks only that prefix instead of all cols cells. put / erase / fill track it exactly; cells_mut conservatively marks the whole row. A debug assertion in clear checks the invariant across the whole test suite. Call sites outside the crate change mechanically (.cells -> .cells()). --- crates/toastty-render/benches/render_term.rs | 4 +- crates/toastty-render/src/lib.rs | 6 +- crates/toastty-render/src/text/instance.rs | 12 +- crates/toastty-render/tests/common/mod.rs | 2 +- .../toastty-term/examples/render_to_stdout.rs | 2 +- crates/toastty-term/src/grid.rs | 313 +++++++++++++----- crates/toastty-term/src/link.rs | 14 +- crates/toastty-term/src/reflow.rs | 39 +-- crates/toastty-term/src/term.rs | 244 +++++++------- crates/toastty-term/src/term/placeholder.rs | 8 +- .../src/term/placeholder_tests.rs | 14 +- crates/toastty-term/src/term/rich_term.rs | 14 +- crates/toastty-term/src/term/rich_tests.rs | 34 +- crates/toastty-term/src/term/vt.rs | 16 +- crates/toastty-term/tests/grapheme_copy.rs | 60 ++-- crates/toastty-term/tests/integration.rs | 8 +- crates/toastty-term/tests/tmux_passthrough.rs | 6 +- crates/toastty/tests/end_to_end.rs | 2 +- 18 files changed, 461 insertions(+), 337 deletions(-) diff --git a/crates/toastty-render/benches/render_term.rs b/crates/toastty-render/benches/render_term.rs index 28768655..dc1f88a4 100644 --- a/crates/toastty-render/benches/render_term.rs +++ b/crates/toastty-render/benches/render_term.rs @@ -165,7 +165,7 @@ impl Harness { } let row = term.row(r); let line_text: String = row - .cells + .cells() .iter() .map(|c| if c.ch == '\0' { ' ' } else { c.ch }) .collect(); @@ -291,7 +291,7 @@ impl Harness { } let row = term.row(r); let line_text: String = row - .cells + .cells() .iter() .map(|c| if c.ch == '\0' { ' ' } else { c.ch }) .collect(); diff --git a/crates/toastty-render/src/lib.rs b/crates/toastty-render/src/lib.rs index cf4a4ecf..d6024d8f 100644 --- a/crates/toastty-render/src/lib.rs +++ b/crates/toastty-render/src/lib.rs @@ -230,7 +230,7 @@ fn mark_blinking_cells_dirty(term: &mut Term, row_blink: &[bool]) { cols.clear(); cols.extend( term.view_row(r) - .cells + .cells() .iter() .enumerate() .filter(|(_, c)| c.style.flags.blink()) @@ -2509,7 +2509,7 @@ impl Renderer { // instead of its text glyphs (the cells keep the text // for selection / copy / the evicted fallback). let suppress = rich_suppress.get(r as usize).copied().unwrap_or(false); - for c in &row.cells { + for c in row.cells() { if c.is_continuation { continue; } @@ -2615,7 +2615,7 @@ impl Renderer { #[allow(clippy::needless_range_loop)] for c in 0..cols as usize { let filled = row - .cells + .cells() .get(c) .is_some_and(crate::text::instance::cell_fills_bg); if !filled { diff --git a/crates/toastty-render/src/text/instance.rs b/crates/toastty-render/src/text/instance.rs index 1d1c605a..2edb4707 100644 --- a/crates/toastty-render/src/text/instance.rs +++ b/crates/toastty-render/src/text/instance.rs @@ -246,7 +246,7 @@ pub fn cell_fills_bg(cell: &Cell) -> bool { #[inline] #[must_use] pub fn row_has_blink(row: &Row) -> bool { - row.cells + row.cells() .iter() .any(|c| c.style.flags.blink() && !c.style.flags.hidden()) } @@ -257,7 +257,7 @@ pub fn row_has_blink(row: &Row) -> bool { #[inline] #[must_use] fn row_is_solid(row: &Row, cols: u16) -> bool { - (0..cols as usize).all(|c| row.cells.get(c).is_some_and(cell_fills_bg)) + (0..cols as usize).all(|c| row.cells().get(c).is_some_and(cell_fills_bg)) } /// Slack (px) below which a sub-pixel uncovered strip at the content @@ -1057,7 +1057,7 @@ pub fn build_instances_into_opts( ExtendCondition::SolidLine => row_is_solid(row, cols), }; for c in 0..cols { - let Some(cell) = row.cells.get(c as usize) else { + let Some(cell) = row.cells().get(c as usize) else { continue; }; @@ -1083,7 +1083,7 @@ pub fn build_instances_into_opts( // columns so the continuation half isn't left showing the // previous frame / default bg. let is_wide = row - .cells + .cells() .get(c as usize + 1) .is_some_and(|next| next.is_continuation); let bg_w = if is_wide { 2.0 * cell_w } else { cell_w }; @@ -1332,7 +1332,7 @@ pub fn build_dirty_instances_into_opts( }; for c in dirty_cells { - let Some(cell) = row.cells.get(c as usize) else { + let Some(cell) = row.cells().get(c as usize) else { continue; }; // Continuation cells: the cluster's primary at c-1 already @@ -1352,7 +1352,7 @@ pub fn build_dirty_instances_into_opts( // columns so the continuation half isn't left showing the // previous frame / default bg. let is_wide = row - .cells + .cells() .get(c as usize + 1) .is_some_and(|next| next.is_continuation); let bg_w = if is_wide { 2.0 * cell_w } else { cell_w }; diff --git a/crates/toastty-render/tests/common/mod.rs b/crates/toastty-render/tests/common/mod.rs index c75b1d12..0f1b63de 100644 --- a/crates/toastty-render/tests/common/mod.rs +++ b/crates/toastty-render/tests/common/mod.rs @@ -125,7 +125,7 @@ pub(crate) fn render_term_offscreen(term: &Term, width: u32, height: u32) -> Rgb for r in 0..rows { let row = term.row(r); let line_text: String = row - .cells + .cells() .iter() .map(|c| if c.ch == '\0' { ' ' } else { c.ch }) .collect(); diff --git a/crates/toastty-term/examples/render_to_stdout.rs b/crates/toastty-term/examples/render_to_stdout.rs index 823608fd..5534ac25 100644 --- a/crates/toastty-term/examples/render_to_stdout.rs +++ b/crates/toastty-term/examples/render_to_stdout.rs @@ -72,7 +72,7 @@ fn main() -> Result<(), Box> { let mut out = std::io::stdout().lock(); writeln!(out, "--- toastty-term grid ({rows}x{cols}) ---")?; for r in 0..rows { - let line: String = term.row(r).cells.iter().map(|c| c.ch).collect(); + let line: String = term.row(r).cells().iter().map(|c| c.ch).collect(); // Trim trailing blanks for legibility. writeln!(out, "{}", line.trim_end())?; } diff --git a/crates/toastty-term/src/grid.rs b/crates/toastty-term/src/grid.rs index d330d3ab..64a5f96c 100644 --- a/crates/toastty-term/src/grid.rs +++ b/crates/toastty-term/src/grid.rs @@ -7,7 +7,6 @@ use crate::cell::{Cell, Style}; use crate::rich::{RichBlockId, RichRow}; -use smallvec::SmallVec; /// A single row in the grid. `soft_wrap` indicates the row continues onto /// the next one (load-bearing for reflow — decision #6). @@ -17,9 +16,23 @@ use smallvec::SmallVec; /// erasing the row or writing a cell into it ([`Row::clear`], /// [`Row::erase`], [`Row::put`]) clears it, so an app drawing over a /// block wins. +/// +/// The cells are private so the row can keep `used`: every cell at or +/// past `used` is exactly [`Cell::BLANK`]. That lets [`Row::clear`] (run +/// on every linefeed at the bottom of the screen, on a row recycled from +/// the far end of the 10k-row scrollback ring and so cold in cache) +/// blank only the prefix that was ever written instead of all `cols` +/// cells: for `seq` / log-style output that's one cache line instead of +/// forty (2026-10-22 perf note in `docs/decisions/scrollback.md`). +/// Writers through [`Row::cells_mut`] conservatively set `used` to the +/// full length; the hot paths ([`Row::put`], [`Row::write_run`], +/// [`Row::erase`]) track it exactly. #[derive(Debug, Clone, Default)] pub struct Row { - pub cells: SmallVec<[Cell; 16]>, + cells: Vec, + /// Upper bound on the written prefix: `cells[used..]` are all + /// `Cell::BLANK`. Always `<= cells.len()`. + used: u16, pub soft_wrap: bool, pub rich: Option, } @@ -29,22 +42,68 @@ impl Row { /// "scroll up" path when the bottom row falls off into history. #[must_use] pub fn blank(cols: u16) -> Self { - let mut cells = SmallVec::with_capacity(cols as usize); - for _ in 0..cols { - cells.push(Cell::BLANK); + Self { + cells: vec![Cell::BLANK; cols as usize], + used: 0, + soft_wrap: false, + rich: None, } + } + + /// A row holding `cells` (any length; reflow and rich-block layout + /// build rows this way). + #[must_use] + pub fn from_cells(cells: Vec, soft_wrap: bool) -> Self { + let used = cells.iter().rposition(|c| *c != Cell::BLANK).map_or(0, |i| i + 1); Self { + used: u16::try_from(used).unwrap_or(u16::MAX), cells, - soft_wrap: false, + soft_wrap, rich: None, } } - /// Reset every cell to blank but keep allocated capacity. + /// The row's cells, one per column. + #[inline] + #[must_use] + pub fn cells(&self) -> &[Cell] { + &self.cells + } + + /// Mutable access to every cell. Counts the whole row as written + /// (see the type docs), so prefer [`Row::put`] / [`Row::erase`] on + /// hot paths. + #[inline] + pub fn cells_mut(&mut self) -> &mut [Cell] { + self.used = u16::try_from(self.cells.len()).unwrap_or(u16::MAX); + &mut self.cells + } + + /// Number of cells (normally the grid's column count). + #[inline] + #[must_use] + pub fn len(&self) -> usize { + self.cells.len() + } + + /// True for a row with no cells (an off-screen ring slot that was + /// never used, see [`Grid`]). + #[inline] + #[must_use] + pub fn is_empty(&self) -> bool { + self.cells.is_empty() + } + + /// Reset every cell to blank but keep allocated capacity. Only the + /// written prefix is touched (see the type docs). pub fn clear(&mut self) { - for c in &mut self.cells { - *c = Cell::BLANK; - } + let used = usize::from(self.used).min(self.cells.len()); + debug_assert!( + self.cells[used..].iter().all(|c| *c == Cell::BLANK), + "Row::used understates the written prefix" + ); + self.cells[..used].fill(Cell::BLANK); + self.used = 0; self.soft_wrap = false; self.rich = None; } @@ -57,30 +116,33 @@ impl Row { self.cells.resize(cols, Cell::BLANK); } else { self.cells.truncate(cols); + self.used = self.used.min(u16::try_from(cols).unwrap_or(u16::MAX)); } } /// Write a cell at `col`, growing the row with blanks if necessary. /// Out-of-range writes against a clamped column are a logic bug in /// the caller — we silently grow, but never beyond `max_cols`. + #[inline] pub fn put(&mut self, col: u16, cell: Cell, max_cols: u16) { self.rich = None; - let col = col as usize; - if col >= self.cells.len() { - let target = (col + 1).min(max_cols as usize); + let c = col as usize; + if c >= self.cells.len() { + let target = (c + 1).min(max_cols as usize); self.cells.resize(target, Cell::BLANK); } - if let Some(slot) = self.cells.get_mut(col) { + if let Some(slot) = self.cells.get_mut(c) { *slot = cell; + self.used = self.used.max(col + 1); } } /// Erase cells in `[start, end)` (open at end) — used by EL handlers. pub fn erase(&mut self, start: u16, end: u16, style: Style) { self.rich = None; - let start = start as usize; - let end = (end as usize).min(self.cells.len()); - if start >= end { + let s = start as usize; + let e = (end as usize).min(self.cells.len()); + if s >= e { return; } let blank = Cell { @@ -90,10 +152,36 @@ impl Row { hyperlink_id: None, extra: None, }; - for c in &mut self.cells[start..end] { - *c = blank; + self.cells[s..e].fill(blank); + #[allow(clippy::cast_possible_truncation)] // e <= cells.len() + let e16 = e as u16; + if blank != Cell::BLANK { + self.used = self.used.max(e16); + } else if e16 >= self.used { + // Erasing (with the default background) through the end of + // the written prefix shortens it: `EL 0` / `ED` keep later + // clears cheap. + self.used = self.used.min(start); } } + + /// Fill every cell with `cell` (BCE erase of a whole row). + pub fn fill(&mut self, cell: Cell) { + self.cells.fill(cell); + self.used = if cell == Cell::BLANK { + 0 + } else { + u16::try_from(self.cells.len()).unwrap_or(u16::MAX) + }; + } + + /// True when every cell is [`Cell::BLANK`] — the written prefix is + /// checked, the rest is blank by construction. + #[must_use] + pub fn is_blank(&self) -> bool { + let used = usize::from(self.used).min(self.cells.len()); + self.cells[..used].iter().all(|c| *c == Cell::BLANK) + } } /// Re-layout callback for [`Grid::resize_reflow_with`]: block id and the @@ -425,10 +513,10 @@ impl Grid { for i in 0..self.visible_rows { let row = self.row_mut(i); // Ensure width matches current geometry, then blank every cell. - if row.cells.len() != cols as usize { + if row.cells().len() != cols as usize { row.resize_cols(cols); } - row.cells.fill(Cell { + row.fill(Cell { ch: ' ', style, is_continuation: false, @@ -588,7 +676,7 @@ impl Grid { // still yields a line for the cursor. let last_nonblank = retained .iter() - .rposition(|r| r.rich.is_some() || r.cells.iter().any(|c| *c != Cell::BLANK)) + .rposition(|r| r.rich.is_some() || r.cells().iter().any(|c| *c != Cell::BLANK)) .unwrap_or(0); let end = cursor_idx .max(last_nonblank) @@ -773,15 +861,15 @@ mod tests { #[test] fn new_row_is_all_blanks() { let r = Row::blank(4); - assert_eq!(r.cells.len(), 4); - assert!(r.cells.iter().all(|c| *c == Cell::BLANK)); + assert_eq!(r.cells().len(), 4); + assert!(r.cells().iter().all(|c| *c == Cell::BLANK)); assert!(!r.soft_wrap); } #[test] fn row_clear_resets_cells_and_soft_wrap() { let mut r = Row::blank(3); - r.cells[0] = Cell { + r.cells_mut()[0] = Cell { ch: 'x', style: red_style(), is_continuation: false, @@ -790,21 +878,74 @@ mod tests { }; r.soft_wrap = true; r.clear(); - assert!(r.cells.iter().all(|c| *c == Cell::BLANK)); + assert!(r.cells().iter().all(|c| *c == Cell::BLANK)); assert!(!r.soft_wrap); } + /// `Row::used` must stay an upper bound on the non-blank prefix + /// whichever path writes the row, or `clear` would leave stale cells. + #[test] + fn row_used_tracks_every_write_path() { + let x = Cell { + ch: 'x', + ..Cell::BLANK + }; + let red = Cell { + style: red_style(), + ..Cell::BLANK + }; + let mut r = Row::blank(10); + assert_eq!(r.used, 0); + r.put(3, x, 10); + assert_eq!(r.used, 4); + r.put(1, x, 10); + assert_eq!(r.used, 4, "an earlier column doesn't shrink it"); + // Default-style erase through the end shortens the prefix... + r.erase(2, 10, Style::RESET); + assert_eq!(r.used, 2); + // ...one that stops short of it doesn't... + r.put(6, x, 10); + r.erase(0, 3, Style::RESET); + assert_eq!(r.used, 7); + // ...and a coloured (BCE) erase extends it. + r.erase(7, 9, red_style()); + assert_eq!(r.used, 9); + r.clear(); + assert!(r.is_blank() && r.used == 0); + // Raw mutable access counts the whole row as written. + r.cells_mut()[9] = x; + assert_eq!(r.used, 10); + r.clear(); + assert!(r.cells().iter().all(|c| *c == Cell::BLANK)); + // Whole-row fills: BCE colour keeps everything, blank resets. + r.fill(red); + assert_eq!(r.used, 10); + r.fill(Cell::BLANK); + assert_eq!(r.used, 0); + // Shrinking clamps it; growing adds blanks past it. + r.put(8, x, 10); + r.resize_cols(5); + assert_eq!(r.used, 5); + r.resize_cols(10); + assert_eq!(r.used, 5); + r.clear(); + assert!(r.is_blank()); + // `from_cells` counts up to the last non-blank cell. + let r = Row::from_cells(vec![Cell::BLANK, x, Cell::BLANK, Cell::BLANK], true); + assert_eq!((r.used, r.len(), r.soft_wrap), (2, 4, true)); + } + #[test] fn row_resize_grows_and_shrinks() { let mut r = Row::blank(2); r.resize_cols(5); - assert_eq!(r.cells.len(), 5); + assert_eq!(r.cells().len(), 5); r.resize_cols(1); - assert_eq!(r.cells.len(), 1); + assert_eq!(r.cells().len(), 1); // grow back — newly added cells must be blank. r.resize_cols(3); - assert_eq!(r.cells[1], Cell::BLANK); - assert_eq!(r.cells[2], Cell::BLANK); + assert_eq!(r.cells()[1], Cell::BLANK); + assert_eq!(r.cells()[2], Cell::BLANK); } #[test] @@ -821,10 +962,10 @@ mod tests { }, 8, ); - assert_eq!(r.cells.len(), 5); - assert_eq!(r.cells[4].ch, 'z'); + assert_eq!(r.cells().len(), 5); + assert_eq!(r.cells()[4].ch, 'z'); // intermediate cells should be blanks - assert_eq!(r.cells[2], Cell::BLANK); + assert_eq!(r.cells()[2], Cell::BLANK); } #[test] @@ -843,14 +984,14 @@ mod tests { }, 3, ); - assert_eq!(r.cells.len(), 3); - assert!(r.cells.iter().all(|c| *c == Cell::BLANK)); + assert_eq!(r.cells().len(), 3); + assert!(r.cells().iter().all(|c| *c == Cell::BLANK)); } #[test] fn row_erase_handles_range_and_clamping() { let mut r = Row::blank(5); - for (i, c) in r.cells.iter_mut().enumerate() { + for (i, c) in r.cells_mut().iter_mut().enumerate() { *c = Cell { ch: char::from(b'a' + i as u8), style: Style::RESET, @@ -860,9 +1001,9 @@ mod tests { }; } r.erase(1, 3, red_style()); - assert_eq!(r.cells[0].ch, 'a'); + assert_eq!(r.cells()[0].ch, 'a'); assert_eq!( - r.cells[1], + r.cells()[1], Cell { ch: ' ', style: red_style(), @@ -872,7 +1013,7 @@ mod tests { } ); assert_eq!( - r.cells[2], + r.cells()[2], Cell { ch: ' ', style: red_style(), @@ -881,13 +1022,13 @@ mod tests { extra: None, } ); - assert_eq!(r.cells[3].ch, 'd'); + assert_eq!(r.cells()[3].ch, 'd'); // Out-of-range end gets clamped. r.erase(4, 100, Style::RESET); - assert_eq!(r.cells[4], Cell::BLANK); + assert_eq!(r.cells()[4], Cell::BLANK); // start >= end is a no-op (no panic). r.erase(3, 3, red_style()); - assert_eq!(r.cells[3].ch, 'd'); + assert_eq!(r.cells()[3].ch, 'd'); } #[test] @@ -898,7 +1039,7 @@ mod tests { assert_eq!(g.cap(), 5); for r in 0..3 { let row = g.row(r); - assert_eq!(row.cells.len(), 4); + assert_eq!(row.cells().len(), 4); } } @@ -934,9 +1075,9 @@ mod tests { ); g.scroll_up(); // Now row 0 should hold what was row 1. - assert_eq!(g.row(0).cells[0].ch, 'b'); + assert_eq!(g.row(0).cells()[0].ch, 'b'); // Row 1 is fresh blank. - assert_eq!(g.row(1).cells[0], Cell::BLANK); + assert_eq!(g.row(1).cells()[0], Cell::BLANK); } #[test] @@ -966,10 +1107,10 @@ mod tests { ); g.scroll_down(); // Row 0 is fresh blank (the inserted line at the top). - assert_eq!(g.row(0).cells[0], Cell::BLANK); + assert_eq!(g.row(0).cells()[0], Cell::BLANK); // Row 1 holds what was row 0 ('a' moved down by one); 'b' fell // off the bottom. - assert_eq!(g.row(1).cells[0].ch, 'a'); + assert_eq!(g.row(1).cells()[0].ch, 'a'); } #[test] @@ -987,17 +1128,17 @@ mod tests { // History: 'h'. Visible: 'a', 'z'. Two spare blank slots. g.scroll_down(); g.scroll_down(); - assert_eq!(g.row(0).cells[0], Cell::BLANK); - assert_eq!(g.row(1).cells[0], Cell::BLANK); + assert_eq!(g.row(0).cells()[0], Cell::BLANK); + assert_eq!(g.row(1).cells()[0], Cell::BLANK); assert_eq!(g.history_lines(), 1); - assert_eq!(g.scrollback_row(0).unwrap().cells[0].ch, 'h'); + assert_eq!(g.scrollback_row(0).unwrap().cells()[0].ch, 'h'); // 'z' must not resurface anywhere as the ring rotates on. for _ in 0..3 { g.scroll_up(); - assert_eq!(g.row(1).cells[0], Cell::BLANK); + assert_eq!(g.row(1).cells()[0], Cell::BLANK); } for n in 0..g.history_lines() { - assert_ne!(g.scrollback_row(n).unwrap().cells[0].ch, 'z'); + assert_ne!(g.scrollback_row(n).unwrap().cells()[0].ch, 'z'); } } @@ -1020,7 +1161,7 @@ mod tests { for r in 0..2 { let row = g.row(r); assert!( - row.cells + row.cells() .iter() .all(|c| c.ch == ' ' && c.style == red_style()) ); @@ -1044,8 +1185,8 @@ mod tests { ); g.resize(3, 6, 3); assert_eq!(g.cols(), 6); - assert_eq!(g.row(0).cells.len(), 6); - assert_eq!(g.row(0).cells[2].ch, 'a'); + assert_eq!(g.row(0).cells().len(), 6); + assert_eq!(g.row(0).cells()[2].ch, 'a'); } #[test] @@ -1076,9 +1217,9 @@ mod tests { g.resize(3, 3, 6); assert_eq!(g.cap(), 6); assert_eq!(g.visible_rows(), 3); - assert_eq!(g.row(0).cells[0].ch, 'a'); - assert_eq!(g.row(1).cells[0].ch, 'b'); - assert_eq!(g.row(2).cells[0], Cell::BLANK); + assert_eq!(g.row(0).cells()[0].ch, 'a'); + assert_eq!(g.row(1).cells()[0].ch, 'b'); + assert_eq!(g.row(2).cells()[0], Cell::BLANK); } #[test] @@ -1087,9 +1228,9 @@ mod tests { // clear_visible should still align the row to current cols. let mut g = Grid::new(1, 3, 1); // Manually shrink the underlying row to simulate a partial state. - g.row_mut(0).cells.truncate(1); + g.row_mut(0).resize_cols(1); g.clear_visible(Style::RESET); - assert_eq!(g.row(0).cells.len(), 3); + assert_eq!(g.row(0).cells().len(), 3); } /// Test helper: write `ch` into column 0 of `row`. @@ -1116,11 +1257,11 @@ mod tests { put_at(&mut g, 1, 'b'); g.scroll_up(); // Visible: 'b' at top, blank at bottom. - assert_eq!(g.row(0).cells[0].ch, 'b'); - assert_eq!(g.row(1).cells[0], Cell::BLANK); + assert_eq!(g.row(0).cells()[0].ch, 'b'); + assert_eq!(g.row(1).cells()[0], Cell::BLANK); // History: 'a' is now scrollback row 0 (most recent). assert_eq!(g.history_lines(), 1); - assert_eq!(g.scrollback_row(0).unwrap().cells[0].ch, 'a'); + assert_eq!(g.scrollback_row(0).unwrap().cells()[0].ch, 'a'); assert!(g.scrollback_row(1).is_none()); } @@ -1139,8 +1280,8 @@ mod tests { assert_eq!(g.history_lines(), 2); // Most recent scrollback rows are 'e' (row 0) then 'd' (row 1); // 'a', 'b', 'c' were evicted. - assert_eq!(g.scrollback_row(0).unwrap().cells[0].ch, 'e'); - assert_eq!(g.scrollback_row(1).unwrap().cells[0].ch, 'd'); + assert_eq!(g.scrollback_row(0).unwrap().cells()[0].ch, 'e'); + assert_eq!(g.scrollback_row(1).unwrap().cells()[0].ch, 'd'); assert!(g.scrollback_row(2).is_none()); } @@ -1155,8 +1296,8 @@ mod tests { g.scroll_up(); // History is full; '1' was evicted. Most recent is '3', then '2'. assert_eq!(g.history_lines(), 2); - assert_eq!(g.scrollback_row(0).unwrap().cells[0].ch, '3'); - assert_eq!(g.scrollback_row(1).unwrap().cells[0].ch, '2'); + assert_eq!(g.scrollback_row(0).unwrap().cells()[0].ch, '3'); + assert_eq!(g.scrollback_row(1).unwrap().cells()[0].ch, '2'); } #[test] @@ -1183,9 +1324,9 @@ mod tests { assert_eq!(g.history_lines(), 1); g.scroll_down(); assert_eq!(g.history_lines(), 1); - assert_eq!(g.scrollback_row(0).unwrap().cells[0].ch, 'a'); - assert_eq!(g.row(0).cells[0], Cell::BLANK); - assert_eq!(g.row(1).cells[0].ch, 'b'); + assert_eq!(g.scrollback_row(0).unwrap().cells()[0].ch, 'a'); + assert_eq!(g.row(0).cells()[0], Cell::BLANK); + assert_eq!(g.row(1).cells()[0].ch, 'b'); } #[test] @@ -1201,19 +1342,19 @@ mod tests { // History (most recent first): 'd', 'c'. Visible: 'e', 'f', 'g'. assert_eq!(g.history_lines(), 2); g.scroll_down(); - assert_eq!(g.row(0).cells[0], Cell::BLANK); - assert_eq!(g.row(1).cells[0].ch, 'e'); - assert_eq!(g.row(2).cells[0].ch, 'f'); + assert_eq!(g.row(0).cells()[0], Cell::BLANK); + assert_eq!(g.row(1).cells()[0].ch, 'e'); + assert_eq!(g.row(2).cells()[0].ch, 'f'); assert_eq!(g.history_lines(), 2); - assert_eq!(g.scrollback_row(0).unwrap().cells[0].ch, 'd'); - assert_eq!(g.scrollback_row(1).unwrap().cells[0].ch, 'c'); + assert_eq!(g.scrollback_row(0).unwrap().cells()[0].ch, 'd'); + assert_eq!(g.scrollback_row(1).unwrap().cells()[0].ch, 'c'); // Scrolling up again pushes the blank top into history and // brings a fresh bottom row; nothing stale resurfaces. g.scroll_up(); - assert_eq!(g.scrollback_row(0).unwrap().cells[0], Cell::BLANK); - assert_eq!(g.row(0).cells[0].ch, 'e'); - assert_eq!(g.row(1).cells[0].ch, 'f'); - assert_eq!(g.row(2).cells[0], Cell::BLANK); + assert_eq!(g.scrollback_row(0).unwrap().cells()[0], Cell::BLANK); + assert_eq!(g.row(0).cells()[0].ch, 'e'); + assert_eq!(g.row(1).cells()[0].ch, 'f'); + assert_eq!(g.row(2).cells()[0], Cell::BLANK); } #[test] @@ -1238,14 +1379,14 @@ mod tests { assert!(g.scrollback_row(0).is_none()); assert_eq!(g.bottom_id(), bottom); assert_eq!(g.oldest_retained_id(), bottom - 1); - assert_eq!(g.row(0).cells[0].ch, 'd'); - assert_eq!(g.row(1).cells[0].ch, 'e'); + assert_eq!(g.row(0).cells()[0].ch, 'd'); + assert_eq!(g.row(1).cells()[0].ch, 'e'); // New history accumulates again, and no old row resurfaces as // the ring wraps. for _ in 0..10 { g.scroll_up(); for n in 0..g.history_lines() { - let ch = g.scrollback_row(n).unwrap().cells[0].ch; + let ch = g.scrollback_row(n).unwrap().cells()[0].ch; assert!(ch == ' ' || ch == 'd' || ch == 'e', "stale {ch:?}"); } } @@ -1404,10 +1545,10 @@ mod tests { assert_eq!(g.visible_rows(), 4); // 3 rows revealed into the viewport; 3 remain in scrollback. assert_eq!(g.history_lines(), 3); - assert_eq!(g.row(0).cells[0].ch, 'd'); - assert_eq!(g.row(2).cells[0].ch, 'f'); + assert_eq!(g.row(0).cells()[0].ch, 'd'); + assert_eq!(g.row(2).cells()[0].ch, 'f'); // Oldest scrollback ('a') still retained, not evicted. - assert_eq!(g.scrollback_row(2).unwrap().cells[0].ch, 'a'); + assert_eq!(g.scrollback_row(2).unwrap().cells()[0].ch, 'a'); assert!(crow < 4, "cursor row must stay in the new viewport"); } @@ -1457,7 +1598,7 @@ mod tests { let (_, remap) = g.resize_reflow_mapped(4, 2, 4 + 10, (1, 1), &mut |_, _| None); assert_eq!(remap.map(old_top), top(&g)); assert_eq!(remap.map(b_line), top(&g) + 2, "'b' is now on row 2"); - assert_eq!(g.row(2).cells[0].ch, 'b'); + assert_eq!(g.row(2).cells()[0].ch, 'b'); // Past the reflowed span: same distance from its last row. assert_eq!(remap.map(b_line + 3), top(&g) + 5); } diff --git a/crates/toastty-term/src/link.rs b/crates/toastty-term/src/link.rs index 33958815..54e8c638 100644 --- a/crates/toastty-term/src/link.rs +++ b/crates/toastty-term/src/link.rs @@ -92,7 +92,7 @@ impl Term { /// row is evicted or `at` is past the row's end. fn primary_pos(&self, at: Pos) -> Option { let row = self.row_by_line_id(at.line_id)?; - let cell = row.cells.get(usize::from(at.col))?; + let cell = row.cells().get(usize::from(at.col))?; if cell.is_continuation && at.col > 0 { return Some(Pos::new(at.line_id, at.col - 1)); } @@ -105,13 +105,13 @@ impl Term { #[must_use] pub fn hyperlink_at(&self, at: Pos) -> Option { let at = self.primary_pos(at)?; - let id = self.row_by_line_id(at.line_id)?.cells[usize::from(at.col)].hyperlink_id?; + let id = self.row_by_line_id(at.line_id)?.cells()[usize::from(at.col)].hyperlink_id?; let url = self.hyperlink_url(id)?.to_string(); let (_, cols) = self.size(); let last_col = cols.saturating_sub(1); let same = |p: Pos| -> bool { self.row_by_line_id(p.line_id) - .and_then(|r| r.cells.get(usize::from(p.col))) + .and_then(|r| r.cells().get(usize::from(p.col))) .is_some_and(|c| c.hyperlink_id == Some(id) || c.is_continuation) }; let prev = |p: Pos| -> Option { @@ -178,16 +178,16 @@ impl Term { && row.soft_wrap && self .row_by_line_id(line_id + 1) - .is_some_and(|next| next.cells.get(1).is_some_and(|c| c.is_continuation)) + .is_some_and(|next| next.cells().get(1).is_some_and(|c| c.is_continuation)) { - row.cells + row.cells() .len() .checked_sub(1) - .filter(|&i| matches!(row.cells[i].ch, ' ' | '\0')) + .filter(|&i| matches!(row.cells()[i].ch, ' ' | '\0')) } else { None }; - for (i, cell) in row.cells.iter().enumerate() { + for (i, cell) in row.cells().iter().enumerate() { if cell.is_continuation || Some(i) == pad_col { continue; } diff --git a/crates/toastty-term/src/reflow.rs b/crates/toastty-term/src/reflow.rs index c52db7d5..8afcf89a 100644 --- a/crates/toastty-term/src/reflow.rs +++ b/crates/toastty-term/src/reflow.rs @@ -16,7 +16,6 @@ use crate::cell::Cell; use crate::grid::Row; -use smallvec::SmallVec; /// Display width of the cluster starting at `cells[i]`: 2 when the next /// cell is the continuation half of a width-2 cluster, else 1. @@ -80,11 +79,11 @@ fn build_logical_lines( let row = &rows[i]; let is_last = !row.soft_wrap; let trim = if is_last { - trailing_blank_run(&row.cells) + trailing_blank_run(row.cells()) } else { - trailing_blank_run(&row.cells).min(1) + trailing_blank_run(row.cells()).min(1) }; - let keep = row.cells.len() - trim; + let keep = row.cells().len() - trim; row_starts.push((lines.len(), content.len())); if i == cursor_idx { @@ -97,7 +96,7 @@ fn build_logical_lines( cursor_offset = content.len() + (cursor_col as usize).min(keep); } - content.extend_from_slice(&row.cells[..keep]); + content.extend_from_slice(&row.cells()[..keep]); // `soft_wrap` on the final retained row (its continuation was // truncated away as blank padding) terminates the line here. @@ -139,11 +138,7 @@ fn wrap_line( let finish_row = |rows: &mut Vec, cur: &mut Vec, soft_wrap: bool| { cur.resize(cols_usize, Cell::BLANK); - rows.push(Row { - cells: SmallVec::from_vec(std::mem::take(cur)), - soft_wrap, - rich: None, - }); + rows.push(Row::from_cells(std::mem::take(cur), soft_wrap)); }; let mut i = 0usize; @@ -352,11 +347,7 @@ mod tests { } fn row(cells: Vec, soft_wrap: bool) -> Row { - Row { - cells: SmallVec::from_vec(cells), - soft_wrap, - rich: None, - } + Row::from_cells(cells, soft_wrap) } fn text(cells: &[Cell]) -> String { @@ -387,15 +378,15 @@ mod tests { let mut rejoined: Vec = Vec::new(); for (idx, r) in narrow.iter().enumerate() { let trim = if idx + 1 < narrow.len() { - trailing_blank_run(&r.cells).min(1) + trailing_blank_run(r.cells()).min(1) } else { - trailing_blank_run(&r.cells) + trailing_blank_run(r.cells()) }; - rejoined.extend_from_slice(&r.cells[..r.cells.len() - trim]); + rejoined.extend_from_slice(&r.cells()[..r.cells().len() - trim]); } let (wide_again, _) = wrap_line(&rejoined, 11, None, &mut Vec::new()); assert_eq!(wide_again.len(), 1); - assert_eq!(text(&wide_again[0].cells).trim_end(), "hello world"); + assert_eq!(text(wide_again[0].cells()).trim_end(), "hello world"); } #[test] @@ -409,12 +400,12 @@ mod tests { assert_eq!(rows.len(), 2); assert!(rows[0].soft_wrap); assert_eq!( - rows[0].cells[3], + rows[0].cells()[3], Cell::BLANK, "last col left blank, no spacer" ); - assert_eq!(rows[1].cells[0].ch, '世'); - assert!(rows[1].cells[1].is_continuation); + assert_eq!(rows[1].cells()[0].ch, '世'); + assert!(rows[1].cells()[1].is_continuation); } #[test] @@ -479,7 +470,7 @@ mod tests { let rows = vec![row(vec![Cell::BLANK, Cell::BLANK], false)]; let (out, cursor) = reflow_rows(&rows, 4, 0, 0); assert_eq!(out.len(), 1); - assert!(out[0].cells.iter().all(|c| *c == Cell::BLANK)); + assert!(out[0].cells().iter().all(|c| *c == Cell::BLANK)); assert_eq!(cursor, (0, 0)); } @@ -492,7 +483,7 @@ mod tests { assert!(narrow.len() >= 3); let (widened, _) = reflow_rows(&narrow, 11, 0, 0); assert_eq!(widened.len(), 1); - assert_eq!(text(&widened[0].cells).trim_end(), "hello world"); + assert_eq!(text(widened[0].cells()).trim_end(), "hello world"); } #[test] diff --git a/crates/toastty-term/src/term.rs b/crates/toastty-term/src/term.rs index 23dceb5f..a429f5df 100644 --- a/crates/toastty-term/src/term.rs +++ b/crates/toastty-term/src/term.rs @@ -1053,7 +1053,7 @@ impl Term { .chain(self.alt.ring_rows()) .chain(std::iter::once(&self.blank_row)); for row in rows { - for c in &row.cells { + for c in row.cells() { if let Some(id) = c.extra { live[usize::from(id.get())] = true; } @@ -1649,7 +1649,7 @@ impl Term { pub fn mark_cell_dirty(&mut self, r: u16, c: u16) { let is_continuation = self .row(r) - .cells + .cells() .get(c as usize) .is_some_and(|cell| cell.is_continuation); if is_continuation && c > 0 { @@ -1958,12 +1958,12 @@ impl Term { let pad_col = if join_soft && self .row_by_line_id(line_id + 1) - .is_some_and(|next| next.cells.get(1).is_some_and(|c| c.is_continuation)) + .is_some_and(|next| next.cells().get(1).is_some_and(|c| c.is_continuation)) { - row.cells + row.cells() .len() .checked_sub(1) - .filter(|&i| matches!(row.cells[i].ch, ' ' | '\0')) + .filter(|&i| matches!(row.cells()[i].ch, ' ' | '\0')) } else { None }; @@ -1975,7 +1975,7 @@ impl Term { let col = u16::try_from(i).unwrap_or(u16::MAX); sel.contains(line_id, col) || wide_partner_selected(sel, row, line_id, col) }; - let len = row.cells.len(); + let len = row.cells().len(); if let (Some(lo), Some(hi)) = ( (0..len).find(|&i| selected(i)), (0..len).rev().find(|&i| selected(i)), @@ -1986,7 +1986,7 @@ impl Term { col += 1; continue; } - col = self.append_cell_text(&row.cells, col, hi + 1, &mut line_buf); + col = self.append_cell_text(row.cells(), col, hi + 1, &mut line_buf); } } if !join_soft { @@ -2047,9 +2047,9 @@ impl Term { use crate::selection::Pos; let char_at = |p: Pos| -> Option { let row = self.row_by_line_id(p.line_id)?; - let cell = row.cells.get(usize::from(p.col))?; + let cell = row.cells().get(usize::from(p.col))?; if cell.is_continuation && p.col > 0 { - return row.cells.get(usize::from(p.col) - 1).map(|c| c.ch); + return row.cells().get(usize::from(p.col) - 1).map(|c| c.ch); } Some(cell.ch) }; @@ -2327,9 +2327,7 @@ impl Term { }; let row = grid.row_mut(bot); row.resize_cols(cols); - for c in &mut row.cells { - *c = blank; - } + row.fill(blank); row.soft_wrap = false; row.rich = None; for r in top..=bot { @@ -2396,9 +2394,7 @@ impl Term { }; let row = grid.row_mut(top); row.resize_cols(cols); - for c in &mut row.cells { - *c = blank; - } + row.fill(blank); row.soft_wrap = false; row.rich = None; for r in top..=bot { @@ -2599,7 +2595,7 @@ impl Term { if to <= from { return; } - let cells = &self.active_grid().row(row).cells; + let cells = &self.active_grid().row(row).cells(); let (a, b) = (usize::from(from), usize::from(to)); let Some(run) = cells.get(a..b) else { return; @@ -2674,7 +2670,7 @@ impl Term { return false; } let mut col = cur - 1; - let cells = &self.active_grid().row(row).cells; + let cells = &self.active_grid().row(row).cells(); if cells .get(usize::from(col)) .is_some_and(|c| c.is_continuation) @@ -2743,7 +2739,7 @@ impl Term { let was_wide = self .active_grid() .row(row) - .cells + .cells() .get(usize::from(col) + 1) .is_some_and(|c| c.is_continuation); let grows = !was_wide @@ -2791,7 +2787,7 @@ impl Term { hyperlink_id: None, extra: None, }; - let cells = &self.active_grid().row(row).cells; + let cells = &self.active_grid().row(row).cells(); let left_orphan = col > 0 && cells.get(col as usize).is_some_and(|c| c.is_continuation); let right_orphan = cells .get(last as usize + 1) @@ -3429,12 +3425,12 @@ impl Term { let cols_u = cols as usize; let col_u = cur_col as usize; let n_u = n as usize; - if row.cells.len() < cols_u { + if row.cells().len() < cols_u { row.resize_cols(cols); } // Shift left. `cells` are `Copy`, so a plain copy_within is // both correct and overlap-safe. - row.cells.copy_within((col_u + n_u)..cols_u, col_u); + row.cells_mut().copy_within((col_u + n_u)..cols_u, col_u); // Blank the vacated tail under the current SGR (matches xterm / // ECMA-48 — the deleted-from-the-right slots use the active // attribute set, not Style::RESET). @@ -3445,7 +3441,7 @@ impl Term { hyperlink_id: None, extra: None, }; - for c in &mut row.cells[(cols_u - n_u)..cols_u] { + for c in &mut row.cells_mut()[(cols_u - n_u)..cols_u] { *c = blank; } self.mark_cells(cur_row, cur_col, cols); @@ -3475,12 +3471,13 @@ impl Term { let cols_u = cols as usize; let col_u = cur_col as usize; let n_u = n as usize; - if row.cells.len() < cols_u { + if row.cells().len() < cols_u { row.resize_cols(cols); } // Shift right. copy_within handles overlap correctly even // when src/dst overlap. - row.cells.copy_within(col_u..(cols_u - n_u), col_u + n_u); + row.cells_mut() + .copy_within(col_u..(cols_u - n_u), col_u + n_u); let blank = Cell { ch: ' ', style, @@ -3488,7 +3485,7 @@ impl Term { hyperlink_id: None, extra: None, }; - for c in &mut row.cells[col_u..(col_u + n_u)] { + for c in &mut row.cells_mut()[col_u..(col_u + n_u)] { *c = blank; } self.mark_cells(cur_row, cur_col, cols); @@ -3570,9 +3567,7 @@ impl Term { for r in cur_row..(cur_row + n) { let row = grid.row_mut(r); row.resize_cols(cols); - for c in &mut row.cells { - *c = blank; - } + row.fill(blank); row.soft_wrap = false; row.rich = None; } @@ -3638,9 +3633,7 @@ impl Term { for r in (region_end - n)..region_end { let row = grid.row_mut(r); row.resize_cols(cols); - for c in &mut row.cells { - *c = blank; - } + row.fill(blank); row.soft_wrap = false; row.rich = None; } @@ -6024,13 +6017,13 @@ fn wide_partner_selected( line_id: u64, col: u16, ) -> bool { - let Some(cell) = row.cells.get(usize::from(col)) else { + let Some(cell) = row.cells().get(usize::from(col)) else { return false; }; if cell.is_continuation { col > 0 && sel.contains(line_id, col - 1) } else { - row.cells + row.cells() .get(usize::from(col) + 1) .is_some_and(|c| c.is_continuation) && sel.contains(line_id, col + 1) @@ -6044,7 +6037,7 @@ fn wide_partner_selected( fn row_fingerprint(row: &crate::grid::Row) -> u64 { use std::hash::{Hash, Hasher}; let mut h = std::collections::hash_map::DefaultHasher::new(); - for c in &row.cells { + for c in row.cells() { c.ch.hash(&mut h); c.is_continuation.hash(&mut h); } @@ -6074,7 +6067,7 @@ mod tests { /// Stringify a row, trimming trailing blanks. fn row_text(t: &Term, r: u16) -> String { - let mut s: String = t.row(r).cells.iter().map(|c| c.ch).collect(); + let mut s: String = t.row(r).cells().iter().map(|c| c.ch).collect(); while s.ends_with(' ') { s.pop(); } @@ -6129,7 +6122,7 @@ mod tests { // LF does not move to col 0 — that's CR's job. Cursor stayed at col 1. assert_eq!(t.cursor().col, 2); assert_eq!(t.cursor().row, 1); - assert_eq!(t.row(1).cells[1].ch, 'b'); + assert_eq!(t.row(1).cells()[1].ch, 'b'); } #[test] @@ -6199,7 +6192,7 @@ mod tests { .primary .scrollback_row(n) .expect("scrollback row retained") - .cells + .cells() .iter() .map(|c| c.ch) .collect(); @@ -6283,7 +6276,7 @@ mod tests { t.scroll_view_by(3, 0.0, 16.0); t.force_snap_view(); assert_eq!(t.view_offset_lines(), 3); - let top = |t: &Term| -> String { t.view_row(0).cells.iter().map(|c| c.ch).collect() }; + let top = |t: &Term| -> String { t.view_row(0).cells().iter().map(|c| c.ch).collect() }; let before = top(&t); assert!(before.starts_with('4'), "view top = {before:?}"); feed(&mut t, b"\x1b[T"); @@ -6318,7 +6311,7 @@ mod tests { let t = run(3, 4, b"a\x0bb\x0cc"); assert_eq!(row_text(&t, 0), "a"); assert_eq!(row_text(&t, 1).trim_end(), " b"); - assert_eq!(t.row(2).cells[2].ch, 'c'); + assert_eq!(t.row(2).cells()[2].ch, 'c'); } #[test] @@ -6425,7 +6418,7 @@ mod tests { // `clear`'s full sequence, then new output builds fresh history. feed(&mut t, b"\x1b[H\x1b[2J\x1b[3Jnew\r\n\r\n\r\n\r\nx"); assert_eq!(t.history_lines(), 1); - let first: String = t.view_row(0).cells.iter().map(|c| c.ch).collect(); + let first: String = t.view_row(0).cells().iter().map(|c| c.ch).collect(); assert!(first.trim_end().is_empty(), "{first:?}"); } @@ -6510,7 +6503,7 @@ mod tests { assert_eq!(row_text(&t, 0), "1mX"); assert_eq!(row_text(&t, 1), "\u{FFFD}\u{FFFD}A"); assert_eq!(row_text(&t, 2), "\u{FFFD}1mY"); - let x = &t.row(0).cells[2]; + let x = &t.row(0).cells()[2]; assert_eq!(x.ch, 'X'); assert_eq!(x.style.fg, crate::Color::Default, "SGR 31 was aborted"); } @@ -6583,14 +6576,14 @@ mod tests { fn sgr_single_param_sets_fg_color() { let mut t = Term::new(1, 4, 0); feed(&mut t, b"\x1b[31mr"); - assert_eq!(t.row(0).cells[0].style.fg, Color::Red); + assert_eq!(t.row(0).cells()[0].style.fg, Color::Red); } #[test] fn sgr_multi_param_applies_in_order() { let mut t = Term::new(1, 4, 0); feed(&mut t, b"\x1b[1;3;31;44mx"); - let s = t.row(0).cells[0].style; + let s = t.row(0).cells()[0].style; assert_eq!(s.fg, Color::Red); assert_eq!(s.bg, Color::Blue); assert!(s.flags.bold()); @@ -6601,29 +6594,29 @@ mod tests { fn sgr_reset_clears_everything() { let mut t = Term::new(1, 4, 0); feed(&mut t, b"\x1b[1;31mA\x1b[0mB"); - assert!(t.row(0).cells[0].style.flags.bold()); - assert_eq!(t.row(0).cells[1].style, Style::RESET); + assert!(t.row(0).cells()[0].style.flags.bold()); + assert_eq!(t.row(0).cells()[1].style, Style::RESET); } #[test] fn sgr_empty_means_reset() { let mut t = Term::new(1, 4, 0); feed(&mut t, b"\x1b[1;31mA\x1b[mB"); - assert_eq!(t.row(0).cells[1].style, Style::RESET); + assert_eq!(t.row(0).cells()[1].style, Style::RESET); } #[test] fn sgr_attribute_reset_codes() { let mut t = Term::new(1, 8, 0); feed(&mut t, b"\x1b[1;3;4;7mA\x1b[22;23;24;27mB"); - let a = t.row(0).cells[0].style.flags; + let a = t.row(0).cells()[0].style.flags; assert_eq!( a, StyleFlags::EMPTY .with(StyleFlags::BOLD | StyleFlags::ITALIC | StyleFlags::REVERSE) .with_underline(UnderlineStyle::Single) ); - let b = t.row(0).cells[1].style.flags; + let b = t.row(0).cells()[1].style.flags; assert_eq!(b, StyleFlags::default()); } @@ -6632,7 +6625,7 @@ mod tests { fn sgr_flags_after(bytes: &[u8], col: usize) -> StyleFlags { let mut t = Term::new(1, 16, 0); feed(&mut t, bytes); - t.row(0).cells[col].style.flags + t.row(0).cells()[col].style.flags } #[test] @@ -6778,10 +6771,10 @@ mod tests { v.extend_from_slice(reset); v.push(b'B'); feed(&mut t, &v); - let a = t.row(0).cells[0].style.flags; + let a = t.row(0).cells()[0].style.flags; assert_eq!(a.bits(), 0xFF, "every rendition bit set"); assert_eq!(a.underline, UnderlineStyle::Double); - assert_eq!(t.row(0).cells[1].style, Style::RESET); + assert_eq!(t.row(0).cells()[1].style, Style::RESET); } // Reset mid-sequence, then set again. let f = sgr_flags_after(b"\x1b[2;9;0;53mx", 0); @@ -6795,8 +6788,8 @@ mod tests { let mut t = Term::new(1, 8, 0); feed(&mut t, b"\x1b[8msecret\x1b[28m!"); assert_eq!(row_text(&t, 0), "secret!"); - assert!(t.row(0).cells[0].style.flags.hidden()); - assert!(!t.row(0).cells[6].style.flags.hidden()); + assert!(t.row(0).cells()[0].style.flags.hidden()); + assert!(!t.row(0).cells()[6].style.flags.hidden()); } #[test] @@ -6808,7 +6801,7 @@ mod tests { // happened to land on. let mut t = Term::new(1, 20, 0); feed(&mut t, b"Internal\x1b[15Ginfra"); - let cells = &t.row(0).cells; + let cells = &t.row(0).cells(); assert_eq!(cells[0].ch, 'I'); assert_eq!(cells[7].ch, 'l'); // CHA jumped to column 15 (1-based) = index 14. @@ -6816,7 +6809,7 @@ mod tests { assert_eq!(cells[18].ch, 'a'); // Defaults: empty Ps and Ps=1 both mean column 1. feed(&mut t, b"\x1b[GX"); - assert_eq!(t.row(0).cells[0].ch, 'X'); + assert_eq!(t.row(0).cells()[0].ch, 'X'); } #[test] @@ -6827,7 +6820,7 @@ mod tests { // subsequent cell rendered with underline + dim. let mut t = Term::new(1, 2, 0); feed(&mut t, b"\x1b[>4;2mA"); - let s = t.row(0).cells[0].style; + let s = t.row(0).cells()[0].style; assert_eq!(s.flags, StyleFlags::default()); } @@ -6835,11 +6828,11 @@ mod tests { fn sgr_bright_and_default_colors() { let mut t = Term::new(1, 4, 0); feed(&mut t, b"\x1b[91;104mA"); - let s = t.row(0).cells[0].style; + let s = t.row(0).cells()[0].style; assert_eq!(s.fg, Color::BrightRed); assert_eq!(s.bg, Color::BrightBlue); feed(&mut t, b"\x1b[39;49mB"); - let s = t.row(0).cells[1].style; + let s = t.row(0).cells()[1].style; assert_eq!(s.fg, Color::Default); assert_eq!(s.bg, Color::Default); } @@ -6868,11 +6861,11 @@ mod tests { for (code, want) in pairs.iter().copied() { let mut t = Term::new(1, 1, 0); feed(&mut t, format!("\x1b[{code}mX").as_bytes()); - assert_eq!(t.row(0).cells[0].style.fg, want, "fg code {code}"); + assert_eq!(t.row(0).cells()[0].style.fg, want, "fg code {code}"); let bg_code = code + 10; let mut t = Term::new(1, 1, 0); feed(&mut t, format!("\x1b[{bg_code}mX").as_bytes()); - assert_eq!(t.row(0).cells[0].style.bg, want, "bg code {bg_code}"); + assert_eq!(t.row(0).cells()[0].style.bg, want, "bg code {bg_code}"); } } @@ -6882,7 +6875,7 @@ mod tests { // it must not silently apply garbage to the style. let mut t = Term::new(1, 4, 0); feed(&mut t, b"\x1b[123mX"); - assert_eq!(t.row(0).cells[0].style, Style::RESET); + assert_eq!(t.row(0).cells()[0].style, Style::RESET); } #[test] @@ -6928,10 +6921,10 @@ mod tests { assert!(t.is_alt_active()); feed(&mut t, b"TOP\r\n\r\nBOT"); // distinct top + bottom content let (rows, _) = t.size(); - let top: String = t.view_row(0).cells.iter().map(|c| c.ch).collect(); + let top: String = t.view_row(0).cells().iter().map(|c| c.ch).collect(); assert!(top.starts_with("TOP"), "row 0 should hold the top text"); // The row one past the bottom must NOT be a wrapped copy of row 0. - let past: String = t.view_row(rows).cells.iter().map(|c| c.ch).collect(); + let past: String = t.view_row(rows).cells().iter().map(|c| c.ch).collect(); assert!( past.trim_end().is_empty(), "view_row(rows) in alt mode must be blank, got {past:?}" @@ -6953,7 +6946,7 @@ mod tests { fn unknown_decset_param_is_a_noop() { let mut t = Term::new(2, 4, 0); feed(&mut t, b"\x1b[?25hX"); - assert_eq!(t.row(0).cells[0].ch, 'X'); + assert_eq!(t.row(0).cells()[0].ch, 'X'); } #[test] @@ -7073,7 +7066,7 @@ mod tests { assert_eq!(t.cursor().col, 3); feed(&mut t, b"d"); assert!(t.row(0).soft_wrap); - assert_eq!(t.row(1).cells[0].ch, 'd'); + assert_eq!(t.row(1).cells()[0].ch, 'd'); } #[test] @@ -7501,7 +7494,7 @@ mod tests { // SGR after consuming it as the palette index. let mut t = Term::new(1, 4, 0); feed(&mut t, b"\x1b[38;5;42mX"); - let s = t.row(0).cells[0].style; + let s = t.row(0).cells()[0].style; assert_eq!(s.fg, Color::Indexed256(42)); // Critical: bg must still be Default. Under the broken parser bg // would be `Color::Green` because `42 - 40 = 2` triggers the 40..=47 @@ -7518,7 +7511,7 @@ mod tests { // would also set bg=BrightBlack. Neither must happen. let mut t = Term::new(1, 4, 0); feed(&mut t, b"\x1b[38;2;200;32;100mX"); - let s = t.row(0).cells[0].style; + let s = t.row(0).cells()[0].style; assert_eq!(s.fg, Color::Rgb(200, 32, 100)); assert_eq!(s.bg, Color::Default); // Sanity: even if a future bug made these flags persist, this @@ -7530,7 +7523,7 @@ mod tests { fn sgr_256_bg_basic() { let mut t = Term::new(1, 4, 0); feed(&mut t, b"\x1b[48;5;1mX"); - let s = t.row(0).cells[0].style; + let s = t.row(0).cells()[0].style; assert_eq!(s.bg, Color::Indexed256(1)); assert_eq!(s.fg, Color::Default); } @@ -7539,7 +7532,7 @@ mod tests { fn sgr_truecolor_bg_basic() { let mut t = Term::new(1, 4, 0); feed(&mut t, b"\x1b[48;2;10;20;30mX"); - let s = t.row(0).cells[0].style; + let s = t.row(0).cells()[0].style; assert_eq!(s.bg, Color::Rgb(10, 20, 30)); assert_eq!(s.fg, Color::Default); } @@ -7552,7 +7545,7 @@ mod tests { let mut t = Term::new(1, 4, 0); // Start with a known fg so we can detect an accidental change. feed(&mut t, b"\x1b[31m\x1b[38;5mX"); - let s = t.row(0).cells[0].style; + let s = t.row(0).cells()[0].style; // fg is whatever the introducer left it as — either kept Red (good) // or reset to Default. Under the broken parser, `5` would land in // `apply_sgr_param(5)` (a no-op currently) but a future bug could @@ -7571,7 +7564,7 @@ mod tests { // Establish a baseline: bold + red. feed(&mut t, b"\x1b[1;31m"); feed(&mut t, b"\x1b[38;2;200;32mX"); - let s = t.row(0).cells[0].style; + let s = t.row(0).cells()[0].style; // Fg was Red; the truecolor sequence is incomplete — fg either // stays Red or becomes whatever the partial parse returned. In // our implementation it stays Red (Option::unwrap_or current fg). @@ -7589,7 +7582,7 @@ mod tests { // bg = Default. let mut t = Term::new(1, 4, 0); feed(&mut t, b"\x1b[31;38;5;42;1mX"); - let s = t.row(0).cells[0].style; + let s = t.row(0).cells()[0].style; assert_eq!(s.fg, Color::Indexed256(42)); assert_eq!(s.bg, Color::Default); assert!(s.flags.bold()); @@ -7602,7 +7595,7 @@ mod tests { // parser must handle both. let mut t = Term::new(1, 4, 0); feed(&mut t, b"\x1b[38:5:42mX"); - let s = t.row(0).cells[0].style; + let s = t.row(0).cells()[0].style; assert_eq!(s.fg, Color::Indexed256(42)); assert_eq!(s.bg, Color::Default); } @@ -7613,7 +7606,7 @@ mod tests { // must not leak. let mut t = Term::new(1, 4, 0); feed(&mut t, b"\x1b[38:2:200:32:100mX"); - let s = t.row(0).cells[0].style; + let s = t.row(0).cells()[0].style; assert_eq!(s.fg, Color::Rgb(200, 32, 100)); assert_eq!(s.bg, Color::Default); } @@ -7624,7 +7617,7 @@ mod tests { // color-space identifier (here `1` — sRGB) must be skipped. let mut t = Term::new(1, 4, 0); feed(&mut t, b"\x1b[38:2:1:200:32:100mX"); - let s = t.row(0).cells[0].style; + let s = t.row(0).cells()[0].style; assert_eq!(s.fg, Color::Rgb(200, 32, 100)); assert_eq!(s.bg, Color::Default); } @@ -7636,7 +7629,7 @@ mod tests { // SGR. Currently 9 is unhandled but we test the principle. let mut t = Term::new(1, 4, 0); feed(&mut t, b"\x1b[1;38;9;31mX"); - let s = t.row(0).cells[0].style; + let s = t.row(0).cells()[0].style; // Bold from before the introducer should stick. assert!(s.flags.bold()); // The trailing `31` (red fg) must apply — the parser must have @@ -7649,7 +7642,7 @@ mod tests { // `\x1b[39m` resets fg but leaves bg/flags alone. let mut t = Term::new(1, 4, 0); feed(&mut t, b"\x1b[1;31;44mA\x1b[39mB"); - let s = t.row(0).cells[1].style; + let s = t.row(0).cells()[1].style; assert_eq!(s.fg, Color::Default); assert_eq!(s.bg, Color::Blue); assert!(s.flags.bold()); @@ -7662,7 +7655,7 @@ mod tests { // index 42 should NOT end up setting bg=Green or anything else. let mut t = Term::new(1, 4, 0); feed(&mut t, b"\x1b[58;5;42mX"); - let s = t.row(0).cells[0].style; + let s = t.row(0).cells()[0].style; assert_eq!(s.fg, Color::Default); assert_eq!(s.bg, Color::Default); assert_eq!(s.flags, StyleFlags::default()); @@ -7672,7 +7665,7 @@ mod tests { fn sgr_underline_color_truecolor_is_consumed_and_ignored() { let mut t = Term::new(1, 4, 0); feed(&mut t, b"\x1b[58;2;200;32;100mX"); - let s = t.row(0).cells[0].style; + let s = t.row(0).cells()[0].style; assert_eq!(s.fg, Color::Default); assert_eq!(s.bg, Color::Default); assert_eq!(s.flags, StyleFlags::default()); @@ -7683,7 +7676,7 @@ mod tests { // Mode 59 = default underline color. Must not panic / leak. let mut t = Term::new(1, 4, 0); feed(&mut t, b"\x1b[31;59mX"); - let s = t.row(0).cells[0].style; + let s = t.row(0).cells()[0].style; assert_eq!(s.fg, Color::Red); } @@ -7707,13 +7700,13 @@ mod tests { (7, Color::Default), ] { assert_eq!( - t.row(0).cells[i].style.fg, + t.row(0).cells()[i].style.fg, want_fg, "cell {i} fg differs (helix leak regression)", ); // bg must always be Default in this scenario. assert_eq!( - t.row(0).cells[i].style.bg, + t.row(0).cells()[i].style.bg, Color::Default, "cell {i} bg accidentally non-default", ); @@ -7726,7 +7719,7 @@ mod tests { // state in the iterator). let mut t = Term::new(1, 4, 0); feed(&mut t, b"\x1b[38;2;1;2;3m\x1b[33mX"); - let s = t.row(0).cells[0].style; + let s = t.row(0).cells()[0].style; assert_eq!(s.fg, Color::Yellow); } @@ -7764,7 +7757,7 @@ mod tests { // Cover the colon-form `48` branch (top-level `bg = ...`). let mut t = Term::new(1, 4, 0); feed(&mut t, b"\x1b[48:5:1mX"); - let s = t.row(0).cells[0].style; + let s = t.row(0).cells()[0].style; assert_eq!(s.bg, Color::Indexed256(1)); assert_eq!(s.fg, Color::Default); } @@ -7774,7 +7767,7 @@ mod tests { // Cover the colon-form `58` branch (top-level discard). let mut t = Term::new(1, 4, 0); feed(&mut t, b"\x1b[58:5:42mX"); - let s = t.row(0).cells[0].style; + let s = t.row(0).cells()[0].style; assert_eq!(s.fg, Color::Default); assert_eq!(s.bg, Color::Default); } @@ -7788,7 +7781,7 @@ mod tests { // codes back into the SGR walker. let mut t = Term::new(1, 4, 0); feed(&mut t, b"\x1b[44m\x1b[48;9mX"); - let s = t.row(0).cells[0].style; + let s = t.row(0).cells()[0].style; assert_eq!(s.bg, Color::Blue); } @@ -8111,12 +8104,12 @@ mod tests { let mut t = Term::new(2, 8, 0); feed(&mut t, "你".as_bytes()); let row = t.row(0); - assert_eq!(row.cells[0].ch, '你'); - assert!(!row.cells[0].is_continuation); - assert!(row.cells[1].is_continuation); - assert_eq!(row.cells[1].ch, '\0'); + assert_eq!(row.cells()[0].ch, '你'); + assert!(!row.cells()[0].is_continuation); + assert!(row.cells()[1].is_continuation); + assert_eq!(row.cells()[1].ch, '\0'); // Continuation inherits the style of the cluster's primary. - assert_eq!(row.cells[1].style, row.cells[0].style); + assert_eq!(row.cells()[1].style, row.cells()[0].style); assert_eq!(t.cursor().col, 2); } @@ -8130,8 +8123,8 @@ mod tests { assert_eq!(t.cursor().col, 2); feed(&mut t, "你".as_bytes()); // Wrap took effect: the wide cluster lands on row 1. - assert_eq!(t.row(1).cells[0].ch, '你'); - assert!(t.row(1).cells[1].is_continuation); + assert_eq!(t.row(1).cells()[0].ch, '你'); + assert!(t.row(1).cells()[1].is_continuation); // Row 0 marked soft-wrap. assert!(t.row(0).soft_wrap); } @@ -8142,10 +8135,10 @@ mod tests { let mut t = Term::new(1, 4, 0); feed(&mut t, "你好".as_bytes()); let row = t.row(0); - assert_eq!(row.cells[0].ch, '你'); - assert!(row.cells[1].is_continuation); - assert_eq!(row.cells[2].ch, '好'); - assert!(row.cells[3].is_continuation); + assert_eq!(row.cells()[0].ch, '你'); + assert!(row.cells()[1].is_continuation); + assert_eq!(row.cells()[2].ch, '好'); + assert!(row.cells()[3].is_continuation); // Cursor sits one past the last column (pending-wrap). assert_eq!(t.cursor().col, 4); } @@ -8199,7 +8192,7 @@ mod tests { &mut t, b"\x08\x08\x1b[27m\xe4\xbd\xa0\x1b[27m\x1b[7m\xe4\xbd\xa0\x1b[27m\x1b[7m\x1b[27m", ); - let cells = &t.view_row(0).cells; + let cells = &t.view_row(0).cells(); // Prompt intact, 你你 at cols 2..6, never overwriting col 0/1. assert_eq!(cells[0].ch, '>'); assert_eq!(cells[1].ch, ' ', "prompt space must not be clobbered"); @@ -8219,7 +8212,7 @@ mod tests { feed(&mut t, "你你".as_bytes()); feed(&mut t, b"\x1b[1G"); // CHA col 1 (1-based) -> col 0 feed(&mut t, b"x"); - let cells = &t.view_row(0).cells; + let cells = &t.view_row(0).cells(); assert_eq!(cells[0].ch, 'x'); assert!( !cells[1].is_continuation, @@ -8236,7 +8229,7 @@ mod tests { feed(&mut t2, "你".as_bytes()); feed(&mut t2, b"\x1b[2G"); // CHA col 2 (1-based) -> col 1 feed(&mut t2, b"x"); - let cells2 = &t2.view_row(0).cells; + let cells2 = &t2.view_row(0).cells(); assert_eq!(cells2[0].ch, ' ', "orphaned wide primary must be cleared"); assert!(!cells2[0].is_continuation); assert_eq!(cells2[1].ch, 'x'); @@ -8744,7 +8737,7 @@ mod tests { assert!(parts[2].starts_with("\x1bP1+r73657472676266=")); assert!(parts[3].starts_with("\x1bP1+r73657472676262=")); // Nothing printed to the grid. - assert_eq!(t.row(0).cells[0].ch, ' '); + assert_eq!(t.row(0).cells()[0].ch, ' '); } #[test] @@ -8759,7 +8752,7 @@ mod tests { assert!(reply.starts_with(b"\x1bP1$r"), "{reply:?}"); // PM / SOS bodies ending in BEL print nothing. feed(&mut t, b"\x1b^hidden\x07\x1bXhidden\x07ok"); - let row: String = t.row(0).cells.iter().map(|c| c.ch).collect(); + let row: String = t.row(0).cells().iter().map(|c| c.ch).collect(); assert_eq!(row.trim_end(), "ok"); } @@ -8821,7 +8814,7 @@ mod tests { assert_eq!(t.drain_pty_replies(), b"\x1bP0+r\x1b\\"); // The terminal is back to normal afterwards. feed(&mut t, b"ok"); - assert_eq!(t.row(0).cells[0].ch, 'o'); + assert_eq!(t.row(0).cells()[0].ch, 'o'); feed(&mut t, b"\x1bP+q5463\x1b\\"); assert_eq!(t.drain_pty_replies(), b"\x1bP1+r5463\x1b\\"); } @@ -8881,7 +8874,7 @@ mod tests { b"\x1bP1;1|17/ab\x1b\\\x1bPtmux:x\x1b\\\x1bPtmuxx\x1b\\", ); assert_eq!(t.drain_pty_replies(), b""); - assert_eq!(t.row(0).cells[0].ch, ' '); + assert_eq!(t.row(0).cells()[0].ch, ' '); } // ----- Sixel (DCS) ------------------------------------------------------ @@ -9254,10 +9247,10 @@ mod tests { let mut t = Term::new(2, 16, 0); feed(&mut t, b"\x1b]8;;https://example.com\x1b\\link"); // First 4 cells must share the same non-None hyperlink id. - let id0 = t.row(0).cells[0].hyperlink_id; + let id0 = t.row(0).cells()[0].hyperlink_id; assert!(id0.is_some()); for c in 0..4 { - assert_eq!(t.row(0).cells[c].hyperlink_id, id0); + assert_eq!(t.row(0).cells()[c].hyperlink_id, id0); } // Resolves back to the URL. let url = t.hyperlink_url(id0.unwrap()).unwrap(); @@ -9271,8 +9264,8 @@ mod tests { &mut t, b"\x1b]8;;https://example.com\x1b\\link\x1b]8;;\x1b\\after", ); - assert!(t.row(0).cells[0].hyperlink_id.is_some()); - assert!(t.row(0).cells[4].hyperlink_id.is_none(), "after closer"); + assert!(t.row(0).cells()[0].hyperlink_id.is_some()); + assert!(t.row(0).cells()[4].hyperlink_id.is_none(), "after closer"); } #[test] @@ -9282,8 +9275,8 @@ mod tests { feed(&mut t, b"\x1b]8;;https://example.com\x1b\\b\x1b]8;;\x1b\\"); // Both cells reference the same intern id (interning dedupes). assert_eq!( - t.row(0).cells[0].hyperlink_id, - t.row(0).cells[1].hyperlink_id, + t.row(0).cells()[0].hyperlink_id, + t.row(0).cells()[1].hyperlink_id, ); } @@ -9296,8 +9289,8 @@ mod tests { feed(&mut t, b"\x1b]8;id=a;https://x.com\x1b\\X\x1b]8;;\x1b\\"); feed(&mut t, b"\x1b]8;id=b;https://x.com\x1b\\Y\x1b]8;;\x1b\\"); assert_eq!( - t.row(0).cells[0].hyperlink_id, - t.row(0).cells[1].hyperlink_id, + t.row(0).cells()[0].hyperlink_id, + t.row(0).cells()[1].hyperlink_id, ); } @@ -9517,7 +9510,7 @@ mod tests { bytes.extend_from_slice("hello \u{41c} world".as_bytes()); bytes.extend_from_slice(b"\x1b\\]"); feed(&mut t, &bytes); - let row: String = t.row(0).cells.iter().map(|c| c.ch).collect(); + let row: String = t.row(0).cells().iter().map(|c| c.ch).collect(); assert_eq!(row.trim_end(), "[]", "intro {intro:x?}"); } // A KGP command after a 0x9C-bearing non-G APC still works. @@ -9966,8 +9959,8 @@ mod tests { assert_eq!(t.view_offset_lines(), 1); // view_row(0) is now scrollback row 0 ('a'); view_row(1) is // logical 0 ('b'). - let r0: String = t.view_row(0).cells.iter().map(|c| c.ch).collect(); - let r1: String = t.view_row(1).cells.iter().map(|c| c.ch).collect(); + let r0: String = t.view_row(0).cells().iter().map(|c| c.ch).collect(); + let r1: String = t.view_row(1).cells().iter().map(|c| c.ch).collect(); assert!(r0.starts_with('a')); assert!(r1.starts_with('b')); } @@ -9998,12 +9991,12 @@ mod tests { let offset_before = t.view_offset_lines(); assert_eq!(offset_before, 1); // Capture what view_row(0) shows. - let before: String = t.view_row(0).cells.iter().map(|c| c.ch).collect(); + let before: String = t.view_row(0).cells().iter().map(|c| c.ch).collect(); // Now generate new output — view_offset should bump so the // user keeps seeing the same content. feed(&mut t, b"e\r\n"); assert!(t.view_offset_lines() >= 2); - let after: String = t.view_row(0).cells.iter().map(|c| c.ch).collect(); + let after: String = t.view_row(0).cells().iter().map(|c| c.ch).collect(); assert_eq!(before, after); } @@ -11308,7 +11301,7 @@ mod major_screen_tests { assert_eq!(t.cursor().style, Style::RESET); let row0 = t.view_row(0); assert!( - row0.cells.iter().all(|c| c.ch == ' '), + row0.cells().iter().all(|c| c.ch == ' '), "RIS must clear the screen" ); assert_eq!(t.scroll_top, 0); @@ -11341,7 +11334,7 @@ mod major_screen_tests { feed(&mut t, b"\x1b[2S"); // SU 2 assert_eq!(first_row_range(&t), 1..3, "SU must shift placement up by 2"); - let row3: String = t.view_row(3).cells.iter().map(|c| c.ch).collect(); + let row3: String = t.view_row(3).cells().iter().map(|c| c.ch).collect(); assert!( row3.starts_with("MARK"), "SU must scroll text up; row3={row3:?}" @@ -11363,7 +11356,7 @@ mod major_screen_tests { 3..5, "SD must shift placement down by 2" ); - let row2: String = t.view_row(2).cells.iter().map(|c| c.ch).collect(); + let row2: String = t.view_row(2).cells().iter().map(|c| c.ch).collect(); assert!( row2.starts_with("TOP"), "SD must scroll text down; row2={row2:?}" @@ -11463,7 +11456,7 @@ mod major_screen_tests { assert_eq!(first_row_range(&t), 4..6); feed(&mut t, b"\x1b[3;1H\x1b[2L"); // IL 2 at row 2 assert_eq!(first_row_range(&t), 4..6, "image stays put"); - assert_eq!(t.row(6).cells[2].ch, 'x', "text moved down by 2"); + assert_eq!(t.row(6).cells()[2].ch, 'x', "text moved down by 2"); assert_eq!(only_clip(&t), 0..2, "and is not clipped"); } @@ -11771,7 +11764,7 @@ mod minor_term_tests { /// Visible text of an alt- or primary-screen row, trailing blanks /// trimmed. fn screen_row(t: &Term, r: u16) -> String { - let s: String = t.row(r).cells.iter().map(|c| c.ch).collect(); + let s: String = t.row(r).cells().iter().map(|c| c.ch).collect(); s.trim_end().to_string() } @@ -11998,7 +11991,7 @@ mod placement_model_tests { } fn row_text(t: &Term, r: u16) -> String { - let s: String = t.row(r).cells.iter().map(|c| c.ch).collect(); + let s: String = t.row(r).cells().iter().map(|c| c.ch).collect(); s.trim_end().to_string() } @@ -12462,7 +12455,8 @@ mod placement_resize_tests { #[allow(clippy::cast_sign_loss)] let row = t.primary_row_by_line_id(p.line as u64).expect("retained"); assert_eq!( - row.cells[10].ch, 'M', + row.cells()[10].ch, + 'M', "image still on its text ({new_rows} rows)" ); } @@ -12708,7 +12702,7 @@ mod placement_size_tests { place_px(&mut t, 200, 100, ""); assert_eq!((t.cursor().row, t.cursor().col), (5 + 6 - 1 + 1, 0)); feed(&mut t, b"Z"); - assert_eq!(t.row(11).cells[0].ch, 'Z'); + assert_eq!(t.row(11).cells()[0].ch, 'Z'); // Not at the edge: one past the image, on its last row. let mut t = Term::new(24, 80, 0); t.set_cell_pixel_size(10, 19); diff --git a/crates/toastty-term/src/term/placeholder.rs b/crates/toastty-term/src/term/placeholder.rs index 01557e0b..c60679f3 100644 --- a/crates/toastty-term/src/term/placeholder.rs +++ b/crates/toastty-term/src/term/placeholder.rs @@ -215,7 +215,7 @@ impl Term { let Some(col) = cur.checked_sub(1) else { return false; }; - let Some(&cell) = self.active_grid().row(row).cells.get(usize::from(col)) else { + let Some(&cell) = self.active_grid().row(row).cells().get(usize::from(col)) else { return false; }; if cell.ch != crate::PLACEHOLDER || cell.is_continuation { @@ -259,7 +259,7 @@ impl Term { let mut seen = vec![false; crate::extra::MAX_ENTRIES + 1]; let mut count = 0usize; let mut note = |row: &Row, count: &mut usize| { - for c in &row.cells { + for c in row.cells() { if let Some(id) = c.extra && !seen[usize::from(id.get())] && is_placeholder(id) @@ -295,7 +295,7 @@ impl Term { let Some(row) = self.primary.scrollback_row_mut(n) else { continue; }; - for c in &mut row.cells { + for c in row.cells_mut() { if c.extra.is_some_and(|id| { matches!(self.extras.get(id), Some(CellExtra::Placeholder(_))) }) { @@ -366,7 +366,7 @@ impl Term { /// Append the placeholder runs of `row` (any row: live grid, /// scrollback, or [`Term::view_row`]'s result) to `out`. pub fn placeholder_runs(&self, row: &Row, out: &mut Vec) { - resolve_runs(&row.cells, &self.extras, out); + resolve_runs(row.cells(), &self.extras, out); } /// The virtual placement `run` draws, if it exists right now. diff --git a/crates/toastty-term/src/term/placeholder_tests.rs b/crates/toastty-term/src/term/placeholder_tests.rs index 397603a8..5c648187 100644 --- a/crates/toastty-term/src/term/placeholder_tests.rs +++ b/crates/toastty-term/src/term/placeholder_tests.rs @@ -92,7 +92,7 @@ fn diacritics_and_placement_id_are_stored_in_the_cell() { let mut t = Term::new(2, 6, 0); feed(&mut t, b"\x1b[38;5;7m\x1b[58;5;3m"); feed(&mut t, cell(&[2, 4, 1]).as_bytes()); - let c = t.active_grid().row(0).cells[0]; + let c = t.active_grid().row(0).cells()[0]; assert_eq!(c.ch, crate::PLACEHOLDER); assert_eq!( t.cell_extra(&c), @@ -232,7 +232,7 @@ fn stray_diacritic_after_text_is_not_a_placeholder() { feed(&mut t, "\u{0305}".as_bytes()); assert!(runs(&t, 0).is_empty()); assert_eq!( - t.grapheme_tail(&t.active_grid().row(0).cells[0]), + t.grapheme_tail(&t.active_grid().row(0).cells()[0]), "\u{0305}" ); } @@ -516,7 +516,7 @@ fn a_full_extras_table_reclaims_scrolled_off_placeholders() { // …and so does a combining mark. feed(&mut t, "\x1b[39me\u{301}".as_bytes()); let row = t.active_grid().row(t.cursor().row); - let e = row.cells[usize::from(t.cursor().col) - 1]; + let e = row.cells()[usize::from(t.cursor().col) - 1]; assert_eq!(t.grapheme_tail(&e), "\u{301}"); } @@ -535,7 +535,7 @@ fn long_icat_loops_stay_bounded() { // Reclamation never frees what's still on screen. let r = runs(&t, 3); assert!(!r.is_empty()); - for c in &t.active_grid().row(3).cells { + for c in t.active_grid().row(3).cells() { if c.ch == crate::PLACEHOLDER { assert!(matches!(t.cell_extra(c), Some(CellExtra::Placeholder(_)))); } @@ -642,7 +642,7 @@ fn scrollback_full_of_live_images_drops_the_oldest_not_the_newest() { // Combining marks are unaffected. feed(&mut t, "\x1b[39me\u{301}".as_bytes()); let row = t.active_grid().row(t.cursor().row); - let e = row.cells[usize::from(t.cursor().col) - 1]; + let e = row.cells()[usize::from(t.cursor().col) - 1]; assert_eq!(t.grapheme_tail(&e), "\u{301}"); } @@ -669,7 +669,7 @@ fn low_yield_sweeps_back_off_exponentially() { let mut k: u16 = 0; 'fill: for n in 0..t.primary.history_lines() { let row = t.primary.scrollback_row_mut(n).unwrap(); - for c in &mut row.cells { + for c in row.cells_mut() { let Some(id) = t.extras.intern(CellExtra::Tab(k)) else { break 'fill; }; @@ -700,7 +700,7 @@ fn low_yield_sweeps_back_off_exponentially() { // Once the cells go away, a sweep frees plenty and the streak resets. t.extras_gc_cooldown = 0; for n in 0..t.primary.history_lines() { - for c in &mut t.primary.scrollback_row_mut(n).unwrap().cells { + for c in t.primary.scrollback_row_mut(n).unwrap().cells_mut() { c.extra = None; } } diff --git a/crates/toastty-term/src/term/rich_term.rs b/crates/toastty-term/src/term/rich_term.rs index a9c11c83..cc7dfb3c 100644 --- a/crates/toastty-term/src/term/rich_term.rs +++ b/crates/toastty-term/src/term/rich_term.rs @@ -100,19 +100,19 @@ pub(crate) fn build_block_rows( )) }; // Don't leave half of a wide char behind. - if row.cells[col].is_continuation && col > 0 { - row.cells[col - 1] = Cell::BLANK; + if row.cells()[col].is_continuation && col > 0 { + row.cells_mut()[col - 1] = Cell::BLANK; } - if w == 1 && row.cells.get(col + 1).is_some_and(|c| c.is_continuation) { - row.cells[col + 1] = Cell::BLANK; + if w == 1 && row.cells().get(col + 1).is_some_and(|c| c.is_continuation) { + row.cells_mut()[col + 1] = Cell::BLANK; } - row.cells[col] = Cell { + row.cells_mut()[col] = Cell { ch, extra, ..Cell::BLANK }; if w == 2 { - row.cells[col + 1] = Cell { + row.cells_mut()[col + 1] = Cell { ch: '\0', is_continuation: true, ..Cell::BLANK @@ -134,7 +134,7 @@ pub(crate) fn build_block_rows( }; let end = (link.cols.end as usize).min(width); for c in (link.cols.start as usize).min(end)..end { - row.cells[c].hyperlink_id = Some(id); + row.cells_mut()[c].hyperlink_id = Some(id); } } rows diff --git a/crates/toastty-term/src/term/rich_tests.rs b/crates/toastty-term/src/term/rich_tests.rs index 12cb7383..b4a647aa 100644 --- a/crates/toastty-term/src/term/rich_tests.rs +++ b/crates/toastty-term/src/term/rich_tests.rs @@ -132,7 +132,7 @@ fn osc(md: &str) -> Vec { fn row_text(t: &Term, r: u16) -> String { t.row(r) - .cells + .cells() .iter() .filter(|c| !c.is_continuation) .map(|c| c.ch) @@ -293,11 +293,11 @@ fn text_is_clipped_and_wide_chars_take_two_cells() { // third wide char doesn't fit in 4 columns and is clipped. feed(&mut t, &osc("日本語")); let r = t.row(0); - assert_eq!(r.cells[0].ch, '日'); - assert!(r.cells[1].is_continuation); - assert_eq!(r.cells[2].ch, '本'); - assert!(r.cells[3].is_continuation); - assert_eq!(r.cells.len(), 4); + assert_eq!(r.cells()[0].ch, '日'); + assert!(r.cells()[1].is_continuation); + assert_eq!(r.cells()[2].ch, '本'); + assert!(r.cells()[3].is_continuation); + assert_eq!(r.cells().len(), 4); } #[test] @@ -432,13 +432,13 @@ fn links_become_hyperlinks() { feed(&mut t, b"\x1b]8;;https://first\x1b\\x\x1b]8;;\x1b\\\r\n"); feed(&mut t, &osc("link:https://ex.com\nplain")); let r = t.row(1); - let id = r.cells[0].hyperlink_id.unwrap(); + let id = r.cells()[0].hyperlink_id.unwrap(); assert_eq!(t.hyperlink_url(id), Some("https://ex.com")); - assert!(r.cells[18].hyperlink_id.is_some()); - assert!(r.cells[19].hyperlink_id.is_none()); - assert!(t.row(2).cells[0].hyperlink_id.is_none()); + assert!(r.cells()[18].hyperlink_id.is_some()); + assert!(r.cells()[19].hyperlink_id.is_none()); + assert!(t.row(2).cells()[0].hyperlink_id.is_none()); // Shares the OSC 8 table (the earlier link keeps id 1). - let first = t.row(0).cells[0].hyperlink_id.unwrap(); + let first = t.row(0).cells()[0].hyperlink_id.unwrap(); assert_eq!(t.hyperlink_url(first), Some("https://first")); assert_ne!(first, id); } @@ -465,7 +465,7 @@ fn all_lines(t: &Term) -> Vec { .map(|id| { t.primary_row_by_line_id(id) .unwrap() - .cells + .cells() .iter() .map(|c| c.ch) .collect::() @@ -673,7 +673,7 @@ fn block_text_is_written_one_grapheme_cluster_per_cell() { let (mut t, _) = term(3, 20, 0); let line = "x\u{2764}\u{fe0f}y \u{1f468}\u{200d}\u{1f469}\u{200d}\u{1f467}z e\u{301}!\u{200b}."; feed(&mut t, &osc(line)); - let cells = &t.row(0).cells; + let cells = &t.row(0).cells(); let shape: Vec<(char, bool, &str)> = cells[..12] .iter() .map(|c| (c.ch, c.is_continuation, t.grapheme_tail(c))) @@ -744,7 +744,7 @@ fn grapheme_tails_survive_resize_relayout() { feed(&mut t, &osc(line)); let tails = |t: &Term, r: u16| -> Vec { t.row(r) - .cells + .cells() .iter() .map(|c| t.grapheme_tail(c).to_owned()) .filter(|s| !s.is_empty()) @@ -792,7 +792,7 @@ fn long_grapheme_tails_are_clipped_to_the_cap() { let text = format!("e{marks}x\u{2764}{}", "\u{fe0f}".repeat(30)); let mut extras = crate::extra::ExtraTable::default(); let rows = super::build_block_rows(&one_run(&text), 1, 10, &mut vec![], &mut extras); - let cells = &rows[0].cells; + let cells = &rows[0].cells(); assert_eq!(cells[0].ch, 'e'); let tail = extras.grapheme_tail(cells[0].extra); assert_eq!(tail.len(), crate::extra::MAX_GRAPHEME_EXTRA_BYTES); @@ -815,7 +815,7 @@ fn wide_cluster_never_straddles_the_last_column() { &mut vec![], &mut extras, ); - let cells = &rows[0].cells; + let cells = &rows[0].cells(); assert_eq!(cells.len(), 10); assert_eq!(cells[8].ch, '9'); assert_eq!( @@ -831,7 +831,7 @@ fn wide_cluster_never_straddles_the_last_column() { &mut vec![], &mut extras, ); - let cells = &rows[0].cells; + let cells = &rows[0].cells(); assert_eq!(cells[8].ch, '\u{2764}'); assert_eq!(extras.grapheme_tail(cells[8].extra), "\u{fe0f}"); assert!(cells[9].is_continuation); diff --git a/crates/toastty-term/src/term/vt.rs b/crates/toastty-term/src/term/vt.rs index 7293d86a..24908e05 100644 --- a/crates/toastty-term/src/term/vt.rs +++ b/crates/toastty-term/src/term/vt.rs @@ -472,9 +472,7 @@ impl Term { row.resize_cols(cols); row.rich = None; row.soft_wrap = false; - for c in &mut row.cells { - *c = e; - } + row.fill(e); } self.scroll_top = 0; self.scroll_bot = rows.saturating_sub(1); @@ -579,13 +577,13 @@ impl Term { let src: Vec = { let next = grid.row_mut(row + 1); next.resize_cols(cols); - next.cells[usize::from(l)..=usize::from(r)].to_vec() + next.cells()[usize::from(l)..=usize::from(r)].to_vec() }; let dst = grid.row_mut(row); dst.resize_cols(cols); dst.rich = None; dst.soft_wrap = false; - dst.cells[usize::from(l)..=usize::from(r)].copy_from_slice(&src); + dst.cells_mut()[usize::from(l)..=usize::from(r)].copy_from_slice(&src); } let last = grid.row_mut(bot); last.resize_cols(cols); @@ -607,13 +605,13 @@ impl Term { let src: Vec = { let prev = grid.row_mut(row - 1); prev.resize_cols(cols); - prev.cells[usize::from(l)..=usize::from(r)].to_vec() + prev.cells()[usize::from(l)..=usize::from(r)].to_vec() }; let dst = grid.row_mut(row); dst.resize_cols(cols); dst.rich = None; dst.soft_wrap = false; - dst.cells[usize::from(l)..=usize::from(r)].copy_from_slice(&src); + dst.cells_mut()[usize::from(l)..=usize::from(r)].copy_from_slice(&src); } let first = grid.row_mut(top); first.resize_cols(cols); @@ -645,7 +643,7 @@ mod tests { fn row_text(t: &Term, r: u16) -> String { let s: String = t .row(r) - .cells + .cells() .iter() .filter(|c| !c.is_continuation) .map(|c| c.ch) @@ -983,7 +981,7 @@ mod tests { #[test] fn decawm_off_combining_mark_attaches_to_last_column() { let t = run(2, 3, "\x1b[?7labc\u{301}".as_bytes()); - let cell = t.row(0).cells[2]; + let cell = t.row(0).cells()[2]; assert_eq!(cell.ch, 'c'); assert!(cell.extra.is_some(), "accent joined the last cell"); } diff --git a/crates/toastty-term/tests/grapheme_copy.rs b/crates/toastty-term/tests/grapheme_copy.rs index 6b04f02a..5971f7d2 100644 --- a/crates/toastty-term/tests/grapheme_copy.rs +++ b/crates/toastty-term/tests/grapheme_copy.rs @@ -41,7 +41,7 @@ fn copy_row(t: &mut Term, r: u16) -> String { /// Full text of the cell at `(r, c)`: `ch` + grapheme tail. fn cell_text(t: &Term, r: u16, c: u16) -> String { - let cell = t.row(r).cells[c as usize]; + let cell = t.row(r).cells()[c as usize]; format!("{}{}", cell.ch, t.grapheme_tail(&cell)) } @@ -62,9 +62,9 @@ fn zwj_family_occupies_first_emoji_width() { let fam = "\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}"; let mut t = run(4, 20, &format!("{fam}x")); let row = t.row(0); - assert_eq!(row.cells[0].ch, '\u{1F468}'); - assert!(row.cells[1].is_continuation); - assert_eq!(row.cells[2].ch, 'x'); + assert_eq!(row.cells()[0].ch, '\u{1F468}'); + assert!(row.cells()[1].is_continuation); + assert_eq!(row.cells()[2].ch, 'x'); assert_eq!(cell_text(&t, 0, 0), fam); assert_eq!(t.cursor().col, 3); assert_eq!(copy_row(&mut t, 0), format!("{fam}x")); @@ -82,15 +82,15 @@ fn zwj_family_same_under_2027() { fn vs16_heart_legacy_stays_one_cell() { let mut t = run(4, 20, "\u{2764}\u{FE0F}|"); assert_eq!(t.cursor().col, 2); - assert_eq!(t.row(0).cells[1].ch, '|'); + assert_eq!(t.row(0).cells()[1].ch, '|'); assert_eq!(copy_row(&mut t, 0), "\u{2764}\u{FE0F}|"); } #[test] fn vs16_heart_under_2027_grows_to_two_cells() { let mut t = run(4, 20, "\x1b[?2027h\u{2764}\u{FE0F}|"); - assert!(t.row(0).cells[1].is_continuation); - assert_eq!(t.row(0).cells[2].ch, '|'); + assert!(t.row(0).cells()[1].is_continuation); + assert_eq!(t.row(0).cells()[2].ch, '|'); assert_eq!(t.cursor().col, 3); assert_eq!(copy_row(&mut t, 0), "\u{2764}\u{FE0F}|"); } @@ -102,9 +102,9 @@ fn growth_clobbers_wide_neighbour_cleanly() { let mut t = run(4, 20, "\x1b[?2027h\u{2764}\u{4F60}\x1b[1;2H\u{FE0F}"); // The cursor is at col 1 (on 你's primary); the left cell is ❤. let row = t.row(0); - assert!(row.cells[1].is_continuation); - assert_eq!(row.cells[2].ch, ' '); - assert!(!row.cells[2].is_continuation); + assert!(row.cells()[1].is_continuation); + assert_eq!(row.cells()[2].ch, ' '); + assert!(!row.cells()[2].is_continuation); assert_eq!(copy_row(&mut t, 0), "\u{2764}\u{FE0F}"); } @@ -114,7 +114,7 @@ fn zwj_with_narrow_base_grows_to_two_cells() { // the grid must reserve both cells in either mode. let s = "\u{2764}\u{200D}\u{1F525}"; let mut t = run(4, 20, &format!("{s}|")); - assert!(t.row(0).cells[1].is_continuation); + assert!(t.row(0).cells()[1].is_continuation); assert_eq!(t.cursor().col, 3); assert_eq!(copy_row(&mut t, 0), format!("{s}|")); } @@ -124,7 +124,7 @@ fn no_growth_in_last_column() { let mut t = run(4, 4, "\x1b[?2027habc\u{2764}\u{FE0F}"); // ❤ sits in the last column (pending wrap); no room to grow. assert_eq!(cell_text(&t, 0, 3), "\u{2764}\u{FE0F}"); - assert!(!t.row(1).cells[0].is_continuation); + assert!(!t.row(1).cells()[0].is_continuation); assert_eq!(copy_row(&mut t, 0), "abc\u{2764}\u{FE0F}"); } @@ -134,13 +134,13 @@ fn skin_tone_modifier_joins_its_base() { for prefix in ["", "\x1b[?2027h"] { let mut t = run(4, 20, &format!("{prefix}{s}|")); assert_eq!(cell_text(&t, 0, 0), s); - assert!(t.row(0).cells[1].is_continuation); - assert_eq!(t.row(0).cells[2].ch, '|'); + assert!(t.row(0).cells()[1].is_continuation); + assert_eq!(t.row(0).cells()[2].ch, '|'); assert_eq!(copy_row(&mut t, 0), format!("{s}|")); } // A lone modifier (no emoji base) stays its own wide cell. let mut t = run(4, 20, "a\u{1F3FD}|"); - assert_eq!(t.row(0).cells[1].ch, '\u{1F3FD}'); + assert_eq!(t.row(0).cells()[1].ch, '\u{1F3FD}'); assert_eq!(copy_row(&mut t, 0), "a\u{1F3FD}|"); // A narrow Dingbats base (✌) takes the modifier and grows to 2 cells. let mut t = run(4, 20, "\u{270C}\u{1F3FD}|"); @@ -158,7 +158,7 @@ fn regional_indicator_flag_legacy_and_2027() { // 2027: one cluster, two cells. let mut t = run(4, 20, &format!("\x1b[?2027h{flag}|")); assert_eq!(cell_text(&t, 0, 0), flag); - assert!(t.row(0).cells[1].is_continuation); + assert!(t.row(0).cells()[1].is_continuation); assert_eq!(t.cursor().col, 3); // A third RI starts a new cluster. let mut t2 = run(4, 20, &format!("\x1b[?2027h{flag}\u{1F1FA}")); @@ -191,15 +191,15 @@ fn hangul_jamo_compose_into_one_cell() { fn combining_mark_after_wide_char_attaches_to_primary() { let mut t = run(4, 20, "\u{4F60}\u{301}x"); assert_eq!(cell_text(&t, 0, 0), "\u{4F60}\u{301}"); - assert!(t.row(0).cells[1].is_continuation); + assert!(t.row(0).cells()[1].is_continuation); assert_eq!(copy_row(&mut t, 0), "\u{4F60}\u{301}x"); } #[test] fn combining_mark_at_column_zero_is_dropped() { let mut t = run(4, 20, "\u{301}a"); - assert_eq!(t.row(0).cells[0].ch, 'a'); - assert_eq!(t.row(0).cells[0].extra, None); + assert_eq!(t.row(0).cells()[0].ch, 'a'); + assert_eq!(t.row(0).cells()[0].extra, None); assert_eq!(copy_row(&mut t, 0), "a"); } @@ -207,7 +207,7 @@ fn combining_mark_at_column_zero_is_dropped() { fn combining_mark_in_pending_wrap_attaches_to_last_column() { let mut t = run(4, 3, "abc\u{301}d"); assert_eq!(cell_text(&t, 0, 2), "c\u{301}"); - assert_eq!(t.row(1).cells[0].ch, 'd'); + assert_eq!(t.row(1).cells()[0].ch, 'd'); assert_eq!(copy(&mut t, 0, 0, 1, 2), "abc\u{301}d"); } @@ -215,7 +215,7 @@ fn combining_mark_in_pending_wrap_attaches_to_last_column() { fn ascii_after_zwj_starts_new_cell() { let mut t = run(4, 20, "\u{1F468}\u{200D}a"); assert_eq!(cell_text(&t, 0, 0), "\u{1F468}\u{200D}"); - assert_eq!(t.row(0).cells[2].ch, 'a'); + assert_eq!(t.row(0).cells()[2].ch, 'a'); assert_eq!(copy_row(&mut t, 0), "\u{1F468}\u{200D}a"); } @@ -223,7 +223,7 @@ fn ascii_after_zwj_starts_new_cell() { fn non_joining_wide_char_after_zwj_tail_starts_new_cell() { // CJK after ZWJ: UAX #29 breaks (GB11 needs ExtPict on both sides). let t = run(4, 20, "\u{1F468}\u{200D}\u{4F60}"); - assert_eq!(t.row(0).cells[2].ch, '\u{4F60}'); + assert_eq!(t.row(0).cells()[2].ch, '\u{4F60}'); assert_eq!(t.cursor().col, 4); } @@ -232,10 +232,10 @@ fn overwrite_and_erase_drop_the_grapheme() { let mut t = run(4, 20, "e\u{301}x"); feed(&mut t, b"\r"); feed(&mut t, b"E"); - assert_eq!(t.row(0).cells[0].extra, None); + assert_eq!(t.row(0).cells()[0].extra, None); assert_eq!(copy_row(&mut t, 0), "Ex"); let mut t = run(4, 20, "e\u{301}x\r\x1b[K"); - assert_eq!(t.row(0).cells[0].extra, None); + assert_eq!(t.row(0).cells()[0].extra, None); assert_eq!(copy_row(&mut t, 0), ""); } @@ -266,7 +266,7 @@ fn graphemes_survive_scrollback_and_reflow() { break; }; if let Some(row) = t.primary_row_by_line_id(id) - && row.cells.first().is_some_and(|c| c.ch == 'a') + && row.cells().first().is_some_and(|c| c.ch == 'a') { found = Some(id); } @@ -282,7 +282,7 @@ fn graphemes_survive_scrollback_and_reflow() { fn ris_resets_extras() { let mut t = run(4, 20, "e\u{301}"); feed(&mut t, b"\x1bc"); - assert_eq!(t.row(0).cells[0].extra, None); + assert_eq!(t.row(0).cells()[0].extra, None); // Interning still works after the reset. feed(&mut t, "o\u{308}".as_bytes()); assert_eq!(cell_text(&t, 0, 0), "o\u{308}"); @@ -296,11 +296,11 @@ fn grapheme_length_is_capped() { } s.push('b'); let t = run(4, 20, &s); - let tail = t.grapheme_tail(&t.row(0).cells[0]).to_string(); + let tail = t.grapheme_tail(&t.row(0).cells()[0]).to_string(); assert!(tail.len() <= 64 && !tail.is_empty()); assert!(tail.chars().all(|c| c == '\u{301}')); // The overflow marks were swallowed, not printed as new cells. - assert_eq!(t.row(0).cells[1].ch, 'b'); + assert_eq!(t.row(0).cells()[1].ch, 'b'); } #[test] @@ -314,9 +314,9 @@ fn alt_screen_cells_keep_graphemes() { #[test] fn tab_copies_as_literal_tab() { let mut t = run(4, 40, "a\tb\tc"); - assert_eq!(t.row(0).cells[8].ch, 'b'); + assert_eq!(t.row(0).cells()[8].ch, 'b'); assert!(matches!( - t.cell_extra(&t.row(0).cells[1]), + t.cell_extra(&t.row(0).cells()[1]), Some(CellExtra::Tab(7)) )); assert_eq!(copy_row(&mut t, 0), "a\tb\tc"); diff --git a/crates/toastty-term/tests/integration.rs b/crates/toastty-term/tests/integration.rs index b97a0b86..667f7169 100644 --- a/crates/toastty-term/tests/integration.rs +++ b/crates/toastty-term/tests/integration.rs @@ -13,7 +13,7 @@ fn run(rows: u16, cols: u16, bytes: &[u8]) -> Term { } fn row_text(t: &Term, r: u16) -> String { - let mut s: String = t.row(r).cells.iter().map(|c| c.ch).collect(); + let mut s: String = t.row(r).cells().iter().map(|c| c.ch).collect(); while s.ends_with(' ') { s.pop(); } @@ -44,7 +44,7 @@ fn cup_with_only_row_specified_homes_column() { // `CSI 3 H` — row 3 col 1 (column defaults). let bytes = b"...\x1b[3HZ"; let t = run(5, 4, bytes); - assert_eq!(t.row(2).cells[0].ch, 'Z'); + assert_eq!(t.row(2).cells()[0].ch, 'Z'); } #[test] @@ -410,12 +410,12 @@ mod selection { assert!(!t.is_cell_selected(pos_for(&t, 0, 0).line_id, 0)); // Row lookup addresses the alt grid while it's active… let row = t.row_by_line_id(id).unwrap(); - assert_eq!(row.cells[0].ch, 'a'); + assert_eq!(row.cells()[0].ch, 'a'); // …and the primary accessor still reaches the primary grid. assert_eq!( t.primary_row_by_line_id(pos_for(&t, 0, 0).line_id) .unwrap() - .cells[0] + .cells()[0] .ch, 'p' ); diff --git a/crates/toastty-term/tests/tmux_passthrough.rs b/crates/toastty-term/tests/tmux_passthrough.rs index 17b1936f..4be97d83 100644 --- a/crates/toastty-term/tests/tmux_passthrough.rs +++ b/crates/toastty-term/tests/tmux_passthrough.rs @@ -47,7 +47,7 @@ fn snapshot(t: &mut Term) -> Snapshot { let cells = (0..ROWS) .map(|r| { t.row(r) - .cells + .cells() .iter() .map(|c| { ( @@ -294,10 +294,10 @@ fn real_tmux_outer_output_parses() { // tmux forwarded "link" (inside its OSC 8) at the outer cursor, // which its preceding DECSTBM had homed to column 0, then drew the // pane's "AFTER" at column 2 over the tail of it. - let row: String = t.row(0).cells.iter().map(|c| c.ch).collect(); + let row: String = t.row(0).cells().iter().map(|c| c.ch).collect(); assert_eq!(row.trim_end(), "liAFTER"); let url = |col: usize| { - t.row(0).cells[col] + t.row(0).cells()[col] .hyperlink_id .and_then(|id| t.hyperlink_url(id)) .map(str::to_owned) diff --git a/crates/toastty/tests/end_to_end.rs b/crates/toastty/tests/end_to_end.rs index b5f95b58..43e671df 100644 --- a/crates/toastty/tests/end_to_end.rs +++ b/crates/toastty/tests/end_to_end.rs @@ -57,7 +57,7 @@ fn pty_echo_drives_term_through_parser() { for r in 0..rows { let row = term.row(r); let s: String = row - .cells + .cells() .iter() .map(|c| if c.ch == '\0' { ' ' } else { c.ch }) .collect(); From 51d042d2279f53780ddf0941374d1b6c66d3750c Mon Sep 17 00:00:00 2001 From: EDToaster Date: Thu, 22 Oct 2026 01:49:47 -0700 Subject: [PATCH 02/11] term: allocate scrollback ring slots on demand, drop the % in Grid::slot Grid::new blanked every ring slot up front: 10 042 rows x 128 cols x 20-byte cells ~= 26 MB of RSS at startup before any output. Off-screen slots now start empty and get their cells when output first scrolls into them; ED 3 hands history memory back. Every path that brings a slot on screen (scroll_up, resize, reflow) sizes it, so visible rows still always hold cols cells. Grid::slot did (head + idx) % cap, a 64-bit divide on every row lookup (several per printed character); a conditional subtract covers the in-range indices, with % kept for far out-of-range ones. --- crates/toastty-term/src/grid.rs | 108 +++++++++++++++++++++++++++----- 1 file changed, 94 insertions(+), 14 deletions(-) diff --git a/crates/toastty-term/src/grid.rs b/crates/toastty-term/src/grid.rs index 64a5f96c..21cbba5c 100644 --- a/crates/toastty-term/src/grid.rs +++ b/crates/toastty-term/src/grid.rs @@ -54,7 +54,10 @@ impl Row { /// build rows this way). #[must_use] pub fn from_cells(cells: Vec, soft_wrap: bool) -> Self { - let used = cells.iter().rposition(|c| *c != Cell::BLANK).map_or(0, |i| i + 1); + let used = cells + .iter() + .rposition(|c| *c != Cell::BLANK) + .map_or(0, |i| i + 1); Self { used: u16::try_from(used).unwrap_or(u16::MAX), cells, @@ -175,6 +178,12 @@ impl Row { }; } + /// Release the cell storage, leaving an empty row (an unused ring + /// slot, see [`Grid`]). + pub(crate) fn release(&mut self) { + *self = Self::default(); + } + /// True when every cell is [`Cell::BLANK`] — the written prefix is /// checked, the rest is blank by construction. #[must_use] @@ -201,6 +210,12 @@ pub type RelayoutFn<'a> = dyn FnMut(RichBlockId, &[Row]) -> Option> + ' /// screen), `clear_history` and `resize` (which rebuild the ring) are the /// only places that touch off-screen slots, and they uphold this /// invariant. `scroll_down` only permutes the visible slots. +/// +/// A blank off-screen slot may also be *empty* (no cells, no +/// allocation): the ring starts that way and fills on demand as output +/// scrolls into history, so an idle terminal doesn't pay for 10k rows of +/// blank cells (~26 MB at 128 columns). Visible rows always hold `cols` +/// cells; every path that brings a slot on screen sizes it. #[derive(Debug)] pub struct Grid { rows: Box<[Row]>, @@ -311,10 +326,11 @@ impl Grid { debug_assert!(cap >= visible_rows as usize, "cap < visible_rows"); debug_assert!(visible_rows > 0 && cols > 0, "zero-sized grid"); let cap = cap.max(visible_rows as usize).max(1); + // Only the visible rows get cells; the scrollback slots stay + // empty until something scrolls into them. let mut v: Vec = Vec::with_capacity(cap); - for _ in 0..cap { - v.push(Row::blank(cols)); - } + v.resize_with(visible_rows as usize, || Row::blank(cols)); + v.resize_with(cap, Row::default); Self { rows: v.into_boxed_slice(), head: 0, @@ -391,8 +407,21 @@ impl Grid { } } + /// Ring slot of logical row `idx`. `head < cap` and callers nearly + /// always pass a visible row, so a conditional subtract replaces the + /// `%` (a 64-bit divide on every cell write and row read); far + /// out-of-range indices still wrap as before. + #[inline] fn slot(&self, idx: u16) -> usize { - (self.head + idx as usize) % self.rows.len() + let s = self.head + idx as usize; + let cap = self.rows.len(); + if s < cap { + s + } else if s - cap < cap { + s - cap + } else { + s % cap + } } /// Every ring slot — visible, scrollback and blank — in storage @@ -403,11 +432,13 @@ impl Grid { } /// Borrow visible row `idx` (0-based, top of viewport = 0). + #[inline] pub fn row(&self, idx: u16) -> &Row { &self.rows[self.slot(idx)] } /// Mutably borrow visible row `idx`. + #[inline] pub fn row_mut(&mut self, idx: u16) -> &mut Row { let s = self.slot(idx); &mut self.rows[s] @@ -452,10 +483,14 @@ impl Grid { // becomes scrollback row 0 after the head bump — that's the // retention path. let cap = self.rows.len(); - let new_bottom_slot = (self.head + self.visible_rows as usize) % cap; + let new_bottom_slot = self.slot(self.visible_rows); self.rows[new_bottom_slot].clear(); self.rows[new_bottom_slot].resize_cols(self.cols); - self.head = (self.head + 1) % cap; + self.head = if self.head + 1 == cap { + 0 + } else { + self.head + 1 + }; // Grow history, capped at the ring's scrollback budget. let max_history = (cap as u32).saturating_sub(u32::from(self.visible_rows)); if self.history_lines < max_history { @@ -500,8 +535,8 @@ impl Grid { let cap = self.rows.len(); for n in 0..self.history_lines as usize { let slot = (self.head + cap - 1 - n) % cap; - self.rows[slot].clear(); - self.rows[slot].resize_cols(self.cols); + // Hand the memory back: the slot refills on demand. + self.rows[slot].release(); } self.history_lines = 0; } @@ -540,8 +575,12 @@ impl Grid { let cap = cap.max(visible_rows as usize).max(1); let old_visible_rows = self.visible_rows; if cap == self.rows.len() { + // Empty (never-used) slots stay empty; the visible rows are + // sized below, since a taller screen can take in some. for r in &mut self.rows { - r.resize_cols(cols); + if !r.is_empty() { + r.resize_cols(cols); + } } } else { let mut v: Vec = Vec::with_capacity(cap); @@ -552,9 +591,8 @@ impl Grid { r.resize_cols(cols); v.push(r); } - for _ in preserve..cap { - v.push(Row::blank(cols)); - } + v.resize_with(visible_rows as usize, || Row::blank(cols)); + v.resize_with(cap, Row::default); self.rows = v.into_boxed_slice(); self.head = 0; // Cap changed → fresh ring; existing scrollback is dropped. @@ -562,6 +600,9 @@ impl Grid { } self.cols = cols; self.visible_rows = visible_rows; + for i in 0..visible_rows { + self.row_mut(i).resize_cols(cols); + } // Re-clamp history to the new scrollback budget. If the visible // region grew within the same cap, some scrollback slots may now // overlap the visible region. @@ -693,7 +734,9 @@ impl Grid { let vis = visible_rows as usize; let budget = cap.saturating_sub(vis); let n = flat.len(); - let mut ring: Vec = (0..cap).map(|_| Row::blank(cols)).collect(); + let mut ring: Vec = Vec::with_capacity(cap); + ring.resize_with(vis, || Row::blank(cols)); + ring.resize_with(cap, Row::default); let mut flat = flat; let (cursor_row, new_history) = if n <= vis { @@ -1031,6 +1074,43 @@ mod tests { assert_eq!(r.cells()[3].ch, 'd'); } + /// The scrollback part of the ring is allocated on demand: empty + /// slots until output scrolls into them, and again after ED 3. + /// Whatever brings a slot on screen gives it `cols` cells. + #[test] + fn grid_allocates_scrollback_slots_lazily() { + let mut g = Grid::new(2, 3, 6); + let sized = |g: &Grid| g.ring_rows().filter(|r| !r.is_empty()).count(); + assert_eq!(sized(&g), 2, "only the visible rows have cells"); + for _ in 0..3 { + put_at(&mut g, 0, 'a'); + g.scroll_up(); + } + assert_eq!(sized(&g), 5); + assert_eq!(g.history_lines(), 3); + for n in 0..3 { + assert_eq!(g.scrollback_row(n).unwrap().len(), 3); + } + for r in 0..2 { + assert_eq!(g.row(r).len(), 3); + } + g.clear_history(); + assert_eq!(sized(&g), 2, "ED 3 frees the history rows"); + // A taller screen in the same ring takes in empty slots: they + // come on screen sized and blank. + g.resize(5, 4, 6); + for r in 0..5 { + assert_eq!(g.row(r).len(), 4); + assert!(g.row(r).cells()[1..].iter().all(|c| *c == Cell::BLANK)); + } + // So does a ring rebuilt at a new capacity, and a reflow. + g.resize(3, 4, 9); + assert_eq!(sized(&g), 3); + let _ = g.resize_reflow(4, 5, 12, (0, 0)); + assert_eq!(sized(&g), 4); + assert!((0..4).all(|r| g.row(r).len() == 5)); + } + #[test] fn grid_new_and_access() { let g = Grid::new(3, 4, 5); From 69664d2d8ffad571df8e3f22abb6990a68172292 Mon Sep 17 00:00:00 2001 From: EDToaster Date: Thu, 22 Oct 2026 01:51:02 -0700 Subject: [PATCH 03/11] term: O(1) damage marks for left-to-right printing, skip redundant mark_all RowDamage::mark binary-searched and inserted for every printed column; printing goes left to right, so append when the column is past the last one (mark_range extends in one go). Damage::mark_all re-walked all rows on every full-screen scroll even when `all` was already set; `all` implies every row is marked, so return early (resize still marks the rows it adds). --- crates/toastty-term/src/damage.rs | 74 ++++++++++++++++++++++++++++++- 1 file changed, 73 insertions(+), 1 deletion(-) diff --git a/crates/toastty-term/src/damage.rs b/crates/toastty-term/src/damage.rs index 57278139..fb2cc33b 100644 --- a/crates/toastty-term/src/damage.rs +++ b/crates/toastty-term/src/damage.rs @@ -53,10 +53,25 @@ impl RowDamage { /// Mark column `col` dirty. No-op when `all_cols` is already set. /// Sorted-insert keeps `cols` ordered and deduped so the renderer /// can iterate it directly. + #[inline] pub fn mark(&mut self, col: u16) { if self.all_cols { return; } + // Printing marks columns left to right, so the common case is a + // plain append (or a repeat of the last column). + match self.cols.last() { + None => { + self.cols.push(col); + return; + } + Some(&last) if col > last => { + self.cols.push(col); + return; + } + Some(&last) if col == last => return, + Some(_) => {} + } match self.cols.binary_search(&col) { Ok(_) => {} // already present Err(idx) => self.cols.insert(idx, col), @@ -80,6 +95,11 @@ impl RowDamage { self.mark_all(); return; } + if self.cols.last().is_none_or(|&last| start > last) { + // Past everything marked so far (a print run): append. + self.cols.extend(start..end); + return; + } for c in start..end { self.mark(c); } @@ -142,6 +162,16 @@ impl Damage { /// renderer's `needs_full_clear` is keyed off `all`, so this is the /// path BSU-watchdog / resize / theme-swap take. pub fn mark_all(&mut self) { + // `all` already implies every row is `all_cols`: rows are only + // un-marked by `clear`, which drops `all` too. Every full-screen + // scroll lands here, so skip re-walking the rows. + if self.all { + return; + } + self.mark_all_rows(); + } + + fn mark_all_rows(&mut self) { self.all = true; for r in &mut self.rows { r.mark_all(); @@ -153,7 +183,8 @@ impl Damage { /// resize, so a partial clean state would be a lie). pub fn resize(&mut self, new_rows: u16) { self.rows.resize_with(new_rows as usize, RowDamage::default); - self.mark_all(); + // Not `mark_all`: the new rows aren't marked even if `all` is. + self.mark_all_rows(); } /// Iterate `(row_index, &RowDamage)` for all rows whose damage is @@ -305,6 +336,47 @@ mod tests { } } + /// The append fast paths in `mark` / `mark_range` must keep `cols` + /// exactly the sorted, deduped set whatever order columns arrive in. + #[test] + fn row_damage_any_mark_order_stays_sorted_unique() { + let mut want = std::collections::BTreeSet::new(); + let mut r = RowDamage::default(); + // A deterministic scramble: runs, repeats, back-steps, ranges. + let mut x: u32 = 7; + for _ in 0..500 { + x = x.wrapping_mul(1_103_515_245).wrapping_add(12_345); + let col = u16::try_from((x >> 16) % 60).unwrap(); + if x & 1 == 0 { + r.mark(col); + want.insert(col); + } else { + let end = col + u16::try_from((x >> 8) % 5).unwrap(); + r.mark_range(col, end, 80); + want.extend(col..end); + } + assert!(r.cols.windows(2).all(|w| w[0] < w[1])); + } + assert_eq!( + r.cols.iter().copied().collect::>(), + want.into_iter().collect::>() + ); + } + + /// `mark_all` skips the row walk when `all` is set; `resize` must + /// still mark the rows it adds. + #[test] + fn damage_resize_while_all_marks_new_rows() { + let mut d = Damage::new(2); + assert!(d.all); + d.resize(5); + assert!(d.rows.iter().all(|r| r.all_cols)); + d.clear(); + d.mark_all(); + d.mark_all(); + assert!(d.all && d.rows.iter().all(|r| r.all_cols)); + } + #[test] fn damage_resize_shrinks_and_dirties() { let mut d = Damage::new(5); From 5312116c700dba629ed4b2da442fdee9292c90ec Mon Sep 17 00:00:00 2001 From: EDToaster Date: Thu, 22 Oct 2026 02:02:11 -0700 Subject: [PATCH 04/11] parser: hand ground text past vte, printable ASCII in runs vte decodes ground text and calls print() once per character. The scanner now tracks when vte is known to be in its ground state (after a CSI / escape sequence's final byte, an OSC's BEL, a DCS's ST) and then dispatches clean ground text itself, exactly as vte's ground_dispatch would, except that each run of printable ASCII arrives in one new Perform::print_ascii call (default: one print per byte). Anything else handed to vte clears the flag, so uncertain cases keep going through vte. The all-ASCII shortcut that left CSI parsing to vte inside 'ground' text (open_seq) is gone: every CSI / escape sequence now goes through step_seq, which is what tells the scanner where vte returns to ground. Tests: a differential proptest runs random escape-heavy input through the parser with and without the bypass (test-only no_bypass hook) and requires identical event streams, including chunk splits. --- .../parser/tests/bypass.txt | 7 + crates/toastty-parser/src/parser.rs | 263 ++++++++++++------ .../toastty-parser/src/parser/tests/bypass.rs | 263 ++++++++++++++++++ crates/toastty-parser/src/perform.rs | 14 + 4 files changed, 461 insertions(+), 86 deletions(-) create mode 100644 crates/toastty-parser/proptest-regressions/parser/tests/bypass.txt create mode 100644 crates/toastty-parser/src/parser/tests/bypass.rs diff --git a/crates/toastty-parser/proptest-regressions/parser/tests/bypass.txt b/crates/toastty-parser/proptest-regressions/parser/tests/bypass.txt new file mode 100644 index 00000000..ba9c81f8 --- /dev/null +++ b/crates/toastty-parser/proptest-regressions/parser/tests/bypass.txt @@ -0,0 +1,7 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc d7c2c900361c770adb01f29dd30463c56852ba274dfd24ec7bc8bba0848be869 # shrinks to pieces = [[27], [27, 91]], cuts = [] diff --git a/crates/toastty-parser/src/parser.rs b/crates/toastty-parser/src/parser.rs index aa38bb4f..85b9c03c 100644 --- a/crates/toastty-parser/src/parser.rs +++ b/crates/toastty-parser/src/parser.rs @@ -199,6 +199,84 @@ fn vte_advance(vte: &mut vte::Parser, perform: &mut P, byte vte.advance(&mut VteAdapter { inner: perform }, bytes); } +/// Ground text, to vte or straight to the handler. With `vte_ground` +/// (vte known to be in its ground state) the text never needs vte's +/// state machine, so [`dispatch_ground`] hands it over directly, in +/// runs; otherwise vte parses it and may leave ground, so the flag +/// drops. `text` must be valid UTF-8 made of whole characters, as all +/// ground text is once the sanitizer has seen it. +#[inline] +fn ground_text( + vte: &mut vte::Parser, + vte_ground: &mut bool, + perform: &mut P, + text: &[u8], +) { + if *vte_ground { + *vte_ground = dispatch_ground(vte, perform, text); + } else { + vte_advance(vte, perform, text); + } +} + +/// Deliver ground text exactly as vte 0.15's `ground_dispatch` would +/// (`print` / `execute` per character, C0 and C1 controls executed), +/// except that each run of printable ASCII goes to the handler in one +/// [`Perform::print_ascii`] call: the terminal writes a run into a row +/// in one go instead of re-deriving the row, wrap and damage state per +/// byte. vte must be in ground. If a non-ASCII stretch isn't valid +/// UTF-8 after all (never expected: callers pass sanitized text), the +/// rest goes to vte as it would have, and this returns `false` because +/// vte may have left ground. +#[inline] +fn dispatch_ground( + vte: &mut vte::Parser, + perform: &mut P, + text: &[u8], +) -> bool { + let mut i = 0; + let n = text.len(); + while i < n { + let b = text[i]; + if (0x20..0x7F).contains(&b) { + let start = i; + i += 1; + while i < n && (0x20..0x7F).contains(&text[i]) { + i += 1; + } + perform.print_ascii(&text[start..i]); + } else if b < 0x20 { + perform.execute(b); + i += 1; + } else if b == 0x7F { + // DEL isn't a control to vte's ground state: it's printed. + perform.print('\x7f'); + i += 1; + } else { + // A stretch of non-ASCII characters: up to the next ASCII + // byte, which can't be inside a multi-byte character. + let start = i; + i += 1; + while i < n && text[i] >= 0x80 { + i += 1; + } + let Ok(chars) = std::str::from_utf8(&text[start..i]) else { + vte_advance(vte, perform, &text[start..]); + return false; + }; + for c in chars.chars() { + if ('\u{80}'..='\u{9f}').contains(&c) { + #[allow(clippy::cast_possible_truncation)] // U+0080..=U+009F + perform.execute(c as u8); + } else { + perform.print(c); + } + } + } + } + true +} + /// The APC / OSC-cap pre-scanner plus vte: everything the parser does /// except tmux passthrough unwrapping, which [`Parser`] layers on top /// so unwrapped payloads can be fed back through this same path. @@ -210,12 +288,16 @@ struct Scanner { /// Rewrites invalid UTF-8 in ground text to U+FFFD the way kitty /// does, before vte sees it (WIRE-14). utf8: Utf8Sanitizer, - /// In an all-ASCII input no byte can abort a sequence (WIRE-17), so - /// the scanner leaves CSI / escape sequences to vte and only notes - /// here (`Some(csi)`) that the last one may still be open. If it - /// still is when the input ends, the state becomes - /// [`ApcState::Seq`] so the next input is checked. - open_seq: Option, + /// vte is known to be in its ground state (and holds no partial + /// UTF-8), so ground text can bypass it ([`dispatch_ground`]). Set + /// where the scanner sees vte return to ground (a CSI or escape + /// sequence's final byte, an OSC's BEL, a DCS's ST), cleared by + /// anything else handed to vte ([`Scanner::feed_vte`]). Clearing is + /// always safe: the text then just goes through vte. + vte_ground: bool, + /// Test hook: never bypass vte, to check the bypass against it. + #[cfg(test)] + no_bypass: bool, /// Inside a CSI (after `ESC [`) whose final byte hasn't arrived: the /// parameters that held a digit so far (bit per parameter) and the /// current parameter's index. Reported through @@ -230,17 +312,38 @@ impl Scanner { apc: ApcState::default(), max_osc_len, utf8: Utf8Sanitizer::default(), - open_seq: None, + vte_ground: true, + #[cfg(test)] + no_bypass: false, csi_digits: None, } } - /// Hand ground text to vte, through the UTF-8 sanitizer where - /// needed. `text` starts at offset `at` of this `advance` call's - /// input, and `clean` is that input's [`Utf8Sanitizer::clean_span`]: - /// the part of `text` inside it is known-valid and goes straight to - /// vte; the parts before (finishing a held character) and after (a - /// trailing partial one, or an invalid chunk) are sanitized. + /// Hand vte bytes that aren't plain ground text. It may leave ground, + /// so ground text goes through it until the scanner sees it return. + #[inline] + fn feed_vte(&mut self, perform: &mut P, bytes: &[u8]) { + self.vte_ground = false; + vte_advance(&mut self.vte, perform, bytes); + } + + /// Ground text known to be clean (see [`ground_text`]). + #[inline] + fn clean_text(&mut self, perform: &mut P, text: &[u8]) { + #[cfg(test)] + if self.no_bypass { + self.vte_ground = false; + } + ground_text(&mut self.vte, &mut self.vte_ground, perform, text); + } + + /// Hand ground text on (to vte or, see [`ground_text`], past it), + /// through the UTF-8 sanitizer where needed. `text` starts at offset + /// `at` of this `advance` call's input, and `clean` is that input's + /// [`Utf8Sanitizer::clean_span`]: the part of `text` inside it is + /// known-valid and goes straight on; the parts before (finishing a + /// held character) and after (a trailing partial one, or an invalid + /// chunk) are sanitized. /// `cut_by_esc` says an ESC follows directly (see /// [`Utf8Sanitizer::feed`]). #[inline] @@ -257,7 +360,7 @@ impl Scanner { // inside the clean span (all of an ASCII or valid chunk). if clean.0 <= at && at + text.len() <= clean.1 && !self.utf8.holds_partial() { if !text.is_empty() { - vte_advance(&mut self.vte, perform, text); + self.clean_text(perform, text); } return; } @@ -275,12 +378,17 @@ impl Scanner { clean: (usize, usize), cut_by_esc: bool, ) { - let vte = &mut self.vte; + #[cfg(test)] + if self.no_bypass { + self.vte_ground = false; + } + // The sanitizer's output is valid UTF-8 in whole characters. + let (vte, vg) = (&mut self.vte, &mut self.vte_ground); if text.is_empty() { // An ESC right away still ends a held partial character. if cut_by_esc && self.utf8.holds_partial() { self.utf8.feed(text, true, &mut |s| { - vte_advance(vte, perform, s); + ground_text(vte, vg, perform, s); }); } return; @@ -292,15 +400,15 @@ impl Scanner { if !head.is_empty() { self.utf8 .feed(head, cut_by_esc && a == text.len(), &mut |s| { - vte_advance(vte, perform, s); + ground_text(vte, vg, perform, s); }); } if !mid.is_empty() { - vte_advance(vte, perform, mid); + ground_text(vte, vg, perform, mid); } if !tail.is_empty() { self.utf8.feed(tail, cut_by_esc, &mut |s| { - vte_advance(vte, perform, s); + ground_text(vte, vg, perform, s); }); } } @@ -350,10 +458,10 @@ impl Scanner { let mut span_ok = true; 'input: loop { let total = bytes.len(); - let (clean, ascii) = if span_ok { - self.utf8.clean_span_ascii(bytes) + let clean = if span_ok { + self.utf8.clean_span_ascii(bytes).0 } else { - ((0, 0), false) + (0, 0) }; while !bytes.is_empty() { match self.apc { @@ -363,15 +471,12 @@ impl Scanner { if let Some(pos) = memchr::memchr(0x1B, bytes) { let at = total - bytes.len(); self.feed_ground(perform, &bytes[..pos], at, clean, true); - // Whatever sequence was open, this ESC ends it. - self.open_seq = None; // Don't feed the ESC to vte yet — peek the next byte. self.apc = ApcState::AfterEsc; bytes = &bytes[pos + 1..]; } else { let at = total - bytes.len(); self.feed_ground(perform, bytes, at, clean, false); - self.close_open_seq(bytes); return None; } } @@ -387,7 +492,7 @@ impl Scanner { continue; } after_esc::OSC => { - vte_advance(&mut self.vte, perform, &[0x1B, byte]); + self.feed_vte(perform, &[0x1B, byte]); self.apc = ApcState::Osc { len: 0 }; continue; } @@ -396,7 +501,7 @@ impl Scanner { // sequence in progress, exactly as the direct // byte stream would) and hold the `P` until we // know whether this is `ESC P tmux ;`. - vte_advance(&mut self.vte, perform, &[0x1B]); + self.feed_vte(perform, &[0x1B]); self.apc = ApcState::DcsIntro { matched: 0 }; continue; } @@ -405,7 +510,7 @@ impl Scanner { // vte had in progress; CAN then returns vte to // ground (the null performer swallows its // execute), and the scanner eats the body. - vte_advance(&mut self.vte, perform, &[0x1B]); + self.feed_vte(perform, &[0x1B]); self.vte.advance(&mut NullPerform, &[0x18]); self.apc = ApcState::SosPm; continue; @@ -414,7 +519,7 @@ impl Scanner { // Ordinary DCS: vte parses it, the scanner // tracks its body (0x9C, BEL, CAN / SUB, ESC; // #32, #36, #45, #54). - vte_advance(&mut self.vte, perform, &[0x1B, byte]); + self.feed_vte(perform, &[0x1B, byte]); self.apc = ApcState::Dcs; continue; } @@ -434,30 +539,19 @@ impl Scanner { after_esc::ESC_SEQ => false, _ => { // A complete escape: replay ESC + this byte. - vte_advance(&mut self.vte, perform, &[0x1B, byte]); + // vte dispatches it and is back in ground, + // unless the byte was a second ESC (a new + // escape) or DEL (ignored inside one). + self.feed_vte(perform, &[0x1B, byte]); + self.vte_ground = !matches!(byte, 0x1B | 0x7F); self.apc = ApcState::Ground; continue; } }; - vte_advance(&mut self.vte, perform, &[0x1B, byte]); - if ascii { - // Nothing here can abort it: vte alone parses it. - self.open_seq = Some(csi); - self.apc = ApcState::Ground; - continue; - } - // Usually the sequence ends (final byte, CAN, SUB - // or an ESC) before any byte >= 0x80: then vte - // alone parses it and the ground path takes the - // rest, as without this check. Otherwise (a - // non-ASCII byte or DEL first, or the input ends - // inside it) step through it. - if let Some(pos) = seq_end(bytes, csi) - && bytes[pos] < 0x7F - { - self.apc = ApcState::Ground; - continue; - } + self.feed_vte(perform, &[0x1B, byte]); + // Step through the sequence to its end, so the + // scanner knows when vte is back in ground (and + // catches a non-ASCII byte aborting it, WIRE-17). match self.step_seq(perform, bytes, csi, total, clean) { Step::Rest(rest) => bytes = rest, Step::Done => return None, @@ -498,9 +592,9 @@ impl Scanner { // Ordinary DCS: replay what we held back and // reprocess this byte as DCS body — it may itself // be an ESC. - vte_advance(&mut self.vte, perform, b"P"); + self.feed_vte(perform, b"P"); if matched > 0 { - vte_advance(&mut self.vte, perform, &TMUX_PREFIX[..matched]); + self.feed_vte(perform, &TMUX_PREFIX[..matched]); } self.apc = ApcState::Dcs; } @@ -508,7 +602,8 @@ impl Scanner { ApcState::DcsAfterEsc => { if bytes[0] == b'\\' { bytes = &bytes[1..]; - vte_advance(&mut self.vte, perform, b"\x1b\\"); + self.feed_vte(perform, b"\x1b\\"); + self.vte_ground = true; // ST: unhook, then ESC \ dispatched. self.apc = ApcState::Ground; } else { // Not ST (WIRE-16). End the DCS without acting @@ -517,7 +612,7 @@ impl Scanner { // and reprocess the ESC, with this byte, as a // fresh escape. perform.dcs_cancel(); - vte_advance(&mut self.vte, perform, &[0x1B]); + self.feed_vte(perform, &[0x1B]); self.vte.advance(&mut NullPerform, &[0x18]); self.apc = ApcState::AfterEsc; } @@ -567,24 +662,10 @@ impl Scanner { ApcState::OscDiscard => bytes = self.discard_osc(bytes), } } - self.close_open_seq(&[]); return None; } } - /// End of an input: if an ASCII-mode sequence (see - /// [`Scanner::open_seq`]) is still open after `last_run`, the next - /// input continues it, so track it from now on. - #[inline] - fn close_open_seq(&mut self, last_run: &[u8]) { - if let Some(csi) = self.open_seq.take() - && self.apc == ApcState::Ground - && seq_end(last_run, csi).is_none() - { - self.apc = ApcState::Seq { csi }; - } - } - /// Pass a CSI (`csi`) or unfinished escape sequence to vte up to the /// byte that ends it: its final byte, CAN/SUB (vte aborts), ESC (vte /// starts over), or a non-ASCII byte (kitty aborts, WIRE-17). A @@ -603,7 +684,7 @@ impl Scanner { let Some(pos) = seq_end(bytes, csi) else { if !bytes.is_empty() { self.note_csi_params(perform, bytes); - vte_advance(&mut self.vte, perform, bytes); + self.feed_vte(perform, bytes); } self.apc = ApcState::Seq { csi }; return Step::Done; @@ -612,14 +693,14 @@ impl Scanner { if b == 0x7F { // DEL: vte ignores it inside a sequence. Pass it, go on. self.note_csi_params(perform, &bytes[..=pos]); - vte_advance(&mut self.vte, perform, &bytes[..=pos]); + self.feed_vte(perform, &bytes[..=pos]); bytes = &bytes[pos + 1..]; continue; } if b == 0x1B { if pos > 0 { self.note_csi_params(perform, &bytes[..pos]); - vte_advance(&mut self.vte, perform, &bytes[..pos]); + self.feed_vte(perform, &bytes[..pos]); } self.apc = ApcState::AfterEsc; return Step::Rest(&bytes[pos + 1..]); @@ -627,22 +708,30 @@ impl Scanner { if b >= 0x80 { if pos > 0 { self.note_csi_params(perform, &bytes[..pos]); - vte_advance(&mut self.vte, perform, &bytes[..pos]); + self.feed_vte(perform, &bytes[..pos]); } self.abort_sequence(perform, false); self.apc = ApcState::Ground; return Step::Aborted(&bytes[pos + 1..]); } - // Ended normally (final byte, CAN or SUB). Hand vte the ground - // text up to the next ESC in the same call when it is - // known-clean, as the ground path would. + // Ended normally (final byte, CAN or SUB): vte dispatches it + // and returns to ground. Not quite always for an escape + // sequence: with no intermediate, `[` `]` `P` `X` `^` `_` + // open a CSI / string instead (possible after a C0 control, + // `ESC ^E [`), so those stay with vte. + self.note_csi_params(perform, &bytes[..=pos]); + self.feed_vte(perform, &bytes[..=pos]); + self.vte_ground = csi || !matches!(b, b'[' | b']' | b'P' | b'X' | b'^' | b'_'); + // The ground text up to the next ESC follows directly when it + // is known-clean, as the ground path would hand it on. let rest = &bytes[pos + 1..]; let at = total - rest.len(); let next_esc = memchr::memchr(0x1B, rest); let run = next_esc.unwrap_or(rest.len()); if clean.0 <= at && at + run <= clean.1 && !self.utf8.holds_partial() { - self.note_csi_params(perform, &bytes[..=pos + run]); - vte_advance(&mut self.vte, perform, &bytes[..=pos + run]); + if run > 0 { + self.clean_text(perform, &rest[..run]); + } let Some(e) = next_esc else { self.apc = ApcState::Ground; return Step::Done; @@ -650,8 +739,6 @@ impl Scanner { self.apc = ApcState::AfterEsc; return Step::Rest(&rest[e + 1..]); } - self.note_csi_params(perform, &bytes[..=pos]); - vte_advance(&mut self.vte, perform, &bytes[..=pos]); self.apc = ApcState::Ground; return Step::Rest(rest); } @@ -665,7 +752,7 @@ impl Scanner { fn abort_sequence(&mut self, perform: &mut P, with_esc: bool) { self.csi_digits = None; if with_esc { - vte_advance(&mut self.vte, perform, &[0x1B]); + self.feed_vte(perform, &[0x1B]); } self.vte.advance(&mut NullPerform, &[0x18]); } @@ -686,7 +773,7 @@ impl Scanner { 0x1B => self.apc = ApcState::AfterEsc, // CAN / SUB: executed, as vte does when they end a string. byte => { - vte_advance(&mut self.vte, perform, &[byte]); + self.feed_vte(perform, &[byte]); self.apc = ApcState::Ground; } } @@ -701,11 +788,11 @@ impl Scanner { bytes: &'b [u8], ) -> &'b [u8] { let Some(pos) = dcs_special(bytes) else { - vte_advance(&mut self.vte, perform, bytes); + self.feed_vte(perform, bytes); return &[]; }; if pos > 0 { - vte_advance(&mut self.vte, perform, &bytes[..pos]); + self.feed_vte(perform, &bytes[..pos]); } match bytes[pos] { // Data, not ST: drop it. @@ -714,7 +801,8 @@ impl Scanner { 0x1B => self.apc = ApcState::DcsAfterEsc, // BEL ends the DCS exactly like ST. 0x07 => { - vte_advance(&mut self.vte, perform, b"\x1b\\"); + self.feed_vte(perform, b"\x1b\\"); + self.vte_ground = true; self.apc = ApcState::Ground; } // CAN / SUB cancel the DCS (xterm, vte). vte unhooks it as @@ -722,7 +810,7 @@ impl Scanner { // it must not be acted on (WIRE-15). byte => { perform.dcs_cancel(); - vte_advance(&mut self.vte, perform, &[byte]); + self.feed_vte(perform, &[byte]); self.apc = ApcState::Ground; } } @@ -749,7 +837,7 @@ impl Scanner { return &bytes[body..]; } if body > 0 { - vte_advance(&mut self.vte, perform, &bytes[..body]); + self.feed_vte(perform, &bytes[..body]); } let Some(pos) = term else { self.apc = ApcState::Osc { len: len + body }; @@ -759,7 +847,9 @@ impl Scanner { // Possibly ST; let the ESC peek logic replay it. self.apc = ApcState::AfterEsc; } else { - vte_advance(&mut self.vte, perform, &bytes[pos..=pos]); + // BEL / CAN / SUB end the string and return vte to ground. + self.feed_vte(perform, &bytes[pos..=pos]); + self.vte_ground = true; self.apc = ApcState::Ground; } &bytes[pos + 1..] @@ -1781,5 +1871,6 @@ mod tests { assert_eq!(r.apc.len(), 1); } + mod bypass; mod tmux; } diff --git a/crates/toastty-parser/src/parser/tests/bypass.rs b/crates/toastty-parser/src/parser/tests/bypass.rs new file mode 100644 index 00000000..37c1ce83 --- /dev/null +++ b/crates/toastty-parser/src/parser/tests/bypass.rs @@ -0,0 +1,263 @@ +//! The ground-text bypass ([`dispatch_ground`]): with vte known to be +//! in ground, the scanner hands text to the handler itself, printable +//! ASCII in [`Perform::print_ascii`] runs. It must produce exactly the +//! events vte would, so these tests run every input both ways (the +//! `no_bypass` hook routes all ground text through vte) and compare. + +use super::*; +use proptest::prelude::*; + +/// Every event, in order. `print_ascii` runs are flattened into +/// `Print`s (that is the contract) but also checked to be printable +/// ASCII. +#[derive(Debug, Clone, PartialEq, Eq)] +enum Ev { + Print(char), + Exec(u8), + Csi(Vec>, Vec, bool, char), + Digits(u32), + Esc(Vec, bool, u8), + Hook(Vec>, Vec, bool, char), + Put(u8), + Unhook, + DcsCancel, + Osc(Vec>, bool), + ApcStart, + Apc(Vec), + ApcEnd, +} + +#[derive(Debug, Default)] +struct Log { + ev: Vec, + runs: usize, +} + +impl Log { + /// Adjacent APC chunks merged: their boundaries follow the reads. + fn normalized(self) -> Vec { + let mut out: Vec = Vec::new(); + for e in self.ev { + if let (Ev::Apc(b), Some(Ev::Apc(prev))) = (&e, out.last_mut()) { + prev.extend_from_slice(b); + continue; + } + out.push(e); + } + out + } +} + +fn ps(p: &Params) -> Vec> { + p.iter().map(<[u16]>::to_vec).collect() +} + +impl Perform for Log { + fn print(&mut self, c: char) { + self.ev.push(Ev::Print(c)); + } + fn print_ascii(&mut self, run: &[u8]) { + assert!(!run.is_empty(), "empty run"); + assert!( + run.iter().all(|b| (0x20..0x7F).contains(b)), + "non-printable byte in an ASCII run: {run:?}" + ); + self.runs += 1; + self.ev + .extend(run.iter().map(|&b| Ev::Print(char::from(b)))); + } + fn execute(&mut self, b: u8) { + self.ev.push(Ev::Exec(b)); + } + fn hook(&mut self, p: &Params, i: &[u8], ig: bool, a: char) { + self.ev.push(Ev::Hook(ps(p), i.to_vec(), ig, a)); + } + fn put(&mut self, b: u8) { + self.ev.push(Ev::Put(b)); + } + fn unhook(&mut self) { + self.ev.push(Ev::Unhook); + } + fn dcs_cancel(&mut self) { + self.ev.push(Ev::DcsCancel); + } + fn osc_dispatch(&mut self, p: &[&[u8]], bell: bool) { + self.ev + .push(Ev::Osc(p.iter().map(|s| s.to_vec()).collect(), bell)); + } + fn csi_dispatch(&mut self, p: &Params, i: &[u8], ig: bool, a: char) { + self.ev.push(Ev::Csi(ps(p), i.to_vec(), ig, a)); + } + fn csi_param_digits(&mut self, mask: u32) { + self.ev.push(Ev::Digits(mask)); + } + fn esc_dispatch(&mut self, i: &[u8], ig: bool, b: u8) { + self.ev.push(Ev::Esc(i.to_vec(), ig, b)); + } + fn apc_start(&mut self) { + self.ev.push(Ev::ApcStart); + } + fn apc_chunk(&mut self, b: &[u8]) { + self.ev.push(Ev::Apc(b.to_vec())); + } + fn apc_end(&mut self) { + self.ev.push(Ev::ApcEnd); + } +} + +/// Feed `chunks` through a parser with the bypass on or off. +fn run(chunks: &[&[u8]], bypass: bool) -> Log { + let mut p = Parser::new(); + p.scanner.no_bypass = !bypass; + let mut log = Log::default(); + for c in chunks { + p.advance(&mut log, c); + } + log +} + +/// Same events with and without the bypass; returns the bypass log's +/// `print_ascii` run count. +fn check(chunks: &[&[u8]]) -> usize { + let fast = run(chunks, true); + let runs = fast.runs; + let slow = run(chunks, false); + assert_eq!(slow.runs, 0, "no_bypass must route all text through vte"); + assert_eq!(fast.normalized(), slow.normalized(), "input: {chunks:?}"); + runs +} + +#[test] +fn plain_text_comes_in_runs() { + assert_eq!(check(&[b"hello world"]), 1); + // Controls split runs; DEL is printed, not a run byte. + assert_eq!(check(&[b"ab\r\ncd\x7fef\tg"]), 4); + // Non-ASCII (and an encoded C1 control) between runs. + assert_eq!(check(&["é世x\u{9b}y🎉z".as_bytes()]), 3); +} + +#[test] +fn text_after_each_kind_of_sequence_takes_the_bypass() { + // CSI, escape with intermediate, OSC by BEL and by ST, DCS by ST, + // APC: the text after each is a run of its own. + let input = b"a\x1b[1;31mb\x1b(Bc\x1b]0;t\x07d\x1b]0;t\x1b\\e\x1bPq#0\x1b\\f\x1b_G;x\x1b\\g"; + assert_eq!(check(&[input]), 7); +} + +#[test] +fn vte_keeps_text_it_might_not_be_in_ground_for() { + // `ESC ESC` leaves vte in an escape and DEL doesn't end one, so the + // next byte is vte's, not text; `ESC ^E [` starts a CSI after a C0 + // control inside the escape. + for input in [ + &b"\x1b\x1bAb"[..], + b"\x1b\x7fAb", + b"\x1b\x05[31mx", + b"\x1b\x05]0;t\x07x", + b"\x1b\x05Pq\x1b\\x", + b"\x1b\x05_x\x1b\\y", + b"\x1b\x05^x\x1b\\y", + b"\x1b(\x05[x", + ] { + check(&[input]); + } +} + +#[test] +fn sequences_split_across_reads() { + let input: &[u8] = b"ab\x1b[38;5;196mcd\x1b]8;;http://x\x1b\\ef\x1b(0gh\xe4\xb8\x96ij"; + for cut in 0..=input.len() { + let (a, b) = input.split_at(cut); + check(&[a, b]); + for cut2 in cut..=input.len() { + let (b1, b2) = input[cut..].split_at(cut2 - cut); + check(&[a, b1, b2]); + } + } +} + +/// Pieces that exercise every scanner and vte state transition, so a +/// random concatenation hits the bypass bookkeeping from all sides. +fn piece() -> impl Strategy> { + let fixed: Vec<&'static [u8]> = vec![ + b"\x1b", + b"[", + b"]", + b"P", + b"X", + b"^", + b"_", + b"\\", + b"(", + b" ", + b"#", + b"?", + b">", + b"$", + b";", + b":", + b"0", + b"1", + b"38", + b"5", + b"m", + b"H", + b"q", + b"A", + b"z", + b"~", + b"\x07", + b"\x18", + b"\x1a", + b"\x7f", + b"\x05", + b"\r", + b"\n", + b"\t", + b"\x9c", + b"\x9b", + b"\x80", + b"\xff", + b"\xc2", + b"\xe4\xb8", + b"\x96", + "é".as_bytes(), + "世".as_bytes(), + "🎉".as_bytes(), + "\u{9b}".as_bytes(), + b"tmux;", + b"hello", + b"\x1b[", + b"\x1b]0;", + b"\x1bP", + b"\x1b_", + b"\x1b\\", + ]; + prop_oneof![ + 4 => proptest::sample::select(fixed).prop_map(<[u8]>::to_vec), + 1 => proptest::collection::vec(any::(), 1..4), + 1 => "[ -~]{1,12}".prop_map(String::into_bytes), + ] +} + +proptest! { + #![proptest_config(ProptestConfig { cases: 2000, ..ProptestConfig::default() })] + + #[test] + fn bypass_matches_vte( + pieces in proptest::collection::vec(piece(), 0..40), + cuts in proptest::collection::vec(any::(), 0..4), + ) { + let input = pieces.concat(); + let mut at: Vec = cuts.iter().map(|i| i.index(input.len() + 1)).collect(); + at.sort_unstable(); + let mut chunks: Vec<&[u8]> = Vec::new(); + let mut prev = 0; + for c in at { + chunks.push(&input[prev..c]); + prev = c; + } + chunks.push(&input[prev..]); + check(&chunks); + } +} diff --git a/crates/toastty-parser/src/perform.rs b/crates/toastty-parser/src/perform.rs index e7e657d1..9a94362b 100644 --- a/crates/toastty-parser/src/perform.rs +++ b/crates/toastty-parser/src/perform.rs @@ -11,6 +11,17 @@ use vte::Params; /// in [`BufferingApcHandler`]. pub trait Perform { fn print(&mut self, _c: char) {} + + /// A run of printable ASCII (`0x20..=0x7E`) in ground state: the + /// same as one [`Self::print`] per byte, which is what the default + /// does. Handlers that can write a run in one go (the terminal grid) + /// override it; ground text reaches them in runs whenever the parser + /// can skip vte's per-byte state machine. + fn print_ascii(&mut self, run: &[u8]) { + for &b in run { + self.print(char::from(b)); + } + } fn execute(&mut self, _byte: u8) {} fn hook(&mut self, _params: &Params, _intermediates: &[u8], _ignore: bool, _action: char) {} fn put(&mut self, _byte: u8) {} @@ -107,6 +118,9 @@ impl Perform for BufferingApcHandler { fn print(&mut self, c: char) { self.inner.print(c); } + fn print_ascii(&mut self, run: &[u8]) { + self.inner.print_ascii(run); + } fn execute(&mut self, byte: u8) { self.inner.execute(byte); } From 40c8f8025f0c1b02285873c8fb95efb2bdaec826 Mon Sep 17 00:00:00 2001 From: EDToaster Date: Thu, 22 Oct 2026 02:02:11 -0700 Subject: [PATCH 05/11] term: print printable-ASCII runs a row segment at a time Term::print_ascii takes the parser's runs. Within a segment that fits before the right edge every byte behaves identically under print_char (width 1, no placeholder or grapheme handling, no pending wrap after the first, wide-cluster orphans only possible at the two ends), so the row is looked up once, the cells written in one loop (Row::write_ascii) and the damage marked as one span (RowDamage::mark_cols, the same representation as per-column marks). Wrap, IRM and DECAWM-off at the edge still go through print_char a byte at a time. A proptest compares the fast path against one print per character (cells, wrap flags, scrollback, cursor, damage, and the pending-wrap state via a probe print) over random text, wide chars, margins, IRM, DECAWM and alt-screen input. --- crates/toastty-term/src/damage.rs | 26 ++- crates/toastty-term/src/grid.rs | 23 ++ crates/toastty-term/src/term.rs | 79 +++++++ .../print_fast_path.proptest-regressions | 9 + crates/toastty-term/tests/print_fast_path.rs | 211 ++++++++++++++++++ 5 files changed, 340 insertions(+), 8 deletions(-) create mode 100644 crates/toastty-term/tests/print_fast_path.proptest-regressions create mode 100644 crates/toastty-term/tests/print_fast_path.rs diff --git a/crates/toastty-term/src/damage.rs b/crates/toastty-term/src/damage.rs index fb2cc33b..40c15bb9 100644 --- a/crates/toastty-term/src/damage.rs +++ b/crates/toastty-term/src/damage.rs @@ -78,6 +78,23 @@ impl RowDamage { } } + /// Mark each column in `[start, end)`: the same set (and the same + /// representation, no `all_cols` promotion) as one [`Self::mark`] + /// per column, for a run of printed cells. + #[inline] + pub fn mark_cols(&mut self, start: u16, end: u16) { + if self.all_cols || start >= end { + return; + } + if self.cols.last().is_none_or(|&last| start > last) { + self.cols.extend(start..end); + return; + } + for c in start..end { + self.mark(c); + } + } + /// Mark the half-open range `[start, end)` dirty, clamped to /// `cols_in_row`. If the range covers the entire row, flips the /// `all_cols` shortcut (saving a per-column insert burst). @@ -95,14 +112,7 @@ impl RowDamage { self.mark_all(); return; } - if self.cols.last().is_none_or(|&last| start > last) { - // Past everything marked so far (a print run): append. - self.cols.extend(start..end); - return; - } - for c in start..end { - self.mark(c); - } + self.mark_cols(start, end); } /// Promote this row to "everything dirty". Drops the per-column list. diff --git a/crates/toastty-term/src/grid.rs b/crates/toastty-term/src/grid.rs index 21cbba5c..9ed984ed 100644 --- a/crates/toastty-term/src/grid.rs +++ b/crates/toastty-term/src/grid.rs @@ -140,6 +140,29 @@ impl Row { } } + /// Write one cell per byte of `run` (printable ASCII) at + /// `col..col + run.len()`, each `template` with that character: the + /// same as one [`Row::put`] per byte. The caller keeps the run inside + /// `max_cols`. + #[inline] + pub(crate) fn write_ascii(&mut self, col: u16, run: &[u8], template: Cell, max_cols: u16) { + self.rich = None; + let start = col as usize; + let end = (start + run.len()).min(max_cols as usize); + if end > self.cells.len() { + self.cells.resize(end, Cell::BLANK); + } + for (slot, &b) in self.cells[start..end].iter_mut().zip(run) { + *slot = Cell { + ch: char::from(b), + ..template + }; + } + #[allow(clippy::cast_possible_truncation)] // end <= max_cols + let end = end as u16; + self.used = self.used.max(end); + } + /// Erase cells in `[start, end)` (open at end) — used by EL handlers. pub fn erase(&mut self, start: u16, end: u16, style: Style) { self.rich = None; diff --git a/crates/toastty-term/src/term.rs b/crates/toastty-term/src/term.rs index a429f5df..eb63dd5f 100644 --- a/crates/toastty-term/src/term.rs +++ b/crates/toastty-term/src/term.rs @@ -2583,6 +2583,81 @@ impl Term { } } + /// Print a run of printable ASCII (`0x20..=0x7E`): exactly + /// [`Self::print_char`] per byte, a row segment at a time. Within a + /// segment that fits before the right edge, nothing per-character can + /// differ: every byte is one cell wide, never a placeholder or a + /// grapheme extension, no wrap is pending after the first, and a + /// wide cluster can only be orphaned at the segment's two ends. So + /// the row is looked up once, the cells are written in one loop and + /// the damage marked as one span. Wrapping, IRM and DECAWM-off at the + /// edge go through `print_char` one byte at a time. + fn print_ascii(&mut self, mut run: &[u8]) { + while let Some(&first) = run.first() { + let right_edge = self.print_right_edge(); + let (row, col) = (self.cursor.row, self.cursor.col); + if self.vt.insert_mode || col >= right_edge || self.wrap_pending() { + self.print_char(char::from(first)); + run = &run[1..]; + continue; + } + let n = run.len().min(usize::from(right_edge - col)); + let (seg, rest) = run.split_at(n); + run = rest; + #[allow(clippy::cast_possible_truncation)] // n <= right_edge - col + let end = col + n as u16; + self.vt.wrap_pending_at = None; + let style = self.cursor.style; + let template = Cell { + ch: ' ', + style, + is_continuation: false, + hyperlink_id: self.current_hyperlink, + extra: None, + }; + let max_cols = self.cols; + let r = self.active_grid_mut().row_mut(row); + // `clear_wide_orphans` for the whole segment. + let cells = r.cells(); + let left_orphan = col > 0 + && cells + .get(usize::from(col)) + .is_some_and(|c| c.is_continuation); + let right_orphan = cells + .get(usize::from(end)) + .is_some_and(|c| c.is_continuation); + let blank = Cell { + hyperlink_id: None, + ..template + }; + if left_orphan { + r.put(col - 1, blank, max_cols); + } + if right_orphan { + r.put(end, blank, max_cols); + } + r.write_ascii(col, seg, template, max_cols); + if let Some(d) = self.damage.rows.get_mut(usize::from(row)) { + if left_orphan { + d.mark(col - 1); + } + d.mark_cols(col, end); + if right_orphan { + d.mark(end); + } + } + self.vt.last_graphic = Some(char::from(seg[n - 1])); + if end >= right_edge && (right_edge < self.cols || !self.vt.autowrap) { + // As in `print_char`: park on the last column and defer + // the wrap (or remember the cell, DECAWM off). + self.cursor.col = right_edge - 1; + self.vt.wrap_pending_at = Some((row, self.cursor.col)); + } else { + self.cursor.col = end; + } + } + } + /// Record that an HT advanced the cursor over `[from, to)` on `row`, so /// copy can reproduce the literal tab (bug #20; kitty and Ghostty do /// the same, xterm copies spaces). Only done when every cell in the @@ -4269,6 +4344,10 @@ impl Perform for Term { self.print_char(c); } + fn print_ascii(&mut self, run: &[u8]) { + Term::print_ascii(self, run); + } + fn execute(&mut self, byte: u8) { match byte { b'\r' => { diff --git a/crates/toastty-term/tests/print_fast_path.proptest-regressions b/crates/toastty-term/tests/print_fast_path.proptest-regressions new file mode 100644 index 00000000..8386b2f8 --- /dev/null +++ b/crates/toastty-term/tests/print_fast_path.proptest-regressions @@ -0,0 +1,9 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc e6ea74ce4c00d64b8dfab65a0d8166c8e1f14e9b85788c56cceb00f557718f8b # shrinks to pieces = [[10], [48, 97, 48, 97, 32, 97, 32, 97, 48, 65, 97, 65, 97, 65, 97, 65, 48, 32, 48, 32], [48, 32, 65, 65, 97, 65, 48, 48, 48, 32, 32, 48, 65, 97, 65, 97, 32, 48, 65, 97], [32, 97, 65, 32, 32, 97, 97, 65, 65, 32, 32, 65, 48, 32, 97, 97, 65, 48, 97, 65, 32, 97, 32, 97], [65, 32, 48, 97, 65, 32, 65, 32, 97, 32, 65, 48, 32, 65, 97, 32, 65, 32], [97, 32, 97, 32, 97, 97, 32, 65, 65, 97, 32, 32, 32, 32, 32, 32, 32, 32, 65, 32, 65, 32, 65, 48, 97, 97], [97, 32, 65, 32, 97, 97, 48, 97, 97, 32, 65, 48, 97, 65], [97, 65, 32, 32, 65, 32, 65, 48, 65, 65, 48, 97, 65, 65, 32, 65, 48, 97, 65, 32, 97, 65, 97], [27, 91, 63, 49, 48, 52, 57, 104]], cut = Index(0) +cc c28aaa43ab4291ae306911add08148acce1d8a54d6dbbfff5e93c0042f2df303 # shrinks to pieces = [[27, 91, 49, 75], [65, 97, 32, 48, 48, 65, 65, 65, 97, 97, 32, 65, 97, 97, 32, 32, 97, 48, 97, 65, 65, 32, 32, 65, 97, 97, 48], [97, 48, 97, 97, 65], [27, 91, 63, 55, 104], [228, 184, 150], [27, 91, 63, 55, 108], [48], [27, 91, 63, 55, 104], [27, 91, 52, 108], [97, 65, 97, 32, 97, 32, 65, 48, 48, 97, 65, 32, 65, 97, 97, 48, 65, 32, 65, 97, 65, 65, 32], [97, 48, 48], [27, 91, 63, 54, 57, 104, 27, 91, 51, 59, 57, 115], [32, 32, 32, 65, 32, 97, 32, 32, 32, 65, 65, 32, 65, 65, 97], [27, 91, 50, 64]], cut = Index(0) +cc fee03599e8c0fb1d53e58cc286ba72ad76085b47a613b6d6e2ce657ca2577efd # shrinks to pieces = [[27, 91, 52, 104]], cut = Index(0) diff --git a/crates/toastty-term/tests/print_fast_path.rs b/crates/toastty-term/tests/print_fast_path.rs new file mode 100644 index 00000000..4d509f6a --- /dev/null +++ b/crates/toastty-term/tests/print_fast_path.rs @@ -0,0 +1,211 @@ +//! The printable-ASCII fast path (`Term::print_ascii`, fed runs by the +//! parser) must leave the terminal exactly as one `print` per character +//! does: same cells, wrap flags, cursor, pending-wrap state, scrollback +//! and damage. Each input is run both ways and the results compared. + +use proptest::prelude::*; +use toastty_parser::{Params, Parser, Perform}; +use toastty_term::Term; + +/// Forwards everything to the terminal except `print_ascii`, which +/// keeps the trait's per-character default. +struct PerChar<'a>(&'a mut Term); + +impl Perform for PerChar<'_> { + fn print(&mut self, c: char) { + self.0.print(c); + } + fn execute(&mut self, b: u8) { + self.0.execute(b); + } + fn hook(&mut self, p: &Params, i: &[u8], ig: bool, a: char) { + self.0.hook(p, i, ig, a); + } + fn put(&mut self, b: u8) { + self.0.put(b); + } + fn unhook(&mut self) { + self.0.unhook(); + } + fn osc_dispatch(&mut self, p: &[&[u8]], bell: bool) { + self.0.osc_dispatch(p, bell); + } + fn csi_dispatch(&mut self, p: &Params, i: &[u8], ig: bool, a: char) { + self.0.csi_dispatch(p, i, ig, a); + } + fn esc_dispatch(&mut self, i: &[u8], ig: bool, b: u8) { + self.0.esc_dispatch(i, ig, b); + } + fn dcs_cancel(&mut self) { + self.0.dcs_cancel(); + } + fn csi_param_digits(&mut self, mask: u32) { + self.0.csi_param_digits(mask); + } + fn apc_start(&mut self) { + self.0.apc_start(); + } + fn apc_chunk(&mut self, b: &[u8]) { + self.0.apc_chunk(b); + } + fn apc_end(&mut self) { + self.0.apc_end(); + } +} + +const ROWS: u16 = 5; +const COLS: u16 = 12; + +fn feed(chunks: &[&[u8]], fast: bool) -> Term { + let mut t = Term::new(ROWS, COLS, 8); + let mut p = Parser::new(); + for c in chunks { + if fast { + p.advance(&mut t, c); + } else { + p.advance(&mut PerChar(&mut t), c); + } + } + t +} + +/// Everything observable about the grid state, as comparable values. +fn state(t: &Term) -> String { + use std::fmt::Write; + let mut s = String::new(); + // Every retained primary row (screen and scrollback), by line id; + // the inputs are far too short to scroll ids past this. + for id in 0..4096 { + if let Some(row) = t.primary_row_by_line_id(id) { + writeln!( + s, + "{id}: wrap={} rich={:?} {:?}", + row.soft_wrap, + row.rich, + row.cells() + ) + .unwrap(); + } + } + if t.is_alt_active() { + for r in 0..ROWS { + let row = t.row(r); + writeln!(s, "alt {r}: wrap={} {:?}", row.soft_wrap, row.cells()).unwrap(); + } + } + writeln!(s, "cursor {:?}", t.cursor()).unwrap(); + writeln!(s, "damage {:?}", t.damage()).unwrap(); + s +} + +fn check(chunks: &[&[u8]]) { + let mut fast = feed(chunks, true); + let mut slow = feed(chunks, false); + assert_eq!(state(&fast), state(&slow), "input: {chunks:?}"); + // Pending-wrap state isn't directly visible: one more character + // (and a CR + run) after the input must land the same way too. + let mut p = Parser::new(); + p.advance(&mut fast, b"Z\rab"); + let mut p = Parser::new(); + p.advance(&mut PerChar(&mut slow), b"Z\rab"); + assert_eq!(state(&fast), state(&slow), "input + probe: {chunks:?}"); +} + +#[test] +fn runs_wrap_scroll_and_park_like_single_prints() { + check(&[b"hello"]); + check(&[b"abcdefghijklmnopqrstuvwxyz0123456789"]); + check(&[b"abcdefghijkl\r\nmno"]); + for _ in 0..3 { + check(&[b"0123456789AB0123456789AB0123456789AB\n\n\n\n\nxyz"]); + } + // DECAWM off: the run overwrites the last column. + check(&[b"\x1b[?7labcdefghijklmnopq\x1b[?7hrs"]); + // IRM. + check(&[b"abcdef\r\x1b[4hXY\x1b[4lZ"]); + // DECSLRM margins, inside and outside. + check(&[b"\x1b[?69h\x1b[3;8sabcdefghijk\x1b[1;10Hqrstuv\x1b[?69l"]); +} + +#[test] +fn runs_clear_wide_cluster_halves_at_both_ends() { + // Overwrite starting on a continuation, and ending just before one. + check(&["世界世界世界".as_bytes(), b"\r\x1b[2Cab"]); + check(&["世界世界世界".as_bytes(), b"\rabc"]); + check(&["ab世界".as_bytes(), b"\x1b[1;4Hxy"]); +} + +#[test] +fn runs_carry_style_and_hyperlinks() { + check(&[b"\x1b[1;31;44mred\x1b[0m \x1b]8;;http://x\x07link\x1b]8;;\x07 done"]); +} + +fn piece() -> impl Strategy> { + let fixed: Vec<&'static [u8]> = vec![ + b"\r", + b"\n", + b"\r\n", + b"\x08", + b"\t", + b"\x7f", + b"\x1b[?7l", + b"\x1b[?7h", + b"\x1b[4h", + b"\x1b[4l", + b"\x1b[?69h\x1b[3;9s", + b"\x1b[?69l", + b"\x1b[2;4r", + b"\x1b[r", + b"\x1b[H", + b"\x1b[3;11H", + b"\x1b[1;12H", + b"\x1b[5C", + b"\x1b[2D", + b"\x1b[K", + b"\x1b[1K", + b"\x1b[2J", + b"\x1b[2@", + b"\x1b[2P", + b"\x1b[3X", + b"\x1b[31;1m", + b"\x1b[44m", + b"\x1b[0m", + b"\x1b]8;;http://x\x07", + b"\x1b]8;;\x07", + b"\x1b[?1049h", + b"\x1b[?1049l", + b"\x1bM", + b"\x1bD", + b"\x1b7", + b"\x1b8", + b"\x1b[?2027h", + b"\x1b[?2027l", + b"\x1b[b", + "世".as_bytes(), + "界".as_bytes(), + "🎉".as_bytes(), + "é".as_bytes(), + "e\u{301}".as_bytes(), + "\u{200d}".as_bytes(), + "❤\u{fe0f}".as_bytes(), + "\u{10EEEE}\u{305}".as_bytes(), + ]; + prop_oneof![ + 3 => proptest::sample::select(fixed).prop_map(<[u8]>::to_vec), + 3 => "[ -~]{1,30}".prop_map(String::into_bytes), + ] +} + +proptest! { + #![proptest_config(ProptestConfig { cases: 1500, ..ProptestConfig::default() })] + + #[test] + fn fast_path_matches_per_char_prints( + pieces in proptest::collection::vec(piece(), 0..30), + cut in any::(), + ) { + let input = pieces.concat(); + let (a, b) = input.split_at(cut.index(input.len() + 1)); + check(&[a, b]); + } +} From 11c2846fc728f492a557a6a70ff513a1f277c02a Mon Sep 17 00:00:00 2001 From: EDToaster Date: Thu, 22 Oct 2026 02:07:12 -0700 Subject: [PATCH 06/11] parser: parse everyday CSI sequences without vte vte's per-byte CSI state machine was most of the time on SGR-heavy output (42 % of the dense workload). With vte in ground, the scanner now parses a CSI of the usual shape itself (private marker, params with ; and :, up to two intermediates, final byte) and dispatches it, matching vte 0.15 byte for byte, including the empty trailing parameter it always pushes and the csi_param_digits report. Anything vte treats specially (C0 / DEL / CAN / SUB / ESC / non-ASCII inside, misplaced private markers or digits, more than 32 params or 2 intermediates, or the input ending mid-sequence) dispatches nothing and goes to vte as before. vte::Params can only be built by vte, so toastty_parser::Params is now our own type with the same API; parameters vte parses (DCS hooks, CSIs left to it) are copied into it. Tests: differential proptests (fast path vs vte, via the no_bypass hook) over CSI bodies built from every byte class vte distinguishes, plus the parameter-cap and saturation edges. --- .../parser/tests/bypass.txt | 2 + crates/toastty-parser/src/lib.rs | 3 +- crates/toastty-parser/src/params.rs | 190 ++++++++++++++++++ crates/toastty-parser/src/parser.rs | 112 ++++++++++- .../toastty-parser/src/parser/tests/bypass.rs | 63 ++++++ crates/toastty-parser/src/perform.rs | 17 +- crates/toastty-parser/tests/utf8_props.rs | 8 +- 7 files changed, 386 insertions(+), 9 deletions(-) create mode 100644 crates/toastty-parser/src/params.rs diff --git a/crates/toastty-parser/proptest-regressions/parser/tests/bypass.txt b/crates/toastty-parser/proptest-regressions/parser/tests/bypass.txt index ba9c81f8..b214c97f 100644 --- a/crates/toastty-parser/proptest-regressions/parser/tests/bypass.txt +++ b/crates/toastty-parser/proptest-regressions/parser/tests/bypass.txt @@ -5,3 +5,5 @@ # It is recommended to check this file in to source control so that # everyone who runs the test benefits from these saved cases. cc d7c2c900361c770adb01f29dd30463c56852ba274dfd24ec7bc8bba0848be869 # shrinks to pieces = [[27], [27, 91]], cuts = [] +cc 7b1039b459b44128e9ff6206466ad51a8086c7666f6c41a1a482479bd164badd # shrinks to pieces = [[91], [27], [92], [91], [27, 91], [48, 60], [109], [27], [97, 65, 97, 97, 48, 65, 32], [27], [27]], cuts = [] +cc d4875c274956771751ce745870ff0c04be1a4c3ba5591e0a84d5c033176a309b # shrinks to seqs = [([], " "), ([48, 48, 48], "")], cut = Index(8384883669867978008) diff --git a/crates/toastty-parser/src/lib.rs b/crates/toastty-parser/src/lib.rs index 9e05efdf..2b9af49b 100644 --- a/crates/toastty-parser/src/lib.rs +++ b/crates/toastty-parser/src/lib.rs @@ -17,10 +17,11 @@ #![forbid(unsafe_code)] +mod params; mod parser; mod perform; mod utf8; +pub use params::{Params, ParamsIter}; pub use parser::{MAX_OSC_LEN, MAX_PASSTHROUGH_LEN, Parser}; pub use perform::{BufferingApcHandler, Perform}; -pub use vte::Params; diff --git a/crates/toastty-parser/src/params.rs b/crates/toastty-parser/src/params.rs new file mode 100644 index 00000000..b8a5ac2c --- /dev/null +++ b/crates/toastty-parser/src/params.rs @@ -0,0 +1,190 @@ +//! CSI / DCS parameter list, the same shape and API as `vte::Params`. +//! +//! The scanner parses common CSI sequences itself (see +//! `Scanner::fast_csi`): vte's per-byte state machine was most of the +//! time spent on SGR-heavy output. `vte::Params` can only be built by +//! vte, so handlers get this type instead, and parameters vte parsed (DCS +//! hooks, CSIs the fast path leaves to vte) are copied into it. + +use core::fmt::{self, Debug, Formatter}; + +/// Most parameters plus subparameters one sequence carries, as in vte. +pub(crate) const MAX_PARAMS: usize = 32; + +/// Parameters with optional subparameters (`CSI 38:2::1:2:3 m`). +/// [`Params::iter`] yields one slice per parameter: the value, then its +/// colon-separated subparameters. +#[derive(Default, Clone, PartialEq, Eq)] +pub struct Params { + /// For the first entry of each parameter, how many entries it spans + /// (1 + subparameters); 0 at subparameter positions. + subparams: [u8; MAX_PARAMS], + /// All parameters and subparameters. + values: [u16; MAX_PARAMS], + /// Subparameters in the current parameter so far. + current_subparams: u8, + /// Total number of parameters and subparameters. + len: usize, +} + +impl Params { + /// Number of parameters and subparameters. + #[inline] + #[must_use] + pub fn len(&self) -> usize { + self.len + } + + /// True if there are none. + #[inline] + #[must_use] + pub fn is_empty(&self) -> bool { + self.len == 0 + } + + /// Iterate the parameters, each with its subparameters. + #[inline] + #[must_use] + pub fn iter(&self) -> ParamsIter<'_> { + ParamsIter { + params: self, + index: 0, + } + } + + /// True if no more entries fit. + #[inline] + pub(crate) fn is_full(&self) -> bool { + self.len == MAX_PARAMS + } + + /// Add `item` as the last value of the current parameter (a `;` or + /// the final byte follows it). Caller checks [`Self::is_full`]. + #[inline] + pub(crate) fn push(&mut self, item: u16) { + self.subparams[self.len - self.current_subparams as usize] = self.current_subparams + 1; + self.values[self.len] = item; + self.current_subparams = 0; + self.len += 1; + } + + /// Add `item` to the current parameter with more to come (a `:` + /// follows it). Caller checks [`Self::is_full`]. + #[inline] + pub(crate) fn extend(&mut self, item: u16) { + self.subparams[self.len - self.current_subparams as usize] = self.current_subparams + 1; + self.values[self.len] = item; + self.current_subparams += 1; + self.len += 1; + } + + /// Copy parameters vte parsed. + pub(crate) fn from_vte(p: &vte::Params) -> Self { + let mut out = Self::default(); + for param in p { + if let Some((&last, head)) = param.split_last() { + for &v in head { + out.extend(v); + } + out.push(last); + } + } + out + } +} + +impl<'a> IntoIterator for &'a Params { + type IntoIter = ParamsIter<'a>; + type Item = &'a [u16]; + + fn into_iter(self) -> Self::IntoIter { + self.iter() + } +} + +/// Iterator over [`Params`]: one slice per parameter. +#[derive(Debug)] +pub struct ParamsIter<'a> { + params: &'a Params, + index: usize, +} + +impl<'a> Iterator for ParamsIter<'a> { + type Item = &'a [u16]; + + #[inline] + fn next(&mut self) -> Option { + if self.index >= self.params.len { + return None; + } + let n = self.params.subparams[self.index] as usize; + let param = &self.params.values[self.index..self.index + n]; + self.index += n; + Some(param) + } + + fn size_hint(&self) -> (usize, Option) { + let remaining = self.params.len - self.index; + (remaining, Some(remaining)) + } +} + +impl Debug for Params { + /// `[1;38:5:2]`, as vte prints it. + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "[")?; + for (i, param) in self.iter().enumerate() { + if i != 0 { + write!(f, ";")?; + } + for (j, sub) in param.iter().enumerate() { + if j != 0 { + write!(f, ":")?; + } + sub.fmt(f)?; + } + } + write!(f, "]") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn collect(p: &Params) -> Vec> { + p.iter().map(<[u16]>::to_vec).collect() + } + + #[test] + fn push_and_extend_group_subparams() { + let mut p = Params::default(); + assert!(p.is_empty()); + // `1;38:2:10;0`, fed the way vte's actions do. + p.push(1); + p.extend(38); + p.extend(2); + p.push(10); + p.push(0); + assert_eq!(collect(&p), vec![vec![1], vec![38, 2, 10], vec![0]]); + assert_eq!(p.len(), 5); + assert_eq!(format!("{p:?}"), "[1;38:2:10;0]"); + } + + #[test] + fn copies_vte_params() { + struct Grab(Option); + impl vte::Perform for Grab { + fn csi_dispatch(&mut self, p: &vte::Params, _: &[u8], _: bool, _: char) { + self.0 = Some(Params::from_vte(p)); + } + } + let mut g = Grab(None); + vte::Parser::new().advance(&mut g, b"\x1b[4:3;;38:2::1:2:3m"); + let p = g.0.unwrap(); + assert_eq!( + collect(&p), + vec![vec![4, 3], vec![0], vec![38, 2, 0, 1, 2, 3]] + ); + } +} diff --git a/crates/toastty-parser/src/parser.rs b/crates/toastty-parser/src/parser.rs index 85b9c03c..a2060387 100644 --- a/crates/toastty-parser/src/parser.rs +++ b/crates/toastty-parser/src/parser.rs @@ -1,3 +1,4 @@ +use crate::Params; use crate::perform::{Perform, VteAdapter}; use crate::utf8::Utf8Sanitizer; @@ -277,6 +278,93 @@ fn dispatch_ground( true } +/// Parse and dispatch a whole CSI sequence (`bytes` follows the `ESC [`) +/// without vte, when it has the everyday shape: an optional private +/// marker, parameters (digits, `;`, `:`), up to two intermediates and a +/// final byte, with nothing vte would treat specially (C0 controls, DEL, +/// CAN / SUB / ESC, bytes >= 0x80, misplaced `<=>?` or digits, too many +/// parameters or intermediates). Returns the bytes consumed through the +/// final byte, or `None` with nothing dispatched, leaving the sequence +/// to vte. Matches vte 0.15's CSI states byte for byte on the shapes it +/// accepts (including the trailing empty parameter vte always pushes), +/// and reports [`Perform::csi_param_digits`] first, as the scanner does. +/// vte must be in ground: the sequence then takes it from ground back +/// to ground, so skipping it changes nothing in vte. +#[inline] +fn fast_csi(perform: &mut P, bytes: &[u8]) -> Option { + /// Where in the sequence we are: vte's `CsiEntry`, `CsiParam` and + /// `CsiIntermediate` states. + #[derive(PartialEq, Eq)] + enum At { + Entry, + Param, + Intermediate, + } + let mut params = Params::default(); + let mut param: u16 = 0; + let mut inter = [0u8; 2]; + let mut n_inter = 0; + let mut at = At::Entry; + // `csi_param_digits`: bit per parameter that held a digit. + let (mut digits, mut idx) = (0u32, 0u32); + for (i, &b) in bytes.iter().enumerate() { + match b { + b'0'..=b'9' if at != At::Intermediate => { + if params.is_full() { + return None; + } + param = param.saturating_mul(10).saturating_add(u16::from(b - b'0')); + if idx < 32 { + digits |= 1 << idx; + } + at = At::Param; + } + b':' if at != At::Intermediate => { + if params.is_full() { + return None; + } + params.extend(param); + param = 0; + at = At::Param; + } + b';' if at != At::Intermediate => { + if params.is_full() { + return None; + } + params.push(param); + param = 0; + idx = idx.saturating_add(1); + at = At::Param; + } + // Private marker: only as the first byte. + 0x3C..=0x3F if at == At::Entry => { + inter[0] = b; + n_inter = 1; + at = At::Param; + } + 0x20..=0x2F => { + if n_inter == inter.len() { + return None; + } + inter[n_inter] = b; + n_inter += 1; + at = At::Intermediate; + } + 0x40..=0x7E => { + if params.is_full() { + return None; + } + params.push(param); + perform.csi_param_digits(digits); + perform.csi_dispatch(¶ms, &inter[..n_inter], false, char::from(b)); + return Some(i + 1); + } + _ => return None, + } + } + None +} + /// The APC / OSC-cap pre-scanner plus vte: everything the parser does /// except tmux passthrough unwrapping, which [`Parser`] layers on top /// so unwrapped payloads can be fed back through this same path. @@ -327,6 +415,16 @@ impl Scanner { vte_advance(&mut self.vte, perform, bytes); } + /// vte is in ground and the bypasses may be used. + #[inline] + fn bypass_ok(&self) -> bool { + #[cfg(test)] + if self.no_bypass { + return false; + } + self.vte_ground + } + /// Ground text known to be clean (see [`ground_text`]). #[inline] fn clean_text(&mut self, perform: &mut P, text: &[u8]) { @@ -535,7 +633,19 @@ impl Scanner { span_ok = false; // No longer on a character boundary. continue 'input; } - after_esc::CSI => true, + after_esc::CSI => { + if self.bypass_ok() + && let Some(n) = fast_csi(perform, bytes) + { + // Dispatched; vte never saw it and is + // still in ground. + self.csi_digits = None; + self.apc = ApcState::Ground; + bytes = &bytes[n..]; + continue; + } + true + } after_esc::ESC_SEQ => false, _ => { // A complete escape: replay ESC + this byte. diff --git a/crates/toastty-parser/src/parser/tests/bypass.rs b/crates/toastty-parser/src/parser/tests/bypass.rs index 37c1ce83..d99fa781 100644 --- a/crates/toastty-parser/src/parser/tests/bypass.rs +++ b/crates/toastty-parser/src/parser/tests/bypass.rs @@ -261,3 +261,66 @@ proptest! { check(&chunks); } } + +#[test] +fn fast_csi_matches_vte_on_edge_shapes() { + let mut long = b"\x1b[".to_vec(); + for i in 0..40 { + long.extend_from_slice(format!("{i};").as_bytes()); + } + long.push(b'm'); + let mut subs = b"\x1b[38".to_vec(); + for _ in 0..40 { + subs.extend_from_slice(b":1"); + } + subs.push(b'm'); + for input in [ + &b"\x1b[m\x1b[;m\x1b[0;;1m\x1b[38:2::10:20:30m"[..], + b"\x1b[?1049h\x1b[>4;2m\x1b[=5u\x1b[<1;2;3M", + b"\x1b[2 q\x1b[1$p\x1b[?1$p\x1b[!p\x1b[1 !p\x1b[1 !#p", + b"\x1b[99999999m\x1b[65535;65536H", + b"\x1b[1?m\x1b[1 2m\x1b[??1h\x1b[1;2 impl Strategy> { + let byte = prop_oneof![ + 6 => proptest::sample::select(b"0123456789".to_vec()), + 3 => Just(b';'), + 1 => Just(b':'), + 1 => proptest::sample::select(b"<=>?".to_vec()), + 1 => proptest::sample::select(b" !\"#$%&'()*+,-./".to_vec()), + 2 => proptest::sample::select(b"@ABHJKmhlpqrsu~`".to_vec()), + 1 => proptest::sample::select(vec![0x05, 0x07, 0x0A, 0x18, 0x1A, 0x1B, 0x7F, 0x80, 0xC3, 0xFF]), + ]; + proptest::collection::vec(byte, 0..40) +} + +proptest! { + #![proptest_config(ProptestConfig { cases: 3000, ..ProptestConfig::default() })] + + #[test] + fn fast_csi_matches_vte( + seqs in proptest::collection::vec((csi_body(), "[ -~]{0,4}"), 1..8), + cut in any::(), + ) { + let mut input = Vec::new(); + for (body, text) in &seqs { + input.extend_from_slice(b"\x1b["); + input.extend_from_slice(body); + input.extend_from_slice(text.as_bytes()); + } + let (a, b) = input.split_at(cut.index(input.len() + 1)); + check(&[a, b]); + } +} diff --git a/crates/toastty-parser/src/perform.rs b/crates/toastty-parser/src/perform.rs index 9a94362b..eb0d88f9 100644 --- a/crates/toastty-parser/src/perform.rs +++ b/crates/toastty-parser/src/perform.rs @@ -1,4 +1,4 @@ -use vte::Params; +use crate::Params; /// Handler trait. Implementors receive every parsed event from a /// [`crate::Parser`]. All methods default to no-ops; override the ones @@ -176,8 +176,9 @@ impl vte::Perform for VteAdapter<'_, P> { fn execute(&mut self, byte: u8) { self.inner.execute(byte); } - fn hook(&mut self, params: &Params, intermediates: &[u8], ignore: bool, action: char) { - self.inner.hook(params, intermediates, ignore, action); + fn hook(&mut self, params: &vte::Params, intermediates: &[u8], ignore: bool, action: char) { + self.inner + .hook(&Params::from_vte(params), intermediates, ignore, action); } fn put(&mut self, byte: u8) { self.inner.put(byte); @@ -188,9 +189,15 @@ impl vte::Perform for VteAdapter<'_, P> { fn osc_dispatch(&mut self, params: &[&[u8]], bell_terminated: bool) { self.inner.osc_dispatch(params, bell_terminated); } - fn csi_dispatch(&mut self, params: &Params, intermediates: &[u8], ignore: bool, action: char) { + fn csi_dispatch( + &mut self, + params: &vte::Params, + intermediates: &[u8], + ignore: bool, + action: char, + ) { self.inner - .csi_dispatch(params, intermediates, ignore, action); + .csi_dispatch(&Params::from_vte(params), intermediates, ignore, action); } fn esc_dispatch(&mut self, intermediates: &[u8], ignore: bool, byte: u8) { self.inner.esc_dispatch(intermediates, ignore, byte); diff --git a/crates/toastty-parser/tests/utf8_props.rs b/crates/toastty-parser/tests/utf8_props.rs index a7738615..2e18d74b 100644 --- a/crates/toastty-parser/tests/utf8_props.rs +++ b/crates/toastty-parser/tests/utf8_props.rs @@ -31,6 +31,10 @@ fn params(p: &Params) -> Vec> { p.iter().map(<[u16]>::to_vec).collect() } +fn vte_params(p: &vte::Params) -> Vec> { + p.iter().map(<[u16]>::to_vec).collect() +} + #[derive(Debug, Default)] struct Rec(Vec); @@ -99,7 +103,7 @@ impl vte::Perform for VteRec { self.0.push(Ev::Exec(b)); } fn hook(&mut self, p: &vte::Params, i: &[u8], ig: bool, a: char) { - self.0.push(Ev::Hook(params(p), i.to_vec(), ig, a)); + self.0.push(Ev::Hook(vte_params(p), i.to_vec(), ig, a)); } fn put(&mut self, b: u8) { self.0.push(Ev::Put(b)); @@ -112,7 +116,7 @@ impl vte::Perform for VteRec { .push(Ev::Osc(p.iter().map(|s| s.to_vec()).collect(), bell)); } fn csi_dispatch(&mut self, p: &vte::Params, i: &[u8], ig: bool, a: char) { - self.0.push(Ev::Csi(params(p), i.to_vec(), ig, a)); + self.0.push(Ev::Csi(vte_params(p), i.to_vec(), ig, a)); } fn esc_dispatch(&mut self, i: &[u8], ig: bool, b: u8) { self.0.push(Ev::Esc(i.to_vec(), ig, b)); From 66bd6e3fe1b2bf2de7a660a6b3f4f19d5f3249b8 Mon Sep 17 00:00:00 2001 From: EDToaster Date: Thu, 22 Oct 2026 02:08:45 -0700 Subject: [PATCH 07/11] term: no table scan or allocation per non-ASCII character Every non-ASCII character printed went through a linear scan of kitty's 297-entry placeholder diacritic table before checking whether the cell to its left is a placeholder at all (34 % of the unicode workload), and try_extend_grapheme built the cluster string before deciding whether the character joins it (malloc + free for nearly every CJK / Greek char). Check the cheap conditions first; behaviour is unchanged. --- crates/toastty-term/src/term.rs | 20 +++++++++++--------- crates/toastty-term/src/term/placeholder.rs | 8 +++++--- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/crates/toastty-term/src/term.rs b/crates/toastty-term/src/term.rs index eb63dd5f..03b097a0 100644 --- a/crates/toastty-term/src/term.rs +++ b/crates/toastty-term/src/term.rs @@ -2453,10 +2453,7 @@ impl Term { // Kitty Unicode placeholders: a diacritic after a placeholder // cell is that cell's row / column / id-MSB (see `placeholder`). // The placeholder itself prints as an ordinary 1-cell char below. - if !c.is_ascii() - && toastty_graphics::is_diacritic(c) - && self.attach_placeholder_diacritic(c) - { + if !c.is_ascii() && self.attach_placeholder_diacritic(c) { return; } let cell_w = Self::char_cell_width(c); @@ -2760,9 +2757,9 @@ impl Term { return false; } let tail = self.extras.grapheme_tail(prev.extra); - let mut cluster = String::with_capacity(prev.ch.len_utf8() + tail.len() + c.len_utf8()); - cluster.push(prev.ch); - cluster.push_str(tail); + // Decide before building any strings: most non-ASCII text (CJK, + // Greek, …) is wide or narrow but never joins, and allocating the + // cluster for every such character was most of its print cost. let joins = cell_w == 0 || { let after_zwj = tail.ends_with('\u{200D}'); // UAX #29 lets a modifier extend *any* base (`a🏽` is one @@ -2776,7 +2773,10 @@ impl Term { .is_some_and(|c| c.is_continuation); let emoji_modifier = emoji_base && ('\u{1F3FB}'..='\u{1F3FF}').contains(&c); (self.grapheme_cluster_mode || after_zwj || emoji_modifier) && { - let mut probe = cluster.clone(); + let mut probe = + String::with_capacity(prev.ch.len_utf8() + tail.len() + c.len_utf8()); + probe.push(prev.ch); + probe.push_str(tail); probe.push(c); probe.graphemes(true).nth(1).is_none() } @@ -2793,7 +2793,9 @@ impl Term { let mut new_tail = String::with_capacity(tail.len() + c.len_utf8()); new_tail.push_str(tail); new_tail.push(c); - cluster.push(c); + let mut cluster = String::with_capacity(prev.ch.len_utf8() + new_tail.len()); + cluster.push(prev.ch); + cluster.push_str(&new_tail); let Some(id) = self.intern_extra(CellExtra::Grapheme(new_tail.into())) else { // Table full: fall back to the old behaviour for zero-width // codepoints (dropped); a joining wide codepoint prints as diff --git a/crates/toastty-term/src/term/placeholder.rs b/crates/toastty-term/src/term/placeholder.rs index c60679f3..634facc3 100644 --- a/crates/toastty-term/src/term/placeholder.rs +++ b/crates/toastty-term/src/term/placeholder.rs @@ -202,9 +202,6 @@ impl Term { /// Returns `false` (print `c` normally) when that cell isn't a /// placeholder or `c` isn't one of kitty's diacritics. pub(super) fn attach_placeholder_diacritic(&mut self, c: char) -> bool { - let Some(idx) = toastty_graphics::diacritic_to_index(c) else { - return false; - }; let row = self.cursor.row; // With a deferred wrap the cursor sits on the cell just written. let cur = if self.wrap_pending() { @@ -221,6 +218,11 @@ impl Term { if cell.ch != crate::PLACEHOLDER || cell.is_continuation { return false; } + // Checked after the cell: the diacritic table is a linear scan, + // and every non-ASCII character printed comes through here. + let Some(idx) = toastty_graphics::diacritic_to_index(c) else { + return false; + }; let mut ph = match cell.extra.and_then(|id| self.extras.get(id)) { Some(CellExtra::Placeholder(p)) => *p, _ => PlaceholderExtra::default(), From 6cf64adc1c6908640810c1af09b2d2bdc3da2a3e Mon Sep 17 00:00:00 2001 From: EDToaster Date: Thu, 22 Oct 2026 02:10:41 -0700 Subject: [PATCH 08/11] parser: find printable-ASCII runs 8 bytes at a time The ground dispatcher's run scan checked one byte per iteration; a SWAR test (high bit, +1 overflow for DEL, -0x20 borrow for C0) does a word at a time. The lowest flagged byte is exact since carries and borrows only reach bytes above it; a unit test covers every byte value at every offset. --- crates/toastty-parser/src/parser.rs | 60 +++++++++++++++++++++++++++-- 1 file changed, 56 insertions(+), 4 deletions(-) diff --git a/crates/toastty-parser/src/parser.rs b/crates/toastty-parser/src/parser.rs index a2060387..5d111e6f 100644 --- a/crates/toastty-parser/src/parser.rs +++ b/crates/toastty-parser/src/parser.rs @@ -220,6 +220,30 @@ fn ground_text( } } +/// Length of the leading run of printable ASCII (`0x20..=0x7E`) in +/// `bytes`, 8 bytes at a time: a byte is flagged if its high bit is set, +/// if adding 1 sets it (0x7F), or if subtracting 0x20 borrows (< 0x20). +/// Carries and borrows only cross into bytes above a flagged one, so the +/// lowest flag is exact. +#[inline] +fn printable_run_len(bytes: &[u8]) -> usize { + const ONES: u64 = 0x0101_0101_0101_0101; + const HIGH: u64 = 0x8080_8080_8080_8080; + let mut i = 0; + while let Some(w) = bytes.get(i..i + 8) { + let x = u64::from_le_bytes([w[0], w[1], w[2], w[3], w[4], w[5], w[6], w[7]]); + let t = (x | x.wrapping_add(ONES) | (x.wrapping_sub(0x20 * ONES) & !x)) & HIGH; + if t != 0 { + return i + (t.trailing_zeros() / 8) as usize; + } + i += 8; + } + while i < bytes.len() && (0x20..0x7F).contains(&bytes[i]) { + i += 1; + } + i +} + /// Deliver ground text exactly as vte 0.15's `ground_dispatch` would /// (`print` / `execute` per character, C0 and C1 controls executed), /// except that each run of printable ASCII goes to the handler in one @@ -241,10 +265,7 @@ fn dispatch_ground( let b = text[i]; if (0x20..0x7F).contains(&b) { let start = i; - i += 1; - while i < n && (0x20..0x7F).contains(&text[i]) { - i += 1; - } + i += printable_run_len(&text[i..]); // >= 1: `b` is printable. perform.print_ascii(&text[start..i]); } else if b < 0x20 { perform.execute(b); @@ -1973,6 +1994,37 @@ mod tests { assert_eq!(r.apc[0], body); } + #[test] + fn printable_run_len_stops_at_the_first_other_byte() { + // Every byte value, at every offset of a 20-byte run (covers the + // word loop, its carries / borrows, and the byte tail). + for b in 0..=255u8 { + for at in 0..20 { + let mut v = vec![b'a'; 20]; + v[at] = b; + v.push(0xFF); + let want = if (0x20..0x7F).contains(&b) { 20 } else { at }; + assert_eq!(printable_run_len(&v), want, "byte {b:#x} at {at}"); + // Something nasty right after the stop. + if at + 1 < 20 { + v[at + 1] = 0x00; + let want = if (0x20..0x7F).contains(&b) { + at + 1 + } else { + at + }; + assert_eq!( + printable_run_len(&v), + want, + "byte {b:#x} at {at}, NUL after" + ); + } + } + } + assert_eq!(printable_run_len(b""), 0); + assert_eq!(printable_run_len(b"~ ~ ~ ~ ~ ~ ~ ~ ~"), 17); + } + #[test] fn intermixed_csi_apc_print() { let r = parse(b"hi\x1b[31m\x1b_g\x1b\\there"); From 620bfda498eb8f7e886d1d9d19be61d638e2a576 Mon Sep 17 00:00:00 2001 From: EDToaster Date: Thu, 22 Oct 2026 02:15:40 -0700 Subject: [PATCH 09/11] parser: read a CSI parameter's digits in one inner loop fast_csi matched every digit through the full byte-class match; the digits of one number now go through a tight inner loop (the parameter count can only change at ; : or the final byte, so one capacity check covers the number). --- .../proptest-regressions/parser/tests/tmux.txt | 8 ++++++++ crates/toastty-parser/src/parser.rs | 10 +++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 crates/toastty-parser/proptest-regressions/parser/tests/tmux.txt diff --git a/crates/toastty-parser/proptest-regressions/parser/tests/tmux.txt b/crates/toastty-parser/proptest-regressions/parser/tests/tmux.txt new file mode 100644 index 00000000..a4750a85 --- /dev/null +++ b/crates/toastty-parser/proptest-regressions/parser/tests/tmux.txt @@ -0,0 +1,8 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc 836ea9c32dff337f3c126c4bea3d1ef633dc5a1ecac3c43ba59aaf60d011ba4d # shrinks to inner = [], after = [], split = Index(0) +cc c2e427772574ff6efc28eb61be42c979a4ca92aeb77713c8166b7589bcefbf6e # shrinks to bytes = [24, 27, 59, 92], split = Index(0) diff --git a/crates/toastty-parser/src/parser.rs b/crates/toastty-parser/src/parser.rs index 5d111e6f..7b36fe95 100644 --- a/crates/toastty-parser/src/parser.rs +++ b/crates/toastty-parser/src/parser.rs @@ -328,13 +328,20 @@ fn fast_csi(perform: &mut P, bytes: &[u8]) -> Option let mut at = At::Entry; // `csi_param_digits`: bit per parameter that held a digit. let (mut digits, mut idx) = (0u32, 0u32); - for (i, &b) in bytes.iter().enumerate() { + let mut i = 0; + while let Some(&b) = bytes.get(i) { match b { b'0'..=b'9' if at != At::Intermediate => { if params.is_full() { return None; } + // The whole number in one go (only `;` / `:` / the final + // byte change the parameter count, so one check covers it). param = param.saturating_mul(10).saturating_add(u16::from(b - b'0')); + while let Some(&d @ b'0'..=b'9') = bytes.get(i + 1) { + param = param.saturating_mul(10).saturating_add(u16::from(d - b'0')); + i += 1; + } if idx < 32 { digits |= 1 << idx; } @@ -382,6 +389,7 @@ fn fast_csi(perform: &mut P, bytes: &[u8]) -> Option } _ => return None, } + i += 1; } None } From 79aefb9d312505765ba47dd178290399b99d914f Mon Sep 17 00:00:00 2001 From: EDToaster Date: Thu, 22 Oct 2026 02:15:40 -0700 Subject: [PATCH 10/11] docs: scrollback decision note on the revised row representation Dated note in docs/decisions/scrollback.md: Vec cells instead of the inline SmallVec, on-demand ring slots, written-prefix tracking. A test pins the Row slot size. --- crates/toastty-term/src/grid.rs | 10 +++++++++- docs/decisions/scrollback.md | 26 ++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/crates/toastty-term/src/grid.rs b/crates/toastty-term/src/grid.rs index 9ed984ed..46ec7202 100644 --- a/crates/toastty-term/src/grid.rs +++ b/crates/toastty-term/src/grid.rs @@ -25,7 +25,7 @@ use crate::rich::{RichBlockId, RichRow}; /// cells: for `seq` / log-style output that's one cache line instead of /// forty (2026-10-22 perf note in `docs/decisions/scrollback.md`). /// Writers through [`Row::cells_mut`] conservatively set `used` to the -/// full length; the hot paths ([`Row::put`], [`Row::write_run`], +/// full length; the hot paths ([`Row::put`], `Row::write_ascii`, /// [`Row::erase`]) track it exactly. #[derive(Debug, Clone, Default)] pub struct Row { @@ -948,6 +948,14 @@ mod tests { assert!(!r.soft_wrap); } + /// A ring slot is a `Vec` header plus a few flags (it was ~350 B + /// with the inline `SmallVec<[Cell; 16]>`); 10k+ of them per grid. + #[test] + fn row_slot_stays_small() { + let s = std::mem::size_of::(); + assert!(s <= 56, "Row grew to {s} bytes"); + } + /// `Row::used` must stay an upper bound on the non-blank prefix /// whichever path writes the row, or `clear` would leave stale cells. #[test] diff --git a/docs/decisions/scrollback.md b/docs/decisions/scrollback.md index a57a950d..a1171492 100644 --- a/docs/decisions/scrollback.md +++ b/docs/decisions/scrollback.md @@ -143,3 +143,29 @@ bitflags = "=2.11.1" 2. **Spacer cells and selection.** When a wide char gets bumped, our spacer is `' '` width=1. For text selection / copy, the spacer should produce zero output and the wide char should produce one grapheme. Add a `Cell::is_spacer()` flag and have copy-out skip spacers. 3. **Span table for SGR runs.** All three prototypes store style per-cell. A space-optimized version stores `Span { start, end, style }` separately. This is a *cell-shape* refactor — orthogonal to the container choice. Defer until profiling shows it matters. 4. **Reflow is not the hot path.** Median 66 ms for the ring is fine; users resize maybe once per session. The hot path is **append**, where the ring is 250 ns p50 — comfortably under one frame budget at any refresh rate. + +## 2026-10-22: row representation revised (performance pass) + +The ring buffer, its stable line ids, reflow and eviction are unchanged. +What changed is the row inside it and when the ring's rows get memory: + +- **`Row.cells` is a `Vec`, not `SmallVec<[Cell; 16]>`.** Real rows + hold one cell per column (80-300), so they always spilled and the + 16-cell inline buffer (~330 B per slot, 20-byte cells) was dead weight + in every one of the 10k+ ring slots. A `Row` is now 56 B. +- **Scrollback slots are allocated on demand.** Only the visible rows + get cells at start-up; an off-screen slot stays empty (no allocation) + until output first scrolls into it, and ED 3 hands history memory + back. At 128 columns that is ~26 MB less RSS for a terminal that + hasn't scrolled. Visible rows still always hold exactly `cols` cells, + so readers see no difference. +- **`Row` tracks its written prefix (`used`).** Every cell at or past + `used` is `Cell::BLANK`, so `Row::clear` (one per bottom-of-screen + linefeed, on a row recycled from the far, cache-cold end of the ring) + blanks only the prefix instead of the whole row: for short lines + (`seq`, logs) one cache line instead of forty. The cells are private + (`Row::cells` / `cells_mut`) so the invariant can't be broken from + outside; `cells_mut` conservatively counts the whole row as written. + +The cell layout itself (20 bytes) is untouched; shrinking `Cell` is still +the lever for scrollback memory once it fills, as noted above. From cc0ba3ae1131eac2de52e8912ef4ea668f2e0506 Mon Sep 17 00:00:00 2001 From: EDToaster Date: Thu, 22 Oct 2026 02:43:29 -0700 Subject: [PATCH 11/11] tests: DEL in an open sequence split from a non-ASCII read aborts like one read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Locks in a fix that came with the ground-text bypass: base's all-ASCII shortcut stopped tracking an open escape / CSI at a DEL (seq_end stops at 0x7F), so a read starting with a byte >= 0x80 didn't abort it (WIRE-17) and vte swallowed the text; the result depended on where the read split. step_seq keeps the sequence open across the DEL. Parser and term tests check split vs one-read agreement for ESC [ 1 DEL | é X, ESC ( DEL | 0x9C a and ESC - DEL | 0x8F 0xBD, and the 3x10 repro (prints U+FFFD X, cursor at column 2). --- crates/toastty-parser/src/parser.rs | 28 ++++++++++++++++++++ crates/toastty-term/tests/integration.rs | 33 ++++++++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/crates/toastty-parser/src/parser.rs b/crates/toastty-parser/src/parser.rs index 7b36fe95..9da25db6 100644 --- a/crates/toastty-parser/src/parser.rs +++ b/crates/toastty-parser/src/parser.rs @@ -1384,6 +1384,34 @@ mod tests { } } + /// A DEL inside an open escape / CSI at the end of an all-ASCII read + /// keeps the sequence open (vte ignores DEL there), so a non-ASCII + /// byte starting the next read still aborts it (WIRE-17), exactly as + /// when the bytes arrive in one read. (The old all-ASCII shortcut + /// stopped tracking the sequence at the DEL, so vte stayed in it and + /// swallowed the next read's text.) + #[test] + fn del_in_open_sequence_then_non_ascii_read_aborts_like_one_read() { + for (a, b) in [ + (&b"\x1b[1\x7f"[..], &b"\xc3\xa9X"[..]), + (b"\x1b(\x7f", b"\x9ca"), + (b"\x1b-\x7f", b"\x8f\xbd"), + ] { + let whole = parse(&[a, b].concat()); + let mut p = Parser::new(); + let mut split = Recorder::default(); + p.advance(&mut split, a); + p.advance(&mut split, b); + assert_eq!(split, whole, "{a:?} | {b:?}"); + assert!(split.csi.is_empty() && split.esc.is_empty(), "{split:?}"); + } + let mut p = Parser::new(); + let mut r = Recorder::default(); + p.advance(&mut r, b"\x1b[1\x7f"); + p.advance(&mut r, b"\xc3\xa9X"); + assert_eq!(r.text, "\u{fffd}X"); + } + #[test] fn plain_text_becomes_print_events() { let r = parse(b"hello"); diff --git a/crates/toastty-term/tests/integration.rs b/crates/toastty-term/tests/integration.rs index 667f7169..f46846c6 100644 --- a/crates/toastty-term/tests/integration.rs +++ b/crates/toastty-term/tests/integration.rs @@ -712,3 +712,36 @@ mod selection { assert!(t.selection().is_none()); } } + +/// WIRE-17 across a read boundary: a DEL inside an open sequence at the +/// end of one read, then a non-ASCII byte, aborts the sequence just as +/// when it all arrives at once (it used to depend on where the read +/// split: the split form printed nothing). +#[test] +fn del_in_open_sequence_split_from_non_ascii_matches_one_read() { + for (a, b) in [ + (&b"\x1b[1\x7f"[..], &b"\xc3\xa9X"[..]), + (b"\x1b(\x7f", b"\x9ca"), + (b"\x1b-\x7f", b"\x8f\xbd"), + ] { + let whole = run(3, 10, &[a, b].concat()); + let mut split = Term::new(3, 10, 32); + let mut p = Parser::new(); + p.advance(&mut split, a); + p.advance(&mut split, b); + for r in 0..3 { + assert_eq!( + row_text(&split, r), + row_text(&whole, r), + "{a:?} | {b:?} row {r}" + ); + } + assert_eq!(split.cursor(), whole.cursor(), "{a:?} | {b:?}"); + } + let mut t = Term::new(3, 10, 32); + let mut p = Parser::new(); + p.advance(&mut t, b"\x1b[1\x7f"); + p.advance(&mut t, b"\xc3\xa9X"); + assert_eq!(row_text(&t, 0), "\u{fffd}X"); + assert_eq!((t.cursor().row, t.cursor().col), (0, 2)); +}