Skip to content

Commit

Permalink
feat: style ranges
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
caarlos0 committed Jan 7, 2025
1 parent aa6f7a7 commit d1d34ec
Show file tree
Hide file tree
Showing 4 changed files with 147 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
52 changes: 52 additions & 0 deletions ranges.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
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.
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))
}
if l := len(stripped); rng.End >= l {
rng.End = l - 1
}
// Add the matched range with its highlight
buf.WriteString(rng.Style.Render(stripped[rng.Start : rng.End+1]))
lastIdx = rng.End + 1
}

// Add any remaining text after the last match
if lastIdx < ansi.StringWidth(s) {
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
}
92 changes: 92 additions & 0 deletions ranges_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
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, 10, NewStyle().Bold(true)),
},
expected: "hello \x1b[1mworld\x1b[0m",
},
{
name: "multiple ranges",
input: "hello world",
ranges: []Range{
NewRange(0, 4, NewStyle().Bold(true)),
NewRange(6, 10, 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, 4, NewStyle().Bold(true)),
},
expected: "\x1b[1mhello\x1b[0m \x1b[32mworld\x1b[0m",
},
{
name: "style at start",
input: "hello world",
ranges: []Range{
NewRange(0, 4, NewStyle().Bold(true)),
},
expected: "\x1b[1mhello\x1b[0m world",
},
{
name: "style at end",
input: "hello world",
ranges: []Range{
NewRange(6, 10, NewStyle().Bold(true)),
},
expected: "hello \x1b[1mworld\x1b[0m",
},
{
name: "multiple styles with gap",
input: "hello beautiful world",
ranges: []Range{
NewRange(0, 4, 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, 4, NewStyle().Bold(true)),
NewRange(6, 10, NewStyle().Italic(true)),
},
expected: "\x1b[1mhello\x1b[0m \x1b[3mworld\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() = %q, want %q", result, tt.expected)
}
})
}
}

0 comments on commit d1d34ec

Please sign in to comment.