Skip to content

fix: preserve grapheme clusters (combining marks) in cell emission - #114

Open
natemoo-re wants to merge 7 commits into
mainfrom
nm/cell-grapheme
Open

natemoo-re wants to merge 7 commits into
mainfrom
nm/cell-grapheme

Conversation

@natemoo-re

@natemoo-re natemoo-re commented Aug 23, 2026 •

Copy link
Copy Markdown
Member

cells with combining marks (zero-width codepoints) were silently dropped when emitted because render_text skipped codepoints with cw == 0. the cell model stored only a single uint32_t, meaning combining accents, ZWJ emoji, skin-tone modifiers, flag variation emoji, and kitty graphics were broken.

the fix here is to update Cell's model to track uint32_t combining[8] and have render_text append zero-width codepoints to the current column before cursor output. this requires updating OUT_BYTES_PER_CELL from 64 to 128 to cover worst-case scenario (base char + 8 combining marks, silently dropping marks beyond 8).

spec §8.3.3 has been updated with a grapheme-cluster preservation requirement. §13 now describes cells as "a grapheme cluster" rather than "a Unicode codepoint" and defines the capacity and truncation behavior.

semi-related to #84

Comment thread src/cell.h
Comment on lines +8 to +10
/* Maximum combining marks stored per cell. Marks beyond this limit are
* silently truncated from the end; the first CELL_MAX_COMBINING are kept. */
#define CELL_MAX_COMBINING 8

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this gives us plenty of headroom for the common case and is unlikely to be a problem in the short-term, but I have a slight concern that it will be a long-term issue if any terminal protocols introduce complex metadata via ZWJ that requires >8 codepoints

an alternative design would be bumping this cieling and keeping a dynamic map of combining size per cell rather than reserving a flat 8 per cell.

@natemoo-re
natemoo-re marked this pull request as ready for review August 23, 2026 11:49
@pkg-pr-new

pkg-pr-new Bot commented Aug 23, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@bomb.sh/tty@114

commit: 5e775b5

@github-actions

github-actions Bot commented Aug 23, 2026 •

Copy link
Copy Markdown

Size Increased — +1.0 KB

112.6 KB unpacked

@codspeed

codspeed Bot commented Aug 23, 2026 •

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 10 untouched benchmarks


Comparing nm/cell-grapheme (5e775b5) with main (00aee53)1

Open in CodSpeed

Footnotes

  1. No successful run was found on main (2864d7d) during the generation of this report, so 00aee53 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report. ↩

The Cell struct stored only a single uint32_t codepoint, so render_text
silently dropped every combining mark (wcwidth ≤ 0): the emitted ANSI
stream contained bare base codepoints with no continuations.

Root cause: kitty-graphics Unicode placeholder cells require two combining
diacritics (row/col index from the rowcolumn-diacritics table) to follow
the base U+10EEEE codepoint. Because those marks were dropped, every
placeholder cell emitted as row 0 — producing the N-repeated-top-band
banding artifact in multi-row placements.

The same drop affected any combining-mark or ZWJ content: accented
characters (e + U+0301), flag pairs, skin-tone modifiers.

Fix:
- Cell gains `uint32_t combining[8]` (zero-terminated). Marks beyond 8
  are silently truncated from the end; the first marks always survive.
- cells_fill uses a designated-init template so combining[] is zeroed
  on every back-buffer reset, and setcell clears it on every base write.
- render_text tracks the last-written column; zero-width codepoints go
  to append_combining() instead of being discarded.
- present_cups / present_lines emit combining[] bytes immediately after
  the base character, before any cursor repositioning.
- OUT_BYTES_PER_CELL bumped 64→128 to cover base + 8 combining marks.
- cell_cmp checks combining[] so a changed mark triggers a diff/rediff.
- Spec updated: §8.3.3 normative preservation requirement, §13 cluster
  cell representation with truncation semantics, §13 measurement note.

Four new tests: kitty placeholder (U+10EEEE + 2 marks), combining accent
(e + U+0301), ZWJ family emoji (ZWJ preserved per-cell; following emoji
start new cells — inherent cell-model constraint, documented), and
truncation-from-end pinned at 8 marks.
427deb4 (#36) inserted the test-alt-os job above the existing test step,
absorbing it into the new job — Linux stopped running tests entirely
while macos/windows kept them. Re-add the step after the wasm build.
Comment thread specs/renderer-spec.md Outdated
Comment thread src/clayterm.c Outdated

@cowboyd cowboyd left a comment •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we make the tests visual as in other test cases? It would involve enhancing our print() helper (yet again!)

expect(trim(print(ansi, 12, 4))).toEqual(`
┌──────────┐
│café      │
│          │
└──────────┘`.trim());

We really ought to see if we can use libghostty for this (not now ofc, but sometime in the unspecified future)

setcell() is a no-op when the base codepoint falls outside the screen or
the active clip region, but render_text recorded last_x unconditionally.
A following combining mark was then appended to whatever cell sat at that
column, leaking it onto a cell the text never owned. setcell now reports
whether it wrote, and last_x is only set on a successful write.
print() now attaches zero-width codepoints (combining marks, ZWJ,
variation selectors) to the most recently written cell instead of
advancing the column, so grapheme tests can compare against a picture
of the screen rather than counting substrings in the raw bytes.
Truncating marks beyond a cell's 8 slots was silent. Surface the first truncation in each frame through the existing per-render error list, mirroring CLIP_DEPTH_EXCEEDED, so callers can detect lost marks without the renderer doing IO.
append_combining now raises COMBINING_MARKS_EXCEEDED the first time a frame drops a mark, using a per-frame flag reset alongside clip_depth_exceeded. Truncation behavior is unchanged.
@natemoo-re

Copy link
Copy Markdown
Member Author

@cowboyd updated print() to support zero-width codepoints so all the grapheme tests can render visually in 9454736. +1 on libghostty someday.

@cowboyd cowboyd left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice work!

@cowboyd cowboyd left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you're going to need a changeset however

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.

2 participants