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 }