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`).
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
+1 -1
View File
@@ -1,3 +1,3 @@
"""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 (
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
+1 -3
View File
@@ -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"])
-5
View File
@@ -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 ""
+17 -8
View File
@@ -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
+96 -40
View File
@@ -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
-2
View File
@@ -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
+39 -13
View File
@@ -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