feat(ui): auto-filter on typing + refactor item struct with favorites
Some checks failed
gobuild / build (push) Failing after 3s
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:
parent
ec5db53b8e
commit
444193a5d2
@ -15,12 +15,17 @@ import (
|
|||||||
|
|
||||||
// item wraps a station for the bubbles list.
|
// item wraps a station for the bubbles list.
|
||||||
type item struct {
|
type item struct {
|
||||||
s radio.Station
|
station radio.Station
|
||||||
|
isFavorite bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (i item) Title() string { return i.s.Name }
|
func (i item) Title() string { return i.station.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) Description() string {
|
||||||
func (i item) FilterValue() string { return i.s.Name + " " + i.s.Tags + " " + i.s.Codec }
|
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 {
|
func truncate(s string, n int) string {
|
||||||
if len(s) <= n {
|
if len(s) <= n {
|
||||||
@ -40,13 +45,16 @@ func (d listDelegate) Render(w io.Writer, m list.Model, index int, listItem list
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
title := i.s.Name
|
title := i.station.Name
|
||||||
|
if i.isFavorite {
|
||||||
|
title = "★ " + title
|
||||||
|
}
|
||||||
if m.Index() == index {
|
if m.Index() == index {
|
||||||
title = lipgloss.NewStyle().Bold(true).Render("▶ " + title)
|
title = lipgloss.NewStyle().Bold(true).Render("▶ " + title)
|
||||||
} else {
|
} else {
|
||||||
title = " " + title
|
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)
|
fmt.Fprintf(w, "%s\n%s", title, desc)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -70,11 +78,8 @@ func NewApp(initial []radio.Station) *App {
|
|||||||
|
|
||||||
items := make([]list.Item, len(initial))
|
items := make([]list.Item, len(initial))
|
||||||
for i, s := range initial {
|
for i, s := range initial {
|
||||||
title := s.Name
|
isFav := favSet[s.Url]
|
||||||
if favSet[s.Url] {
|
items[i] = item{station: s, isFavorite: isFav}
|
||||||
title = "★ " + title
|
|
||||||
}
|
|
||||||
items[i] = item{s: radio.Station{Name: title, Codec: s.Codec, Bitrate: s.Bitrate, Url: s.Url, Tags: s.Tags}}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
l := list.New(items, listDelegate{}, 60, 20)
|
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 {
|
if i, ok := a.list.SelectedItem().(item); ok {
|
||||||
// Placeholder: for now just show what would be played.
|
// Placeholder: for now just show what would be played.
|
||||||
// Later: switch to playback model + use player.Play
|
// 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
|
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:
|
case tea.WindowSizeMsg:
|
||||||
a.list.SetSize(msg.Width-4, msg.Height-4)
|
a.list.SetSize(msg.Width-4, msg.Height-4)
|
||||||
}
|
}
|
||||||
@ -118,7 +139,7 @@ func (a *App) View() string {
|
|||||||
if a.quitting {
|
if a.quitting {
|
||||||
return "Thanks for using GoStations!\n"
|
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).
|
// Run starts the TUI (alt screen).
|
||||||
|
|||||||
@ -27,3 +27,27 @@ func TestApp_BasicKeyHandling(t *testing.T) {
|
|||||||
t.Log("list size not updated (may be ok in test)")
|
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
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user