Skip to content

Commit

Permalink
fix(filter): properly handle options with ansi styles (#789)
Browse files Browse the repository at this point in the history
* fix(filter): handle styles option matches

* perf: use ranges

* fix: cut

* fix: ansi update
  • Loading branch information
caarlos0 authored Jan 7, 2025
1 parent cd151b5 commit d3d20ef
Show file tree
Hide file tree
Showing 5 changed files with 112 additions and 29 deletions.
18 changes: 14 additions & 4 deletions filter/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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 {
Expand All @@ -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,
Expand Down
76 changes: 54 additions & 22 deletions filter/filter.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
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
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)
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()))

Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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 changes: 41 additions & 0 deletions filter/filter_test.go
Original file line number Diff line number Diff line change
@@ -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 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down

0 comments on commit d3d20ef

Please sign in to comment.