Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,27 @@ reads that marker.
Only a kitty escape continues an upload now: a delete aborts it, as the
protocol requires, and is counted, and a sixel or `RIS` abandons it. An
abandoned upload is no image, and its bytes still count in `bytes()`.
- `Screen::find`, `find_all`, `locate`, `mask_matching` and, with `regex`,
`matches`, `find_match`, `find_all_matches` and `mask_matches` no longer
take time quadratic in the size of the screen (#584), and neither does
`contains` once the first non-ASCII cell comes late. Every row a search
visited re-read the whole grid to ask whether it was ASCII, so one `find`
on 1000x1000 took 3 s in a release build where `contains` took 6 ms; it
now takes 5 ms. A `wait_until` calling `find`, and `wait_until_matches`,
paid that on every wake-up.
- The JSON reader refuses a cell no termlens could have written (#582). A
saved screen whose cell held an escape sequence loaded, and `termlens
render --text`, `--ansi` and `diff` then wrote it to the terminal: an OSC
set the window title and `ESC [2J` cleared the screen. Refused now, in
the grid and the styled history, naming the cell: a control character
(C0, DEL or C1, as `Screen::parse` refuses it), printable ASCII after a
cell's first character, an empty cell marked wide or an ASCII one with
no U+FE0F after it, and a wide character's second column that holds text
or follows none; and a screen of no columns or rows. A tracking-mode set
keeps only its four mode bits, so `MouseModes::len` and `iter` agree. No
check refuses a file a termlens wrote: a keycap such as `1️⃣`, a cell
whose width only this build's width tables dispute, and a wide character
that vt100 0.16 left in the last column after a resize all still read.

## [0.11.6] - 2026-10-14

Expand Down
31 changes: 31 additions & 0 deletions crates/termlens-cli/tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1214,6 +1214,37 @@ fn render_and_diff_read_a_screen_from_stdin() -> termlens::Result<()> {
Ok(())
}

/// A saved screen whose cell carried an escape sequence drove the terminal
/// of whoever rendered it: `render --text` wrote the OSC that set the window
/// title and the `ESC [2J` that cleared the screen straight to stdout, and
/// then refused its own output as input (#582). The reader now refuses the
/// file, so nothing reaches stdout and the tool exits 2 naming the cell.
#[test]
fn a_saved_screen_cannot_drive_the_terminal_that_renders_it() {
let screen = Screen::parse("size: 8x1 cursor: 0,0\nhi").expect("a saved screen");
let mut value = serde_json::to_value(&screen).expect("serializes");
value["cells"][0][1]["contents"] = "\u{1b}]0;PWNED\u{7}\u{1b}[2Jh".into();
let document = value.to_string();
let before = data("before.snap");
for args in [
&["render", "--text", "-"][..],
&["render", "--ansi", "-"],
&["diff", "--color", "never", "-", &before],
] {
let out = with_stdin(args, &document);
assert_eq!(out.status.code(), Some(2), "{args:?}: {out:?}");
assert!(
!out.stdout.contains(&0x1b),
"{args:?} wrote an escape: {out:?}"
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("cell 0,1") && stderr.contains("control character"),
"{args:?}: {stderr}"
);
}
}

/// Standard input is read once, so it can be one of `diff`'s operands and
/// not both — said plainly rather than left to look like an empty screen.
#[test]
Expand Down
62 changes: 40 additions & 22 deletions crates/termlens/src/screen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -191,12 +191,20 @@ impl MouseMode {
/// # }
/// ```
#[derive(Clone, Copy, PartialEq, Eq, Hash, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
// `Deserialize` is written by hand in `serde_impl.rs`, to drop the bits no
// mode occupies (#582).
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct MouseModes(u8);

impl MouseModes {
/// The set holding the modes whose bits are set in `bits`. A bit no mode
/// occupies is dropped: kept, it counted in `len()` and never appeared
/// in `iter()`, and a saved screen can carry one (#582).
pub(crate) fn from_bits(bits: u8) -> Self {
Self(bits)
let known = MouseMode::TRACKING
.iter()
.fold(0, |known, mode| known | mode.bit());
Self(bits & known)
}

/// Whether the application currently has `mode` enabled.
Expand Down Expand Up @@ -1620,7 +1628,7 @@ impl Screen {
visit((0, 0));
return;
}
let needle = &self.fold(needle);
let needle = &nfc(needle);
if !needle.contains('\n') {
for row in 0..self.rows {
let (text, cols) = self.searchable_row(row);
Expand Down Expand Up @@ -1776,7 +1784,7 @@ impl Screen {
/// row's width, which is the one thing a mask exists not to do.
#[must_use]
pub fn mask_matching(&self, pattern: &str, fill: char) -> Screen {
let pattern = self.fold(pattern);
let pattern = nfc(pattern);
if pattern.contains('\n') {
// A needle that spans rows is matched by the engine `find_all`
// uses. The per-row scan below is handed one row at a time and
Expand Down Expand Up @@ -2027,22 +2035,17 @@ impl Screen {
}

/// True when every cell holds only ASCII, so normalization is the
/// identity and the search helpers can skip it. This is the shape of
/// virtually every screen, which is what keeps `contains` — evaluated
/// on every wait wake-up — as cheap as it was.
/// identity and `contains` can search [`text`](Self::text) as it is.
/// This is the shape of virtually every screen, which is what keeps
/// `contains` — evaluated on every wait wake-up — as cheap as it was.
///
/// A pass over every cell, so it is asked once per query and never per
/// row: asked per row it made every search quadratic in the size of the
/// screen, three seconds for one `find` on 1000x1000 (#584).
fn is_ascii(&self) -> bool {
self.cells.iter().all(|c| c.contents().is_ascii())
}

/// `needle` in the form the search helpers compare against.
fn fold(&self, needle: &str) -> String {
if self.is_ascii() && needle.is_ascii() {
needle.to_owned()
} else {
nfc(needle)
}
}

/// The whole grid in searchable form: rows joined with `\n`, trailing
/// whitespace stripped per row, each row folded the same way
/// [`searchable_row`](Self::searchable_row) folds it — so `contains` and
Expand All @@ -2067,8 +2070,12 @@ impl Screen {
/// folding cell by cell composes exactly what the terminal draws in one
/// cell and can never compose across a cell boundary. It also keeps the
/// byte-to-column map exact, which is what [`find`](Self::find) reports.
///
/// Whether to normalize at all is decided per cell for the same reason
/// it is cheap there: an ASCII cell is NFC already. Deciding it for the
/// whole screen, a pass over every cell, on each row visited is what
/// made every search quadratic (#584).
fn searchable_row(&self, row: u16) -> (String, Vec<u16>) {
let ascii = self.is_ascii();
let mut text = String::with_capacity(usize::from(self.cols));
let mut cols = Vec::with_capacity(usize::from(self.cols));
for col in 0..self.cols {
Expand All @@ -2081,10 +2088,8 @@ impl Screen {
let before = text.len();
if cell.contents().is_empty() {
text.push(' ');
} else if ascii {
text.push_str(cell.contents());
} else {
text.extend(cell.contents().nfc());
push_nfc(&mut text, cell.contents());
}
cols.resize(text.len(), col);
debug_assert!(text.len() > before || cell.is_wide_continuation());
Expand Down Expand Up @@ -2208,9 +2213,22 @@ impl Screen {
}
}

/// `s` folded to NFC.
/// `s` folded to NFC, the form a needle and the screen are compared in.
fn nfc(s: &str) -> String {
s.nfc().collect()
let mut out = String::with_capacity(s.len());
push_nfc(&mut out, s);
out
}

/// Append `s` folded to NFC. ASCII is NFC already — no ASCII character
/// decomposes, or composes with the one after it — so it is copied, and
/// that is the shape of virtually every cell and every needle.
fn push_nfc(out: &mut String, s: &str) {
if s.is_ascii() {
out.push_str(s);
} else {
out.extend(s.nfc());
}
}

/// Clamp any range expression to `0..len`, as `(start, end)` exclusive.
Expand Down
23 changes: 14 additions & 9 deletions crates/termlens/src/screen/parse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,19 @@ fn parse_header(line: &str) -> Result<(u16, u16, (u16, u16, bool))> {
Ok((cols, rows, cursor))
}

/// The columns `ch` takes in a cell, or why no saved screen holds it.
///
/// `unicode_width` answers `None` for a control character — C0, DEL and C1
/// — and `Some(0)` for a zero-width mark. Reading them alike folded an
/// `ESC` into the cell before it, and the snapshot renderings then carried
/// the raw byte out (#376). The JSON reader asks this of every character in
/// every cell, so the two readers refuse the same ones (#582).
pub(super) fn cell_width(ch: char) -> std::result::Result<usize, String> {
ch.width().ok_or_else(|| {
format!("{ch:?} is a control character; the snapshot format never writes one")
})
}

/// One grid line into `row`, a slice of exactly `cols` cells. A zero-width
/// combining mark joins the cell before it; a wide character takes two; a
/// control character is an error.
Expand Down Expand Up @@ -194,15 +207,7 @@ fn lay_out_row(line: &str, number: usize, cols: u16, row: &mut [Cell], widen: bo
};
let mut col = 0usize;
for ch in line.chars() {
// `unicode_width` answers `None` for a control character and
// `Some(0)` for a zero-width mark. Reading them alike folded an
// `ESC` into the cell before it, and the snapshot renderings then
// carried the raw byte out (#376).
let Some(width) = ch.width() else {
return Err(Error::Parse(format!(
"line {number}: {ch:?} is a control character; the snapshot format never writes one"
)));
};
let width = cell_width(ch).map_err(|why| Error::Parse(format!("line {number}: {why}")))?;
if width == 0 {
// The cell that owns the glyph before this mark. A wide
// character advanced the column by two, so one step back lands
Expand Down
Loading
Loading