diff --git a/go.mod b/go.mod index ba0dc2f6..826bf77b 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/alecthomas/chroma/v2 v2.27.0 github.com/charmbracelet/glamour v0.10.0 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 + github.com/charmbracelet/x/ansi v0.8.0 github.com/cli/browser v1.3.0 github.com/cli/safeexec v1.0.1 github.com/cli/shurcooL-graphql v0.0.4 @@ -17,7 +18,6 @@ require ( github.com/itchyny/gojq v0.12.19 github.com/leaanthony/go-ansi-parser v1.6.1 github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d - github.com/muesli/reflow v0.3.0 github.com/muesli/termenv v0.16.0 github.com/stretchr/testify v1.12.1 github.com/thlib/go-timezone-local v0.0.8 @@ -35,7 +35,6 @@ require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/x/ansi v0.8.0 // indirect github.com/charmbracelet/x/cellbuf v0.0.13 // indirect github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect github.com/charmbracelet/x/term v0.2.1 // indirect @@ -55,6 +54,7 @@ require ( github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/muesli/reflow v0.3.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/shopspring/decimal v1.4.0 // indirect github.com/spf13/cast v1.7.0 // indirect diff --git a/pkg/text/text.go b/pkg/text/text.go index 687517f9..53fd0067 100644 --- a/pkg/text/text.go +++ b/pkg/text/text.go @@ -9,7 +9,7 @@ import ( "unicode" "github.com/charmbracelet/lipgloss" - "github.com/muesli/reflow/truncate" + "github.com/charmbracelet/x/ansi" "golang.org/x/text/runes" "golang.org/x/text/transform" "golang.org/x/text/unicode/norm" @@ -46,7 +46,12 @@ func Truncate(maxWidth int, s string) string { if maxWidth >= minWidthForEllipsis { tail = ellipsis } - r := truncate.StringWithTail(s, uint(maxWidth), tail) + // ansi.Truncate measures in grapheme clusters, the same unit lipgloss.Width + // uses in DisplayWidth above. Measuring the cut with a different width model + // than the one used to decide whether a cut is needed makes the result miss + // the budget: a rune-wise truncator reads an emoji ZWJ sequence as one wide + // character per person in it, so it stops several columns early. + r := ansi.Truncate(s, maxWidth, tail) if DisplayWidth(r) < maxWidth { r += " " } diff --git a/pkg/text/text_test.go b/pkg/text/text_test.go index 37d02816..1606892f 100644 --- a/pkg/text/text_test.go +++ b/pkg/text/text_test.go @@ -163,6 +163,24 @@ func TestTruncate(t *testing.T) { }, want: "\x1b[0;31mé́́é́́é́́é́́é́́é́́é́́é́́...\x1b[0m", }, + { + // A ZWJ sequence occupies two columns however many people are in it, + // so eight of the eleven are still available for the text after it. + name: "Emoji ZWJ sequence", + args: args{ + max: 11, + s: "👨‍👩‍👧‍👦 family names here", + }, + want: "👨‍👩‍👧‍👦 famil...", + }, + { + name: "Regional indicator pair", + args: args{ + max: 11, + s: "🇯🇵 Japanese README", + }, + want: "🇯🇵 Japan...", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -172,6 +190,40 @@ func TestTruncate(t *testing.T) { } } +func TestTruncateFillsTheBudget(t *testing.T) { + // Truncate measures with DisplayWidth to decide whether a string is too + // wide, so the result has to be measured the same way: never wider than the + // budget, and never narrower than it once the string really was too long. + // Mixing two width models silently loses columns, which shows up as ragged + // table output for anyone whose text is not ASCII. + corpus := []string{ + "hello world, a plain ascii string", + "テストテストテストテスト", + "幫新舉報違章工廠新增編號", + "프로젝트 내의", + "💡💡💡💡💡💡💡💡💡💡💡💡", + "👨‍👩‍👧‍👦 family names here", + "🇯🇵 Japanese README", + "🏳️‍🌈 pride flag entry", + "👍🏽 thumbs up with a skin tone", + "❤️ reactions on issues", + "é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́", + "\x1b[0;31mcoloured text that is long\x1b[0m", + } + for _, s := range corpus { + for maxWidth := 1; maxWidth <= 24; maxWidth++ { + got := Truncate(maxWidth, s) + gotWidth := DisplayWidth(got) + assert.LessOrEqual(t, gotWidth, maxWidth, + "Truncate(%d, %q) = %q is wider than the budget", maxWidth, s, got) + if DisplayWidth(s) > maxWidth { + assert.Equal(t, maxWidth, gotWidth, + "Truncate(%d, %q) = %q leaves columns unused", maxWidth, s, got) + } + } + } +} + func TestPadRight(t *testing.T) { type args struct { max int