Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 15 additions & 4 deletions internal/plugins/gitstatus/diff_renderer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
}
Expand Down
38 changes: 38 additions & 0 deletions internal/plugins/gitstatus/diff_renderer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package gitstatus
import (
"strings"
"testing"

"github.com/rivo/uniseg"
)

func TestRenderLineDiff_EmptyDiff(t *testing.T) {
Expand Down Expand Up @@ -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 {
Expand Down