Skip to content

Commit 0198fc3

Browse files
committed
Merge branch 'master' into v2-exp
2 parents 95f30db + 1e3ee34 commit 0198fc3

8 files changed

Lines changed: 129 additions & 15 deletions

File tree

.github/dependabot.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ updates:
1717
all:
1818
patterns:
1919
- "*"
20+
ignore:
21+
- dependency-name: github.com/charmbracelet/bubbletea/v2
22+
versions:
23+
- v2.0.0-beta1
2024

2125
- package-ecosystem: "github-actions"
2226
directory: "/"

.github/workflows/coverage.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ jobs:
1717
go-version: ${{ matrix.go-version }}
1818

1919
- name: Checkout code
20-
uses: actions/checkout@v5
20+
uses: actions/checkout@v6
2121

2222
- run: |
2323
git config --global url."https://${{ secrets.PERSONAL_ACCESS_TOKEN }}@github.com/charmbracelet".insteadOf "https://github.com/charmbracelet"

.golangci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ linters:
3333
generated: lax
3434
presets:
3535
- common-false-positives
36+
settings:
37+
exhaustive:
38+
default-signifies-exhaustive: true
3639
issues:
3740
max-issues-per-linter: 0
3841
max-same-issues: 0

borders.go

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"unicode/utf8"
88

99
"github.com/charmbracelet/x/ansi"
10+
"github.com/clipperhouse/displaywidth"
1011
"github.com/rivo/uniseg"
1112
)
1213

@@ -58,10 +59,7 @@ func (b Border) GetLeftSize() int {
5859

5960
func getBorderEdgeWidth(borderParts ...string) (maxWidth int) {
6061
for _, piece := range borderParts {
61-
w := maxRuneWidth(piece)
62-
if w > maxWidth {
63-
maxWidth = w
64-
}
62+
maxWidth = max(maxWidth, maxRuneWidth(piece))
6563
}
6664
return maxWidth
6765
}
@@ -564,17 +562,19 @@ func (s Style) styleBorderBlend(border string, fg []color.Color, bg color.Color)
564562
}
565563

566564
func maxRuneWidth(str string) int {
565+
switch len(str) {
566+
case 0:
567+
return 0
568+
case 1:
569+
return displaywidth.String(str)
570+
}
571+
567572
var width int
568573

569-
state := -1
570-
for len(str) > 0 {
571-
var w int
572-
_, str, w, state = uniseg.FirstGraphemeClusterInString(str, state)
573-
if w > width {
574-
width = w
575-
}
574+
g := displaywidth.StringGraphemes(str)
575+
for g.Next() {
576+
width = max(width, g.Width())
576577
}
577-
578578
return width
579579
}
580580

borders_test.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package lipgloss
22

33
import (
44
"testing"
5+
6+
"github.com/rivo/uniseg"
57
)
68

79
func BenchmarkBorderRendering(b *testing.B) {
@@ -174,3 +176,49 @@ func BenchmarkGetFirstRuneAsString(b *testing.B) {
174176
}
175177
})
176178
}
179+
180+
func BenchmarkMaxRuneWidth(b *testing.B) {
181+
testCases := []struct {
182+
name string
183+
str string
184+
}{
185+
{"Blank", " "},
186+
{"ASCII", "+"},
187+
{"Markdown", "|"},
188+
{"Normal", "├"},
189+
{"Rounded", "╭"},
190+
{"Block", "█"},
191+
{"Emoji", "😀"},
192+
}
193+
for _, tc := range testCases {
194+
b.Run(tc.name, func(b *testing.B) {
195+
b.Run("Before", func(b *testing.B) {
196+
b.ReportAllocs()
197+
for b.Loop() {
198+
_ = maxRuneWidthOld(tc.str)
199+
}
200+
})
201+
b.Run("After", func(b *testing.B) {
202+
b.ReportAllocs()
203+
for b.Loop() {
204+
_ = maxRuneWidth(tc.str)
205+
}
206+
})
207+
})
208+
}
209+
}
210+
211+
func maxRuneWidthOld(str string) int {
212+
var width int
213+
214+
state := -1
215+
for len(str) > 0 {
216+
var w int
217+
_, str, w, state = uniseg.FirstGraphemeClusterInString(str, state)
218+
if w > width {
219+
width = w
220+
}
221+
}
222+
223+
return width
224+
}

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ require (
1313
github.com/charmbracelet/x/ansi v0.11.6
1414
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f
1515
github.com/charmbracelet/x/term v0.2.2
16+
github.com/clipperhouse/displaywidth v0.9.0
1617
github.com/lucasb-eyer/go-colorful v1.3.0
1718
github.com/rivo/uniseg v0.4.7
1819
golang.org/x/sys v0.41.0
@@ -21,7 +22,6 @@ require (
2122
require (
2223
github.com/charmbracelet/x/termios v0.1.1 // indirect
2324
github.com/charmbracelet/x/windows v0.2.2 // indirect
24-
github.com/clipperhouse/displaywidth v0.9.0 // indirect
2525
github.com/clipperhouse/stringish v0.1.1 // indirect
2626
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
2727
github.com/mattn/go-runewidth v0.0.19 // indirect

whitespace.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,18 @@ func (w whitespace) render(width int) string {
3434
// Cycle through runes and print them into the whitespace.
3535
for i := 0; i < width; {
3636
b.WriteRune(r[j])
37+
// Measure the width of the rune we just wrote, ensuring we always
38+
// make progress to avoid infinite loops with zero-width characters
39+
// like tabs.
40+
runeWidth := ansi.StringWidth(string(r[j]))
41+
if runeWidth < 1 {
42+
runeWidth = 1
43+
}
44+
i += runeWidth
3745
j++
3846
if j >= len(r) {
3947
j = 0
4048
}
41-
i += ansi.StringWidth(string(r[j]))
4249
}
4350

4451
// Fill any extra gaps white spaces. This might be necessary if any runes

whitespace_test.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package lipgloss
2+
3+
import (
4+
"testing"
5+
"time"
6+
)
7+
8+
func TestWhitespaceRenderWithTab(t *testing.T) {
9+
// This test verifies that rendering whitespace with tab characters
10+
// doesn't cause an infinite loop (issue #108)
11+
done := make(chan bool, 1)
12+
13+
go func() {
14+
ws := newWhitespace(WithWhitespaceChars("\t"))
15+
_ = ws.render(10)
16+
done <- true
17+
}()
18+
19+
select {
20+
case <-done:
21+
// Success - render completed
22+
case <-time.After(2 * time.Second):
23+
t.Fatal("whitespace.render() with tab character caused infinite loop")
24+
}
25+
}
26+
27+
func TestWhitespaceRenderWithZeroWidthChar(t *testing.T) {
28+
// Test with zero-width joiner (another zero-width character)
29+
done := make(chan bool, 1)
30+
31+
go func() {
32+
ws := newWhitespace(WithWhitespaceChars("\u200d")) // zero-width joiner
33+
_ = ws.render(5)
34+
done <- true
35+
}()
36+
37+
select {
38+
case <-done:
39+
// Success
40+
case <-time.After(2 * time.Second):
41+
t.Fatal("whitespace.render() with zero-width character caused infinite loop")
42+
}
43+
}
44+
45+
func TestWhitespaceRenderNormal(t *testing.T) {
46+
// Verify normal behavior still works
47+
ws := newWhitespace(WithWhitespaceChars("*"))
48+
result := ws.render(5)
49+
if len(result) != 5 {
50+
t.Errorf("expected 5 characters, got %d", len(result))
51+
}
52+
}

0 commit comments

Comments
 (0)