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.
This commit is contained in:
@@ -1,3 +1,3 @@
|
||||
"""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 (
|
||||
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
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user