Skip to content

Grapheme cluster processing (DEC private mode 2027) - #1857

Merged
raphamorim merged 8 commits into
mainfrom
mode-2027
Aug 10, 2026
Merged

raphamorim merged 8 commits into
mainfrom
mode-2027

Conversation

@raphamorim

@raphamorim raphamorim commented Aug 10, 2026 •

Copy link
Copy Markdown
Owner

Implements grapheme clustering per contour's terminal-unicode-core proposal: with CSI ? 2027 h, incoming text is segmented into UAX #29 extended grapheme clusters, so a ZWJ emoji or an Indic conjunct occupies one cell slot instead of one per width-bearing codepoint. Ships on the unicode foundation (#1854). Four commits, one per layer.

Manually verified

Built and ran: ruler + printf '\e[?2027h🧑‍🌾|' puts the pipe at column 3 (one two-cell cluster, shaped as a single glyph); the same sequence after \e[?2027l puts it at column 5 (legacy). CSI ? 2027 $p answers CSI ? 2027 ; 1 $y — the DECRQM detection contract apps like notcurses rely on, verified from a live shell.

The design: position-anchored, stateless

The core deviation from ghostty (which filters controls and resets cursor-movement state): no cross-call segmentation state exists. Any no-break sequence accumulates into a single cell, so everything the break rules can look behind at — an emoji ZWJ run (GB11), regional-indicator parity (GB12/13), an Indic conjunct chain (GB9c) — is exactly the previous cell's contents, and try_cluster_append rebuilds the BreakState from them per codepoint (a handful of steps against rio-unicode's conformance-proven machine). Cursor movement, clears, scrolling, and screen switches invalidate clusters automatically; there are no reset hooks to forget. RI parity correctness across cells falls out of the invariant (a complete pair always occupies one cell).

Width follows ghostty's effect rules: valid VS16/VS15 flip the cluster wide/narrow (reusing the existing promotion machinery, extracted as widen_prev_cell with its row-edge wrap dance); an invalid selector is ignored outright (ghostty's .ignore — cell untouched, deviating deliberately from the legacy attach); any width-bearing continuation makes the whole cluster wide; zero-width continuations attach as before. Bulk non-ASCII decoding bails to scalar under the mode; ASCII bulk paths stay fast (ASCII never continues a cluster; the previous-cell anchor self-invalidates across runs).

Layer by layer

  1. Mode plumbing: NamedPrivateMode::GraphemeCluster, Mode::GRAPHEME_CLUSTER (bit 24), DECSET/DECRST, DECRQM Set/Reset, RIS reset, shared across main/alt screens (matching ghostty/contour). Default: off.
  2. Cluster input path: as above. Mode off is bit-identical — the full legacy suite passes unchanged.
  3. Grid-consumer audit: regex search now feeds every codepoint a cell carries to the DFA (a search for a decomposed accent or a ZWJ sequence can finally match, cell-granular positions; bg-only cells stop feeding color bits decoded as chars). Reflow verified atomic (pair + extras id ride one Square through shrink/grow, reclaim-safe, test-pinned); copy/serialize round-trips cluster text into an identical grid; selection needs nothing (clusters are wide pairs); kitty placeholders coexist untouched.
  4. Embedder channel: the wasm wire format gains a CELL_HAS_CLUSTER flag (free bit above the wide field, backward compatible) + RioTerm::cluster_text(); the C ABI gets RIO_CELL_HAS_CLUSTER in a reserved style_flags bit (the by-value struct can't grow) + rio_render_state_cell_cluster() with a count-first UTF-32 contract. An end-to-end test drives DECSET 2027 + a ZWJ emoji through inject_output and reads the cluster back through the public surface.

Performance

The cluster path takes the ghostty devlog-006 treatment: a flat grapheme-class table over U+0000..U+20000 (same window as the width table) and a precomputed transition table folding the entire break-rule chain into one indexed byte — (prev_class, next_class, state) -> (break | next_state), 5,832 entries, ~6KB. Both are built at first use by driving rio-unicode's conformance-tested reference implementation, so they can't drift from the rules, and a test asserts every entry against the reference. On the worst-case workload (pure Japanese text + emoji ZWJ clusters, best-of-7): mode-on throughput went from 31.5 to 59.9 Mcps, cutting the overhead vs mode-off from 3.05x to 1.53x. Mode off stays bit-identical and cost-free.

Tests

470+ across the tree, including 12 mode-2027 tests porting ghostty's Terminal.zig corpus concepts (ZWJ scalar/bulk, RI pairing + parity break, GB9c conjuncts with ghostty's wide rule, both selector cases, skin tones, row-edge widening, reflow, text round-trip) and 2 cluster-search tests. Rendering needed zero changes — #1854's mark-shaping already draws cluster cells as single glyphs.

Deliberately deferred

  • A config knob for the boot default (default-on decision comes after bake time, the ghostty path — a knob controlling nothing yet is noise).
  • rioterm.js renderer-side use of the new flag (separate repo; one conditional).
  • tmux passthrough remains ecosystem-limited, as scoped.

