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 3c4ba25..07789d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,53 @@ 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 + 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 + 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 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 + 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 @@ -58,6 +105,33 @@ 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) +- **`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 + `£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/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 242051b..7f666c0 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 @@ -213,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 @@ -232,12 +243,13 @@ 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. - Locking shifts remain G0/G1 only (`LS2`/`LS3` are not modelled). - `DECSC`/`DECRC` do not save or restore the charset state. + 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. + `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 @@ -272,7 +284,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/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/src/emu/seq.rs b/crates/termlens/src/emu/seq.rs index b62c057..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 @@ -26,17 +28,21 @@ //! 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 -//! ASCII. Locking shifts remain G0/G1 (`SO`/`SI`); `LS2`/`LS3` are not -//! modelled. The tracker decides, the emulator rewrites: the bytes the +//! `┌` 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. `DECSC`/`DECRC` save and restore this state alongside the cursor, +//! 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 //! read, which `emu/vt100.rs` stages. 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 @@ -117,13 +123,27 @@ 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, } +/// 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 @@ -204,6 +224,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 @@ -305,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, @@ -329,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], @@ -380,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 @@ -403,6 +452,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 @@ -449,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(), @@ -465,6 +522,7 @@ impl SeqTracker { capture, frame_printable: 0, focus_events: false, + mouse_tracking: 0, cursor_style: None, esc_intermediate: 0, g0: Charset::Ascii, @@ -473,6 +531,7 @@ impl SeqTracker { g3: Charset::Ascii, shifted_out: false, single_shift: None, + saved_charsets: None, dcs_introducer: 0, dcs_final: 0, dcs_intermediate: 0, @@ -578,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 @@ -589,9 +654,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 +665,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 { @@ -623,17 +690,46 @@ 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; + } + + /// 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; + self.mouse_tracking = 0; + } + /// 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, @@ -665,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) { @@ -694,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; } @@ -714,11 +812,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, @@ -751,6 +849,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 @@ -791,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 { @@ -1166,7 +1288,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), }; @@ -1235,15 +1357,43 @@ 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.mouse_tracking = 0; 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 @@ -1955,7 +2105,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 +2205,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 +2239,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 +2285,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..3e7048b 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; @@ -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, @@ -302,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(), @@ -377,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, } } @@ -411,6 +418,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, @@ -947,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..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)] @@ -116,7 +122,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::{ @@ -155,3 +161,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/crates/termlens/src/screen.rs b/crates/termlens/src/screen.rs index 9a46e71..ed9962a 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, @@ -434,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 @@ -596,16 +708,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 +1879,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 +1898,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/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/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()?, diff --git a/crates/termlens/tests/charset.rs b/crates/termlens/tests/charset.rs index 3f21c3a..4d70dad 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,63 @@ 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 +/// 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. @@ -169,3 +227,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(()) +} 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/inspect.rs b/crates/termlens/tests/inspect.rs index 40a5c90..34f93ed 100644 --- a/crates/termlens/tests/inspect.rs +++ b/crates/termlens/tests/inspect.rs @@ -95,3 +95,158 @@ 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" + ); +} + +/// 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}"); +} 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/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/crates/termlens/tests/stable.rs b/crates/termlens/tests/stable.rs new file mode 100644 index 0000000..4f185f7 --- /dev/null +++ b/crates/termlens/tests/stable.rs @@ -0,0 +1,176 @@ +//! `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 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 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"))?; + 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(()) +} + +/// 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'; done")?; + t.wait_until(|s| s.contains("noisy") && s.bells() > 0)?; + + 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:?}" + ); + + 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 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("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_secs(2), Duration::from_millis(400)) + .unwrap_err(); + match err { + Error::Timeout { + waiting_for, + timeout, + .. + } => { + assert!( + waiting_for.starts_with("the screen to hold still for 2s"), + "{waiting_for}" + ); + assert_eq!(timeout, Duration::from_millis(400)); + } + 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(1200)); + let start = Instant::now(); + 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(500), + "a 1s 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/crates/termlens/tests/state.rs b/crates/termlens/tests/state.rs index 81f20e2..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. @@ -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() @@ -226,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/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" diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 9c8cce4..0b6c38d 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. @@ -251,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. @@ -284,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 @@ -324,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 @@ -491,7 +520,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 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(()) +} 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();