From be0a1baeabb761561e4f29ab9c0ed67568b9163d Mon Sep 17 00:00:00 2001 From: yu2971512385-ui Date: Sun, 8 Nov 2026 11:25:49 +0800 Subject: [PATCH] gitstatus: clip diff lines by glyph, not by rune 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 --- internal/plugins/gitstatus/diff_renderer.go | 19 ++++++++-- .../plugins/gitstatus/diff_renderer_test.go | 38 +++++++++++++++++++ 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/internal/plugins/gitstatus/diff_renderer.go b/internal/plugins/gitstatus/diff_renderer.go index 806dc1e6f..4481b1dc7 100644 --- a/internal/plugins/gitstatus/diff_renderer.go +++ b/internal/plugins/gitstatus/diff_renderer.go @@ -7,7 +7,7 @@ import ( "charm.land/lipgloss/v2" "github.com/charmbracelet/x/ansi" "github.com/marcus/sidecar/internal/styles" - "github.com/mattn/go-runewidth" + "github.com/rivo/uniseg" ) // DiffViewMode specifies the diff rendering mode. @@ -903,12 +903,23 @@ func clipToWidth(s string, maxWidth int) (string, bool) { if strings.IndexByte(s, 0x1b) >= 0 { return s, lipgloss.Width(s) > maxWidth } + // Step a grapheme cluster at a time, measured the way lipgloss.Width above + // and padRight below measure. A cluster is what a terminal draws as one + // character and may be several runes: a flag, an emoji carrying a skin + // tone, a family joined by zero-width joiners. Added up rune by rune a + // family counts as eight columns where it occupies two, so the clip and + // the pad disagree about the same line, and a cut between its runes + // leaves a dangling joiner. width := 0 - for i, r := range s { + rest := s + state := -1 + for len(rest) > 0 { if width > maxWidth { - return s[:i], true + return s[:len(s)-len(rest)], true } - width += runewidth.RuneWidth(r) + var cluster string + cluster, rest, _, state = uniseg.FirstGraphemeClusterInString(rest, state) + width += ansi.StringWidth(cluster) } return s, width > maxWidth } diff --git a/internal/plugins/gitstatus/diff_renderer_test.go b/internal/plugins/gitstatus/diff_renderer_test.go index 0e0fc0e51..d2e34b94d 100644 --- a/internal/plugins/gitstatus/diff_renderer_test.go +++ b/internal/plugins/gitstatus/diff_renderer_test.go @@ -3,6 +3,8 @@ package gitstatus import ( "strings" "testing" + + "github.com/rivo/uniseg" ) func TestRenderLineDiff_EmptyDiff(t *testing.T) { @@ -209,6 +211,42 @@ func TestPadRight(t *testing.T) { } } +// A terminal draws each of these as one character, out of several runes. +var multiRuneGlyphs = map[string]string{ + "family": "\U0001F468\u200d\U0001F469\u200d\U0001F467\u200d\U0001F466", + "rainbow": "\U0001F3F3\ufe0f\u200d\U0001F308", + "skin tone": "\U0001F44D\U0001F3FD", + "flag": "\U0001F1EF\U0001F1F5", +} + +// clipToWidth cuts a line to fit a column. Cutting between the runes of one +// glyph leaves a dangling zero-width joiner or a lone regional indicator at +// the end of the line. +func TestClipToWidth_KeepsGlyphsWhole(t *testing.T) { + for name, glyph := range multiRuneGlyphs { + t.Run(name, func(t *testing.T) { + line := "+ " + strings.Repeat(glyph, 8) + " end" + + boundaries := map[int]bool{0: true} + for g := uniseg.NewGraphemes(line); g.Next(); { + _, to := g.Positions() + boundaries[to] = true + } + + for width := 1; width <= 20; width++ { + got, _ := clipToWidth(line, width) + if !strings.HasPrefix(line, got) { + t.Fatalf("clipToWidth(line, %d) is not a prefix of the line", width) + } + if !boundaries[len(got)] { + t.Errorf("clipToWidth(line, %d) cut at byte %d, inside a glyph: %q", + width, len(got), got) + } + } + }) + } +} + func TestDiffViewMode_Constants(t *testing.T) { // Verify the constants exist and are distinct if DiffViewUnified == DiffViewSideBySide {