Grapheme cluster processing becomes a named, tracked, DECRQM-visible
mode: DECSET/DECRST toggle Mode::GRAPHEME_CLUSTER (bit 24), the
report arm answers Set/Reset so applications can detect support, RIS
resets it, and it is shared across main/alt screens like ghostty and
contour. Plumbing only — segmentation behavior lands with the
cluster input path, and this must not ship to users before it does:
answering DECRQM makes notcurses and friends enable the mode and
expect clustering.
When mode 2027 is set, each incoming codepoint asks whether a UAX #29
break separates it from the previous cell's cluster; no break means it
joins that cell instead of starting its own. The design carries no
cross-call segmentation state: any no-break sequence accumulates into
one cell, so everything the rules can look behind at — an emoji ZWJ
run, regional-indicator parity, an Indic conjunct chain — is exactly
the previous cell's contents, and the BreakState is rebuilt from them
per codepoint. Cursor movement, clears, scrolling and screen switches
therefore invalidate a cluster automatically, with no reset hooks.

Width follows ghostty's effect rules: a valid emoji variation
selector flips the cluster wide or narrow and an invalid one is
ignored outright (not attached, unlike the legacy path); any other
width-bearing continuation makes the whole cluster wide, reusing the
VS16 widen mechanics — including the row-edge wrap dance — now
extracted as widen_prev_cell; zero-width continuations attach as
before. The bulk non-ASCII decode path bails to scalar under the
mode (whole-run placement would split clusters); ASCII bulk paths
stay fast, since ASCII never continues a cluster and the
previous-cell anchor self-invalidates across a run.

Mode off is bit-identical to before: the full legacy suite passes
unchanged. Nine cluster tests port ghostty's Terminal.zig corpus
concepts: ZWJ sequences scalar and bulk, RI pairing and parity
breaks, GB9c conjuncts (wide, per ghostty's width rule), valid and
invalid variation selectors, skin tones, combining marks, and
widening across the row edge.
The regex search DFA now sees every codepoint a cell carries, not
just its base: attached combining marks and cluster continuations
feed in visual order (reversed for leftward search), so a search for
a decomposed accent or a ZWJ emoji sequence can actually match, with
match positions staying cell-granular. Bg-only cells — whose bits
store color where the codepoint lives — read as blanks for search
instead of the color bits decoded into a char.

The rest of the audit confirmed correctness by construction and pins
it with tests: reflow moves a cluster atomically (the wide pair and
its extras id ride the same Square through shrink-rewrap and
grow-unwrap, every row-moving op conservatively sets the extras
hint, and reclaim after resize leaves the slot live), and
copy/serialize emits full cluster text that replays into an
identical grid. Selection needs nothing: clusters are wide pairs and
the existing spacer-aware expansion covers them. Kitty Unicode
placeholders coexist untouched — placeholder-to-placeholder always
breaks (GCB Other), and their coordinate diacritics attach through
the same zero-width path as before.
The wasm wire format carried only a cell's base codepoint, so a
mode-2027 cluster (or any combining sequence) reached rioterm.js as
its first character. Word 0 gains a CELL_HAS_CLUSTER flag in the free
bits above the wide field, and RioTerm::cluster_text(line, col)
returns the cell's full text for flagged cells — old renderers that
ignore the bit keep drawing bases, unchanged.

The C ABI had the same gap: rio_cell_s returns one codepoint. The
struct is returned by value, so it can't grow; a reserved style_flags
bit (RIO_CELL_HAS_CLUSTER, bit 15 — StyleFlags proper ends at bit 10)
signals attachments, and rio_render_state_cell_cluster writes the
full text as UTF-32 into a caller buffer with a count-first contract.

Both sit on one new RenderState accessor pair (cluster_of /
cell_cluster_text), guarded against bg-only squares whose extras-id
bits are color data. An end-to-end test drives DECSET 2027 and a ZWJ
emoji through inject_output and reads the cluster back through the
public surface.
@raphamorim
raphamorim merged commit 36271d7 into main Aug 10, 2026
14 checks passed
@raphamorim
raphamorim deleted the mode-2027 branch August 10, 2026 19:47
jquast added a commit to jquast/ucs-detect that referenced this pull request Aug 12, 2026
Pulled in master for PR raphamorim/rio#1857 and
the results are remarkable. Only missing standalone RI/VS support, which
is a bit rare. It looks like kitty support has also recently been added.
jquast added a commit to jquast/ucs-detect that referenced this pull request Aug 30, 2026
* Fully implement the kitty keyboard test.   Compliant implementations (even kitty itself) only report 'partial' support because the current probe only queried the currently-enabled flags, which are always at rest.  You have to first SET the flag value, then check it to see if it 'stuck'.

* better "partial" kitty keyboard reporting

mixing changes from @btpence, I chose not to use "stack" here as
proposed earlier, but the most directly "set and get" methods, and to
continue to document this caveat about partial support, though it needs
testing with these two terminals (alacritty, terminal.exe) with the new
data format

* Incredible improvements from rio!

Pulled in master for PR raphamorim/rio#1857 and
the results are remarkable. Only missing standalone RI/VS support, which
is a bit rare. It looks like kitty support has also recently been added.

* report partial kitty features with more general caveat

* make docker builds optional, too noisy

* terminator no longer "sampled_pct"

I didn't intend to "sample" before, but no matter same results

* Incredible recent unicode improvements by contour?

-  xtversion_raw: contour 0.7.0-master-834edcd4
+  xtversion_raw: contour 0.7.0-master-c51e15ed

* some kitty kbd improved-collected, profiling changes otherwise

* detect and remove "stale" screenshot files

* updated screenshots, and remove stale ones

---------

Co-authored-by: Brian Pence <bpence@celestialsoftware.net>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant