diff --git a/lintunes/__init__.py b/lintunes/__init__.py index 2572358..56bb6de 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.47.0" +__version__ = "0.47.1" diff --git a/lintunes/gui/sidebar.py b/lintunes/gui/sidebar.py index e44b930..daa52f3 100644 --- a/lintunes/gui/sidebar.py +++ b/lintunes/gui/sidebar.py @@ -94,8 +94,10 @@ class SidebarPanel(QWidget): inner.setSpacing(0) inner.addWidget(self._lists, stretch=1) self.art = SidebarArt(player) - inner.addWidget(self.art) layout.addWidget(group, stretch=1) + self._inner, self._outer = inner, layout + self.art.has_art_changed.connect(self._place_art) + self._place_art(False) # What the header row lines the Library button up with. self.list_area = group @@ -107,6 +109,14 @@ 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: @@ -253,17 +263,21 @@ 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 +# The no-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 class SidebarArt(QWidget): - """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.""" + """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.""" download_art_requested = pyqtSignal(int) # track id + has_art_changed = pyqtSignal(bool) def __init__(self, player, parent=None): super().__init__(parent) @@ -290,7 +304,14 @@ class SidebarArt(QWidget): self.setToolTip("") if self._art_window.isVisible(): self._art_window.set_artwork(self._pixmap, self._title()) + self._fit_height() self.update() + if self.has_art() != getattr(self, "_had_art", None): + self._had_art = self.has_art() + self.has_art_changed.emit(self._had_art) + + def has_art(self) -> bool: + return self._pixmap is not None and not self._pixmap.isNull() def refresh(self): """Re-read art for the current track (after tag edits).""" @@ -302,10 +323,15 @@ class SidebarArt(QWidget): return (" — ".join(p for p in (self._track.artist, self._track.album) if p) or self._track.name) + def _fit_height(self): + # Keep it square: height follows the sidebar's width (plus the gap + # above the empty panel) + height = self.width() + (0 if self.has_art() else NO_ART_GAP) + if self.height() != height: + self.setFixedHeight(height) + def resizeEvent(self, event): - # Keep it square: height follows the sidebar's width - if self.height() != self.width(): - self.setFixedHeight(self.width()) + self._fit_height() super().resizeEvent(event) def mouseReleaseEvent(self, event): @@ -323,17 +349,14 @@ class SidebarArt(QWidget): def paintEvent(self, event): painter = QPainter(self) 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()) + # 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( - NO_ART_MARGIN + 0.5, NO_ART_MARGIN + 0.5, - -NO_ART_MARGIN - 0.5, -NO_ART_MARGIN - 0.5) + 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, - rim=theme.button_rim_px(self.fontMetrics())) + NO_ART_RADIUS, like=CONTROL_HEIGHT) return scaled = self._pixmap.scaled( self.size(), Qt.AspectRatioMode.KeepAspectRatioByExpanding, diff --git a/lintunes/gui/track_table.py b/lintunes/gui/track_table.py index f09a051..fa43006 100644 --- a/lintunes/gui/track_table.py +++ b/lintunes/gui/track_table.py @@ -111,7 +111,7 @@ class ClickToJumpScrollStyle(QProxyStyle): if hot: color = color.darker(theme.HOVER_DARKER) theme.paint_emboss(painter, thumb, color, theme.BUTTON_RADIUS, - rim=theme.button_rim_px(option.fontMetrics)) + like=theme.button_height_px(option.fontMetrics)) painter.restore() diff --git a/lintunes/theme.py b/lintunes/theme.py index fa536b4..b21c891 100644 --- a/lintunes/theme.py +++ b/lintunes/theme.py @@ -236,13 +236,15 @@ def deboss_qss(selector: str, color: QColor, *, radius: int = 8, def paint_deboss(painter, rect, color: QColor, radius: float, - rim: float | None = None): + like: 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. - ``rim`` (pixels) fixes the rims' height for a panel much taller than a - button; without it they're ``EMBOSS_RIM_WIDTH`` of the height.""" + ``like`` (pixels) shades a panel taller than that exactly as a panel + ``like`` tall is shaded along its top and bottom, flat in between, so a + tall panel is recessed as much as a short one rather than gray at the + top.""" from PyQt6.QtGui import QPen - path = _fill_embossed(painter, rect, color, radius, rim, pressed=True) + path = _fill_embossed(painter, rect, color, radius, like, pressed=True) painter.setPen(QPen(color.darker(135), 1)) painter.drawPath(path) # the darker top edge @@ -255,38 +257,40 @@ def paint_deboss(painter, rect, color: QColor, radius: float, 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.""" + like: float | None, pressed: bool): + """Fill a rounded ``rect`` with ``emboss_stops``, shaded as a shape + ``like`` pixels tall would be at its top and bottom (None: as itself). + Returns the path.""" from PyQt6.QtCore import QPointF from PyQt6.QtGui import QLinearGradient, QPainterPath path = QPainterPath() path.addRoundedRect(rect, radius, radius) + height = max(rect.height(), 1.0) + scale = min(like, height) / height if like else 1.0 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)] + middle = stops[2][1] + top = [(pos * scale, c) for pos, c in stops[:3]] + bottom = [(1 - (1 - pos) * scale, c) for pos, c in stops[2:]] gradient = QLinearGradient(QPointF(0, rect.top()), QPointF(0, rect.bottom())) - for pos, stop in stops: + for pos, stop in top + [(top[-1][0], middle)] + bottom: 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 - text line plus padding, outline and Fusion's margin (14 px). For things - shaped otherwise (a scrollbar thumb) that still want a button's emboss.""" - return EMBOSS_RIM_WIDTH * (metrics.height() + 14) +def button_height_px(metrics) -> int: + """How tall a button is for a font with these QFontMetrics: the text + line plus padding, outline and Fusion's margin (14 px). For things shaped + otherwise (a scrollbar thumb) that still want a button's emboss.""" + return metrics.height() + 14 -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.""" +def paint_emboss(painter, rect, color: QColor, radius: float, like: float): + """A button's face for a widget that paints itself: shaded at its top + and bottom exactly as a button ``like`` pixels tall, flat in between, so + a long scrollbar thumb is embossed exactly as much as a button.""" from PyQt6.QtGui import QPen - path = _fill_embossed(painter, rect, color, radius, rim, pressed=False) + path = _fill_embossed(painter, rect, color, radius, like, pressed=False) painter.setPen(QPen(color.darker(135), 1)) painter.drawPath(path) # the darker bottom edge (emboss_edges) diff --git a/tests/test_round69.py b/tests/test_round69.py index c97bed2..96bf641 100644 --- a/tests/test_round69.py +++ b/tests/test_round69.py @@ -52,8 +52,9 @@ def test_library_button_spans_the_playlist_list(qapp, tmp_path): window = _window(qapp, tmp_path) header = window.sidebar.header lists = window.sidebar.list_area - # The frame around the playlists takes in the album art too. - assert window.sidebar.art.parentWidget() is lists + # With nothing playing the art is the empty panel, below the frame on + # the window (Round 80); the frame takes in real art (test_round80). + assert window.sidebar.art.parentWidget() is window.sidebar assert header.parentWidget() is not window.sidebar assert _gx(header) == _gx(lists) > _gx(window) assert header.width() == lists.width() diff --git a/tests/test_round80.py b/tests/test_round80.py index d5e909a..5eaec12 100644 --- a/tests/test_round80.py +++ b/tests/test_round80.py @@ -78,13 +78,12 @@ def test_thickness_follows_ui_scale(qapp): ClickToJumpScrollStyle.extent = before -def test_thumb_rims_match_a_real_button(qapp): +def test_thumb_is_shaded_like_a_real_button(qapp): qapp.setStyleSheet(theme.button_emboss_qss(QColor("#d0d0d0"))) try: button = QPushButton("OK") - height = button.sizeHint().height() - assert abs(theme.button_rim_px(button.fontMetrics()) - - theme.EMBOSS_RIM_WIDTH * height) < 1 + assert abs(theme.button_height_px(button.fontMetrics()) + - button.sizeHint().height()) <= 2 finally: qapp.setStyleSheet("") @@ -101,43 +100,77 @@ def test_thumb_paints(qapp): # -------------------------------------------------------------------------- -# no album art: a debossed panel, not a flat square +# no album art: a debossed panel on the window, not a box in the list # -------------------------------------------------------------------------- -def test_no_art_is_a_debossed_panel(qapp): - from PyQt6.QtCore import QObject, pyqtSignal - from lintunes.gui.sidebar import NO_ART_MARGIN, SidebarArt +def _sidebar(qapp, tmp_path): + from lintunes.gui.main_window import MainWindow + from lintunes.library_manager import LibraryManager + from lintunes.models.library import Library + from lintunes.models.track import Track + from lintunes.preferences import Preferences + library = Library(tracks={1: Track(track_id=1, name="One")}) + window = MainWindow(LibraryManager(library, tmp_path), Preferences(tmp_path)) + window.resize(1000, 700) + window.show() + qapp.processEvents() + return window, window.sidebar - class Player(QObject): - track_changed = pyqtSignal(object) - art = SidebarArt(Player()) - art.resize(200, 200) +def test_no_art_panel_sits_below_the_frame(qapp, tmp_path): + from lintunes.gui.sidebar import NO_ART_GAP + window, sidebar = _sidebar(qapp, tmp_path) + art = sidebar.art + assert not art.has_art() + assert art.parentWidget() is sidebar + assert art.y() >= sidebar.list_area.geometry().bottom() + assert art.height() == art.width() + NO_ART_GAP 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 + window_bg = art.palette().window().color() + # the gap above is bare window; the panel's middle is the visualizer's + # color, flat, not shaded gray over its whole height + assert image.pixelColor(art.width() // 2, 1) == window_bg + panel = art.palette().alternateBase().color() + for y in (art.height() // 3, art.height() // 2, 2 * art.height() // 3): + assert image.pixelColor(art.width() // 2, y) == panel + window.close() -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.""" +def test_real_art_goes_inside_the_frame(qapp, tmp_path): + from PyQt6.QtGui import QPixmap + 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._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.""" from PyQt6.QtCore import QRectF from PyQt6.QtGui import QImage, QPainter - color = QColor("#e0e0e0") + color = QColor("#f2f2f2") - def row10(**kw): - image = QImage(100, 400, QImage.Format.Format_ARGB32) + def paint(height, **kw): + image = QImage(100, height, QImage.Format.Format_ARGB32) image.fill(0) painter = QPainter(image) - theme.paint_deboss(painter, QRectF(0.5, 0.5, 99, 399), color, 8, **kw) + theme.paint_deboss(painter, QRectF(0.5, 0.5, 99, height - 1), + color, 8, **kw) painter.end() - return image.pixelColor(50, 10).lightness() + return image - assert row10(rim=3) > row10() + 5 + short, tall = paint(62), paint(400, like=62) + for y in (3, 10, 20): + assert abs(short.pixelColor(50, y).lightness() + - tall.pixelColor(50, y).lightness()) <= 1 + assert tall.pixelColor(50, 200) == color