grid: a scroll over a dirty region, and DL/IL, ship rows instead of a Scrolled directive #447

Closed
opened 2026-09-25 12:52:03 +09:00 by natsukium · 0 comments
Owner

Split from #422 (its scroll half was declined until this lands).

Problem

The daemon ships GridMsg::Scrolled only for a pure scroll: scroll_region_up / scroll_region_down in crates/felis-grid/src/editing.rs check region_has_dirty_rows and, if any row of the region is already dirty, mark the whole region and ship it as RowDelta rows. The per-subscriber gate accept_scroll (crates/felis-daemon/src/serve/streaming.rs) does the same when a slow subscriber still owes rows in the region. DL / IL (editing.rs dl / il) never produce a directive at all: they rotate the band [cursor row, margins.bottom] and then mark_all(), so even the rows outside the margins are replayed.

So in practice the client almost never receives one. Measured for #422 (Linux, NVIDIA, release, 212x93, 43ac9b14, instrumented client):

workload frames Scrolled received rows marked per frame (median) build+upload median
seq -w flood, 6 runs 366-480 each 0, 0, 2, 3, 14, 19 93 577-1671 us
a line every 33 ms (printf "\n..."), 3 runs 193-209 0 93 1207-2334 us
less, j every 30 ms 143 0 92 630 us
vim -u NONE, ^E every 30 ms, 2 runs 183-224 0 92 427-638 us

