v0.47.0: no album art shows a debossed panel, not a flat square

The sidebar's art square, when a track has no art, is now the list's own
background with a rounded panel set into it, debossed like the visualizer
and the now-playing panel. paint_deboss takes its rims in pixels when
asked, so a panel as tall as the sidebar is recessed as much as a short
one rather than fading over 16 px.
This commit is contained in:
2026-10-10 11:26:33 -07:00
parent 617402657f
commit fbb439b7ee
4 changed files with 91 additions and 29 deletions
+1 -1
View File
@@ -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.46.0" __version__ = "0.47.0"
+19 -6
View File
@@ -4,7 +4,7 @@ from PyQt6.QtWidgets import (
QHBoxLayout, QListWidget, QListWidgetItem, QStackedWidget, QSizePolicy, QHBoxLayout, QListWidget, QListWidgetItem, QStackedWidget, QSizePolicy,
QFrame, QFrame,
) )
from PyQt6.QtCore import Qt, QTimer, QPoint, pyqtSignal from PyQt6.QtCore import Qt, QTimer, QPoint, QRectF, pyqtSignal
from PyQt6.QtGui import QBrush, QPainter, QPalette, QPixmap from PyQt6.QtGui import QBrush, QPainter, QPalette, QPixmap
from lintunes.models import PlaylistType from lintunes.models import PlaylistType
@@ -253,9 +253,14 @@ class FriendPlaylistList(QListWidget):
self.playlist_selected.emit(current.data(Qt.ItemDataRole.UserRole)) self.playlist_selected.emit(current.data(Qt.ItemDataRole.UserRole))
# The no-art panel: inset from the list's edges, corners like the visualizer's.
NO_ART_MARGIN = 6
NO_ART_RADIUS = 8
class SidebarArt(QWidget): class SidebarArt(QWidget):
"""Square album art pinned to the sidebar bottom; gray square when the """Square album art pinned to the sidebar bottom; an empty debossed panel
current track has no embedded art. A click opens the big art window, or, when the current track has no embedded art. A click opens the big art window, or,
on the gray square, asks for the track's art to be downloaded.""" on the gray square, asks for the track's art to be downloaded."""
download_art_requested = pyqtSignal(int) # track id download_art_requested = pyqtSignal(int) # track id
@@ -317,10 +322,18 @@ class SidebarArt(QWidget):
def paintEvent(self, event): def paintEvent(self, event):
painter = QPainter(self) painter = QPainter(self)
# A shade lighter than mid: the sidebar group's frame line is drawn
# in mid, and would vanish into a placeholder of the same gray.
painter.fillRect(self.rect(), self.palette().mid().color().lighter(112))
if self._pixmap is None or self._pixmap.isNull(): if self._pixmap is None or self._pixmap.isNull():
# No art: the list's own background, and in it a rounded panel
# debossed like the visualizer and the now-playing panel.
painter.fillRect(self.rect(), self.palette().base())
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
rect = QRectF(self.rect()).adjusted(
NO_ART_MARGIN + 0.5, NO_ART_MARGIN + 0.5,
-NO_ART_MARGIN - 0.5, -NO_ART_MARGIN - 0.5)
theme.paint_deboss(painter, rect,
self.palette().alternateBase().color(),
NO_ART_RADIUS,
rim=theme.button_rim_px(self.fontMetrics()))
return return
scaled = self._pixmap.scaled( scaled = self._pixmap.scaled(
self.size(), Qt.AspectRatioMode.KeepAspectRatioByExpanding, self.size(), Qt.AspectRatioMode.KeepAspectRatioByExpanding,
+28 -22
View File
@@ -235,17 +235,14 @@ def deboss_qss(selector: str, color: QColor, *, radius: int = 8,
f" background: {emboss_gradient(color, pressed=True)}; {extra} }}") f" background: {emboss_gradient(color, pressed=True)}; {extra} }}")
def paint_deboss(painter, rect, color: QColor, radius: float): def paint_deboss(painter, rect, color: QColor, radius: float,
rim: float | None = None):
"""``deboss_qss`` for a widget that paints itself: fills ``rect`` (a """``deboss_qss`` for a widget that paints itself: fills ``rect`` (a
QRectF) as a debossed rounded panel and returns its path, for clipping.""" QRectF) as a debossed rounded panel and returns its path, for clipping.
from PyQt6.QtCore import QPointF ``rim`` (pixels) fixes the rims' height for a panel much taller than a
from PyQt6.QtGui import QLinearGradient, QPainterPath, QPen button; without it they're ``EMBOSS_RIM_WIDTH`` of the height."""
path = QPainterPath() from PyQt6.QtGui import QPen
path.addRoundedRect(rect, radius, radius) path = _fill_embossed(painter, rect, color, radius, rim, pressed=True)
gradient = QLinearGradient(QPointF(0, rect.top()), QPointF(0, rect.bottom()))
for pos, stop in emboss_stops(color, pressed=True):
gradient.setColorAt(pos, stop)
painter.fillPath(path, gradient)
painter.setPen(QPen(color.darker(135), 1)) painter.setPen(QPen(color.darker(135), 1))
painter.drawPath(path) painter.drawPath(path)
# the darker top edge # the darker top edge
@@ -257,6 +254,25 @@ def paint_deboss(painter, rect, color: QColor, radius: float):
return path return path
def _fill_embossed(painter, rect, color: QColor, radius: float,
rim: float | None, pressed: bool):
"""Fill a rounded ``rect`` with ``emboss_stops``; ``rim`` in pixels, or
None for the stops' own fractions. Returns the path."""
from PyQt6.QtCore import QPointF
from PyQt6.QtGui import QLinearGradient, QPainterPath
path = QPainterPath()
path.addRoundedRect(rect, radius, radius)
stops = emboss_stops(color, pressed)
if rim is not None:
edge = min(rim / max(rect.height(), 1.0), 0.49)
stops = [(pos, c) for pos, (_, c) in zip([0, edge, 0.5, 1 - edge, 1], stops)]
gradient = QLinearGradient(QPointF(0, rect.top()), QPointF(0, rect.bottom()))
for pos, stop in stops:
gradient.setColorAt(pos, stop)
painter.fillPath(path, gradient)
return path
def button_rim_px(metrics) -> float: def button_rim_px(metrics) -> float:
"""How tall a button's rims are, in pixels, for a font with these """How tall a button's rims are, in pixels, for a font with these
QFontMetrics: ``EMBOSS_RIM_WIDTH`` of a button's height, which is the QFontMetrics: ``EMBOSS_RIM_WIDTH`` of a button's height, which is the
@@ -269,18 +285,8 @@ def paint_emboss(painter, rect, color: QColor, radius: float, rim: float):
"""A button's face for a widget that paints itself: ``emboss_stops`` lit """A button's face for a widget that paints itself: ``emboss_stops`` lit
from above, but with rims ``rim`` pixels tall whatever ``rect``'s height, from above, but with rims ``rim`` pixels tall whatever ``rect``'s height,
so a long scrollbar thumb is embossed exactly as much as a button.""" so a long scrollbar thumb is embossed exactly as much as a button."""
from PyQt6.QtCore import QPointF from PyQt6.QtGui import QPen
from PyQt6.QtGui import QLinearGradient, QPainterPath, QPen path = _fill_embossed(painter, rect, color, radius, rim, pressed=False)
path = QPainterPath()
path.addRoundedRect(rect, radius, radius)
height = max(rect.height(), 1.0)
edge = min(rim / height, 0.49)
stops = emboss_stops(color)
positions = [0, edge, 0.5, 1 - edge, 1]
gradient = QLinearGradient(QPointF(0, rect.top()), QPointF(0, rect.bottom()))
for pos, (_, stop) in zip(positions, stops):
gradient.setColorAt(pos, stop)
painter.fillPath(path, gradient)
painter.setPen(QPen(color.darker(135), 1)) painter.setPen(QPen(color.darker(135), 1))
painter.drawPath(path) painter.drawPath(path)
# the darker bottom edge (emboss_edges) # the darker bottom edge (emboss_edges)
+43
View File
@@ -98,3 +98,46 @@ def test_thumb_paints(qapp):
trough = image.pixelColor(7, 5) trough = image.pixelColor(7, 5)
middle = image.pixelColor(7, thumb.center().y()) middle = image.pixelColor(7, thumb.center().y())
assert middle != trough assert middle != trough
# --------------------------------------------------------------------------
# no album art: a debossed panel, not a flat square
# --------------------------------------------------------------------------
def test_no_art_is_a_debossed_panel(qapp):
from PyQt6.QtCore import QObject, pyqtSignal
from lintunes.gui.sidebar import NO_ART_MARGIN, SidebarArt
class Player(QObject):
track_changed = pyqtSignal(object)
art = SidebarArt(Player())
art.resize(200, 200)
image = art.grab().toImage()
base = art.palette().base().color()
# the margin around the panel is the list's own background...
assert image.pixelColor(1, 100) == base
# ...the corner is rounded off, so it's background too...
assert image.pixelColor(NO_ART_MARGIN + 1, NO_ART_MARGIN + 1) == base
# ...and the panel is shaded darker at the top than in the middle
top = image.pixelColor(100, NO_ART_MARGIN + 2).lightness()
middle = image.pixelColor(100, 100).lightness()
assert top < middle
def test_deboss_rims_can_be_fixed_in_pixels(qapp):
"""On a 400 px panel the default rims are 32 px deep; 3 px rims leave
row 10 already on the face, a good deal lighter."""
from PyQt6.QtCore import QRectF
from PyQt6.QtGui import QImage, QPainter
color = QColor("#e0e0e0")
def row10(**kw):
image = QImage(100, 400, QImage.Format.Format_ARGB32)
image.fill(0)
painter = QPainter(image)
theme.paint_deboss(painter, QRectF(0.5, 0.5, 99, 399), color, 8, **kw)
painter.end()
return image.pixelColor(50, 10).lightness()
assert row10(rim=3) > row10() + 5