v0.31.3: header row moves up beside the seek bar; knolled top row

The Library button, the view's header (search box or playlist name) and
the seek row now share one band under the transport boxes, instead of the
seek row hanging alone with a header strip below it. The now-playing panel
caps at 560px and the gaps between controls share the rest evenly; more
room under the menu bar. Long playlist names shrink up to 2pt, then elide.
This commit is contained in:
2026-09-28 22:43:21 -07:00
parent f280fc5330
commit a9c815ba1a
12 changed files with 423 additions and 276 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
"""LinTunes — iTunes-style music library manager and player for Linux."""
__version__ = "0.31.2"
__version__ = "0.31.3"
+54
View File
@@ -0,0 +1,54 @@
"""A one-line label that shrinks its text a little before it gives up and
elides — for a playlist name in the header row, where the room left between
the Library button and the seek row depends on the window."""
from PyQt6.QtCore import Qt
from PyQt6.QtGui import QFontMetrics
from PyQt6.QtWidgets import QLabel, QSizePolicy
# How far below its normal size the text may go (points) before eliding.
MAX_SHRINK_PT = 2
class FitLabel(QLabel):
def __init__(self, text: str = "", parent=None):
super().__init__(parent)
self._full = ""
self._base_pt = 0
self.setSizePolicy(QSizePolicy.Policy.Ignored,
QSizePolicy.Policy.Preferred)
self.setAlignment(Qt.AlignmentFlag.AlignVCenter
| Qt.AlignmentFlag.AlignLeft)
self.setText(text)
def setText(self, text: str):
self._full = text or ""
self.setToolTip("")
self._fit()
def full_text(self) -> str:
return self._full
def resizeEvent(self, event):
super().resizeEvent(event)
self._fit()
def _fit(self):
font = self.font()
if not self._base_pt:
self._base_pt = font.pointSize()
room = self.contentsRect().width() - 2 * self.margin()
text = self._full
for pt in range(self._base_pt, self._base_pt - MAX_SHRINK_PT - 1, -1):
font.setPointSize(max(1, pt))
if QFontMetrics(font).horizontalAdvance(text) <= room:
break
else:
text = QFontMetrics(font).elidedText(
text, Qt.TextElideMode.ElideRight, max(0, room))
if font != self.font():
self.setFont(font)
# Elided: the whole name is a hover away.
self.setToolTip(self._full if text != self._full else "")
super().setText(text)
+8 -4
View File
@@ -14,8 +14,9 @@ from __future__ import annotations
from PyQt6.QtCore import pyqtSignal
from PyQt6.QtGui import QColor
from PyQt6.QtWidgets import QLabel, QVBoxLayout, QWidget
from PyQt6.QtWidgets import QVBoxLayout, QWidget
from lintunes.gui.fit_label import FitLabel
from lintunes.gui.track_table import TrackTableView
from lintunes.models.playlist import PlaylistSettings
@@ -45,12 +46,15 @@ class FollowedPlaylistView(QWidget):
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
self.title = QLabel()
# Shown in the transport bar's header row, not in this layout.
self.title = FitLabel(parent=self)
font = self.title.font()
font.setBold(True)
self.title.setFont(font)
self.title.setContentsMargins(8, 2, 8, 2)
layout.addWidget(self.title)
self.title.setContentsMargins(4, 0, 0, 0)
self.title.hide()
self.header = self.title
self.title.header_owner = self
self.table = TrackTableView(playlist_mode=True)
self.table.set_read_only(True)
self.table.set_content_editable(False)
+7 -3
View File
@@ -33,6 +33,7 @@ from lintunes.cassette.friend_library import (
from lintunes.cassette.state import in_dir
from lintunes.gui import cassette_art
from lintunes.gui.cassette_art import OFF, ON, PARTIAL
from lintunes.gui.fit_label import FitLabel
from lintunes.models.playlist import PlaylistSettings
FUZZY_GREY = QColor("#9a9a9a")
@@ -73,12 +74,15 @@ class FriendPlaylistPane(QWidget):
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
self.title = QLabel()
# Shown in the transport bar's header row, not in this layout.
self.title = FitLabel(parent=self)
font = self.title.font()
font.setBold(True)
self.title.setFont(font)
self.title.setContentsMargins(8, 2, 8, 2)
layout.addWidget(self.title)
self.title.setContentsMargins(4, 0, 0, 0)
self.title.hide()
self.header = self.title
self.title.header_owner = self
self.table = TrackTableView(playlist_mode=True)
self.table.set_read_only(True)
self.table.set_content_editable(False)
+17 -11
View File
@@ -169,6 +169,10 @@ class _BrowserList(QListWidget):
return item.text()
# The search box fills the header row's slot up to this width.
SEARCH_MAX_WIDTH = 320
class LibraryView(TableSettingsMixin, QWidget):
"""All-tracks view with the iTunes-style 3-column browser (Ctrl+B)."""
@@ -185,24 +189,26 @@ class LibraryView(TableSettingsMixin, QWidget):
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
# Header strip matching the sidebar's Library button / playlist name
# band, so the browser+tracklist top aligns with the playlist tree.
# It hosts the search box, which thereby sits directly right of the
# Library button across the splitter.
self._top_strip = QWidget()
layout.addWidget(self._top_strip)
# Header strip hosting the search box. Not in this view's layout:
# MainWindow shows the current view's ``header`` in the transport
# bar's header row, right of the Library button, where a playlist's
# name goes (TransportBar.set_view_header).
self._top_strip = QWidget(self)
self._top_strip.hide()
self.header = self._top_strip
self._top_strip.header_owner = self
# Search: a debounced filter box occupying ~1/3 of the width, top
# right (iTunes-style). It narrows the track list and the browser
# columns together.
# Search: a debounced filter box that narrows the track list and the
# browser columns together.
self._search_text = ""
self._search = QLineEdit()
self._search.setPlaceholderText("search")
self._search.setClearButtonEnabled(True)
self._search.setMaximumWidth(SEARCH_MAX_WIDTH)
strip_row = QHBoxLayout(self._top_strip)
strip_row.setContentsMargins(4, 2, 4, 2)
strip_row.addStretch(2)
strip_row.setContentsMargins(0, 2, 0, 2)
strip_row.addWidget(self._search, 1)
strip_row.addStretch(0)
self._search_timer = QTimer(self)
self._search_timer.setSingleShot(True)
+17
View File
@@ -135,6 +135,18 @@ class MainWindow(QMainWindow):
self._content.addWidget(self._playlist_view)
self._content.setCurrentWidget(self._library_view)
# The header row under the transport boxes: the Library button over
# 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._content.currentChanged.connect(self._mount_view_header)
self._mount_view_header()
# Moving the Library button out of the sidebar put it last in the
# focus chain, and a playlist tree that takes the first focus selects
# its first playlist: the window would open on that, not the library.
self._sidebar.library_button.setFocus()
# Bottom status bar, left to right: version/self-update button, then a
# centered totals readout. Both are non-permanent widgets, so a
# transient showMessage (import/scrobble/error/update text) replaces
@@ -298,6 +310,10 @@ class MainWindow(QMainWindow):
self._version_button.refresh_theme()
self._sidebar.refresh_style()
def _mount_view_header(self, _index=None):
view = self._content.currentWidget()
self._transport.set_view_header(getattr(view, "header", None))
def apply_ui_metrics(self):
metrics = theme.scale_metrics(self._prefs)
for view in (self._library_view, self._playlist_view):
@@ -305,6 +321,7 @@ class MainWindow(QMainWindow):
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"])
def _show_preferences(self):
+13 -5
View File
@@ -1,6 +1,7 @@
from PyQt6.QtWidgets import QWidget, QVBoxLayout, QLabel, QApplication
from PyQt6.QtWidgets import QWidget, QVBoxLayout, QApplication
from PyQt6.QtCore import pyqtSignal
from lintunes.gui.fit_label import FitLabel
from lintunes.gui.table_settings import TableSettingsMixin
from lintunes.gui.track_table import TrackTableView, parse_tracks_mime
from lintunes.gui.playlist_ops import add_tracks_with_dup_check
@@ -21,10 +22,17 @@ class PlaylistView(TableSettingsMixin, QWidget):
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
self._name_label = QLabel()
self._name_label.setStyleSheet("font-weight: bold; padding: 0 8px;")
self._name_label.setFixedHeight(34)
layout.addWidget(self._name_label)
# The playlist's name. Not in this view's layout: it is this view's
# ``header``, shown in the transport bar's header row (see
# TransportBar.set_view_header), shrinking a little when it's long.
self._name_label = FitLabel(parent=self)
font = self._name_label.font()
font.setBold(True)
self._name_label.setFont(font)
self._name_label.setContentsMargins(4, 0, 0, 0)
self._name_label.hide()
self.header = self._name_label
self._name_label.header_owner = self
self.table = TrackTableView(playlist_mode=True)
layout.addWidget(self.table)
+4 -1
View File
@@ -60,7 +60,10 @@ class SidebarPanel(QWidget):
top_row.addWidget(self.library_button, stretch=1)
top_row.addWidget(self.friend_button)
self.refresh_style()
layout.addWidget(top)
# Not in this panel's layout: MainWindow lays it into the transport
# bar's header row, over this panel's width (TransportBar.track_split).
self.header = top
top.header_owner = self
self._lists = QStackedWidget()
self.tree = PlaylistTree(manager)
+199 -20
View File
@@ -2,9 +2,9 @@ import time
from PyQt6.QtWidgets import (
QWidget, QFrame, QHBoxLayout, QVBoxLayout, QLabel, QToolButton, QSlider,
QPushButton, QSizePolicy, QStyle,
QPushButton, QSizePolicy, QStyle, QLayout,
)
from PyQt6.QtCore import Qt, QTimer, pyqtSignal
from PyQt6.QtCore import Qt, QTimer, QEvent, QPoint, QRect, pyqtSignal
from PyQt6.QtGui import QColor, QFont
from lintunes.tap_tempo import TapTempo
@@ -16,10 +16,20 @@ from lintunes import theme
ICON_GRAY = QColor("#4A4A4A")
# Height floor for the top control panel: the row of CONTROL_HEIGHT boxes and
# now-playing panel, the seek row hanging under the panel (~17px at medium
# scale), and the 3px margins. Any leftover sits below the seek row.
BAR_HEIGHT = 86
# Breathing room between the menu bar and the row of boxes.
TOP_MARGIN = 9
# Between the row of boxes and the header row under it.
ROW_GAP = 4
# Side margin of the row of boxes — the Library button's own left margin, so
# the transport box and the Library button share a left edge.
SIDE_MARGIN = 8
# The now-playing panel grows with the window up to this width; past it the
# extra goes to the gaps between the controls, evenly.
NOW_PLAYING_MAX = 560
NOW_PLAYING_MIN = 220
# Floor for each of those gaps.
MIN_GAP = 12
# Shared height for the boxed side controls (transport, shuffle, bpm) and the
# visualizer, so they line up. ~midway between their old heights: the stretchy
@@ -31,6 +41,10 @@ CONTROL_HEIGHT = 62
# panel (the seek row sits below the panel, not beside the boxes).
TOP = Qt.AlignmentFlag.AlignTop
# Height floor for the whole bar: margin, the row of boxes, and the header row
# (Library button · search or playlist name · seek) at the medium UI scale.
BAR_HEIGHT = TOP_MARGIN + CONTROL_HEIGHT + ROW_GAP + 34
def _box_style(bg: QColor, button: bool = False) -> str:
"""A transport box. A box that *is* one button (shuffle, bpm) gets the
shared button face — emboss, outline, whole-box darkening on hover; the
@@ -251,6 +265,73 @@ class BpmButton(QPushButton):
self.setText("bpm")
class HeaderRow(QWidget):
"""The band under the row of boxes, laid out by hand because its three
parts answer to three different things: the Library button is as wide as
the sidebar below it, the seek row sits exactly under the now-playing
panel above it, and the current view's header (search box or playlist
name) takes what lies between — which is where that view starts."""
GAP = 6
def __init__(self, seek: QWidget, parent=None):
super().__init__(parent)
self.seek = seek
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._panel = QRect() # now-playing panel, in our coordinates
def set_left(self, widget: QWidget):
self.left = widget
widget.setParent(self)
widget.show()
self.place()
def set_view_header(self, widget: QWidget | None):
"""Show ``widget`` (a view's header) in the middle slot. The one it
replaces goes back to its own view, hidden, so it lives and dies with
that view."""
old = self.view_header
if old is widget:
return
if old is not None:
old.hide()
old.setParent(getattr(old, "header_owner", None))
self.view_header = widget
if widget is not None:
widget.setParent(self)
widget.show()
self.place()
def set_split(self, sidebar_right: int, content_left: int):
self._split = (sidebar_right, content_left)
self.place()
def set_panel(self, rect: QRect):
self._panel = rect
self.place()
def resizeEvent(self, event):
super().resizeEvent(event)
self.place()
def place(self):
h = self.height()
sidebar_right, content_left = self._split
if self.left is not None:
self.left.setGeometry(0, 0, sidebar_right, h)
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)
class TransportBar(QWidget):
"""Top bar: boxed transport buttons and shuffle (left), visualizer,
now-playing text, seek slider, boxed bpm tap button (right)."""
@@ -266,8 +347,24 @@ class TransportBar(QWidget):
self._current_track = None
self.setMinimumHeight(BAR_HEIGHT)
layout = QHBoxLayout(self)
layout.setContentsMargins(8, 3, 8, 3)
outer = QVBoxLayout(self)
outer.setContentsMargins(0, TOP_MARGIN, 0, 0)
outer.setSpacing(ROW_GAP)
# The panel's width is set from the bar's (_size_now_playing), so the
# bar's minimum must not come from the panel's current width, or a
# window could never shrink past the width it last had.
outer.setSizeConstraint(QLayout.SizeConstraint.SetNoConstraint)
layout = QHBoxLayout()
layout.setContentsMargins(SIDE_MARGIN, 0, SIDE_MARGIN, 0)
layout.setSpacing(0)
outer.addLayout(layout)
# Knolled: every control keeps its size and the gaps between them
# are equal, growing together once the now-playing panel is as wide
# as it gets.
def gap():
layout.addSpacing(MIN_GAP)
layout.addStretch(1)
# Rounded button boxes carry a user-tunable grayscale fill, decoupled
# from the stripe color they used to share via palette(alternate-base).
@@ -287,6 +384,7 @@ class TransportBar(QWidget):
height=CONTROL_HEIGHT, split=True)
self._boxes.append(transport_box)
layout.addWidget(transport_box, alignment=TOP)
gap()
self._shuffle_btn = FlashButton("shuffle")
self._shuffle_btn.setMinimumWidth(50)
@@ -296,12 +394,12 @@ class TransportBar(QWidget):
height=CONTROL_HEIGHT, split=True)
self._boxes.append(shuffle_box)
layout.addWidget(shuffle_box, alignment=TOP)
gap()
layout.addSpacing(10)
self._visualizer = VisualizerWidget(player, prefs)
self._visualizer.setFixedHeight(CONTROL_HEIGHT)
layout.addWidget(self._visualizer, alignment=TOP)
layout.addSpacing(10)
gap()
# Master output volume: a slim horizontal slider in its own slot
# between the visualizer and the timeline. The HBox centers it
@@ -325,10 +423,7 @@ class TransportBar(QWidget):
self._volume_save_timer.timeout.connect(self._save_volume)
self._volume_slider.valueChanged.connect(self._on_volume_changed)
layout.addWidget(self._volume_slider, alignment=TOP)
layout.addSpacing(10)
center = QVBoxLayout()
center.setSpacing(0)
gap()
# White rounded now-playing panel: holds only the song title and the
# artist/album line, centered, filling the zone above the seek row.
@@ -351,7 +446,9 @@ class TransportBar(QWidget):
np.addStretch(1)
self._apply_now_playing_font()
seek_row = QHBoxLayout()
seek = QWidget()
seek_row = QHBoxLayout(seek)
seek_row.setContentsMargins(0, 0, 0, 0)
self._elapsed_label = QLabel("")
self._elapsed_label.setFixedWidth(50)
self._remaining_label = QLabel("")
@@ -366,12 +463,15 @@ class TransportBar(QWidget):
# The now-playing panel is exactly as tall as the boxes and shares
# their top edge, so boxes, visualizer, panel and bpm read as one row;
# the seek row hangs underneath the panel and any slack goes below it.
# the seek row hangs underneath the panel, in the header row.
self._now_playing.setFixedHeight(CONTROL_HEIGHT)
center.addWidget(self._now_playing)
center.addLayout(seek_row)
center.addStretch(1)
layout.addLayout(center, stretch=1)
# Its width is set by _size_now_playing: all the room the controls
# and minimal gaps leave, up to NOW_PLAYING_MAX; the gaps share the
# rest.
self._now_playing.setFixedWidth(NOW_PLAYING_MIN)
layout.addWidget(self._now_playing, alignment=TOP)
self._now_playing.installEventFilter(self)
gap()
# bpm tap button lives on the far right as its own box, top-aligned
# with the now-playing panel like the transport boxes — kept out of
@@ -382,6 +482,16 @@ class TransportBar(QWidget):
hug=True, height=CONTROL_HEIGHT, split=True)
self._boxes.append(bpm_box)
layout.addWidget(bpm_box, alignment=TOP)
self._fixed_controls = (transport_box, shuffle_box, self._visualizer,
self._volume_slider, bpm_box)
# Library button · the current view's header · seek row. MainWindow
# 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)
outer.addWidget(self._header_row)
self._split_widgets: tuple = ()
# Wiring
self._prev_btn.clicked.connect(player.previous)
@@ -401,6 +511,75 @@ class TransportBar(QWidget):
self._visualizer.cast_stop_requested.connect(
lambda: cast.disconnect())
# ---- the header row ----
def set_sidebar_header(self, widget: QWidget):
"""The Library button strip, laid over the sidebar's width."""
self._header_row.set_left(widget)
def set_view_header(self, widget: QWidget | None):
"""The shown view's header (search box, playlist name), between the
Library button and the seek row."""
self._header_row.set_view_header(widget)
def set_header_height(self, height: int):
self._header_row.setFixedHeight(height)
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)
for widget in self._split_widgets:
widget.installEventFilter(self)
self._place_header()
def eventFilter(self, obj, event):
if event.type() in (QEvent.Type.Resize, QEvent.Type.Move):
self._place_header()
return super().eventFilter(obj, event)
def resizeEvent(self, event):
super().resizeEvent(event)
self._size_now_playing()
self._place_header()
def _controls_width(self) -> int:
"""Everything in the row of boxes but the now-playing panel, with
each gap at its floor."""
return (2 * SIDE_MARGIN
# A fixed width shows in the minimum, not the size hint.
+ sum(max(w.sizeHint().width(), w.minimumWidth())
for w in self._fixed_controls)
+ len(self._fixed_controls) * MIN_GAP)
def _size_now_playing(self):
room = self.width() - self._controls_width()
self._now_playing.setFixedWidth(
max(NOW_PLAYING_MIN, min(NOW_PLAYING_MAX, room)))
def minimumSizeHint(self):
hint = super().minimumSizeHint()
hint.setWidth(self._controls_width() + NOW_PLAYING_MIN)
return hint
def sizeHint(self):
hint = super().sizeHint()
hint.setWidth(self._controls_width() + NOW_PLAYING_MAX)
return hint
def _place_header(self):
row = self._header_row
def x_of(widget, x=0): # siblings, not ancestors: go via global
return row.mapFromGlobal(widget.mapToGlobal(QPoint(x, 0))).x()
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))
def refresh_theme(self):
"""Re-apply everything driven by Preferences: button glyphs (highlight
color), the tunable grayscale button boxes, and the now-playing panel