Skip to content

Close out the open fixes and add bin!, snapshot_after and wait_stable for v0.9.0 - #243

Merged
vyncint merged 14 commits into
mainfrom
feat/v0.9-close-out
Sep 5, 2026
Merged

Close out the open fixes and add bin!, snapshot_after and wait_stable for v0.9.0#243
vyncint merged 14 commits into
mainfrom
feat/v0.9-close-out

Conversation

@vyncint

@vyncint vyncint commented Sep 5, 2026

Copy link
Copy Markdown
Owner

Closes every open issue that is a fix rather than a design decision, and adds the two APIs the 0.9.0 release was waiting on. One commit per item, each with its own test and CHANGELOG entry.

Fixes (issues verified against main @ 63d7966 before each change)

Issue Change
inspect #229, #236 --help/-h print one usage text and exit 0; --timeout SECONDS and --idle MILLIS replace the hardcoded 5 s / 300 ms; --version; unknown options are refused rather than spawned
inspect #237 test: a relative program path from a scratch directory (symlink, not copy — macOS kills a system binary copied out of /bin)
fixtures #230 unicode-torture writes a raw 0xE9; the reviewed snapshot shows U+FFFD with done in its proper column
tests #240 the default-cwd test reassembles a wrapped path from the whole screen; verified from a 201-character directory where the old form fails
emu #234 UK set: ESC ( A then # draws £
emu #232 DECSC/DECRC save and restore G0–G3 and the locking shift; nothing saved → ASCII; RIS clears the slot
emu #233 DECSTR modelled: charsets, DECSC slot, cursor shape, focus, and — replayed to vt100 — DECCKM, bracketed paste, mouse tracking/encoding, cursor visibility; alternate screen left alone; the unmodelled remainder is listed in the README
emu #151 the tracker keeps the set of mouse tracking modes; Screen::mouse_modes() reports it, DECRQM answers each mode on its own evidence, mouse_mode() and the input path are unchanged
ci #238 install.yml matrix gains decode × no-default-features; the registry check demands decode only on that leg; the no-defaults leg fails if the consumer's tree resolves insta; ci.yml's #226 note updated. Both legs dry-run locally against the published 0.8.0

