diff --git a/CLAUDE.md b/CLAUDE.md index fe2fb59..c174a30 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -131,10 +131,14 @@ persistence) → GUI (Qt widgets that read the manager and connect to its signal 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 + kept edge to edge with the playlist list — `list_area`, scrollbar included — + 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. Everything + in it stands on one floor (`HeaderRow.BOTTOM` above the lists): boxes by + their bottom edge, text by its baseline. The row of boxes above floats — + equal space before, between and after the controls, with the panel a fixed + share of the width. 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 diff --git a/lintunes/__init__.py b/lintunes/__init__.py index 301675f..a0de2ea 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.4" +__version__ = "0.31.5" diff --git a/lintunes/gui/library_view.py b/lintunes/gui/library_view.py index 0ea8c17..4f1556a 100644 --- a/lintunes/gui/library_view.py +++ b/lintunes/gui/library_view.py @@ -3,7 +3,7 @@ from collections import Counter from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QListWidget, QSplitter, QLineEdit, - QStyledItemDelegate, QStyleOptionViewItem, QStyle, QApplication, + QStyledItemDelegate, QStyleOptionViewItem, QStyle, QApplication, QSizePolicy, ) from PyQt6.QtCore import Qt, QTimer, QRectF, QPersistentModelIndex, pyqtSignal @@ -205,8 +205,11 @@ class LibraryView(TableSettingsMixin, QWidget): self._search.setPlaceholderText("search") self._search.setClearButtonEnabled(True) self._search.setMaximumWidth(SEARCH_MAX_WIDTH) + # As tall as the Library button: the header row sizes the strip. + self._search.setSizePolicy(QSizePolicy.Policy.Expanding, + QSizePolicy.Policy.Expanding) strip_row = QHBoxLayout(self._top_strip) - strip_row.setContentsMargins(0, 2, 0, 2) + strip_row.setContentsMargins(0, 0, 0, 0) strip_row.addWidget(self._search, 1) strip_row.addStretch(0) @@ -306,13 +309,6 @@ class LibraryView(TableSettingsMixin, QWidget): return [t.track_id for t in self._filtered_tracks(up_to="artist") if t.album.casefold() == value] - def set_header_height(self, height: int): - # Kept in sync with the sidebar Library button and the playlist view's - # name strip so the tracklist tops line up across views. - self._top_strip.setFixedHeight(height) - # Keep the search box inside the strip at small UI scales. - self._search.setMaximumHeight(max(16, height - 4)) - # ---- settings persistence (TableSettingsMixin hooks) ---- @property diff --git a/lintunes/gui/main_window.py b/lintunes/gui/main_window.py index 87b6792..e50c4bc 100644 --- a/lintunes/gui/main_window.py +++ b/lintunes/gui/main_window.py @@ -139,7 +139,7 @@ class MainWindow(QMainWindow): # 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._transport.track_split(self._sidebar.list_area, 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 @@ -318,8 +318,6 @@ class MainWindow(QMainWindow): metrics = theme.scale_metrics(self._prefs) for view in (self._library_view, self._playlist_view): view.table.verticalHeader().setDefaultSectionSize(metrics["row_height"]) - 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"]) diff --git a/lintunes/gui/playlist_view.py b/lintunes/gui/playlist_view.py index 62dd512..4c40fb9 100644 --- a/lintunes/gui/playlist_view.py +++ b/lintunes/gui/playlist_view.py @@ -58,11 +58,6 @@ class PlaylistView(TableSettingsMixin, QWidget): manager.track_updated.connect(self.table.model_.refresh_track) manager.playlists_changed.connect(self._on_playlists_changed) - def set_header_height(self, height: int): - # Kept in sync with the sidebar's Library button so the tree's top - # aligns with the tracklist's top - self._name_label.setFixedHeight(height) - @property def playlist_id(self) -> str: return self._playlist.persistent_id if self._playlist else "" diff --git a/lintunes/gui/sidebar.py b/lintunes/gui/sidebar.py index 8d97371..05bbddf 100644 --- a/lintunes/gui/sidebar.py +++ b/lintunes/gui/sidebar.py @@ -1,7 +1,7 @@ from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QPushButton, QTreeWidget, QTreeWidgetItem, QMenu, QInputDialog, QMessageBox, QAbstractItemView, QLineEdit, QApplication, - QHBoxLayout, QListWidget, QListWidgetItem, QStackedWidget, + QHBoxLayout, QListWidget, QListWidgetItem, QStackedWidget, QSizePolicy, ) from PyQt6.QtCore import Qt, QTimer, QPoint, pyqtSignal from PyQt6.QtGui import QBrush, QPainter, QPalette, QPixmap @@ -22,6 +22,9 @@ KIND_ROLE = Qt.ItemDataRole.UserRole + 1 # "folder" | "playlist" | "smart" # Rotated Floral Heart Bullet, painted in the branch area (where the folder # triangle sits) for smart playlists, so the name stays aligned with the others. SMART_GLYPH = "❧" +# Bare window left of the sidebar, so the playlists (and the Library button +# over them) don't run into the window's edge. +SIDEBAR_INSET = 8 class SidebarPanel(QWidget): @@ -41,7 +44,10 @@ class SidebarPanel(QWidget): def __init__(self, manager, player, parent=None): super().__init__(parent) layout = QVBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) + # A strip of bare window left of the playlists; the Library button + # above them (in the transport bar's header row) is exactly as wide + # as the list, scrollbar included — see TransportBar.track_split. + layout.setContentsMargins(SIDEBAR_INSET, 0, 0, 0) layout.setSpacing(0) self.friend_mode = False @@ -57,6 +63,10 @@ class SidebarPanel(QWidget): self.friend_button = QPushButton("▾") self.friend_button.setToolTip("Browse a friend's library") self.friend_button.setVisible(False) + for button in (self.library_button, self.friend_button): + # As tall as the header row makes the strip. + button.setSizePolicy(button.sizePolicy().horizontalPolicy(), + QSizePolicy.Policy.Expanding) top_row.addWidget(self.library_button, stretch=1) top_row.addWidget(self.friend_button) self.refresh_style() @@ -71,6 +81,8 @@ class SidebarPanel(QWidget): self.friend_list = FriendPlaylistList() self._lists.addWidget(self.friend_list) layout.addWidget(self._lists, stretch=1) + # What the header row lines the Library button up with. + self.list_area = self._lists self.art = SidebarArt(player) layout.addWidget(self.art) @@ -141,9 +153,7 @@ class SidebarPanel(QWidget): self.tree.select_item(pid) def apply_metrics(self, header_strip: int): - self.library_button.setFixedHeight(header_strip) - self.friend_button.setFixedHeight(header_strip) - # Square: the button's own height, minus its vertical margins. + # Square: as wide as the button is tall (the header row sets that). self.friend_button.setFixedWidth(max(16, header_strip - 4)) def refresh_style(self): @@ -161,8 +171,7 @@ class SidebarPanel(QWidget): checked_text = "black" split = not self.friend_button.isHidden() joint = ("border-top-right-radius: 0; border-bottom-right-radius: 0;" - " margin: 2px 0 2px 8px;" if split else - "margin: 2px 4px 2px 8px;") + if split else "") self.library_button.setStyleSheet(theme.button_face_qss( "QPushButton", base, extra=f"text-align: left; padding-left: 10px; {joint}", checked_color=lit, checked_text=checked_text)) @@ -171,7 +180,7 @@ class SidebarPanel(QWidget): self.friend_button.setStyleSheet(theme.button_face_qss( "QPushButton", base, extra="padding: 0; border-left: none; border-top-left-radius: 0;" - " border-bottom-left-radius: 0; margin: 2px 4px 2px 0;")) + " border-bottom-left-radius: 0;")) class FriendPlaylistList(QListWidget): """A friend's shared playlists in friend mode. The UFO at each row's left diff --git a/lintunes/gui/transport.py b/lintunes/gui/transport.py index d2f36a2..f51bbce 100644 --- a/lintunes/gui/transport.py +++ b/lintunes/gui/transport.py @@ -20,15 +20,15 @@ ICON_GRAY = QColor("#4A4A4A") TOP_MARGIN = 9 # Between the row of boxes and the header row under it. ROW_GAP = 4 -# The now-playing panel grows with the window up to this width. -NOW_PLAYING_MAX = 700 +# The now-playing panel is this share of the bar's width (700px on a 1920px +# screen), within these bounds. The row floats: the space before the first +# control, between each pair and after the last is one equal gap, so the +# whole row scales with the window instead of bunching up anywhere. +NOW_PLAYING_SHARE = 700 / 1920 NOW_PLAYING_MIN = 220 -# The row of boxes is knolled: the space before the first control, between -# each pair and after the last is one equal gap, between these bounds. Once -# the gaps are as wide as they get, any extra splits between the two ends, -# so the row sits centered rather than spreading out. +NOW_PLAYING_MAX = 1100 +# Floor for each of those gaps. MIN_GAP = 12 -MAX_GAP = 28 # Shared height for the boxed side controls (transport, shuffle, bpm) and the # visualizer, so they line up. ~midway between their old heights: the stretchy @@ -272,6 +272,10 @@ class HeaderRow(QWidget): name) takes what lies between — which is where that view starts.""" GAP = 6 + # Above the boxes (Library button, search box) and below them to the + # lists: every item in the row stands on one floor, BOTTOM px up. + TOP_PAD = 2 + BOTTOM = 5 def __init__(self, seek: QWidget, parent=None): super().__init__(parent) @@ -279,7 +283,7 @@ class HeaderRow(QWidget): 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._split = (0, 0, 0) # list left, list right, content left self._panel = QRect() # now-playing panel, in our coordinates def set_left(self, widget: QWidget): @@ -304,8 +308,8 @@ class HeaderRow(QWidget): widget.show() self.place() - def set_split(self, sidebar_right: int, content_left: int): - self._split = (sidebar_right, content_left) + def set_split(self, list_left: int, list_right: int, content_left: int): + self._split = (list_left, list_right, content_left) self.place() def set_panel(self, rect: QRect): @@ -317,18 +321,37 @@ class HeaderRow(QWidget): self.place() def place(self): - h = self.height() - sidebar_right, content_left = self._split + floor = self.height() - self.BOTTOM + box_h = max(0, floor - self.TOP_PAD) + list_left, list_right, content_left = self._split if self.left is not None: - self.left.setGeometry(0, 0, sidebar_right, h) + self.left.setGeometry(list_left, self.TOP_PAD, + max(0, list_right - list_left), box_h) + # The playhead's knob and the times' baselines stand on the floor. 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) + drop = self._drop(self.seek) + self.seek.drop.changeSize(0, drop, QSizePolicy.Policy.Minimum, + QSizePolicy.Policy.Fixed) + self.seek.layout().invalidate() + self.seek.setGeometry(seek_left, self.TOP_PAD, + max(0, panel.right() + 1 - seek_left), + box_h + drop) + header = self.view_header + if header is not None: + width = max(0, seek_left - self.GAP - content_left) + if isinstance(header, QLabel): + header.setAlignment(Qt.AlignmentFlag.AlignLeft + | Qt.AlignmentFlag.AlignBottom) + header.setGeometry(content_left, self.TOP_PAD, width, + box_h + self._drop(header)) + else: + header.setGeometry(content_left, self.TOP_PAD, width, box_h) + + def _drop(self, widget) -> int: + """Text stands on its baseline: its descenders hang below the + floor, as they would below a line of print.""" + return max(0, min(self.BOTTOM, widget.fontMetrics().descent() - 1)) class TransportBar(QWidget): @@ -456,12 +479,29 @@ class TransportBar(QWidget): self._elapsed_label.setFixedWidth(50) self._remaining_label = QLabel("") self._remaining_label.setFixedWidth(50) - self._remaining_label.setAlignment(Qt.AlignmentFlag.AlignRight) + # Both stand on the header row's floor, like the playlist name. + self._elapsed_label.setAlignment(Qt.AlignmentFlag.AlignLeft + | Qt.AlignmentFlag.AlignBottom) + self._remaining_label.setAlignment(Qt.AlignmentFlag.AlignRight + | Qt.AlignmentFlag.AlignBottom) self._slider = SeekSlider() self._slider.setRange(0, 0) self._slider.setFocusPolicy(Qt.FocusPolicy.NoFocus) + # The row reaches below the floor by the text's descent (so the + # times' baselines stand on it); the slider is held up by the same + # amount, so its knob does too. HeaderRow.place sets both. + slider_col = QVBoxLayout() + slider_col.setSpacing(0) + slider_col.addStretch(1) + slider_col.addWidget(self._slider) + seek.drop = QSpacerItem(0, 0, QSizePolicy.Policy.Minimum, + QSizePolicy.Policy.Fixed) + slider_col.addSpacerItem(seek.drop) + for label in (self._elapsed_label, self._remaining_label): + label.setSizePolicy(QSizePolicy.Policy.Fixed, + QSizePolicy.Policy.Expanding) seek_row.addWidget(self._elapsed_label) - seek_row.addWidget(self._slider) + seek_row.addLayout(slider_col) seek_row.addWidget(self._remaining_label) # The now-playing panel is exactly as tall as the boxes and shares @@ -491,7 +531,8 @@ class TransportBar(QWidget): # 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) + self._header_strip = 34 + self.set_header_height(34) outer.addWidget(self._header_row) self._split_widgets: tuple = () @@ -518,6 +559,7 @@ class TransportBar(QWidget): def set_sidebar_header(self, widget: QWidget): """The Library button strip, laid over the sidebar's width.""" self._header_row.set_left(widget) + self.set_header_height(self._header_strip) def set_view_header(self, widget: QWidget | None): """The shown view's header (search box, playlist name), between the @@ -525,13 +567,22 @@ class TransportBar(QWidget): self._header_row.set_view_header(widget) def set_header_height(self, height: int): - self._header_row.setFixedHeight(height) + """``height`` is the UI scale's header strip: the Library button's + own height plus its old 2px margins; the floor gap is added on.""" + row = self._header_row + self._header_strip = height + box = height - 4 + if row.left is not None: + # Never shorter than the Library button can be drawn at this + # font, or it would poke through the floor. + box = max(box, row.left.minimumSizeHint().height()) + row.setFixedHeight(box + HeaderRow.TOP_PAD + HeaderRow.BOTTOM) - 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) + def track_split(self, lists: QWidget, content: QWidget): + """Keep the header row's Library button exactly over ``lists`` (the + playlist list, scrollbar included) and the view header starting where + ``content`` does, as the splitter or window moves them.""" + self._split_widgets = (lists, content) for widget in self._split_widgets: widget.installEventFilter(self) self._place_header() @@ -556,19 +607,23 @@ class TransportBar(QWidget): + len(self._fixed_controls) * MIN_GAP) def _size_now_playing(self): - """Panel first, up to its cap; then equal gaps up to MAX_GAP; then - whatever is still left, split between the two ends.""" - room = self.width() - self._controls_width() - panel = max(NOW_PLAYING_MIN, min(NOW_PLAYING_MAX, room)) + """The panel takes its share of the width; the rest is split into + equal gaps before, between and after the controls.""" + spaces = len(self._gaps) + 2 + fixed = sum(max(w.sizeHint().width(), w.minimumWidth()) + for w in self._fixed_controls) + panel = round(self.width() * NOW_PLAYING_SHARE) + panel = min(panel, self.width() - fixed - spaces * MIN_GAP) + panel = max(NOW_PLAYING_MIN, min(NOW_PLAYING_MAX, panel)) self._now_playing.setFixedWidth(panel) - controls = self._controls_width() - (2 + len(self._gaps)) * MIN_GAP - free = max(0, self.width() - controls - panel) - gap = max(MIN_GAP, min(MAX_GAP, free // (len(self._gaps) + 2))) - end = max(MIN_GAP, (free - gap * len(self._gaps)) // 2) + free = max(0, self.width() - fixed - panel) + gap = max(MIN_GAP, free // spaces) for spacer in self._gaps: spacer.changeSize(gap, 0, QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Minimum) - self._row.setContentsMargins(end, 0, end, 0) + # Integer rounding's leftover pixels go to the right end. + end = max(MIN_GAP, free - gap * len(self._gaps)) + self._row.setContentsMargins(gap, 0, max(MIN_GAP, end - gap), 0) self._row.invalidate() def minimumSizeHint(self): @@ -578,7 +633,7 @@ class TransportBar(QWidget): def sizeHint(self): hint = super().sizeHint() - hint.setWidth(self._controls_width() + NOW_PLAYING_MAX) + hint.setWidth(self._controls_width() + NOW_PLAYING_MIN * 2) return hint def _place_header(self): @@ -590,8 +645,9 @@ class TransportBar(QWidget): 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)) + lists, content = self._split_widgets + row.set_split(x_of(lists), x_of(lists, lists.width()), + x_of(content)) def refresh_theme(self): """Re-apply everything driven by Preferences: button glyphs (highlight diff --git a/tests/test_round17.py b/tests/test_round17.py index 74d7c6d..34aa60d 100644 --- a/tests/test_round17.py +++ b/tests/test_round17.py @@ -278,8 +278,6 @@ class TestSearchBarPlacement: manager = _manager(tmp_path, [Track(track_id=1, name="A")]) view = LibraryView(manager) assert view._search.parentWidget() is view._top_strip - view.set_header_height(34) - assert view._top_strip.height() >= 0 # metrics path still works def test_search_still_filters(self, qapp, tmp_path): from lintunes.gui.library_view import LibraryView diff --git a/tests/test_round69.py b/tests/test_round69.py index fe743e5..3a9dde3 100644 --- a/tests/test_round69.py +++ b/tests/test_round69.py @@ -11,7 +11,7 @@ from PyQt6.QtCore import QPoint 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.gui.transport import NOW_PLAYING_MIN from lintunes.library_manager import LibraryManager from lintunes.models.library import Library from lintunes.models.playlist import Playlist @@ -46,12 +46,37 @@ def test_startup_still_opens_on_the_library(qapp, tmp_path): assert window._sidebar.library_button.isChecked() -def test_library_button_spans_the_sidebar(qapp, tmp_path): +def test_library_button_spans_the_playlist_list(qapp, tmp_path): + # Edge to edge with the list below it, scrollbar included, with bare + # window to the left of both. window = _window(qapp, tmp_path) header = window.sidebar.header + lists = window.sidebar.list_area assert header.parentWidget() is not window.sidebar - assert _gx(header) == _gx(window.sidebar) - assert header.width() == window.sidebar.width() + assert _gx(header) == _gx(lists) > _gx(window) + assert header.width() == lists.width() + button = window.sidebar.library_button + assert _gx(button) == _gx(lists) + assert _gx(button, button.width()) == _gx(lists, lists.width()) + + +def test_header_row_items_share_one_floor(qapp, tmp_path): + from lintunes.gui.transport import HeaderRow + window = _window(qapp, tmp_path) + row = window._transport._header_row + floor = row.height() - HeaderRow.BOTTOM + + def bottom(widget): + return widget.mapTo(row, QPoint(0, widget.height())).y() + + assert bottom(window.sidebar.library_button) == floor + assert bottom(window._library_view._search) == floor + assert bottom(window._transport._slider) == floor + window._show_playlist("abcd1234") + qapp.processEvents() + label = window._playlist_view.header + baseline = bottom(label) - label.fontMetrics().descent() + assert abs(baseline - floor) <= 1 def test_search_then_title_sit_where_the_view_starts(qapp, tmp_path): @@ -84,13 +109,13 @@ def test_header_row_shares_the_seek_band(qapp, tmp_path): assert library.mapTo(bar, QPoint()).y() >= row.geometry().top() -def test_panel_caps_and_gaps_take_the_rest(qapp, tmp_path): - window = _window(qapp, tmp_path, width=1600) +def test_panel_scales_with_the_window(qapp, tmp_path): + window = _window(qapp, tmp_path, width=1920) bar = window._transport - assert bar._now_playing.width() == NOW_PLAYING_MAX + assert abs(bar._now_playing.width() - 700) <= 1 window.resize(800, 500) qapp.processEvents() - assert NOW_PLAYING_MIN <= bar._now_playing.width() < NOW_PLAYING_MAX + assert NOW_PLAYING_MIN <= bar._now_playing.width() < 700 bpm = bar._bpm_button.parentWidget() assert bar._now_playing.geometry().right() < bpm.geometry().left() assert bpm.geometry().right() < bar.width() @@ -116,10 +141,10 @@ def test_long_name_shrinks_then_elides(qapp): def test_row_is_knolled_with_equal_gaps_and_ends(qapp, tmp_path): - from lintunes.gui.transport import MAX_GAP, MIN_GAP + from lintunes.gui.transport import MIN_GAP window = _window(qapp, tmp_path) bar = window._transport - for width in (900, 1280, 1800): + for width in (900, 1280, 1920): window.resize(width, 500) qapp.processEvents() row = [bar._prev_btn.parentWidget(), bar._shuffle_btn.parentWidget(), @@ -128,7 +153,8 @@ def test_row_is_knolled_with_equal_gaps_and_ends(qapp, tmp_path): edges = [(w.geometry().left(), w.geometry().right()) for w in row] gaps = [b[0] - a[1] - 1 for a, b in zip(edges, edges[1:])] assert max(gaps) - min(gaps) <= 1 - assert MIN_GAP <= gaps[0] <= MAX_GAP + assert gaps[0] >= MIN_GAP + # The ends get the same space as the gaps: the row floats. left_end, right_end = edges[0][0], bar.width() - 1 - edges[-1][1] - assert abs(left_end - right_end) <= 1 - assert left_end >= gaps[0] - 1 + for end in (left_end, right_end): + assert abs(end - gaps[0]) <= 7