Skip to content

fix(screen): linear searches, and a JSON reader that refuses what no termlens wrote - #613

Merged
vyncint merged 3 commits into
mainfrom
fix/screen-json-and-search
Oct 27, 2026
Merged

vyncint merged 3 commits into
mainfrom
fix/screen-json-and-search

Conversation

@vyncint

@vyncint vyncint commented Oct 27, 2026

Copy link
Copy Markdown
Owner

Two screen-format issues.

#584 — searches were quadratic. searchable_row asked is_ascii() — a pass over every cell — for every row a search visited. Now each cell decides for itself whether it needs NFC (an ASCII cell is NFC already), the needle is folded on its own, and contains keeps its one whole-screen check per query. Every public entry point (find, find_all, locate, mask_matching, and with regex matches, find_match, find_all_matches, mask_matches) goes through these.

1000x1000 release, before → after debug, before → after
find 3233 ms → 5.3 ms 26,770 ms → 74 ms
contains, last cell non-ASCII 2993 ms → 8.2 ms 27,656 ms → 101 ms

The regression test times all entry points on 200x1000 against a reference on the same screen (one pass for searches, building the masked screen for masks), best-of-N, bound 10x: old code 27–464x, new 1.1–1.6x. Stress is dispatched on this branch because the test is timing-based.

#582 — the JSON reader accepted cells the text reader refuses. A cell holding ESC]0;PWNED BEL ESC[2J loaded, and render --text / diff then drove the reader's terminal. Now refused, naming the cell, in the grid and the styled history: a control character (C0/DEL/C1, by the same cell_width rule Screen::parse now shares), printable ASCII after a cell's first character, an empty or ASCII-first cell marked wide without U+FE0F, and a continuation that holds text or follows no wide cell; and 0x0 screens. Unknown MouseModes bits are masked, so len() and iter() agree.

The compatibility promise decided every rule: each refusal is something no release could write (verified against vt100 0.16.2 and 0.18.1, vte 0.15, and unicode-width 0.2.0–0.2.2 over every code point). Kept readable on purpose: keycaps like 1️⃣ (0.18 writes them wide), ⚠️ in both its 0.16 and 0.18 forms, U+FFFD, cells whose width only a newer unicode-width table disputes, and a wide character 0.16 stranded in the last column after a resize. The compat corpus passes; removing each rule in turn turns exactly one test red.

Not in scope (noted): the window title and the text history are not validated; an old JSON file with a stranded wide character loads but its render --text output is refused by Screen::parse (pre-existing).

Closes #582
Closes #584

searchable_row asked whether the whole screen was ASCII on every row
it built, a pass over every cell, so find, find_all, locate,
mask_matching and the regex matches and mask were quadratic in the
size of the screen: one find on 1000x1000 took 3 s in a release build
where contains took 6 ms. contains was quadratic too once the first
non-ASCII cell came late.

The question is now asked per cell, where it is cheap because an
ASCII cell is NFC already, and a needle is folded on its own, since
its folding never depended on the screen. contains keeps its one
whole-screen check per query. find on 1000x1000 now takes 5 ms in
release and 74 ms in debug.

The test times every search against the work it cannot avoid on the
same screen, one pass over the grid or building the masked screen,
rather than against a clock: the old code ran at 27 to 464 times
that, the new at under 2, and the bound is 10.

Closes #584

Signed-off-by: Vyncint Ng <115854244+vyncint@users.noreply.github.com>
Deserialize for Screen checked the grid's shape and the cursor but
took every cell as given, so a saved screen whose cell held an escape
sequence loaded, and termlens render --text, --ansi and diff wrote it
to the reader's terminal: an OSC set the window title, ESC [2J cleared
the screen. A cell of three characters, a wide flag in the wrong
place, an empty grid and mouse_modes 255 loaded too.

Each cell, in the grid and in the styled history, is now held to the
rules Screen::parse builds cells by, as far as they can be checked
without a width table: no control character, by the text reader's own
test; no printable ASCII after the first character; no empty cell marked
wide, nor an ASCII one without a U+FE0F; no second column of a wide
character that holds text or follows none. A screen of no columns or
rows is refused. MouseModes drops the bits no mode occupies, through a
hand-written Deserialize in the derive's shape.

The JSON is a persisted format, so every check refuses only what no
termlens wrote. vt100 0.16 and 0.18 drop control characters before the
grid and append only zero-width characters to a cell, the tracker sets
four mode bits, and spawn and resize refuse a grid under 2x2. What a
writer did produce reads as before: a width the tables of another
unicode-width release give differently (0.2.1 to 0.2.2 moved 197 code
points), a keycap that U+FE0F makes two columns under vt100 0.18, and a
wide character vt100 0.16's resize left in the last column.

Closes #582

Signed-off-by: Vyncint Ng <115854244+vyncint@users.noreply.github.com>
Resolves the CHANGELOG conflict with #612: both sets of entries kept.

Signed-off-by: Vyncint Ng <115854244+vyncint@users.noreply.github.com>
@vyncint
vyncint merged commit 0b17311 into main Oct 27, 2026
16 checks passed
@vyncint
vyncint deleted the fix/screen-json-and-search branch October 27, 2026 10:07
vyncint added a commit that referenced this pull request Oct 27, 2026
Resolves the CHANGELOG conflict with #613: both sets of entries kept.

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

1 participant