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 3 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
62 changes: 62 additions & 0 deletions ranges.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package lipgloss

import (
"strings"

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

// StyleRanges allows to, given a string, style a range of it differently.
// The function will take into account existing styles.
// See [StyleRanges] to style multipe ranges in the same string.

Check failure on line 11 in ranges.go

View workflow job for this annotation

GitHub Actions / lint / lint-soft (ubuntu-latest)

`multipe` is a misspelling of `multiple` (misspell)

Check failure on line 11 in ranges.go

View workflow job for this annotation

GitHub Actions / lint / lint-soft (ubuntu-latest)

`multipe` is a misspelling of `multiple` (misspell)

Check failure on line 11 in ranges.go

View workflow job for this annotation

GitHub Actions / lint / lint-soft (macos-latest)

`multipe` is a misspelling of `multiple` (misspell)

Check failure on line 11 in ranges.go

View workflow job for this annotation

GitHub Actions / lint / lint-soft (macos-latest)

`multipe` is a misspelling of `multiple` (misspell)

Check failure on line 11 in ranges.go

View workflow job for this annotation

GitHub Actions / lint / lint-soft (windows-latest)

`multipe` is a misspelling of `multiple` (misspell)

Check failure on line 11 in ranges.go

View workflow job for this annotation

GitHub Actions / lint / lint-soft (windows-latest)

`multipe` is a misspelling of `multiple` (misspell)
func StyleRange(s string, start, end int, style Style) string {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead, maybe we could do StyleRanges(s, NewRange(start, end, style)) where func StyleRanges(s string, ranges ...Range) string 🤔

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah sounds good to me as well :)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, great call @aymanbagabas. Part of me thinks it should be singular, though, just so users don't think it must take multiple values 🤔. But maybe I'm overthinking it.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fwiw I'm more inclined into using plural, as it indicates one or more, whereas singular indicates only one...

if end == 0 {
return s
}
return StyleRanges(s, []Range{{
start,
end,
style,
}})
}

// 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
}
176 changes: 176 additions & 0 deletions ranges_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
package lipgloss

import (
"testing"

"github.com/muesli/termenv"
)

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

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

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