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 {