Grapheme cluster processing (DEC private mode 2027) - #1857
Merged
Merged
Conversation
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.
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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[?2027lputs it at column 5 (legacy).CSI ? 2027 $panswersCSI ? 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_appendrebuilds theBreakStatefrom 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_cellwith 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
NamedPrivateMode::GraphemeCluster,Mode::GRAPHEME_CLUSTER(bit 24), DECSET/DECRST, DECRQM Set/Reset, RIS reset, shared across main/alt screens (matching ghostty/contour). Default: off.CELL_HAS_CLUSTERflag (free bit above the wide field, backward compatible) +RioTerm::cluster_text(); the C ABI getsRIO_CELL_HAS_CLUSTERin a reservedstyle_flagsbit (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 throughinject_outputand 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