grid: stop marking the cursor row on cursor-only moves #463

Merged
natsukium merged 1 commit from cursor-454 into main 2026-09-25 20:35:42 +09:00
Owner

A move of the cursor alone no longer marks the cursor's row dirty. Each of those marks cost a full row on the wire. A program that prints a line per frame now ships 2 rows per cycle instead of 3. The wire format is unchanged.

Why

docs/explanation/rendering/damage-tracking.md "Correctness" (and REQ-1203) requires a cursor-only move to leave damage empty: the cursor ships as CursorState and the client draws it as an overlay. The grid still marked the cursor row on CR, on LF / RI when they don't scroll, and on BS, HT, CUU/CUD/CUF/CUB, CUP, CHA, VPA, CHT, CBT, DECRC, DECSLRM and DECOM. Since #455, a scroll moves each dirty mark along with its row. The mark a \r leaves before each line feed therefore turned into one extra row shipped every frame.

What changed

  • grid: I removed the mark from every handler that only moves the cursor. One handler keeps it: HT under ?41 (MoreFix). That handler sets a row's soft-wrap bit without writing a cell, and the soft-wrap bit is part of the row payload.
  • Open question from the issue: does any consumer read a cursor-row mark as a signal about the cursor? No. I checked each one:
    • compose_diffs compares the cursor with what each subscriber last received on every compose, whether or not anything is marked.
    • The shadow's set_cursor_state marks nothing.
    • The renderer's row cache repaints the row the cursor left and the row it entered by itself. So the old cursor cell is still repainted.
    • Hyperlink hover recomputes on RowDelta / Hyperlink, and a cursor move changes neither.
    • Kitty Unicode placeholders are ordinary cell content.
  • Tests:
    • damage_correctness.rs now requires damage to be empty after each cursor-only handler. It also runs a property over random motion sequences on a non-empty screen, which includes DECSTBM, DECSLRM, DECOM and the reverse-wrap modes. A new case checks that a CRLF at the bottom leaves only the row it vacated marked. All three tests fail on main.
    • every_mirror_ends_each_compose_holding_the_grids_cells now also feeds cursor motion, ?41 / ?45 / ?1045 and ?25. On top of the cells, it compares each shadow's cursor (row, col, visible) and soft-wrap bits with the grid's. If the MoreFix mark is removed, the property fails on the soft-wrap bit; the committed regression seed is that case. It passes at 30 000 cases in release.
    • synchronized_output_holds_writes_into_one_damage_burst used to rely on a bare \r\n marking row 1. It now writes to that row.
  • Docs: damage-tracking.md "Correctness" now says which handlers mark and which don't. testing.md now describes the extended harnesses. CHANGELOG.md has a new entry.

Measurements

I used the method from #455: a temporary counter in the client that records, for each pull cycle, the RowDelta rows and payload bytes and the Scrolled directives it receives. Setup: release build, 212x93 window. This host has no monitor connected, so the window ran under a headless sway at 60 Hz. Each workload ran 3 times per build; the table shows medians of the per-cycle values. The one-minute load average was 3.7 to 14.8 because CI jobs were running; the row counts do not depend on load.

workload rows / cycle (base → new) bytes / cycle (base → new) Scrolled / cycle
a line every 33 ms (printf "\n...") 3 → 2 798 → 514 1.00 → 1.00
vim -u NONE, ^E every 30 ms 5 → 4 (mean 3.7 → 2.6) 1542 → 1229 0.56 → 0.54
less, j every 30 ms 3 → 3 768 → 768 0.97 → 0.98
one keystroke every 50 ms 1 → 1 306 → 306 0 → 0

less stays at 3, contrary to the issue's prediction. Its bytes for a j are \r\x1b[K<new line>\x1b[m\r\n:\x1b[K: it writes the new line on the bottom row before its CRLF. The row above the prompt is therefore really written that cycle, and no cursor mark was involved. The pager workload is the case the issue measured, and it drops to 2.

Review

Independent review with pi (sol) over origin/main...HEAD. Round 1: PASS, no findings.

Closes #454

