From af92ff9b730bd77a855b5c2bdb2a8bccdbfc6ede Mon Sep 17 00:00:00 2001 From: trav Date: Tue, 13 Oct 2026 10:20:26 -0700 Subject: [PATCH] v0.51.2: album art sits in the empty art panel's own frame, below the list, instead of inside the list's frame --- lintunes/__init__.py | 2 +- lintunes/gui/sidebar.py | 88 +++++++++++++++-------------------------- tests/test_round80.py | 18 --------- tests/test_round82.py | 48 +++++++++++++++------- 4 files changed, 66 insertions(+), 90 deletions(-) diff --git a/lintunes/__init__.py b/lintunes/__init__.py index 7cb678c..aab5374 100644 --- a/lintunes/__init__.py +++ b/lintunes/__init__.py @@ -1,3 +1,3 @@ """LinTunes — iTunes-style music library manager and player for Linux.""" -__version__ = "0.51.1" +__version__ = "0.51.2" diff --git a/lintunes/gui/sidebar.py b/lintunes/gui/sidebar.py index 268d97f..d68ff29 100644 --- a/lintunes/gui/sidebar.py +++ b/lintunes/gui/sidebar.py @@ -2,9 +2,9 @@ from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QPushButton, QTreeWidget, QTreeWidgetItem, QMenu, QInputDialog, QMessageBox, QAbstractItemView, QLineEdit, QApplication, QHBoxLayout, QListWidget, QListWidgetItem, QStackedWidget, QSizePolicy, - QFrame, QStyle, QStyleOptionFrame, + QFrame, ) -from PyQt6.QtCore import Qt, QTimer, QPoint, QRect, QRectF, pyqtSignal +from PyQt6.QtCore import Qt, QTimer, QPoint, QRectF, pyqtSignal from PyQt6.QtGui import QBrush, QPainter, QPalette, QPixmap from lintunes.models import PlaylistType @@ -76,9 +76,7 @@ class SidebarPanel(QWidget): self.header = top top.header_owner = self - # The playlists and the album art under them are one group inside - # one frame — the list's own frame, drawn around both, so the art - # sits within it rather than hanging below. + # The playlists in their frame; the album art panel sits below it. group = QFrame() self._lists = QStackedWidget() self.tree = PlaylistTree(manager) @@ -95,9 +93,9 @@ class SidebarPanel(QWidget): inner.addWidget(self._lists, stretch=1) self.art = SidebarArt(player) layout.addWidget(group, stretch=1) - self._inner, self._outer = inner, layout - self.art.has_art_changed.connect(self._place_art) - self._place_art(False) + # Art or none, the same panel in the same place: a cover changes + # what is in the panel, never the frame around it. + layout.addWidget(self.art) # What the header row lines the Library button up with. self.list_area = group @@ -109,14 +107,6 @@ class SidebarPanel(QWidget): self.friend_list.playlist_selected.connect(self._on_friend_playlist) self.friend_list.follow_toggled.connect(self.follow_toggled) - def _place_art(self, has_art: bool): - """Art goes inside the list's frame; the empty panel below it, on - the window, where it reads as set into the window like the - visualizer rather than as a box inside a white one.""" - for lay in (self._inner, self._outer): - lay.removeWidget(self.art) - (self._inner if has_art else self._outer).addWidget(self.art) - def _on_library_clicked(self): self.library_button.setChecked(True) if self.friend_mode: @@ -263,22 +253,18 @@ class FriendPlaylistList(QListWidget): self.playlist_selected.emit(current.data(Qt.ItemDataRole.UserRole)) -# The no-art panel: on the window under the list's frame, this far below -# it, with the visualizer's corners. +# The art panel: on the window under the list's frame, this far below it, +# with the visualizer's corners. NO_ART_GAP = 6 NO_ART_RADIUS = 8 -# Art sits inside the list's frame, so the frame never closes between them; -# the art draws that closing line itself, this tall, so a cover with a white -# border doesn't run straight on from the list. -ART_RULE = 1 class SidebarArt(QWidget): - """Square album art pinned to the sidebar bottom, inside the playlist - list's frame. With no embedded art it is an empty panel instead, set - into the window below the frame like the visualizer (SidebarPanel moves - it on ``has_art_changed``). A click opens the big art window, or, on the - empty panel, asks for the track's art to be downloaded.""" + """Square album art pinned to the sidebar bottom: a panel set into the + window below the playlist list's frame, like the visualizer, with the + cover inside its border. With no embedded art the panel is empty. A + click opens the big art window, or, on the empty panel, asks for the + track's art to be downloaded.""" download_art_requested = pyqtSignal(int) # track id has_art_changed = pyqtSignal(bool) @@ -329,8 +315,8 @@ class SidebarArt(QWidget): def _fit_height(self): # Keep it square: height follows the sidebar's width (plus the gap - # above the empty panel) - height = self.width() + (ART_RULE if self.has_art() else NO_ART_GAP) + # above the panel) + height = self.width() + NO_ART_GAP if self.height() != height: self.setFixedHeight(height) @@ -351,40 +337,30 @@ class SidebarArt(QWidget): super().mouseReleaseEvent(event) def paintEvent(self, event): + # Straight onto the window, a panel debossed in the visualizer's + # color and shaded at its edges as the visualizer is. + from lintunes.gui.transport import CONTROL_HEIGHT + from PyQt6.QtGui import QPainterPath painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + rect = QRectF(self.rect()).adjusted(0.5, NO_ART_GAP + 0.5, -0.5, -0.5) + theme.paint_deboss(painter, rect, + self.palette().alternateBase().color(), + NO_ART_RADIUS, like=CONTROL_HEIGHT) if self._pixmap is None or self._pixmap.isNull(): - # No art: straight onto the window, a panel debossed in the - # visualizer's color and shaded at its edges as the visualizer is. - from lintunes.gui.transport import CONTROL_HEIGHT - painter.setRenderHint(QPainter.RenderHint.Antialiasing) - rect = QRectF(self.rect()).adjusted(0.5, NO_ART_GAP + 0.5, -0.5, -0.5) - theme.paint_deboss(painter, rect, - self.palette().alternateBase().color(), - NO_ART_RADIUS, like=CONTROL_HEIGHT) return - # The list frame's own bottom edge, drawn by the style: a frame - # whose bottom row is this widget's top row, its sides pushed out of - # view, so the line is exactly the one under the list with no art. - option = QStyleOptionFrame() - option.initFrom(self) - option.frameShape = QFrame.Shape.StyledPanel - option.state |= QStyle.StateFlag.State_Sunken - option.lineWidth = option.midLineWidth = 0 - frame = self.style().pixelMetric(QStyle.PixelMetric.PM_DefaultFrameWidth) - option.rect = QRect(-frame - 4, ART_RULE - 4 * frame - 8, - self.width() + 2 * frame + 8, 4 * frame + 8) - painter.save() - painter.setClipRect(0, 0, self.width(), ART_RULE) - self.style().drawPrimitive(QStyle.PrimitiveElement.PE_Frame, - option, painter, self) - painter.restore() - area = self.rect().adjusted(0, ART_RULE, 0, 0) + # The cover fills the panel inside its border, so the border and its + # darker top edge stay where they are with or without art. + inside = rect.adjusted(1, 1, -1, -1) + clip = QPainterPath() + clip.addRoundedRect(inside, NO_ART_RADIUS - 1, NO_ART_RADIUS - 1) + painter.setClipPath(clip) + area = inside.toAlignedRect() scaled = self._pixmap.scaled( area.size(), Qt.AspectRatioMode.KeepAspectRatioByExpanding, Qt.TransformationMode.SmoothTransformation) - x = (area.width() - scaled.width()) // 2 + x = area.left() + (area.width() - scaled.width()) // 2 y = area.top() + (area.height() - scaled.height()) // 2 - painter.setClipRect(area) painter.drawPixmap(x, y, scaled) diff --git a/tests/test_round80.py b/tests/test_round80.py index 7eb2c80..848db36 100644 --- a/tests/test_round80.py +++ b/tests/test_round80.py @@ -136,24 +136,6 @@ def test_no_art_panel_sits_below_the_frame(qapp, tmp_path): window.close() -def test_real_art_goes_inside_the_frame(qapp, tmp_path): - from PyQt6.QtGui import QPixmap - from lintunes.gui.sidebar import ART_RULE - window, sidebar = _sidebar(qapp, tmp_path) - art = sidebar.art - pixmap = QPixmap(10, 10) - pixmap.fill(QColor("red")) - art._pixmap = pixmap - art.has_art_changed.emit(True) - art._fit_height() - assert art.parentWidget() is sidebar.list_area - assert art.height() == art.width() + ART_RULE - art._pixmap = None - art.set_track(None) - assert art.parentWidget() is sidebar - window.close() - - def test_tall_deboss_copies_a_short_panels_edges(qapp): """Shaded ``like`` a 62 px panel, a 400 px one matches it row for row along the top, and is flat in the middle.""" diff --git a/tests/test_round82.py b/tests/test_round82.py index a88f062..fe07152 100644 --- a/tests/test_round82.py +++ b/tests/test_round82.py @@ -1,27 +1,45 @@ -"""Round 82: the list frame closes above album art too.""" +"""Round 82: album art sits in the same panel, in the same place, as the +empty art panel: the cover changes what is inside the frame, never the +frame.""" from PyQt6.QtGui import QColor, QPixmap from tests.test_round80 import _sidebar -def test_art_draws_the_frames_closing_line(qapp, tmp_path): - """A white cover inside the list's frame starts under a line in the - frame's own color, not straight on from the list.""" - from lintunes.gui.sidebar import ART_RULE - window, sidebar = _sidebar(qapp, tmp_path) - art = sidebar.art +def _show_art(qapp, art, color): pixmap = QPixmap(10, 10) - pixmap.fill(QColor("white")) + pixmap.fill(QColor(color)) art._pixmap = pixmap - art.has_art_changed.emit(True) art._fit_height() qapp.processEvents() - group = sidebar.list_area.grab().toImage() - # the frame's own left edge, halfway down the list - frame = group.pixelColor(0, group.height() // 4) + + +def test_art_keeps_the_empty_panels_place_and_border(qapp, tmp_path): + from lintunes.gui.sidebar import NO_ART_GAP + window, sidebar = _sidebar(qapp, tmp_path) + art = sidebar.art + empty_geometry = art.geometry() + empty = art.grab().toImage() + _show_art(qapp, art, "white") + assert art.parentWidget() is sidebar + assert art.geometry() == empty_geometry + assert art.height() == art.width() + NO_ART_GAP image = art.grab().toImage() middle = image.width() // 2 - for y in range(ART_RULE): - assert image.pixelColor(middle, y) == frame - assert image.pixelColor(middle, ART_RULE) == QColor("white") + # the gap and the panel's darker top edge, unchanged + for y in range(NO_ART_GAP + 1): + assert image.pixelColor(middle, y) == empty.pixelColor(middle, y) + # ...and the cover just inside it + assert image.pixelColor(middle, NO_ART_GAP + 2) == QColor("white") + assert image.pixelColor(middle, image.height() // 2) == QColor("white") + window.close() + + +def test_art_changes_no_layout(qapp, tmp_path): + window, sidebar = _sidebar(qapp, tmp_path) + list_geometry = sidebar.list_area.geometry() + _show_art(qapp, sidebar.art, "red") + sidebar.art.has_art_changed.emit(True) + qapp.processEvents() + assert sidebar.list_area.geometry() == list_geometry window.close()