// itemDelegate is a compact single-line list renderer for media items: a // media-type icon, the title, and a right-aligned meta column (year · duration · // watched). It replaces bubbles' two-line default delegate so the browser reads // like a tidy table. package tui import ( "fmt" "io" "strings" "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "tsplay/internal/api" ) type itemDelegate struct{ st styles } func newItemDelegate(st styles) itemDelegate { return itemDelegate{st: st} } func (d itemDelegate) Height() int { return 1 } func (d itemDelegate) Spacing() int { return 0 } func (d itemDelegate) Update(tea.Msg, *list.Model) tea.Cmd { return nil } func (d itemDelegate) Render(w io.Writer, m list.Model, index int, raw list.Item) { it, ok := raw.(listItem) if !ok { return } selected := index == m.Index() icon := cell(mediaIcon(it.item), 2) title := rowTitle(it.item) meta := rowMeta(it.item) cursor := " " if selected { cursor = d.st.accent.Render("▸ ") } width := m.Width() if width <= 0 { width = 80 } // Layout: cursor(2) + icon(2) + " " + title + gap + meta + 1 right margin. fixed := 2 + lipgloss.Width(icon) + 1 + lipgloss.Width(meta) + 1 titleBudget := width - fixed if lipgloss.Width(title) > titleBudget { title = truncate(title, titleBudget) } gap := width - fixed - lipgloss.Width(title) if gap < 0 { gap = 0 } titleR := title if selected { titleR = d.st.accent.Render(title) } row := cursor + icon + " " + titleR + strings.Repeat(" ", gap) + d.st.muted.Render(meta) fmt.Fprint(w, row) } // Nerd Font Material Design icon glyphs (nf-md-*), matching the family Omarchy // uses in waybar/walker. Defined by codepoint so the source bytes can't be // mangled by an editor or terminal. These are single display-width. const ( iconMovie = "\U000f07de" // nf-md-movie_open iconTV = "\U000f082b" // nf-md-television_classic iconFolder = "\U000f024b" // nf-md-folder iconEpisode = "\U000f0fce" // nf-md-movie_play iconMusic = "\U000f075a" // nf-md-music iconAlbum = "\U000f0025" // nf-md-album iconArtist = "\U000f0803" // nf-md-account_music iconMusicBox = "\U000f0223" // nf-md-music_box (music library) iconLibrary = "\U000f0253" // nf-md-folder_multiple (generic library) iconFile = "\U000f0224" // nf-md-file (plain leaf) ) // IconLegend returns the (label, glyph) pairs tsplay uses, for the `--icons` // debug command so the user can confirm their terminal font renders them. func IconLegend() [][2]string { return [][2]string{ {"movie", iconMovie}, {"tv / series", iconTV}, {"season / folder", iconFolder}, {"episode", iconEpisode}, {"audio track", iconMusic}, {"album", iconAlbum}, {"artist", iconArtist}, {"music library", iconMusicBox}, {"generic library", iconLibrary}, {"file", iconFile}, } } // mediaIcon picks a Nerd Font glyph for an item's type (or its library's // collection type, or a generic folder/leaf glyph). func mediaIcon(it api.Item) string { switch it.Type { case "Movie": return iconMovie case "Series": return iconTV case "Season": return iconFolder case "Episode": return iconEpisode case "Audio": return iconMusic case "MusicAlbum": return iconAlbum case "MusicArtist": return iconArtist case "CollectionFolder", "UserView": switch it.CollectionType { case "movies": return iconMovie case "tvshows": return iconTV case "music": return iconMusicBox default: return iconLibrary } } if it.IsFolder { return iconFolder } return iconFile } // rowTitle is the left text for a row: episode/track numbering where it helps, // otherwise the bare name (year lives in the meta column). func rowTitle(it api.Item) string { switch it.Type { case "Episode": if it.ParentIndexNumber > 0 && it.IndexNumber > 0 { return fmt.Sprintf("S%02dE%02d %s", it.ParentIndexNumber, it.IndexNumber, it.Name) } if it.IndexNumber > 0 { return fmt.Sprintf("%d. %s", it.IndexNumber, it.Name) } case "Audio": if it.IndexNumber > 0 { return fmt.Sprintf("%d. %s", it.IndexNumber, it.Name) } } return it.Name } // rowMeta is the right-aligned column: "(year) 1h 23m ✓". func rowMeta(it api.Item) string { var parts []string if it.ProductionYear > 0 && (it.Type == "Movie" || it.Type == "Series") { parts = append(parts, fmt.Sprintf("(%d)", it.ProductionYear)) } if d := formatDuration(it.RunTimeTicks); d != "" { parts = append(parts, d) } if it.UserData.Played { parts = append(parts, "✓") } else if it.UserData.PlayedPercentage > 1 { parts = append(parts, fmt.Sprintf("%.0f%%", it.UserData.PlayedPercentage)) } return strings.Join(parts, " ") } // cell pads s on the right so it occupies exactly w display columns. This keeps // the title column aligned even when a terminal measures an emoji as one cell // instead of two. func cell(s string, w int) string { if gap := w - lipgloss.Width(s); gap > 0 { return s + strings.Repeat(" ", gap) } return s } // truncate shortens s to at most max display columns, appending an ellipsis. func truncate(s string, max int) string { if max <= 0 { return "" } if lipgloss.Width(s) <= max { return s } var b strings.Builder for _, r := range s { if lipgloss.Width(b.String()+string(r))+1 > max { break } b.WriteRune(r) } return b.String() + "…" }