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..b214c97f --- /dev/null +++ b/crates/toastty-parser/proptest-regressions/parser/tests/bypass.txt @@ -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 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/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/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 aa38bb4f..9da25db6 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; @@ -199,6 +200,200 @@ 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); + } +} + +/// 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 +/// [`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 += printable_run_len(&text[i..]); // >= 1: `b` is printable. + 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 +} + +/// 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); + 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; + } + 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, + } + i += 1; + } + 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. @@ -210,12 +405,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 +429,48 @@ 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); + } + + /// 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]) { + #[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 +487,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 +505,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 +527,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 +585,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 +598,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 +619,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 +628,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 +637,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 +646,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; } @@ -430,34 +662,35 @@ 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. - 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 +731,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 +741,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 +751,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 +801,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 +823,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 +832,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 +847,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 +878,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 +891,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 +912,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 +927,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 +940,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 +949,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 +976,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 +986,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..] @@ -1155,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"); @@ -1773,6 +2030,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"); @@ -1781,5 +2069,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..d99fa781 --- /dev/null +++ b/crates/toastty-parser/src/parser/tests/bypass.rs @@ -0,0 +1,326 @@ +//! 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); + } +} + +#[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 e7e657d1..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 @@ -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); } @@ -162,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); @@ -174,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)); 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 7be57604..8604ed90 100644 --- a/crates/toastty-render/src/lib.rs +++ b/crates/toastty-render/src/lib.rs @@ -233,7 +233,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()) @@ -2409,7 +2409,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; } @@ -2515,7 +2515,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 e34c8fa5..0cba33ed 100644 --- a/crates/toastty-render/src/text/instance.rs +++ b/crates/toastty-render/src/text/instance.rs @@ -250,7 +250,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()) } @@ -261,7 +261,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 @@ -1061,7 +1061,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; }; @@ -1087,7 +1087,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 }; @@ -1336,7 +1336,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 @@ -1356,7 +1356,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/damage.rs b/crates/toastty-term/src/damage.rs index 57278139..40c15bb9 100644 --- a/crates/toastty-term/src/damage.rs +++ b/crates/toastty-term/src/damage.rs @@ -53,16 +53,48 @@ 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), } } + /// 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). @@ -80,9 +112,7 @@ impl RowDamage { self.mark_all(); 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. @@ -142,6 +172,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 +193,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 +346,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); diff --git a/crates/toastty-term/src/grid.rs b/crates/toastty-term/src/grid.rs index 3bd960a8..1c512941 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_ascii`, +/// [`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,71 @@ 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 +119,56 @@ 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); + } + } + + /// 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; - 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 { @@ -91,10 +179,42 @@ 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) + }; + } + + /// 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] + 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 @@ -114,6 +234,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]>, @@ -224,10 +350,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, @@ -304,8 +431,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 @@ -316,11 +456,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] @@ -365,10 +507,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 { @@ -413,8 +559,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; } @@ -426,10 +572,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, @@ -454,8 +600,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); @@ -466,9 +616,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. @@ -476,6 +625,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. @@ -590,7 +742,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) @@ -607,7 +759,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 { @@ -775,15 +929,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, @@ -793,21 +947,82 @@ 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); } + /// 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] + 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] @@ -825,10 +1040,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] @@ -848,14 +1063,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, @@ -866,9 +1081,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(), @@ -879,7 +1094,7 @@ mod tests { } ); assert_eq!( - r.cells[2], + r.cells()[2], Cell { ch: ' ', style: red_style(), @@ -889,13 +1104,50 @@ 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'); + } + + /// 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] @@ -906,7 +1158,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); } } @@ -944,9 +1196,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] @@ -978,10 +1230,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] @@ -999,17 +1251,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'); } } @@ -1033,7 +1285,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()) ); @@ -1058,8 +1310,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] @@ -1092,9 +1344,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] @@ -1103,9 +1355,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`. @@ -1133,11 +1385,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()); } @@ -1156,8 +1408,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()); } @@ -1172,8 +1424,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] @@ -1200,9 +1452,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] @@ -1218,19 +1470,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] @@ -1255,14 +1507,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:?}"); } } @@ -1421,10 +1673,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"); } @@ -1474,7 +1726,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 9235ab50..762e678f 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 { @@ -175,7 +175,7 @@ impl Term { let mut map: Vec<(usize, Pos)> = Vec::new(); for line_id in lo..=hi { let row = self.row_by_line_id(line_id)?; - for (i, cell) in row.cells.iter().enumerate() { + for (i, cell) in row.cells().iter().enumerate() { if cell.is_continuation || cell.wrap_pad { continue; } diff --git a/crates/toastty-term/src/reflow.rs b/crates/toastty-term/src/reflow.rs index e76c2d39..fa59cd31 100644 --- a/crates/toastty-term/src/reflow.rs +++ b/crates/toastty-term/src/reflow.rs @@ -17,7 +17,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. @@ -90,11 +89,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_wrap_pad(&row.cells) + trailing_wrap_pad(row.cells()) }; - let keep = row.cells.len() - trim; + let keep = row.cells().len() - trim; row_starts.push((lines.len(), content.len())); if i == cursor_idx { @@ -107,7 +106,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. @@ -156,11 +155,7 @@ fn wrap_line( Cell::BLANK }; cur.resize(cols_usize, fill); - 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; @@ -371,11 +366,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 { @@ -406,15 +397,15 @@ mod tests { let mut rejoined: Vec = Vec::new(); for (idx, r) in narrow.iter().enumerate() { let trim = if idx + 1 < narrow.len() { - trailing_wrap_pad(&r.cells) + trailing_wrap_pad(r.cells()) } 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] @@ -427,9 +418,9 @@ mod tests { let (rows, _) = wrap_line(&content, 4, None, &mut Vec::new()); assert_eq!(rows.len(), 2); assert!(rows[0].soft_wrap); - assert_eq!(rows[0].cells[3], Cell::WRAP_PAD, "last col is the pad"); - assert_eq!(rows[1].cells[0].ch, '世'); - assert!(rows[1].cells[1].is_continuation); + assert_eq!(rows[0].cells()[3], Cell::WRAP_PAD, "last col is the pad"); + assert_eq!(rows[1].cells()[0].ch, '世'); + assert!(rows[1].cells()[1].is_continuation); } #[test] @@ -496,11 +487,11 @@ mod tests { let content: Vec = "foo bar".chars().map(cell).collect(); let rows = vec![row(content, false)]; let (narrow, _) = reflow_rows(&rows, 4, 0, 0); - assert_eq!(text(&narrow[0].cells), "foo "); + assert_eq!(text(&narrow[0].cells()), "foo "); assert!(narrow[0].soft_wrap); let (widened, _) = reflow_rows(&narrow, 20, 0, 0); assert_eq!(widened.len(), 1); - assert_eq!(text(&widened[0].cells).trim_end(), "foo bar"); + assert_eq!(text(&widened[0].cells()).trim_end(), "foo bar"); } #[test] @@ -519,7 +510,7 @@ mod tests { cur = reflow_rows(&cur, cols, 0, 0).0; } assert_eq!(cur.len(), 1); - assert_eq!(text(&cur[0].cells).trim_end(), "a世界世界世界"); + assert_eq!(text(&cur[0].cells()).trim_end(), "a世界世界世界"); } #[test] @@ -539,7 +530,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)); } @@ -552,7 +543,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 6702a38c..5e912cf3 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; } @@ -1651,7 +1651,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 { @@ -1963,14 +1963,14 @@ 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)), ) { let mut col = lo; while col <= hi { - 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 { @@ -2031,9 +2031,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) }; @@ -2312,9 +2312,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 { @@ -2382,9 +2380,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 { @@ -2443,10 +2439,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); @@ -2504,11 +2497,9 @@ impl Term { // A wide char that didn't fit in the last column leaves // that column unwritten. Mark it as a pad so reflow and // copy can tell it from a real space printed there. - if bumped_wide - && let Some(cell) = row.cells.get_mut(usize::from(right_edge) - 1) - && *cell == Cell::BLANK - { - *cell = Cell::WRAP_PAD; + let pad = right_edge - 1; + if bumped_wide && row.cells().get(usize::from(pad)) == Some(&Cell::BLANK) { + row.put(pad, Cell::WRAP_PAD, right_edge); } } let last_col = right_edge.saturating_sub(1); @@ -2586,6 +2577,82 @@ 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, + wrap_pad: 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 @@ -2598,7 +2665,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; @@ -2673,7 +2740,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) @@ -2688,9 +2755,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 @@ -2704,7 +2771,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() } @@ -2721,7 +2791,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 @@ -2742,7 +2814,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 @@ -2792,7 +2864,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) @@ -3430,12 +3502,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). @@ -3447,7 +3519,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); @@ -3477,12 +3549,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, @@ -3491,7 +3564,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); @@ -3574,9 +3647,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; } @@ -3643,9 +3714,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; } @@ -4281,6 +4350,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' => { @@ -6029,13 +6102,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) @@ -6049,7 +6122,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); } @@ -6079,7 +6152,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(); } @@ -6134,7 +6207,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] @@ -6204,7 +6277,7 @@ mod tests { .primary .scrollback_row(n) .expect("scrollback row retained") - .cells + .cells() .iter() .map(|c| c.ch) .collect(); @@ -6288,7 +6361,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"); @@ -6323,7 +6396,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] @@ -6430,7 +6503,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:?}"); } @@ -6515,7 +6588,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"); } @@ -6588,14 +6661,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()); @@ -6606,29 +6679,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()); } @@ -6637,7 +6710,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] @@ -6783,10 +6856,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); @@ -6800,8 +6873,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] @@ -6813,7 +6886,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. @@ -6821,7 +6894,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] @@ -6832,7 +6905,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()); } @@ -6840,11 +6913,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); } @@ -6873,11 +6946,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}"); } } @@ -6887,7 +6960,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] @@ -6933,10 +7006,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:?}" @@ -6958,7 +7031,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] @@ -7016,7 +7089,7 @@ mod tests { fn row_glyphs(t: &Term, r: u16) -> String { let s: String = t .row(r) - .cells + .cells() .iter() .filter(|c| !c.is_continuation) .map(|c| c.ch) @@ -7045,15 +7118,15 @@ mod tests { // "abc" then a wide char at 4 cols: it doesn't fit in column 3, // so it wraps and column 3 holds the pad. let t = run(3, 4, "abc世".as_bytes()); - assert_eq!(t.row(0).cells[3], Cell::WRAP_PAD); + assert_eq!(t.row(0).cells()[3], Cell::WRAP_PAD); assert!(t.row(0).soft_wrap); assert_eq!(row_glyphs(&t, 1), "世"); // A space printed into the last column is a real blank. let t = run(3, 4, b"abc d"); - assert_eq!(t.row(0).cells[3], Cell::BLANK); + assert_eq!(t.row(0).cells()[3], Cell::BLANK); // So is a wide char after a pending wrap: column 3 was printed. let t = run(3, 4, "abcd世".as_bytes()); - assert_eq!(t.row(0).cells[3].ch, 'd'); + assert_eq!(t.row(0).cells()[3].ch, 'd'); } #[test] @@ -7062,7 +7135,7 @@ mod tests { // second 世 doesn't fit and leaves a pad in column 5. let mut t = Term::new(3, 6, 100); feed(&mut t, "ab 世世x".as_bytes()); - assert_eq!(t.row(0).cells[5], Cell::WRAP_PAD); + assert_eq!(t.row(0).cells()[5], Cell::WRAP_PAD); t.resize(3, 20); assert_eq!(row_glyphs(&t, 0), "ab 世世x"); // Odd widths bump a wide char at every row end; drag-resizing @@ -7141,7 +7214,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] @@ -7569,7 +7642,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 @@ -7586,7 +7659,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 @@ -7598,7 +7671,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); } @@ -7607,7 +7680,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); } @@ -7620,7 +7693,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 @@ -7639,7 +7712,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). @@ -7657,7 +7730,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()); @@ -7670,7 +7743,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); } @@ -7681,7 +7754,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); } @@ -7692,7 +7765,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); } @@ -7704,7 +7777,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 @@ -7717,7 +7790,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()); @@ -7730,7 +7803,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()); @@ -7740,7 +7813,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()); @@ -7751,7 +7824,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); } @@ -7775,13 +7848,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", ); @@ -7794,7 +7867,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); } @@ -7832,7 +7905,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); } @@ -7842,7 +7915,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); } @@ -7856,7 +7929,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); } @@ -8179,12 +8252,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); } @@ -8198,8 +8271,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); } @@ -8210,10 +8283,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); } @@ -8267,7 +8340,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"); @@ -8287,7 +8360,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, @@ -8304,7 +8377,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'); @@ -8812,7 +8885,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] @@ -8827,7 +8900,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"); } @@ -8889,7 +8962,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\\"); } @@ -8949,7 +9022,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) ------------------------------------------------------ @@ -9322,10 +9395,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(); @@ -9339,8 +9412,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] @@ -9350,8 +9423,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, ); } @@ -9364,8 +9437,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, ); } @@ -9585,7 +9658,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. @@ -10034,8 +10107,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')); } @@ -10066,12 +10139,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); } @@ -11376,7 +11449,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); @@ -11409,7 +11482,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:?}" @@ -11431,7 +11504,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:?}" @@ -11531,7 +11604,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"); } @@ -11839,7 +11912,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() } @@ -12066,7 +12139,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() } @@ -12530,7 +12603,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)" ); } @@ -12776,7 +12850,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..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() { @@ -215,12 +212,17 @@ 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 { 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(), @@ -259,7 +261,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 +297,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 +368,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 9d3d3b97..dde21ce5 100644 --- a/crates/toastty-term/src/term/vt.rs +++ b/crates/toastty-term/src/term/vt.rs @@ -473,9 +473,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); @@ -580,13 +578,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); @@ -608,13 +606,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); @@ -646,7 +644,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) @@ -984,7 +982,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 ae52d93b..9b6a8374 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..f46846c6 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' ); @@ -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)); +} 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]); + } +} 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(); diff --git a/docs/decisions/scrollback.md b/docs/decisions/scrollback.md index 3beed136..e1fc4b54 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. *Done as `Cell::wrap_pad`: `Term::print_char` and reflow write `Cell::WRAP_PAD` there, reflow drops only that cell when it rejoins a soft-wrapped row (a real space at the wrap point stays), and copy and URL detection skip it.* 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.