From 202e7c48eb75e2b5823d5917c1358bf8d58264d4 Mon Sep 17 00:00:00 2001 From: yu2971512385-ui <287936273+yu2971512385-ui@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:17:57 +0800 Subject: [PATCH] fix(text): truncate by grapheme cluster, like DisplayWidth measures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Truncate asks DisplayWidth whether a string is too wide, then hands the cut to reflow's truncate, which measures rune by rune. The two disagree on anything built from more than one rune: lipgloss.Width reads an emoji ZWJ sequence as the two columns a terminal actually draws, while a rune-wise walk reads it as two columns per person in it. The result is that Truncate stops short of the budget it was given. A table cell holding "๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ family names in the author field" gives back six fewer columns than asked for, so that row's later columns sit six places right of every other row's. A lone regional indicator pair goes the other way and overshoots: Truncate(1, "๐Ÿ‡ฏ๐Ÿ‡ต") returned a half flag two columns wide. #159 moved DisplayWidth onto lipgloss for exactly this reason; this moves the matching cut onto the same width model. ansi.Truncate comes from charmbracelet/x/ansi, which is what lipgloss.Width already calls, so the two can no longer drift apart. It was already in the module graph as an indirect dependency. Every existing case in TestTruncate passes unchanged, including the Japanese, Chinese, Korean, accented and ANSI-coloured ones. Co-Authored-By: Claude Opus 5 (1M context) --- go.mod | 4 ++-- pkg/text/text.go | 9 ++++++-- pkg/text/text_test.go | 52 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 4 deletions(-) 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