fix(ui): emoji ZWJ sequences compose, and wrapping measures the font that draws - #94
Merged
Conversation
… 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>
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.
🧑🚀 drew as a person standing next to a rocket
The shaper was never the problem.
go-opentype/shapecomposes 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+200Dfor 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 atextFace(the x/image measurer) but drew with atoolkit.Font.detailContent,previewContentandpreviewHeaderLinesnow 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.
wrapTextis deleted rather than left as a trap — it defaulted to thetextFacemeasurer, which is the wrong choice for every remaining caller, whilewrapMeasuredtakes the measurer explicitly.Verification
go test -raceover the gated packages → 100.0% of statements, race-clean.Second commit
style: gofmt ui/icons.go and ui/scene.go— both were already gofmt-unclean on main; a directory-widegofmtwhile 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