feat(ui): custom substring/AND filter for station list
Some checks failed
gobuild / build (push) Failing after 3s
Some checks failed
gobuild / build (push) Failing after 3s
Replace default fuzzy filter with substring matching where multi-word queries require all words to appear. Fix auto-filter typing to properly batch returned commands instead of discarding them. Update tests to drive command loops and verify exact substring matches.
This commit is contained in:
parent
444193a5d2
commit
3924aae93b
BIN
gostations
Executable file
BIN
gostations
Executable file
Binary file not shown.
@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
|
"strings"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/bubbles/list"
|
"github.com/charmbracelet/bubbles/list"
|
||||||
@ -88,6 +89,37 @@ func NewApp(initial []radio.Station) *App {
|
|||||||
l.SetFilteringEnabled(true)
|
l.SetFilteringEnabled(true)
|
||||||
l.Styles.Title = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("63"))
|
l.Styles.Title = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("63"))
|
||||||
|
|
||||||
|
// Use a custom substring/AND filter instead of the default fuzzy (which matches
|
||||||
|
// any combination of individual letters, e.g. "WFMT" matching stations with W or F or M or T).
|
||||||
|
// This makes "/WFMT" find stations containing the substring "wfmt" (case-insensitive),
|
||||||
|
// and multi-word searches require all words to appear somewhere in the target.
|
||||||
|
l.Filter = func(term string, targets []string) []list.Rank {
|
||||||
|
term = strings.TrimSpace(term)
|
||||||
|
if term == "" {
|
||||||
|
ranks := make([]list.Rank, len(targets))
|
||||||
|
for i := range targets {
|
||||||
|
ranks[i] = list.Rank{Index: i}
|
||||||
|
}
|
||||||
|
return ranks
|
||||||
|
}
|
||||||
|
words := strings.Fields(strings.ToLower(term))
|
||||||
|
var ranks []list.Rank
|
||||||
|
for i, t := range targets {
|
||||||
|
tl := strings.ToLower(t)
|
||||||
|
matches := true
|
||||||
|
for _, w := range words {
|
||||||
|
if !strings.Contains(tl, w) {
|
||||||
|
matches = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if matches {
|
||||||
|
ranks = append(ranks, list.Rank{Index: i})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ranks
|
||||||
|
}
|
||||||
|
|
||||||
return &App{list: l}
|
return &App{list: l}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -117,12 +149,13 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
r := rune(s[0])
|
r := rune(s[0])
|
||||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
|
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
|
||||||
if a.list.FilterState() != list.Filtering {
|
if a.list.FilterState() != list.Filtering {
|
||||||
// Simulate pressing the filter key to activate
|
// Simulate pressing the filter key to activate, then feed the char
|
||||||
slash := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}
|
slash := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}
|
||||||
a.list, _ = a.list.Update(slash)
|
var c1 tea.Cmd
|
||||||
// Now feed the original key into the active filter
|
a.list, c1 = a.list.Update(slash)
|
||||||
a.list, _ = a.list.Update(msg)
|
var c2 tea.Cmd
|
||||||
return a, nil
|
a.list, c2 = a.list.Update(msg)
|
||||||
|
return a, tea.Batch(c1, c2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -34,20 +34,59 @@ func TestApp_AutoFilterOnTyping(t *testing.T) {
|
|||||||
{Name: "Other Station", Url: "http://other", Codec: "AAC", Bitrate: "64", Tags: "news"},
|
{Name: "Other Station", Url: "http://other", Codec: "AAC", Bitrate: "64", Tags: "news"},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Simulate typing 'W' (should auto enter filter and filter to WFMT)
|
// Simulate typing 'W' (auto enter filter). Drive any returned cmds.
|
||||||
model, _ := app.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'W'}})
|
model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'W'}})
|
||||||
a := model.(*App)
|
a := model.(*App)
|
||||||
|
for cmd != nil {
|
||||||
|
msg := cmd()
|
||||||
|
if msg == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
model, cmd = a.Update(msg.(tea.Msg))
|
||||||
|
a = model.(*App)
|
||||||
|
}
|
||||||
|
|
||||||
// After auto filter start + 'W', the filter value should be "W" and state Filtering or applied
|
|
||||||
fv := a.list.FilterValue()
|
fv := a.list.FilterValue()
|
||||||
if fv != "W" {
|
if fv != "W" {
|
||||||
t.Errorf("expected filter value 'W' after typing W, got %q", fv)
|
t.Errorf("expected filter value 'W' after typing W, got %q", fv)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The visible items should be filtered (at least the WFMT one should match)
|
|
||||||
visible := a.list.VisibleItems()
|
visible := a.list.VisibleItems()
|
||||||
if len(visible) == 0 {
|
if len(visible) == 0 {
|
||||||
t.Error("expected some visible items after filter 'W'")
|
t.Error("expected some visible items after filter 'W'")
|
||||||
}
|
}
|
||||||
// Check that 'Other' is not the only one, or better, since fuzzy, 'W' may match others weakly, but at least not empty
|
|
||||||
|
// Now type the rest of "WFMT", driving cmds each time
|
||||||
|
for _, r := range "FMT" {
|
||||||
|
model, cmd = a.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
|
||||||
|
a = model.(*App)
|
||||||
|
for cmd != nil {
|
||||||
|
msg := cmd()
|
||||||
|
if msg == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
model, cmd = a.Update(msg.(tea.Msg))
|
||||||
|
a = model.(*App)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fv = a.list.FilterValue()
|
||||||
|
if fv != "WFMT" {
|
||||||
|
t.Errorf("expected filter value 'WFMT', got %q", fv)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: in live typing simulation, state may stay 'filtering' and filteredItems update may depend on internal cmd processing.
|
||||||
|
// For verifying the custom substring filter logic itself, use SetFilterText which synchronously applies.
|
||||||
|
a.list.SetFilterText("WFMT")
|
||||||
|
|
||||||
|
visible = a.list.VisibleItems()
|
||||||
|
if len(visible) != 1 {
|
||||||
|
t.Errorf("expected exactly 1 item for 'WFMT' substring filter, got %d", len(visible))
|
||||||
|
}
|
||||||
|
if len(visible) > 0 {
|
||||||
|
if it, ok := visible[0].(item); ok {
|
||||||
|
if it.station.Name != "WFMT 98.7" {
|
||||||
|
t.Errorf("expected 'WFMT 98.7' to be the match, got %q", it.station.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user