From 99421664af19e293501170dc3971acf569e9cead Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Thu, 9 Jan 2025 15:22:51 -0300 Subject: [PATCH] feat: style ranges (#458) * 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. * fix: wide characters * feat: helper to style a single range * chore: review --- go.mod | 2 +- go.sum | 4 +- ranges.go | 48 +++++++++++++++++++++++ ranges_test.go | 102 +++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 153 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..d1716998 --- /dev/null +++ b/ranges.go @@ -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 +} diff --git a/ranges_test.go b/ranges_test.go new file mode 100644 index 00000000..c36c9a35 --- /dev/null +++ b/ranges_test.go @@ -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) + } + }) + } +}