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
56 changes: 56 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,62 @@ listed under a **Changed** or **Removed** heading.

### Fixed

- **An exported recording scrolled every frame up by one row.**
`Screen::to_ansi` ends every row with a newline, the bottom one included
— right for a file, wrong for a repaint: replayed, that last linefeed
sits on the last row and scrolls the picture away, so `asciinema` showed
a screen the test never saw. `Recording::to_asciicast` now drops exactly
that newline. The regression test replays each exported event into a
terminal of the recorded size and compares every row against the frame it
came from, rather than checking the event's shape. (#295)

- **`Screen::parse` deleted grid rows that read like a styles block.** The
split between grid and metadata was a search for a `styles:` marker, and
a screen can *contain* that word: a snapshot of one came back blank. The
header's row count now decides where the grid ends, which is unambiguous
for anything `Display` wrote. The one input that cannot be read both ways
— a hand-trimmed grid that also carries a styles block — resolves as
content and says so in the rustdoc, because a wrong row of text shows up
in a diff and a silently dropped one does not. (#296)

- **`Screen::parse` rejected a combining mark after a wide character** —
its own format, for a cell the emulator stores correctly. A wide glyph
advances two columns, so stepping one back lands on the continuation
half, which holds no text; the mark now attaches to the leading cell that
owns the glyph. (#297)

- **A hidden cursor made a snapshot differ from itself.** The text format
records `cursor: hidden` without a position, so a parsed screen came back
at `0,0` and `ScreenDiff::is_empty` compared the coordinates anyway —
a difference with nothing visible behind it, on a screen state most TUIs
are in. A hidden cursor draws nothing, so its position is no longer part
of the picture, for `diff` and for `wait_stable` alike; visibility, and a
visible cursor's position, still are. (#298)

- **`Screen::parse` panicked on a non-ASCII hex colour.** `fg=#a€bc` is six
*bytes*, so the length check passed and slicing in pairs landed inside a
character — an unwind out of the one function whose job is turning bad
text into `Error::Parse`, and the CLI shares it. Every byte is now
checked as an ASCII hex digit before any of them is read. (#299)

- **`mask_matching` left a needle that spans rows fully visible.** It is
documented as matching "the way `find_all` matches", and `find_all`
crosses row boundaries — but the mask ran its matcher one row at a time,
where a newline can never appear. It reported the match and masked
nothing, which is precisely the failure a mask exists to prevent.
`find_all` and the masks now share one multi-row engine, so they cannot
disagree about what matched. (#300)

- **A fixture announced it was ready before it could be resized.**
`form-echo` (and the new `ratatui-app`) drew their first frame — the one
a test synchronizes on — before the first `event::read()`, and crossterm
registers its `SIGWINCH` listener on that call. A resize landing in the
gap was lost for good, since SIGWINCH's default disposition is ignore,
and `wait_frame` then ran out a full deadline against an application that
had simply never heard. `resize-echo` already guarded against this; the
other two now do the same. Found by the stress workflow on macOS at one
thread. (#292)

- **A query test raced its own fixture's pause.**
`a_query_the_app_moved_past_is_context_not_a_cause` spent one 400 ms
budget on both of its waits — the one that must *succeed* and the one
Expand Down
117 changes: 96 additions & 21 deletions crates/termlens/src/screen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -525,6 +525,21 @@ impl Cell {
}
}

/// Whether two cursors look the same on screen.
///
/// A hidden cursor draws nothing, so *where* it sits is not part of the
/// picture — and the snapshot text format does not record it, which is what
/// made a parsed snapshot report a difference against its own original that
/// no one could see (#298). A visible cursor's position is part of the
/// picture, and so is the visibility itself.
pub(crate) fn same_cursor(a: (u16, u16, bool), b: (u16, u16, bool)) -> bool {
match (a.2, b.2) {
(false, false) => true,
(true, true) => (a.0, a.1) == (b.0, b.1),
_ => false,
}
}

/// Where [`Screen::locate`] found a needle.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Location {
Expand Down Expand Up @@ -616,8 +631,7 @@ impl Screen {
pub(crate) fn same_picture(&self, other: &Screen) -> bool {
self.cols == other.cols
&& self.rows == other.rows
&& (self.cursor_row, self.cursor_col, self.cursor_visible)
== (other.cursor_row, other.cursor_col, other.cursor_visible)
&& same_cursor(self.cursor(), other.cursor())
&& (Arc::ptr_eq(&self.cells, &other.cells) || self.cells == other.cells)
}

Expand Down Expand Up @@ -1399,12 +1413,49 @@ impl Screen {
let Ok(extra) = u16::try_from(segments.len() - 1) else {
return;
};
self.for_each_multirow_match(&segments, |row| {
let (first_line, cols) = self.searchable_row(row);
let first = first_line.trim_end();
// The needle's first character: on this row for a non-empty
// first segment, else the first character after the leading
// newlines (start of a following row).
let at = match segments.iter().position(|s| !s.is_empty()) {
Some(0) => {
let byte_off = first.len() - segments[0].len();
match cols.get(byte_off) {
Some(&col) => (row, col),
None => return false,
}
}
Some(k) => match u16::try_from(k) {
Ok(k) => (row + k, 0),
Err(_) => return false,
},
None => (row + extra, 0),
};
visit(at)
});
}

/// Every non-overlapping multi-row match of `segments`, reported as the
/// row its **first** segment sits on. The shape such a needle has to
/// have: the first segment ends a row (after the trailing-whitespace
/// trim), the middle segments are whole rows, the last starts one.
///
/// One engine, two callers — [`find_all`](Self::find_all) and
/// [`mask_matching`](Self::mask_matching) — so they cannot disagree
/// about what matched, which is exactly how a mask came to leave a
/// needle `find_all` reported (#300).
fn for_each_multirow_match(&self, segments: &[&str], mut visit: impl FnMut(u16) -> bool) {
let Ok(extra) = u16::try_from(segments.len().saturating_sub(1)) else {
return;
};
let Some(last_start) = self.rows.checked_sub(extra) else {
return;
};
let mut row = 0;
while row < last_start {
let (first_line, cols) = self.searchable_row(row);
let (first_line, _) = self.searchable_row(row);
let first = first_line.trim_end();
let tail_matches = || {
segments[1..].iter().enumerate().all(|(i, seg)| {
Expand All @@ -1421,24 +1472,7 @@ impl Screen {
row += 1;
continue;
}
// The needle's first character: on this row for a non-empty
// first segment, else the first character after the leading
// newlines (start of a following row).
let at = match segments.iter().position(|s| !s.is_empty()) {
Some(0) => {
let byte_off = first.len() - segments[0].len();
match cols.get(byte_off) {
Some(&col) => (row, col),
None => return,
}
}
Some(k) => match u16::try_from(k) {
Ok(k) => (row + k, 0),
Err(_) => return,
},
None => (row + extra, 0),
};
if !visit(at) {
if !visit(row) {
return;
}
// Non-overlapping: the rows this match spanned are spent.
Expand Down Expand Up @@ -1496,6 +1530,13 @@ impl Screen {
#[must_use]
pub fn mask_matching(&self, pattern: &str, fill: char) -> Screen {
let pattern = self.fold(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
// never sees a newline, so it matched nothing and masked
// nothing — while `find_all` reported the hit (#300).
return self.masked_multiline(&pattern, fill);
}
self.masked_spans(
|hay| {
hay.match_indices(pattern.as_str())
Expand All @@ -1506,6 +1547,40 @@ impl Screen {
)
}

/// The multi-row half of [`mask_matching`](Self::mask_matching): every
/// cell a row-spanning needle covers. `needle` is already folded.
fn masked_multiline(&self, needle: &str, fill: char) -> Screen {
let segments: Vec<&str> = needle.split('\n').collect();
let Ok(extra) = u16::try_from(segments.len().saturating_sub(1)) else {
return self.clone();
};
let width = usize::from(self.cols);
let mut hits = vec![false; self.cells.len()];
self.for_each_multirow_match(&segments, |row| {
for (index, segment) in segments.iter().enumerate() {
let at = row + index as u16;
let (text, cols) = self.searchable_row(at);
let line = text.trim_end();
// The same shape the matcher just checked: the first segment
// ends its row, the last starts one, the rest are whole rows.
let (start, end) = if index == 0 {
(line.len().saturating_sub(segment.len()), line.len())
} else if index as u16 == extra {
(0, segment.len())
} else {
(0, line.len())
};
for byte in start..end {
if let Some(&col) = cols.get(byte) {
hits[usize::from(at) * width + usize::from(col)] = true;
}
}
}
true
});
self.apply_mask(hits, Some(fill))
}

/// A copy of this screen with every cell for which `predicate` holds
/// blanked — everything dim, say, or everything in a given colour. See
/// [`mask_rect`](Self::mask_rect) for what a mask keeps.
Expand Down
10 changes: 8 additions & 2 deletions crates/termlens/src/screen/diff.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

use std::fmt;

use super::{Cell, Screen, Style};
use super::{same_cursor, Cell, Screen, Style};

/// The difference between two screens. Built by [`Screen::diff`]; render it
/// with `{}`.
Expand Down Expand Up @@ -160,11 +160,17 @@ pub(super) fn row_styles(screen: &Screen, row: u16) -> String {
impl ScreenDiff {
/// True when the two screens show the same picture: same size, same
/// cursor, every cell equal.
///
/// A *hidden* cursor's coordinates are not part of the picture — it
/// draws nothing, and the snapshot text format does not record where it
/// was, so a screen parsed back from its own snapshot used to report a
/// difference no one could see (#298). Visibility itself is compared,
/// and so is a visible cursor's position.
#[must_use]
pub fn is_empty(&self) -> bool {
self.cells.is_empty()
&& self.before_size == self.after_size
&& self.before_cursor == self.after_cursor
&& same_cursor(self.before_cursor, self.after_cursor)
}

/// Every changed cell as `(row, col, before, after)`, in reading order,
Expand Down
Loading