diff --git a/CHANGELOG.md b/CHANGELOG.md index 3574ac6..e84d4a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/crates/termlens/src/screen.rs b/crates/termlens/src/screen.rs index 30f5b31..725469d 100644 --- a/crates/termlens/src/screen.rs +++ b/crates/termlens/src/screen.rs @@ -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 { @@ -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) } @@ -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)| { @@ -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. @@ -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()) @@ -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. diff --git a/crates/termlens/src/screen/diff.rs b/crates/termlens/src/screen/diff.rs index e9a5de1..6235d5d 100644 --- a/crates/termlens/src/screen/diff.rs +++ b/crates/termlens/src/screen/diff.rs @@ -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 `{}`. @@ -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, diff --git a/crates/termlens/src/screen/parse.rs b/crates/termlens/src/screen/parse.rs index bc31fb7..b14e46f 100644 --- a/crates/termlens/src/screen/parse.rs +++ b/crates/termlens/src/screen/parse.rs @@ -24,6 +24,18 @@ impl Screen { /// line, `styles:` and its span lines (or `(none)`). Everything else is /// an [`Error::Parse`] naming the line. /// + /// **The header's row count decides where the grid ends**, so a grid may + /// contain the words `styles:` or `(none)` as ordinary content. The one + /// ambiguous input is a snapshot whose trailing blank rows were trimmed + /// by hand *and* which carries a styles block: its block falls inside + /// the declared row count and is read as content. Content wins, on + /// purpose — a wrong row of text shows up in a diff, a silently dropped + /// one does not. + /// + /// A hidden cursor's position is not in the text format, so it comes + /// back at `0,0`. That is not a difference anyone can see, and + /// [`diff`](Self::diff) does not report it as one. + /// /// The round trip is exact for what the format carries: the parsed /// screen renders to the same text, with the same `styles:` block, and /// [`diff`](Self::diff)s empty against the original. It does not @@ -50,41 +62,31 @@ impl Screen { let (cols, rows, cursor) = parse_header(header)?; let body: Vec<&str> = lines.collect(); - // Where the grid ends: at the `styles:` marker when there is one, - // else at the end. A grid row could read `styles:` itself, so the - // marker is the one preceded by a blank line (or first) and followed - // by nothing but span lines — the shape `with_styles` writes. - let marker = body.iter().enumerate().position(|(i, line)| { - line.trim_end() == "styles:" - && (i == 0 || body[i - 1].trim().is_empty()) - && body[i + 1..].iter().all(|l| is_span_line(l)) - }); - let (grid, styles) = match marker { - // The blank separator before the marker is not a grid row. - Some(at) => (&body[..at.saturating_sub(1)], &body[at + 1..]), - None => (&body[..], &body[body.len()..]), - }; + // The grid is the number of lines the header declares, and nothing + // else decides that. `Display` writes every row — blank ones + // included — before `with_styles` adds its block, so counting is + // unambiguous for text this crate produced. Deliberately *not* a + // search for the `styles:` marker: a grid can contain that word as + // ordinary content, and reading content as metadata silently + // deleted it (#296). + // + // Fewer lines than rows is a snapshot whose trailing blank rows were + // trimmed; the remainder pads out blank. The cost of the rule is at + // the other end: in a *trimmed* snapshot that also carries a styles + // block, the block's lines are within the declared row count and are + // read as grid content. Content wins ties, on purpose — a wrong row + // of text is visible in a diff, a silently dropped one is not. + let split = body.len().min(usize::from(rows)); + let (grid, rest) = body.split_at(split); - // Fewer grid lines than rows is a grid whose trailing blank rows - // were dropped; more is only tolerated when the extra lines are - // blank, so a stray line is an error rather than a lost row. let mut cells = vec![ Cell::new(String::new(), Style::default(), false, false); usize::from(cols) * usize::from(rows) ]; for (index, line) in grid.iter().enumerate() { - let number = index + 2; - if index >= usize::from(rows) { - if line.trim().is_empty() { - continue; - } - return Err(Error::Parse(format!( - "line {number}: expected `styles:` or the end of the text after the {rows}-row grid, got {line:?}" - ))); - } parse_row( line, - number, + index + 2, cols, &mut cells[index * usize::from(cols)..][..usize::from(cols)], )?; @@ -99,22 +101,23 @@ impl Screen { cells, TermState::default(), ); - let first_style_line = marker.map_or(body.len(), |at| at + 1) + 2; - parse_styles(styles, first_style_line, &mut screen)?; + + // After the grid: blank separator lines, then `styles:` and its + // spans, or nothing at all. + if let Some(at) = rest.iter().position(|line| !line.trim().is_empty()) { + let number = split + at + 2; + if rest[at].trim_end() != "styles:" { + return Err(Error::Parse(format!( + "line {number}: expected `styles:` or the end of the text after the {rows}-row grid, got {:?}", + rest[at] + ))); + } + parse_styles(&rest[at + 1..], number + 1, &mut screen)?; + } Ok(screen) } } -/// A line the `styles:` block may hold: `(none)`, blank, or `ROW: …`. -fn is_span_line(line: &str) -> bool { - let line = line.trim_end(); - line.is_empty() - || line == "(none)" - || line - .split_once(": ") - .is_some_and(|(row, _)| !row.is_empty() && row.bytes().all(|b| b.is_ascii_digit())) -} - /// `size: x cursor: ,` or `cursor: hidden`. fn parse_header(line: &str) -> Result<(u16, u16, (u16, u16, bool))> { let bad = || { @@ -154,7 +157,18 @@ fn parse_row(line: &str, number: usize, cols: u16, row: &mut [Cell]) -> Result<( for ch in line.chars() { let width = ch.width().unwrap_or(0); if width == 0 { - match col.checked_sub(1).map(|c| &mut row[c]) { + // The cell that owns the glyph before this mark. A wide + // character advanced the column by two, so one step back lands + // on its continuation half, which holds no text — the mark + // belongs to the leading cell another step back (#297). + let owner = col.checked_sub(1).map(|c| { + if row[c].wide_continuation { + c.saturating_sub(1) + } else { + c + } + }); + match owner.map(|c| &mut row[c]) { Some(cell) if !cell.contents.is_empty() => cell.contents.push(ch), _ => { return Err(Error::Parse(format!( @@ -247,12 +261,19 @@ fn parse_styles(lines: &[&str], first_line: usize, screen: &mut Screen) -> Resul } /// `4` (indexed) or `#rrggbb`. +/// +/// Six *bytes* of hex is not six hex digits: `#a\u{20ac}bc` is six bytes and +/// slicing it in pairs lands inside a character, which used to panic where +/// the whole point of this module is to turn bad text into `Error::Parse` +/// (#299). Every byte is checked before any of them is read as a digit. fn parse_color(text: &str) -> Option { if let Some(hex) = text.strip_prefix('#') { - if hex.len() != 6 { + let hex = hex.as_bytes(); + if hex.len() != 6 || !hex.iter().all(u8::is_ascii_hexdigit) { return None; } - let channel = |i: usize| u8::from_str_radix(&hex[i..i + 2], 16).ok(); + let digit = |i: usize| char::from(hex[i]).to_digit(16).map(|d| d as u8); + let channel = |i: usize| Some(digit(i)? * 16 + digit(i + 1)?); return Some(Color::Rgb(channel(0)?, channel(2)?, channel(4)?)); } text.parse().ok().map(Color::Indexed) @@ -281,12 +302,103 @@ mod tests { #[test] fn a_short_grid_is_padded_and_none_is_accepted() { - let screen = Screen::parse("size: 4x3 cursor: hidden\nhi\n\nstyles:\n(none)").unwrap(); + // `lines()` drops the empty piece after a trailing newline, so a + // full Display of a screen ending in blank rows is already "short". + let screen = Screen::parse("size: 4x3 cursor: hidden\nhi").unwrap(); assert_eq!(screen.row_text(0), "hi "); assert_eq!(screen.row_text(2), " "); assert_eq!(screen.cursor(), (0, 0, false)); - let plain = Screen::parse("size: 4x3 cursor: hidden\nhi").unwrap(); - assert!(screen.diff(&plain).is_empty()); + let padded = Screen::parse("size: 4x3 cursor: hidden\nhi\n\n").unwrap(); + assert!(screen.diff(&padded).is_empty()); + // `(none)` after a full-height grid is the styles block saying the + // screen carries no styles at all. + let none = Screen::parse("size: 4x3 cursor: hidden\nhi\n\n\n\nstyles:\n(none)").unwrap(); + assert!(screen.diff(&none).is_empty()); + } + + /// The header's row count decides where the grid ends, so these words + /// are content when they fall inside it (#296). + #[test] + fn a_grid_may_contain_the_words_of_a_styles_block() { + let saved = "size: 8x3 cursor: 0,0\n\nstyles:\n(none)"; + let screen = Screen::parse(saved).unwrap(); + assert_eq!(screen.row_text(1).trim_end(), "styles:"); + assert_eq!(screen.row_text(2).trim_end(), "(none)"); + assert_eq!(screen.to_string(), saved); + // Even a row shaped like a style span is text when it is in the grid. + let spans = "size: 12x2 cursor: 0,0\n0: 0-1 bold\n1: 5 reverse"; + let screen = Screen::parse(spans).unwrap(); + assert_eq!(screen.row_text(0).trim_end(), "0: 0-1 bold"); + assert!(screen.cell(0, 0).unwrap().style().is_default()); + assert_eq!(screen.to_string(), spans); + } + + /// The one input the row-count rule cannot read both ways: a grid whose + /// trailing blank rows were trimmed by hand *and* which carries a styles + /// block. Content wins, and the leftover line is named rather than + /// silently swallowed. + #[test] + fn a_trimmed_grid_with_a_styles_block_is_read_as_content() { + // Wide enough to hold the word: `styles:` becomes row 2, and the + // span line after it has nowhere left to belong. + let err = Screen::parse("size: 12x3 cursor: hidden\nhi\n\nstyles:\n(none)") + .unwrap_err() + .to_string(); + assert!( + err.contains("line 5") && err.contains("expected `styles:`"), + "{err}" + ); + // Too narrow to hold it, and the same reading fails one line + // earlier — on the row itself rather than on what follows it. + let narrow = Screen::parse("size: 4x3 cursor: hidden\nhi\n\nstyles:\n(none)") + .unwrap_err() + .to_string(); + assert!( + narrow.contains("line 4") && narrow.contains("wider"), + "{narrow}" + ); + } + + /// Six bytes of hex is not six hex digits (#299). + #[test] + fn a_malformed_colour_is_an_error_not_a_panic() { + for token in [ + "fg=#a\u{20ac}bc", + "bg=#a\u{20ac}bc", + "fg=#12345", + "fg=#zzzzzz", + "fg=300", + ] { + let input = format!("size: 2x2 cursor: 0,0\nx\n\nstyles:\n0: 0 {token}"); + let err = Screen::parse(&input).unwrap_err().to_string(); + assert!(err.contains("line 5"), "{token}: {err}"); + } + // The valid forms still parse. + let ok = + Screen::parse("size: 2x2 cursor: 0,0\nxy\n\n\nstyles:\n0: 0 fg=4 bg=#1e1e2e").unwrap(); + assert_eq!(ok.cell(0, 0).unwrap().style().fg, Color::Indexed(4)); + assert_eq!( + ok.cell(0, 0).unwrap().style().bg, + Color::Rgb(0x1e, 0x1e, 0x2e) + ); + } + + /// A combining mark after a wide character belongs to the cell that owns + /// the glyph, not to its continuation half (#297). + #[test] + fn a_combining_mark_follows_a_wide_character() { + let screen = Screen::parse("size: 6x1 cursor: 0,0\n\u{6771}\u{301}X").unwrap(); + assert_eq!(screen.cell(0, 0).unwrap().contents(), "\u{6771}\u{301}"); + assert!(screen.cell(0, 0).unwrap().is_wide()); + assert!(screen.cell(0, 1).unwrap().is_wide_continuation()); + assert_eq!(screen.cell(0, 2).unwrap().contents(), "X"); + // Several marks in a row, and a wide character at the right margin. + let many = Screen::parse("size: 4x1 cursor: 0,0\nab\u{6771}\u{301}\u{302}").unwrap(); + assert_eq!( + many.cell(0, 2).unwrap().contents(), + "\u{6771}\u{301}\u{302}" + ); + assert!(many.cell(0, 3).unwrap().is_wide_continuation()); } #[test] diff --git a/crates/termlens/src/terminal.rs b/crates/termlens/src/terminal.rs index 84d9fca..ee18fb1 100644 --- a/crates/termlens/src/terminal.rs +++ b/crates/termlens/src/terminal.rs @@ -813,7 +813,16 @@ impl Recording { ); for (at, frame) in &self.frames { let mut data = String::from("\x1b[H\x1b[2J"); - data.push_str(&frame.to_ansi().replace('\n', "\r\n")); + // `to_ansi` ends every row with a newline, the bottom one + // included — right for a file or a paste into a terminal, wrong + // for a repaint: replayed, that last linefeed sits on the last + // row and scrolls the whole frame up by one, so the player + // showed a screen the test never saw (#295). One newline comes + // off; the rest become CRLF, because a recording is replayed in + // raw mode where LF alone does not return the carriage. + let ansi = frame.to_ansi(); + let painted = ansi.strip_suffix('\n').unwrap_or(&ansi); + data.push_str(&painted.replace('\n', "\r\n")); out.push_str(&format!( "[{:.6}, \"o\", {}]\n", at.as_secs_f64(), diff --git a/crates/termlens/tests/export.rs b/crates/termlens/tests/export.rs index cbe3262..f65b1db 100644 --- a/crates/termlens/tests/export.rs +++ b/crates/termlens/tests/export.rs @@ -3,7 +3,7 @@ use std::time::Duration; -use termlens::{Key, Terminal}; +use termlens::{Key, Screen, Terminal}; mod common; @@ -183,3 +183,107 @@ mod json { Ok(()) } } + +/// A grid can hold the words a styles block is made of. Reading them as +/// metadata deleted them, and a snapshot that silently loses a visible row +/// is worse than one that fails to parse (#296). +#[test] +#[cfg_attr( + windows, + ignore = "ConPTY rewrites the stream before termlens sees it, so the grid under test is its rendering (#149)" +)] +fn a_grid_holding_the_words_of_a_styles_block_round_trips() -> termlens::Result<()> { + let mut t = emit(&["--raw", r"\r\nstyles:\r\n(none)\r\nREADY", "--wait"])?; + t.wait_until(|s| s.contains("READY"))?; + let screen = t.screen(); + assert_eq!(screen.row_text(1).trim_end(), "styles:"); + assert_eq!(screen.row_text(2).trim_end(), "(none)"); + + let plain = screen.to_string(); + assert_eq!( + Screen::parse(&plain)?.to_string(), + plain, + "content, not metadata" + ); + let styled = screen.with_styles().to_string(); + let parsed = Screen::parse(&styled)?; + assert_eq!(parsed.with_styles().to_string(), styled); + assert!(screen.diff(&parsed).is_empty(), "{}", screen.diff(&parsed)); + + t.send(Key::Enter)?; + t.wait_exit()?; + Ok(()) +} + +/// The emulator stores a wide character and its combining mark in one cell; +/// the parser walked back onto the continuation half, which holds no text, +/// and refused its own format (#297). +#[test] +#[cfg_attr( + windows, + ignore = "ConPTY re-renders wide characters into columns of its own choosing (#149)" +)] +fn a_wide_character_with_a_combining_mark_round_trips() -> termlens::Result<()> { + // A wide+mark cell mid-row, and another ending exactly at the right + // margin of the 30-column grid. + // The characters go in as themselves; only \r\n is for the fixture to + // interpret. 28 narrow cells put the second wide glyph in the last two + // columns of the 30-column grid. + let margin = "a".repeat(28); + let text = format!("\u{6771}\u{301}X\\r\\n{margin}\u{6771}\u{302}\\r\\nREADY"); + let mut t = emit(&["--raw", &text, "--wait"])?; + t.wait_until(|s| s.contains("READY"))?; + let screen = t.screen(); + assert_eq!(screen.cell(0, 0).unwrap().contents(), "\u{6771}\u{301}"); + assert!(screen.cell(1, 28).unwrap().is_wide(), "{screen}"); + assert!(screen.cell(1, 29).unwrap().is_wide_continuation()); + + let saved = screen.with_styles().to_string(); + let parsed = Screen::parse(&saved)?; + assert!(screen.diff(&parsed).is_empty(), "{}", screen.diff(&parsed)); + assert_eq!(parsed.cell(0, 0).unwrap().contents(), "\u{6771}\u{301}"); + assert_eq!(parsed.with_styles().to_string(), saved); + + t.send(Key::Enter)?; + t.wait_exit()?; + Ok(()) +} + +/// A hidden cursor draws nothing, and the text format does not record where +/// it sat — so a screen parsed back from its own snapshot reported a +/// difference no reader could see (#298). +#[test] +#[cfg_attr( + windows, + ignore = "ConPTY drives the cursor itself, so its position and visibility are not the child's (#149)" +)] +fn a_hidden_cursor_round_trips_as_the_same_picture() -> termlens::Result<()> { + let mut t = emit(&["--raw", r"hello\e[2;3H\e[?25l", "--wait"])?; + t.wait_until(|s| s.contains("hello") && !s.cursor().2)?; + let screen = t.screen(); + assert_eq!(screen.cursor(), (1, 2, false)); + + let parsed = Screen::parse(&screen.with_styles().to_string())?; + assert_eq!( + parsed.cursor(), + (0, 0, false), + "the position is not in the text" + ); + assert!(screen.diff(&parsed).is_empty(), "{}", screen.diff(&parsed)); + + // Visibility itself, and a visible cursor's position, are still picture. + let shown = Screen::parse("size: 30x4 cursor: 1,2\nhello")?; + assert!( + !parsed.diff(&shown).is_empty(), + "hidden vs visible is a change" + ); + let moved = Screen::parse("size: 30x4 cursor: 2,5\nhello")?; + assert!( + !shown.diff(&moved).is_empty(), + "a visible cursor moving is a change" + ); + + t.send(Key::Enter)?; + t.wait_exit()?; + Ok(()) +} diff --git a/crates/termlens/tests/record.rs b/crates/termlens/tests/record.rs index 0ba2b58..0bd8475 100644 --- a/crates/termlens/tests/record.rs +++ b/crates/termlens/tests/record.rs @@ -138,3 +138,63 @@ fn the_asciicast_is_a_v2_header_and_one_full_repaint_per_frame() -> termlens::Re assert!(t.wait_exit()?.success()); Ok(()) } + +/// The exported event must *redraw* the frame it came from — every row, the +/// bottom one included (#295). `to_ansi` ends every row with a newline, and +/// the one after the last row scrolled the whole picture up by one when the +/// recording was replayed, so a player showed a screen the test never saw. +#[test] +#[cfg_attr( + windows, + ignore = "ConPTY closes a DEC 2026 bracket before the content it wrapped, so a recorded frame never holds what was drawn (#149)" +)] +fn the_asciicast_replays_every_row_of_every_frame() -> termlens::Result<()> { + // 8x3, and the bottom row is filled edge to edge: a frame whose last row + // is full is the one a stray linefeed damages most. + let mut t = common::spawn_emit( + Terminal::builder() + .size(8, 3) + .timeout(Duration::from_secs(10)), + &[ + "READY", + "--wait", + "--raw", + r"\e[?2026h\e[H\e[2JTOP\e[2;1HMIDDLE\e[3;1HBOTTOMXX\e[?2026l", + "--wait", + "--raw", + r"\e[?2026h\e[H\e[2JSECOND\e[3;1HFILLEDUP\e[?2026l", + "--wait", + ], + )?; + t.wait_until(|s| s.contains("READY"))?; + let recorder = t.record(); + t.send(Key::Enter)?; + t.wait_frame(|s| s.contains("BOTTOMXX"))?; + t.send(Key::Enter)?; + t.wait_frame(|s| s.contains("FILLEDUP"))?; + let recording = recorder.stop()?; + assert_eq!(recording.len(), 2, "two frames were bracketed"); + + let cast = recording.to_asciicast(); + let events: Vec<&str> = cast.lines().skip(1).collect(); + assert_eq!(events.len(), recording.len(), "one event per frame"); + for (event, (_, frame)) in events.iter().zip(recording.frames()) { + let parsed: serde_json::Value = serde_json::from_str(event).expect("an asciicast event"); + let data = parsed[2].as_str().expect("the output payload"); + // Replay it into a terminal of the recorded size, exactly as a + // player would, and hold the result against the frame it came from. + let (cols, rows) = frame.size(); + let mut replay = vt100::Parser::new(rows, cols, 0); + replay.process(data.as_bytes()); + let played: Vec = replay + .screen() + .rows(0, cols) + .map(|row| row.trim_end().to_owned()) + .collect(); + let recorded: Vec = (0..rows) + .map(|row| frame.row_text(row).trim_end().to_owned()) + .collect(); + assert_eq!(played, recorded, "the event must redraw its frame"); + } + Ok(()) +} diff --git a/crates/termlens/tests/search.rs b/crates/termlens/tests/search.rs index b638c58..53649ac 100644 --- a/crates/termlens/tests/search.rs +++ b/crates/termlens/tests/search.rs @@ -222,3 +222,50 @@ mod patterns { Ok(()) } } + +/// `mask_matching` is documented as matching "the way `find_all` matches", +/// and `find_all` spans rows. The mask ran its matcher one row at a time, so +/// a needle crossing a row boundary was reported and then left on screen — +/// the one failure mode a mask exists to prevent (#300). +#[test] +#[cfg_attr( + windows, + ignore = "ConPTY re-renders the grid, so the wide-character columns this asserts on are its own (#149)" +)] +fn a_mask_covers_a_needle_that_spans_rows() -> termlens::Result<()> { + // Two occurrences, one of them crossing a wide character, and a row that + // must survive untouched between them. + let mut t = emit(&[ + "--raw", + r"abc\r\ndef\r\nKEEP ME\r\nab東\r\ndef\r\n", + "--wait", + ])?; + t.wait_until(|s| s.contains("KEEP ME"))?; + let screen = t.screen(); + + assert_eq!(screen.find_all("abc\ndef"), vec![(0, 0)]); + let masked = screen.mask_matching("abc\ndef", '*'); + assert!( + masked.find_all("abc\ndef").is_empty(), + "the needle survived the mask:\n{masked}" + ); + assert_eq!(masked.row_text(0).trim_end(), "***"); + assert_eq!(masked.row_text(1).trim_end(), "***"); + // Everything outside the match is untouched, styles included. + assert_eq!(masked.row_text(2).trim_end(), "KEEP ME"); + assert_eq!(masked.row_text(3).trim_end(), "ab東"); + assert_eq!(masked.size(), screen.size()); + assert_eq!(masked.cursor(), screen.cursor()); + + // A wide character under the match becomes two fill cells, so the row + // keeps its width — the invariant every mask holds. + let wide = screen.mask_matching("ab東\ndef", '#'); + assert_eq!(wide.row_text(3).trim_end(), "####"); + assert_eq!(wide.row_text(4).trim_end(), "###"); + assert_eq!(wide.row_text(2).trim_end(), "KEEP ME"); + assert!(wide.find_all("ab東\ndef").is_empty(), "{wide}"); + + t.send(Key::Enter)?; + t.wait_exit()?; + Ok(()) +} diff --git a/fixtures/form-echo/src/main.rs b/fixtures/form-echo/src/main.rs index 38eb3fa..aa69f8f 100644 --- a/fixtures/form-echo/src/main.rs +++ b/fixtures/form-echo/src/main.rs @@ -186,6 +186,16 @@ fn main() -> io::Result<()> { EnableFocusChange )?; + // Initialize crossterm's event source (which registers the SIGWINCH + // listener) BEFORE the first draw, for the reason `resize-echo` already + // does it: a test synchronizes on the first drawn frame and then + // resizes, and a SIGWINCH landing between that draw and the first + // `event::read()` is silently lost — SIGWINCH's default disposition is + // ignore, so nothing queues it and the acknowledgement never comes + // (#292). A fixture must not announce that it is ready before it can + // actually receive what a test is about to send it. + let _ = event::poll(std::time::Duration::from_secs(0))?; + let mut app = App::default(); draw(&mut out, &app)?; diff --git a/fixtures/ratatui-app/src/main.rs b/fixtures/ratatui-app/src/main.rs index 9891c97..503da98 100644 --- a/fixtures/ratatui-app/src/main.rs +++ b/fixtures/ratatui-app/src/main.rs @@ -5,6 +5,7 @@ //! test. use std::io; +use std::time::Duration; use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind}; use ratatui::crossterm::execute; @@ -20,6 +21,12 @@ fn main() -> io::Result<()> { } fn run(terminal: &mut ratatui::DefaultTerminal, state: &mut State) -> io::Result<()> { + // Register crossterm's SIGWINCH listener before the first frame, the way + // `resize-echo` and `form-echo` do: the fidelity test synchronizes on + // that frame and then resizes, and a signal arriving before the first + // `event::read()` is silently lost, because SIGWINCH's default + // disposition is ignore (#292). + let _ = event::poll(Duration::from_secs(0))?; paint(terminal, state)?; loop { match event::read()? { diff --git a/skills/termlens/SKILL.md b/skills/termlens/SKILL.md index e940bd4..ae1aec6 100644 --- a/skills/termlens/SKILL.md +++ b/skills/termlens/SKILL.md @@ -362,8 +362,13 @@ fn snapshot_diff_and_mask() -> termlens::Result<()> { // it in the GRID — the mask keeps every column and style where it was, // which a text filter over the rendering cannot. (cols, rows), like size(). let masked: Screen = after.mask_rect(70..80, 0..1); - // Or by shape: every digit anywhere becomes `#`. - let _digits_hidden = after.mask_matching("0123456789", '#'); + // `mask_matching` takes a LITERAL, not a set of characters: this hides + // the exact text "Counter: 1". To mask by shape, use `mask_cells` (or + // `mask_matches` with the `regex` feature) — a predicate per cell. + let _by_text = after.mask_matching("Counter: 1", '#'); + let _digits_hidden = after.mask_cells(|c| { + !c.contents().is_empty() && c.contents().chars().all(|ch| ch.is_ascii_digit()) + }); termlens::assert_screen_snapshot!(&masked); // Every occurrence, not just the first; and a needle that spans a soft @@ -460,7 +465,7 @@ from_r, to_c, to_r)`, `scroll(col, row, Scroll::Down)`, `resize(cols, rows)`, | `unsupported()` / `insert_mode()` | sequences the emulator did not implement (`^[[20h`…), so a plausible grid can be told from a right one / IRM left on | | `with_styles()` | `Display` with a `styles:` block; snapshot this to catch colour regressions | | `diff(&other)` | `ScreenDiff`: `is_empty()`, `cells()`, and a `Display` of only the rows that changed | -| `mask_rect(cols, rows)` / `mask_matching(chars, fill)` / `mask_cells(pred)` | a new `Screen` with those cells replaced, styles and columns intact | +| `mask_rect(cols, rows)` / `mask_matching(literal, fill)` / `mask_cells(pred)` | a new `Screen` with those cells replaced, styles and columns intact. `mask_matching` is a literal (rows included — it spans a wrap the way `find_all` does); `mask_cells` blanks by predicate | | `to_ansi()` / `to_svg()` / `to_html()` | renderings a person can see; `Screen::parse(text)` reads the text format back | **Style** (`Copy`, public fields): `fg`, `bg` (`Color::Default` /