Keep a wide pair whole when one half is written or erased #21

Merged
natsukium merged 1 commit from fix/wide-cluster-overwrite into main 2026-10-04 17:49:31 +09:00
Owner

Closes #11.

The grid's write and erase paths recognized only a bare Char as the owner of a Spacer, so a multi-codepoint wide grapheme (VS16 emoji, flag, keycap, ZWJ sequence) lost text when one half was overwritten or erased. The selective and rectangle erases and the DECFRA / OSC 66 overwrites had no edge repair at all, so they split a plain 字 too.

Change

  • Eviction keys on the Spacer alone: whatever its left neighbor holds is its owner (evict_wide_partner_at, the ASCII bulk path).
  • Erases (ECH/EL/ED via range_blank_inner, DECSEL, DECSED, DECERA, DECSERA) widen their span to the whole pair through one helper, pair_aligned_span. Both halves of a pair share one protection state, so a protected pair survives whole.
  • Overwrites (DECFRA, the OSC 66 block stamp) blank the outside half instead.
  • Edge probes skip cells past the row watermark, where a recycled row's stale Spacer would pull in a live cell.
  • REP after a fold repeats the whole cluster. It used to repeat narrow ❤, and 🇯🇵 turned into 🇯🇯.
  • The ASCII bulk loop probes only the run's two edges.

Out of scope: the cell-moving editors (ICH, DCH, IRM, SL/SR, DECCRA, resize) belong to #12.

Verification

  • crates/felis-grid/tests/snapshot_wide_pairs.rs: 24 cases with a role-aware cell renderer. Each case's pre-fix output was recorded first, and all 24 reproduced the bug.
  • every_spacer_sits_right_of_a_two_cell_glyph in tests/proptest_grid.rs: fails on main, passes here.
  • Ad-hoc full-screen ASCII repaint bench: the first per-cell version regressed it by 31%; the edge-only version is 29% faster than main. ascii_throughput and unicode_throughput show no regression.
  • GUI before/after (release build, niri screenshot): the prompt repaint of ❄️/🇯🇵/❤️, ASCII over 👩‍💻, REP, and DECFRA over 字 render correctly. The other cases looked the same before because an orphan Spacer draws blank; their difference is in captured text.
  • just check green. Design reviewed by sol in two rounds, then luna; code reviewed by sol (PASS).
Closes #11. The grid's write and erase paths recognized only a bare `Char` as the owner of a `Spacer`, so a multi-codepoint wide grapheme (VS16 emoji, flag, keycap, ZWJ sequence) lost text when one half was overwritten or erased. The selective and rectangle erases and the DECFRA / OSC 66 overwrites had no edge repair at all, so they split a plain `字` too. ## Change - Eviction keys on the `Spacer` alone: whatever its left neighbor holds is its owner (`evict_wide_partner_at`, the ASCII bulk path). - Erases (ECH/EL/ED via `range_blank_inner`, DECSEL, DECSED, DECERA, DECSERA) widen their span to the whole pair through one helper, `pair_aligned_span`. Both halves of a pair share one protection state, so a protected pair survives whole. - Overwrites (DECFRA, the OSC 66 block stamp) blank the outside half instead. - Edge probes skip cells past the row watermark, where a recycled row's stale `Spacer` would pull in a live cell. - REP after a fold repeats the whole cluster. It used to repeat narrow `❤`, and `🇯🇵` turned into `🇯🇯`. - The ASCII bulk loop probes only the run's two edges. Out of scope: the cell-moving editors (ICH, DCH, IRM, SL/SR, DECCRA, resize) belong to #12. ## Verification - `crates/felis-grid/tests/snapshot_wide_pairs.rs`: 24 cases with a role-aware cell renderer. Each case's pre-fix output was recorded first, and all 24 reproduced the bug. - `every_spacer_sits_right_of_a_two_cell_glyph` in `tests/proptest_grid.rs`: fails on main, passes here. - Ad-hoc full-screen ASCII repaint bench: the first per-cell version regressed it by 31%; the edge-only version is 29% faster than main. `ascii_throughput` and `unicode_throughput` show no regression. - GUI before/after (release build, niri screenshot): the prompt repaint of ❄️/🇯🇵/❤️, ASCII over 👩‍💻, REP, and DECFRA over `字` render correctly. The other cases looked the same before because an orphan `Spacer` draws blank; their difference is in captured text. - `just check` green. Design reviewed by sol in two rounds, then luna; code reviewed by sol (PASS).
grid: keep a wide pair whole when one half is written or erased
All checks were successful
bench / Criterion full-suite snapshot (pull_request) Has been skipped
fuzz / cargo fuzz nightly long-run (pull_request) Has been skipped
bench / Criterion regression gate (pull_request) Successful in 36m48s
fuzz / cargo fuzz smoke (per target) (pull_request) Successful in 17m7s
pr / nix flake check (pull_request) Successful in 10m45s
pr / cargo build / clippy / test / deny (pull_request) Successful in 12m56s
pr / wire schema is compatible with the base (pull_request) Successful in 8m8s
pr / frontend smoke (x86_64-linux) (pull_request) Successful in 7m13s
windows / detect relevant changes (pull_request) Successful in 2m22s
windows / windows docs-only no-op (pull_request) Has been skipped
windows / cargo clippy (Windows cross) (pull_request) Successful in 2m26s
darwin / detect relevant changes (pull_request) Successful in 2m11s
darwin / darwin docs-only no-op (pull_request) Has been skipped
darwin / build felis (aarch64-darwin) (pull_request) Successful in 2m9s
windows / cargo nextest (Windows) (pull_request) Successful in 3m58s
windows / frontend smoke (Windows) (pull_request) Successful in 1m41s
windows / package felis (x86_64-pc-windows-msvc) (pull_request) Has been skipped
c68e6b189a
The grid's write and erase paths recognized only a bare `Char` as the
owner of a `Spacer`, so a multi-codepoint wide grapheme (VS16 emoji,
flag, keycap, ZWJ sequence) lost text when half of it was touched: a
prompt that redraws an unchanged `❄️` evicted the emoji and the next
character, and ASCII over the left half of `👩‍💻` blanked the first
letter. The selective and rectangle erases (DECSEL, DECSED, DECERA,
DECSERA) and the overwrites (DECFRA, the OSC 66 block stamp) had no
edge repair at all, so they split a plain `字` too.

A `Spacer` belongs to the cell on its left whatever that cell holds, so
the eviction now keys on the `Spacer` alone. Erases widen their span to
the whole pair, which is protection-correct because both halves carry
the same style and DECCARA cannot change protection; overwrites evict
the outside half instead, since widening would fill it with non-wide
content. Edge probes skip cells past the row watermark, where a
recycled row's stale `Spacer` would otherwise pull in a live cell.

The ASCII bulk loop now probes only the run's two edges: a pair inside
the run is overwritten whole, and probing every cell regressed a
full-screen repaint by 31%. Edge-only probing makes that repaint 29%
faster than before this change.

REP repeated `last_printed`, which the fold path never updated, so
`❤️ CSI 2 b` printed narrow hearts and a flag repeated a lone indicator
that paired into a different flag; a fold into the just-printed cell
now re-anchors it to the cluster.

Cell-moving editors (ICH, DCH, IRM, SL/SR, DECCRA, resize) still split
pairs and are tracked separately.

Closes #11

Assisted-by: Claude Code:Opus 5.5
natsukium deleted branch fix/wide-cluster-overwrite 2026-10-04 17:49:31 +09:00
Sign in to join this conversation.
No reviewers
No milestone
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
felis-terminal/felis!21
No description provided.