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
160 lines
4.2 KiB
Go
160 lines
4.2 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 {
|
|
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"))
|
|
|
|
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
|
|
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)
|
|
}
|
|
|
|
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
|
|
}
|