Three causes:

  • A flood or a streaming printer prints into the region before the tick's line feed, so the region is dirty when it scrolls.
  • less rewrites its prompt on the bottom row before it scrolls: same gate.
  • vim scrolls with DECSTBM + DL (ESC[1;28r ESC[H ESC[M ESC[r, captured from a pty). DL does not go through scroll_region_up.

Every such frame rebuilds and uploads the whole region on the client (#424's row cache marks every row a RowDelta touches), and ships every row of it on the wire. docs/explanation/rendering/pipeline.md already describes the intended behavior ("everything accumulated since the last emission coalesces into one Scrolled op plus a bottom-row batch"); the grid does not do that today.

Proposal

No wire change. GridScrolled { region_top, region_bottom, n_rows, direction } already names an arbitrary row band, and the ordering contract (docs/reference/ipc.md: queued directives first, then one RowDelta whose entries override) already lets a cycle carry a directive plus rows. What changes is when the daemon emits it and which rows it marks:

  1. Mixed ticks. On a scroll, shift the region's dirty bits with the rows instead of marking the whole region: a dirty row at r becomes dirty at r - n (up) or r + n (down), bits pushed out of the region drop, and the rotated-in band is marked as today. Then emit the directive regardless of prior dirt. accept_scroll does the same rotation on the subscriber's own Damage instead of falling back to a region replay. The cycle then ships Scrolled + only the rows whose final content differs from the shifted mirror.
  2. DL / IL. Outside DECLRMM, CSI n M at cursor row r inside the margins is a scroll up of [r, margins.bottom] by n with no scrollback push; CSI n L is a scroll down of the same band. Emit Scrolled { region_top: r, region_bottom: margins.bottom, ... } for them, under the same rotation rule.
  3. Coalescing. Consecutive queued directives with the same band and direction merge by summing n_rows; once the accumulated shift reaches the band height, drop the directives and mark the band (the directive then saves nothing). This answers the cost argument in the editing.rs comment ("a directive on top doubles the per-frame cost"): that holds only while the region is also replayed in full, which rotation removes.

Sketch of a mixed tick (region 0..=92, print on 92, LF, print on 92):

Scrolled { region_top: 0, region_bottom: 92, n_rows: 1, direction: Up }
RowDelta { rows: [(91, ...), (92, ...)] }   // was: rows 0..=92

Compatibility

  • felis.proto is untouched, so tools/proto/compat.py has nothing to gate. No Hello flag: docs/explanation/architecture/ipc.md already rejects a capability bit for scroll directives.
  • Semantics change for released clients: ipc.md states Scrolled is "emitted only when the scrolled region is otherwise clean". A released client applies the shift then the RowDelta overrides, so it converges as long as the daemon marks the right rows; nothing in the client assumes the band is clean afterwards. This needs confirming against the v0.1.0 shadow (apply_scroll_directive and the pull/cycle boundary) before the reference text is changed.
  • A band narrower than the DECSTBM margins (from DL/IL) is already admissible: the admission rule only requires region_top <= region_bottom inside the announced grid and 1 <= n_rows <= height.

What it unlocks

#422 part 2: rotate per-slot y offsets on the client (a uniform; storage buffers are not guaranteed in vertex shaders on downlevel adapters), rotate the shadow's damage bits instead of marking the region, and rotate the row cache and ShapeFrame slots. On the frames above that would replace roughly the whole 430-2300 us build+upload with one or two rebuilt rows. This issue is worth doing on its own for wire bytes even before the client side lands.

Open questions

  • Coalescing across different bands (vim's DL band vs a full-region LF in the same tick): ship each, or fall back to a replay after some count? The queue cap is 1024 today.
  • Does rotating damage bits need the grid's Damage to learn a band shift, or can rotate_region carry it? Either way crates/felis-grid/tests/damage_correctness.rs (no underdraw) must hold with a directive and rows in the same cycle, and the row_cache.rs property test with directives on a dirty band.
  • The DECLRMM sub-rectangle path stays on replay; worth a directive with column bounds, or not (no wire field for columns, and no measured workload)?
  • Scrollback-anchored placements already shift on ScrolledIntoScrollback; DL/IL directives must not push into scrollback or shift placements differently from today.
  • Remeasure the four workloads above after the change: Scrolled count per run and rows marked per frame should drop to the rotated-in band plus the rows actually written.

Refs #422, #424.

Split from #422 (its scroll half was declined until this lands). ## Problem The daemon ships `GridMsg::Scrolled` only for a pure scroll: `scroll_region_up` / `scroll_region_down` in `crates/felis-grid/src/editing.rs` check `region_has_dirty_rows` and, if any row of the region is already dirty, mark the whole region and ship it as `RowDelta` rows. The per-subscriber gate `accept_scroll` (`crates/felis-daemon/src/serve/streaming.rs`) does the same when a slow subscriber still owes rows in the region. DL / IL (`editing.rs` `dl` / `il`) never produce a directive at all: they rotate the band `[cursor row, margins.bottom]` and then `mark_all()`, so even the rows outside the margins are replayed. So in practice the client almost never receives one. Measured for #422 (Linux, NVIDIA, release, 212x93, 43ac9b14, instrumented client): | workload | frames | `Scrolled` received | rows marked per frame (median) | build+upload median | |---|---|---|---|---| | `seq -w` flood, 6 runs | 366-480 each | 0, 0, 2, 3, 14, 19 | 93 | 577-1671 us | | a line every 33 ms (`printf "\n..."`), 3 runs | 193-209 | 0 | 93 | 1207-2334 us | | `less`, `j` every 30 ms | 143 | 0 | 92 | 630 us | | `vim -u NONE`, `^E` every 30 ms, 2 runs | 183-224 | 0 | 92 | 427-638 us | Three causes: - A flood or a streaming printer prints into the region before the tick's line feed, so the region is dirty when it scrolls. - `less` rewrites its prompt on the bottom row before it scrolls: same gate. - vim scrolls with DECSTBM + DL (`ESC[1;28r ESC[H ESC[M ESC[r`, captured from a pty). DL does not go through `scroll_region_up`. Every such frame rebuilds and uploads the whole region on the client (#424's row cache marks every row a `RowDelta` touches), and ships every row of it on the wire. `docs/explanation/rendering/pipeline.md` already describes the intended behavior ("everything accumulated since the last emission coalesces into one `Scrolled` op plus a bottom-row batch"); the grid does not do that today. ## Proposal No wire change. `GridScrolled { region_top, region_bottom, n_rows, direction }` already names an arbitrary row band, and the ordering contract (`docs/reference/ipc.md`: queued directives first, then one `RowDelta` whose entries override) already lets a cycle carry a directive plus rows. What changes is when the daemon emits it and which rows it marks: 1. **Mixed ticks.** On a scroll, shift the region's dirty bits with the rows instead of marking the whole region: a dirty row at `r` becomes dirty at `r - n` (up) or `r + n` (down), bits pushed out of the region drop, and the rotated-in band is marked as today. Then emit the directive regardless of prior dirt. `accept_scroll` does the same rotation on the subscriber's own `Damage` instead of falling back to a region replay. The cycle then ships `Scrolled` + only the rows whose final content differs from the shifted mirror. 2. **DL / IL.** Outside DECLRMM, `CSI n M` at cursor row `r` inside the margins is a scroll up of `[r, margins.bottom]` by `n` with no scrollback push; `CSI n L` is a scroll down of the same band. Emit `Scrolled { region_top: r, region_bottom: margins.bottom, ... }` for them, under the same rotation rule. 3. **Coalescing.** Consecutive queued directives with the same band and direction merge by summing `n_rows`; once the accumulated shift reaches the band height, drop the directives and mark the band (the directive then saves nothing). This answers the cost argument in the `editing.rs` comment ("a directive on top doubles the per-frame cost"): that holds only while the region is also replayed in full, which rotation removes. Sketch of a mixed tick (region 0..=92, print on 92, LF, print on 92): ``` Scrolled { region_top: 0, region_bottom: 92, n_rows: 1, direction: Up } RowDelta { rows: [(91, ...), (92, ...)] } // was: rows 0..=92 ``` ### Compatibility - `felis.proto` is untouched, so `tools/proto/compat.py` has nothing to gate. No `Hello` flag: `docs/explanation/architecture/ipc.md` already rejects a capability bit for scroll directives. - Semantics change for released clients: `ipc.md` states `Scrolled` is "emitted only when the scrolled region is otherwise clean". A released client applies the shift then the `RowDelta` overrides, so it converges as long as the daemon marks the right rows; nothing in the client assumes the band is clean afterwards. This needs confirming against the v0.1.0 shadow (`apply_scroll_directive` and the pull/cycle boundary) before the reference text is changed. - A band narrower than the DECSTBM margins (from DL/IL) is already admissible: the admission rule only requires `region_top <= region_bottom` inside the announced grid and `1 <= n_rows <= height`. ## What it unlocks #422 part 2: rotate per-slot y offsets on the client (a uniform; storage buffers are not guaranteed in vertex shaders on downlevel adapters), rotate the shadow's damage bits instead of marking the region, and rotate the row cache and `ShapeFrame` slots. On the frames above that would replace roughly the whole 430-2300 us build+upload with one or two rebuilt rows. This issue is worth doing on its own for wire bytes even before the client side lands. ## Open questions - Coalescing across different bands (vim's DL band vs a full-region LF in the same tick): ship each, or fall back to a replay after some count? The queue cap is 1024 today. - Does rotating damage bits need the grid's `Damage` to learn a band shift, or can `rotate_region` carry it? Either way `crates/felis-grid/tests/damage_correctness.rs` (no underdraw) must hold with a directive and rows in the same cycle, and the `row_cache.rs` property test with directives on a dirty band. - The DECLRMM sub-rectangle path stays on replay; worth a directive with column bounds, or not (no wire field for columns, and no measured workload)? - Scrollback-anchored placements already shift on `ScrolledIntoScrollback`; DL/IL directives must not push into scrollback or shift placements differently from today. - Remeasure the four workloads above after the change: `Scrolled` count per run and rows marked per frame should drop to the rotated-in band plus the rows actually written. Refs #422, #424.
Commenting is not possible because the repository is archived.
No description provided.