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.
193 lines
5.1 KiB
Go
193 lines
5.1 KiB
Go
package ui
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"strings"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/bubbles/list"
|
|
"github.com/charmbracelet/lipgloss"
|
|
|
|
"github.com/gmgauthier/gostations/internal/data"
|
|
"github.com/gmgauthier/gostations/internal/radio"
|
|
)
|
|
|
|
// item wraps a station for the bubbles list.
|
|
type item struct {
|
|
station radio.Station
|
|
isFavorite bool
|
|
}
|
|
|
|
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 {
|
|
return s
|
|
}
|
|
return s[:n] + "…"
|
|
}
|
|
|
|
// listDelegate for nice rendering.
|
|
type listDelegate struct{}
|
|
|
|
func (d listDelegate) Height() int { return 2 }
|
|
func (d listDelegate) Spacing() int { return 1 }
|
|
func (d listDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil }
|
|
func (d listDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
|
|
i, ok := listItem.(item)
|
|
if !ok {
|
|
return
|
|
}
|
|
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.station.Codec, i.station.Bitrate, truncate(i.station.Url, 45))
|
|
fmt.Fprintf(w, "%s\n%s", title, desc)
|
|
}
|
|
|
|
// App is the root model. Currently focused on selection (playback integration later).
|
|
type App struct {
|
|
list list.Model
|
|
quitting bool
|
|
}
|
|
|
|
func NewApp(initial []radio.Station) *App {
|
|
favs, err := data.NewFavorites()
|
|
if err != nil {
|
|
log.Printf("warning: could not load favorites: %v", err)
|
|
}
|
|
favSet := map[string]bool{}
|
|
if favs != nil {
|
|
for _, s := range favs.List() {
|
|
favSet[s.Url] = true
|
|
}
|
|
}
|
|
|
|
items := make([]list.Item, len(initial))
|
|
for i, s := range initial {
|
|
isFav := favSet[s.Url]
|
|
items[i] = item{station: s, isFavorite: isFav}
|
|
}
|
|
|
|
l := list.New(items, listDelegate{}, 60, 20)
|
|
l.Title = "GoStations - Radio Browser (new TUI • ★ = favorite)"
|
|
l.SetShowStatusBar(true)
|
|
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}
|
|
}
|
|
|
|
func (a *App) Init() tea.Cmd {
|
|
return nil
|
|
}
|
|
|
|
func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case tea.KeyMsg:
|
|
switch msg.String() {
|
|
case "q", "ctrl+c":
|
|
a.quitting = true
|
|
return a, tea.Quit
|
|
case "enter":
|
|
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.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, then feed the char
|
|
slash := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
case tea.WindowSizeMsg:
|
|
a.list.SetSize(msg.Width-4, msg.Height-4)
|
|
}
|
|
|
|
var cmd tea.Cmd
|
|
a.list, cmd = a.list.Update(msg)
|
|
return a, cmd
|
|
}
|
|
|
|
func (a *App) View() string {
|
|
if a.quitting {
|
|
return "Thanks for using GoStations!\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).
|
|
func Run(initial []radio.Station) error {
|
|
p := tea.NewProgram(NewApp(initial), tea.WithAltScreen())
|
|
_, err := p.Run()
|
|
return err
|
|
}
|
|
|
|
// Short is a small helper (duplicated from old for TUI list desc; can be shared later).
|
|
func Short(s string, i int) string {
|
|
runes := []rune(s)
|
|
if len(runes) > i {
|
|
return string(runes[:i])
|
|
}
|
|
return s
|
|
}
|