feat(ui): auto-filter on typing + refactor item struct with favorites
Some checks failed
gobuild / build (push) Failing after 3s

- 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
This commit is contained in:
Greg Gauthier 2026-06-05 21:36:49 +01:00
parent ec5db53b8e
commit 444193a5d2
2 changed files with 58 additions and 13 deletions

View File

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

View File

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