gostations/internal/ui/ui.go
Greg Gauthier e0f45bdd5e
Some checks failed
gobuild / build (push) Failing after 5s
feat: add favorites management (CLI + TUI) and in-filter server search
- New `gostations fav` subcommand with `list`, `add`, `del` (supports index, URL, or search flags)
- TUI favorites toggle via `f` key; favorites auto-detected on load for "Your Favorites" title
- Pressing ENTER while filtering performs a server-side search and refreshes the list
- Default TUI now loads favorites if present, otherwise falls back to broad station lookup
- Added `sortedForDisplay` for stable fav list ordering and index-based deletion
- Tests for favorites title heuristic, sorted display, and fav del-by-index integration
2026-06-05 22:30:50 +01:00

304 lines
8.5 KiB
Go

package ui
import (
"context"
"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
currentSearchTerm string
favs *data.Favorites
}
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}
}
// Heuristic: if every provided initial station is a favorite, this was a "favorites" initial load
isFavoritesInitial := len(initial) > 0
if isFavoritesInitial {
for _, s := range initial {
if !favSet[s.Url] {
isFavoritesInitial = false
break
}
}
}
l := list.New(items, listDelegate{}, 60, 20)
title := "GoStations - Radio Browser (new TUI • ★ = favorite)"
if isFavoritesInitial {
title = "GoStations - Your Favorites"
}
l.Title = title
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, favs: favs}
}
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 a.list.FilterState() == list.Filtering || a.list.FilterState() == list.FilterApplied {
// User pressed enter while in filter: treat the filter term as a new lookup/search
term := strings.TrimSpace(a.list.FilterValue())
if term != "" {
a.currentSearchTerm = term
a.list.Title = fmt.Sprintf(`Searching for "%s"...`, term)
return a, searchCmd(term)
}
}
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
case "f":
// Toggle favorite on the currently selected item
if sel, ok := a.list.SelectedItem().(item); ok {
masterIdx := a.list.GlobalIndex()
if a.favs == nil {
var ferr error
a.favs, ferr = data.NewFavorites()
if ferr != nil {
statusCmd := a.list.NewStatusMessage("Favorites unavailable: " + ferr.Error())
return a, statusCmd
}
}
url := sel.station.Url
wasFav := sel.isFavorite
if wasFav {
a.favs.Remove(url)
} else {
a.favs.Add(sel.station)
}
if saveErr := a.favs.Save(); saveErr != nil {
statusCmd := a.list.NewStatusMessage("Failed to save favorites: " + saveErr.Error())
return a, statusCmd
}
sel.isFavorite = !wasFav
setCmd := a.list.SetItem(masterIdx, sel)
status := "Removed from favorites"
if sel.isFavorite {
status = "Added to favorites ★"
}
statusCmd := a.list.NewStatusMessage(status)
return a, tea.Batch(setCmd, statusCmd)
}
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)
case searchResultsMsg:
if msg.err != nil {
a.list.Title = fmt.Sprintf("Search error: %v (press / to search again)", msg.err)
return a, nil
}
// Rebuild items (preserve favorites)
var favs *data.Favorites
var ferr error
if a.favs != nil {
favs = a.favs
// reload to get latest persisted state (in case of external edits)
favs, ferr = data.NewFavorites()
if ferr != nil {
favs = a.favs // fallback to in-memory
}
} else {
favs, ferr = data.NewFavorites()
}
a.favs = favs
favSet := map[string]bool{}
if favs != nil {
for _, fs := range favs.List() {
favSet[fs.Url] = true
}
}
newItems := make([]list.Item, len(msg.stations))
for i, s := range msg.stations {
newItems[i] = item{station: s, isFavorite: favSet[s.Url]}
}
setCmd := a.list.SetItems(newItems)
title := "GoStations - Radio Browser (new TUI • ★ = favorite)"
if a.currentSearchTerm != "" {
title = fmt.Sprintf("GoStations - Results for %q (%d)", a.currentSearchTerm, len(newItems))
}
a.list.Title = title
a.list.ResetFilter()
return a, setCmd
}
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 current list • ENTER while filtering = server search for term & refresh list • f = toggle ★ favorite • q quit • --legacy for old UI)\n"
}
// searchCmd performs an async station search (used for in-TUI lookups via the filter box).
func searchCmd(name string) tea.Cmd {
return func() tea.Msg {
stations, err := radio.Search(context.Background(), name, "", "", "", false)
return searchResultsMsg{stations: stations, err: err}
}
}
// searchResultsMsg is sent when a background search completes.
type searchResultsMsg struct {
stations []radio.Station
err error
}
// 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
}