v0.31.5: Library button edge to edge with the playlists; one floor; floating top row

The sidebar gets 8px of bare window on its left, and the Library button
spans exactly the playlist list below it, scrollbar included. The Library
button, search box, playlist name, times and playhead all stand on one
floor 5px above the lists (text by its baseline). The top row floats:
equal space before, between and after the controls, with the now-playing
panel a fixed share of the width (700px at 1920). The header row grows if
the app font makes the Library button taller than the strip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-28 23:04:12 -07:00
co-authored by Claude Opus 5.5
parent d5cdddee33
commit 6f50248202
9 changed files with 167 additions and 85 deletions
+8 -4
View File
@@ -131,10 +131,14 @@ persistence) → GUI (Qt widgets that read the manager and connect to its signal
horizontal `QSplitter` (`SidebarPanel` | stacked `LibraryView`/`PlaylistView`). horizontal `QSplitter` (`SidebarPanel` | stacked `LibraryView`/`PlaylistView`).
The bar's second row (`transport.HeaderRow`, placed by hand) holds three The bar's second row (`transport.HeaderRow`, placed by hand) holds three
things owned elsewhere: the sidebar's Library button (`SidebarPanel.header`, things owned elsewhere: the sidebar's Library button (`SidebarPanel.header`,
kept as wide as the sidebar via `track_split`), the shown view's `header` kept edge to edge with the playlist list — `list_area`, scrollbar included —
(search box, playlist name — mounted on the content stack's via `track_split`), the shown view's `header` (search box, playlist name —
`currentChanged`, returned to its view when it goes), and the seek row, mounted on the content stack's `currentChanged`, returned to its view when
exactly under the now-playing panel. Since the Library button left the 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 sidebar, `MainWindow` must hand it the first focus, or the playlist tree
takes it and opens its first playlist at startup. takes it and opens its first playlist at startup.
`track_table.py` is the shared track grid (drag/drop, copy/paste, drop `track_table.py` is the shared track grid (drag/drop, copy/paste, drop
+1 -1
View File
@@ -1,3 +1,3 @@
"""LinTunes — iTunes-style music library manager and player for Linux.""" """LinTunes — iTunes-style music library manager and player for Linux."""
__version__ = "0.31.4" __version__ = "0.31.5"
+5 -9
View File
@@ -3,7 +3,7 @@ from collections import Counter
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QListWidget, QSplitter, QLineEdit, QWidget, QVBoxLayout, QHBoxLayout, QListWidget, QSplitter, QLineEdit,
QStyledItemDelegate, QStyleOptionViewItem, QStyle, QApplication, QStyledItemDelegate, QStyleOptionViewItem, QStyle, QApplication, QSizePolicy,
) )
from PyQt6.QtCore import Qt, QTimer, QRectF, QPersistentModelIndex, pyqtSignal from PyQt6.QtCore import Qt, QTimer, QRectF, QPersistentModelIndex, pyqtSignal
@@ -205,8 +205,11 @@ class LibraryView(TableSettingsMixin, QWidget):
self._search.setPlaceholderText("search") self._search.setPlaceholderText("search")
self._search.setClearButtonEnabled(True) self._search.setClearButtonEnabled(True)
self._search.setMaximumWidth(SEARCH_MAX_WIDTH) 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 = 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.addWidget(self._search, 1)
strip_row.addStretch(0) 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") return [t.track_id for t in self._filtered_tracks(up_to="artist")
if t.album.casefold() == value] 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) ---- # ---- settings persistence (TableSettingsMixin hooks) ----
@property @property
+1 -3
View File
@@ -139,7 +139,7 @@ class MainWindow(QMainWindow):
# the sidebar, then whichever view is showing's own header (search # the sidebar, then whichever view is showing's own header (search
# box, playlist name), then the seek row. # box, playlist name), then the seek row.
self._transport.set_sidebar_header(self._sidebar.header) 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._content.currentChanged.connect(self._mount_view_header)
self._mount_view_header() self._mount_view_header()
# Moving the Library button out of the sidebar put it last in the # 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) metrics = theme.scale_metrics(self._prefs)
for view in (self._library_view, self._playlist_view): for view in (self._library_view, self._playlist_view):
view.table.verticalHeader().setDefaultSectionSize(metrics["row_height"]) 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._sidebar.apply_metrics(metrics["header_strip"])
self._transport.set_header_height(metrics["header_strip"]) self._transport.set_header_height(metrics["header_strip"])
self.statusBar().setMinimumHeight(metrics["status_height"]) self.statusBar().setMinimumHeight(metrics["status_height"])
-5
View File
@@ -58,11 +58,6 @@ class PlaylistView(TableSettingsMixin, QWidget):
manager.track_updated.connect(self.table.model_.refresh_track) manager.track_updated.connect(self.table.model_.refresh_track)
manager.playlists_changed.connect(self._on_playlists_changed) 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 @property
def playlist_id(self) -> str: def playlist_id(self) -> str:
return self._playlist.persistent_id if self._playlist else "" return self._playlist.persistent_id if self._playlist else ""
+17 -8
View File
@@ -1,7 +1,7 @@
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QPushButton, QTreeWidget, QTreeWidgetItem, QMenu, QWidget, QVBoxLayout, QPushButton, QTreeWidget, QTreeWidgetItem, QMenu,
QInputDialog, QMessageBox, QAbstractItemView, QLineEdit, QApplication, QInputDialog, QMessageBox, QAbstractItemView, QLineEdit, QApplication,
QHBoxLayout, QListWidget, QListWidgetItem, QStackedWidget, QHBoxLayout, QListWidget, QListWidgetItem, QStackedWidget, QSizePolicy,
) )
from PyQt6.QtCore import Qt, QTimer, QPoint, pyqtSignal from PyQt6.QtCore import Qt, QTimer, QPoint, pyqtSignal
from PyQt6.QtGui import QBrush, QPainter, QPalette, QPixmap 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 # 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. # triangle sits) for smart playlists, so the name stays aligned with the others.
SMART_GLYPH = "❧" 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): class SidebarPanel(QWidget):
@@ -41,7 +44,10 @@ class SidebarPanel(QWidget):
def __init__(self, manager, player, parent=None): def __init__(self, manager, player, parent=None):
super().__init__(parent) super().__init__(parent)
layout = QVBoxLayout(self) 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) layout.setSpacing(0)
self.friend_mode = False self.friend_mode = False
@@ -57,6 +63,10 @@ class SidebarPanel(QWidget):
self.friend_button = QPushButton("▾") self.friend_button = QPushButton("▾")
self.friend_button.setToolTip("Browse a friend's library") self.friend_button.setToolTip("Browse a friend's library")
self.friend_button.setVisible(False) 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.library_button, stretch=1)
top_row.addWidget(self.friend_button) top_row.addWidget(self.friend_button)
self.refresh_style() self.refresh_style()
@@ -71,6 +81,8 @@ class SidebarPanel(QWidget):
self.friend_list = FriendPlaylistList() self.friend_list = FriendPlaylistList()
self._lists.addWidget(self.friend_list) self._lists.addWidget(self.friend_list)
layout.addWidget(self._lists, stretch=1) 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) self.art = SidebarArt(player)
layout.addWidget(self.art) layout.addWidget(self.art)
@@ -141,9 +153,7 @@ class SidebarPanel(QWidget):
self.tree.select_item(pid) self.tree.select_item(pid)
def apply_metrics(self, header_strip: int): def apply_metrics(self, header_strip: int):
self.library_button.setFixedHeight(header_strip) # Square: as wide as the button is tall (the header row sets that).
self.friend_button.setFixedHeight(header_strip)
# Square: the button's own height, minus its vertical margins.
self.friend_button.setFixedWidth(max(16, header_strip - 4)) self.friend_button.setFixedWidth(max(16, header_strip - 4))
def refresh_style(self): def refresh_style(self):
@@ -161,8 +171,7 @@ class SidebarPanel(QWidget):
checked_text = "black" checked_text = "black"
split = not self.friend_button.isHidden() split = not self.friend_button.isHidden()
joint = ("border-top-right-radius: 0; border-bottom-right-radius: 0;" joint = ("border-top-right-radius: 0; border-bottom-right-radius: 0;"
" margin: 2px 0 2px 8px;" if split else if split else "")
"margin: 2px 4px 2px 8px;")
self.library_button.setStyleSheet(theme.button_face_qss( self.library_button.setStyleSheet(theme.button_face_qss(
"QPushButton", base, extra=f"text-align: left; padding-left: 10px; {joint}", "QPushButton", base, extra=f"text-align: left; padding-left: 10px; {joint}",
checked_color=lit, checked_text=checked_text)) checked_color=lit, checked_text=checked_text))
@@ -171,7 +180,7 @@ class SidebarPanel(QWidget):
self.friend_button.setStyleSheet(theme.button_face_qss( self.friend_button.setStyleSheet(theme.button_face_qss(
"QPushButton", base, "QPushButton", base,
extra="padding: 0; border-left: none; border-top-left-radius: 0;" 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): class FriendPlaylistList(QListWidget):
"""A friend's shared playlists in friend mode. The UFO at each row's left """A friend's shared playlists in friend mode. The UFO at each row's left
+96 -40
View File
@@ -20,15 +20,15 @@ ICON_GRAY = QColor("#4A4A4A")
TOP_MARGIN = 9 TOP_MARGIN = 9
# Between the row of boxes and the header row under it. # Between the row of boxes and the header row under it.
ROW_GAP = 4 ROW_GAP = 4
# The now-playing panel grows with the window up to this width. # The now-playing panel is this share of the bar's width (700px on a 1920px
NOW_PLAYING_MAX = 700 # 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 NOW_PLAYING_MIN = 220
# The row of boxes is knolled: the space before the first control, between NOW_PLAYING_MAX = 1100
# each pair and after the last is one equal gap, between these bounds. Once # Floor for each of those gaps.
# the gaps are as wide as they get, any extra splits between the two ends,
# so the row sits centered rather than spreading out.
MIN_GAP = 12 MIN_GAP = 12
MAX_GAP = 28
# Shared height for the boxed side controls (transport, shuffle, bpm) and the # Shared height for the boxed side controls (transport, shuffle, bpm) and the
# visualizer, so they line up. ~midway between their old heights: the stretchy # 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.""" name) takes what lies between — which is where that view starts."""
GAP = 6 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): def __init__(self, seek: QWidget, parent=None):
super().__init__(parent) super().__init__(parent)
@@ -279,7 +283,7 @@ class HeaderRow(QWidget):
seek.setParent(self) seek.setParent(self)
self.left: QWidget | None = None self.left: QWidget | None = None
self.view_header: 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 self._panel = QRect() # now-playing panel, in our coordinates
def set_left(self, widget: QWidget): def set_left(self, widget: QWidget):
@@ -304,8 +308,8 @@ class HeaderRow(QWidget):
widget.show() widget.show()
self.place() self.place()
def set_split(self, sidebar_right: int, content_left: int): def set_split(self, list_left: int, list_right: int, content_left: int):
self._split = (sidebar_right, content_left) self._split = (list_left, list_right, content_left)
self.place() self.place()
def set_panel(self, rect: QRect): def set_panel(self, rect: QRect):
@@ -317,18 +321,37 @@ class HeaderRow(QWidget):
self.place() self.place()
def place(self): def place(self):
h = self.height() floor = self.height() - self.BOTTOM
sidebar_right, content_left = self._split box_h = max(0, floor - self.TOP_PAD)
list_left, list_right, content_left = self._split
if self.left is not None: 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 panel = self._panel
seek_left = max(panel.left(), content_left + self.GAP) seek_left = max(panel.left(), content_left + self.GAP)
seek_h = min(h, self.seek.sizeHint().height()) drop = self._drop(self.seek)
self.seek.setGeometry(seek_left, (h - seek_h) // 2, self.seek.drop.changeSize(0, drop, QSizePolicy.Policy.Minimum,
max(0, panel.right() + 1 - seek_left), seek_h) QSizePolicy.Policy.Fixed)
if self.view_header is not None: self.seek.layout().invalidate()
self.view_header.setGeometry( self.seek.setGeometry(seek_left, self.TOP_PAD,
content_left, 0, max(0, seek_left - self.GAP - content_left), h) 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): class TransportBar(QWidget):
@@ -456,12 +479,29 @@ class TransportBar(QWidget):
self._elapsed_label.setFixedWidth(50) self._elapsed_label.setFixedWidth(50)
self._remaining_label = QLabel("") self._remaining_label = QLabel("")
self._remaining_label.setFixedWidth(50) 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 = SeekSlider()
self._slider.setRange(0, 0) self._slider.setRange(0, 0)
self._slider.setFocusPolicy(Qt.FocusPolicy.NoFocus) 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._elapsed_label)
seek_row.addWidget(self._slider) seek_row.addLayout(slider_col)
seek_row.addWidget(self._remaining_label) seek_row.addWidget(self._remaining_label)
# The now-playing panel is exactly as tall as the boxes and shares # 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) # hands over the first two (see set_sidebar_header / set_view_header)
# and says where the sidebar ends (track_split). # and says where the sidebar ends (track_split).
self._header_row = HeaderRow(seek) self._header_row = HeaderRow(seek)
self._header_row.setFixedHeight(34) self._header_strip = 34
self.set_header_height(34)
outer.addWidget(self._header_row) outer.addWidget(self._header_row)
self._split_widgets: tuple = () self._split_widgets: tuple = ()
@@ -518,6 +559,7 @@ class TransportBar(QWidget):
def set_sidebar_header(self, widget: QWidget): def set_sidebar_header(self, widget: QWidget):
"""The Library button strip, laid over the sidebar's width.""" """The Library button strip, laid over the sidebar's width."""
self._header_row.set_left(widget) self._header_row.set_left(widget)
self.set_header_height(self._header_strip)
def set_view_header(self, widget: QWidget | None): def set_view_header(self, widget: QWidget | None):
"""The shown view's header (search box, playlist name), between the """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) self._header_row.set_view_header(widget)
def set_header_height(self, height: int): 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): def track_split(self, lists: QWidget, content: QWidget):
"""Keep the header row's Library button as wide as ``sidebar`` and """Keep the header row's Library button exactly over ``lists`` (the
the view header starting where ``content`` does, as the splitter or playlist list, scrollbar included) and the view header starting where
window moves them.""" ``content`` does, as the splitter or window moves them."""
self._split_widgets = (sidebar, content) self._split_widgets = (lists, content)
for widget in self._split_widgets: for widget in self._split_widgets:
widget.installEventFilter(self) widget.installEventFilter(self)
self._place_header() self._place_header()
@@ -556,19 +607,23 @@ class TransportBar(QWidget):
+ len(self._fixed_controls) * MIN_GAP) + len(self._fixed_controls) * MIN_GAP)
def _size_now_playing(self): def _size_now_playing(self):
"""Panel first, up to its cap; then equal gaps up to MAX_GAP; then """The panel takes its share of the width; the rest is split into
whatever is still left, split between the two ends.""" equal gaps before, between and after the controls."""
room = self.width() - self._controls_width() spaces = len(self._gaps) + 2
panel = max(NOW_PLAYING_MIN, min(NOW_PLAYING_MAX, room)) 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) self._now_playing.setFixedWidth(panel)
controls = self._controls_width() - (2 + len(self._gaps)) * MIN_GAP free = max(0, self.width() - fixed - panel)
free = max(0, self.width() - controls - panel) gap = max(MIN_GAP, free // spaces)
gap = max(MIN_GAP, min(MAX_GAP, free // (len(self._gaps) + 2)))
end = max(MIN_GAP, (free - gap * len(self._gaps)) // 2)
for spacer in self._gaps: for spacer in self._gaps:
spacer.changeSize(gap, 0, QSizePolicy.Policy.Fixed, spacer.changeSize(gap, 0, QSizePolicy.Policy.Fixed,
QSizePolicy.Policy.Minimum) 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() self._row.invalidate()
def minimumSizeHint(self): def minimumSizeHint(self):
@@ -578,7 +633,7 @@ class TransportBar(QWidget):
def sizeHint(self): def sizeHint(self):
hint = super().sizeHint() hint = super().sizeHint()
hint.setWidth(self._controls_width() + NOW_PLAYING_MAX) hint.setWidth(self._controls_width() + NOW_PLAYING_MIN * 2)
return hint return hint
def _place_header(self): def _place_header(self):
@@ -590,8 +645,9 @@ class TransportBar(QWidget):
row.set_panel(QRect(x_of(self._now_playing), 0, row.set_panel(QRect(x_of(self._now_playing), 0,
self._now_playing.width(), row.height())) self._now_playing.width(), row.height()))
if self._split_widgets: if self._split_widgets:
sidebar, content = self._split_widgets lists, content = self._split_widgets
row.set_split(x_of(sidebar, sidebar.width()), x_of(content)) row.set_split(x_of(lists), x_of(lists, lists.width()),
x_of(content))
def refresh_theme(self): def refresh_theme(self):
"""Re-apply everything driven by Preferences: button glyphs (highlight """Re-apply everything driven by Preferences: button glyphs (highlight
-2
View File
@@ -278,8 +278,6 @@ class TestSearchBarPlacement:
manager = _manager(tmp_path, [Track(track_id=1, name="A")]) manager = _manager(tmp_path, [Track(track_id=1, name="A")])
view = LibraryView(manager) view = LibraryView(manager)
assert view._search.parentWidget() is view._top_strip 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): def test_search_still_filters(self, qapp, tmp_path):
from lintunes.gui.library_view import LibraryView from lintunes.gui.library_view import LibraryView
+39 -13
View File
@@ -11,7 +11,7 @@ from PyQt6.QtCore import QPoint
from lintunes.gui.fit_label import FitLabel from lintunes.gui.fit_label import FitLabel
from lintunes.gui.main_window import MainWindow 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.library_manager import LibraryManager
from lintunes.models.library import Library from lintunes.models.library import Library
from lintunes.models.playlist import Playlist 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() 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) window = _window(qapp, tmp_path)
header = window.sidebar.header header = window.sidebar.header
lists = window.sidebar.list_area
assert header.parentWidget() is not window.sidebar assert header.parentWidget() is not window.sidebar
assert _gx(header) == _gx(window.sidebar) assert _gx(header) == _gx(lists) > _gx(window)
assert header.width() == window.sidebar.width() 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): 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() assert library.mapTo(bar, QPoint()).y() >= row.geometry().top()
def test_panel_caps_and_gaps_take_the_rest(qapp, tmp_path): def test_panel_scales_with_the_window(qapp, tmp_path):
window = _window(qapp, tmp_path, width=1600) window = _window(qapp, tmp_path, width=1920)
bar = window._transport bar = window._transport
assert bar._now_playing.width() == NOW_PLAYING_MAX assert abs(bar._now_playing.width() - 700) <= 1
window.resize(800, 500) window.resize(800, 500)
qapp.processEvents() 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() bpm = bar._bpm_button.parentWidget()
assert bar._now_playing.geometry().right() < bpm.geometry().left() assert bar._now_playing.geometry().right() < bpm.geometry().left()
assert bpm.geometry().right() < bar.width() 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): 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) window = _window(qapp, tmp_path)
bar = window._transport bar = window._transport
for width in (900, 1280, 1800): for width in (900, 1280, 1920):
window.resize(width, 500) window.resize(width, 500)
qapp.processEvents() qapp.processEvents()
row = [bar._prev_btn.parentWidget(), bar._shuffle_btn.parentWidget(), 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] 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:])] gaps = [b[0] - a[1] - 1 for a, b in zip(edges, edges[1:])]
assert max(gaps) - min(gaps) <= 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] left_end, right_end = edges[0][0], bar.width() - 1 - edges[-1][1]
assert abs(left_end - right_end) <= 1 for end in (left_end, right_end):
assert left_end >= gaps[0] - 1 assert abs(end - gaps[0]) <= 7