From d1d34ecc683d6777291a75bd67a2bc6cb18fac3b Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Tue, 7 Jan 2025 08:46:30 -0300 Subject: [PATCH 1/4] feat: style ranges Extracted from https://github.com/charmbracelet/gum/pull/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. --- go.mod | 2 +- go.sum | 4 +-- ranges.go | 52 ++++++++++++++++++++++++++++ ranges_test.go | 92 ++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 147 insertions(+), 3 deletions(-) create mode 100644 ranges.go create mode 100644 ranges_test.go diff --git a/go.mod b/go.mod index 2bd57cc6..f241566b 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index e894e755..548286da 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/ranges.go b/ranges.go new file mode 100644 index 00000000..73918676 --- /dev/null +++ b/ranges.go @@ -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 +} diff --git a/ranges_test.go b/ranges_test.go new file mode 100644 index 00000000..35794d7c --- /dev/null +++ b/ranges_test.go @@ -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) + } + }) + } +} From d5c50354c3e578a2feb088c372d6e58a3106a9e0 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Tue, 7 Jan 2025 10:21:34 -0300 Subject: [PATCH 2/4] fix: wide characters --- ranges.go | 12 ++++-------- ranges_test.go | 30 ++++++++++++++++++++---------- 2 files changed, 24 insertions(+), 18 deletions(-) diff --git a/ranges.go b/ranges.go index 73918676..699e2fb7 100644 --- a/ranges.go +++ b/ranges.go @@ -8,6 +8,7 @@ import ( // 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 @@ -24,18 +25,13 @@ func StyleRanges(s string, ranges []Range) string { 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 + buf.WriteString(rng.Style.Render(ansi.Cut(stripped, rng.Start, rng.End))) + lastIdx = rng.End } // Add any remaining text after the last match - if lastIdx < ansi.StringWidth(s) { - buf.WriteString(ansi.TruncateLeft(s, lastIdx, "")) - } + buf.WriteString(ansi.TruncateLeft(s, lastIdx, "")) return buf.String() } diff --git a/ranges_test.go b/ranges_test.go index 35794d7c..326aed9c 100644 --- a/ranges_test.go +++ b/ranges_test.go @@ -23,7 +23,7 @@ func TestStyleRanges(t *testing.T) { name: "single range in middle", input: "hello world", ranges: []Range{ - NewRange(6, 10, NewStyle().Bold(true)), + NewRange(6, 11, NewStyle().Bold(true)), }, expected: "hello \x1b[1mworld\x1b[0m", }, @@ -31,8 +31,8 @@ func TestStyleRanges(t *testing.T) { name: "multiple ranges", input: "hello world", ranges: []Range{ - NewRange(0, 4, NewStyle().Bold(true)), - NewRange(6, 10, NewStyle().Italic(true)), + NewRange(0, 5, NewStyle().Bold(true)), + NewRange(6, 11, NewStyle().Italic(true)), }, expected: "\x1b[1mhello\x1b[0m \x1b[3mworld\x1b[0m", }, @@ -40,7 +40,7 @@ func TestStyleRanges(t *testing.T) { name: "overlapping with existing ANSI", input: "hello \x1b[32mworld\x1b[0m", ranges: []Range{ - NewRange(0, 4, NewStyle().Bold(true)), + NewRange(0, 5, NewStyle().Bold(true)), }, expected: "\x1b[1mhello\x1b[0m \x1b[32mworld\x1b[0m", }, @@ -48,7 +48,7 @@ func TestStyleRanges(t *testing.T) { name: "style at start", input: "hello world", ranges: []Range{ - NewRange(0, 4, NewStyle().Bold(true)), + NewRange(0, 5, NewStyle().Bold(true)), }, expected: "\x1b[1mhello\x1b[0m world", }, @@ -56,7 +56,7 @@ func TestStyleRanges(t *testing.T) { name: "style at end", input: "hello world", ranges: []Range{ - NewRange(6, 10, NewStyle().Bold(true)), + NewRange(6, 11, NewStyle().Bold(true)), }, expected: "hello \x1b[1mworld\x1b[0m", }, @@ -64,7 +64,7 @@ func TestStyleRanges(t *testing.T) { name: "multiple styles with gap", input: "hello beautiful world", ranges: []Range{ - NewRange(0, 4, NewStyle().Bold(true)), + NewRange(0, 5, NewStyle().Bold(true)), NewRange(16, 23, NewStyle().Italic(true)), }, expected: "\x1b[1mhello\x1b[0m beautiful \x1b[3mworld\x1b[0m", @@ -73,11 +73,21 @@ func TestStyleRanges(t *testing.T) { name: "adjacent ranges", input: "hello world", ranges: []Range{ - NewRange(0, 4, NewStyle().Bold(true)), - NewRange(6, 10, NewStyle().Italic(true)), + 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 { @@ -85,7 +95,7 @@ func TestStyleRanges(t *testing.T) { 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) + t.Errorf("StyleRanges()\n got = %q\nwant = %q\n", result, tt.expected) } }) } From 9354b646be36d4f4176cee8d9b6685e99ed6f272 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Wed, 8 Jan 2025 12:51:56 -0300 Subject: [PATCH 3/4] feat: helper to style a single range --- ranges.go | 14 ++++++++++ ranges_test.go | 74 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/ranges.go b/ranges.go index 699e2fb7..37e3346d 100644 --- a/ranges.go +++ b/ranges.go @@ -6,6 +6,20 @@ import ( "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. +func StyleRange(s string, start, end int, style Style) string { + 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. diff --git a/ranges_test.go b/ranges_test.go index 326aed9c..162c52be 100644 --- a/ranges_test.go +++ b/ranges_test.go @@ -6,6 +6,80 @@ import ( "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 From 1c3ba1b107c456087f07f28a2859f8b3b3ac1b95 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Wed, 8 Jan 2025 23:01:49 -0300 Subject: [PATCH 4/4] chore: review --- ranges.go | 16 +---------- ranges_test.go | 76 +------------------------------------------------- 2 files changed, 2 insertions(+), 90 deletions(-) diff --git a/ranges.go b/ranges.go index 37e3346d..d1716998 100644 --- a/ranges.go +++ b/ranges.go @@ -6,24 +6,10 @@ import ( "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. -func StyleRange(s string, start, end int, style Style) string { - 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 { +func StyleRanges(s string, ranges ...Range) string { if len(ranges) == 0 { return s } diff --git a/ranges_test.go b/ranges_test.go index 162c52be..c36c9a35 100644 --- a/ranges_test.go +++ b/ranges_test.go @@ -6,80 +6,6 @@ import ( "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 @@ -167,7 +93,7 @@ func TestStyleRanges(t *testing.T) { for _, tt := range tests { renderer.SetColorProfile(termenv.ANSI) t.Run(tt.name, func(t *testing.T) { - result := StyleRanges(tt.input, tt.ranges) + result := StyleRanges(tt.input, tt.ranges...) if result != tt.expected { t.Errorf("StyleRanges()\n got = %q\nwant = %q\n", result, tt.expected) }