Skip to content

gitstatus: clip diff lines by glyph, not by rune - #330

Open
yu2971512385-ui wants to merge 1 commit into
marcus:mainfrom
yu2971512385-ui:fix/clip-diff-lines-by-glyph
Open

yu2971512385-ui wants to merge 1 commit into
marcus:mainfrom
yu2971512385-ui:fix/clip-diff-lines-by-glyph

Conversation

@yu2971512385-ui

Copy link
Copy Markdown

clipToWidth measures the same line two ways. Its escape-sequence fallback asks lipgloss.Width, and padRight right below it does too, but the main path adds up runewidth.RuneWidth rune by rune. The answers differ for anything a terminal draws as one glyph out of several runes:

glyph runes lipgloss.Width sum of RuneWidth
👨‍👩‍👧‍👦 family 7 2 8
👍🏽 thumb with a skin tone 2 2 4
🏳️‍🌈 rainbow flag 4 2 3

So a diff line holding one is clipped against one measure and padded against the other, and because the cut lands on a rune boundary rather than a glyph boundary it can leave a dangling zero-width joiner at the end of the line.

Over "+ " + glyph×8 + " end" at every width from 1 to 20:

                     cuts that land inside a glyph
                     shipped   this change
family                 15/20           0/20
skin tone              10/20           0/20
flag                    8/20           0/20
rainbow flag            7/20           0/20

The change

Step a grapheme cluster at a time with uniseg, already a direct dependency, and measure each with ansi.StringWidth — which this file already imports, and which is what lipgloss.Width calls underneath, so the clip and the pad now agree.

The early exit on the width budget is kept, so a huge line is still not walked to the end.

Checks

ASCII is unaffected. Over "", "a", "hello world", "x"×200 and "+ added a line of code" at every width from 0 to 30, both versions return byte-identical results.

TestClipToWidth_KeepsGlyphsWhole is added to diff_renderer_test.go: the result stays a prefix of the line, and the cut lands on a grapheme boundary.

What I ran, and what I did not

The sandbox I work in would not run go test ./... here. I put both versions of clipToWidth into a scratch module pinned to the go-runewidth v0.0.23, charm.land/lipgloss/v2 v2.0.3 and rivo/uniseg v0.4.7 this repo locks, and ran both over the cases above; the tables are that program's output. CI is the real check.

One thing worth knowing: lipgloss.Width and runewidth.StringWidth do not always agree — under these pins runewidth calls a flag one column where lipgloss v2 calls it two. That is why the width here comes from ansi.StringWidth rather than runewidth.StringWidth: it keeps one measure across the clip, the fallback and the pad.

Disclosure: written with AI assistance (Claude). The figures above are real output.

🤖 Generated with Claude Code

clipToWidth measures the same line two ways. Its escape-sequence
fallback asks lipgloss.Width, and padRight just below it does too, but
the main path adds up runewidth.RuneWidth rune by rune. The answers
differ for anything a terminal draws as one glyph out of several runes:
a family emoji is two columns and counts as eight.

So a diff line holding one is clipped against one measure and padded
against another, and because the cut lands on a rune boundary rather
than a glyph boundary it can leave a dangling zero-width joiner at the
end of the line.

Step a grapheme cluster at a time with uniseg, already a direct
dependency, and measure each with ansi.StringWidth, which this file
already imports and which is what lipgloss.Width calls underneath. The
early exit on the width budget is kept. ASCII output is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
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