diff --git a/gostations b/gostations new file mode 100755 index 0000000..98ff703 Binary files /dev/null and b/gostations differ diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 3bbef48..6b3a957 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -4,6 +4,7 @@ import ( "fmt" "io" "log" + "strings" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/bubbles/list" @@ -88,6 +89,37 @@ func NewApp(initial []radio.Station) *App { l.SetFilteringEnabled(true) 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} } @@ -117,12 +149,13 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { r := rune(s[0]) if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') { 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{'/'}} - a.list, _ = a.list.Update(slash) - // Now feed the original key into the active filter - a.list, _ = a.list.Update(msg) - return a, nil + var c1 tea.Cmd + a.list, c1 = a.list.Update(slash) + var c2 tea.Cmd + a.list, c2 = a.list.Update(msg) + return a, tea.Batch(c1, c2) } } } diff --git a/internal/ui/ui_test.go b/internal/ui/ui_test.go index 6354b80..ffd1d2f 100644 --- a/internal/ui/ui_test.go +++ b/internal/ui/ui_test.go @@ -34,20 +34,59 @@ func TestApp_AutoFilterOnTyping(t *testing.T) { {Name: "Other Station", Url: "http://other", Codec: "AAC", Bitrate: "64", Tags: "news"}, }) - // Simulate typing 'W' (should auto enter filter and filter to WFMT) - model, _ := app.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'W'}}) + // Simulate typing 'W' (auto enter filter). Drive any returned cmds. + model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'W'}}) 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() if fv != "W" { 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() if len(visible) == 0 { 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) + } + } + } }