Skip to content

fix(text): truncate by grapheme cluster, like DisplayWidth measures - #314

Closed
yu2971512385-ui wants to merge 1 commit into
cli:trunkfrom
yu2971512385-ui:fix/truncate-grapheme-width
Closed

yu2971512385-ui wants to merge 1 commit into
cli:trunkfrom
yu2971512385-ui:fix/truncate-grapheme-width

Conversation

@yu2971512385-ui

Copy link
Copy Markdown

Truncate decides whether a string needs cutting with DisplayWidth
(lipgloss.Width, which measures grapheme clusters), then performs the cut with
reflow/truncate, which measures rune by rune. The two disagree about anything
assembled from more than one rune, so the result misses the budget it was given.

The symptom

A table at a 46-column terminal width, rendered with pkg/tableprinter:

# trunk
#1201  fix: handle 👨‍👩‍👧‍👦 family na...        open
#1202  docs: add 🇯🇵 Japanese translat...  open
#1203  chore: bump dependencies after...  open

# with this change
#1201  fix: handle 👨‍👩‍👧‍👦 family names in...  open
#1202  docs: add 🇯🇵 Japanese translat...  open
#1203  chore: bump dependencies after...  open

A terminal draws 👨‍👩‍👧‍👦 in two columns, and lipgloss.Width agrees.
A rune-wise walk sees four wide emoji joined by zero-width joiners and charges
eight. So Truncate hands back six fewer columns than it was asked for, and the
open column on that row lands six places right of where it does on every other
row.

The error runs the other way for regional indicator pairs, which are two runes
of width 1 each but one two-column glyph:

text.Truncate(1, "🇯🇵")  // "🇯" — half a flag, two columns wide, budget was 1

The change

ansi.Truncate comes from charmbracelet/x/ansi, which is what lipgloss.Width
already calls to produce the number DisplayWidth returns, so the measurement
and the cut can no longer drift apart. It was already in the module graph as an
indirect dependency, and it handles the ANSI-escape preservation reflow was
doing (reflow stays in the graph via glamour).

This finishes what #159 started: that PR moved DisplayWidth onto lipgloss to
fix cli/cli#9018, but left the cut itself on the old width model.

Tests

Every existing case in TestTruncate passes unchanged — including the Japanese,
Chinese, Korean, accented-character and ANSI-coloured ones — plus two new cases
for a ZWJ sequence and a regional indicator pair.

TestTruncateFillsTheBudget is the invariant underneath all of them: across a
corpus of ASCII, CJK, emoji, combining-mark and coloured strings at every width
from 1 to 24, DisplayWidth(Truncate(n, s)) is never above n, and is exactly
n whenever the input was genuinely too wide. It records 97 assertion failures
on trunk and none after the change.

🤖 Generated with Claude Code

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.

cli#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) <noreply@anthropic.com>
@yu2971512385-ui
yu2971512385-ui requested a review from a team as a code owner October 10, 2026 16:18
@yu2971512385-ui
yu2971512385-ui requested review from sergiou87 and removed request for a team October 10, 2026 16:18
@BagToad BagToad closed this Oct 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Repository list table is misaligned when rendering emojis

2 participants