feat(ui): custom substring/AND filter for station list
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:
Greg Gauthier 2026-06-05 21:46:04 +01:00
parent 444193a5d2
commit 3924aae93b
3 changed files with 83 additions and 11 deletions

BIN
gostations Executable file

Binary file not shown.

View File

@ -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)
} }
} }
} }

View File

@ -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)
}
}
}
} }