diff --git a/README.md b/README.md index 93fb682..fb09304 100644 --- a/README.md +++ b/README.md @@ -144,9 +144,10 @@ Requires Go 1.26+ and a reachable MPD server. ./mpdtui -mini # lightweight inline player ./mpdtui -p # fuzzy-search playlists; Enter clears the queue and plays it ./mpdtui -t # fuzzy-search tracks; Enter adds it to the queue and plays it +./mpdtui -v # print version and exit ``` -`-mini`, `-p`, and `-t` are mutually exclusive. +`-mini`, `-p`, `-t`, and `-lyrics-line` are mutually exclusive. Connects using the same environment variables as `mpc`: diff --git a/cmd/mpdtui/main.go b/cmd/mpdtui/main.go index 6e5e25a..39fcff5 100644 --- a/cmd/mpdtui/main.go +++ b/cmd/mpdtui/main.go @@ -13,15 +13,22 @@ import ( "mpdtui/internal/mpdclient" "mpdtui/internal/picker" "mpdtui/internal/ui" + "mpdtui/internal/version" ) func main() { + showVersion := flag.Bool("v", false, "print version and exit") miniMode := flag.Bool("mini", false, "run the lightweight inline player instead of the full panel UI") playlistPicker := flag.Bool("p", false, "fuzzy-search playlists; Enter clears the queue and plays the selection") trackPicker := flag.Bool("t", false, "fuzzy-search tracks; Enter adds the selection to the queue and plays it") lyricsLine := flag.Bool("lyrics-line", false, "print the current synced (.lrc) lyrics window (1 line above, the current line, 2 lines below) and exit -- for embedding in an external tool like conky") flag.Parse() + if *showVersion { + fmt.Println(version.String) + return + } + if modeCount(*miniMode, *playlistPicker, *trackPicker, *lyricsLine) > 1 { fmt.Fprintln(os.Stderr, "mpdtui: -mini, -p, -t, and -lyrics-line are mutually exclusive") os.Exit(1) diff --git a/internal/ui/keys_test.go b/internal/ui/keys_test.go index fae1987..09f8e7a 100644 --- a/internal/ui/keys_test.go +++ b/internal/ui/keys_test.go @@ -17,6 +17,7 @@ import ( func newTestApp() *App { a := &App{tv: tview.NewApplication()} a.build() + a.queue.table.SetRect(0, 0, 150, 40) return a } diff --git a/internal/ui/lyrics.go b/internal/ui/lyrics.go index eb8fb2b..39a62f7 100644 --- a/internal/ui/lyrics.go +++ b/internal/ui/lyrics.go @@ -313,8 +313,14 @@ func resolveLyricsViewerColumnBounds(qx, qw, yearX, durationX int) (int, int) { // layout, not a stale one. func (v *lyricsViewer) positionOverQueueColumns() { qx, qy, qw, qh := v.app.queue.table.GetRect() - cols := newQueueColumns(v.app.musicDir != "", v.app.metaDB != nil) - yearX, _, _ := v.app.queue.table.GetCell(0, cols.year).GetLastPosition() + cols := v.app.queue.cols + var yearX int + if cols.year >= 0 { + yearX, _, _ = v.app.queue.table.GetCell(0, cols.year).GetLastPosition() + } else { + // In compact mode (no Year column), position lyrics viewer over the right 50% of the Queue table. + yearX = qx + qw/2 + } durationX, _, _ := v.app.queue.table.GetCell(0, cols.duration).GetLastPosition() yearX, durationX = resolveLyricsViewerColumnBounds(qx, qw, yearX, durationX) v.SetRect(lyricsViewerRect(qy, qh, yearX, durationX)) diff --git a/internal/ui/queue.go b/internal/ui/queue.go index 8af6178..5d2ad67 100644 --- a/internal/ui/queue.go +++ b/internal/ui/queue.go @@ -36,6 +36,11 @@ type queuePanel struct { // goroutine, same as every other field here. cols queueColumns + // lastRenderedWidth is the table width (runes) at the last render pass, + // tracked so SetDrawFunc can trigger a re-render when terminal resize + // alters the available column space on smaller screens. + lastRenderedWidth int + // metaCache holds the last known local metadata (rating/mark) per // song file, populated asynchronously (see refreshTrackMeta) so // render() never blocks the UI goroutine on a database read. Absent @@ -79,9 +84,22 @@ func newQueuePanel(app *App) *queuePanel { q.app.refreshNowPlaying() }) q.table = t - q.cols = newQueueColumns(app.musicDir != "", app.metaDB != nil) + _, _, w, _ := t.GetRect() + showYear, showGenre, showComposer, showType := queueOptionalColumns(w, app.musicDir != "", app.metaDB != nil) + q.cols = newQueueColumns(app.musicDir != "", app.metaDB != nil, showYear, showGenre, showComposer, showType) setQueueHeader(t, q.cols) + t.SetDrawFunc(func(screen tcell.Screen, x, y, width, height int) (int, int, int, int) { + if width > 0 && width != q.lastRenderedWidth { + q.lastRenderedWidth = width + q.render(q.currentID) + } + if width <= 2 || height <= 2 { + return x, y, 0, 0 + } + return x + 1, y + 1, width - 2, height - 2 + }) + search := tview.NewInputField().SetLabel("Search track: ") search.SetBorder(true) search.SetDoneFunc(func(key tcell.Key) { @@ -169,12 +187,21 @@ func (q *queuePanel) refresh() { // automatic column spacing). Year has no max of its own: yearFromDate // already caps it to at most 4 characters. const ( - queueTitleMaxLen = 30 - queueAlbumMaxLen = 20 - queueArtistMaxLen = 40 - queueGenreMaxLen = 9 - queueComposerMaxLen = 14 - queueColumnGap = " " + queueTitleMaxLen = 30 + queueAlbumMaxLen = 20 + queueArtistMaxLen = 40 + queueGenreMaxLen = 9 + queueComposerMaxLen = 14 + queueTitleCompactMaxLen = 22 + queueAlbumCompactMaxLen = 16 + queueArtistCompactMaxLen = 20 + queueColumnGap = " " + + // queueCompactWidthThreshold is the Queue table width (runes) below + // which the table drops Year, Genre, and Composer columns to preserve + // space for Title, Album, Artist, Play count, Mark, Rating, Type, and + // Duration on smaller or scaled screens (e.g. 1080p @ 1.5x scaling). + queueCompactWidthThreshold = 130 ) // queueTitleColor tints the Title cell with the active theme's Green, @@ -195,13 +222,10 @@ var ( // queueColumns holds the Queue table's column indices for one header/ // render pass. Lyr only exists as a column when the lyrics feature is -// actually active -- a music_dir that config.LoadMusicDir has already -// confirmed both exists and is a real directory, not just configured -- -// so an install without it (or with a broken/stale setting) looks -// exactly like it did before this feature existed, rather than always -// reserving space for a column that will never show anything. Every -// other column shifts left by one to fill that gap when Lyr is absent -// (lyr == -1). +// actually active. Playcount/Mark/Rating exist when metadata is active. +// Year, Genre, Composer, and Type are optional columns included +// progressively based on available screen width in order of priority: +// Year first, then Genre, Composer, and finally Type on wide screens. type queueColumns struct { lyr, title, album, artist, year, genre, composer, playcount, mark, rating, typ, duration int } @@ -210,11 +234,9 @@ type queueColumns struct { // Marker (0) and position (1) are always fixed; Title always follows at // 2; everything from there on is assigned sequentially, with Lyr // included only when lyricsActive and Playcount/Mark/Rating included -// only when metadataActive (App.metaDB != nil) -- same "don't reserve -// space for a column that will never show anything" reasoning as Lyr. -// Playcount/Mark/Rating sit right before Type, in that order, per -// explicit request (Playcount ahead of Mark, itself ahead of Rating). -func newQueueColumns(lyricsActive, metadataActive bool) queueColumns { +// only when metadataActive (App.metaDB != nil). Year, Genre, Composer, +// and Type are included conditionally according to available space and priority. +func newQueueColumns(lyricsActive, metadataActive, showYear, showGenre, showComposer, showType bool) queueColumns { var c queueColumns c.title = 2 next := 3 @@ -228,12 +250,24 @@ func newQueueColumns(lyricsActive, metadataActive bool) queueColumns { next++ c.artist = next next++ - c.year = next - next++ - c.genre = next - next++ - c.composer = next - next++ + if showYear { + c.year = next + next++ + } else { + c.year = -1 + } + if showGenre { + c.genre = next + next++ + } else { + c.genre = -1 + } + if showComposer { + c.composer = next + next++ + } else { + c.composer = -1 + } if metadataActive { c.playcount = next next++ @@ -246,8 +280,12 @@ func newQueueColumns(lyricsActive, metadataActive bool) queueColumns { c.mark = -1 c.rating = -1 } - c.typ = next - next++ + if showType { + c.typ = next + next++ + } else { + c.typ = -1 + } c.duration = next return c } @@ -302,10 +340,18 @@ func setQueueHeader(t *tview.Table, cols queueColumns) { set(cols.title, "Title"+queueColumnGap, tview.AlignLeft) set(cols.album, "Album"+queueColumnGap, tview.AlignLeft) set(cols.artist, "Artist"+queueColumnGap, tview.AlignLeft) - set(cols.year, "Year"+queueColumnGap, tview.AlignLeft) - set(cols.genre, "Genre"+queueColumnGap, tview.AlignLeft) - set(cols.composer, "Composer"+queueColumnGap, tview.AlignLeft) - t.GetCell(0, cols.composer).SetExpansion(1) + if cols.year >= 0 { + set(cols.year, "Year"+queueColumnGap, tview.AlignLeft) + } + if cols.genre >= 0 { + set(cols.genre, "Genre"+queueColumnGap, tview.AlignLeft) + } + if cols.composer >= 0 { + set(cols.composer, "Composer"+queueColumnGap, tview.AlignLeft) + t.GetCell(0, cols.composer).SetExpansion(1) + } else { + t.GetCell(0, cols.artist).SetExpansion(1) + } if cols.playcount >= 0 { set(cols.playcount, "Plays"+queueColumnGap, tview.AlignRight) } @@ -315,15 +361,122 @@ func setQueueHeader(t *tview.Table, cols queueColumns) { if cols.rating >= 0 { set(cols.rating, "Rating"+queueColumnGap, tview.AlignRight) } - set(cols.typ, "Type"+formatGap, tview.AlignRight) + if cols.typ >= 0 { + set(cols.typ, "Type"+formatGap, tview.AlignRight) + } set(cols.duration, "Duration", tview.AlignRight) } +// queueOptionalColumns determines which optional columns (Year, Genre, +// Composer, Type) should be shown given the available table width and active +// features. The core columns (Title, Lyr, Album, Artist, Plays, Mark, +// Rating, Duration) are always preserved on all displays. Extra +// space is allocated progressively by priority: Year first, then Genre, +// Composer, and finally Type on wide screens. +func queueOptionalColumns(width int, lyricsActive, metadataActive bool) (showYear, showGenre, showComposer, showType bool) { + if width <= 0 { + return false, false, false, false + } + fixed := 2 + 3 + 8 + 2 // marker(2) + pos(3) + duration(8) + border(2) + if lyricsActive { + fixed += 3 + } + if metadataActive { + fixed += 7 + 4 + 8 // plays(7) + mark(4) + rating(8) + } + avail := width - fixed + // Base comfortable text space for Title (24), Album (16), Artist (22) + gaps (6) = 68 + const baseTextSpace = 68 + if avail >= baseTextSpace+6 { // Priority 1: Year (4 + 2 gap = 6) + showYear = true + } + if avail >= baseTextSpace+6+11 { // Priority 2: Genre (9 + 2 gap = 11) + showGenre = true + } + if avail >= baseTextSpace+6+11+16 { // Priority 3: Composer (14 + 2 gap = 16) + showComposer = true + } + if avail >= baseTextSpace+6+11+16+6 { // Priority 4 (Last): Type (4 + 2 gap = 6) + showType = true + } + return showYear, showGenre, showComposer, showType +} + +// queueColumnTruncation calculates the maximum text lengths (runes) for Title, +// Album, and Artist based on available table width and optional columns. +// Fixed standard caps (30/20/40) are used when all columns fit comfortably. +// On narrower screens, text column caps scale proportionally to the available width +// after reserving fixed space for marker, pos, lyrics, play count, mark, rating, +// type, and duration columns, ensuring the trailing metadata and duration columns +// are never pushed off screen. +func queueColumnTruncation(width int, lyricsActive, metadataActive, showYear, showGenre, showComposer, showType bool) (titleLen, albumLen, artistLen int) { + if showYear && showGenre && showComposer && showType { + return queueTitleMaxLen, queueAlbumMaxLen, queueArtistMaxLen + } + if width <= 0 { + return queueTitleCompactMaxLen, queueAlbumCompactMaxLen, queueArtistCompactMaxLen + } + fixed := 2 + 3 + 8 + 2 + if lyricsActive { + fixed += 3 + } + if metadataActive { + fixed += 7 + 4 + 8 + } + if showYear { + fixed += 6 + } + if showGenre { + fixed += 11 + } + if showComposer { + fixed += 16 + } + if showType { + fixed += 6 + } + avail := width - fixed + if avail <= 0 { + return 12, 8, 12 + } + // Distribute available width proportionally: Title ~38%, Album ~26%, Artist ~36% + // Subtract 2 per column for queueColumnGap + tLen := (avail*38)/100 - 2 + aLen := (avail*26)/100 - 2 + arLen := (avail*36)/100 - 2 + + if tLen < 12 { + tLen = 12 + } + if aLen < 8 { + aLen = 8 + } + if arLen < 12 { + arLen = 12 + } + + if tLen > queueTitleMaxLen { + tLen = queueTitleMaxLen + } + if aLen > queueAlbumMaxLen { + aLen = queueAlbumMaxLen + } + if arLen > queueArtistMaxLen { + arLen = queueArtistMaxLen + } + + return tLen, aLen, arLen +} + func (q *queuePanel) render(curID int) { q.table.Clear() lyricsActive := q.app.musicDir != "" metadataActive := q.app.metaDB != nil - cols := newQueueColumns(lyricsActive, metadataActive) + _, _, w, _ := q.table.GetRect() + q.lastRenderedWidth = w + + showYear, showGenre, showComposer, showType := queueOptionalColumns(w, lyricsActive, metadataActive) + cols := newQueueColumns(lyricsActive, metadataActive, showYear, showGenre, showComposer, showType) q.cols = cols setQueueHeader(q.table, cols) // lrcDirs/txtDirs cache internal/lyrics.LRCCandidates/Candidates per @@ -333,6 +486,9 @@ func (q *queuePanel) render(curID int) { // re-listing it (once per format) more than once per directory. lrcDirs := map[string]map[string]string{} txtDirs := map[string]map[string]string{} + + titleMaxLen, albumMaxLen, artistMaxLen := queueColumnTruncation(w, lyricsActive, metadataActive, showYear, showGenre, showComposer, showType) + for i, s := range q.songs { row := i + queueHeaderRows marker := " " @@ -343,7 +499,7 @@ func (q *queuePanel) render(curID int) { if title == "" { title = baseName(s.File) } - titleText := truncateWithEllipsis(title, queueTitleMaxLen) + titleText := truncateWithEllipsis(title, titleMaxLen) q.table.SetCell(row, 0, tview.NewTableCell(marker)) q.table.SetCell(row, 1, tview.NewTableCell(fmt.Sprintf("%3d", i+1))) q.table.SetCell(row, cols.title, tview.NewTableCell(titleText+queueColumnGap). @@ -358,12 +514,24 @@ func (q *queuePanel) render(curID int) { // should only take to contain the icon"). q.table.SetCell(row, cols.lyr, tview.NewTableCell(lyricsCellText(q.lyricsPresence(s.File, lrcDirs, txtDirs)))) } - q.table.SetCell(row, cols.album, tview.NewTableCell(truncateWithEllipsis(s.Album, queueAlbumMaxLen)+queueColumnGap)) - q.table.SetCell(row, cols.artist, tview.NewTableCell(truncateWithEllipsis(s.Artist, queueArtistMaxLen)+queueColumnGap)) - q.table.SetCell(row, cols.year, tview.NewTableCell(yearFromDate(s.Date)+queueColumnGap)) - q.table.SetCell(row, cols.genre, tview.NewTableCell(truncateWithEllipsis(s.Genre, queueGenreMaxLen)+queueColumnGap)) - q.table.SetCell(row, cols.composer, tview.NewTableCell(truncateWithEllipsis(s.Composer, queueComposerMaxLen)+queueColumnGap). - SetExpansion(1)) + q.table.SetCell(row, cols.album, tview.NewTableCell(truncateWithEllipsis(s.Album, albumMaxLen)+queueColumnGap)) + + artistCell := tview.NewTableCell(truncateWithEllipsis(s.Artist, artistMaxLen) + queueColumnGap) + if cols.composer < 0 { + artistCell.SetExpansion(1) + } + q.table.SetCell(row, cols.artist, artistCell) + + if cols.year >= 0 { + q.table.SetCell(row, cols.year, tview.NewTableCell(yearFromDate(s.Date)+queueColumnGap)) + } + if cols.genre >= 0 { + q.table.SetCell(row, cols.genre, tview.NewTableCell(truncateWithEllipsis(s.Genre, queueGenreMaxLen)+queueColumnGap)) + } + if cols.composer >= 0 { + q.table.SetCell(row, cols.composer, tview.NewTableCell(truncateWithEllipsis(s.Composer, queueComposerMaxLen)+queueColumnGap). + SetExpansion(1)) + } if cols.playcount >= 0 || cols.mark >= 0 || cols.rating >= 0 { // Whatever's cached so far (possibly the zero-value Track, if // the background fetch below hasn't landed yet) -- never a @@ -379,7 +547,9 @@ func (q *queuePanel) render(curID int) { q.table.SetCell(row, cols.rating, ratingCell(meta.Rating)) } } - q.table.SetCell(row, cols.typ, formatTagCell(s.File)) + if cols.typ >= 0 { + q.table.SetCell(row, cols.typ, formatTagCell(s.File)) + } q.table.SetCell(row, cols.duration, tview.NewTableCell(FormatDuration(s.Duration)).SetAlign(tview.AlignRight)) } q.table.SetTitle(fmt.Sprintf(" Queue (%d) ", len(q.songs))) diff --git a/internal/ui/queue_lyrics_test.go b/internal/ui/queue_lyrics_test.go index 40940ad..5d0aa16 100644 --- a/internal/ui/queue_lyrics_test.go +++ b/internal/ui/queue_lyrics_test.go @@ -18,6 +18,7 @@ import ( func newTestAppWithMusicDir(musicDir string) *App { a := &App{tv: tview.NewApplication(), musicDir: musicDir} a.build() + a.queue.table.SetRect(0, 0, 150, 40) return a } @@ -151,7 +152,7 @@ func TestQueueRenderShowsLyricsIconInLyrColumnOnlyWhenAvailable(t *testing.T) { {ID: 2, Title: "Without Lyrics", File: "artist/Without Lyrics.mp3"}, } a.queue.render(-1) - lyrCol := newQueueColumns(true, false).lyr + lyrCol := a.queue.cols.lyr if got, want := a.queue.table.GetCell(queueHeaderRows, lyrCol).Text, txtTickText(); got != want { t.Errorf("Lyr cell for the track with a .txt = %q, want %q", got, want) @@ -188,7 +189,7 @@ func TestQueueRenderShowsBothTicksWhenBothFormatsPresent(t *testing.T) { a := newTestAppWithMusicDir(dir) a.queue.songs = []mpdclient.Song{{ID: 1, Title: "Track", File: "artist/Track.mp3"}} a.queue.render(-1) - lyrCol := newQueueColumns(true, false).lyr + lyrCol := a.queue.cols.lyr got := a.queue.table.GetCell(queueHeaderRows, lyrCol).Text want := lrcTickText() + txtTickText() @@ -212,7 +213,7 @@ func TestQueueRenderRechecksLyricsOnEveryRender(t *testing.T) { a := newTestAppWithMusicDir(dir) a.queue.songs = []mpdclient.Song{{ID: 1, Title: "Track", File: "artist/Track.mp3"}} a.queue.render(-1) - lyrCol := newQueueColumns(true, false).lyr + lyrCol := a.queue.cols.lyr if got := a.queue.table.GetCell(queueHeaderRows, lyrCol).Text; got != "" { t.Fatalf("setup: Lyr cell = %q, want empty (no lyrics file yet)", got) diff --git a/internal/ui/queue_test.go b/internal/ui/queue_test.go index ca9f03d..0579d7a 100644 --- a/internal/ui/queue_test.go +++ b/internal/ui/queue_test.go @@ -3,6 +3,7 @@ package ui import ( "strings" "testing" + "time" "unicode/utf8" "github.com/gdamore/tcell/v2" @@ -262,21 +263,21 @@ func TestQueueRenderShowsDefaultUnratedUnmarkedRow(t *testing.T) { // lyr == -1 is the "no such column" sentinel render()/setQueueHeader // check before ever touching that index. func TestNewQueueColumnsOmitsLyrWhenInactive(t *testing.T) { - cols := newQueueColumns(false, false) + cols := newQueueColumns(false, false, true, true, true, true) if cols.lyr != -1 { t.Errorf("lyr = %d, want -1 (no Lyr column when lyrics is inactive)", cols.lyr) } want := queueColumns{lyr: -1, title: 2, album: 3, artist: 4, year: 5, genre: 6, composer: 7, playcount: -1, mark: -1, rating: -1, typ: 8, duration: 9} if cols != want { - t.Errorf("newQueueColumns(false, false) = %+v, want %+v", cols, want) + t.Errorf("newQueueColumns(false, false, true, true, true, true) = %+v, want %+v", cols, want) } } func TestNewQueueColumnsIncludesLyrWhenActive(t *testing.T) { - cols := newQueueColumns(true, false) + cols := newQueueColumns(true, false, true, true, true, true) want := queueColumns{lyr: 3, title: 2, album: 4, artist: 5, year: 6, genre: 7, composer: 8, playcount: -1, mark: -1, rating: -1, typ: 9, duration: 10} if cols != want { - t.Errorf("newQueueColumns(true, false) = %+v, want %+v", cols, want) + t.Errorf("newQueueColumns(true, false, true, true, true, true) = %+v, want %+v", cols, want) } } @@ -286,18 +287,211 @@ func TestNewQueueColumnsIncludesLyrWhenActive(t *testing.T) { // (i.e. App.metaDB != nil) -- otherwise the layout is identical to Lyr's // own "no such column" omission. func TestNewQueueColumnsIncludesMarkAndRatingWhenMetadataActive(t *testing.T) { - cols := newQueueColumns(false, true) + cols := newQueueColumns(false, true, true, true, true, true) want := queueColumns{lyr: -1, title: 2, album: 3, artist: 4, year: 5, genre: 6, composer: 7, playcount: 8, mark: 9, rating: 10, typ: 11, duration: 12} if cols != want { - t.Errorf("newQueueColumns(false, true) = %+v, want %+v", cols, want) + t.Errorf("newQueueColumns(false, true, true, true, true, true) = %+v, want %+v", cols, want) } } func TestNewQueueColumnsIncludesLyrAndMarkAndRatingTogether(t *testing.T) { - cols := newQueueColumns(true, true) + cols := newQueueColumns(true, true, true, true, true, true) want := queueColumns{lyr: 3, title: 2, album: 4, artist: 5, year: 6, genre: 7, composer: 8, playcount: 9, mark: 10, rating: 11, typ: 12, duration: 13} if cols != want { - t.Errorf("newQueueColumns(true, true) = %+v, want %+v", cols, want) + t.Errorf("newQueueColumns(true, true, true, true, true, true) = %+v, want %+v", cols, want) + } +} + +func TestNewQueueColumnsProgressivePriority(t *testing.T) { + // Level 0: Year, Genre, Composer, Type all omitted + c0 := newQueueColumns(false, false, false, false, false, false) + want0 := queueColumns{lyr: -1, title: 2, album: 3, artist: 4, year: -1, genre: -1, composer: -1, playcount: -1, mark: -1, rating: -1, typ: -1, duration: 5} + if c0 != want0 { + t.Errorf("level 0 columns = %+v, want %+v", c0, want0) + } + + // Level 1: Year only (Priority 1) + c1 := newQueueColumns(false, false, true, false, false, false) + want1 := queueColumns{lyr: -1, title: 2, album: 3, artist: 4, year: 5, genre: -1, composer: -1, playcount: -1, mark: -1, rating: -1, typ: -1, duration: 6} + if c1 != want1 { + t.Errorf("level 1 (Year) columns = %+v, want %+v", c1, want1) + } + + // Level 2: Year + Genre (Priority 1 + 2) + c2 := newQueueColumns(false, false, true, true, false, false) + want2 := queueColumns{lyr: -1, title: 2, album: 3, artist: 4, year: 5, genre: 6, composer: -1, playcount: -1, mark: -1, rating: -1, typ: -1, duration: 7} + if c2 != want2 { + t.Errorf("level 2 (Year+Genre) columns = %+v, want %+v", c2, want2) + } + + // Level 3: Year + Genre + Composer (Priority 1 + 2 + 3) + c3 := newQueueColumns(false, false, true, true, true, false) + want3 := queueColumns{lyr: -1, title: 2, album: 3, artist: 4, year: 5, genre: 6, composer: 7, playcount: -1, mark: -1, rating: -1, typ: -1, duration: 8} + if c3 != want3 { + t.Errorf("level 3 (Year+Genre+Composer) columns = %+v, want %+v", c3, want3) + } + + // Level 4: Full + Type (Priority 1 + 2 + 3 + 4) with metadata and lyrics + c4 := newQueueColumns(true, true, true, true, true, true) + want4 := queueColumns{lyr: 3, title: 2, album: 4, artist: 5, year: 6, genre: 7, composer: 8, playcount: 9, mark: 10, rating: 11, typ: 12, duration: 13} + if c4 != want4 { + t.Errorf("level 4 (Full) columns = %+v, want %+v", c4, want4) + } +} + +func TestQueueOptionalColumnsProgressiveBreakpoints(t *testing.T) { + // Narrow screen (e.g. 70 runes with metadata & lyrics active) + y, g, c, ty := queueOptionalColumns(70, true, true) + if y || g || c || ty { + t.Errorf("width 70: got (year=%v, genre=%v, composer=%v, type=%v), want all false", y, g, c, ty) + } + + // 1080p @ 1.5x scale (approx 95 runes with metadata & lyrics active) + y, g, c, ty = queueOptionalColumns(95, true, true) + if y || g || c || ty { + t.Errorf("width 95: got (year=%v, genre=%v, composer=%v, type=%v), want all false", y, g, c, ty) + } + + // Medium screen with room for Year (Priority 1) + y, g, c, ty = queueOptionalColumns(115, true, true) + if !y || g || c || ty { + t.Errorf("width 115: got (year=%v, genre=%v, composer=%v, type=%v), want (true, false, false, false)", y, g, c, ty) + } + + // Medium-wide screen with room for Year + Genre (Priority 1 + 2) + y, g, c, ty = queueOptionalColumns(125, true, true) + if !y || !g || c || ty { + t.Errorf("width 125: got (year=%v, genre=%v, composer=%v, type=%v), want (true, true, false, false)", y, g, c, ty) + } + + // Wide screen with room for Year + Genre + Composer (Priority 1 + 2 + 3) + y, g, c, ty = queueOptionalColumns(140, true, true) + if !y || !g || !c || ty { + t.Errorf("width 140: got (year=%v, genre=%v, composer=%v, type=%v), want (true, true, true, false)", y, g, c, ty) + } + + // Ultra-wide screen with room for all columns including Type (Priority 1 + 2 + 3 + 4) + y, g, c, ty = queueOptionalColumns(150, true, true) + if !y || !g || !c || !ty { + t.Errorf("width 150: got (year=%v, genre=%v, composer=%v, type=%v), want (true, true, true, true)", y, g, c, ty) + } +} + +func TestQueueHeaderRowCompactOmitsYearGenreComposer(t *testing.T) { + a := newTestApp() + a.queue.table.SetRect(0, 0, 80, 40) + a.queue.render(-1) + + wantHeaders := []struct { + col int + text string + align int + }{ + {0, "", tview.AlignLeft}, + {1, "", tview.AlignLeft}, + {2, "Title" + queueColumnGap, tview.AlignLeft}, + {3, "Album" + queueColumnGap, tview.AlignLeft}, + {4, "Artist" + queueColumnGap, tview.AlignLeft}, + {5, "Duration", tview.AlignRight}, + } + for _, w := range wantHeaders { + cell := a.queue.table.GetCell(0, w.col) + if cell.Text != w.text { + t.Errorf("header col %d text = %q, want %q", w.col, cell.Text, w.text) + } + if cell.Align != w.align { + t.Errorf("header col %d align = %d, want %d", w.col, cell.Align, w.align) + } + } + if a.queue.table.GetCell(0, 4).Expansion != 1 { + t.Errorf("Artist header cell in compact mode has expansion %d, want 1", a.queue.table.GetCell(0, 4).Expansion) + } +} + +func TestQueueRenderCompactOmitsYearGenreComposerAndExpandsArtist(t *testing.T) { + a := newTestApp() + a.queue.table.SetRect(0, 0, 80, 40) + a.queue.songs = []mpdclient.Song{{ + ID: 1, + Title: "A Very Long Song Title Exceeding Max", + Album: "A Very Long Album Name Exceeding Max", + Artist: "A Very Long Artist Name Exceeding Max", + Date: "1999", + Genre: "Rock", + Composer: "Composer Name", + File: "artist/track.mp3", + Duration: 180 * time.Second, + }} + a.queue.render(-1) + + row := queueHeaderRows + showY, showG, showC, showTy := queueOptionalColumns(80, false, false) + titleLen, albumLen, artistLen := queueColumnTruncation(80, false, false, showY, showG, showC, showTy) + wantTitle := truncateWithEllipsis("A Very Long Song Title Exceeding Max", titleLen) + queueColumnGap + if got := a.queue.table.GetCell(row, 2).Text; got != wantTitle { + t.Errorf("compact title cell = %q, want %q", got, wantTitle) + } + wantAlbum := truncateWithEllipsis("A Very Long Album Name Exceeding Max", albumLen) + queueColumnGap + if got := a.queue.table.GetCell(row, 3).Text; got != wantAlbum { + t.Errorf("compact album cell = %q, want %q", got, wantAlbum) + } + wantArtist := truncateWithEllipsis("A Very Long Artist Name Exceeding Max", artistLen) + queueColumnGap + if got := a.queue.table.GetCell(row, 4).Text; got != wantArtist { + t.Errorf("compact artist cell = %q, want %q", got, wantArtist) + } + if a.queue.table.GetCell(row, 4).Expansion != 1 { + t.Errorf("Artist data cell in compact mode has expansion %d, want 1", a.queue.table.GetCell(row, 4).Expansion) + } + if got := a.queue.table.GetCell(row, 5).Text; got != "3:00" { + t.Errorf("compact duration cell = %q, want %q", got, "3:00") + } +} + +func TestQueueColumnTruncationAcrossDifferentWidths(t *testing.T) { + // Full layout on wide screens + tLen, aLen, arLen := queueColumnTruncation(150, true, true, true, true, true, true) + if tLen != queueTitleMaxLen || aLen != queueAlbumMaxLen || arLen != queueArtistMaxLen { + t.Errorf("wide full truncation = (%d, %d, %d), want (%d, %d, %d)", tLen, aLen, arLen, queueTitleMaxLen, queueAlbumMaxLen, queueArtistMaxLen) + } + + // 1080p @ 1.5x scale (approx 95 width with metadata and lyrics active) + showY, showG, showC, showTy := queueOptionalColumns(95, true, true) + tLen, aLen, arLen = queueColumnTruncation(95, true, true, showY, showG, showC, showTy) + // Fixed width: 2(marker) + 3(pos) + 3(lyr) + 7(plays) + 4(mark) + 8(rating) + 8(duration) + 2(border) = 37 + // Text width: (tLen+2) + (aLen+2) + (arLen+2) + totalWidth := 37 + (tLen + 2) + (aLen + 2) + (arLen + 2) + if totalWidth > 95 { + t.Errorf("95-width queue total column width = %d, exceeds available 95", totalWidth) + } + + // Very narrow width (e.g. 60 width) + tLen, aLen, arLen = queueColumnTruncation(60, true, true, false, false, false, false) + if tLen < 12 || aLen < 8 || arLen < 12 { + t.Errorf("narrow truncation dropped below floor: (%d, %d, %d)", tLen, aLen, arLen) + } +} + +func TestQueueDynamicResizeOnDraw(t *testing.T) { + a := newTestApp() + a.queue.table.SetRect(0, 0, 150, 40) + a.queue.render(-1) + if a.queue.cols.year < 0 || a.queue.cols.genre < 0 || a.queue.cols.composer < 0 { + t.Errorf("initial wide width 150: expected full columns, got year=%d, genre=%d, composer=%d", a.queue.cols.year, a.queue.cols.genre, a.queue.cols.composer) + } + + // Shrink below threshold and trigger Draw on table (which executes SetDrawFunc) + screen := tcell.NewSimulationScreen("") + if err := screen.Init(); err != nil { + t.Fatalf("screen.Init: %v", err) + } + defer screen.Fini() + screen.SetSize(80, 24) + + a.queue.table.SetRect(0, 0, 80, 24) + a.queue.table.Draw(screen) + + if a.queue.cols.year >= 0 || a.queue.cols.genre >= 0 || a.queue.cols.composer >= 0 { + t.Errorf("after shrinking to width 80: expected optional columns hidden, got year=%d, genre=%d, composer=%d", a.queue.cols.year, a.queue.cols.genre, a.queue.cols.composer) } } diff --git a/internal/ui/trackmetadata_test.go b/internal/ui/trackmetadata_test.go index 270f59d..3ab779e 100644 --- a/internal/ui/trackmetadata_test.go +++ b/internal/ui/trackmetadata_test.go @@ -30,6 +30,7 @@ func newTestAppWithMetaDB(t *testing.T) *App { a := &App{tv: tview.NewApplication(), metaDB: db, playCountedSongID: -1} a.build() + a.queue.table.SetRect(0, 0, 150, 40) a.runAsync = func(work func() error, onSuccess func()) { if err := work(); err != nil { a.showError(err)