v0.47.1: the no-art panel sits on the window, shaded like the visualizer
With no art, the sidebar's art widget moves out of the playlist list's frame onto the window below it, so the panel is set into the gray like the visualizer rather than into a white box. A tall debossed panel is now shaded along its top and bottom exactly as the 62 px visualizer is, flat in the visualizer's color between, instead of one gradient stretched over its whole height, which read as gray. The scrollbar thumb is shaded the same way, like a button.
This commit is contained in:
@@ -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.47.0"
|
__version__ = "0.47.1"
|
||||||
|
|||||||
+40
-17
@@ -94,8 +94,10 @@ class SidebarPanel(QWidget):
|
|||||||
inner.setSpacing(0)
|
inner.setSpacing(0)
|
||||||
inner.addWidget(self._lists, stretch=1)
|
inner.addWidget(self._lists, stretch=1)
|
||||||
self.art = SidebarArt(player)
|
self.art = SidebarArt(player)
|
||||||
inner.addWidget(self.art)
|
|
||||||
layout.addWidget(group, stretch=1)
|
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.
|
# What the header row lines the Library button up with.
|
||||||
self.list_area = group
|
self.list_area = group
|
||||||
|
|
||||||
@@ -107,6 +109,14 @@ class SidebarPanel(QWidget):
|
|||||||
self.friend_list.playlist_selected.connect(self._on_friend_playlist)
|
self.friend_list.playlist_selected.connect(self._on_friend_playlist)
|
||||||
self.friend_list.follow_toggled.connect(self.follow_toggled)
|
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):
|
def _on_library_clicked(self):
|
||||||
self.library_button.setChecked(True)
|
self.library_button.setChecked(True)
|
||||||
if self.friend_mode:
|
if self.friend_mode:
|
||||||
@@ -253,17 +263,21 @@ 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.
|
# The no-art panel: on the window under the list's frame, this far below
|
||||||
NO_ART_MARGIN = 6
|
# it, with the visualizer's corners.
|
||||||
|
NO_ART_GAP = 6
|
||||||
NO_ART_RADIUS = 8
|
NO_ART_RADIUS = 8
|
||||||
|
|
||||||
|
|
||||||
class SidebarArt(QWidget):
|
class SidebarArt(QWidget):
|
||||||
"""Square album art pinned to the sidebar bottom; an empty debossed panel
|
"""Square album art pinned to the sidebar bottom, inside the playlist
|
||||||
when the current track has no embedded art. A click opens the big art window, or,
|
list's frame. With no embedded art it is an empty panel instead, set
|
||||||
on the gray square, asks for the track's art to be downloaded."""
|
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
|
download_art_requested = pyqtSignal(int) # track id
|
||||||
|
has_art_changed = pyqtSignal(bool)
|
||||||
|
|
||||||
def __init__(self, player, parent=None):
|
def __init__(self, player, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
@@ -290,7 +304,14 @@ class SidebarArt(QWidget):
|
|||||||
self.setToolTip("")
|
self.setToolTip("")
|
||||||
if self._art_window.isVisible():
|
if self._art_window.isVisible():
|
||||||
self._art_window.set_artwork(self._pixmap, self._title())
|
self._art_window.set_artwork(self._pixmap, self._title())
|
||||||
|
self._fit_height()
|
||||||
self.update()
|
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):
|
def refresh(self):
|
||||||
"""Re-read art for the current track (after tag edits)."""
|
"""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)
|
return (" — ".join(p for p in (self._track.artist, self._track.album) if p)
|
||||||
or self._track.name)
|
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):
|
def resizeEvent(self, event):
|
||||||
# Keep it square: height follows the sidebar's width
|
self._fit_height()
|
||||||
if self.height() != self.width():
|
|
||||||
self.setFixedHeight(self.width())
|
|
||||||
super().resizeEvent(event)
|
super().resizeEvent(event)
|
||||||
|
|
||||||
def mouseReleaseEvent(self, event):
|
def mouseReleaseEvent(self, event):
|
||||||
@@ -323,17 +349,14 @@ class SidebarArt(QWidget):
|
|||||||
def paintEvent(self, event):
|
def paintEvent(self, event):
|
||||||
painter = QPainter(self)
|
painter = QPainter(self)
|
||||||
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
|
# No art: straight onto the window, a panel debossed in the
|
||||||
# debossed like the visualizer and the now-playing panel.
|
# visualizer's color and shaded at its edges as the visualizer is.
|
||||||
painter.fillRect(self.rect(), self.palette().base())
|
from lintunes.gui.transport import CONTROL_HEIGHT
|
||||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||||
rect = QRectF(self.rect()).adjusted(
|
rect = QRectF(self.rect()).adjusted(0.5, NO_ART_GAP + 0.5, -0.5, -0.5)
|
||||||
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,
|
theme.paint_deboss(painter, rect,
|
||||||
self.palette().alternateBase().color(),
|
self.palette().alternateBase().color(),
|
||||||
NO_ART_RADIUS,
|
NO_ART_RADIUS, like=CONTROL_HEIGHT)
|
||||||
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,
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ class ClickToJumpScrollStyle(QProxyStyle):
|
|||||||
if hot:
|
if hot:
|
||||||
color = color.darker(theme.HOVER_DARKER)
|
color = color.darker(theme.HOVER_DARKER)
|
||||||
theme.paint_emboss(painter, thumb, color, theme.BUTTON_RADIUS,
|
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()
|
painter.restore()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+26
-22
@@ -236,13 +236,15 @@ def deboss_qss(selector: str, color: QColor, *, radius: int = 8,
|
|||||||
|
|
||||||
|
|
||||||
def paint_deboss(painter, rect, color: QColor, radius: float,
|
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
|
"""``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.
|
||||||
``rim`` (pixels) fixes the rims' height for a panel much taller than a
|
``like`` (pixels) shades a panel taller than that exactly as a panel
|
||||||
button; without it they're ``EMBOSS_RIM_WIDTH`` of the height."""
|
``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
|
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.setPen(QPen(color.darker(135), 1))
|
||||||
painter.drawPath(path)
|
painter.drawPath(path)
|
||||||
# the darker top edge
|
# 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,
|
def _fill_embossed(painter, rect, color: QColor, radius: float,
|
||||||
rim: float | None, pressed: bool):
|
like: float | None, pressed: bool):
|
||||||
"""Fill a rounded ``rect`` with ``emboss_stops``; ``rim`` in pixels, or
|
"""Fill a rounded ``rect`` with ``emboss_stops``, shaded as a shape
|
||||||
None for the stops' own fractions. Returns the path."""
|
``like`` pixels tall would be at its top and bottom (None: as itself).
|
||||||
|
Returns the path."""
|
||||||
from PyQt6.QtCore import QPointF
|
from PyQt6.QtCore import QPointF
|
||||||
from PyQt6.QtGui import QLinearGradient, QPainterPath
|
from PyQt6.QtGui import QLinearGradient, QPainterPath
|
||||||
path = QPainterPath()
|
path = QPainterPath()
|
||||||
path.addRoundedRect(rect, radius, radius)
|
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)
|
stops = emboss_stops(color, pressed)
|
||||||
if rim is not None:
|
middle = stops[2][1]
|
||||||
edge = min(rim / max(rect.height(), 1.0), 0.49)
|
top = [(pos * scale, c) for pos, c in stops[:3]]
|
||||||
stops = [(pos, c) for pos, (_, c) in zip([0, edge, 0.5, 1 - edge, 1], stops)]
|
bottom = [(1 - (1 - pos) * scale, c) for pos, c in stops[2:]]
|
||||||
gradient = QLinearGradient(QPointF(0, rect.top()), QPointF(0, rect.bottom()))
|
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)
|
gradient.setColorAt(pos, stop)
|
||||||
painter.fillPath(path, gradient)
|
painter.fillPath(path, gradient)
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
||||||
def button_rim_px(metrics) -> float:
|
def button_height_px(metrics) -> int:
|
||||||
"""How tall a button's rims are, in pixels, for a font with these
|
"""How tall a button is for a font with these QFontMetrics: the text
|
||||||
QFontMetrics: ``EMBOSS_RIM_WIDTH`` of a button's height, which is the
|
line plus padding, outline and Fusion's margin (14 px). For things shaped
|
||||||
text line plus padding, outline and Fusion's margin (14 px). For things
|
otherwise (a scrollbar thumb) that still want a button's emboss."""
|
||||||
shaped otherwise (a scrollbar thumb) that still want a button's emboss."""
|
return metrics.height() + 14
|
||||||
return EMBOSS_RIM_WIDTH * (metrics.height() + 14)
|
|
||||||
|
|
||||||
|
|
||||||
def paint_emboss(painter, rect, color: QColor, radius: float, rim: float):
|
def paint_emboss(painter, rect, color: QColor, radius: float, like: float):
|
||||||
"""A button's face for a widget that paints itself: ``emboss_stops`` lit
|
"""A button's face for a widget that paints itself: shaded at its top
|
||||||
from above, but with rims ``rim`` pixels tall whatever ``rect``'s height,
|
and bottom exactly as a button ``like`` pixels tall, flat in between, so
|
||||||
so a long scrollbar thumb is embossed exactly as much as a button."""
|
a long scrollbar thumb is embossed exactly as much as a button."""
|
||||||
from PyQt6.QtGui import QPen
|
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.setPen(QPen(color.darker(135), 1))
|
||||||
painter.drawPath(path)
|
painter.drawPath(path)
|
||||||
# the darker bottom edge (emboss_edges)
|
# the darker bottom edge (emboss_edges)
|
||||||
|
|||||||
@@ -52,8 +52,9 @@ def test_library_button_spans_the_playlist_list(qapp, tmp_path):
|
|||||||
window = _window(qapp, tmp_path)
|
window = _window(qapp, tmp_path)
|
||||||
header = window.sidebar.header
|
header = window.sidebar.header
|
||||||
lists = window.sidebar.list_area
|
lists = window.sidebar.list_area
|
||||||
# The frame around the playlists takes in the album art too.
|
# With nothing playing the art is the empty panel, below the frame on
|
||||||
assert window.sidebar.art.parentWidget() is lists
|
# 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 header.parentWidget() is not window.sidebar
|
||||||
assert _gx(header) == _gx(lists) > _gx(window)
|
assert _gx(header) == _gx(lists) > _gx(window)
|
||||||
assert header.width() == lists.width()
|
assert header.width() == lists.width()
|
||||||
|
|||||||
+63
-30
@@ -78,13 +78,12 @@ def test_thickness_follows_ui_scale(qapp):
|
|||||||
ClickToJumpScrollStyle.extent = before
|
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")))
|
qapp.setStyleSheet(theme.button_emboss_qss(QColor("#d0d0d0")))
|
||||||
try:
|
try:
|
||||||
button = QPushButton("OK")
|
button = QPushButton("OK")
|
||||||
height = button.sizeHint().height()
|
assert abs(theme.button_height_px(button.fontMetrics())
|
||||||
assert abs(theme.button_rim_px(button.fontMetrics())
|
- button.sizeHint().height()) <= 2
|
||||||
- theme.EMBOSS_RIM_WIDTH * height) < 1
|
|
||||||
finally:
|
finally:
|
||||||
qapp.setStyleSheet("")
|
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):
|
def _sidebar(qapp, tmp_path):
|
||||||
from PyQt6.QtCore import QObject, pyqtSignal
|
from lintunes.gui.main_window import MainWindow
|
||||||
from lintunes.gui.sidebar import NO_ART_MARGIN, SidebarArt
|
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())
|
def test_no_art_panel_sits_below_the_frame(qapp, tmp_path):
|
||||||
art.resize(200, 200)
|
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()
|
image = art.grab().toImage()
|
||||||
base = art.palette().base().color()
|
window_bg = art.palette().window().color()
|
||||||
# the margin around the panel is the list's own background...
|
# the gap above is bare window; the panel's middle is the visualizer's
|
||||||
assert image.pixelColor(1, 100) == base
|
# color, flat, not shaded gray over its whole height
|
||||||
# ...the corner is rounded off, so it's background too...
|
assert image.pixelColor(art.width() // 2, 1) == window_bg
|
||||||
assert image.pixelColor(NO_ART_MARGIN + 1, NO_ART_MARGIN + 1) == base
|
panel = art.palette().alternateBase().color()
|
||||||
# ...and the panel is shaded darker at the top than in the middle
|
for y in (art.height() // 3, art.height() // 2, 2 * art.height() // 3):
|
||||||
top = image.pixelColor(100, NO_ART_MARGIN + 2).lightness()
|
assert image.pixelColor(art.width() // 2, y) == panel
|
||||||
middle = image.pixelColor(100, 100).lightness()
|
window.close()
|
||||||
assert top < middle
|
|
||||||
|
|
||||||
|
|
||||||
def test_deboss_rims_can_be_fixed_in_pixels(qapp):
|
def test_real_art_goes_inside_the_frame(qapp, tmp_path):
|
||||||
"""On a 400 px panel the default rims are 32 px deep; 3 px rims leave
|
from PyQt6.QtGui import QPixmap
|
||||||
row 10 already on the face, a good deal lighter."""
|
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.QtCore import QRectF
|
||||||
from PyQt6.QtGui import QImage, QPainter
|
from PyQt6.QtGui import QImage, QPainter
|
||||||
color = QColor("#e0e0e0")
|
color = QColor("#f2f2f2")
|
||||||
|
|
||||||
def row10(**kw):
|
def paint(height, **kw):
|
||||||
image = QImage(100, 400, QImage.Format.Format_ARGB32)
|
image = QImage(100, height, QImage.Format.Format_ARGB32)
|
||||||
image.fill(0)
|
image.fill(0)
|
||||||
painter = QPainter(image)
|
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()
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user