Skip to content
Closed
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
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
9 changes: 7 additions & 2 deletions pkg/text/text.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 += " "
}
Expand Down
52 changes: 52 additions & 0 deletions pkg/text/text_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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
Expand Down