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:
@@ -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,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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user