Skip to content

fix(ui): emoji ZWJ sequences compose, and wrapping measures the font that draws - #94

Merged
tannevaled merged 2 commits into
mainfrom
feat/zwj-compose
Aug 8, 2026
Merged

fix(ui): emoji ZWJ sequences compose, and wrapping measures the font that draws#94
tannevaled merged 2 commits into
mainfrom
feat/zwj-compose

Conversation

@tannevaled

Copy link
Copy Markdown
Contributor

🧑‍🚀 drew as a person standing next to a rocket

The shaper was never the problem. go-opentype/shape composes the sequence correctly when handed it whole — I checked before changing anything: it returns exactly one glyph.

The damage happened before it was called. The toolkit's fallback routing split runs per rune, and the zero-width joiner is carried by several script faces — Thai, Arabic, Devanagari and Hebrew all ship U+200D for their own shaping. First-face-wins routing therefore handed the joiner to the Thai face, and the sequence reached the shaper as three runs: person, joiner, rocket. The GSUB ligature never saw them together.

Fixed in the owning library (go-widgets/toolkit#168, v0.127.0): a rune that continues the preceding grapheme stays in the current run whenever that run's face can render it. This PR bumps to it and asserts the result through the reader's own chain — 🧑‍🚀 and 👨‍👩‍👦 each measure exactly one glyph's advance.

Three wraps measured the wrong font

While verifying, three callers turned out to violate the rule wrapMeasured's own doc comment states: they wrapped with a textFace (the x/image measurer) but drew with a toolkit.Font. detailContent, previewContent and previewHeaderLines now wrap with the font that actually draws the line, as the feed card already did.

A line that "fits" one rasteriser can overflow the other and be clipped mid-word. Composed emoji widened the gap: a ZWJ sequence is two glyphs to the x/image measurer and one to the shaper that draws it.

wrapText is deleted rather than left as a trap — it defaulted to the textFace measurer, which is the wrong choice for every remaining caller, while wrapMeasured takes the measurer explicitly.

Verification

  • go test -race over the gated packages → 100.0% of statements, race-clean.
  • Proved on pixels: 🧑‍🚀 renders as a single helmeted face in a live @nasa card (it previously occupied two cells), and 👨‍👩‍👦, 👩‍💻, 👨‍🍳 compose too.

Second commit

style: gofmt ui/icons.go and ui/scene.go — both were already gofmt-unclean on main; a directory-wide gofmt while working next door picked them up. Split into its own commit so it does not hide inside a behaviour change. Alignment only, no code.

🤖 Generated with Claude Code

tannevaled and others added 2 commits August 8, 2026 21:41
… font

🧑‍🚀 drew as a person standing next to a rocket. The cause was not the shaper —
go-opentype/shape composes the sequence correctly when handed it whole — but the
toolkit's fallback routing, which split runs per rune. The zero-width joiner is
carried by several script faces (Thai, Arabic, Devanagari and Hebrew all ship
U+200D for their own shaping), so first-face-wins routing sent it to the Thai
face, and the sequence reached the shaper as three runs: person, joiner, rocket.
The GSUB ligature never saw them together.

Fixed in the owning library (go-widgets/toolkit v0.127.0): a rune that continues
the preceding grapheme stays in the current run whenever that run's face can
render it. This bumps to it, and asserts the result through the reader's own
font chain — 🧑‍🚀 and 👨‍👩‍👦 each measure exactly one glyph's advance.

While verifying, three callers turned out to violate the rule wrapMeasured's own
doc comment states: they wrapped with a textFace (the x/image measurer) but drew
with a toolkit.Font. detailContent, previewContent and previewHeaderLines now
wrap with the font that actually draws the line, as the feed card already did.
A line that "fits" one rasteriser can overflow the other and be clipped mid-word;
composed emoji widened the gap, since a ZWJ sequence is two glyphs to the
x/image measurer and one to the shaper that draws it.

wrapText is deleted rather than left as a trap: it defaulted to the textFace
measurer, which is exactly the wrong choice for every remaining caller, and
wrapMeasured takes the measurer explicitly.

100% coverage held; race-clean. Verified on pixels: the composed astronaut is a
single helmeted face in a live @nasa card, and 👩‍💻 / 👨‍🍳 / 👨‍👩‍👦 compose too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Both were already gofmt-unclean on main; a directory-wide gofmt while working
next door picked them up. Kept as its own commit so it does not hide inside a
behaviour change — the diff is alignment only, no code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@tannevaled
tannevaled merged commit 1810de8 into main Aug 8, 2026
11 checks passed
@tannevaled
tannevaled deleted the feat/zwj-compose branch August 8, 2026 19:46
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