A move of the cursor alone no longer marks the cursor's row dirty. Each of those marks cost a full row on the wire. A program that prints a line per frame now ships 2 rows per cycle instead of 3. The wire format is unchanged. ## Why `docs/explanation/rendering/damage-tracking.md` "Correctness" (and REQ-1203) requires a cursor-only move to leave damage empty: the cursor ships as `CursorState` and the client draws it as an overlay. The grid still marked the cursor row on CR, on LF / RI when they don't scroll, and on BS, HT, CUU/CUD/CUF/CUB, CUP, CHA, VPA, CHT, CBT, DECRC, DECSLRM and DECOM. Since #455, a scroll moves each dirty mark along with its row. The mark a `\r` leaves before each line feed therefore turned into one extra row shipped every frame. ## What changed - **grid:** I removed the mark from every handler that only moves the cursor. One handler keeps it: HT under `?41` (MoreFix). That handler sets a row's soft-wrap bit without writing a cell, and the soft-wrap bit is part of the row payload. - **Open question from the issue: does any consumer read a cursor-row mark as a signal about the cursor?** No. I checked each one: - `compose_diffs` compares the cursor with what each subscriber last received on every compose, whether or not anything is marked. - The shadow's `set_cursor_state` marks nothing. - The renderer's row cache repaints the row the cursor left and the row it entered by itself. So the old cursor cell is still repainted. - Hyperlink hover recomputes on `RowDelta` / `Hyperlink`, and a cursor move changes neither. - Kitty Unicode placeholders are ordinary cell content. - **Tests:** - `damage_correctness.rs` now requires damage to be empty after each cursor-only handler. It also runs a property over random motion sequences on a non-empty screen, which includes DECSTBM, DECSLRM, DECOM and the reverse-wrap modes. A new case checks that a CRLF at the bottom leaves only the row it vacated marked. All three tests fail on `main`. - `every_mirror_ends_each_compose_holding_the_grids_cells` now also feeds cursor motion, `?41` / `?45` / `?1045` and `?25`. On top of the cells, it compares each shadow's cursor (row, col, visible) and soft-wrap bits with the grid's. If the MoreFix mark is removed, the property fails on the soft-wrap bit; the committed regression seed is that case. It passes at 30 000 cases in release. - `synchronized_output_holds_writes_into_one_damage_burst` used to rely on a bare `\r\n` marking row 1. It now writes to that row. - **Docs:** damage-tracking.md "Correctness" now says which handlers mark and which don't. testing.md now describes the extended harnesses. CHANGELOG.md has a new entry. ## Measurements I used the method from #455: a temporary counter in the client that records, for each pull cycle, the `RowDelta` rows and payload bytes and the `Scrolled` directives it receives. Setup: release build, 212x93 window. This host has no monitor connected, so the window ran under a headless sway at 60 Hz. Each workload ran 3 times per build; the table shows medians of the per-cycle values. The one-minute load average was 3.7 to 14.8 because CI jobs were running; the row counts do not depend on load. | workload | rows / cycle (base → new) | bytes / cycle (base → new) | `Scrolled` / cycle | | --- | --- | --- | --- | | a line every 33 ms (`printf "\n..."`) | 3 → 2 | 798 → 514 | 1.00 → 1.00 | | `vim -u NONE`, `^E` every 30 ms | 5 → 4 (mean 3.7 → 2.6) | 1542 → 1229 | 0.56 → 0.54 | | `less`, `j` every 30 ms | 3 → 3 | 768 → 768 | 0.97 → 0.98 | | one keystroke every 50 ms | 1 → 1 | 306 → 306 | 0 → 0 | `less` stays at 3, contrary to the issue's prediction. Its bytes for a `j` are `\r\x1b[K<new line>\x1b[m\r\n:\x1b[K`: it writes the new line on the bottom row before its CRLF. The row above the prompt is therefore really written that cycle, and no cursor mark was involved. The pager workload is the case the issue measured, and it drops to 2. ## Review Independent review with `pi` (sol) over `origin/main...HEAD`. Round 1: **PASS**, no findings. Closes #454
grid: stop marking the cursor row on cursor-only moves
Some checks failed
bench / Criterion full-suite snapshot (pull_request) Has been skipped
fuzz / cargo fuzz nightly long-run (pull_request) Has been skipped
darwin / detect relevant changes (pull_request) Failing after 5m1s
windows / detect relevant changes (pull_request) Failing after 5m0s
darwin / build felis (aarch64-darwin) (pull_request) Has been skipped
darwin / darwin docs-only no-op (pull_request) Has been skipped
darwin / publish felis (aarch64-darwin) (pull_request) Has been skipped
windows / cargo nextest (Windows) (pull_request) Has been skipped
windows / windows docs-only no-op (pull_request) Has been skipped
windows / frontend smoke (Windows) (pull_request) Has been skipped
windows / package felis (x86_64-pc-windows-msvc) (pull_request) Has been skipped
bench / Criterion regression gate (pull_request) Successful in 6m25s
fuzz / cargo fuzz smoke (per target) (pull_request) Successful in 1m54s
pr / cargo build / clippy / test / deny (pull_request) Successful in 2m6s
windows / cargo clippy (Windows cross) (pull_request) Successful in 42s
pr / nix flake check (pull_request) Successful in 1m3s
pr / wire schema is compatible with the base (pull_request) Successful in 2m41s
pr / frontend smoke (x86_64-linux) (pull_request) Successful in 48s
pr / publish felis (x86_64-linux) (pull_request) Has been skipped
179fd6209f
The damage contract says a cursor-only move leaves damage empty, since
the cursor ships as CursorState and is drawn as a client-side overlay,
but CR, LF / RI without a scroll, BS, HT, CUU/CUD/CUF/CUB, CUP, CHA,
VPA, CHT, CBT, DECRC, DECSLRM and DECOM all still marked the cursor
row. Now that a scroll moves the dirty marks with the rows, the mark a
CR leaves before each line feed costs a whole row on the wire every
frame: a program printing a line per frame shipped the new bottom row,
its default-cell entry, and the row above, which only the CR marked.

Nothing downstream reads a cursor-row mark as a cursor signal: the
daemon diffs the cursor against what each subscriber last received on
every compose, independent of damage, the shadow's CursorState apply
marks nothing, and the renderer's row cache repaints the rows the
cursor left and entered on its own. The one handler that changes row
state without writing a cell, HT under ?41 setting the soft-wrap bit,
keeps its mark.

Measured with a per-cycle RowDelta counter in the client, on a 212x93
window under a headless sway at 60 Hz, 3 runs each, medians:

  printf "\n..." every 33 ms:   3 -> 2 rows, 798 -> 514 bytes
  vim -u NONE, ^E every 30 ms:  5 -> 4 rows (mean 3.7 -> 2.6)
  less, j every 30 ms:          3 -> 3 rows
  one keystroke every 50 ms:    1 -> 1 row

less stays at 3 because it writes the new line on the bottom row
before its CRLF, so the row above is genuinely written that cycle.

damage_correctness.rs now asserts empty damage for each cursor-only
handler and for random motion sequences, and the daemon mirror
property adds cursor motion and ?41/?45/?1045 to its input and
compares the cursor and soft-wrap bits as well as the cells; with the
HT mark removed it fails on the soft-wrap bit.

Assisted-by: Claude Code
natsukium deleted branch cursor-454 2026-09-25 20:35:42 +09:00
Commenting is not possible because the repository is archived.
No description provided.