diff --git a/lintunes/__init__.py b/lintunes/__init__.py index 136933a..2572358 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.46.0" +__version__ = "0.47.0" diff --git a/lintunes/gui/sidebar.py b/lintunes/gui/sidebar.py index 0decca9..e44b930 100644 --- a/lintunes/gui/sidebar.py +++ b/lintunes/gui/sidebar.py @@ -4,7 +4,7 @@ from PyQt6.QtWidgets import ( QHBoxLayout, QListWidget, QListWidgetItem, QStackedWidget, QSizePolicy, 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 lintunes.models import PlaylistType @@ -253,9 +253,14 @@ class FriendPlaylistList(QListWidget): 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): - """Square album art pinned to the sidebar bottom; gray square when the - current track has no embedded art. A click opens the big art window, or, + """Square album art pinned to the sidebar bottom; an empty debossed panel + 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.""" download_art_requested = pyqtSignal(int) # track id @@ -317,10 +322,18 @@ class SidebarArt(QWidget): def paintEvent(self, event): 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(): + # 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 scaled = self._pixmap.scaled( self.size(), Qt.AspectRatioMode.KeepAspectRatioByExpanding, diff --git a/lintunes/theme.py b/lintunes/theme.py index a94b1a1..fa536b4 100644 --- a/lintunes/theme.py +++ b/lintunes/theme.py @@ -235,17 +235,14 @@ def deboss_qss(selector: str, color: QColor, *, radius: int = 8, 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 - QRectF) as a debossed rounded panel and returns its path, for clipping.""" - from PyQt6.QtCore import QPointF - from PyQt6.QtGui import QLinearGradient, QPainterPath, QPen - path = QPainterPath() - path.addRoundedRect(rect, radius, radius) - 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) + QRectF) as a debossed rounded panel and returns its path, for clipping. + ``rim`` (pixels) fixes the rims' height for a panel much taller than a + button; without it they're ``EMBOSS_RIM_WIDTH`` of the height.""" + from PyQt6.QtGui import QPen + path = _fill_embossed(painter, rect, color, radius, rim, pressed=True) painter.setPen(QPen(color.darker(135), 1)) painter.drawPath(path) # the darker top edge @@ -257,6 +254,25 @@ def paint_deboss(painter, rect, color: QColor, radius: float): 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: """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 @@ -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 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.""" - from PyQt6.QtCore import QPointF - from PyQt6.QtGui import QLinearGradient, QPainterPath, QPen - 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) + from PyQt6.QtGui import QPen + path = _fill_embossed(painter, rect, color, radius, rim, pressed=False) painter.setPen(QPen(color.darker(135), 1)) painter.drawPath(path) # the darker bottom edge (emboss_edges) diff --git a/tests/test_round80.py b/tests/test_round80.py index 2696094..d5e909a 100644 --- a/tests/test_round80.py +++ b/tests/test_round80.py @@ -98,3 +98,46 @@ def test_thumb_paints(qapp): trough = image.pixelColor(7, 5) middle = image.pixelColor(7, thumb.center().y()) 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