Skip to content

feat: DEC line drawing, modified wheel, Screen equality, history notes - #204

Merged
vyncint merged 3 commits into
mainfrom
feat/v0.8
Aug 29, 2026
Merged

feat: DEC line drawing, modified wheel, Screen equality, history notes#204
vyncint merged 3 commits into
mainfrom
feat/v0.8

Conversation

@vyncint

@vyncint vyncint commented Aug 29, 2026

Copy link
Copy Markdown
Owner

Closes ten of the twelve open items of the v0.8 milestone. The two that
are not code changes here — #146 (styled history) and #147 (addressing
history) — are design questions, and each gets a written decision in its
issue once this lands rather than a half-built feature in a release.

DEC Special Graphics is translated (#179)

ESC ( 0 l q q q k is ┌───┐, and the grid used to say lqqqk. That is how
every ncurses application draws its borders (smacs/rmacs on an xterm
terminfo are exactly ESC ( 0 / ESC ( B), so the crate's own promise —
assert on what a user would see — was wrong for a large class of programs,
and wrong in the worse direction too: a snapshot that had blessed lqqqk kept
passing after the border broke.

vt100 drops the designation entirely (ESC ( 0 reaches unhandled_escape),
so the sequence tracker now holds the G0/G1 designations and the SO/SI
locking shift, and the emulator asks it, byte by byte and before stepping,
whether a byte draws as a glyph. If so the glyph is staged in place of the byte
and both parsers receive the staged stream — rewriting before either parser is
what keeps the primary and the attribute shadow the same shape, exactly as the
SGR rewrite does, and the shadow's correspondence check runs on every snapshot.
A stream that never designates a set takes the old slice-through path and pays
nothing.

Scope is stated rather than implied, in the README, DESIGN.md and the
CHANGELOG: G0/G1 and the locking shifts only; one set translated; other
designations read as ASCII; G2/G3 and single shifts not modelled; RIS returns
both sets to ASCII; DECSC/DECRC do not save charset state. An OSC 8 label
written in the graphics set records the glyphs, since the label is what a
reader sees.

Terminal::scroll_with (#174)

Scroll::{ctrl, alt, shift} and a ScrollChord type, mirroring
MouseButton/MouseChord; scroll is now the unmodified case of it, as
click is of click_with. The wheel gets a chord type of its own rather than a
wider MouseChord so a wheel direction cannot be handed to click_with — a
notch has no release, and the type keeps that from being a runtime surprise.
The form-echo fixture now names mouse modifiers, so Ctrl-wheel is proved
through crossterm's parser and not only through our own table.

Screen: PartialEq + Eq (#183)

Equality is the same observation, counters included, and the doc says so —
along with the trap it replaces: comparing to_string() is style-blind.

Waits name scrolled-off history (#203, the diagnostic half of #147)

wait_until and wait_frame errors, on both the timeout and EOF paths, say
how many rows have scrolled off the top and point at Screen::full_text. The
note is conditional on purpose: the predicate is an arbitrary closure, so the
wait cannot know what it was looking for — only what is true. Silent when
nothing has scrolled. contains and find say where they stop.

resize after exit is refused (#202) — behaviour change

The same Error::Write as send, on the same evidence (EOF). It used to
succeed and the snapshot then reported a size no application ever rendered at.
Listed under Changed. No existing test resized after wait_exit.

The rest

Verification

fmt · clippy -D warnings in all three feature configurations · 360 tests
all-features, 340 no-default-features, 358 decode-only (337 on main)
· docs -D warnings, default and all-features · cargo deny · MSRV 1.85 with
--locked --all-features · cargo-semver-checks against the published 0.7.0:
no semver update required (every API change is additive).

Every new guard was mutation-checked — removed in turn, its test went red,
file restored from the commit: charset translation off, SO ignored, RIS
keeping the graphics set, the history note silenced, resize accepting a
departed child, wheel modifiers dropped, and the colours tie order reversed.

What the stress workflow found

The first stress run on this branch — 100 iterations, ten shards — went 9 of
10 green
. The ubuntu/8-thread shard failed on its first iteration in
mouse_events_outside_the_grid_are_refused: a resize followed at once by
Esc, and the fixture never exited. That test landed in #177 after the last
stress run, so it had never been stressed. It reproduced locally at about one
run in forty.

The cause is in crossterm's event reader, and a real terminal has the same
race. When its poll reports the tty and the SIGWINCH fd in the same call,
the signal arm returns the Resize event at once, abandoning the tty
readiness it was handed — and mio registers edge-triggered (EPOLLET,
EV_CLEAR), so the Esc already in the queue is never offered again until
more input arrives. The application blocks in read() with the keystroke
behind it.

Fixed in the second commit: form-echo acknowledges a resize on its last:
line, the test waits for that frame before sending Esc, and
Terminal::resize documents the trap so a user's own test does not walk into
it.

…and then a real bug in resize

The second stress run went 9 of 10 again, now failing at 16 threads on
iteration 21 — at the new wait_frame for the acknowledgement. The error was
telling: the live screen showed last: resize:18x4, so the fixture had
repainted, yet the wait reported no repaint since the cursor. resize sent the
SIGWINCH first and took the frame cursor afterwards; a fast application's
acknowledging repaint could complete, and be counted, in that gap — so the one
frame the wait had been promised sat behind the cursor. The doc's guarantee
("only a frame completed after the resize can satisfy the wait") was inverted
by the ordering. Worse, such a frame was rendered into the old grid and then
clipped.

Fixed in the third commit: the grid is resized and the cursor taken before
the ioctl, all under one state lock, so no byte is processed between the two
steps; if the ioctl fails the grid is restored so kernel and grid never
disagree. A new test in frames.rs resizes three times and requires each
acknowledging frame to be offered and to carry the new size. Listed under
Fixed in the CHANGELOG — this one affected users, not just our suite.

Verified with 40 iterations each of the input and frames suites and 12 of the
whole workspace, all at 16 threads, no failure — and the stress workflow is
running again on the final commit; the release PR will cite that run.

Closes the small and medium items of the v0.8 milestone; the two design
questions (#146, #147) are addressed in their issues.

- DEC Special Graphics (ESC ( 0, SO/SI) is translated so ncurses borders
  read as box drawing, and an OSC 8 label records the glyphs (#179)
- Terminal::scroll_with with Scroll::{ctrl,alt,shift} and ScrollChord (#174)
- Screen implements PartialEq/Eq as the same observation (#183)
- wait_until/wait_frame errors name scrolled-off rows and full_text (#203)
- resize is refused once the child released the terminal (#202)
- Bitmap::colours is linear with a documented tie order (#201)
- resize documents that history keeps its captured width (#148)
- doc links name Error::Size, not Error::Input (#198)
- README mirror compile-checks insta::assert_snapshot! (#200)
- CONTRIBUTING lists every CI gate and the real full-suite command (#199)
- SECURITY.md names the two unsafe FFI calls instead of claiming none
- cells_between uses i64 so it holds its own bound

Signed-off-by: Vyncint Ng <115854244+vyncint@users.noreply.github.com>
@vyncint vyncint added this to the v0.8 milestone Aug 29, 2026
The stress workflow failed once at 8 threads on ubuntu, in
mouse_events_outside_the_grid_are_refused: a resize followed at once by Esc
hung the fixture and wait_exit timed out. Reproduced locally at about one
run in forty.

crossterm's event reader returns the Resize event as soon as its poll
reports the SIGWINCH, abandoning the tty readiness delivered in the same
poll; mio registers edge-triggered (EPOLLET, EV_CLEAR), so the Esc already
in the queue is never offered again until more input arrives. A real
terminal has the same race.

The fixture now acknowledges a resize on its last: line, the test waits for
that frame before sending Esc, and Terminal::resize documents the trap so a
user's test does not walk into it. 40 further iterations of the input suite
and 10 of the whole workspace at 8 threads: no failure.

Signed-off-by: Vyncint Ng <115854244+vyncint@users.noreply.github.com>
The second stress run failed at 16 threads on iteration 21, at the wait for
the resize acknowledgement, with the live screen showing the acknowledging
repaint: resize sent the SIGWINCH first and took the frame cursor after, so
a fast application's repaint could complete and be counted in that gap, sit
behind the cursor, and never be offered to wait_frame. Such a frame was also
rendered into the old grid and clipped afterwards.

The grid is now resized and the cursor taken before the ioctl, under one
state lock, so no byte is processed between the two steps; if the ioctl
fails the grid is restored. A new test resizes three times and requires each
acknowledging frame to be offered and to carry the new size. 40 iterations
each of the input and frames suites and 12 of the workspace at 16 threads:
no failure.

Signed-off-by: Vyncint Ng <115854244+vyncint@users.noreply.github.com>
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