Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: style ranges #458

Merged
merged 4 commits into from
Jan 9, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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)
}
})
}
}
Loading