grid: disarm a pending zero-width joiner on the C0 cursor controls #414

Merged
natsukium merged 2 commits from grid/zwj-c0-disarm into main 2026-09-25 05:41:27 +09:00
Owner

Closes #412

A pending zero-width joiner (Grid::zwj_pending, armed by the ZWJ fold and consumed by the next print) now disarms on the C0 controls that move the cursor: BS, HT, LF, VT, FF, CR. Before, when one of them put the cursor right behind a cluster ending in a joiner, the next emoji folded into that cluster:

  • BS onto the cluster's spacer;
  • HT pinned at the right edge right behind the cluster;
  • CR parking at a DECLRMM left margin right behind the cluster;
  • LF / VT / FF landing behind such a cluster on the next row.

Which controls

kitty's draw_control_char rebuilds its segmentation state at exactly BS, HT, LF/VT/FF and CR (from the cell left of the new cursor) and keeps it across BEL. felis keeps the GB11 join stream-scoped rather than re-deriving it from cells, so the equivalent is to disarm at those controls. BEL stays transparent: it leaves the cursor at the joiner (pinned by bell_keeps_a_pending_zwj_armed). CSI sequences are out of scope: SGR between a joiner and the next base keeps joining, as in kitty and ghostty.

Both print paths

The per-byte and batched paths share Sink::execute for controls, so the one reset covers both. cursor_controls_disarm_a_pending_zwj_on_both_paths runs the same streams through the #410 per-byte-vs-batched oracle at chunk sizes 1 and unbounded.

The #410 proptest was not extended: its oracle compares the two print paths, which share execute, so it cannot see this class. Each control has a deterministic test instead (crates/felis-grid/src/tests.rs), each verified to fail without the fix.

Docs

CHANGELOG "Fixed" entry; one sentence in docs/explanation/data-model/grid-and-cells.md "Building a cluster" on how far the continuation reaches.

Closes #412 A pending zero-width joiner (`Grid::zwj_pending`, armed by the ZWJ fold and consumed by the next print) now disarms on the C0 controls that move the cursor: BS, HT, LF, VT, FF, CR. Before, when one of them put the cursor right behind a cluster ending in a joiner, the next emoji folded into that cluster: - BS onto the cluster's spacer; - HT pinned at the right edge right behind the cluster; - CR parking at a DECLRMM left margin right behind the cluster; - LF / VT / FF landing behind such a cluster on the next row. ## Which controls kitty's `draw_control_char` rebuilds its segmentation state at exactly BS, HT, LF/VT/FF and CR (from the cell left of the new cursor) and keeps it across BEL. felis keeps the GB11 join stream-scoped rather than re-deriving it from cells, so the equivalent is to disarm at those controls. BEL stays transparent: it leaves the cursor at the joiner (pinned by `bell_keeps_a_pending_zwj_armed`). CSI sequences are out of scope: SGR between a joiner and the next base keeps joining, as in kitty and ghostty. ## Both print paths The per-byte and batched paths share `Sink::execute` for controls, so the one reset covers both. `cursor_controls_disarm_a_pending_zwj_on_both_paths` runs the same streams through the #410 per-byte-vs-batched oracle at chunk sizes 1 and unbounded. The #410 proptest was not extended: its oracle compares the two print paths, which share `execute`, so it cannot see this class. Each control has a deterministic test instead (`crates/felis-grid/src/tests.rs`), each verified to fail without the fix. ## Docs CHANGELOG "Fixed" entry; one sentence in `docs/explanation/data-model/grid-and-cells.md` "Building a cluster" on how far the continuation reaches.
grid: disarm a pending zero-width joiner on the C0 cursor controls
Some checks failed
bench / Criterion full-suite snapshot (pull_request) Has been skipped
bench / Criterion regression gate (pull_request) Failing after 2m26s
darwin / build felis (aarch64-darwin) (pull_request) Successful in 56s
darwin / publish felis (aarch64-darwin) (pull_request) Has been skipped
pr / nix flake check (pull_request) Successful in 38s
pr / cargo build / clippy / test / deny (pull_request) Failing after 1m5s
pr / wire schema is compatible with the base (pull_request) Successful in 14s
fuzz / cargo fuzz nightly long-run (pull_request) Has been skipped
darwin / detect relevant changes (pull_request) Successful in 5s
darwin / darwin docs-only no-op (pull_request) Has been skipped
fuzz / cargo fuzz smoke (per target) (pull_request) Successful in 1m26s
pr / frontend smoke (x86_64-linux) (pull_request) Successful in 2m18s
pr / publish felis (x86_64-linux) (pull_request) Has been skipped
windows / detect relevant changes (pull_request) Successful in 4s
windows / windows docs-only no-op (pull_request) Has been skipped
windows / cargo nextest (Windows) (pull_request) Successful in 4m19s
windows / frontend smoke (Windows) (pull_request) Successful in 1m50s
windows / package felis (x86_64-pc-windows-msvc) (pull_request) Has been skipped
windows / cargo clippy (Windows cross) (pull_request) Successful in 12s
560afbdb0c
The GB11 continuation is armed by the ZWJ fold and consumed by the
next print, but BS, HT, LF, VT, FF and CR left it armed. When the
control put the cursor right behind a cluster ending in a joiner (BS
onto the cluster's spacer, HT pinned at the right edge, CR parking at
a left margin, LF landing behind such a cluster on the next row), the
next emoji folded into that cluster instead of starting its own cell.
Whether it joined depended on the joiner having been the last print
before the control, not on anything the grid shows.

kitty rebuilds its segmentation state at exactly these controls from
the cell left of the new cursor. felis keeps the join stream-scoped
rather than re-deriving it from cells, so the equivalent here is to
disarm. BEL stays transparent, as in kitty: it leaves the cursor at
the joiner. Both print paths share `execute`, so one reset covers
them; the batched-versus-per-byte proptest cannot see this class, so
each control gets a deterministic test.

Assisted-by: Claude Code
ci: rerun checks that failed under runner load
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
darwin / build felis (aarch64-darwin) (pull_request) Successful in 53s
darwin / publish felis (aarch64-darwin) (pull_request) Has been skipped
fuzz / cargo fuzz smoke (per target) (pull_request) Successful in 1m2s
pr / nix flake check (pull_request) Successful in 7s
pr / cargo build / clippy / test / deny (pull_request) Successful in 1m40s
bench / Criterion regression gate (pull_request) Successful in 2m13s
darwin / detect relevant changes (pull_request) Successful in 3s
darwin / darwin docs-only no-op (pull_request) Has been skipped
pr / wire schema is compatible with the base (pull_request) Successful in 11s
pr / frontend smoke (x86_64-linux) (pull_request) Successful in 1m24s
pr / publish felis (x86_64-linux) (pull_request) Has been skipped
windows / detect relevant changes (pull_request) Successful in 5s
windows / windows docs-only no-op (pull_request) Has been skipped
windows / cargo nextest (Windows) (pull_request) Successful in 4m49s
windows / cargo clippy (Windows cross) (pull_request) Successful in 12s
windows / frontend smoke (Windows) (pull_request) Successful in 1m42s
windows / package felis (x86_64-pc-windows-msvc) (pull_request) Has been skipped
9433bb3222
The Criterion gate and one daemon serve test failed while the shared
runner host sat at load 12-19; neither touches the changed code path.

Assisted-by: Claude Code
natsukium deleted branch grid/zwj-c0-disarm 2026-09-25 05:41:27 +09:00
Commenting is not possible because the repository is archived.
No description provided.