Skip to content

Commit

Permalink
feat: style ranges (#458)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
caarlos0 authored Jan 9, 2025
1 parent aa6f7a7 commit 9942166
Show file tree
Hide file tree
Showing 4 changed files with 153 additions and 3 deletions.
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ go 1.18

require (
github.com/aymanbagabas/go-udiff v0.2.0
github.com/charmbracelet/x/ansi v0.6.0
github.com/charmbracelet/x/ansi v0.6.1-0.20250107110353-48b574af22a5
github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a
github.com/muesli/termenv v0.15.2
github.com/rivo/uniseg v0.4.7
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiE
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
github.com/charmbracelet/x/ansi v0.6.0 h1:qOznutrb93gx9oMiGf7caF7bqqubh6YIM0SWKyA08pA=
github.com/charmbracelet/x/ansi v0.6.0/go.mod h1:KBUFw1la39nl0dLl10l5ORDAqGXaeurTQmwyyVKse/Q=
github.com/charmbracelet/x/ansi v0.6.1-0.20250107110353-48b574af22a5 h1:TSjbA80sXnABV/Vxhnb67Ho7p8bEYqz6NIdhLAx+1yg=
github.com/charmbracelet/x/ansi v0.6.1-0.20250107110353-48b574af22a5/go.mod h1:KBUFw1la39nl0dLl10l5ORDAqGXaeurTQmwyyVKse/Q=
github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30=
github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
Expand Down
48 changes: 48 additions & 0 deletions ranges.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package lipgloss

import (
"strings"

"github.com/charmbracelet/x/ansi"
)

// StyleRanges allows to, given a string, style ranges of it differently.
// The function will take into account existing styles.
// Ranges should not overlap.
func StyleRanges(s string, ranges ...Range) string {
if len(ranges) == 0 {
return s
}

var buf strings.Builder
lastIdx := 0
stripped := ansi.Strip(s)

// Use Truncate and TruncateLeft to style match.MatchedIndexes without
// losing the original option style:
for _, rng := range ranges {
// Add the text before this match
if rng.Start > lastIdx {
buf.WriteString(ansi.Cut(s, lastIdx, rng.Start))
}
// Add the matched range with its highlight
buf.WriteString(rng.Style.Render(ansi.Cut(stripped, rng.Start, rng.End)))
lastIdx = rng.End
}

// Add any remaining text after the last match
buf.WriteString(ansi.TruncateLeft(s, lastIdx, ""))

return buf.String()
}

// NewRange returns a range that can be used with [StyleRanges].
func NewRange(start, end int, style Style) Range {
return Range{start, end, style}
}

// Range to be used with [StyleRanges].
type Range struct {
Start, End int
Style Style
}
102 changes: 102 additions & 0 deletions ranges_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package lipgloss

import (
"testing"

"github.com/muesli/termenv"
)

func TestStyleRanges(t *testing.T) {
tests := []struct {
name string
input string
ranges []Range
expected string
}{
{
name: "empty ranges",
input: "hello world",
ranges: []Range{},
expected: "hello world",
},
{
name: "single range in middle",
input: "hello world",
ranges: []Range{
NewRange(6, 11, NewStyle().Bold(true)),
},
expected: "hello \x1b[1mworld\x1b[0m",
},
{
name: "multiple ranges",
input: "hello world",
ranges: []Range{
NewRange(0, 5, NewStyle().Bold(true)),
NewRange(6, 11, NewStyle().Italic(true)),
},
expected: "\x1b[1mhello\x1b[0m \x1b[3mworld\x1b[0m",
},
{
name: "overlapping with existing ANSI",
input: "hello \x1b[32mworld\x1b[0m",
ranges: []Range{
NewRange(0, 5, NewStyle().Bold(true)),
},
expected: "\x1b[1mhello\x1b[0m \x1b[32mworld\x1b[0m",
},
{
name: "style at start",
input: "hello world",
ranges: []Range{
NewRange(0, 5, NewStyle().Bold(true)),
},
expected: "\x1b[1mhello\x1b[0m world",
},
{
name: "style at end",
input: "hello world",
ranges: []Range{
NewRange(6, 11, NewStyle().Bold(true)),
},
expected: "hello \x1b[1mworld\x1b[0m",
},
{
name: "multiple styles with gap",
input: "hello beautiful world",
ranges: []Range{
NewRange(0, 5, NewStyle().Bold(true)),
NewRange(16, 23, NewStyle().Italic(true)),
},
expected: "\x1b[1mhello\x1b[0m beautiful \x1b[3mworld\x1b[0m",
},
{
name: "adjacent ranges",
input: "hello world",
ranges: []Range{
NewRange(0, 5, NewStyle().Bold(true)),
NewRange(6, 11, NewStyle().Italic(true)),
},
expected: "\x1b[1mhello\x1b[0m \x1b[3mworld\x1b[0m",
},
{
name: "wide-width characters",
input: "Hello 你好 世界",
ranges: []Range{
NewRange(0, 5, NewStyle().Bold(true)), // "Hello"
NewRange(7, 10, NewStyle().Italic(true)), // "你好"
NewRange(11, 50, NewStyle().Bold(true)), // "世界"
},
expected: "\x1b[1mHello\x1b[0m \x1b[3m你好\x1b[0m \x1b[1m世界\x1b[0m",
},
}

for _, tt := range tests {
renderer.SetColorProfile(termenv.ANSI)
t.Run(tt.name, func(t *testing.T) {
result := StyleRanges(tt.input, tt.ranges...)
if result != tt.expected {
t.Errorf("StyleRanges()\n got = %q\nwant = %q\n", result, tt.expected)
}
})
}
}

0 comments on commit 9942166

Please sign in to comment.