package model import ( "fmt" "iter" "strings" "time" "unicode/utf8" "charm.land/lipgloss/v2" "github.com/charmbracelet/x/ansi" "github.com/bjarneo/cliamp/playlist" "github.com/bjarneo/cliamp/ui" ) // formatListMatchCount returns a human-readable "matches of total" summary // for filtered lists. func (m Model) formatListMatchCount(matches, total int) string { if matches < 0 { matches = 0 } if total < 0 { total = 0 } return fmt.Sprintf("%d matches of %d total", matches, total) } // formatTrackTime formats a duration in seconds as M:SS or H:MM:SS for tracks. // Returns "" when secs is non-positive so callers can skip rendering entirely. func formatTrackTime(secs int) string { if secs <= 0 { return "" } h := secs / 3600 m := (secs % 3600) / 60 s := secs % 60 if h > 0 { return fmt.Sprintf("%d:%02d:%02d", h, m, s) } return fmt.Sprintf("%d:%02d", m, s) } // formatPlaylistDuration formats a total runtime for a playlist as "1h 23m" // or "12m" or "45s". Returns "" when secs is non-positive. func formatPlaylistDuration(secs int) string { if secs <= 0 { return "" } h := secs / 3600 m := (secs % 3600) / 60 if h > 0 { if m == 0 { return fmt.Sprintf("%dh", h) } return fmt.Sprintf("%dh %dm", h, m) } if m > 0 { return fmt.Sprintf("%dm", m) } return fmt.Sprintf("%ds", secs) } // formatTrackRow renders a track list row of the form // // "01. Title · Album 3:42" // // with the duration right-aligned at ui.PanelWidth - 4 (to leave space for // the cursor prefix the caller adds). The title column is truncated as // needed; the duration is hidden when secs is 0. func formatTrackRow(num int, name string, secs int) string { const prefixOverhead = 4 // leaves room for " " / "> " caller prefix dur := formatTrackTime(secs) numStr := fmt.Sprintf("%d. ", num) numLen := utf8.RuneCountInString(numStr) durLen := utf8.RuneCountInString(dur) titleBudget := ui.PanelWidth - prefixOverhead - numLen if dur != "" { titleBudget -= durLen + 1 // +1 for spacing gap } if titleBudget < 4 { titleBudget = 4 } title := truncate(name, titleBudget) if dur == "" { return numStr + title } pad := ui.PanelWidth - prefixOverhead - durLen - numLen - utf8.RuneCountInString(title) if pad < 1 { pad = 1 } return numStr + title + strings.Repeat(" ", pad) + dur } // truncate shortens s to maxW runes, appending "…" if truncated. // Uses RuneCountInString first to avoid rune slice allocation in the common // case where the string is already short enough. func truncate(s string, maxW int) string { if maxW <= 0 { return "" } if utf8.RuneCountInString(s) <= maxW { return s } if maxW == 1 { return "…" } r := []rune(s) return string(r[:maxW-1]) + "…" } // cursorLine renders a list item with "> " prefix when active, " " otherwise. func cursorLine(label string, active bool) string { if active { return playlistSelectedStyle.Render("> " + label) } return dimStyle.Render(" " + label) } // spinnerFrames is the braille-dot animation used to indicate loading. The // view re-renders on the model tick so the spinner advances on its own. var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} // spinnerFrame returns the current animation frame, time-driven so the caller // doesn't need to track an animation index. func spinnerFrame() string { idx := (time.Now().UnixMilli() / 100) % int64(len(spinnerFrames)) return spinnerFrames[idx] } // loadingLine renders a single styled "