From 3aaf309764bc2e88d5d1a42cf4936a695784afdc Mon Sep 17 00:00:00 2001 From: Vyncint Ng Date: Sat, 5 Sep 2026 07:53:15 +0700 Subject: [PATCH 01/14] fix(inspect): parse --help, --timeout and --idle before the program name `inspect --help` spawned a program called `--help` and failed with a PATH error, because the argument loop knew only `--size` and took the next argument as the program whatever it looked like (#229). Both timings were hardcoded too, so a program slower than five seconds to paint could not be inspected: the wait expired and the viewer printed a partial screen (#236). The loop now consumes options until the first non-option: - `-h`/`--help` print the usage to stdout and exit 0; a missing program prints the same text to stderr and exits 1. The usage text has one home. - `--timeout SECONDS` (default 5) sets the builder deadline; `--idle MILLIS` (default 300) the silence window for the still-running path. A malformed value is rejected in one line, shaped like the `--size` diagnostic. - `--version` prints the termlens version; an unknown option is refused rather than spawned; `--` ends option parsing. The still-running settle is bounded by the deadline as before, or by the silence window itself when that is longer, so `--idle 10000` cannot make the settle time out before it has had a chance to see silence. Closes #229 Closes #236 Signed-off-by: Vyncint Ng --- CHANGELOG.md | 10 +++ crates/termlens/examples/inspect.rs | 104 ++++++++++++++++++------ crates/termlens/tests/inspect.rs | 119 ++++++++++++++++++++++++++++ 3 files changed, 210 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c4ba25..3f011d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,16 @@ listed under a **Changed** or **Removed** heading. state means nothing. `wait_exit` is deliberately unaffected: the child's exit status is still true. (#211) +- **The `inspect` example answers `--help`, and takes its deadline and + silence window from flags.** `inspect --help` used to look for a program + called `--help`, and both timings were hardcoded, so an application slower + than five seconds to paint its first screen could not be inspected at all. + `--timeout SECONDS` (default 5) and `--idle MILLIS` (default 300) now sit + beside `--size`; `--help`/`-h` print one usage text to stdout and exit 0, + a missing program prints the same text to stderr and exits 1, and + `--version` names the termlens version the example was built from. An + unknown option is refused rather than spawned. (#229, #236) + ### Changed - **The smallest terminal is 2x2, not 1x1.** One column panics the emulator on diff --git a/crates/termlens/examples/inspect.rs b/crates/termlens/examples/inspect.rs index 4de85dc..976d0f9 100644 --- a/crates/termlens/examples/inspect.rs +++ b/crates/termlens/examples/inspect.rs @@ -4,10 +4,18 @@ //! ```sh //! cargo run --example inspect -- ls -la //! cargo run --example inspect -- --size 120x40 htop +//! cargo run --example inspect -- --timeout 30 ./target/debug/slow-app //! ``` //! -//! Waits for the program to exit (up to the timeout) or, if it keeps -//! running, for 300ms of output silence — then prints the screen. +//! Waits for the program to exit (up to the deadline, `--timeout`, five +//! seconds by default) or, if it keeps running, for a window of output +//! silence (`--idle`, 300ms by default) — then prints the screen. Five +//! seconds is what a test suite wants, where a deadline exists to turn a +//! hang into a readable failure; a person at a terminal pointing this at an +//! application that loads a large file or compiles before it draws is +//! willing to wait longer, which is what the flag is for. The silence +//! window has the same shape: an application that paints in bursts wider +//! than 300ms is snapshotted mid-render unless it is widened. //! //! Exit code 0 means inspect ran and printed a screen; the trailer under //! the screen says what the program did — its exit status, or that it was @@ -22,39 +30,87 @@ use std::time::Duration; use termlens::Terminal; +/// The one copy of the usage text: `--help` prints it to stdout and exits +/// 0, a missing program prints it to stderr and exits 1 (#229). +const USAGE: &str = "\ +usage: inspect [--size COLSxROWS] [--timeout SECONDS] [--idle MILLIS] [args…] + +Runs in an 80x24 pseudo-terminal (or --size), waits for it to +exit or for the deadline (--timeout, default 5 seconds), and prints the +rendered screen. A program still running at the deadline is snapshotted +after --idle milliseconds (default 300) of output silence, then killed. + +Exit code 0: a screen was printed; the trailer under it says what the +program did. Exit code 1: inspect itself could not run — bad arguments, +or a program that could not be spawned. + + -h, --help print this text + --version print the termlens version this example was built from + -- end of options; the program name follows"; + +/// The value after `flag`, or the one-line diagnostic every flag shares: +/// a missing value names the kind expected, a malformed one shows an +/// example — the shape `--size` set, so `--timeout` and `--idle` read the +/// same way (#236). +fn take( + args: &mut impl Iterator, + flag: &str, + kind: &str, + example: &str, + parse: impl Fn(&str) -> Option, +) -> Result { + let Some(raw) = args.next() else { + return Err(format!("{flag} needs a {kind} argument")); + }; + parse(&raw).ok_or_else(|| format!("bad {flag} {raw:?}, expected e.g. {example}")) +} + fn main() -> ExitCode { let mut args = std::env::args().skip(1).peekable(); let mut size = (80u16, 24u16); - if args.peek().map(String::as_str) == Some("--size") { - args.next(); - let Some(spec) = args.next() else { - eprintln!("--size needs a COLSxROWS argument"); - return ExitCode::FAILURE; - }; - match spec.split_once('x') { - Some((c, r)) => match (c.parse(), r.parse()) { - (Ok(c), Ok(r)) => size = (c, r), - _ => { - eprintln!("bad --size {spec:?}, expected e.g. 120x40"); - return ExitCode::FAILURE; - } - }, - None => { - eprintln!("bad --size {spec:?}, expected e.g. 120x40"); - return ExitCode::FAILURE; + let mut timeout = Duration::from_secs(5); + let mut idle = Duration::from_millis(300); + + // Options come before the program; everything after it is the + // program's own, however flag-like it looks. + while args.peek().is_some_and(|a| a.starts_with('-') && a != "-") { + let flag = args.next().unwrap_or_default(); + let parsed = match flag.as_str() { + "-h" | "--help" => { + println!("{USAGE}"); + return ExitCode::SUCCESS; } + "--version" => { + println!("inspect (termlens {})", env!("CARGO_PKG_VERSION")); + return ExitCode::SUCCESS; + } + "--" => break, + "--size" => take(&mut args, "--size", "COLSxROWS", "120x40", |spec| { + let (c, r) = spec.split_once('x')?; + Some((c.parse().ok()?, r.parse().ok()?)) + }) + .map(|s| size = s), + "--timeout" => take(&mut args, "--timeout", "SECONDS", "30", |s| s.parse().ok()) + .map(|secs| timeout = Duration::from_secs(secs)), + "--idle" => take(&mut args, "--idle", "MILLIS", "1000", |s| s.parse().ok()) + .map(|millis| idle = Duration::from_millis(millis)), + other => Err(format!("unknown option {other:?} (try --help)")), + }; + if let Err(message) = parsed { + eprintln!("inspect: {message}"); + return ExitCode::FAILURE; } } let Some(program) = args.next() else { - eprintln!("usage: inspect [--size COLSxROWS] [args…]"); + eprintln!("{USAGE}"); return ExitCode::FAILURE; }; let mut t = match Terminal::builder() .size(size.0, size.1) - .timeout(Duration::from_secs(5)) + .timeout(timeout) .args(args) .spawn(&program) { @@ -72,8 +128,10 @@ fn main() -> ExitCode { out.push_str(&format!("\n--- exited: {status} ---\n")); } Err(termlens::Error::Timeout { .. }) => { - // Still running at the deadline: settle on a quiet screen instead. - let _ = t.wait_idle(Duration::from_millis(300)); + // Still running at the deadline: settle on a quiet screen + // instead. The settle is bounded by the deadline too, unless the + // silence window asked for is itself longer than that. + let _ = t.wait_idle_for(idle, timeout.max(idle)); out.push_str(&t.screen().to_string()); out.push_str("\n--- still running at the deadline (killed on exit) ---\n"); } diff --git a/crates/termlens/tests/inspect.rs b/crates/termlens/tests/inspect.rs index 40a5c90..229e261 100644 --- a/crates/termlens/tests/inspect.rs +++ b/crates/termlens/tests/inspect.rs @@ -95,3 +95,122 @@ fn inspect_survives_a_reader_that_closes_early() { assert!(!stderr.contains("panicked"), "inspect panicked:\n{stderr}"); assert_eq!(status.code(), Some(0), "stderr:\n{stderr}"); } + +/// `--help` is the first thing anyone types at an unfamiliar command; it +/// used to be spawned as a program called `--help` (#229). The usage text +/// has one home, so the no-program path prints the same words to stderr. +#[test] +fn inspect_prints_its_usage_for_help_and_for_a_missing_program() { + let bin = inspect_bin(); + + for flag in ["--help", "-h"] { + let help = run_inspect(&bin, &[flag]); + assert_eq!( + help.status.code(), + Some(0), + "{flag} is a successful request" + ); + let stdout = String::from_utf8_lossy(&help.stdout); + assert!( + stdout.starts_with( + "usage: inspect [--size COLSxROWS] [--timeout SECONDS] [--idle MILLIS]" + ), + "{flag} must print the usage to stdout, got:\n{stdout}" + ); + assert!(help.stderr.is_empty(), "{flag} wrote to stderr"); + } + + let version = run_inspect(&bin, &["--version"]); + assert_eq!(version.status.code(), Some(0)); + assert!( + String::from_utf8_lossy(&version.stdout) + .contains(&format!("termlens {}", env!("CARGO_PKG_VERSION"))), + "--version names the crate version" + ); + + let none = run_inspect(&bin, &[]); + assert_eq!( + none.status.code(), + Some(1), + "no program is still a usage error" + ); + assert!(none.stdout.is_empty()); + assert!( + String::from_utf8_lossy(&none.stderr).starts_with("usage: inspect"), + "the same usage goes to stderr:\n{}", + String::from_utf8_lossy(&none.stderr) + ); + + let unknown = run_inspect(&bin, &["--bogus", "sh"]); + assert_eq!(unknown.status.code(), Some(1)); + assert!( + String::from_utf8_lossy(&unknown.stderr).contains("unknown option \"--bogus\""), + "an unknown option is refused rather than spawned:\n{}", + String::from_utf8_lossy(&unknown.stderr) + ); +} + +/// Both timings are flags now (#236): a malformed value is rejected in one +/// line the way `--size` rejects one, and the deadline is honoured — a +/// program slower than the default five seconds can be cut off at one. +#[test] +fn inspect_takes_its_deadline_and_silence_window_from_flags() { + let bin = inspect_bin(); + + for (args, expect) in [ + ( + &["--timeout", "abc", "sh"][..], + "bad --timeout \"abc\", expected e.g. 30", + ), + ( + &["--idle", "1.5", "sh"][..], + "bad --idle \"1.5\", expected e.g. 1000", + ), + (&["--timeout"][..], "--timeout needs a SECONDS argument"), + (&["--idle"][..], "--idle needs a MILLIS argument"), + ] { + let out = run_inspect(&bin, args); + assert_eq!(out.status.code(), Some(1), "{args:?}"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stderr.contains(expect), "{args:?}: got {stderr:?}"); + assert_eq!( + stderr.lines().count(), + 1, + "one line, like --size: {stderr:?}" + ); + } + + // A one-second deadline against a program that sleeps for thirty: + // inspect must report "still running" long before the default five + // seconds would have, with the output painted before the deadline + // still on the screen it prints. + let started = std::time::Instant::now(); + let cut = run_inspect( + &bin, + &[ + "--timeout", + "1", + "--idle", + "50", + "sh", + "-c", + "echo painted; sleep 30", + ], + ); + let elapsed = started.elapsed(); + assert!( + cut.status.success(), + "{}", + String::from_utf8_lossy(&cut.stderr) + ); + let stdout = String::from_utf8_lossy(&cut.stdout); + assert!(stdout.contains("painted"), "{stdout}"); + assert!( + stdout.contains("--- still running at the deadline (killed on exit) ---"), + "{stdout}" + ); + assert!( + elapsed < std::time::Duration::from_secs(4), + "a 1s deadline took {elapsed:?}; the flag was not honoured" + ); +} From 4b7f47bdba3d9dbec06ae5dbbafd9878d5282fc4 Mon Sep 17 00:00:00 2001 From: Vyncint Ng Date: Sat, 5 Sep 2026 07:53:16 +0700 Subject: [PATCH 02/14] test(inspect): cover a relative program path from a scratch directory A relative program path is how the viewer is pointed at something just built, and it resolves only because a child now starts in the test process's working directory rather than $HOME (#215). The test pinning that default reads `pwd` inside a shell; nothing pinned the mechanism the viewer relies on, so it could stop working unnoticed (#237). The new case links /bin/echo into CARGO_TARGET_TMPDIR, runs inspect from there with `./echo`, and asserts the program's output reaches the screen. A symlink rather than a copy: macOS kills a system binary copied out of /bin, since its signature is trusted at that path only. Closes #237 Signed-off-by: Vyncint Ng --- crates/termlens/tests/inspect.rs | 36 ++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/crates/termlens/tests/inspect.rs b/crates/termlens/tests/inspect.rs index 229e261..34f93ed 100644 --- a/crates/termlens/tests/inspect.rs +++ b/crates/termlens/tests/inspect.rs @@ -214,3 +214,39 @@ fn inspect_takes_its_deadline_and_silence_window_from_flags() { "a 1s deadline took {elapsed:?}; the flag was not honoured" ); } + +/// A relative program path is how `inspect` is pointed at something just +/// built (`inspect ./target/debug/myapp`), and it resolves only because a +/// child starts in the test process's working directory rather than in +/// `$HOME` (#215). The test pinning that default reads `pwd` inside a +/// shell; this one pins the mechanism the viewer actually relies on (#237). +#[cfg(unix)] +#[test] +fn inspect_resolves_a_relative_program_path_from_its_working_directory() { + let bin = inspect_bin(); + let scratch = std::path::Path::new(env!("CARGO_TARGET_TMPDIR")).join("inspect-relative"); + std::fs::create_dir_all(&scratch).expect("scratch directory"); + // A real program linked into the scratch directory, rather than `sh -c`, + // which resolves its own arguments and would test the shell instead of + // termlens. A symlink rather than a copy: macOS refuses to run a system + // binary copied out of `/bin` (its signature is trusted at that path + // only), and a multi-call `echo` keeps its own name this way. + let echo = scratch.join("echo"); + let _ = std::fs::remove_file(&echo); + std::os::unix::fs::symlink("/bin/echo", &echo) + .expect("link /bin/echo into the scratch directory"); + + let out = Command::new(&bin) + .current_dir(&scratch) + .args(["./echo", "relative path resolved"]) + .output() + .expect("failed to run the inspect example"); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + out.status.success(), + "inspect failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + assert!(stdout.contains("relative path resolved"), "{stdout}"); + assert!(stdout.contains("--- exited: exit code 0 ---"), "{stdout}"); +} From 336013362932243593ae1d8e8199e45a56d9063c Mon Sep 17 00:00:00 2001 From: Vyncint Ng Date: Sat, 5 Sep 2026 07:55:09 +0700 Subject: [PATCH 03/14] test(fixtures): give unicode-torture a raw non-UTF-8 byte MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #217 taught the reader to turn a byte that is not UTF-8 into U+FFFD instead of dropping it, and asked for two guards: an inline test, which landed as tests/utf8.rs, and a fixture line, so a parser change that reintroduces the drop turns a snapshot red rather than depending on one printf staying alive. The fixture line never landed (#230). unicode-torture now writes `raw: caf\xe9 done` straight to stdout — a string literal cannot hold the byte — and the reviewed snapshot shows the replacement character with `done` in the column it would occupy had the byte decoded. The snapshot test waits on row 8 now that the fixture prints one more line. Closes #230 Signed-off-by: Vyncint Ng --- crates/termlens/tests/fixtures.rs | 4 ++-- ...unicode_torture_renders_with_correct_widths.snap | 4 ++-- fixtures/unicode-torture/src/main.rs | 13 ++++++++++++- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/crates/termlens/tests/fixtures.rs b/crates/termlens/tests/fixtures.rs index c3b3039..e5b8f04 100644 --- a/crates/termlens/tests/fixtures.rs +++ b/crates/termlens/tests/fixtures.rs @@ -164,8 +164,8 @@ fn unicode_torture_renders_with_correct_widths() -> termlens::Result<()> { // Wait on the cursor, not on contains("done"): the predicate would turn // true before the trailing newline is processed, and the snapshot would // catch the cursor mid-line. After "done\r\n" the cursor rests at the - // start of row 7 — that is the fixture's true "finished drawing" state. - t.wait_until(|s| s.cursor() == (7, 0, true))?; + // start of row 8 — that is the fixture's true "finished drawing" state. + t.wait_until(|s| s.cursor() == (8, 0, true))?; let screen = t.screen(); // "width: |一二三| vs |abc|" — "width: " is 7 columns, "|一二三|" is diff --git a/crates/termlens/tests/snapshots/fixtures__unicode_torture_renders_with_correct_widths.snap b/crates/termlens/tests/snapshots/fixtures__unicode_torture_renders_with_correct_widths.snap index 42f83f4..46118e2 100644 --- a/crates/termlens/tests/snapshots/fixtures__unicode_torture_renders_with_correct_widths.snap +++ b/crates/termlens/tests/snapshots/fixtures__unicode_torture_renders_with_correct_widths.snap @@ -1,13 +1,13 @@ --- source: crates/termlens/tests/fixtures.rs -assertion_line: 161 expression: screen --- -size: 80x24 cursor: 7,0 +size: 80x24 cursor: 8,0 ascii: the quick brown fox cjk: 你好 世界 漢字 emoji: 🦀 crab, family 👩‍👩‍👧‍👦, flag 🇻🇳 viet-nfc: Tiếng Việt — cà phê sữa đá viet-nfd: Tiếng Việt width: |一二三| vs |abc| +raw: caf� done done diff --git a/fixtures/unicode-torture/src/main.rs b/fixtures/unicode-torture/src/main.rs index 9ed5c77..7f2baf5 100644 --- a/fixtures/unicode-torture/src/main.rs +++ b/fixtures/unicode-torture/src/main.rs @@ -3,13 +3,16 @@ //! Covers: plain ASCII, double-width CJK, emoji (including a ZWJ family and //! a regional-indicator flag), Vietnamese in both NFC and NFD normalization //! (the NFD line is spelled with explicit escapes so the source file's -//! encoding can never change the bytes), and a wide-vs-narrow width ruler. +//! encoding can never change the bytes), a wide-vs-narrow width ruler, and +//! one line carrying a raw byte that is not UTF-8. //! //! No timing, no randomness — print, wait for one line on stdin, exit 0. //! The stdin guard exists because output written immediately before exit //! can be discarded by macOS's pty teardown; the harness observes the //! lines, then sends Enter to release the fixture. +use std::io::Write; + fn main() { println!("ascii: the quick brown fox"); println!("cjk: 你好 世界 漢字"); @@ -17,6 +20,14 @@ fn main() { println!("viet-nfc: Tiếng Việt — cà phê sữa đá"); println!("viet-nfd: Tie\u{0302}\u{0301}ng Vie\u{0323}\u{0302}t"); println!("width: |一二三| vs |abc|"); + // A byte that is not UTF-8: a Latin-1 `é` in what should have been + // text. A string literal cannot hold it, so the bytes go out directly. + // The grid must show U+FFFD here and keep `done` in the column it would + // occupy had the byte decoded; the sanitizer used to drop it, shifting + // every column after it left (#217, #230). + std::io::stdout() + .write_all(b"raw: caf\xe9 done\n") + .expect("stdout is writable"); println!("done"); let mut guard = String::new(); From a018ffb6a3113ddd640abe782c81f45967f53233 Mon Sep 17 00:00:00 2001 From: Vyncint Ng Date: Sat, 5 Sep 2026 07:55:09 +0700 Subject: [PATCH 04/14] test: read the child's working directory off the whole screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The default-working-directory test read pwd's output from row 0 of an 80-column grid, so a checkout whose absolute path is longer than 80 characters wrapped the path onto row 1 and the test failed on canonicalize — deterministically, in both profiles, for a reason that has nothing to do with the behaviour it pins (#240). A wrap inserts no character of its own, so the visible screen with its row breaks removed is the path as printed, whatever its length. Verified from a 201-character directory: the old form fails there, this one passes, and both still fail if spawn stops defaulting to the test process's directory. Closes #240 Signed-off-by: Vyncint Ng --- crates/termlens/tests/builder_validation.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/termlens/tests/builder_validation.rs b/crates/termlens/tests/builder_validation.rs index bf3f7b1..9f5d38f 100644 --- a/crates/termlens/tests/builder_validation.rs +++ b/crates/termlens/tests/builder_validation.rs @@ -35,7 +35,12 @@ fn the_default_working_directory_is_the_test_process_s() -> termlens::Result<()> // test runner's (#215). Pinned here so the default cannot move quietly. let mut t = Terminal::builder().spawn("/bin/pwd")?; assert!(t.wait_exit()?.success()); - let reported = std::path::PathBuf::from(t.screen().row_text(0).trim()); + // The path is read off the grid, and a path longer than the 80 columns + // wraps onto the next row — a checkout under a long temporary directory + // is enough (#240). A wrap inserts no character of its own, so the + // whole screen with the row breaks removed is the path as printed, + // whatever its length. + let reported = std::path::PathBuf::from(t.screen().text().replace('\n', "").trim()); let expected = std::env::current_dir()?; assert_eq!( reported.canonicalize()?, From 72c23679e8a357b24b7bfdf70d8f72e34d2172e8 Mon Sep 17 00:00:00 2001 From: Vyncint Ng Date: Sat, 5 Sep 2026 07:58:29 +0700 Subject: [PATCH 05/14] =?UTF-8?q?fix(emu):=20translate=20the=20UK=20charac?= =?UTF-8?q?ter=20set's=20#=20as=20=C2=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ESC ( A` designates the DEC United Kingdom set, whose one difference from ASCII is that `#` draws `£`. The tracker parsed the designation and then rendered ASCII, so a price printed in the UK set showed `#42` on the grid, a test asserting `£42` failed against a correct application, and a snapshot that blessed `#42` kept passing (#234). The charset enum gains a `Uk` variant, the designation parser recognises `A`, and the glyph lookup — renamed from graphics_glyph to charset_glyph now that it serves two sets — maps the single byte `#` to `£` for it. The staging path in the backend already rewrites a byte into the glyph it draws, so nothing downstream changes. Designations with no table (the alternate ROMs, the other national sets) still read as ASCII, and the module docs and README now say which sets are translated. Closes #234 Signed-off-by: Vyncint Ng --- CHANGELOG.md | 8 ++++ README.md | 7 ++-- crates/termlens/src/emu/seq.rs | 67 ++++++++++++++++++-------------- crates/termlens/src/emu/vt100.rs | 2 +- crates/termlens/tests/charset.rs | 22 +++++++++++ 5 files changed, 73 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f011d5..1330b4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,14 @@ listed under a **Changed** or **Removed** heading. shifts remain G0/G1 only (`SO`/`SI`); `LS2`/`LS3` and `DECSC`/`DECRC` of charset state are still unmodelled. (#235) +- **The UK character set is translated: `ESC ( A` then `#` draws `£`.** + The designation was parsed and then rendered as ASCII, so an application + printing a price in the UK set showed `#42` on the grid, a test asserting + `£42` failed against a correct application, and a snapshot that blessed + `#42` kept passing. The set differs from ASCII in that one position, so + that is the one byte translated; the alternate-ROM sets and the other + national sets still read as ASCII, and the docs now say which sets are + translated. (#234) - **`find` no longer matches the blank padding past the end of a row.** Its single-row path searched the row padded out to the terminal width while `contains` searched the trimmed text, so `find("Total: ")` was `Some` on diff --git a/README.md b/README.md index 242051b..7dd367f 100644 --- a/README.md +++ b/README.md @@ -232,10 +232,11 @@ design. termlens's position: decision (`Terminal::resize` says why). The visible grid stays the fully-featured surface. - **Character sets: G0–G3 designation, SO/SI locking shifts, SS2/SS3 - single shifts, and one set translated.** `ESC ( ) * + Ps` designations, + single shifts, and two sets translated.** `ESC ( ) * + Ps` designations, the `SO`/`SI` locking shifts, and `ESC N`/`ESC O` (SS2/SS3, one character) - are modelled, and the DEC Special Graphics set (`0`) is translated; every - other designation — the UK set, the alternate ROMs — reads as ASCII. + are modelled; the DEC Special Graphics set (`0`) and the UK set (`A`, + `£` at `#`) are translated, and every other designation — the alternate + ROMs, the other national sets — is acknowledged and reads as ASCII. Locking shifts remain G0/G1 only (`LS2`/`LS3` are not modelled). `DECSC`/`DECRC` do not save or restore the charset state. - `wait_frame` needs the application to bracket its repaints in DEC 2026 diff --git a/crates/termlens/src/emu/seq.rs b/crates/termlens/src/emu/seq.rs index b62c057..9933f3f 100644 --- a/crates/termlens/src/emu/seq.rs +++ b/crates/termlens/src/emu/seq.rs @@ -26,8 +26,10 @@ //! letters — which is how ncurses draws every border (`smacs`/`rmacs` on an //! xterm terminfo are exactly `ESC ( 0` / `ESC ( B`). A mixed line of text //! and box-drawing uses the single shift instead, so `ESC * 0 ESC N l` is -//! `┌` and the character after it is back to the locked set. Only the DEC -//! Special Graphics set is translated; every other designation reads as +//! `┌` and the character after it is back to the locked set. Two sets are +//! translated: DEC Special Graphics, and the UK set (`ESC ( A`), whose one +//! difference from ASCII is `£` at `#`; every other designation — the +//! alternate ROMs, the other national sets — is acknowledged and reads as //! ASCII. Locking shifts remain G0/G1 (`SO`/`SI`); `LS2`/`LS3` are not //! modelled. The tracker decides, the emulator rewrites: the bytes the //! parsers see are then a translated stream rather than a sub-slice of the @@ -117,9 +119,12 @@ pub(crate) fn decode_base64(input: &[u8]) -> Option> { /// Which glyph set a G0–G3 designation currently names. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Charset { - /// ASCII — and every national replacement set, which differ from it in - /// a handful of positions this crate does not model. + /// ASCII — and every designation this crate has no table for: the + /// alternate ROMs and the national replacement sets other than the UK + /// one, which differ from it in a handful of positions. Ascii, + /// The DEC United Kingdom set (`ESC ( A`): ASCII with `£` at `#`. + Uk, /// DEC Special Graphics (`ESC ( 0`): the line-drawing set. DecSpecialGraphics, } @@ -589,9 +594,10 @@ impl SeqTracker { std::mem::replace(&mut self.frame_printable, 0) } - /// The glyph `b` draws in the invoked character set, when that set is - /// DEC Special Graphics and `b` is one of the bytes it redefines — or - /// `None` when the byte draws as itself, or is not a character at all. + /// The glyph `b` draws in the invoked character set, when that set + /// redefines `b` — the 32 bytes DEC Special Graphics redraws, or `#` in + /// the UK set — or `None` when the byte draws as itself, or is not a + /// character at all. /// /// Consulted **before** the byte is stepped: whether a byte is a /// character depends on the state the tracker is in before it, and the @@ -599,14 +605,15 @@ impl SeqTracker { /// single shift is read here and consumed in `transition` when the /// character is processed, so a second look (the open-link label) still /// sees the same set. - pub(crate) fn graphics_glyph(&self, b: u8) -> Option<&'static str> { + pub(crate) fn charset_glyph(&self, b: u8) -> Option<&'static str> { if self.state != State::Ground { return None; } - if self.invoked_charset() != Charset::DecSpecialGraphics { - return None; + match self.invoked_charset() { + Charset::Ascii => None, + Charset::Uk => (b == b'#').then_some("\u{a3}"), + Charset::DecSpecialGraphics => dec_special_graphics(b), } - dec_special_graphics(b) } fn invoked_charset(&self) -> Charset { @@ -625,15 +632,17 @@ impl SeqTracker { /// Apply the final byte of an `ESC ( ) * + Ps` designation. /// - /// `0` is DEC Special Graphics. Everything else — `B` (ASCII), `A` (UK, - /// which differs from ASCII only at `#`), the alternate-ROM sets — - /// reads as ASCII: close enough for every one of them that guessing at - /// the odd position would be a worse answer than the plain one. + /// `0` is DEC Special Graphics and `A` the United Kingdom set, whose one + /// difference from ASCII is `£` at `#`. Everything else — `B` (ASCII), + /// the alternate-ROM sets `1` and `2`, the other national sets — reads + /// as ASCII: a designation acknowledged and left untranslated, which is + /// close enough for every one of them that guessing at the odd position + /// would be a worse answer than the plain one. fn designate(&mut self, final_byte: u8) { - let set = if final_byte == b'0' { - Charset::DecSpecialGraphics - } else { - Charset::Ascii + let set = match final_byte { + b'0' => Charset::DecSpecialGraphics, + b'A' => Charset::Uk, + _ => Charset::Ascii, }; match self.esc_intermediate { b'(' => self.g0 = set, @@ -1166,7 +1175,7 @@ impl SeqTracker { // as the glyph it draws: the label is what a reader sees // and clicks, and a reader sees `─`, not `q`. if printable && self.link_open { - let drawn: &[u8] = match self.graphics_glyph(b) { + let drawn: &[u8] = match self.charset_glyph(b) { Some(glyph) => glyph.as_bytes(), None => std::slice::from_ref(&b), }; @@ -1955,7 +1964,7 @@ mod tests { let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE); let mut out = String::new(); for &b in bytes { - match t.graphics_glyph(b) { + match t.charset_glyph(b) { Some(glyph) => out.push_str(glyph), None if t.state == State::Ground && (0x20..0x7f).contains(&b) => { out.push(b as char); @@ -2055,13 +2064,13 @@ mod tests { let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE); t.feed(b"\x1b*0\x1bN"); assert_eq!( - t.graphics_glyph(b'l'), + t.charset_glyph(b'l'), Some("\u{250c}"), "shift pending before {ch}" ); t.feed(ch.as_bytes()); assert_eq!( - t.graphics_glyph(b'l'), + t.charset_glyph(b'l'), None, "shift must not survive {ch} and translate the next l" ); @@ -2089,19 +2098,19 @@ mod tests { for &b in b"\x1b(0" { t.step(b); } - assert_eq!(t.graphics_glyph(b'q'), Some("\u{2500}")); + assert_eq!(t.charset_glyph(b'q'), Some("\u{2500}")); for &b in b"\x1b]0;lqqk" { - assert_eq!(t.graphics_glyph(b), None, "inside an OSC: {b:?}"); + assert_eq!(t.charset_glyph(b), None, "inside an OSC: {b:?}"); t.step(b); } t.step(0x07); assert_eq!(&*t.title(), "lqqk", "the title keeps its letters"); for &b in b"\x1b[3" { - assert_eq!(t.graphics_glyph(b), None, "inside a CSI: {b:?}"); + assert_eq!(t.charset_glyph(b), None, "inside a CSI: {b:?}"); t.step(b); } t.step(b'm'); - assert_eq!(t.graphics_glyph(b'x'), Some("\u{2502}"), "and ground again"); + assert_eq!(t.charset_glyph(b'x'), Some("\u{2502}"), "and ground again"); } /// Other national sets and the alternate ROMs read as ASCII, and a second @@ -2135,14 +2144,14 @@ mod tests { t.feed(b"\x1b("); assert!(t.mid_sequence()); t.feed(b"0"); - assert_eq!(t.graphics_glyph(b'l'), Some("\u{250c}")); + assert_eq!(t.charset_glyph(b'l'), Some("\u{250c}")); let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE); t.feed(b"\x1b*"); assert!(t.mid_sequence()); t.feed(b"0\x1bN"); assert!(!t.mid_sequence()); - assert_eq!(t.graphics_glyph(b'l'), Some("\u{250c}")); + assert_eq!(t.charset_glyph(b'l'), Some("\u{250c}")); } #[test] diff --git a/crates/termlens/src/emu/vt100.rs b/crates/termlens/src/emu/vt100.rs index 8438373..2e17464 100644 --- a/crates/termlens/src/emu/vt100.rs +++ b/crates/termlens/src/emu/vt100.rs @@ -222,7 +222,7 @@ impl Emulator for Vt100Emulator { // Asked before the step, because whether this byte is a character // at all depends on the state the tracker is in before it — and // a designation's own final byte must not be drawn. - if let Some(glyph) = self.tracker.graphics_glyph(byte) { + if let Some(glyph) = self.tracker.charset_glyph(byte) { self.staged.extend_from_slice(&bytes[fed..i]); self.staged.extend_from_slice(glyph.as_bytes()); fed = i + 1; diff --git a/crates/termlens/tests/charset.rs b/crates/termlens/tests/charset.rs index 3f21c3a..eb22dd3 100644 --- a/crates/termlens/tests/charset.rs +++ b/crates/termlens/tests/charset.rs @@ -169,3 +169,25 @@ fn a_multibyte_character_consumes_a_single_shift() -> termlens::Result<()> { assert!(t.wait_exit()?.success()); Ok(()) } + +/// The UK set (`ESC ( A`) differs from ASCII in one position: `#` draws +/// `£`. The designation used to be consumed and the byte drawn as itself, +/// so a price in the UK set read `#42` and a test asserting `£42` failed +/// against an application that was correct (#234). `SO`/`SI` select it the +/// way they select the graphics set. +#[test] +fn the_uk_set_draws_a_pound_sign_at_hash() -> termlens::Result<()> { + let mut t = sh(concat!( + r"printf '\033(A#42 a-z\033(B#\n'; ", + r"printf '\033)A\016#\017#\n'; ", + "printf DONE; read _" + ))?; + t.wait_until(|s| s.contains("DONE"))?; + let s = t.screen(); + assert_eq!(s.row_text(0).trim_end(), "£42 a-z#", "{s}"); + assert_eq!(s.row_text(1).trim_end(), "£#", "{s}"); + assert_eq!(s.find("£"), Some((0, 0)), "one cell, one column: {s}"); + t.send(Key::Enter)?; + assert!(t.wait_exit()?.success()); + Ok(()) +} From 96208ccadc4fca483819b079bbe8e56f700a901b Mon Sep 17 00:00:00 2001 From: Vyncint Ng Date: Sat, 5 Sep 2026 08:00:41 +0700 Subject: [PATCH 06/14] fix(emu): save and restore the charset state on DECSC/DECRC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Save, jump, draw the frame, restore is how a full-screen application draws a border, and DECRC (ESC 8) left the character sets as whatever was designated in between, so the border after it rendered as `lqk` — the failure #204 fixed for the designation itself, arriving through a different door (#232). The tracker gains a saved slot holding G0–G3 and the locking shift. ESC 7 fills it, ESC 8 restores it, a restore with nothing saved returns to the power-on defaults as xterm does, and RIS clears the slot so a restore after a reset cannot resurrect a designation from before it. vt100 saves and restores the cursor and attributes itself; only the half it does not know about lives here. Closes #232 Signed-off-by: Vyncint Ng --- CHANGELOG.md | 8 ++++ README.md | 4 +- crates/termlens/src/emu/seq.rs | 76 ++++++++++++++++++++++++++++---- crates/termlens/tests/charset.rs | 39 +++++++++++++++- 4 files changed, 115 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1330b4c..35698d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,14 @@ listed under a **Changed** or **Removed** heading. shifts remain G0/G1 only (`SO`/`SI`); `LS2`/`LS3` and `DECSC`/`DECRC` of charset state are still unmodelled. (#235) +- **`DECSC`/`DECRC` save and restore the character-set state.** Save, + jump, draw the frame, restore is how a full-screen application draws a + border, and the restore lost the designation, so the border after it + rendered as `lqk` — the failure #204 fixed, arriving through a different + door. `ESC 7` now saves G0–G3 and the locking shift alongside the cursor + the backend already saved, `ESC 8` restores them, a restore with nothing + saved returns to ASCII as xterm does, and `RIS` clears the slot so a + restore cannot resurrect a designation from before the reset. (#232) - **The UK character set is translated: `ESC ( A` then `#` draws `£`.** The designation was parsed and then rendered as ASCII, so an application printing a price in the UK set showed `#42` on the grid, a test asserting diff --git a/README.md b/README.md index 7dd367f..6b537cc 100644 --- a/README.md +++ b/README.md @@ -237,8 +237,8 @@ design. termlens's position: are modelled; the DEC Special Graphics set (`0`) and the UK set (`A`, `£` at `#`) are translated, and every other designation — the alternate ROMs, the other national sets — is acknowledged and reads as ASCII. - Locking shifts remain G0/G1 only (`LS2`/`LS3` are not modelled). - `DECSC`/`DECRC` do not save or restore the charset state. + `DECSC`/`DECRC` save and restore this state with the cursor. Locking + shifts remain G0/G1 only (`LS2`/`LS3` are not modelled). - `wait_frame` needs the application to bracket its repaints in DEC 2026 synchronized updates, and only the last 8 completed frames are retained; everything else waits with `wait_until`, under the three rules in diff --git a/crates/termlens/src/emu/seq.rs b/crates/termlens/src/emu/seq.rs index 9933f3f..5764e1e 100644 --- a/crates/termlens/src/emu/seq.rs +++ b/crates/termlens/src/emu/seq.rs @@ -30,8 +30,10 @@ //! translated: DEC Special Graphics, and the UK set (`ESC ( A`), whose one //! difference from ASCII is `£` at `#`; every other designation — the //! alternate ROMs, the other national sets — is acknowledged and reads as -//! ASCII. Locking shifts remain G0/G1 (`SO`/`SI`); `LS2`/`LS3` are not -//! modelled. The tracker decides, the emulator rewrites: the bytes the +//! ASCII. `DECSC`/`DECRC` save and restore this state alongside the cursor, +//! and `RIS` returns it to power-on. Locking shifts remain G0/G1 +//! (`SO`/`SI`); `LS2`/`LS3` are not modelled. The tracker decides, the +//! emulator rewrites: the bytes the //! parsers see are then a translated stream rather than a sub-slice of the //! read, which `emu/vt100.rs` stages. @@ -129,6 +131,17 @@ enum Charset { DecSpecialGraphics, } +/// What `DECSC` saves of the character-set state — see +/// [`SeqTracker::saved_charsets`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct SavedCharsets { + g0: Charset, + g1: Charset, + g2: Charset, + g3: Charset, + shifted_out: bool, +} + /// The glyph a byte draws in DEC Special Graphics, where it differs from /// ASCII: the 32 bytes `0x5f..=0x7e`. Everything below `_` draws as itself. /// The mapping is xterm's, which is also what every terminfo `acsc` string @@ -408,6 +421,13 @@ pub(crate) struct SeqTracker { /// an intervening control or designation must not consume it, or a /// mixed line that shifts then redraws would lose the graphic. single_shift: Option, + /// The charset half of the state `DECSC` (`ESC 7`) saves: G0–G3 and + /// which of G0/G1 is locked in. `DECRC` (`ESC 8`) restores it; with + /// nothing saved it restores the power-on defaults, as xterm does. vt100 + /// saves and restores the cursor and attributes itself, so only the + /// half it does not know about lives here. `None` after RIS, or a + /// restore after a reset would resurrect a designation from before it. + saved_charsets: Option, /// Which introducer opened the current DCS-class string: `P` (DCS), /// `X` (SOS), `^` (PM) or `_` (APC). Sixel and kitty graphics differ /// only by this, so consuming all four alike — which is all the tracker @@ -478,6 +498,7 @@ impl SeqTracker { g3: Charset::Ascii, shifted_out: false, single_shift: None, + saved_charsets: None, dcs_introducer: 0, dcs_final: 0, dcs_intermediate: 0, @@ -630,6 +651,18 @@ impl SeqTracker { } } + /// Every set back to ASCII with G0 invoked and no single shift pending: + /// the power-on charset state, which RIS returns to and which `DECRC` + /// with nothing saved restores. + fn reset_charsets(&mut self) { + self.g0 = Charset::Ascii; + self.g1 = Charset::Ascii; + self.g2 = Charset::Ascii; + self.g3 = Charset::Ascii; + self.shifted_out = false; + self.single_shift = None; + } + /// Apply the final byte of an `ESC ( ) * + Ps` designation. /// /// `0` is DEC Special Graphics and `A` the United Kingdom set, whose one @@ -1244,15 +1277,42 @@ impl SeqTracker { // the terminal still holds. b'c' => { self.cursor_style = None; - self.g0 = Charset::Ascii; - self.g1 = Charset::Ascii; - self.g2 = Charset::Ascii; - self.g3 = Charset::Ascii; - self.shifted_out = false; - self.single_shift = None; + self.reset_charsets(); + self.saved_charsets = None; self.close_link(); State::Ground } + // DECSC / DECRC: the charset half of save-cursor and + // restore-cursor. The idiom is save, jump, draw a border, + // restore — and a restore that forgot the designation + // rendered the border after it as `lqk`. vt100 does the + // cursor and attributes; the sets are ours (#232). + b'7' => { + self.saved_charsets = Some(SavedCharsets { + g0: self.g0, + g1: self.g1, + g2: self.g2, + g3: self.g3, + shifted_out: self.shifted_out, + }); + State::Ground + } + b'8' => { + match self.saved_charsets { + Some(saved) => { + self.g0 = saved.g0; + self.g1 = saved.g1; + self.g2 = saved.g2; + self.g3 = saved.g3; + self.shifted_out = saved.shifted_out; + } + // Nothing saved: xterm restores the defaults rather + // than leaving the current designation, and so do + // we — the least surprising answer, and no new state. + None => self.reset_charsets(), + } + State::Ground + } // SS2 / SS3: invoke G2 / G3 for the next character only. // These are two-character escapes in the *output* stream; // `ESC O A` as a DECCKM cursor key is what we *send*, a diff --git a/crates/termlens/tests/charset.rs b/crates/termlens/tests/charset.rs index eb22dd3..426bb4e 100644 --- a/crates/termlens/tests/charset.rs +++ b/crates/termlens/tests/charset.rs @@ -83,10 +83,11 @@ fn a_styled_border_keeps_its_style() -> termlens::Result<()> { /// A hard reset returns both sets to ASCII — and clears the screen, as RIS /// does, so the glyph drawn before it is gone and the byte after it is a -/// letter again. +/// letter again. It clears the `DECSC` slot too: a `DECRC` after the reset +/// must not resurrect a designation from before it (#232). #[test] fn a_hard_reset_returns_to_ascii() -> termlens::Result<()> { - let mut t = sh(r"printf '\033(0q\033cq'; printf DONE; read _")?; + let mut t = sh(r"printf '\033(0q\0337\033c\0338q'; printf DONE; read _")?; t.wait_until(|s| s.contains("DONE"))?; let s = t.screen(); assert_eq!(s.row_text(0).trim_end(), "qDONE", "{s}"); @@ -96,6 +97,40 @@ fn a_hard_reset_returns_to_ascii() -> termlens::Result<()> { Ok(()) } +/// The save/jump/draw/restore idiom: `DECSC` saves the designated sets and +/// the locking shift with the cursor, `DECRC` brings them back. The +/// restore used to leave whatever was designated in between, so a border +/// drawn after it read `lqk` (#232). Row 1 restores an `SO` state, since +/// the shift is part of what is saved. +#[test] +fn decsc_and_decrc_save_and_restore_the_charset_state() -> termlens::Result<()> { + let mut t = sh(concat!( + r"printf '\033(0\0337\033(B\0338lqk\033(B\n'; ", + r"printf '\033)0\016\0337\017\0338lqk\017\n'; ", + "printf DONE; read _" + ))?; + t.wait_until(|s| s.contains("DONE"))?; + let s = t.screen(); + assert_eq!(s.row_text(0).trim_end(), "┌─┐", "{s}"); + assert_eq!(s.row_text(1).trim_end(), "┌─┐", "{s}"); + t.send(Key::Enter)?; + assert!(t.wait_exit()?.success()); + Ok(()) +} + +/// `DECRC` with nothing saved restores the defaults, as xterm does — G0 at +/// ASCII — rather than leaving whatever was last designated. +#[test] +fn decrc_with_nothing_saved_returns_to_ascii() -> termlens::Result<()> { + let mut t = sh(r"printf '\033(0\0338lqk'; printf DONE; read _")?; + t.wait_until(|s| s.contains("DONE"))?; + let s = t.screen(); + assert_eq!(s.row_text(0).trim_end(), "lqkDONE", "{s}"); + t.send(Key::Enter)?; + assert!(t.wait_exit()?.success()); + Ok(()) +} + /// SS2/SS3 invoke G2/G3 for one character, then the locking shift resumes. /// `|` is itself a Special Graphics byte (`≠`); a shift that stuck would /// translate it, which is worse than never shifting. From 201342e0f133bc63b2a97cc37607c6b8ff907508 Mon Sep 17 00:00:00 2001 From: Vyncint Ng Date: Sat, 5 Sep 2026 08:03:28 +0700 Subject: [PATCH 07/14] fix(emu): model DECSTR, the soft reset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `CSI ! p` parsed cleanly and did nothing, so an application that soft-reset on teardown and then printed text had that text rendered in the character set it had just told the terminal to forget — while RIS got the same case right, leaving the pair inconsistent rather than uniformly unimplemented (#233). The tracker recognises the `!` intermediate and, on `p`, returns its own state to power-on: the character sets and the DECSC slot, the cursor shape, focus reporting. It then reports a SoftReset event, and the backend replays to vt100 — which does not implement DECSTR — the sequences that reset the modes it holds: cursor-key mode, bracketed paste, every mouse tracking mode and encoding, and cursor visibility. The alternate screen is left alone, as specified. Attributes, margins, origin and insert modes and the keypad are deliberately not replayed: nothing on Screen observes them, and a replay nothing can check is a claim nothing can catch. The README lists both halves. Closes #233 Signed-off-by: Vyncint Ng --- CHANGELOG.md | 11 +++++++ README.md | 6 +++- crates/termlens/src/emu/seq.rs | 43 +++++++++++++++++++++---- crates/termlens/src/emu/vt100.rs | 22 +++++++++++++ crates/termlens/tests/charset.rs | 23 ++++++++++++++ crates/termlens/tests/state.rs | 54 ++++++++++++++++++++++++++++++++ 6 files changed, 152 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 35698d5..9abb48c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,6 +76,17 @@ listed under a **Changed** or **Removed** heading. the backend already saved, `ESC 8` restores them, a restore with nothing saved returns to ASCII as xterm does, and `RIS` clears the slot so a restore cannot resurrect a designation from before the reset. (#232) +- **`DECSTR` (soft reset, `CSI ! p`) is modelled.** The polite reset a + well-behaved TUI sends on startup and teardown parsed cleanly and did + nothing, so text printed after it kept rendering in the character set the + application had told the terminal to forget, while `RIS` got this right. + It now returns the character sets and the `DECSC` slot to power-on, the + cursor shape to the terminal's default, and turns off cursor-key mode, + bracketed paste, every mouse tracking mode and encoding, and focus + reporting; the cursor becomes visible and the alternate screen is left + alone, as specified. Attributes, margins, origin and insert modes and the + keypad are not replayed — nothing on `Screen` observes them — and the + README says so. (#233) - **The UK character set is translated: `ESC ( A` then `#` draws `£`.** The designation was parsed and then rendered as ASCII, so an application printing a price in the UK set showed `#42` on the grid, a test asserting diff --git a/README.md b/README.md index 6b537cc..5aa4aa5 100644 --- a/README.md +++ b/README.md @@ -273,7 +273,11 @@ design. termlens's position: terminal would infer.** The cursor shape follows `DECSCUSR` and is cleared by a hard reset (`RIS`); the window title is not, because in xterm the title is a window property that `RIS` does not restore, and guessing either - way would be the same error. Nothing here models `DECSTR` (soft reset). + way would be the same error. `DECSTR` (soft reset) resets what a `Screen` + can observe — cursor keys, bracketed paste, mouse tracking, focus + reporting, the cursor's visibility and shape, the character sets — and + leaves the alternate screen alone; attributes, margins, origin and insert + modes and the keypad are not modelled. - **Two SGR style attributes are not modeled.** Overline (`SGR 53`) and double underline (`SGR 21`) do not reach [`Style`](https://docs.rs/termlens/latest/termlens/struct.Style.html), so `with_styles()` cannot distinguish those attributes from a plain cell. diff --git a/crates/termlens/src/emu/seq.rs b/crates/termlens/src/emu/seq.rs index 5764e1e..3ed46ab 100644 --- a/crates/termlens/src/emu/seq.rs +++ b/crates/termlens/src/emu/seq.rs @@ -31,7 +31,7 @@ //! difference from ASCII is `£` at `#`; every other designation — the //! alternate ROMs, the other national sets — is acknowledged and reads as //! ASCII. `DECSC`/`DECRC` save and restore this state alongside the cursor, -//! and `RIS` returns it to power-on. Locking shifts remain G0/G1 +//! and `RIS` and `DECSTR` return it to power-on. Locking shifts remain G0/G1 //! (`SO`/`SI`); `LS2`/`LS3` are not modelled. The tracker decides, the //! emulator rewrites: the bytes the //! parsers see are then a translated stream rather than a sub-slice of the @@ -222,6 +222,10 @@ pub(crate) enum SeqEvent { SyncBegin, /// A `CSI ? 2026 … l` completed: a frame is now complete. SyncEnd, + /// `DECSTR` (`CSI ! p`) completed: the tracker has already returned + /// its own state to the defaults; the emulator must do the same for + /// the modes it holds. + SoftReset, /// The application asked the terminal a question. Query(Query), /// An inline graphics payload completed. Carried out of the tracker @@ -663,6 +667,20 @@ impl SeqTracker { self.single_shift = None; } + /// The tracker's half of `DECSTR`: the character sets and the `DECSC` + /// slot back to power-on, the cursor shape back to the terminal's + /// default, focus reporting off. The window title, the clipboard, the + /// bell count and the link log stay, for the same reason they survive + /// `RIS`: they are records of what the application emitted, not modes + /// the terminal holds. An open link span stays open too — a soft reset + /// clears no screen, so the text after it is still the span's text. + fn soft_reset(&mut self) { + self.reset_charsets(); + self.saved_charsets = None; + self.cursor_style = None; + self.focus_events = false; + } + /// Apply the final byte of an `ESC ( ) * + Ps` designation. /// /// `0` is DEC Special Graphics and `A` the United Kingdom set, whose one @@ -756,11 +774,11 @@ impl SeqTracker { self.csi_has_digits = true; } b';' => self.end_csi_param(), - // `$` is the intermediate of the DECRQM request (`CSI ? n $ p`) - // and `SP` of DECSCUSR (`CSI Ps SP q`); recording them keeps - // those sequences classifiable instead of discarding them as - // unrecognized. - b'$' | b' ' => self.csi_intermediate = b, + // `$` is the intermediate of the DECRQM request (`CSI ? n $ p`), + // `SP` of DECSCUSR (`CSI Ps SP q`) and `!` of DECSTR + // (`CSI ! p`); recording them keeps those sequences + // classifiable instead of discarding them as unrecognized. + b'$' | b' ' | b'!' => self.csi_intermediate = b, // Sub-parameters or other intermediates: none of the sequences // we recognize use them. _ => self.csi_invalid = true, @@ -793,6 +811,19 @@ impl SeqTracker { }; } + // DECSTR (`CSI ! p`), the soft reset: what a well-behaved TUI sends + // on startup and teardown for a known-good terminal without the + // screen clear RIS brings. The spec's list is long; the modes this + // crate holds are returned to their defaults here, and the emulator + // is told to do the same for the ones it holds (#233). + if self.csi_intermediate == b'!' { + if b == b'p' && self.csi_prefix == 0 && params_empty { + self.soft_reset(); + return SeqEvent::SoftReset; + } + return SeqEvent::None; + } + // DECSCUSR (`CSI Ps SP q`): the shape of the cursor, and whether it // blinks. vt100 models neither, so a modal editor switching to a bar // for insert mode is invisible without this — as is the program that diff --git a/crates/termlens/src/emu/vt100.rs b/crates/termlens/src/emu/vt100.rs index 2e17464..e45b4f1 100644 --- a/crates/termlens/src/emu/vt100.rs +++ b/crates/termlens/src/emu/vt100.rs @@ -236,6 +236,17 @@ impl Emulator for Vt100Emulator { self.record_graphics(*payload); None } + SeqEvent::SoftReset => { + // The tracker has reset what it holds; the grid's + // modes are vt100's, which does not implement DECSTR, + // so it is handed the sequences that reset them one by + // one. The soft reset itself goes through first, in + // stream order, then the replay. + self.feed_staged(&bytes[fed..=i]); + fed = i + 1; + self.feed(SOFT_RESET_REPLAY); + None + } SeqEvent::None => None, SeqEvent::SyncBegin => { // Stamped here, at the byte that opened the update, @@ -411,6 +422,17 @@ impl Emulator for Vt100Emulator { } } +/// What `DECSTR` resets of the state vt100 holds, as the sequences vt100 +/// understands: cursor keys back to normal (`DECCKM`), bracketed paste off, +/// every mouse tracking mode and encoding off, and the cursor visible +/// (`DECTCEM`). The rest of the specified list — attributes, margins, +/// origin and insert modes, the keypad — is deliberately not replayed: +/// none of it is observable through `Screen` today, and a replay nothing +/// can check is a claim nothing can catch. The alternate screen is left +/// alone, as the spec says. +const SOFT_RESET_REPLAY: &[u8] = + b"\x1b[?1l\x1b[?2004l\x1b[?9l\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1005l\x1b[?1006l\x1b[?25h"; + fn convert_mouse(mode: ::vt100::MouseProtocolMode) -> MouseMode { match mode { ::vt100::MouseProtocolMode::None => MouseMode::None, diff --git a/crates/termlens/tests/charset.rs b/crates/termlens/tests/charset.rs index 426bb4e..4d70dad 100644 --- a/crates/termlens/tests/charset.rs +++ b/crates/termlens/tests/charset.rs @@ -97,6 +97,29 @@ fn a_hard_reset_returns_to_ascii() -> termlens::Result<()> { Ok(()) } +/// A soft reset (`DECSTR`, `CSI ! p`) returns the sets to ASCII the way +/// `RIS` does — and unlike `RIS` clears nothing, so the glyph drawn before +/// it stays. It used to parse cleanly and do nothing, so text printed after +/// an application's teardown reset kept rendering as box drawing (#233). +#[test] +fn a_soft_reset_returns_to_ascii_without_clearing_the_screen() -> termlens::Result<()> { + let mut t = sh(concat!( + r"printf '\033(0q\033[!pq\n'; ", + r"printf '\033(0\0337\033[!p\0338lqk\n'; ", + "printf DONE; read _" + ))?; + t.wait_until(|s| s.contains("DONE"))?; + let s = t.screen(); + assert_eq!(s.row_text(0).trim_end(), "─q", "{s}"); + // Row 1 draws after a DECRC: the soft reset emptied the saved slot, so + // the restore returns to ASCII rather than to the graphics set saved + // before it. (The cursor it restores is the row's start, where it was.) + assert_eq!(s.row_text(1).trim_end(), "lqk", "{s}"); + t.send(Key::Enter)?; + assert!(t.wait_exit()?.success()); + Ok(()) +} + /// The save/jump/draw/restore idiom: `DECSC` saves the designated sets and /// the locking shift with the cursor, `DECRC` brings them back. The /// restore used to leave whatever was designated in between, so a border diff --git a/crates/termlens/tests/state.rs b/crates/termlens/tests/state.rs index 81f20e2..a90adfc 100644 --- a/crates/termlens/tests/state.rs +++ b/crates/termlens/tests/state.rs @@ -202,6 +202,60 @@ fn a_snapshot_keeps_its_own_view_of_the_links() -> termlens::Result<()> { Ok(()) } +/// `DECSTR` (`CSI ! p`) is the polite reset — no screen clear — that a +/// well-behaved TUI sends on teardown, and it used to have no effect at +/// all (#233). What it resets here is the list a test can check on a +/// `Screen`: cursor keys, bracketed paste, mouse tracking, focus reporting, +/// the cursor's visibility and shape, and the character sets (covered in +/// `charset.rs`). Attributes, margins, origin and insert modes and the +/// keypad are not replayed — nothing observes them, so nothing could catch +/// a wrong replay — and the alternate screen is left alone, as specified. +#[test] +fn a_soft_reset_returns_the_modes_a_screen_can_observe() -> termlens::Result<()> { + let mut t = Terminal::builder() + .timeout(Duration::from_secs(10)) + .args([ + "-c", + concat!( + r"printf '\033[?1049h\033[?1h\033[?2004h\033[?1000h\033[?1006h\033[?1004h\033[?25l\033[5 q'; ", + r"printf 'set'; read _; ", + r"printf '\033[!p'; ", + r"printf ' reset'; read _", + ), + ]) + .spawn("sh")?; + + t.wait_until(|s| { + s.contains("set") + && s.alternate_screen() + && s.application_cursor() + && s.bracketed_paste() + && s.mouse_mode() == MouseMode::PressRelease + && s.focus_events() + && !s.cursor().2 + && s.cursor_shape() == CursorShape::Bar + })?; + + t.send(Key::Enter)?; + t.wait_until(|s| s.contains("reset"))?; + let s = t.screen(); + assert!( + s.alternate_screen(), + "the alternate screen is left alone: {s}" + ); + assert!(!s.application_cursor(), "{s}"); + assert!(!s.bracketed_paste(), "{s}"); + assert_eq!(s.mouse_mode(), MouseMode::None, "{s}"); + assert!(!s.focus_events(), "{s}"); + assert!(s.cursor().2, "DECTCEM: the cursor is visible again: {s}"); + assert_eq!(s.cursor_shape(), CursorShape::Default, "{s}"); + assert_eq!(s.cursor_blink(), None, "{s}"); + + t.send(Key::Enter)?; + assert!(t.wait_exit()?.success()); + Ok(()) +} + #[test] fn mouse_mode_reports_the_exact_tracking_mode() -> termlens::Result<()> { let mut t = Terminal::builder() From d03b261e2bad395a6f7a898995fc587c13a41483 Mon Sep 17 00:00:00 2001 From: Vyncint Ng Date: Sat, 5 Sep 2026 08:07:45 +0700 Subject: [PATCH 08/14] feat(emu): track the set of mouse tracking modes the application asked for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The backend collapses ?9/?1000/?1002/?1003 into one mutually exclusive value — right for the input path, since a terminal reports in exactly one protocol and `click` must encode for it — so it could not say which members of the group an application enabled. crossterm's EnableMouseCapture sends three at once and only the last survived: a regression from any-motion to button-motion tracking (losing hover) was invisible, and a DECRQM probe for any member but the last had to be answered "not recognized" (#151). The sequence tracker now keeps the requested set, beside the focus flag and the window title that live there for the same reason. Every mode named in one `CSI ? … h/l` list is set or cleared together; RIS and DECSTR empty the set. Screen::mouse_modes reports it as a small MouseModes set (contains/is_empty/len/iter), DECRQM answers each tracking mode from it, and Screen::mouse_mode and the input path keep the backend's one-protocol answer unchanged. DESIGN.md's honesty paragraph is rewritten around the two facts. Closes #151 Signed-off-by: Vyncint Ng --- CHANGELOG.md | 10 +++ crates/termlens/src/emu/seq.rs | 60 +++++++++++-- crates/termlens/src/emu/vt100.rs | 95 +++++++++++++-------- crates/termlens/src/lib.rs | 2 +- crates/termlens/src/screen.rs | 142 ++++++++++++++++++++++++++++++- crates/termlens/tests/queries.rs | 54 ++++++++++++ crates/termlens/tests/state.rs | 66 +++++++++++++- docs/DESIGN.md | 26 ++++-- 8 files changed, 404 insertions(+), 51 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9abb48c..5bde7f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,16 @@ listed under a **Changed** or **Removed** heading. state means nothing. `wait_exit` is deliberately unaffected: the child's exit status is still true. (#211) +- **`Screen::mouse_modes` reports every mouse tracking mode the application + enabled, and `DECRQM` answers each one on its own evidence.** The backend + collapses `?9`/`?1000`/`?1002`/`?1003` into the one protocol a terminal + reports in — right for the input path, and unchanged there — so it could + not say which members of the group an application asked for: crossterm's + `EnableMouseCapture` sends three at once and only the last survived, a + regression from any-motion to button-motion tracking (losing hover) was + invisible, and a `DECRQM` probe for any member but the last had to be + answered "not recognized". The sequence tracker now keeps the requested + set; `mouse_mode()` still reports the protocol. (#151) - **The `inspect` example answers `--help`, and takes its deadline and silence window from flags.** `inspect --help` used to look for a program called `--help`, and both timings were hardcoded, so an application slower diff --git a/crates/termlens/src/emu/seq.rs b/crates/termlens/src/emu/seq.rs index 3ed46ab..0a5a28c 100644 --- a/crates/termlens/src/emu/seq.rs +++ b/crates/termlens/src/emu/seq.rs @@ -13,10 +13,12 @@ //! are parsed incrementally in O(1) space, and `?2026` is recognized //! anywhere in a multi-mode list such as `CSI ? 2026 ; 25 h`. //! -//! It also tracks the one piece of screen state the vt100 backend does not -//! expose: the **window title** (`OSC 0`/`OSC 2`), kept whole in its own -//! buffer — the diagnostic capture below truncates at 24 bytes, real titles -//! don't fit. +//! It also tracks screen state the vt100 backend does not expose: the +//! **window title** (`OSC 0`/`OSC 2`), kept whole in its own buffer — the +//! diagnostic capture below truncates at 24 bytes, real titles don't fit — +//! focus reporting (mode 1004), the cursor shape (`DECSCUSR`), and the +//! **set of mouse tracking modes** the application asked for, which vt100 +//! collapses into the one protocol it would report in. //! //! And it holds the **character-set state** vt100 ignores: which glyph set //! `ESC ( ) * + Ps` designated into G0–G3, which of G0/G1 `SI`/`SO` has @@ -40,7 +42,7 @@ use std::sync::Arc; use crate::graphics::{GraphicsBuilder, GraphicsCounts, GraphicsPayload}; -use crate::screen::{Clipboard, Link}; +use crate::screen::{Clipboard, Link, MouseModes}; /// OSC strings are captured whole (titles must not truncate), but bounded: /// a buggy or hostile stream must not grow memory without limit. No real @@ -327,6 +329,19 @@ fn printable(bytes: &[u8]) -> String { out } +/// The bit a DEC private mode number occupies in the tracked set of mouse +/// tracking modes, matching [`MouseModes`]'s layout; zero for any other +/// parameter, so accumulating over a whole list is one `|=` per value. +fn mouse_bit(mode: u32) -> u8 { + match mode { + 9 => 1, + 1000 => 2, + 1002 => 4, + 1003 => 8, + _ => 0, + } +} + #[derive(Debug)] pub(crate) struct SeqTracker { state: State, @@ -351,6 +366,10 @@ pub(crate) struct SeqTracker { /// anywhere in a multi-mode list, so scanning for it beats assuming it /// is the only parameter. csi_saw_1004: bool, + /// The mouse tracking modes (`9`, `1000`, `1002`, `1003`) named in the + /// current CSI's parameter list, as [`mouse_bit`]s — the same scan as + /// `csi_saw_1004`, for a group rather than one mode. + csi_saw_mouse: u8, /// Raw capture of the current sequence (from ESC), for diagnostics /// and DCS query recognition. Bounded; long sequences truncate. seq_buf: [u8; 24], @@ -402,6 +421,14 @@ pub(crate) struct SeqTracker { /// Tracked here because vt100 does not model 1004 at all — the same /// reason the window title is tracked here. focus_events: bool, + /// The mouse tracking modes the application has enabled and not yet + /// disabled, as [`mouse_bit`]s. vt100 collapses the four into the one + /// protocol it would report in — correct for the input path, since a + /// terminal reports in one protocol — which cannot say which members + /// of the group were asked for; crossterm asks for three at once. The + /// set is kept here so `DECRQM` can answer each mode on its own + /// evidence and a snapshot can report what was requested (#151). + mouse_tracking: u8, /// The raw `DECSCUSR` parameter the application last asked for, or /// `None` while it has never asked. Kept as the parameter rather than /// as a decoded shape so the one place that knows what `5` means is @@ -478,6 +505,7 @@ impl SeqTracker { csi_param_count: 0, csi_saw_2026: false, csi_saw_1004: false, + csi_saw_mouse: 0, seq_buf: [0; 24], seq_len: 0, osc_buf: Vec::new(), @@ -494,6 +522,7 @@ impl SeqTracker { capture, frame_printable: 0, focus_events: false, + mouse_tracking: 0, cursor_style: None, esc_intermediate: 0, g0: Charset::Ascii, @@ -608,6 +637,12 @@ impl SeqTracker { self.focus_events } + /// The mouse tracking modes the application has enabled and not yet + /// disabled — the requested set, not the one protocol vt100 reports in. + pub(crate) fn mouse_tracking(&self) -> MouseModes { + MouseModes::from_bits(self.mouse_tracking) + } + /// The raw `DECSCUSR` parameter last requested, `None` if never. pub(crate) fn cursor_style(&self) -> Option { self.cursor_style @@ -679,6 +714,7 @@ impl SeqTracker { self.saved_charsets = None; self.cursor_style = None; self.focus_events = false; + self.mouse_tracking = 0; } /// Apply the final byte of an `ESC ( ) * + Ps` designation. @@ -725,6 +761,7 @@ impl SeqTracker { self.csi_param_count = 0; self.csi_saw_2026 = false; self.csi_saw_1004 = false; + self.csi_saw_mouse = 0; } fn push_seq(&mut self, b: u8) { @@ -754,6 +791,7 @@ impl SeqTracker { if self.csi_param == 1004 { self.csi_saw_1004 = true; } + self.csi_saw_mouse |= mouse_bit(self.csi_param); if self.csi_param_count == 0 { self.csi_first_param = self.csi_param; } @@ -864,6 +902,17 @@ impl SeqTracker { } } + // The mouse tracking modes, kept as the set the application asked + // for — see `mouse_tracking`. Every mode named in one list is set + // or cleared together, which is how they arrive. + if self.csi_prefix == b'?' && self.csi_saw_mouse != 0 { + match b { + b'h' => self.mouse_tracking |= self.csi_saw_mouse, + b'l' => self.mouse_tracking &= !self.csi_saw_mouse, + _ => {} + } + } + // DEC private mode 2026 (synchronized output). if self.csi_prefix == b'?' && self.csi_saw_2026 { match b { @@ -1310,6 +1359,7 @@ impl SeqTracker { self.cursor_style = None; self.reset_charsets(); self.saved_charsets = None; + self.mouse_tracking = 0; self.close_link(); State::Ground } diff --git a/crates/termlens/src/emu/vt100.rs b/crates/termlens/src/emu/vt100.rs index e45b4f1..3e7048b 100644 --- a/crates/termlens/src/emu/vt100.rs +++ b/crates/termlens/src/emu/vt100.rs @@ -313,6 +313,7 @@ impl Emulator for Vt100Emulator { bracketed_paste: screen.bracketed_paste(), application_cursor: screen.application_cursor(), mouse: convert_mouse(screen.mouse_protocol_mode()), + mouse_modes: self.tracker.mouse_tracking(), clipboard: self.tracker.clipboard(), bells: self.tracker.bells(), focus_events: self.tracker.focus_events(), @@ -388,28 +389,23 @@ impl Emulator for Vt100Emulator { screen.mouse_protocol_encoding(), ::vt100::MouseProtocolEncoding::Utf8 )), - // The mouse tracking modes need care, because vt100 collapses - // all four into one mutually exclusive value. Two cases, and - // only one of them is ambiguous: - // - // - Nothing is tracking. Then nothing was collapsed, and every - // tracking mode is genuinely reset — a fact, not a guess. This - // is the state every application is in when it probes at - // startup, so it is the case that decides whether - // capability detection works at all. - // - A *different* mode is tracking. The application may have set - // several (crossterm's EnableMouseCapture sends 1000, 1002 and - // 1003 together) and vt100 kept only the last, so claiming the - // others are reset would be a guess dressed up as an answer. - // `NotRecognized` stays honest here. - 9 | 1000 | 1002 | 1003 => match screen.mouse_protocol_mode() { - ::vt100::MouseProtocolMode::None => ModeState::Reset, - ::vt100::MouseProtocolMode::Press if mode == 9 => ModeState::Set, - ::vt100::MouseProtocolMode::PressRelease if mode == 1000 => ModeState::Set, - ::vt100::MouseProtocolMode::ButtonMotion if mode == 1002 => ModeState::Set, - ::vt100::MouseProtocolMode::AnyMotion if mode == 1003 => ModeState::Set, - _ => ModeState::NotRecognized, - }, + // The mouse tracking modes, each on its own evidence: the + // tracker keeps the set the application asked for (#151). vt100 + // collapses the four into one value, so before this a probe for + // `1002` while `1003` was also on had to answer "not + // recognized" — the only honest reply to a question the state + // could not answer, and one crossterm's three-at-once enable + // provoked on every run. + 9 => on(self.tracker.mouse_tracking().contains(MouseMode::Press)), + 1000 => on(self + .tracker + .mouse_tracking() + .contains(MouseMode::PressRelease)), + 1002 => on(self + .tracker + .mouse_tracking() + .contains(MouseMode::ButtonMotion)), + 1003 => on(self.tracker.mouse_tracking().contains(MouseMode::AnyMotion)), _ => ModeState::NotRecognized, } } @@ -969,19 +965,50 @@ mod tests { } #[test] - fn an_active_tracking_mode_reports_itself_and_stays_silent_on_the_rest() { - let emu = emu_with(b"\x1b[?1002h"); - assert_eq!(emu.mode_state(1002), ModeState::Set); - // Genuinely ambiguous: crossterm's EnableMouseCapture sends 1000, - // 1002 and 1003 together and vt100 keeps only the last, so calling - // the others reset would be a guess dressed up as an answer. - for mode in [9, 1000, 1003] { - assert_eq!( - emu.mode_state(mode), - ModeState::NotRecognized, - "mode {mode}" - ); + fn each_tracking_mode_is_answered_on_its_own_evidence() { + // crossterm's EnableMouseCapture: three modes in one breath. vt100 + // keeps only the last, and before the tracker held the set (#151) + // the other two had to be answered "not recognized" — the only + // honest reply to a question the state could not answer. + let emu = emu_with(b"\x1b[?1000h\x1b[?1002h\x1b[?1003h"); + for mode in [1000, 1002, 1003] { + assert_eq!(emu.mode_state(mode), ModeState::Set, "mode {mode}"); } + assert_eq!(emu.mode_state(9), ModeState::Reset, "never asked for"); + // The set is what was asked for; the protocol is vt100's collapse. + let screen = emu.snapshot(); + assert_eq!(screen.mouse_mode(), MouseMode::AnyMotion); + assert_eq!( + screen.mouse_modes().iter().collect::>(), + [ + MouseMode::PressRelease, + MouseMode::ButtonMotion, + MouseMode::AnyMotion + ] + ); + + // Releasing one member releases that member alone in the set, while + // vt100 — like xterm — turns reporting off, since the protocol it + // was reporting in is gone. Both facts are true; each is reported + // where it belongs. + let emu = emu_with(b"\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1003l"); + assert_eq!(emu.mode_state(1003), ModeState::Reset); + assert_eq!(emu.mode_state(1002), ModeState::Set); + let screen = emu.snapshot(); + assert_eq!(screen.mouse_mode(), MouseMode::None); + assert!(screen.mouse_modes().contains(MouseMode::ButtonMotion)); + + // A list sets or clears every member it names, and a hard reset + // empties the set. + let emu = emu_with(b"\x1b[?1000;1002;1003h\x1b[?1000;1002l"); + assert_eq!( + emu.snapshot().mouse_modes().iter().collect::>(), + [MouseMode::AnyMotion] + ); + assert!(emu_with(b"\x1b[?1003h\x1bc") + .snapshot() + .mouse_modes() + .is_empty()); } #[test] diff --git a/crates/termlens/src/lib.rs b/crates/termlens/src/lib.rs index 2da41ff..ee7dfff 100644 --- a/crates/termlens/src/lib.rs +++ b/crates/termlens/src/lib.rs @@ -116,7 +116,7 @@ pub use graphics::{ GraphicsAction, GraphicsFormat, GraphicsPayload, GraphicsProtocol, GraphicsSeen, }; pub use keys::{Chord, Input, Key}; -pub use screen::{Cell, Clipboard, Color, CursorShape, Link, MouseMode, Screen, Style}; +pub use screen::{Cell, Clipboard, Color, CursorShape, Link, MouseMode, MouseModes, Screen, Style}; #[cfg(unix)] pub use terminal::Signal; pub use terminal::{ diff --git a/crates/termlens/src/screen.rs b/crates/termlens/src/screen.rs index 9a46e71..29f64de 100644 --- a/crates/termlens/src/screen.rs +++ b/crates/termlens/src/screen.rs @@ -99,6 +99,100 @@ pub enum MouseMode { AnyMotion, } +impl MouseMode { + /// The bit this mode occupies in a [`MouseModes`] set; `None` has none. + fn bit(self) -> u8 { + match self { + MouseMode::None => 0, + MouseMode::Press => 1, + MouseMode::PressRelease => 2, + MouseMode::ButtonMotion => 4, + MouseMode::AnyMotion => 8, + } + } + + /// Every tracking mode, in the order of the private modes that enable + /// them: `?9`, `?1000`, `?1002`, `?1003`. + const TRACKING: [MouseMode; 4] = [ + MouseMode::Press, + MouseMode::PressRelease, + MouseMode::ButtonMotion, + MouseMode::AnyMotion, + ]; +} + +/// The set of mouse tracking modes an application has enabled and not yet +/// disabled — what it *asked for*, as distinct from the one protocol the +/// terminal reports in, which is [`Screen::mouse_mode`]. +/// +/// A terminal reports mouse events in exactly one protocol, so the four +/// tracking modes collapse into one value on the input path: enabling +/// `?1003` after `?1002` upgrades the reports, disabling either turns them +/// off. But an application enables them as a set — crossterm's +/// `EnableMouseCapture` sends `?1000`, `?1002` and `?1003` together — and +/// "did it ask for any-motion tracking, or only button-motion?" is a +/// question about that set. Whether hovering does anything at all hangs on +/// it, and a regression from `?1003` to `?1002` passes every test that +/// only reads the collapsed value while the last mode sent stays the same. +/// +/// Read it from a snapshot via [`Screen::mouse_modes`]: +/// +/// ```no_run +/// # fn main() -> termlens::Result<()> { +/// # let mut t = termlens::Terminal::builder().spawn("true")?; +/// use termlens::MouseMode; +/// t.wait_until(|s| s.mouse_modes().contains(MouseMode::AnyMotion))?; +/// # Ok(()) +/// # } +/// ``` +#[derive(Clone, Copy, PartialEq, Eq, Hash, Default)] +pub struct MouseModes(u8); + +impl MouseModes { + pub(crate) fn from_bits(bits: u8) -> Self { + Self(bits) + } + + /// Whether the application currently has `mode` enabled. + /// + /// [`MouseMode::None`] is not a tracking mode; asking for it answers + /// whether the set is empty, so `contains(MouseMode::None)` reads as + /// "the application asked for no tracking at all". + #[must_use] + pub fn contains(self, mode: MouseMode) -> bool { + match mode { + MouseMode::None => self.is_empty(), + tracking => self.0 & tracking.bit() != 0, + } + } + + /// True while no tracking mode is enabled. + #[must_use] + pub fn is_empty(self) -> bool { + self.0 == 0 + } + + /// How many tracking modes are enabled. + #[must_use] + pub fn len(self) -> usize { + self.0.count_ones() as usize + } + + /// The enabled modes, in the order of the private modes that enable + /// them (`?9`, `?1000`, `?1002`, `?1003`). + pub fn iter(self) -> impl Iterator { + MouseMode::TRACKING + .into_iter() + .filter(move |mode| self.0 & mode.bit() != 0) + } +} + +impl fmt::Debug for MouseModes { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_set().entries(self.iter()).finish() + } +} + /// The shape of the cursor an application asked its terminal for with /// `DECSCUSR` (`CSI Ps SP q`). /// @@ -275,7 +369,12 @@ pub(crate) struct TermState { pub(crate) alternate_screen: bool, pub(crate) bracketed_paste: bool, pub(crate) application_cursor: bool, + /// The one protocol the terminal reports in — the backend's collapsed + /// value, which is also what `click` and `scroll` encode for. pub(crate) mouse: MouseMode, + /// The tracking modes the application has asked for and not yet + /// released, kept by the sequence tracker (#151). + pub(crate) mouse_modes: MouseModes, /// Behind an `Arc` deliberately: `Screen` is embedded in every /// `Error` and cloned on every wait, so its size is load-bearing. pub(crate) clipboard: Option>, @@ -313,6 +412,7 @@ impl Default for TermState { bracketed_paste: false, application_cursor: false, mouse: MouseMode::None, + mouse_modes: MouseModes::default(), clipboard: None, bells: 0, focus_events: false, @@ -596,16 +696,35 @@ impl Screen { self.state.focus_events } - /// Which mouse events the application asked to be reported — - /// [`MouseMode::None`] until it enables a tracking mode. + /// The protocol mouse events are reported in — [`MouseMode::None`] + /// until the application enables a tracking mode. /// [`Terminal::click`](crate::Terminal::click) and /// [`Terminal::scroll`](crate::Terminal::scroll) consult the same /// state, so their reports always match what the application expects. + /// + /// One value, because a terminal reports in one protocol: the four + /// tracking modes are mutually exclusive on the wire, the last one + /// enabled wins, and disabling any of them turns reporting off. For the + /// *set* the application asked for — which distinguishes an + /// application that enabled `?1002` and `?1003` from one that enabled + /// `?1003` alone — see [`mouse_modes`](Self::mouse_modes). #[must_use] pub fn mouse_mode(&self) -> MouseMode { self.state.mouse } + /// Every mouse tracking mode the application has enabled and not yet + /// disabled, as a set — see [`MouseModes`] for why the set and the + /// reporting protocol are two different facts. + /// + /// `DECRQM` answers each tracking mode from this same set, so an + /// application that probes `?1002` while `?1003` is also on is told + /// "set" rather than "not recognized". + #[must_use] + pub fn mouse_modes(&self) -> MouseModes { + self.state.mouse_modes + } + /// The most recent `OSC 52` clipboard write observed at this snapshot, /// or `None` if the application has not copied anything yet. /// @@ -1748,6 +1867,7 @@ mod tests { bracketed_paste: true, application_cursor: true, mouse: MouseMode::AnyMotion, + mouse_modes: MouseModes::from_bits(0b1110), clipboard: Some(Arc::new(Clipboard::new("c", Some("copied".into())))), bells: 3, focus_events: true, @@ -1766,6 +1886,24 @@ mod tests { assert_eq!(s.title(), "my app"); assert!(s.alternate_screen() && s.bracketed_paste() && s.application_cursor()); assert_eq!(s.mouse_mode(), MouseMode::AnyMotion); + let modes = s.mouse_modes(); + assert_eq!( + modes.iter().collect::>(), + [ + MouseMode::PressRelease, + MouseMode::ButtonMotion, + MouseMode::AnyMotion + ] + ); + assert!(modes.contains(MouseMode::ButtonMotion) && !modes.contains(MouseMode::Press)); + assert!(!modes.contains(MouseMode::None) && !modes.is_empty() && modes.len() == 3); + assert_eq!( + format!("{modes:?}"), + "{PressRelease, ButtonMotion, AnyMotion}" + ); + assert!( + default.mouse_modes().is_empty() && default.mouse_modes().contains(MouseMode::None) + ); let clip = s.clipboard().expect("captured"); assert_eq!((clip.targets(), clip.text()), ("c", Some("copied"))); assert_eq!(s.scrollback_rows(), 1); diff --git a/crates/termlens/tests/queries.rs b/crates/termlens/tests/queries.rs index 7c49c08..dbce498 100644 --- a/crates/termlens/tests/queries.rs +++ b/crates/termlens/tests/queries.rs @@ -331,6 +331,60 @@ fn a_probe_then_enable_application_gets_its_mouse() -> termlens::Result<()> { Ok(()) } +/// Each mouse tracking mode is answered on its own evidence now that the +/// tracker keeps the set the application asked for (#151). Before, a probe +/// for a member other than the last one enabled — which is every probe +/// after crossterm's three-at-once enable — was answered "not recognized". +#[test] +fn decrqm_answers_each_mouse_tracking_mode_on_its_own() -> termlens::Result<()> { + // Reply values: 1 = set, 2 = reset, 0 = not recognized. The reply is + // `ESC [ ? ; $ y`, so its length follows the mode's. + for (script, reply_len, expect, label) in [ + ( + r"printf '\033[?1000h\033[?1002h\033[?1003h\033[?1002$p'", + 11, + "[?1002;1$y", + "a member other than the last enabled is set", + ), + ( + r"printf '\033[?1000h\033[?1002h\033[?1003h\033[?9$p'", + 8, + "[?9;2$y", + "a member never asked for is reset, not unrecognized", + ), + ( + r"printf '\033[?1000h\033[?1002h\033[?1003h\033[?1003l\033[?1003$p'", + 11, + "[?1003;2$y", + "a released member is reset while the others stay", + ), + ( + r"printf '\033[?1000h\033[?1002h\033[?1003h\033[?1003l\033[?1002$p'", + 11, + "[?1002;1$y", + "and the others do stay set", + ), + ] { + let mut t = Terminal::builder() + .size(80, 6) + .timeout(Duration::from_secs(10)) + .args([ + "-c", + &format!( + "stty -icanon -echo; {script}; \ + head -c {reply_len} | tr -d '\\033'; printf ' DONE'; read guard" + ), + ]) + .spawn("/bin/sh")?; + t.wait_until(|s| s.contains("DONE"))?; + let row = t.screen().row_text(0); + assert!(row.contains(expect), "{label}: got {row:?}"); + t.send(Key::Enter)?; + assert!(t.wait_exit()?.success()); + } + Ok(()) +} + /// Mode 1004 is now answerable, because termlens tracks it exactly — the /// honesty rule's precondition. Before, an application probing for focus /// support was told "not recognized" even right after enabling it. diff --git a/crates/termlens/tests/state.rs b/crates/termlens/tests/state.rs index a90adfc..8f29c24 100644 --- a/crates/termlens/tests/state.rs +++ b/crates/termlens/tests/state.rs @@ -4,7 +4,7 @@ use std::time::Duration; -use termlens::{CursorShape, Key, MouseMode, Screen, Terminal}; +use termlens::{CursorShape, Key, MouseMode, MouseModes, Screen, Terminal}; /// One script walks the whole state surface: set everything, assert, then /// unwind everything and assert the way back. @@ -280,6 +280,70 @@ fn mouse_mode_reports_the_exact_tracking_mode() -> termlens::Result<()> { Ok(()) } +/// The set an application asked for is a different fact from the protocol +/// the terminal reports in, and only the latter was observable: crossterm +/// enables 1000, 1002 and 1003 together, the backend keeps the last, and an +/// application downgraded from any-motion to button-motion — losing hover +/// entirely — was indistinguishable from one that never had it (#151). The +/// input path keeps the collapsed value; the set is reported beside it. +#[test] +fn mouse_modes_reports_the_set_the_application_asked_for() -> termlens::Result<()> { + let mut t = Terminal::builder() + .timeout(Duration::from_secs(10)) + .args([ + "-c", + concat!( + r"printf '\033[?1000h\033[?1002h\033[?1003h\033[?1006h'; printf 'all three\n'; read _; ", + r"printf '\033[?1003l'; printf 'minus 1003\n'; read _; ", + r"printf '\033[?1002l\033[?1000l'; printf 'none\n'; read _", + ), + ]) + .spawn("sh")?; + + let set = |modes: &[MouseMode]| -> Vec { modes.to_vec() }; + t.wait_until(|s| { + s.contains("all three") + && s.mouse_mode() == MouseMode::AnyMotion + && s.mouse_modes().iter().collect::>() + == set(&[ + MouseMode::PressRelease, + MouseMode::ButtonMotion, + MouseMode::AnyMotion, + ]) + })?; + let s = t.screen(); + assert!( + s.mouse_modes().contains(MouseMode::ButtonMotion), + "{:?}", + s.mouse_modes() + ); + assert!( + !s.mouse_modes().contains(MouseMode::Press), + "{:?}", + s.mouse_modes() + ); + assert_eq!(s.mouse_modes().len(), 3); + + // Releasing 1003 alone: the set still holds the other two, while the + // protocol collapses to none — as xterm does, and as `click` needs. + t.send(Key::Enter)?; + t.wait_until(|s| { + s.contains("minus 1003") + && s.mouse_mode() == MouseMode::None + && s.mouse_modes().iter().collect::>() + == set(&[MouseMode::PressRelease, MouseMode::ButtonMotion]) + })?; + + t.send(Key::Enter)?; + t.wait_until(|s| s.contains("none") && s.mouse_modes().is_empty())?; + assert_eq!(t.screen().mouse_modes(), MouseModes::default()); + assert!(t.screen().mouse_modes().contains(MouseMode::None)); + + t.send(Key::Enter)?; + assert!(t.wait_exit()?.success()); + Ok(()) +} + #[test] fn a_clipboard_write_is_observable_with_its_payload() -> termlens::Result<()> { // The taskboard case from the coverage study: `y` copies the selected diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 9c8cce4..e160106 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -59,15 +59,25 @@ program nobody modified for us. Every reply is truthful or absent: modes whose state the emulator holds exactly report set/reset, and anything else reports "not recognized" rather than a guess. -The mouse tracking modes show where that line actually falls. The +The mouse tracking modes used to show where that line falls. The backend collapses `9`/`1000`/`1002`/`1003` into one mutually exclusive -value, so it cannot say which members of a group an application set — -crossterm's `EnableMouseCapture` sends three at once and only the last -survives. But that ambiguity exists only *while something is tracking*. +value — rightly, for the *input* path: a terminal reports in exactly one +protocol, the last mode enabled wins, and disabling any of them turns +reporting off, which is what xterm does and what `click` must encode +for. But that value cannot say which members of the group an +application *asked for*; crossterm's `EnableMouseCapture` sends three at +once and only the last survives, so a probe for `1002` while `1003` was +also on could only be answered "not recognized" — honest, and provoked +on every run. The sequence tracker now keeps the requested set, beside +the focus flag and the window title that live there for the same reason +(the backend does not model them), so `DECRQM` answers each tracking +mode on its own evidence and `Screen::mouse_modes` reports the set, +while `Screen::mouse_mode` and the input path keep the backend's +one-protocol answer. Both are facts; each is reported where it belongs. With no tracking mode active — the state every application probes from -at startup — nothing was collapsed and every tracking mode is genuinely -reset, so that is what we report. Answering "not recognized" there -would close a loop on itself: the application concludes the terminal has +at startup — every tracking mode reports reset, which is what lets +capability detection succeed: answering "not recognized" there would +close a loop on itself, where the application concludes the terminal has no mouse, never enables tracking, and `click` then refuses, blaming the application for a decision we caused. @@ -491,7 +501,7 @@ Rules: input modes (bracketed paste, application cursor, mouse tracking) are captured with every snapshot and read through plain accessors — `Screen::title`, `Screen::alternate_screen`, `Screen::bracketed_paste`, - `Screen::application_cursor`, `Screen::mouse_mode`, + `Screen::application_cursor`, `Screen::mouse_mode`, `Screen::mouse_modes`, `Screen::focus_events`, `Screen::clipboard`, `Screen::cursor_shape`, `Screen::cursor_blink`, `Screen::links`. Keeping them out of the rendering means existing snapshot files stay valid, and state From eb6f8a01b59db92fe37474f6c1a98740e7af9821 Mon Sep 17 00:00:00 2001 From: Vyncint Ng Date: Sat, 5 Sep 2026 08:09:27 +0700 Subject: [PATCH 09/14] ci(install): verify a --no-default-features consumer as well as decode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit install.yml is the only job that builds termlens from outside this workspace, and it did so in one shape — `--features decode`, the one the fewest real consumers use: of the three in-house ones, launchbound and reconverge declare default-features = false and only mossaic takes a feature. Feature resolution across a registry boundary is exactly the class this workflow exists to catch, which is why #226 recorded the gap as deferred rather than dropped (#238). The matrix gains a `features` dimension over `decode` and `no-default-features`, on both operating systems. The registry step demands the `decode` feature only on the leg that asks for it; the consumer step picks the matching `cargo add` flag; the decode test moves to its own file written only on that leg; and the no-defaults leg fails if the consumer's tree still resolves `insta`, the whole of the default feature's dependency tree. ci.yml's deferral note now says this half is done. Closes #238 Signed-off-by: Vyncint Ng --- .github/workflows/ci.yml | 7 ++-- .github/workflows/install.yml | 64 ++++++++++++++++++++++++++++++----- CHANGELOG.md | 8 +++++ 3 files changed, 67 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 51b08f0..98b99fd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -71,9 +71,10 @@ jobs: # # Priced and deferred, deliberately (#226): the default set on the macOS # leg — a second full suite per PR for a fault class the stress workflow - # is better placed to find — and a --no-default-features consumer in - # install.yml, which would need the registry step there to stop assuming - # `decode`. + # is better placed to find. The other half #226 deferred, a + # --no-default-features consumer in install.yml, landed with #238: that + # workflow's matrix now runs both shapes, and its registry step demands + # `decode` only on the leg that asks for it. features: name: features runs-on: ubuntu-latest diff --git a/.github/workflows/install.yml b/.github/workflows/install.yml index 3e62f22..73acb0e 100644 --- a/.github/workflows/install.yml +++ b/.github/workflows/install.yml @@ -14,7 +14,12 @@ # here. That is why this runs daily and not only at publish. # * A feature that resolves in this workspace and nowhere else. `decode` is # off by default and pulls a dependency of its own, which makes it exactly -# the shape of thing that works until someone outside tries it. +# the shape of thing that works until someone outside tries it. The +# mirror image is a consumer that declines the defaults: an optional +# dependency a workspace sibling happens to enable looks fine here and +# fails for them. Two of the three in-house consumers are that shape +# (launchbound and reconverge; only mossaic takes `decode`), so both +# shapes are verified (#238). # # The consumer is a real crate in an empty directory, and the test it runs is # a real PTY — which is the whole product, so nothing else would do. Nothing @@ -56,11 +61,13 @@ concurrency: jobs: consume: - name: consume (${{ matrix.os }}) + name: consume (${{ matrix.os }}, ${{ matrix.features }}) strategy: fail-fast: false matrix: os: [ubuntu-latest, macos-latest] + # The two shapes real consumers use — see the header (#238). + features: [decode, no-default-features] runs-on: ${{ matrix.os }} timeout-minutes: 30 steps: @@ -69,6 +76,9 @@ jobs: env: TAG: ${{ github.event.release.tag_name }} WANTED: ${{ inputs.version }} + # Only the leg that asks for `decode` demands the registry advertise + # it; the other leg proves the crate stands with no feature at all. + WANTED_FEATURES: ${{ matrix.features == 'decode' && 'decode insta' || '' }} run: | api="https://crates.io/api/v1/crates/termlens" agent="termlens-install-check (github actions)" @@ -107,7 +117,7 @@ jobs: # actually ask for — a feature renamed at the last moment would be # invisible to every test in this repository. features=$(printf '%s' "$body" | jq -r '.version.features | keys | join(",")') - for wanted in decode insta; do + for wanted in $WANTED_FEATURES; do case ",$features," in *",$wanted,"*) ;; *) echo "::error::the published $version has no '$wanted' feature (it has: $features)"; exit 1 ;; @@ -122,18 +132,24 @@ jobs: - name: Build a consumer against it env: VERSION: ${{ steps.crate.outputs.version }} + FEATURES: ${{ matrix.features }} run: | cd "$RUNNER_TEMP" cargo new --lib consumer --edition 2021 --vcs none cd consumer - # Exactly what the README tells a reader to type, plus the feature - # that carries the decoder. - cargo add termlens@"=$VERSION" --dev --features decode + # Exactly what the README tells a reader to type, plus the flag this + # leg verifies: the feature that carries the decoder, or none of the + # defaults at all. + case "$FEATURES" in + decode) cargo add termlens@"=$VERSION" --dev --features decode ;; + no-default-features) cargo add termlens@"=$VERSION" --dev --no-default-features ;; + *) echo "::error::unknown matrix leg $FEATURES"; exit 1 ;; + esac mkdir -p tests cat > tests/published.rs <<'RUST' - //! The published crate, from an empty project: a real PTY, a real - //! screen, and the decoder behind the `decode` feature. + //! The published crate, from an empty project: a real PTY and a + //! real screen, in every feature configuration. use std::time::Duration; use termlens::{Key, Terminal}; @@ -163,6 +179,24 @@ jobs: assert!(t.wait_exit()?.success()); Ok(()) } + RUST + + if [ "$FEATURES" = decode ]; then + cat > tests/decode.rs <<'RUST' + //! The decoder behind the `decode` feature, reached from outside. + use std::time::Duration; + + use termlens::{Key, Terminal}; + + fn sh(script: &str) -> termlens::Result { + Terminal::builder() + .size(40, 10) + .env_clear() + .timeout(Duration::from_secs(30)) + .arg("-c") + .arg(script) + .spawn("/bin/sh") + } #[test] fn the_decode_feature_reaches_the_pixels() -> termlens::Result<()> { @@ -192,9 +226,21 @@ jobs: Ok(()) } RUST + fi echo "--- the consumer's manifest ---" cat Cargo.toml echo "--- what it resolved to ---" cargo tree --depth 1 --edges normal,dev - cargo test --test published -- --nocapture + if [ "$FEATURES" = no-default-features ]; then + # The point of this leg. `insta` is the whole of the default + # feature's tree; a consumer that declined the defaults must not + # carry it, and an optional dependency that only resolves because + # a workspace sibling enables it would show up here and nowhere + # in this repository. + if cargo tree --edges normal,dev | grep -qE '(^|[^[:alnum:]_-])insta v'; then + echo "::error::a --no-default-features consumer still resolves insta" + exit 1 + fi + fi + cargo test --tests -- --nocapture diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bde7f4..74463fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,14 @@ listed under a **Changed** or **Removed** heading. invisible, and a `DECRQM` probe for any member but the last had to be answered "not recognized". The sequence tracker now keeps the requested set; `mouse_mode()` still reports the protocol. (#151) +- **The fresh-install check verifies a `--no-default-features` consumer as + well as a `decode` one.** `install.yml` is the only job that builds + termlens from outside this workspace, and it did so in one shape — the + one the fewest real consumers use: of the three in-house ones, two declare + `default-features = false`. Its matrix now runs both shapes on Ubuntu and + macOS, the registry check demands the `decode` feature only on the leg + that asks for it, and the no-defaults leg fails if the consumer's tree + still resolves `insta`. (#238) - **The `inspect` example answers `--help`, and takes its deadline and silence window from flags.** `inspect --help` used to look for a program called `--help`, and both timings were hardcoded, so an application slower From 4dec464d435beaac908ecad8c9f777eb3b4d7d42 Mon Sep 17 00:00:00 2001 From: Vyncint Ng Date: Sat, 5 Sep 2026 08:12:03 +0700 Subject: [PATCH 10/14] feat: add termlens::bin! for a package's own binaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every integration test of a binary opened with the same five lines: a fixed 80x24 grid so snapshots are stable, env_clear() so nothing on the host leaks into the program under test, a five-second deadline so a hang is a readable failure, and spawn(env!("CARGO_BIN_EXE_myapp")). The chain now has a name — `termlens::bin!("myapp")?` — and builder calls after the name override any default: `bin!("myapp", size(120, 40), env("NO_COLOR", "1"))`. A macro_rules macro, so it costs no dependency and hides no Terminal; a misspelled binary is a compile error naming the variable rather than a spawn failure at run time. CARGO_BIN_EXE_* exists only in the integration tests of the package that owns the binary, so the macro's tests live in the hello-tui fixture, which gains termlens as a dev-dependency without default features — keeping the features job's --no-default-features leg an actual no-insta build. Roadmap P0.2 (ecosystem docs/TERMLENS-ROADMAP.md). Signed-off-by: Vyncint Ng --- CHANGELOG.md | 8 +++++ Cargo.lock | 1 + README.md | 6 ++++ crates/termlens/src/lib.rs | 42 ++++++++++++++++++++++++++ fixtures/hello-tui/Cargo.toml | 8 +++++ fixtures/hello-tui/tests/bin_macro.rs | 43 +++++++++++++++++++++++++++ 6 files changed, 108 insertions(+) create mode 100644 fixtures/hello-tui/tests/bin_macro.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 74463fa..e19f139 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,14 @@ listed under a **Changed** or **Removed** heading. state means nothing. `wait_exit` is deliberately unaffected: the child's exit status is still true. (#211) +- **`termlens::bin!("myapp")` spawns one of your package's binaries under + the harness defaults.** Every integration test of a binary opened with the + same five lines — a fixed 80x24 grid, `env_clear()`, a five-second + deadline, `spawn(env!("CARGO_BIN_EXE_myapp"))` — so the chain has a name. + Builder calls follow the name and override any default: + `termlens::bin!("myapp", size(120, 40), env("NO_COLOR", "1"))?`. A + misspelled binary is a compile error naming the variable, not a spawn + failure at run time. - **`Screen::mouse_modes` reports every mouse tracking mode the application enabled, and `DECRQM` answers each one on its own evidence.** The backend collapses `?9`/`?1000`/`?1002`/`?1003` into the one protocol a terminal diff --git a/Cargo.lock b/Cargo.lock index 11f8dfd..aedf9cd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -184,6 +184,7 @@ name = "hello-tui" version = "0.8.0" dependencies = [ "crossterm", + "termlens", ] [[package]] diff --git a/README.md b/README.md index 5aa4aa5..7331b86 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,12 @@ fn quits_from_the_main_screen() -> termlens::Result<()> { When a wait times out, the error embeds the screen — your CI log shows exactly what the app was displaying, not "assertion failed: false". +The builder chain above is what every test of a package's own binary +starts from, so it has a name: `termlens::bin!("myapp")` spawns +`CARGO_BIN_EXE_myapp` at 80x24 with a cleared environment and a +five-second deadline, and builder calls after the name override any of it — +`termlens::bin!("myapp", size(120, 40), env("NO_COLOR", "1"))?`. + ## What it is (and is not) - **Not** an expect-style stream matcher — [rexpect] and [expectrl] already diff --git a/crates/termlens/src/lib.rs b/crates/termlens/src/lib.rs index ee7dfff..b0986ee 100644 --- a/crates/termlens/src/lib.rs +++ b/crates/termlens/src/lib.rs @@ -155,3 +155,45 @@ macro_rules! assert_screen_snapshot { $crate::insta::assert_snapshot!($screen, @$inline) }; } + +/// Spawn one of this package's binaries under the harness defaults. +/// +/// `termlens::bin!("myapp")` is the chain every integration test of a +/// binary starts from: +/// +/// ```ignore +/// Terminal::builder() +/// .size(80, 24) // a fixed grid, so snapshots are stable +/// .env_clear() // nothing on the host leaks into the app +/// .timeout(Duration::from_secs(5)) // a hang is a readable failure, not a stuck job +/// .spawn(env!("CARGO_BIN_EXE_myapp")) +/// ``` +/// +/// Any builder method can follow the name as a call, and later calls +/// override the defaults: +/// +/// ```ignore +/// let mut t = termlens::bin!("myapp")?; +/// let mut t = termlens::bin!("myapp", size(120, 40), env("NO_COLOR", "1"))?; +/// let mut t = termlens::bin!("myapp", timeout(Duration::from_secs(30)), args(["--fast"]))?; +/// ``` +/// +/// `CARGO_BIN_EXE_` is set by Cargo for the integration tests of the +/// package that owns the binary, so this works from that package's `tests/` +/// and a misspelled name is a compile error naming the variable rather than +/// a spawn failure at run time. The examples above are not compiled as +/// doctests for the same reason: this crate has no binary called `myapp`. +/// To spawn a program that is not one of your own binaries, or with +/// different defaults, use [`Terminal::builder`] directly — the macro adds +/// nothing else. +#[macro_export] +macro_rules! bin { + ($name:literal $(, $method:ident $args:tt)* $(,)?) => { + $crate::Terminal::builder() + .size(80, 24) + .env_clear() + .timeout(::std::time::Duration::from_secs(5)) + $(.$method $args)* + .spawn(::std::env!(::std::concat!("CARGO_BIN_EXE_", $name))) + }; +} diff --git a/fixtures/hello-tui/Cargo.toml b/fixtures/hello-tui/Cargo.toml index 7a0adf3..5c058b1 100644 --- a/fixtures/hello-tui/Cargo.toml +++ b/fixtures/hello-tui/Cargo.toml @@ -12,5 +12,13 @@ authors.workspace = true [dependencies] crossterm.workspace = true +# `termlens::bin!` can only be exercised from the integration tests of a +# package that owns a binary — that is where Cargo sets CARGO_BIN_EXE_* — so +# the macro's test lives here rather than beside the crate's own tests. +# Without default features, so the `features` CI job's --no-default-features +# leg still builds termlens without `insta`; the test needs nothing of it. +[dev-dependencies] +termlens = { path = "../../crates/termlens", default-features = false } + [lints] workspace = true diff --git a/fixtures/hello-tui/tests/bin_macro.rs b/fixtures/hello-tui/tests/bin_macro.rs new file mode 100644 index 0000000..a173616 --- /dev/null +++ b/fixtures/hello-tui/tests/bin_macro.rs @@ -0,0 +1,43 @@ +//! `termlens::bin!` from the one place it is designed for: the integration +//! tests of the package that owns the binary, where Cargo sets +//! `CARGO_BIN_EXE_hello-tui`. termlens's own tests cannot use the macro — +//! the crate has no binaries — which is why this test lives in a fixture. + +use std::time::Duration; + +use termlens::Key; + +/// The bare form: the binary at 80x24, a cleared environment, a five-second +/// deadline, and a `Result` like `spawn`'s. +#[test] +fn bin_spawns_this_package_s_binary_under_the_defaults() -> termlens::Result<()> { + let mut t = termlens::bin!("hello-tui")?; + // The bottom-right corner is the last byte the fixture draws. + t.wait_until(|s| s.contains("╯"))?; + let s = t.screen(); + assert_eq!(s.size(), (80, 24), "{s}"); + assert!(s.contains("status: ready"), "{s}"); + assert!(s.alternate_screen(), "{s}"); + t.send(Key::Char('q'))?; + assert!(t.wait_exit()?.success()); + Ok(()) +} + +/// Builder calls follow the name and override the defaults; a trailing +/// comma is allowed, since a list of calls tends to grow one per line. +#[test] +fn bin_takes_builder_calls_after_the_name() -> termlens::Result<()> { + let mut t = termlens::bin!( + "hello-tui", + size(100, 30), + timeout(Duration::from_secs(20)), + env("HELLO_TUI_UNUSED", "1"), + )?; + t.wait_until(|s| s.contains("╯"))?; + let s = t.screen(); + assert_eq!(s.size(), (100, 30), "{s}"); + assert!(s.contains("status: ready"), "{s}"); + t.send(Key::Char('q'))?; + assert!(t.wait_exit()?.success()); + Ok(()) +} From 4e0455e34226f659135524ebaf8c1e014742ff30 Mon Sep 17 00:00:00 2001 From: Vyncint Ng Date: Sat, 5 Sep 2026 08:18:15 +0700 Subject: [PATCH 11/14] feat: add snapshot_after and wait_stable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wait_until guarantees only that the bytes which made the predicate true have been processed, and nothing marks where a repaint ends, so the common "wait for X, then snapshot the whole screen" needs three rules from DESIGN §2 to be race-free — and the first real user tripped over them. And the settle those rules ask for, wait_idle, is reset by bytes: an application that rings the bell, rewrites a cell with the glyph already in it or answers a query never goes silent, so a suite for such an application had no honest way to settle at all. snapshot_after(pred) waits for the predicate, then for the picture to hold still for 100ms, and returns that screen. wait_stable(quiet) is the settle on its own: what resets its clock is a change to the grid — any cell, the cursor or the size — not the arrival of bytes, checked under the same mid-sequence and open-synchronized-update conditions wait_idle uses, with stillness that predates the call counted and EOF counted as still. It returns the newest observation of the still picture, so counters on it are current. Both have _for twins. Roadmap P0.3 (ecosystem docs/TERMLENS-ROADMAP.md). Signed-off-by: Vyncint Ng --- CHANGELOG.md | 11 ++ README.md | 5 + crates/termlens/src/screen.rs | 12 ++ crates/termlens/src/terminal.rs | 192 +++++++++++++++++++++++++++++++- crates/termlens/tests/stable.rs | 156 ++++++++++++++++++++++++++ docs/DESIGN.md | 25 ++++- 6 files changed, 397 insertions(+), 4 deletions(-) create mode 100644 crates/termlens/tests/stable.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index e19f139..07789d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,17 @@ listed under a **Changed** or **Removed** heading. state means nothing. `wait_exit` is deliberately unaffected: the child's exit status is still true. (#211) +- **`snapshot_after` and `wait_stable`: the whole-screen snapshot as one + call, and a settle that output changing nothing cannot hold up.** + `snapshot_after(pred)` waits for the predicate, then for the picture to + hold still for 100ms, and returns that screen — DESIGN §2's three rules + for race-free waits without having to remember them. `wait_stable(quiet)` + is the settle on its own, and differs from `wait_idle` in what resets the + clock: changes rather than bytes, so a bell, a cell rewritten with the + glyph already in it or an answered query — output `wait_idle` can never + see silence through — is invisible to it. Both have `_for` twins, refuse + to settle inside an open synchronized update, count stillness that + predates the call, and return the screen they settled on. - **`termlens::bin!("myapp")` spawns one of your package's binaries under the harness defaults.** Every integration test of a binary opened with the same five lines — a fixed 80x24 grid, `env_clear()`, a five-second diff --git a/README.md b/README.md index 7331b86..7f666c0 100644 --- a/README.md +++ b/README.md @@ -219,6 +219,11 @@ design. termlens's position: mid-escape-sequence, and no synchronized update is open. Silence is evidence a render finished — not proof. Use it for "the app settled", not for precise sequencing. +- **`snapshot_after(pred)` is the whole-screen snapshot with the rules + built in**: it waits for the predicate, then for the picture to hold + still, and returns that screen. `wait_stable(quiet)` is the settle on its + own; unlike `wait_idle` it is reset by *changes*, not bytes, so a bell or + a repaint that alters no cell does not keep it waiting. - **Hermetic environments.** `env_clear()` blocks inheritance, `TERM=xterm-256color` is pinned by default, fixtures draw no clocks and no animations. The CI suite runs a 100-iteration diff --git a/crates/termlens/src/screen.rs b/crates/termlens/src/screen.rs index 29f64de..ed9962a 100644 --- a/crates/termlens/src/screen.rs +++ b/crates/termlens/src/screen.rs @@ -534,6 +534,18 @@ impl Screen { } } + /// Whether two snapshots show the same picture: size, cursor and every + /// cell. Deliberately not `==`, which also compares the out-of-band + /// state — bells, repaints, the title — none of which is a picture. + /// This is what `wait_stable` watches. + 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) + && (Arc::ptr_eq(&self.cells, &other.cells) || self.cells == other.cells) + } + /// Stamp the repaint count onto a freshly built snapshot. /// /// The count lives on the terminal, not the emulator: it is the same diff --git a/crates/termlens/src/terminal.rs b/crates/termlens/src/terminal.rs index 54cb2e2..9ff375a 100644 --- a/crates/termlens/src/terminal.rs +++ b/crates/termlens/src/terminal.rs @@ -2802,7 +2802,8 @@ impl Terminal { /// [`screen`](Self::screen) taken at that moment is half-painted — /// including for an application that brackets every repaint /// correctly. `wait_idle` will not declare idleness while an update - /// is open. + /// is open. [`snapshot_after`](Self::snapshot_after) is rules 1–3 + /// as one call: the predicate, then a settle, then the screen. /// /// Applications that emit DEC 2026 synchronized updates get rule 3 for /// free from [`wait_frame`](Self::wait_frame), which sees only complete @@ -3206,6 +3207,195 @@ impl Terminal { } } + /// How long the picture must hold still for + /// [`snapshot_after`](Self::snapshot_after) — the settle the suite's + /// own whole-screen snapshots use, and long enough that a repaint split + /// across two PTY reads on a loaded machine still reads as one. + const SETTLE: Duration = Duration::from_millis(100); + + /// Wait for `predicate`, then for the picture to hold still, and return + /// that screen — the three rules for race-free waits as one call. + /// + /// [`wait_until`](Self::wait_until) guarantees only that the bytes + /// which made the predicate true have been processed, and nothing + /// marks where a repaint ends, so a whole-screen snapshot taken right + /// after it can be torn: half a row painted, the rest still crossing + /// the PTY (`docs/DESIGN.md` §2). This waits for the predicate, then + /// for the grid to stay unchanged for 100ms + /// ([`wait_stable`](Self::wait_stable) with a fixed window), and hands + /// back the screen that held still: + /// + /// ```no_run + /// # fn main() -> termlens::Result<()> { + /// # let mut t = termlens::Terminal::builder().spawn("true")?; + /// let screen = t.snapshot_after(|s| s.contains("Ready"))?; + /// insta::assert_snapshot!(screen); + /// # Ok(()) + /// # } + /// ``` + /// + /// Assert on the returned `Screen` rather than on a later + /// [`screen`](Self::screen): the live grid may already have moved on, + /// and the returned one is the instant that was seen to hold still. + /// The settle is a heuristic — stillness is evidence a render + /// finished, not proof — so an application that emits DEC 2026 + /// synchronized updates should prefer [`wait_frame`](Self::wait_frame), + /// whose frames are complete by construction; and an application that + /// keeps painting *different* content cannot settle here, and times + /// out saying so. + /// + /// Both halves run under the deadline (builder `timeout`), each on its + /// own, so a predicate that takes most of it does not starve the + /// settle. + /// + /// # Errors + /// + /// [`Error::Timeout`] / [`Error::Eof`] from the predicate wait, each + /// carrying the screen. [`Error::Timeout`] from the settle when the + /// picture keeps changing, or the application is inside an unfinished + /// synchronized update — the message says which. [`Error::Emulator`] + /// if the emulation itself failed. + pub fn snapshot_after(&mut self, predicate: impl FnMut(&Screen) -> bool) -> Result { + self.snapshot_after_deadline(predicate, self.default_timeout) + } + + /// [`snapshot_after`](Self::snapshot_after) with a per-call deadline, + /// applied to each half. + /// + /// # Errors + /// + /// Same as [`snapshot_after`](Self::snapshot_after), against `timeout`. + pub fn snapshot_after_for( + &mut self, + predicate: impl FnMut(&Screen) -> bool, + timeout: Duration, + ) -> Result { + self.snapshot_after_deadline(predicate, timeout) + } + + fn snapshot_after_deadline( + &mut self, + predicate: impl FnMut(&Screen) -> bool, + timeout: Duration, + ) -> Result { + self.wait_until_deadline(predicate, timeout)?; + self.wait_stable_deadline(Self::SETTLE, timeout) + } + + /// Block until the picture has held still — no cell, cursor or size + /// change for `quiet` — with the stream not ending mid-escape-sequence + /// and **no synchronized update left open**, and return the screen + /// that held still. EOF counts as still (nothing more can arrive). + /// + /// The difference from [`wait_idle`](Self::wait_idle) is what resets + /// the clock: bytes there, *changes* here. An application that rings + /// the bell, rewrites a cell with the glyph already in it, or answers + /// a query produces output that changes nothing, and `wait_idle` never + /// sees silence through it; this does not care, because the grid is + /// what a snapshot asserts on. Stillness before the call counts — a + /// grid that has been quiet longer than `quiet` returns at once — and + /// the screen returned is the newest observation of that picture, so + /// its counters ([`Screen::bells`], [`Screen::repaints`]) are current. + /// + /// The same honest caveat as `wait_idle`: a picture that stopped + /// changing is evidence the application finished rendering, not proof. + /// Prefer [`wait_until`](Self::wait_until) on visible content where + /// possible, [`wait_frame`](Self::wait_frame) where the application + /// emits DEC 2026 synchronized updates, and + /// [`snapshot_after`](Self::snapshot_after) for the common case of a + /// predicate followed by a whole-screen snapshot. + /// + /// # Errors + /// + /// [`Error::Timeout`] when the deadline (builder `timeout`) expires + /// first — the picture kept changing, `quiet` exceeds the timeout, or + /// the application is inside an unfinished synchronized update, which + /// the message names. [`Error::Emulator`] if the emulation failed. + pub fn wait_stable(&mut self, quiet: Duration) -> Result { + self.wait_stable_deadline(quiet, self.default_timeout) + } + + /// [`wait_stable`](Self::wait_stable) with a per-call timeout. As with + /// [`wait_idle_for`](Self::wait_idle_for), `quiet` is the stillness + /// waited *for* and `timeout` how long to wait for it, so `quiet` must + /// be the smaller of the two. + /// + /// # Errors + /// + /// Same as [`wait_stable`](Self::wait_stable), against `timeout`. + pub fn wait_stable_for(&mut self, quiet: Duration, timeout: Duration) -> Result { + self.wait_stable_deadline(quiet, timeout) + } + + fn wait_stable_deadline(&mut self, quiet: Duration, timeout: Duration) -> Result { + let deadline = Instant::now() + timeout; + let mut guard = self.shared.lock(); + // The picture as last observed, and since when it has looked so. + // Bytes are the only thing that can change the grid, so the last + // byte's arrival bounds the last change: stillness before the call + // counts, exactly as silence before a `wait_idle` does. + let mut last = guard.peek_snapshot(); + let mut since = guard.last_activity; + let mut seen_generation = guard.generation; + loop { + // Ahead of the EOF shortcut: a grid that stopped changing + // because the emulator died has not settled. + if let Some(failure) = guard.emulator_failure() { + return Err(failure); + } + if guard.generation != seen_generation { + seen_generation = guard.generation; + let now = guard.peek_snapshot(); + if !now.same_picture(&last) { + since = guard.last_activity; + } + // Always the newest observation, so the screen handed back + // carries current counters even when the picture is old. + last = now; + } + if guard.eof { + return Ok(last); + } + let held = since.elapsed(); + if held >= quiet + && !guard.emu.mid_sequence() + && !guard.utf8_pending + && !guard.emu.in_sync_update() + { + return Ok(last); + } + + let now = Instant::now(); + if now >= deadline { + let stuck_mid_frame = guard.emu.in_sync_update(); + let screen = guard.peek_snapshot(); + let note = format!("{}{}", guard.query_note(), history_note(&screen)); + drop(guard); + let waiting_for = if stuck_mid_frame { + format!( + "the screen to hold still for {quiet:?} — the application is inside \ + an unfinished DEC 2026 synchronized update (Begin with no End), so \ + the screen below is a half-painted frame{note}" + ) + } else { + format!("the screen to hold still for {quiet:?}{note}") + }; + return Err(Error::Timeout { + waiting_for, + timeout, + screen, + }); + } + // Sleep until the stillness could complete, the deadline hits, + // or new bytes arrive — whichever first; poll-cap while only a + // mid-sequence stall is being waited out. + let sleep = if held < quiet { quiet - held } else { POLL_CAP } + .min(deadline - now) + .max(Duration::from_millis(1)); + guard = self.shared.wait_timeout(guard, sleep); + } + } + /// The child's OS process id, when the platform reports one. /// /// Useful for out-of-band inspection (`/proc`, `ps`, `lsof`). The pid diff --git a/crates/termlens/tests/stable.rs b/crates/termlens/tests/stable.rs new file mode 100644 index 0000000..8a065c0 --- /dev/null +++ b/crates/termlens/tests/stable.rs @@ -0,0 +1,156 @@ +//! `wait_stable` and `snapshot_after`: settling on the *picture* rather +//! than on silence, and the three rules for race-free waits as one call. + +use std::time::{Duration, Instant}; + +use termlens::{Error, Key, Terminal}; + +fn sh(script: &str) -> termlens::Result { + Terminal::builder() + .size(40, 6) + .env_clear() + .timeout(Duration::from_secs(10)) + .args(["-c", script]) + .spawn("/bin/sh") +} + +/// Two paints a second apart: the settle ends between them, so the screen +/// returned shows the first and not the second — the instant that held +/// still, not a later `screen()`. +#[test] +fn snapshot_after_returns_the_screen_once_it_holds_still() -> termlens::Result<()> { + let mut t = sh("printf first; sleep 1; printf ' second'; read _")?; + let screen = t.snapshot_after(|s| s.contains("first"))?; + assert_eq!(screen.row_text(0).trim_end(), "first", "{screen}"); + t.wait_until(|s| s.contains("second"))?; + t.send(Key::Enter)?; + assert!(t.wait_exit()?.success()); + Ok(()) +} + +/// The predicate half fails first, with `wait_until`'s own message: a +/// snapshot was never taken of a screen that never showed the thing. +#[test] +fn snapshot_after_fails_on_the_predicate_before_it_settles() -> termlens::Result<()> { + let mut t = sh("printf other; read _")?; + let err = t + .snapshot_after_for(|s| s.contains("never"), Duration::from_millis(300)) + .unwrap_err(); + match &err { + Error::Timeout { waiting_for, .. } => assert!( + waiting_for.starts_with("the screen predicate to hold"), + "{waiting_for}" + ), + other => panic!("expected a timeout, got {other:?}"), + } + t.send(Key::Enter)?; + assert!(t.wait_exit()?.success()); + Ok(()) +} + +/// A bell every 10ms: bytes without end, and not one cell changes. +/// `wait_idle` can never see silence here; `wait_stable` settles, and the +/// screen it hands back carries the bell count as of the newest byte, not +/// as of the paint. +#[test] +fn wait_stable_ignores_output_that_changes_no_cell() -> termlens::Result<()> { + let mut t = sh(r"printf noisy; while :; do printf '\a'; sleep 0.01; done")?; + t.wait_until(|s| s.contains("noisy"))?; + + let idle = t.wait_idle_for(Duration::from_millis(100), Duration::from_millis(600)); + assert!( + matches!(idle, Err(Error::Timeout { .. })), + "silence never comes, so wait_idle must time out: {idle:?}" + ); + + let start = Instant::now(); + let screen = t.wait_stable(Duration::from_millis(100))?; + assert_eq!(screen.row_text(0).trim_end(), "noisy", "{screen}"); + assert!( + screen.bells() >= 1, + "the returned screen is a current observation" + ); + assert!( + start.elapsed() < Duration::from_secs(5), + "settled long after the picture stopped changing: {:?}", + start.elapsed() + ); + Ok(()) +} + +/// A counter repainting every 20ms never holds still, and the timeout says +/// what was waited for, with the deadline that applied. +#[test] +fn wait_stable_times_out_while_the_picture_keeps_changing() -> termlens::Result<()> { + let mut t = sh(r"i=0; while :; do printf '\r%s' $i; i=$((i+1)); sleep 0.02; done")?; + t.wait_until(|s| s.contains("3"))?; + let err = t + .wait_stable_for(Duration::from_millis(150), Duration::from_millis(700)) + .unwrap_err(); + match err { + Error::Timeout { + waiting_for, + timeout, + .. + } => { + assert!( + waiting_for.starts_with("the screen to hold still for 150ms"), + "{waiting_for}" + ); + assert_eq!(timeout, Duration::from_millis(700)); + } + other => panic!("expected a timeout, got {other:?}"), + } + Ok(()) +} + +/// A still picture inside an open DEC 2026 update is a half-painted frame, +/// not a settled one — the same guarantee `wait_idle` gives — and the +/// message names the real state. Closing the update lets the same picture +/// settle at once. +#[test] +fn wait_stable_does_not_settle_inside_an_open_synchronized_update() -> termlens::Result<()> { + let mut t = sh(r"printf '\033[?2026hhalf'; read _; printf '\033[?2026l'; read _")?; + t.wait_until(|s| s.contains("half"))?; + let err = t + .wait_stable_for(Duration::from_millis(50), Duration::from_millis(400)) + .unwrap_err(); + match &err { + Error::Timeout { waiting_for, .. } => assert!( + waiting_for.contains("unfinished DEC 2026 synchronized update"), + "{waiting_for}" + ), + other => panic!("expected a timeout, got {other:?}"), + } + + t.send(Key::Enter)?; + let screen = t.wait_stable(Duration::from_millis(50))?; + assert!(screen.contains("half"), "{screen}"); + t.send(Key::Enter)?; + assert!(t.wait_exit()?.success()); + Ok(()) +} + +/// Stillness before the call counts, and so does EOF: a grid that has been +/// quiet longer than `quiet` returns without waiting it out again, and an +/// exited child's final screen is still by definition. +#[test] +fn a_screen_that_already_holds_still_settles_at_once() -> termlens::Result<()> { + let mut t = sh("printf settled; read _")?; + t.wait_until(|s| s.contains("settled"))?; + std::thread::sleep(Duration::from_millis(300)); + let start = Instant::now(); + let screen = t.wait_stable(Duration::from_millis(200))?; + assert!( + start.elapsed() < Duration::from_millis(150), + "a 200ms stillness already elapsed was waited out again: {:?}", + start.elapsed() + ); + assert!(screen.contains("settled"), "{screen}"); + + t.send(Key::Enter)?; + assert!(t.wait_exit()?.success()); + let screen = t.wait_stable(Duration::from_secs(5))?; + assert!(screen.contains("settled"), "EOF is still: {screen}"); + Ok(()) +} diff --git a/docs/DESIGN.md b/docs/DESIGN.md index e160106..0b6c38d 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -261,6 +261,21 @@ screen dump** — a CI log alone answers "what was the app showing?". **This is a heuristic**: silence is evidence of a finished render, not proof. Prefer `wait_until` on visible content, or `wait_frame` where the app uses synchronized output. +- `wait_stable(quiet)` — resolves when the **picture** has not changed for + `quiet` — no cell, cursor or size differs between snapshots — under the + same mid-sequence and open-update conditions as `wait_idle`, and returns + the screen that held still. What resets the clock is the difference: + bytes for `wait_idle`, changes here. A bell, a cell rewritten with the + glyph already in it, a query answered — output that changes nothing — + keeps `wait_idle` from ever seeing silence and is invisible here. + Stillness before the call counts (bytes are the only thing that can + change the grid, so the last byte bounds the last change), EOF counts as + still, the screen returned is the newest observation of the picture so + its counters are current, and the heuristic caveat is the same. +- `snapshot_after(pred)` — `wait_until(pred)`, then `wait_stable` with a + fixed 100ms window, returning the settled screen: rules 1–3 below as one + call, for the common case of "wait for the app to show X, then snapshot + the whole screen". Both halves run under the deadline, each on its own. - `wait_exit()` — polls `try_wait` on a capped backoff ladder (1→20ms), then grace-drains the PTY (≤500ms) so the final screen is complete before returning. Idempotent via a cached status. @@ -294,7 +309,8 @@ matching a way of waiting: | you waited with | use | |---|---| | `wait_frame` | the `Screen` it returns — the matched frame, complete by construction | -| `wait_until` | `wait_idle` after it (no idleness while an update is open), then `screen()` | +| `wait_until` | `wait_idle` or `wait_stable` after it (neither settles while an update is open), then `screen()` | +| `snapshot_after` | the `Screen` it returns — predicate, then stillness, in one call | | neither | a predicate naming the last thing the app paints, so its truth implies the repaint finished | ### The three rules for race-free waits @@ -334,8 +350,11 @@ the PTY. Three rules make such waits deterministic: our own suite. 3. **Settle before whole-screen snapshots.** A snapshot asserts on cells the test never named, so no targeted predicate can cover it; call - `wait_idle` first. That is a heuristic (silence ≠ proof of a finished - render — see above), and it is the honest tool for the job. + `wait_idle` first — or `wait_stable`, which an application's bells and + no-op repaints cannot keep from settling. That is a heuristic (silence + ≠ proof of a finished render — see above), and it is the honest tool + for the job. `snapshot_after(pred)` is rules 1–3 as one call: the + predicate, the settle, and the screen it settled on. 4. **`wait_frame` removes the torn-frame race, not rule 1.** An application that brackets its repaints in DEC 2026 synchronized updates From df3aad7e25fc97a3297589409c2e4eb5decb14c2 Mon Sep 17 00:00:00 2001 From: Vyncint Ng Date: Sat, 5 Sep 2026 08:21:40 +0700 Subject: [PATCH 12/14] docs: show snapshot_after and bin! in the crate docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The crate-level snapshot example took a screen with no wait in front of it, which is the shape DESIGN §2 warns against; it now goes through snapshot_after, and the paragraph after it points a reader testing their own binary at bin!. Signed-off-by: Vyncint Ng --- crates/termlens/src/lib.rs | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/crates/termlens/src/lib.rs b/crates/termlens/src/lib.rs index b0986ee..29944b4 100644 --- a/crates/termlens/src/lib.rs +++ b/crates/termlens/src/lib.rs @@ -84,19 +84,25 @@ //! application normalized the other way. The grid keeps exactly the //! codepoints the application sent. //! -//! With the default `insta` feature, snapshot-test whole screens: +//! With the default `insta` feature, snapshot-test whole screens — after +//! waiting for what the application paints and for the picture to hold +//! still, which [`snapshot_after`](Terminal::snapshot_after) does in one +//! call: //! //! ```no_run //! # fn main() -> termlens::Result<()> { //! # let mut t = termlens::Terminal::builder().spawn("true")?; +//! let screen = t.snapshot_after(|s| s.contains("Ready"))?; //! #[cfg(feature = "insta")] -//! { -//! insta::assert_snapshot!(t.screen()); // plain insta… -//! termlens::assert_screen_snapshot!(t.screen()); // …or the bundled macro -//! } +//! insta::assert_snapshot!(screen); // or termlens::assert_screen_snapshot!(screen) //! # Ok(()) //! # } //! ``` +//! +//! Testing a binary of your own package? [`bin!`] spawns +//! `CARGO_BIN_EXE_` under the harness defaults — a fixed grid, a +//! cleared environment, a deadline — with builder calls after the name to +//! override any of them. #![warn(missing_docs)] From b2f70e436741e5dda688520ec474f82a788abdb3 Mon Sep 17 00:00:00 2001 From: Vyncint Ng Date: Sat, 5 Sep 2026 08:26:24 +0700 Subject: [PATCH 13/14] test: make the wait_stable tests hold under load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stress workflow failed one of the new tests at 16 threads on macOS: the counter it drove with a shell loop and `sleep 0.02` stalled for longer than the 150ms of stillness the test asked for — a process spawned per iteration on a loaded machine — so the picture genuinely held still and wait_stable was right to return it. The premise was load-sensitive, not the library. Every timing premise in the file is now one that scheduling cannot defeat: the changing screen is `seq` scrolling as fast as the PTY takes it (one process, no per-iteration spawn), the stillness asked for is far longer than the deadline so the wait can only time out, the bell loop is a builtin without `sleep` and its wait_idle expectation is shaped the same way, the "already still" case compares a full second against half of one, and the two-paint case leaves two seconds between paints. Signed-off-by: Vyncint Ng --- crates/termlens/tests/stable.rs | 64 +++++++++++++++++++++------------ 1 file changed, 42 insertions(+), 22 deletions(-) diff --git a/crates/termlens/tests/stable.rs b/crates/termlens/tests/stable.rs index 8a065c0..4f185f7 100644 --- a/crates/termlens/tests/stable.rs +++ b/crates/termlens/tests/stable.rs @@ -14,12 +14,12 @@ fn sh(script: &str) -> termlens::Result { .spawn("/bin/sh") } -/// Two paints a second apart: the settle ends between them, so the screen -/// returned shows the first and not the second — the instant that held -/// still, not a later `screen()`. +/// Two paints two seconds apart: the settle ends between them, so the +/// screen returned shows the first and not the second — the instant that +/// held still, not a later `screen()`. #[test] fn snapshot_after_returns_the_screen_once_it_holds_still() -> termlens::Result<()> { - let mut t = sh("printf first; sleep 1; printf ' second'; read _")?; + let mut t = sh("printf first; sleep 2; printf ' second'; read _")?; let screen = t.snapshot_after(|s| s.contains("first"))?; assert_eq!(screen.row_text(0).trim_end(), "first", "{screen}"); t.wait_until(|s| s.contains("second"))?; @@ -48,16 +48,22 @@ fn snapshot_after_fails_on_the_predicate_before_it_settles() -> termlens::Result Ok(()) } -/// A bell every 10ms: bytes without end, and not one cell changes. -/// `wait_idle` can never see silence here; `wait_stable` settles, and the -/// screen it hands back carries the bell count as of the newest byte, not -/// as of the paint. +/// Bells without end, and not one cell changes. `wait_idle` cannot see +/// silence through them; `wait_stable` settles, and the screen it hands +/// back carries the bell count as of the newest byte, not as of the paint. +/// +/// The bell loop is a shell builtin with no `sleep`: a process spawned per +/// iteration stalls for hundreds of milliseconds on a loaded machine, and +/// a stall is real silence — the stress workflow found exactly that. The +/// `wait_idle` expectation is made load-proof the same way the deadline +/// tests are: two seconds of silence cannot be observed inside a 400ms +/// deadline unless the stream had already been silent for most of it. #[test] fn wait_stable_ignores_output_that_changes_no_cell() -> termlens::Result<()> { - let mut t = sh(r"printf noisy; while :; do printf '\a'; sleep 0.01; done")?; - t.wait_until(|s| s.contains("noisy"))?; + let mut t = sh(r"printf noisy; while :; do printf '\a'; done")?; + t.wait_until(|s| s.contains("noisy") && s.bells() > 0)?; - let idle = t.wait_idle_for(Duration::from_millis(100), Duration::from_millis(600)); + let idle = t.wait_idle_for(Duration::from_secs(2), Duration::from_millis(400)); assert!( matches!(idle, Err(Error::Timeout { .. })), "silence never comes, so wait_idle must time out: {idle:?}" @@ -78,14 +84,26 @@ fn wait_stable_ignores_output_that_changes_no_cell() -> termlens::Result<()> { Ok(()) } -/// A counter repainting every 20ms never holds still, and the timeout says -/// what was waited for, with the deadline that applied. +/// A picture that keeps changing never settles, and the timeout says what +/// was waited for, with the deadline that applied. +/// +/// `seq` scrolls a new number onto the screen as fast as the PTY takes +/// them — one process, no per-iteration spawn to stall under load — and +/// the stillness asked for (2s) is far longer than the deadline (400ms), +/// so the wait can only succeed if the child had already been stalled for +/// most of two seconds when it began. A version of this test that asked +/// for 150ms of stillness against a shell loop with `sleep 0.02` was found +/// by the stress workflow: at 16 threads the loop's `sleep` took longer +/// than 150ms to spawn, the picture genuinely held still, and +/// `wait_stable` was right to say so. #[test] fn wait_stable_times_out_while_the_picture_keeps_changing() -> termlens::Result<()> { - let mut t = sh(r"i=0; while :; do printf '\r%s' $i; i=$((i+1)); sleep 0.02; done")?; - t.wait_until(|s| s.contains("3"))?; + let mut t = sh("seq 1 100000000")?; + // Any digit: which numbers are on screen when a snapshot lands is + // whatever the flood happens to show, so no particular one is waited for. + t.wait_until(|s| s.text().chars().any(|c| c.is_ascii_digit()))?; let err = t - .wait_stable_for(Duration::from_millis(150), Duration::from_millis(700)) + .wait_stable_for(Duration::from_secs(2), Duration::from_millis(400)) .unwrap_err(); match err { Error::Timeout { @@ -94,10 +112,10 @@ fn wait_stable_times_out_while_the_picture_keeps_changing() -> termlens::Result< .. } => { assert!( - waiting_for.starts_with("the screen to hold still for 150ms"), + waiting_for.starts_with("the screen to hold still for 2s"), "{waiting_for}" ); - assert_eq!(timeout, Duration::from_millis(700)); + assert_eq!(timeout, Duration::from_millis(400)); } other => panic!("expected a timeout, got {other:?}"), } @@ -138,12 +156,14 @@ fn wait_stable_does_not_settle_inside_an_open_synchronized_update() -> termlens: fn a_screen_that_already_holds_still_settles_at_once() -> termlens::Result<()> { let mut t = sh("printf settled; read _")?; t.wait_until(|s| s.contains("settled"))?; - std::thread::sleep(Duration::from_millis(300)); + std::thread::sleep(Duration::from_millis(1200)); let start = Instant::now(); - let screen = t.wait_stable(Duration::from_millis(200))?; + let screen = t.wait_stable(Duration::from_secs(1))?; + // Waiting the stillness out again would take a full second; an + // immediate return takes far less even on a loaded machine. assert!( - start.elapsed() < Duration::from_millis(150), - "a 200ms stillness already elapsed was waited out again: {:?}", + start.elapsed() < Duration::from_millis(500), + "a 1s stillness already elapsed was waited out again: {:?}", start.elapsed() ); assert!(screen.contains("settled"), "{screen}"); From a4208056aaf2c974c43ef475fed341c549559e20 Mon Sep 17 00:00:00 2001 From: Vyncint Ng Date: Sat, 5 Sep 2026 08:29:14 +0700 Subject: [PATCH 14/14] chore(deny): allow the fixture's path dev-dependency on termlens cargo-deny treats a path dependency without a version requirement as a wildcard, and the hello-tui fixture now dev-depends on termlens by path for the bin! macro's test. Every fixture is publish = false, so a version requirement there would be a number to bump on every release and nothing more; allow-wildcard-paths is the option cargo-deny provides for exactly this, and wildcards on registry dependencies stay denied. Signed-off-by: Vyncint Ng --- deny.toml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/deny.toml b/deny.toml index b8b9cb7..704c60f 100644 --- a/deny.toml +++ b/deny.toml @@ -25,6 +25,12 @@ allow = [ confidence-threshold = 0.8 [bans] +# Path dependencies without a version are "wildcards" to cargo-deny. The +# only one here is a fixture's dev-dependency on the crate under test +# (hello-tui -> termlens, for the bin! macro's test), and every fixture is +# publish = false, so a version requirement would only be a number to bump +# on every release. Wildcards on registry dependencies stay denied. +allow-wildcard-paths = true multiple-versions = "warn" wildcards = "deny"