Skip to content

Commit 9942166

Browse files
authored
feat: style ranges (#458)
* feat: style ranges Extracted from charmbracelet/gum#789 , this allows to style ranges of a given string without breaking its current styles. The resulting ansi sequences aren't that beautiful (as there might be many styles+reset with nothing in them), but it works. We can optimize this later I think. * fix: wide characters * feat: helper to style a single range * chore: review
1 parent aa6f7a7 commit 9942166

File tree

4 files changed

+153
-3
lines changed

4 files changed

+153
-3
lines changed

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ go 1.18
88

99
require (
1010
github.com/aymanbagabas/go-udiff v0.2.0
11-
github.com/charmbracelet/x/ansi v0.6.0
11+
github.com/charmbracelet/x/ansi v0.6.1-0.20250107110353-48b574af22a5
1212
github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a
1313
github.com/muesli/termenv v0.15.2
1414
github.com/rivo/uniseg v0.4.7

go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiE
22
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
33
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
44
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
5-
github.com/charmbracelet/x/ansi v0.6.0 h1:qOznutrb93gx9oMiGf7caF7bqqubh6YIM0SWKyA08pA=
6-
github.com/charmbracelet/x/ansi v0.6.0/go.mod h1:KBUFw1la39nl0dLl10l5ORDAqGXaeurTQmwyyVKse/Q=
5+
github.com/charmbracelet/x/ansi v0.6.1-0.20250107110353-48b574af22a5 h1:TSjbA80sXnABV/Vxhnb67Ho7p8bEYqz6NIdhLAx+1yg=
6+
github.com/charmbracelet/x/ansi v0.6.1-0.20250107110353-48b574af22a5/go.mod h1:KBUFw1la39nl0dLl10l5ORDAqGXaeurTQmwyyVKse/Q=
77
github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30=
88
github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
99
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=

ranges.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package lipgloss
2+
3+
import (
4+
"strings"
5+
6+
"github.com/charmbracelet/x/ansi"
7+
)
8+
9+
// StyleRanges allows to, given a string, style ranges of it differently.
10+
// The function will take into account existing styles.
11+
// Ranges should not overlap.
12+
func StyleRanges(s string, ranges ...Range) string {
13+
if len(ranges) == 0 {
14+
return s
15+
}
16+
17+
var buf strings.Builder
18+
lastIdx := 0
19+
stripped := ansi.Strip(s)
20+
21+
// Use Truncate and TruncateLeft to style match.MatchedIndexes without
22+
// losing the original option style:
23+
for _, rng := range ranges {
24+
// Add the text before this match
25+
if rng.Start > lastIdx {
26+
buf.WriteString(ansi.Cut(s, lastIdx, rng.Start))
27+
}
28+
// Add the matched range with its highlight
29+
buf.WriteString(rng.Style.Render(ansi.Cut(stripped, rng.Start, rng.End)))
30+
lastIdx = rng.End
31+
}
32+
33+
// Add any remaining text after the last match
34+
buf.WriteString(ansi.TruncateLeft(s, lastIdx, ""))
35+
36+
return buf.String()
37+
}
38+
39+
// NewRange returns a range that can be used with [StyleRanges].
40+
func NewRange(start, end int, style Style) Range {
41+
return Range{start, end, style}
42+
}
43+
44+
// Range to be used with [StyleRanges].
45+
type Range struct {
46+
Start, End int
47+
Style Style
48+
}

ranges_test.go

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
package lipgloss
2+
3+
import (
4+
"testing"
5+
6+
"github.com/muesli/termenv"
7+
)
8+
9+
func TestStyleRanges(t *testing.T) {
10+
tests := []struct {
11+
name string
12+
input string
13+
ranges []Range
14+
expected string
15+
}{
16+
{
17+
name: "empty ranges",
18+
input: "hello world",
19+
ranges: []Range{},
20+
expected: "hello world",
21+
},
22+
{
23+
name: "single range in middle",
24+
input: "hello world",
25+
ranges: []Range{
26+
NewRange(6, 11, NewStyle().Bold(true)),
27+
},
28+
expected: "hello \x1b[1mworld\x1b[0m",
29+
},
30+
{
31+
name: "multiple ranges",
32+
input: "hello world",
33+
ranges: []Range{
34+
NewRange(0, 5, NewStyle().Bold(true)),
35+
NewRange(6, 11, NewStyle().Italic(true)),
36+
},
37+
expected: "\x1b[1mhello\x1b[0m \x1b[3mworld\x1b[0m",
38+
},
39+
{
40+
name: "overlapping with existing ANSI",
41+
input: "hello \x1b[32mworld\x1b[0m",
42+
ranges: []Range{
43+
NewRange(0, 5, NewStyle().Bold(true)),
44+
},
45+
expected: "\x1b[1mhello\x1b[0m \x1b[32mworld\x1b[0m",
46+
},
47+
{
48+
name: "style at start",
49+
input: "hello world",
50+
ranges: []Range{
51+
NewRange(0, 5, NewStyle().Bold(true)),
52+
},
53+
expected: "\x1b[1mhello\x1b[0m world",
54+
},
55+
{
56+
name: "style at end",
57+
input: "hello world",
58+
ranges: []Range{
59+
NewRange(6, 11, NewStyle().Bold(true)),
60+
},
61+
expected: "hello \x1b[1mworld\x1b[0m",
62+
},
63+
{
64+
name: "multiple styles with gap",
65+
input: "hello beautiful world",
66+
ranges: []Range{
67+
NewRange(0, 5, NewStyle().Bold(true)),
68+
NewRange(16, 23, NewStyle().Italic(true)),
69+
},
70+
expected: "\x1b[1mhello\x1b[0m beautiful \x1b[3mworld\x1b[0m",
71+
},
72+
{
73+
name: "adjacent ranges",
74+
input: "hello world",
75+
ranges: []Range{
76+
NewRange(0, 5, NewStyle().Bold(true)),
77+
NewRange(6, 11, NewStyle().Italic(true)),
78+
},
79+
expected: "\x1b[1mhello\x1b[0m \x1b[3mworld\x1b[0m",
80+
},
81+
{
82+
name: "wide-width characters",
83+
input: "Hello 你好 世界",
84+
ranges: []Range{
85+
NewRange(0, 5, NewStyle().Bold(true)), // "Hello"
86+
NewRange(7, 10, NewStyle().Italic(true)), // "你好"
87+
NewRange(11, 50, NewStyle().Bold(true)), // "世界"
88+
},
89+
expected: "\x1b[1mHello\x1b[0m \x1b[3m你好\x1b[0m \x1b[1m世界\x1b[0m",
90+
},
91+
}
92+
93+
for _, tt := range tests {
94+
renderer.SetColorProfile(termenv.ANSI)
95+
t.Run(tt.name, func(t *testing.T) {
96+
result := StyleRanges(tt.input, tt.ranges...)
97+
if result != tt.expected {
98+
t.Errorf("StyleRanges()\n got = %q\nwant = %q\n", result, tt.expected)
99+
}
100+
})
101+
}
102+
}

0 commit comments

Comments
 (0)