From 444193a5d207092908a3d11f208ad057ebfa0aad Mon Sep 17 00:00:00 2001 From: Greg Gauthier Date: Fri, 5 Jun 2026 21:36:49 +0100 Subject: [PATCH] feat(ui): auto-filter on typing + refactor item struct with favorites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add auto-start filter when typing alphanumeric characters - Refactor item struct to use explicit station field and isFavorite flag - Show ★ prefix for favorites in list rendering - Improve description formatting with bullet separators - Include URL in FilterValue for better search - Update help text and add corresponding test --- internal/ui/ui.go | 47 ++++++++++++++++++++++++++++++------------ internal/ui/ui_test.go | 24 +++++++++++++++++++++ 2 files changed, 58 insertions(+), 13 deletions(-) diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 2a6b227..3bbef48 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -15,12 +15,17 @@ import ( // item wraps a station for the bubbles list. type item struct { - s radio.Station + station radio.Station + isFavorite bool } -func (i item) Title() string { return i.s.Name } -func (i item) Description() string { return fmt.Sprintf("%s %s %s", i.s.Codec, i.s.Bitrate, truncate(i.s.Url, 50)) } -func (i item) FilterValue() string { return i.s.Name + " " + i.s.Tags + " " + i.s.Codec } +func (i item) Title() string { return i.station.Name } +func (i item) Description() string { + return fmt.Sprintf("%s • %s kbps • %s", i.station.Codec, i.station.Bitrate, truncate(i.station.Url, 50)) +} +func (i item) FilterValue() string { + return i.station.Name + " " + i.station.Tags + " " + i.station.Codec + " " + i.station.Url +} func truncate(s string, n int) string { if len(s) <= n { @@ -40,13 +45,16 @@ func (d listDelegate) Render(w io.Writer, m list.Model, index int, listItem list if !ok { return } - title := i.s.Name + title := i.station.Name + if i.isFavorite { + title = "★ " + title + } if m.Index() == index { title = lipgloss.NewStyle().Bold(true).Render("▶ " + title) } else { title = " " + title } - desc := fmt.Sprintf(" %s • %s kbps • %s", i.s.Codec, i.s.Bitrate, truncate(i.s.Url, 45)) + desc := fmt.Sprintf(" %s • %s kbps • %s", i.station.Codec, i.station.Bitrate, truncate(i.station.Url, 45)) fmt.Fprintf(w, "%s\n%s", title, desc) } @@ -70,11 +78,8 @@ func NewApp(initial []radio.Station) *App { items := make([]list.Item, len(initial)) for i, s := range initial { - title := s.Name - if favSet[s.Url] { - title = "★ " + title - } - items[i] = item{s: radio.Station{Name: title, Codec: s.Codec, Bitrate: s.Bitrate, Url: s.Url, Tags: s.Tags}} + isFav := favSet[s.Url] + items[i] = item{station: s, isFavorite: isFav} } l := list.New(items, listDelegate{}, 60, 20) @@ -101,10 +106,26 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if i, ok := a.list.SelectedItem().(item); ok { // Placeholder: for now just show what would be played. // Later: switch to playback model + use player.Play - a.list.Title = "Would play: " + i.s.Name + " (press q)" + a.list.Title = "Would play: " + i.station.Name + " (press q)" } return a, nil } + + // Auto-start filter on first alphanumeric character (better UX than requiring / first) + s := msg.String() + if len(s) == 1 { + 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 + 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 + } + } + } case tea.WindowSizeMsg: a.list.SetSize(msg.Width-4, msg.Height-4) } @@ -118,7 +139,7 @@ func (a *App) View() string { if a.quitting { return "Thanks for using GoStations!\n" } - return "\n" + a.list.View() + "\n\n(enter=play stub • / filter • q quit • --legacy for old UI)\n" + return "\n" + a.list.View() + "\n\n(type to filter • enter=play stub • / or letters for filter • q quit • --legacy for old UI)\n" } // Run starts the TUI (alt screen). diff --git a/internal/ui/ui_test.go b/internal/ui/ui_test.go index e04f719..6354b80 100644 --- a/internal/ui/ui_test.go +++ b/internal/ui/ui_test.go @@ -27,3 +27,27 @@ func TestApp_BasicKeyHandling(t *testing.T) { t.Log("list size not updated (may be ok in test)") } } + +func TestApp_AutoFilterOnTyping(t *testing.T) { + app := NewApp([]radio.Station{ + {Name: "WFMT 98.7", Url: "http://wfmt", Codec: "MP3", Bitrate: "128", Tags: "chicago,classical"}, + {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'}}) + 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 +}