From 4e196d82d86f34e0608ed9555d0b059d3bee07a7 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Wed, 2 Sep 2026 09:44:11 +0200 Subject: [PATCH] fix(whitespace): prevent wide runes from overflowing render width whitespace.render checked the loop bound only before writing a rune, then advanced by the rune width. A rune wider than the remaining space was still written, overshooting the requested width by up to runeWidth-1 cells, and this made the trailing space-fill unreachable. Stop before a wide rune would overflow and let the existing space-fill pad the remaining cells, so the result is always exactly the requested width. This also fixes overflow surfaced through Place/PlaceHorizontal with WithWhitespaceChars for multi-cell whitespace characters. --- whitespace.go | 15 ++++++++++----- whitespace_test.go | 24 ++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/whitespace.go b/whitespace.go index e353a07..312820d 100644 --- a/whitespace.go +++ b/whitespace.go @@ -33,14 +33,19 @@ func (w whitespace) render(width int) string { // Cycle through runes and print them into the whitespace. for i := 0; i < width; { - b.WriteRune(r[j]) - // Measure the width of the rune we just wrote, ensuring we always - // make progress to avoid infinite loops with zero-width characters - // like tabs. + // Measure the width of the current rune, ensuring we always make + // progress to avoid infinite loops with zero-width characters like + // tabs. runeWidth := ansi.StringWidth(string(r[j])) if runeWidth < 1 { runeWidth = 1 } + // Stop before a wide rune would overflow the requested width; the + // remaining cells are filled with spaces below. + if i+runeWidth > width { + break + } + b.WriteRune(r[j]) i += runeWidth j++ if j >= len(r) { @@ -48,7 +53,7 @@ func (w whitespace) render(width int) string { } } - // Fill any extra gaps white spaces. This might be necessary if any runes + // Fill any extra gaps with spaces. This might be necessary if any runes // are more than one cell wide, which could leave a one-rune gap. short := width - ansi.StringWidth(b.String()) if short > 0 { diff --git a/whitespace_test.go b/whitespace_test.go index fdc1d09..e834256 100644 --- a/whitespace_test.go +++ b/whitespace_test.go @@ -3,6 +3,8 @@ package lipgloss import ( "testing" "time" + + "github.com/charmbracelet/x/ansi" ) func TestWhitespaceRenderWithTab(t *testing.T) { @@ -50,3 +52,25 @@ func TestWhitespaceRenderNormal(t *testing.T) { t.Errorf("expected 5 characters, got %d", len(result)) } } + +func TestWhitespaceRenderWideRune(t *testing.T) { + // A whitespace char that is more than one cell wide must never render + // wider than the requested width; the trailing cells are padded with + // spaces instead of overflowing. + ws := newWhitespace(WithWhitespaceChars("東")) // width 2 + for _, width := range []int{0, 1, 2, 3, 4, 5} { + got := ansi.StringWidth(ws.render(width)) + if got != width { + t.Errorf("render(%d): expected rendered width %d, got %d", width, width, got) + } + } +} + +func TestPlaceHorizontalWideWhitespace(t *testing.T) { + // Placing content in a box with a wide whitespace char must keep the + // box exactly the requested width. + got := PlaceHorizontal(5, Left, "ab", WithWhitespaceChars("東")) + if w := ansi.StringWidth(got); w != 5 { + t.Errorf("PlaceHorizontal width: expected 5, got %d (%q)", w, got) + } +}