Two new APIs

  • termlens::bin!("myapp", size(120, 40), …) — the builder chain every binary test starts from, as a macro_rules macro. Tested from the hello-tui fixture, the one place CARGO_BIN_EXE_* exists; the fixture's dev-dependency on termlens is default-features = false so the features job's no-defaults leg stays a real no-insta build.
  • snapshot_after(pred) (predicate → settle → the settled Screen) and wait_stable(quiet) (reset by changes, not bytes, so bells and no-op repaints cannot hold it up). Both have _for twins and refuse to settle inside an open synchronized update. DESIGN §2 documents both and the route table gains a row. This is a wait/timing change, so stress.yml ran on this branch. The first run (https://github.com/vyncint/termlens/actions/runs/33935865723) failed one of the new tests at 16 threads on macOS: the test drove a counter with a shell loop and sleep 0.02, the spawn stalled past the 150 ms of stillness it asked for, and wait_stable was right to report a still picture. The tests now use premises scheduling cannot defeat (seq flooding, stillness asked for far longer than the deadline, builtin loops without sleep); the re-run on the fixed tests is https://github.com/vyncint/termlens/actions/runs/33936252643. The only commit after it changes deny.toml.

Deferred to its own PR, deliberately: a Rust fixture binary replacing every /bin/sh helper touches every test file and is a precondition for Windows rather than for 0.9.0. The four design issues (#146, #147, #149, #150) have status comments and stay open.

Verification

  • cargo fmt --check; clippy -D warnings in all four feature configurations; the full suite in all four configurations plus the release profile; cargo doc -D warnings (both configurations); MSRV 1.85 check --all-targets; cargo deny; the CI-gates-listed script; DCO and no-AI-attribution checks over the range.
  • Snapshot change reviewed: one new row in fixtures__unicode_torture_renders_with_correct_widths.snap.
  • stress.yml on this branch, 100 iterations per OS: https://github.com/vyncint/termlens/actions/runs/33936252643 (after the test fix described above).
  • cargo deny needed allow-wildcard-paths for the fixture's path dev-dependency on termlens; registry wildcards stay denied.

`inspect --help` spawned a program called `--help` and failed with a PATH
error, because the argument loop knew only `--size` and took the next
argument as the program whatever it looked like (#229). Both timings were
hardcoded too, so a program slower than five seconds to paint could not be
inspected: the wait expired and the viewer printed a partial screen (#236).

The loop now consumes options until the first non-option:

- `-h`/`--help` print the usage to stdout and exit 0; a missing program
  prints the same text to stderr and exits 1. The usage text has one home.
- `--timeout SECONDS` (default 5) sets the builder deadline; `--idle MILLIS`
  (default 300) the silence window for the still-running path. A malformed
  value is rejected in one line, shaped like the `--size` diagnostic.
- `--version` prints the termlens version; an unknown option is refused
  rather than spawned; `--` ends option parsing.

The still-running settle is bounded by the deadline as before, or by the
silence window itself when that is longer, so `--idle 10000` cannot make
the settle time out before it has had a chance to see silence.

Closes #229
Closes #236

Signed-off-by: Vyncint Ng <vyncint@icloud.com>
A relative program path is how the viewer is pointed at something just
built, and it resolves only because a child now starts in the test
process's working directory rather than $HOME (#215). The test pinning
that default reads `pwd` inside a shell; nothing pinned the mechanism the
viewer relies on, so it could stop working unnoticed (#237).

The new case links /bin/echo into CARGO_TARGET_TMPDIR, runs inspect from
there with `./echo`, and asserts the program's output reaches the screen.
A symlink rather than a copy: macOS kills a system binary copied out of
/bin, since its signature is trusted at that path only.

Closes #237

Signed-off-by: Vyncint Ng <vyncint@icloud.com>
#217 taught the reader to turn a byte that is not UTF-8 into U+FFFD
instead of dropping it, and asked for two guards: an inline test, which
landed as tests/utf8.rs, and a fixture line, so a parser change that
reintroduces the drop turns a snapshot red rather than depending on one
printf staying alive. The fixture line never landed (#230).

unicode-torture now writes `raw: caf\xe9 done` straight to stdout — a
string literal cannot hold the byte — and the reviewed snapshot shows the
replacement character with `done` in the column it would occupy had the
byte decoded. The snapshot test waits on row 8 now that the fixture prints
one more line.

Closes #230

Signed-off-by: Vyncint Ng <vyncint@icloud.com>
The default-working-directory test read pwd's output from row 0 of an
80-column grid, so a checkout whose absolute path is longer than 80
characters wrapped the path onto row 1 and the test failed on
canonicalize — deterministically, in both profiles, for a reason that has
nothing to do with the behaviour it pins (#240).

A wrap inserts no character of its own, so the visible screen with its
row breaks removed is the path as printed, whatever its length. Verified
from a 201-character directory: the old form fails there, this one
passes, and both still fail if spawn stops defaulting to the test
process's directory.

Closes #240

Signed-off-by: Vyncint Ng <vyncint@icloud.com>
`ESC ( A` designates the DEC United Kingdom set, whose one difference from
ASCII is that `#` draws `£`. The tracker parsed the designation and then
rendered ASCII, so a price printed in the UK set showed `#42` on the grid,
a test asserting `£42` failed against a correct application, and a
snapshot that blessed `#42` kept passing (#234).

The charset enum gains a `Uk` variant, the designation parser recognises
`A`, and the glyph lookup — renamed from graphics_glyph to charset_glyph
now that it serves two sets — maps the single byte `#` to `£` for it. The
staging path in the backend already rewrites a byte into the glyph it
draws, so nothing downstream changes. Designations with no table (the
alternate ROMs, the other national sets) still read as ASCII, and the
module docs and README now say which sets are translated.

Closes #234

Signed-off-by: Vyncint Ng <vyncint@icloud.com>
Save, jump, draw the frame, restore is how a full-screen application
draws a border, and DECRC (ESC 8) left the character sets as whatever was
designated in between, so the border after it rendered as `lqk` — the
failure #204 fixed for the designation itself, arriving through a
different door (#232).

The tracker gains a saved slot holding G0–G3 and the locking shift. ESC 7
fills it, ESC 8 restores it, a restore with nothing saved returns to the
power-on defaults as xterm does, and RIS clears the slot so a restore
after a reset cannot resurrect a designation from before it. vt100 saves
and restores the cursor and attributes itself; only the half it does not
know about lives here.

Closes #232

Signed-off-by: Vyncint Ng <vyncint@icloud.com>
`CSI ! p` parsed cleanly and did nothing, so an application that
soft-reset on teardown and then printed text had that text rendered in
the character set it had just told the terminal to forget — while RIS got
the same case right, leaving the pair inconsistent rather than uniformly
unimplemented (#233).

The tracker recognises the `!` intermediate and, on `p`, returns its own
state to power-on: the character sets and the DECSC slot, the cursor
shape, focus reporting. It then reports a SoftReset event, and the backend
replays to vt100 — which does not implement DECSTR — the sequences that
reset the modes it holds: cursor-key mode, bracketed paste, every mouse
tracking mode and encoding, and cursor visibility. The alternate screen is
left alone, as specified. Attributes, margins, origin and insert modes and
the keypad are deliberately not replayed: nothing on Screen observes them,
and a replay nothing can check is a claim nothing can catch. The README
lists both halves.

Closes #233

Signed-off-by: Vyncint Ng <vyncint@icloud.com>
…d for

The backend collapses ?9/?1000/?1002/?1003 into one mutually exclusive
value — right for the input path, since a terminal reports in exactly one
protocol and `click` must encode for it — so it could not say which
members of the group an application enabled. crossterm's EnableMouseCapture
sends three at once and only the last survived: a regression from
any-motion to button-motion tracking (losing hover) was invisible, and a
DECRQM probe for any member but the last had to be answered "not
recognized" (#151).

The sequence tracker now keeps the requested set, beside the focus flag
and the window title that live there for the same reason. Every mode named
in one `CSI ? … h/l` list is set or cleared together; RIS and DECSTR empty
the set. Screen::mouse_modes reports it as a small MouseModes set
(contains/is_empty/len/iter), DECRQM answers each tracking mode from it,
and Screen::mouse_mode and the input path keep the backend's one-protocol
answer unchanged. DESIGN.md's honesty paragraph is rewritten around the
two facts.

Closes #151

Signed-off-by: Vyncint Ng <vyncint@icloud.com>
install.yml is the only job that builds termlens from outside this
workspace, and it did so in one shape — `--features decode`, the one the
fewest real consumers use: of the three in-house ones, launchbound and
reconverge declare default-features = false and only mossaic takes a
feature. Feature resolution across a registry boundary is exactly the
class this workflow exists to catch, which is why #226 recorded the gap as
deferred rather than dropped (#238).

The matrix gains a `features` dimension over `decode` and
`no-default-features`, on both operating systems. The registry step
demands the `decode` feature only on the leg that asks for it; the
consumer step picks the matching `cargo add` flag; the decode test moves
to its own file written only on that leg; and the no-defaults leg fails if
the consumer's tree still resolves `insta`, the whole of the default
feature's dependency tree. ci.yml's deferral note now says this half is
done.

Closes #238

Signed-off-by: Vyncint Ng <vyncint@icloud.com>
Every integration test of a binary opened with the same five lines: a
fixed 80x24 grid so snapshots are stable, env_clear() so nothing on the
host leaks into the program under test, a five-second deadline so a hang
is a readable failure, and spawn(env!("CARGO_BIN_EXE_myapp")). The chain
now has a name — `termlens::bin!("myapp")?` — and builder calls after the
name override any default: `bin!("myapp", size(120, 40), env("NO_COLOR",
"1"))`. A macro_rules macro, so it costs no dependency and hides no
Terminal; a misspelled binary is a compile error naming the variable
rather than a spawn failure at run time.

CARGO_BIN_EXE_* exists only in the integration tests of the package that
owns the binary, so the macro's tests live in the hello-tui fixture, which
gains termlens as a dev-dependency without default features — keeping the
features job's --no-default-features leg an actual no-insta build.

Roadmap P0.2 (ecosystem docs/TERMLENS-ROADMAP.md).

Signed-off-by: Vyncint Ng <vyncint@icloud.com>
wait_until guarantees only that the bytes which made the predicate true
have been processed, and nothing marks where a repaint ends, so the common
"wait for X, then snapshot the whole screen" needs three rules from DESIGN
§2 to be race-free — and the first real user tripped over them. And the
settle those rules ask for, wait_idle, is reset by bytes: an application
that rings the bell, rewrites a cell with the glyph already in it or
answers a query never goes silent, so a suite for such an application had
no honest way to settle at all.

snapshot_after(pred) waits for the predicate, then for the picture to hold
still for 100ms, and returns that screen. wait_stable(quiet) is the settle
on its own: what resets its clock is a change to the grid — any cell, the
cursor or the size — not the arrival of bytes, checked under the same
mid-sequence and open-synchronized-update conditions wait_idle uses, with
stillness that predates the call counted and EOF counted as still. It
returns the newest observation of the still picture, so counters on it are
current. Both have _for twins.

Roadmap P0.3 (ecosystem docs/TERMLENS-ROADMAP.md).

Signed-off-by: Vyncint Ng <vyncint@icloud.com>
The crate-level snapshot example took a screen with no wait in front of
it, which is the shape DESIGN §2 warns against; it now goes through
snapshot_after, and the paragraph after it points a reader testing their
own binary at bin!.

Signed-off-by: Vyncint Ng <vyncint@icloud.com>
The stress workflow failed one of the new tests at 16 threads on macOS:
the counter it drove with a shell loop and `sleep 0.02` stalled for longer
than the 150ms of stillness the test asked for — a process spawned per
iteration on a loaded machine — so the picture genuinely held still and
wait_stable was right to return it. The premise was load-sensitive, not
the library.

Every timing premise in the file is now one that scheduling cannot defeat:
the changing screen is `seq` scrolling as fast as the PTY takes it (one
process, no per-iteration spawn), the stillness asked for is far longer
than the deadline so the wait can only time out, the bell loop is a
builtin without `sleep` and its wait_idle expectation is shaped the same
way, the "already still" case compares a full second against half of one,
and the two-paint case leaves two seconds between paints.

Signed-off-by: Vyncint Ng <vyncint@icloud.com>
@vyncint
vyncint force-pushed the feat/v0.9-close-out branch from 08f8c1b to b2f70e4 Compare September 5, 2026 01:28
cargo-deny treats a path dependency without a version requirement as a
wildcard, and the hello-tui fixture now dev-depends on termlens by path for
the bin! macro's test. Every fixture is publish = false, so a version
requirement there would be a number to bump on every release and nothing
more; allow-wildcard-paths is the option cargo-deny provides for exactly
this, and wildcards on registry dependencies stay denied.

Signed-off-by: Vyncint Ng <vyncint@icloud.com>
@vyncint
vyncint merged commit 595e825 into main Sep 5, 2026
12 checks passed
@vyncint
vyncint deleted the feat/v0.9-close-out branch September 5, 2026 01:43
@vyncint vyncint mentioned this pull request Sep 5, 2026
vyncint added a commit that referenced this pull request Sep 5, 2026
workspace.package.version 0.8.0 -> 0.9.0 with the lockfile refreshed, and
the CHANGELOG's [Unreleased] section moved to [0.9.0] - 2026-09-05 with a
fresh empty [Unreleased] above it. Nothing in the docs names 0.8 as
current. The tree is the one #243 merged, which the stress workflow passed
10/10 shards on.

Signed-off-by: Vyncint Ng <vyncint@icloud.com>
@vyncint vyncint changed the title Close out the open fixes and land roadmap P0.2/P0.3 for v0.9.0 Close out the open fixes and add bin!, snapshot_after and wait_stable for v0.9.0 Sep 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant