diff --git a/CLAUDE.md b/CLAUDE.md index 38635cc..fe2fb59 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -129,6 +129,14 @@ persistence) → GUI (Qt widgets that read the manager and connect to its signal - **`lintunes/gui/`** — `main_window.py` assembles a top `TransportBar` over a horizontal `QSplitter` (`SidebarPanel` | stacked `LibraryView`/`PlaylistView`). + The bar's second row (`transport.HeaderRow`, placed by hand) holds three + things owned elsewhere: the sidebar's Library button (`SidebarPanel.header`, + kept as wide as the sidebar via `track_split`), the shown view's `header` + (search box, playlist name — mounted on the content stack's + `currentChanged`, returned to its view when it goes), and the seek row, + exactly under the now-playing panel. Since the Library button left the + sidebar, `MainWindow` must hand it the first focus, or the playlist tree + takes it and opens its first playlist at startup. `track_table.py` is the shared track grid (drag/drop, copy/paste, drop indicator). `playlist_ops.py::add_tracks_with_dup_check` is the single funnel for every add-to-playlist path. `theme.py` applies the palette (highlight diff --git a/lintunes/__init__.py b/lintunes/__init__.py index 1b24f9b..aec08eb 100644 --- a/lintunes/__init__.py +++ b/lintunes/__init__.py @@ -1,3 +1,3 @@ """LinTunes — iTunes-style music library manager and player for Linux.""" -__version__ = "0.31.2" +__version__ = "0.31.3" diff --git a/lintunes/gui/fit_label.py b/lintunes/gui/fit_label.py new file mode 100644 index 0000000..93a8037 --- /dev/null +++ b/lintunes/gui/fit_label.py @@ -0,0 +1,54 @@ +"""A one-line label that shrinks its text a little before it gives up and +elides — for a playlist name in the header row, where the room left between +the Library button and the seek row depends on the window.""" + +from PyQt6.QtCore import Qt +from PyQt6.QtGui import QFontMetrics +from PyQt6.QtWidgets import QLabel, QSizePolicy + +# How far below its normal size the text may go (points) before eliding. +MAX_SHRINK_PT = 2 + + +class FitLabel(QLabel): + + def __init__(self, text: str = "", parent=None): + super().__init__(parent) + self._full = "" + self._base_pt = 0 + self.setSizePolicy(QSizePolicy.Policy.Ignored, + QSizePolicy.Policy.Preferred) + self.setAlignment(Qt.AlignmentFlag.AlignVCenter + | Qt.AlignmentFlag.AlignLeft) + self.setText(text) + + def setText(self, text: str): + self._full = text or "" + self.setToolTip("") + self._fit() + + def full_text(self) -> str: + return self._full + + def resizeEvent(self, event): + super().resizeEvent(event) + self._fit() + + def _fit(self): + font = self.font() + if not self._base_pt: + self._base_pt = font.pointSize() + room = self.contentsRect().width() - 2 * self.margin() + text = self._full + for pt in range(self._base_pt, self._base_pt - MAX_SHRINK_PT - 1, -1): + font.setPointSize(max(1, pt)) + if QFontMetrics(font).horizontalAdvance(text) <= room: + break + else: + text = QFontMetrics(font).elidedText( + text, Qt.TextElideMode.ElideRight, max(0, room)) + if font != self.font(): + self.setFont(font) + # Elided: the whole name is a hover away. + self.setToolTip(self._full if text != self._full else "") + super().setText(text) diff --git a/lintunes/gui/followed_view.py b/lintunes/gui/followed_view.py index 19c4bee..e6bf522 100644 --- a/lintunes/gui/followed_view.py +++ b/lintunes/gui/followed_view.py @@ -14,8 +14,9 @@ from __future__ import annotations from PyQt6.QtCore import pyqtSignal from PyQt6.QtGui import QColor -from PyQt6.QtWidgets import QLabel, QVBoxLayout, QWidget +from PyQt6.QtWidgets import QVBoxLayout, QWidget +from lintunes.gui.fit_label import FitLabel from lintunes.gui.track_table import TrackTableView from lintunes.models.playlist import PlaylistSettings @@ -45,12 +46,15 @@ class FollowedPlaylistView(QWidget): layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) - self.title = QLabel() + # Shown in the transport bar's header row, not in this layout. + self.title = FitLabel(parent=self) font = self.title.font() font.setBold(True) self.title.setFont(font) - self.title.setContentsMargins(8, 2, 8, 2) - layout.addWidget(self.title) + self.title.setContentsMargins(4, 0, 0, 0) + self.title.hide() + self.header = self.title + self.title.header_owner = self self.table = TrackTableView(playlist_mode=True) self.table.set_read_only(True) self.table.set_content_editable(False) diff --git a/lintunes/gui/friend_mode.py b/lintunes/gui/friend_mode.py index 34e3a6c..464fd80 100644 --- a/lintunes/gui/friend_mode.py +++ b/lintunes/gui/friend_mode.py @@ -33,6 +33,7 @@ from lintunes.cassette.friend_library import ( from lintunes.cassette.state import in_dir from lintunes.gui import cassette_art from lintunes.gui.cassette_art import OFF, ON, PARTIAL +from lintunes.gui.fit_label import FitLabel from lintunes.models.playlist import PlaylistSettings FUZZY_GREY = QColor("#9a9a9a") @@ -73,12 +74,15 @@ class FriendPlaylistPane(QWidget): layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) - self.title = QLabel() + # Shown in the transport bar's header row, not in this layout. + self.title = FitLabel(parent=self) font = self.title.font() font.setBold(True) self.title.setFont(font) - self.title.setContentsMargins(8, 2, 8, 2) - layout.addWidget(self.title) + self.title.setContentsMargins(4, 0, 0, 0) + self.title.hide() + self.header = self.title + self.title.header_owner = self self.table = TrackTableView(playlist_mode=True) self.table.set_read_only(True) self.table.set_content_editable(False) diff --git a/lintunes/gui/library_view.py b/lintunes/gui/library_view.py index 6b3ca8c..0ea8c17 100644 --- a/lintunes/gui/library_view.py +++ b/lintunes/gui/library_view.py @@ -169,6 +169,10 @@ class _BrowserList(QListWidget): return item.text() +# The search box fills the header row's slot up to this width. +SEARCH_MAX_WIDTH = 320 + + class LibraryView(TableSettingsMixin, QWidget): """All-tracks view with the iTunes-style 3-column browser (Ctrl+B).""" @@ -185,24 +189,26 @@ class LibraryView(TableSettingsMixin, QWidget): layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) - # Header strip matching the sidebar's Library button / playlist name - # band, so the browser+tracklist top aligns with the playlist tree. - # It hosts the search box, which thereby sits directly right of the - # Library button across the splitter. - self._top_strip = QWidget() - layout.addWidget(self._top_strip) + # Header strip hosting the search box. Not in this view's layout: + # MainWindow shows the current view's ``header`` in the transport + # bar's header row, right of the Library button, where a playlist's + # name goes (TransportBar.set_view_header). + self._top_strip = QWidget(self) + self._top_strip.hide() + self.header = self._top_strip + self._top_strip.header_owner = self - # Search: a debounced filter box occupying ~1/3 of the width, top - # right (iTunes-style). It narrows the track list and the browser - # columns together. + # Search: a debounced filter box that narrows the track list and the + # browser columns together. self._search_text = "" self._search = QLineEdit() self._search.setPlaceholderText("search") self._search.setClearButtonEnabled(True) + self._search.setMaximumWidth(SEARCH_MAX_WIDTH) strip_row = QHBoxLayout(self._top_strip) - strip_row.setContentsMargins(4, 2, 4, 2) - strip_row.addStretch(2) + strip_row.setContentsMargins(0, 2, 0, 2) strip_row.addWidget(self._search, 1) + strip_row.addStretch(0) self._search_timer = QTimer(self) self._search_timer.setSingleShot(True) diff --git a/lintunes/gui/main_window.py b/lintunes/gui/main_window.py index 59e72c8..87b6792 100644 --- a/lintunes/gui/main_window.py +++ b/lintunes/gui/main_window.py @@ -135,6 +135,18 @@ class MainWindow(QMainWindow): self._content.addWidget(self._playlist_view) self._content.setCurrentWidget(self._library_view) + # The header row under the transport boxes: the Library button over + # the sidebar, then whichever view is showing's own header (search + # box, playlist name), then the seek row. + self._transport.set_sidebar_header(self._sidebar.header) + self._transport.track_split(self._sidebar, self._content) + self._content.currentChanged.connect(self._mount_view_header) + self._mount_view_header() + # Moving the Library button out of the sidebar put it last in the + # focus chain, and a playlist tree that takes the first focus selects + # its first playlist: the window would open on that, not the library. + self._sidebar.library_button.setFocus() + # Bottom status bar, left to right: version/self-update button, then a # centered totals readout. Both are non-permanent widgets, so a # transient showMessage (import/scrobble/error/update text) replaces @@ -298,6 +310,10 @@ class MainWindow(QMainWindow): self._version_button.refresh_theme() self._sidebar.refresh_style() + def _mount_view_header(self, _index=None): + view = self._content.currentWidget() + self._transport.set_view_header(getattr(view, "header", None)) + def apply_ui_metrics(self): metrics = theme.scale_metrics(self._prefs) for view in (self._library_view, self._playlist_view): @@ -305,6 +321,7 @@ class MainWindow(QMainWindow): self._library_view.set_header_height(metrics["header_strip"]) self._playlist_view.set_header_height(metrics["header_strip"]) self._sidebar.apply_metrics(metrics["header_strip"]) + self._transport.set_header_height(metrics["header_strip"]) self.statusBar().setMinimumHeight(metrics["status_height"]) def _show_preferences(self): diff --git a/lintunes/gui/playlist_view.py b/lintunes/gui/playlist_view.py index 4f3c764..62dd512 100644 --- a/lintunes/gui/playlist_view.py +++ b/lintunes/gui/playlist_view.py @@ -1,6 +1,7 @@ -from PyQt6.QtWidgets import QWidget, QVBoxLayout, QLabel, QApplication +from PyQt6.QtWidgets import QWidget, QVBoxLayout, QApplication from PyQt6.QtCore import pyqtSignal +from lintunes.gui.fit_label import FitLabel from lintunes.gui.table_settings import TableSettingsMixin from lintunes.gui.track_table import TrackTableView, parse_tracks_mime from lintunes.gui.playlist_ops import add_tracks_with_dup_check @@ -21,10 +22,17 @@ class PlaylistView(TableSettingsMixin, QWidget): layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) - self._name_label = QLabel() - self._name_label.setStyleSheet("font-weight: bold; padding: 0 8px;") - self._name_label.setFixedHeight(34) - layout.addWidget(self._name_label) + # The playlist's name. Not in this view's layout: it is this view's + # ``header``, shown in the transport bar's header row (see + # TransportBar.set_view_header), shrinking a little when it's long. + self._name_label = FitLabel(parent=self) + font = self._name_label.font() + font.setBold(True) + self._name_label.setFont(font) + self._name_label.setContentsMargins(4, 0, 0, 0) + self._name_label.hide() + self.header = self._name_label + self._name_label.header_owner = self self.table = TrackTableView(playlist_mode=True) layout.addWidget(self.table) diff --git a/lintunes/gui/sidebar.py b/lintunes/gui/sidebar.py index 3694aa5..8d97371 100644 --- a/lintunes/gui/sidebar.py +++ b/lintunes/gui/sidebar.py @@ -60,7 +60,10 @@ class SidebarPanel(QWidget): top_row.addWidget(self.library_button, stretch=1) top_row.addWidget(self.friend_button) self.refresh_style() - layout.addWidget(top) + # Not in this panel's layout: MainWindow lays it into the transport + # bar's header row, over this panel's width (TransportBar.track_split). + self.header = top + top.header_owner = self self._lists = QStackedWidget() self.tree = PlaylistTree(manager) diff --git a/lintunes/gui/transport.py b/lintunes/gui/transport.py index afdaa39..17532dd 100644 --- a/lintunes/gui/transport.py +++ b/lintunes/gui/transport.py @@ -2,9 +2,9 @@ import time from PyQt6.QtWidgets import ( QWidget, QFrame, QHBoxLayout, QVBoxLayout, QLabel, QToolButton, QSlider, - QPushButton, QSizePolicy, QStyle, + QPushButton, QSizePolicy, QStyle, QLayout, ) -from PyQt6.QtCore import Qt, QTimer, pyqtSignal +from PyQt6.QtCore import Qt, QTimer, QEvent, QPoint, QRect, pyqtSignal from PyQt6.QtGui import QColor, QFont from lintunes.tap_tempo import TapTempo @@ -16,10 +16,20 @@ from lintunes import theme ICON_GRAY = QColor("#4A4A4A") -# Height floor for the top control panel: the row of CONTROL_HEIGHT boxes and -# now-playing panel, the seek row hanging under the panel (~17px at medium -# scale), and the 3px margins. Any leftover sits below the seek row. -BAR_HEIGHT = 86 +# Breathing room between the menu bar and the row of boxes. +TOP_MARGIN = 9 +# Between the row of boxes and the header row under it. +ROW_GAP = 4 +# Side margin of the row of boxes — the Library button's own left margin, so +# the transport box and the Library button share a left edge. +SIDE_MARGIN = 8 + +# The now-playing panel grows with the window up to this width; past it the +# extra goes to the gaps between the controls, evenly. +NOW_PLAYING_MAX = 560 +NOW_PLAYING_MIN = 220 +# Floor for each of those gaps. +MIN_GAP = 12 # Shared height for the boxed side controls (transport, shuffle, bpm) and the # visualizer, so they line up. ~midway between their old heights: the stretchy @@ -31,6 +41,10 @@ CONTROL_HEIGHT = 62 # panel (the seek row sits below the panel, not beside the boxes). TOP = Qt.AlignmentFlag.AlignTop +# Height floor for the whole bar: margin, the row of boxes, and the header row +# (Library button · search or playlist name · seek) at the medium UI scale. +BAR_HEIGHT = TOP_MARGIN + CONTROL_HEIGHT + ROW_GAP + 34 + def _box_style(bg: QColor, button: bool = False) -> str: """A transport box. A box that *is* one button (shuffle, bpm) gets the shared button face — emboss, outline, whole-box darkening on hover; the @@ -251,6 +265,73 @@ class BpmButton(QPushButton): self.setText("bpm") +class HeaderRow(QWidget): + """The band under the row of boxes, laid out by hand because its three + parts answer to three different things: the Library button is as wide as + the sidebar below it, the seek row sits exactly under the now-playing + panel above it, and the current view's header (search box or playlist + name) takes what lies between — which is where that view starts.""" + + GAP = 6 + + def __init__(self, seek: QWidget, parent=None): + super().__init__(parent) + self.seek = seek + seek.setParent(self) + self.left: QWidget | None = None + self.view_header: QWidget | None = None + self._split = (0, 0) # sidebar right edge, content left edge + self._panel = QRect() # now-playing panel, in our coordinates + + def set_left(self, widget: QWidget): + self.left = widget + widget.setParent(self) + widget.show() + self.place() + + def set_view_header(self, widget: QWidget | None): + """Show ``widget`` (a view's header) in the middle slot. The one it + replaces goes back to its own view, hidden, so it lives and dies with + that view.""" + old = self.view_header + if old is widget: + return + if old is not None: + old.hide() + old.setParent(getattr(old, "header_owner", None)) + self.view_header = widget + if widget is not None: + widget.setParent(self) + widget.show() + self.place() + + def set_split(self, sidebar_right: int, content_left: int): + self._split = (sidebar_right, content_left) + self.place() + + def set_panel(self, rect: QRect): + self._panel = rect + self.place() + + def resizeEvent(self, event): + super().resizeEvent(event) + self.place() + + def place(self): + h = self.height() + sidebar_right, content_left = self._split + if self.left is not None: + self.left.setGeometry(0, 0, sidebar_right, h) + panel = self._panel + seek_left = max(panel.left(), content_left + self.GAP) + seek_h = min(h, self.seek.sizeHint().height()) + self.seek.setGeometry(seek_left, (h - seek_h) // 2, + max(0, panel.right() + 1 - seek_left), seek_h) + if self.view_header is not None: + self.view_header.setGeometry( + content_left, 0, max(0, seek_left - self.GAP - content_left), h) + + class TransportBar(QWidget): """Top bar: boxed transport buttons and shuffle (left), visualizer, now-playing text, seek slider, boxed bpm tap button (right).""" @@ -266,8 +347,24 @@ class TransportBar(QWidget): self._current_track = None self.setMinimumHeight(BAR_HEIGHT) - layout = QHBoxLayout(self) - layout.setContentsMargins(8, 3, 8, 3) + outer = QVBoxLayout(self) + outer.setContentsMargins(0, TOP_MARGIN, 0, 0) + outer.setSpacing(ROW_GAP) + # The panel's width is set from the bar's (_size_now_playing), so the + # bar's minimum must not come from the panel's current width, or a + # window could never shrink past the width it last had. + outer.setSizeConstraint(QLayout.SizeConstraint.SetNoConstraint) + layout = QHBoxLayout() + layout.setContentsMargins(SIDE_MARGIN, 0, SIDE_MARGIN, 0) + layout.setSpacing(0) + outer.addLayout(layout) + + # Knolled: every control keeps its size and the gaps between them + # are equal, growing together once the now-playing panel is as wide + # as it gets. + def gap(): + layout.addSpacing(MIN_GAP) + layout.addStretch(1) # Rounded button boxes carry a user-tunable grayscale fill, decoupled # from the stripe color they used to share via palette(alternate-base). @@ -287,6 +384,7 @@ class TransportBar(QWidget): height=CONTROL_HEIGHT, split=True) self._boxes.append(transport_box) layout.addWidget(transport_box, alignment=TOP) + gap() self._shuffle_btn = FlashButton("shuffle") self._shuffle_btn.setMinimumWidth(50) @@ -296,12 +394,12 @@ class TransportBar(QWidget): height=CONTROL_HEIGHT, split=True) self._boxes.append(shuffle_box) layout.addWidget(shuffle_box, alignment=TOP) + gap() - layout.addSpacing(10) self._visualizer = VisualizerWidget(player, prefs) self._visualizer.setFixedHeight(CONTROL_HEIGHT) layout.addWidget(self._visualizer, alignment=TOP) - layout.addSpacing(10) + gap() # Master output volume: a slim horizontal slider in its own slot # between the visualizer and the timeline. The HBox centers it @@ -325,10 +423,7 @@ class TransportBar(QWidget): self._volume_save_timer.timeout.connect(self._save_volume) self._volume_slider.valueChanged.connect(self._on_volume_changed) layout.addWidget(self._volume_slider, alignment=TOP) - layout.addSpacing(10) - - center = QVBoxLayout() - center.setSpacing(0) + gap() # White rounded now-playing panel: holds only the song title and the # artist/album line, centered, filling the zone above the seek row. @@ -351,7 +446,9 @@ class TransportBar(QWidget): np.addStretch(1) self._apply_now_playing_font() - seek_row = QHBoxLayout() + seek = QWidget() + seek_row = QHBoxLayout(seek) + seek_row.setContentsMargins(0, 0, 0, 0) self._elapsed_label = QLabel("") self._elapsed_label.setFixedWidth(50) self._remaining_label = QLabel("") @@ -366,12 +463,15 @@ class TransportBar(QWidget): # The now-playing panel is exactly as tall as the boxes and shares # their top edge, so boxes, visualizer, panel and bpm read as one row; - # the seek row hangs underneath the panel and any slack goes below it. + # the seek row hangs underneath the panel, in the header row. self._now_playing.setFixedHeight(CONTROL_HEIGHT) - center.addWidget(self._now_playing) - center.addLayout(seek_row) - center.addStretch(1) - layout.addLayout(center, stretch=1) + # Its width is set by _size_now_playing: all the room the controls + # and minimal gaps leave, up to NOW_PLAYING_MAX; the gaps share the + # rest. + self._now_playing.setFixedWidth(NOW_PLAYING_MIN) + layout.addWidget(self._now_playing, alignment=TOP) + self._now_playing.installEventFilter(self) + gap() # bpm tap button lives on the far right as its own box, top-aligned # with the now-playing panel like the transport boxes — kept out of @@ -382,6 +482,16 @@ class TransportBar(QWidget): hug=True, height=CONTROL_HEIGHT, split=True) self._boxes.append(bpm_box) layout.addWidget(bpm_box, alignment=TOP) + self._fixed_controls = (transport_box, shuffle_box, self._visualizer, + self._volume_slider, bpm_box) + + # Library button · the current view's header · seek row. MainWindow + # hands over the first two (see set_sidebar_header / set_view_header) + # and says where the sidebar ends (track_split). + self._header_row = HeaderRow(seek) + self._header_row.setFixedHeight(34) + outer.addWidget(self._header_row) + self._split_widgets: tuple = () # Wiring self._prev_btn.clicked.connect(player.previous) @@ -401,6 +511,75 @@ class TransportBar(QWidget): self._visualizer.cast_stop_requested.connect( lambda: cast.disconnect()) + # ---- the header row ---- + + def set_sidebar_header(self, widget: QWidget): + """The Library button strip, laid over the sidebar's width.""" + self._header_row.set_left(widget) + + def set_view_header(self, widget: QWidget | None): + """The shown view's header (search box, playlist name), between the + Library button and the seek row.""" + self._header_row.set_view_header(widget) + + def set_header_height(self, height: int): + self._header_row.setFixedHeight(height) + + def track_split(self, sidebar: QWidget, content: QWidget): + """Keep the header row's Library button as wide as ``sidebar`` and + the view header starting where ``content`` does, as the splitter or + window moves them.""" + self._split_widgets = (sidebar, content) + for widget in self._split_widgets: + widget.installEventFilter(self) + self._place_header() + + def eventFilter(self, obj, event): + if event.type() in (QEvent.Type.Resize, QEvent.Type.Move): + self._place_header() + return super().eventFilter(obj, event) + + def resizeEvent(self, event): + super().resizeEvent(event) + self._size_now_playing() + self._place_header() + + def _controls_width(self) -> int: + """Everything in the row of boxes but the now-playing panel, with + each gap at its floor.""" + return (2 * SIDE_MARGIN + # A fixed width shows in the minimum, not the size hint. + + sum(max(w.sizeHint().width(), w.minimumWidth()) + for w in self._fixed_controls) + + len(self._fixed_controls) * MIN_GAP) + + def _size_now_playing(self): + room = self.width() - self._controls_width() + self._now_playing.setFixedWidth( + max(NOW_PLAYING_MIN, min(NOW_PLAYING_MAX, room))) + + def minimumSizeHint(self): + hint = super().minimumSizeHint() + hint.setWidth(self._controls_width() + NOW_PLAYING_MIN) + return hint + + def sizeHint(self): + hint = super().sizeHint() + hint.setWidth(self._controls_width() + NOW_PLAYING_MAX) + return hint + + def _place_header(self): + row = self._header_row + + def x_of(widget, x=0): # siblings, not ancestors: go via global + return row.mapFromGlobal(widget.mapToGlobal(QPoint(x, 0))).x() + + row.set_panel(QRect(x_of(self._now_playing), 0, + self._now_playing.width(), row.height())) + if self._split_widgets: + sidebar, content = self._split_widgets + row.set_split(x_of(sidebar, sidebar.width()), x_of(content)) + def refresh_theme(self): """Re-apply everything driven by Preferences: button glyphs (highlight color), the tunable grayscale button boxes, and the now-playing panel diff --git a/tests/test_round62.py b/tests/test_round62.py index d51a8f5..4028af6 100644 --- a/tests/test_round62.py +++ b/tests/test_round62.py @@ -23,4 +23,5 @@ def test_every_control_shares_the_now_playing_row(qapp, tmp_path): def test_seek_row_hangs_under_the_panel(qapp, tmp_path): bar = _build_bar(qapp, tmp_path) - assert bar._slider.geometry().top() > bar._now_playing.geometry().bottom() + slider_top = bar._slider.mapTo(bar, bar._slider.rect().topLeft()).y() + assert slider_top > bar._now_playing.geometry().bottom() diff --git a/tests/test_round69.py b/tests/test_round69.py index 5466348..bdf8b59 100644 --- a/tests/test_round69.py +++ b/tests/test_round69.py @@ -1,252 +1,115 @@ -"""Round 69: followed playlists in my sidebar. +"""Round 69: the header row moves up into the seek row's band. -* A followed friend playlist shows in my sidebar with a purple UFO — mixed in - alphabetically, or inside a folder named after the friend (a per-friend - setting) — and is never a Playlist of mine, so it stays out of the - library and its conflict merge. -* It plays from the cache: Player queues opaque keys - ("cassette::") resolved through Cassette. Plays scrobble - but don't count in my play journal. Songs not here yet are grey and skipped. -* Its cassette column is hidden by default; pressing one takes the song into - my library — straight from the cache if it's there, otherwise by asking. -* Unfollowing frees the cache; requests never re-ask for cached songs. +The Library button, the view's header (search box or playlist name) and the +seek row share one row under the transport boxes: the Library button as wide +as the sidebar, the header where the view starts, the seek row exactly under +the now-playing panel. The panel stops at NOW_PLAYING_MAX and the gaps between +the controls share what's left. """ -import shutil -from pathlib import Path -from unittest.mock import MagicMock -import pytest +from PyQt6.QtCore import QPoint -from lintunes.cassette import delivery, matching, publish, share -from lintunes.cassette import requests as req -from lintunes.cassette.service import CassetteService -from lintunes.cassette.state import ( - CassetteState, Friend, Selection, cache_dir, in_dir, out_dir, -) -from lintunes.gui.followed_view import parse_key, track_key -from lintunes.models import Playlist, Track +from lintunes.gui.fit_label import FitLabel +from lintunes.gui.main_window import MainWindow +from lintunes.gui.transport import NOW_PLAYING_MAX, NOW_PLAYING_MIN +from lintunes.library_manager import LibraryManager from lintunes.models.library import Library - -TOKEN = "0123456789abcdef" +from lintunes.models.playlist import Playlist +from lintunes.models.track import Track +from lintunes.preferences import Preferences -def _friend_share(root, mp3_file, cached=(10,)): - theirs = Library() - for tid, name in ((10, "One"), (11, "Two"), (12, "Three")): - theirs.tracks[tid] = Track(track_id=tid, name=name, artist="Sam's Band", - album="Demo", total_time=1000, size=100) - theirs.playlists["P"] = Playlist(name="Road Trip", persistent_id="P", - track_ids=[11, 10]) - share.publish_share(in_dir(root, TOKEN), theirs, - Selection(all_library=True, all_playlists=True)) - publish.publish_intro(in_dir(root, TOKEN), "Sam", None) - cache = cache_dir(root, TOKEN) - cache.mkdir(parents=True, exist_ok=True) - for tid in cached: - shutil.copyfile(mp3_file, cache / f"{tid}.mp3") - return theirs +def _window(qapp, tmp_path, width=1280): + library = Library(tracks={1: Track(track_id=1, name="One")}) + library.playlists["abcd1234"] = Playlist( + persistent_id="abcd1234", name="Road Trip", track_ids=[1]) + window = MainWindow(LibraryManager(library, tmp_path), Preferences(tmp_path)) + window.resize(width, 500) + window.show() + qapp.processEvents() + return window -# ---- keys and the player ---- - -class TestPlayer: - def test_keys(self): - assert parse_key(track_key(TOKEN, 12)) == (TOKEN, 12) - assert parse_key("12") is None and parse_key(12) is None - - def test_friend_track_scrobbles_but_isnt_counted(self, qapp, tmp_path, mp3_file): - from lintunes.player import Player - manager = MagicMock() - player = Player(manager) - friend_track = Track(track_id=5, name="Theirs", location=str(mp3_file)) - player.track_resolver = lambda key: friend_track if key == "cassette:t:5" else None - finished = [] - player.track_finished.connect(finished.append) - player._current_track = friend_track - player._current_key = "cassette:t:5" - player._note_finished(friend_track) - player._note_finished(friend_track) - manager.record_play.assert_not_called() - assert finished == [friend_track] # Last.fm still hears it - assert player._resolve("cassette:t:5") is friend_track - assert player._resolve("cassette:t:6") is None +def _x(widget, window, x=0): + return widget.mapTo(window, QPoint(x, 0)).x() -# ---- the service never re-asks for cached songs ---- - -class TestRequests: - def test_followed_songs_already_cached_arent_requested(self, qapp, tmp_path, mp3_file): - root = tmp_path / "root" - _friend_share(root, mp3_file, cached=(10,)) - service = CassetteService(root=root, config_loader=lambda: {}) - service._state.friends[TOKEN] = Friend(token=TOKEN, device_id="D", name="Sam") - service.save_requests(TOKEN, {12}, ["P"]) - assert req.read_requests(out_dir(root, TOKEN)) == {11, 12} - - def test_unfollowed_cache_is_pruned(self, tmp_path, mp3_file): - root = tmp_path / "root" - _friend_share(root, mp3_file, cached=(10, 12)) - friend = Friend(token=TOKEN, device_id="D", name="Sam", followed=["P"]) - plan = delivery.plan_receive(friend, root, matching.LibraryIndex([])) - assert [p.name for p in plan.cache_prune] == ["12.mp3"] - friend.followed = [] - plan = delivery.plan_receive(friend, root, matching.LibraryIndex([])) - assert sorted(p.name for p in plan.cache_prune) == ["10.mp3", "12.mp3"] - delivery.run_plans([], [plan], None) - assert list(cache_dir(root, TOKEN).iterdir()) == [] - - def test_no_playlists_file_prunes_nothing(self, tmp_path, mp3_file): - root = tmp_path / "root" - _friend_share(root, mp3_file, cached=(10,)) - (in_dir(root, TOKEN) / share.PLAYLISTS_FILE).unlink() - friend = Friend(token=TOKEN, device_id="D", name="Sam") - plan = delivery.plan_receive(friend, root, matching.LibraryIndex([])) - assert plan.cache_prune == [] +def _gx(widget, x=0): + return widget.mapToGlobal(QPoint(x, 0)).x() -# ---- the sidebar and the view ---- - -class _Window: - def __init__(self, qapp): - from PyQt6.QtWidgets import QMainWindow, QStackedWidget - from lintunes.gui.sidebar import SidebarPanel - self.main = QMainWindow() - self.content_stack = QStackedWidget() - self.main.setCentralWidget(self.content_stack) - manager = MagicMock() - manager.library.playlists = { - "M1": Playlist(name="Morning", persistent_id="M1"), - "M2": Playlist(name="Zebra", persistent_id="M2")} - self.sidebar = SidebarPanel(manager, MagicMock()) - self.player = MagicMock() - self.player.current_key = None - self.player.is_playing.return_value = False - self.now_playing_context = "" - self.played = [] - self.totals = 0 - - def statusBar(self): - return self.main.statusBar() - - def play_keys(self, keys, start, context): - self.played.append((keys, start, context)) - - def update_totals(self): - self.totals += 1 - - def show_my_library(self): - pass +def test_startup_still_opens_on_the_library(qapp, tmp_path): + # The moved Library button fell to the end of the focus chain, the tree + # took the first focus and selected its first playlist. + window = _window(qapp, tmp_path) + assert window._content.currentWidget() is window._library_view + assert window._sidebar.library_button.isChecked() -@pytest.fixture -def followed(qapp, tmp_path, mp3_file): - from lintunes.gui.cassette_ui import CassetteUi - from lintunes.library_manager import LibraryManager - root = tmp_path / "root" - _friend_share(root, mp3_file, cached=(10,)) - service = CassetteService(root=root, config_loader=lambda: {}) - service._state.friends[TOKEN] = Friend(token=TOKEN, device_id="D", name="Sam", - followed=["P"], layout="mixed") - manager = LibraryManager(Library(), tmp_path / "data") - manager.organize_root = lambda: tmp_path / "Music" - window = _Window(qapp) - ui = CassetteUi(window.main, MagicMock(), manager) - ui._window = window - ui.service = service - ui._hook_window() - return ui, window, service, manager +def test_library_button_spans_the_sidebar(qapp, tmp_path): + window = _window(qapp, tmp_path) + header = window.sidebar.header + assert header.parentWidget() is not window.sidebar + assert _gx(header) == _gx(window.sidebar) + assert header.width() == window.sidebar.width() -class TestSidebar: - def test_mixed_in_alphabetically(self, followed): - ui, window, _, _ = followed - tree = window.sidebar.tree - names = [tree.topLevelItem(i).text(0) for i in range(tree.topLevelItemCount())] - assert names == ["Morning", "Road Trip", "Zebra"] +def test_search_then_title_sit_where_the_view_starts(qapp, tmp_path): + window = _window(qapp, tmp_path) + search_strip = window._library_view.header + assert search_strip.isVisible() + assert _gx(search_strip) == _gx(window._content) - def test_in_a_friend_folder(self, followed): - ui, window, service, _ = followed - service._state.friends[TOKEN].layout = "folder" - ui._refresh_followed() - tree = window.sidebar.tree - names = [tree.topLevelItem(i).text(0) for i in range(tree.topLevelItemCount())] - assert names == ["Morning", "Sam", "Zebra"] - folder = tree.topLevelItem(1) - assert folder.child(0).text(0) == "Road Trip" - - def test_followed_items_arent_editable_or_draggable(self, followed): - from PyQt6.QtCore import Qt - _, window, _, _ = followed - tree = window.sidebar.tree - item = tree._find_item(f"cassette:{TOKEN}:P") - assert not item.flags() & Qt.ItemFlag.ItemIsEditable - assert not item.flags() & Qt.ItemFlag.ItemIsDragEnabled + window._show_playlist("abcd1234") + qapp.processEvents() + label = window._playlist_view.header + assert label.isVisible() and label.full_text() == "Road Trip" + assert _gx(label) == _gx(window._content) + # The search strip went home to its view, hidden. + assert not search_strip.isVisible() + assert search_strip.parentWidget() is window._library_view -class TestFollowedView: - def test_cached_plays_uncached_is_grey(self, followed): - ui, window, _, _ = followed - ui.show_followed(f"cassette:{TOKEN}:P") - view = ui.followed_view - assert [t.track_id for t in view.table.model_.tracks] == [11, 10] - assert view._foreground(view.table.model_.tracks[0]) is not None # 11 not here - assert view._foreground(view.table.model_.tracks[1]) is None # 10 cached - view._on_double_click(view.table.model_.index(1, 0)) - assert window.played == [([track_key(TOKEN, 10)], 0, - f"followed:cassette:{TOKEN}:P")] - view._on_double_click(view.table.model_.index(0, 0)) # not here yet - assert len(window.played) == 1 - assert "last synced" in view.status_suffix() - - def test_cassette_column_hidden_by_default(self, followed): - from lintunes.gui.track_table import CASSETTE_FIELD - ui, _, _, _ = followed - ui.show_followed(f"cassette:{TOKEN}:P") - assert CASSETTE_FIELD not in ui.followed_view.table.model_.fields - assert ui.followed_view.table.cassette_hooks is not None - - def test_resolve_points_at_the_cache(self, followed): - ui, _, service, _ = followed - track = ui.resolve_key(track_key(TOKEN, 10)) - assert Path(track.location).parent == cache_dir(service.root, TOKEN) - assert ui.resolve_key(track_key(TOKEN, 11)) is None - - def test_cassette_a_cached_song_imports_it_now(self, followed, tmp_path): - ui, _, service, manager = followed - ui.toggle_cassette(TOKEN, 10) - [track] = manager.library.tracks.values() - assert tmp_path / "Music" in Path(track.location).parents - assert track.play_count == 0 - assert service.state().friends[TOKEN].cassetted == [] - - def test_cassette_an_uncached_song_asks_for_it(self, followed): - ui, _, service, manager = followed - ui.toggle_cassette(TOKEN, 12) - assert service.state().friends[TOKEN].cassetted == [12] - assert 12 in req.read_requests(out_dir(service.root, TOKEN)) - assert ui.cassette_state(TOKEN, 12) == "on" - ui.toggle_cassette(TOKEN, 12) - assert ui.cassette_state(TOKEN, 12) == "off" - - def test_unfollow(self, followed): - ui, window, service, _ = followed - ui.unfollow(f"cassette:{TOKEN}:P") - assert service.state().friends[TOKEN].followed == [] - tree = window.sidebar.tree - names = [tree.topLevelItem(i).text(0) for i in range(tree.topLevelItemCount())] - assert names == ["Morning", "Zebra"] +def test_header_row_shares_the_seek_band(qapp, tmp_path): + window = _window(qapp, tmp_path) + bar = window._transport + panel = bar._now_playing + seek = bar._slider.parentWidget() + # Under the panel, edge to edge, and level with the Library button. + assert _gx(seek) == _gx(panel) + assert seek.width() == panel.width() + library = window.sidebar.library_button + row = bar._header_row + assert row.geometry().top() > panel.geometry().bottom() + assert library.mapTo(bar, QPoint()).y() >= row.geometry().top() -class TestSettings: - def test_follow_settings_on_the_friend_page(self, qapp, tmp_path): - from lintunes.gui.sync_settings_dialog import SyncSettingsDialog - from tests.test_round64 import _NoProbe - state = CassetteState() - state.friends["t"] = Friend(token="t", device_id="D", name="Sam") - dialog = SyncSettingsDialog(state, tmp_path, {}, probe=_NoProbe()) - page = dialog.friend_pages["t"] - assert page.layout_folder.isChecked() and page.mode_separate.isChecked() - page.layout_mixed.setChecked(True) - page.mode_library.setChecked(True) - friend = dialog.state.friends["t"] - assert (friend.layout, friend.followed_mode) == ("mixed", "library") +def test_panel_caps_and_gaps_take_the_rest(qapp, tmp_path): + window = _window(qapp, tmp_path, width=1600) + bar = window._transport + assert bar._now_playing.width() == NOW_PLAYING_MAX + window.resize(800, 500) + qapp.processEvents() + assert NOW_PLAYING_MIN <= bar._now_playing.width() < NOW_PLAYING_MAX + bpm = bar._bpm_button.parentWidget() + assert bar._now_playing.geometry().right() < bpm.geometry().left() + assert bpm.geometry().right() < bar.width() + + +def test_views_seek_row_never_overlaps_the_header(qapp, tmp_path): + window = _window(qapp, tmp_path, width=800) + strip = window._library_view.header + seek = window._transport._slider.parentWidget() + assert _gx(strip, strip.width()) <= _gx(seek) + + +def test_long_name_shrinks_then_elides(qapp): + label = FitLabel() + base = label.font().pointSize() + label.resize(120, 30) + label.setText("A short one") + assert label.text() == "A short one" + label.setText("An Extraordinarily Long Playlist Name That Goes On Forever") + assert label.font().pointSize() >= base - 2 + assert label.text().endswith("…") + assert label.toolTip() == label.full_text()