gostations/internal/ui/ui.go
Greg Gauthier ec5db53b8e
Some checks failed
gobuild / build (push) Failing after 4s
feat(cli): default to bubbles TUI, add find/play subcmds + JSON favorites
- implement internal/ui with bubbles list + ★ fav markers, filter, enter stub
- add data/favorites (JSON roundtrip, Add/Remove, XDG), config tests
- wire subcommands: `find -j`, `play [url|search]`
- gate legacy wmenu behind --legacy; keep old flags for seeding
- fix inverted -short guards, format string, go.mod deps
- add unit tests for radio prune, player IsInstalled, ui keys, etc.
2026-06-05 21:23:11 +01:00

139 lines
3.5 KiB
Go

package ui
import (
"fmt"
"io"
"log"
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 {
s radio.Station
}
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 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.s.Name
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))
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 {
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}}
}
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"))
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.s.Name + " (press q)"
}
return a, nil
}
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(enter=play stub • / 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
}