fix(filter): properly handle options with ansi styles (#789)

* fix(filter): handle styles option matches

* perf: use ranges

* fix: cut

* fix: ansi update
This commit is contained in:
Carlos Alexandro Becker 2025-01-07 11:08:24 -03:00 committed by GitHub
commit d3d20efc70
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 112 additions and 29 deletions

View file

@ -14,6 +14,7 @@ import (
"github.com/charmbracelet/gum/internal/stdin"
"github.com/charmbracelet/gum/internal/timeout"
"github.com/charmbracelet/gum/internal/tty"
"github.com/charmbracelet/x/ansi"
"github.com/sahilm/fuzzy"
)
@ -59,13 +60,21 @@ func (o Options) Run() error {
if o.Value != "" {
i.SetValue(o.Value)
}
choices := map[string]string{}
filteringChoices := []string{}
for _, opt := range o.Options {
s := ansi.Strip(opt)
choices[s] = opt
filteringChoices = append(filteringChoices, s)
}
switch {
case o.Value != "" && o.Fuzzy:
matches = fuzzy.Find(o.Value, o.Options)
matches = fuzzy.Find(o.Value, filteringChoices)
case o.Value != "" && !o.Fuzzy:
matches = exactMatches(o.Value, o.Options)
matches = exactMatches(o.Value, filteringChoices)
default:
matches = matchAll(o.Options)
matches = matchAll(filteringChoices)
}
if o.NoLimit {
@ -86,7 +95,8 @@ func (o Options) Run() error {
}
m := model{
choices: o.Options,
choices: choices,
filteringChoices: filteringChoices,
indicator: o.Indicator,
matches: matches,
header: o.Header,

View file

@ -19,6 +19,7 @@ import (
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
"github.com/sahilm/fuzzy"
)
@ -124,7 +125,8 @@ func (k keymap) ShortHelp() []key.Binding {
type model struct {
textinput textinput.Model
viewport *viewport.Model
choices []string
choices map[string]string
filteringChoices []string
matches []fuzzy.Match
cursor int
header string
@ -201,28 +203,37 @@ func (m model) View() string {
s.WriteString(" ")
}
// For this match, there are a certain number of characters that have
// caused the match. i.e. fuzzy matching.
// We should indicate to the users which characters are being matched.
mi := 0
var buf strings.Builder
for ci, c := range match.Str {
// Check if the current character index matches the current matched
// index. If so, color the character to indicate a match.
if mi < len(match.MatchedIndexes) && ci == match.MatchedIndexes[mi] {
// Flush text buffer.
s.WriteString(lineTextStyle.Render(buf.String()))
buf.Reset()
s.WriteString(m.matchStyle.Render(string(c)))
// We have matched this character, so we never have to check it
// again. Move on to the next match.
mi++
} else {
// Not a match, buffer a regular character.
buf.WriteRune(c)
}
styledOption := m.choices[match.Str]
if len(match.MatchedIndexes) == 0 {
// No matches, just render the text.
s.WriteString(lineTextStyle.Render(styledOption))
s.WriteRune('\n')
continue
}
// Use ansi.Truncate and ansi.TruncateLeft and ansi.StringWidth to
// style match.MatchedIndexes without losing the original option style:
var buf strings.Builder
lastIdx := 0
for _, rng := range matchedRanges(match.MatchedIndexes) {
// fmt.Print("here ", lastIdx, rng, " - ", match.Str[rng[0]:rng[1]+1], "\r\n")
// Add the text before this match
if rng[0] > lastIdx {
buf.WriteString(ansi.Cut(styledOption, lastIdx, rng[0]))
}
// Add the matched character with highlight
buf.WriteString(m.matchStyle.Render(match.Str[rng[0] : rng[1]+1]))
lastIdx = rng[1] + 1
}
// Add any remaining text after the last match
// fmt.Print("here ", lastIdx, ansi.StringWidth(styledOption), len(match.Str), "\r\n")
if lastIdx < ansi.StringWidth(styledOption) {
remaining := ansi.TruncateLeft(styledOption, lastIdx, "")
buf.WriteString(remaining)
}
// Flush text buffer.
s.WriteString(lineTextStyle.Render(buf.String()))
@ -356,7 +367,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if !m.strict {
choices = append(choices, m.textinput.Value())
}
choices = append(choices, m.choices...)
choices = append(choices, m.filteringChoices...)
if m.fuzzy {
if m.sort {
m.matches = fuzzy.Find(m.textinput.Value(), choices)
@ -370,7 +381,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// If the search field is empty, let's not display the matches
// (none), but rather display all possible choices.
if m.textinput.Value() == "" {
m.matches = matchAll(m.choices)
m.matches = matchAll(m.filteringChoices)
}
// For reverse layout, we need to offset the viewport so that the
@ -511,3 +522,24 @@ func clamp(low, high, val int) int {
}
return val
}
func matchedRanges(in []int) [][2]int {
if len(in) == 0 {
return [][2]int{}
}
current := [2]int{in[0], in[0]}
if len(in) == 1 {
return [][2]int{current}
}
var out [][2]int
for i := 1; i < len(in); i++ {
if in[i] == current[1]+1 {
current[1] = in[i]
} else {
out = append(out, current)
current = [2]int{in[i], in[i]}
}
}
out = append(out, current)
return out
}

41
filter/filter_test.go Normal file
View file

@ -0,0 +1,41 @@
package filter
import (
"reflect"
"testing"
)
func TestMatchedRanges(t *testing.T) {
for name, tt := range map[string]struct {
in []int
out [][2]int
}{
"empty": {
in: []int{},
out: [][2]int{},
},
"one char": {
in: []int{1},
out: [][2]int{{1, 1}},
},
"2 char range": {
in: []int{1, 2},
out: [][2]int{{1, 2}},
},
"multiple char range": {
in: []int{1, 2, 3, 4, 5, 6},
out: [][2]int{{1, 6}},
},
"multiple char ranges": {
in: []int{1, 2, 3, 5, 6, 10, 11, 12, 13, 23, 24, 40, 42, 43, 45, 52},
out: [][2]int{{1, 3}, {5, 6}, {10, 13}, {23, 24}, {40, 40}, {42, 43}, {45, 45}, {52, 52}},
},
} {
t.Run(name, func(t *testing.T) {
match := matchedRanges(tt.in)
if !reflect.DeepEqual(match, tt.out) {
t.Errorf("expected %v, got %v", tt.out, match)
}
})
}
}

2
go.mod
View file

@ -11,7 +11,7 @@ require (
github.com/charmbracelet/glamour v0.8.0
github.com/charmbracelet/lipgloss v1.0.0
github.com/charmbracelet/log v0.4.0
github.com/charmbracelet/x/ansi v0.6.0
github.com/charmbracelet/x/ansi v0.6.1-0.20250107110353-48b574af22a5
github.com/charmbracelet/x/editor v0.1.0
github.com/charmbracelet/x/term v0.2.1
github.com/muesli/reflow v0.3.0

4
go.sum
View file

@ -30,8 +30,8 @@ github.com/charmbracelet/lipgloss v1.0.0 h1:O7VkGDvqEdGi93X+DeqsQ7PKHDgtQfF8j8/O
github.com/charmbracelet/lipgloss v1.0.0/go.mod h1:U5fy9Z+C38obMs+T+tJqst9VGzlOYGj4ri9reL3qUlo=
github.com/charmbracelet/log v0.4.0 h1:G9bQAcx8rWA2T3pWvx7YtPTPwgqpk7D68BX21IRW8ZM=
github.com/charmbracelet/log v0.4.0/go.mod h1:63bXt/djrizTec0l11H20t8FDSvA4CRZJ1KH22MdptM=
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/editor v0.1.0 h1:p69/dpvlwRTs9uYiPeAWruwsHqTFzHhTvQOd/WVSX98=
github.com/charmbracelet/x/editor v0.1.0/go.mod h1:oivrEbcP/AYt/Hpvk5pwDXXrQ933gQS6UzL6fxqAGSA=
github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b h1:MnAMdlwSltxJyULnrYbkZpp4k58Co7Tah3ciKhSNo0Q=