v0.31.1: one button look — crisper emboss, darken on hover

- The emboss hugs the edges: thin lit and shaded rims, a defined outline
  that's a touch darker along the bottom, and a nearly flat face — shape
  without depth.
- Hover darkens the whole button slightly instead of Qt's pale panel on
  top of it.
- One face for every button (theme.button_face_qss): regular buttons,
  Library and its ▾, the purple friend-mode buttons, and the transport
  boxes. Shuffle and bpm are boxes that are one button, so the whole box
  darkens (bpm's whole box is now its tap target); the play box's three
  tiles each darken on their own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-28 22:17:05 -07:00
co-authored by Claude Opus 5.5
parent 051312d91d
commit c8c90b830a
6 changed files with 145 additions and 73 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.31.0" __version__ = "0.31.1"
+5 -8
View File
@@ -29,14 +29,11 @@ ON_HOVER_GREY = QColor("#bdbdbd")
def friend_button_qss(selector: str = "QPushButton", padding: str = "3px 12px") -> str: def friend_button_qss(selector: str = "QPushButton", padding: str = "3px 12px") -> str:
"""Light purple, embossed like every other button.""" """Light purple, with the same face as every other button."""
light = FRIEND_PURPLE.lighter(106).name() from lintunes import theme
dark = FRIEND_PURPLE.darker(104).name() return theme.button_face_qss(
return (f"{selector} {{ background: qlineargradient(x1:0, y1:0, x2:0, y2:1," selector, FRIEND_PURPLE,
f" stop:0 {light}, stop:0.5 {FRIEND_PURPLE.name()}, stop:1 {dark});" extra=f"color: black; padding: {padding}; min-height: 0;")
f" color: black; border: 1px solid {FRIEND_PURPLE_DARK.name()};"
f" border-radius: 4px; padding: {padding}; min-height: 0; }}"
f"{selector}:pressed {{ background: {FRIEND_PURPLE_DARK.name()}; }}")
def button_rect(cell: QRectF, margin: float = 2.0) -> QRectF: def button_rect(cell: QRectF, margin: float = 2.0) -> QRectF:
+13 -21
View File
@@ -148,35 +148,27 @@ class SidebarPanel(QWidget):
emboss follows theme changes like every other button's. With the emboss follows theme changes like every other button's. With the
friend selector showing, the two share a border and have square friend selector showing, the two share a border and have square
corners where they meet; in friend mode both turn light purple.""" corners where they meet; in friend mode both turn light purple."""
from lintunes.gui.cassette_art import FRIEND_PURPLE, FRIEND_PURPLE_DARK from lintunes.gui.cassette_art import FRIEND_PURPLE
palette = QApplication.palette() palette = QApplication.palette()
base = palette.color(QPalette.ColorRole.AlternateBase) base = palette.color(QPalette.ColorRole.AlternateBase)
lit = palette.color(QPalette.ColorRole.Highlight) lit = palette.color(QPalette.ColorRole.Highlight)
split = not self.friend_button.isHidden() checked_text = palette.color(QPalette.ColorRole.HighlightedText).name()
border = "palette(mid)"
if self.friend_mode: if self.friend_mode:
base = lit = FRIEND_PURPLE base = lit = FRIEND_PURPLE
border = FRIEND_PURPLE_DARK.name() checked_text = "black"
checked_text = "black" if self.friend_mode else "palette(highlighted-text)" split = not self.friend_button.isHidden()
joint = ("border-top-right-radius: 0; border-bottom-right-radius: 0;" joint = ("border-top-right-radius: 0; border-bottom-right-radius: 0;"
" margin: 2px 0 2px 8px;" if split else " margin: 2px 0 2px 8px;" if split else
"margin: 2px 4px 2px 8px;") "margin: 2px 4px 2px 8px;")
self.library_button.setStyleSheet( self.library_button.setStyleSheet(theme.button_face_qss(
"QPushButton { text-align: left; padding-left: 10px;" "QPushButton", base, extra=f"text-align: left; padding-left: 10px; {joint}",
f" border: 1px solid {border}; border-radius: 4px; {joint}" checked_color=lit, checked_text=checked_text))
f" background: {theme.emboss_gradient(base)}; }}" # The ▾ is the same button cut off at the end: same face, no left
"QPushButton:checked {" # border (it shares the Library button's), square where they meet.
f" background: {theme.emboss_gradient(lit, pressed=True)};" self.friend_button.setStyleSheet(theme.button_face_qss(
f" color: {checked_text}; }}") "QPushButton", base,
self.friend_button.setStyleSheet( extra="padding: 0; border-left: none; border-top-left-radius: 0;"
"QPushButton { padding: 0;" " border-bottom-left-radius: 0; margin: 2px 4px 2px 0;"))
f" border: 1px solid {border}; border-left: none;"
" border-radius: 4px; border-top-left-radius: 0;"
" border-bottom-left-radius: 0; margin: 2px 4px 2px 0;"
f" background: {theme.emboss_gradient(base)}; }}"
"QPushButton:pressed {"
f" background: {theme.emboss_gradient(base, pressed=True)}; }}")
class FriendPlaylistList(QListWidget): class FriendPlaylistList(QListWidget):
"""A friend's shared playlists in friend mode. The UFO at each row's left """A friend's shared playlists in friend mode. The UFO at each row's left
+35 -12
View File
@@ -31,13 +31,32 @@ CONTROL_HEIGHT = 62
# panel (the seek row sits below the panel, not beside the boxes). # panel (the seek row sits below the panel, not beside the boxes).
TOP = Qt.AlignmentFlag.AlignTop TOP = Qt.AlignmentFlag.AlignTop
def _box_style(bg: QColor) -> str: def _box_style(bg: QColor, button: bool = False) -> str:
return f""" """A transport box. A box that *is* one button (shuffle, bpm) gets the
QFrame#transportBox {{ shared button face — emboss, outline, whole-box darkening on hover; the
border: 1px solid palette(mid); three-tile play box gets the same face, and each tile darkens on its own
border-radius: 8px; (see TILE_STYLE)."""
background: {theme.emboss_gradient(bg)}; face = theme.button_face_qss("QFrame#transportBox", bg, radius=8)
}} if not button:
# Hover belongs to the tiles here, not the whole box.
face += (f"QFrame#transportBox:hover {{ background:"
f" {theme.emboss_gradient(bg)}; }}")
return face + TILE_STYLE
# Tool buttons inside a box: no light panel of their own. A tile in the play
# box darkens slightly under the cursor — the same "whole button, a little
# darker" as every other button — and a lone button leaves that to its box.
TILE_STYLE = """
QFrame#transportBox QToolButton, QFrame#transportBox QPushButton {
border: none; background: transparent; border-radius: 6px;
}
QFrame#transportBox[tiles="true"] QToolButton:hover {
background: rgba(0, 0, 0, 22);
}
QFrame#transportBox[tiles="true"] QToolButton:pressed {
background: rgba(0, 0, 0, 38);
}
""" """
@@ -105,7 +124,10 @@ def _box(*widgets, bg: QColor, margins=(10, 3, 10, 3), hug=False,
a full-bleed tap target with its content centered in its share.""" a full-bleed tap target with its content centered in its share."""
frame = QFrame() frame = QFrame()
frame.setObjectName("transportBox") frame.setObjectName("transportBox")
frame.setStyleSheet(_box_style(bg)) # A box holding one control is that control: it darkens as a whole.
frame.setProperty("tiles", len(widgets) > 1)
frame.setAttribute(Qt.WidgetAttribute.WA_Hover)
frame.setStyleSheet(_box_style(bg, button=len(widgets) == 1))
inner = QHBoxLayout(frame) inner = QHBoxLayout(frame)
inner.setContentsMargins(*margins) inner.setContentsMargins(*margins)
if split: if split:
@@ -180,7 +202,7 @@ class BpmButton(QPushButton):
self._manager = manager self._manager = manager
self._tempo = TapTempo() self._tempo = TapTempo()
self._target_track_id: int | None = None self._target_track_id: int | None = None
self.setFixedWidth(64) self.setFixedWidth(74) # + the box's 3 px margins = its old footprint
self.setFlat(True) # flat text inside its box, like the transport icons self.setFlat(True) # flat text inside its box, like the transport icons
self.setFocusPolicy(Qt.FocusPolicy.NoFocus) self.setFocusPolicy(Qt.FocusPolicy.NoFocus)
self.setEnabled(False) self.setEnabled(False)
@@ -356,8 +378,9 @@ class TransportBar(QWidget):
# with the now-playing panel like the transport boxes — kept out of # with the now-playing panel like the transport boxes — kept out of
# the seek row so it doesn't drive the timeline row's height. # the seek row so it doesn't drive the timeline row's height.
self._bpm_button = BpmButton(player, manager) self._bpm_button = BpmButton(player, manager)
bpm_box = _box(self._bpm_button, bg=box_bg, margins=(8, 8, 8, 8), # The whole box is the tap target, so it darkens (and taps) as one.
hug=True, height=CONTROL_HEIGHT) bpm_box = _box(self._bpm_button, bg=box_bg, margins=(3, 3, 3, 3),
hug=True, height=CONTROL_HEIGHT, split=True)
self._boxes.append(bpm_box) self._boxes.append(bpm_box)
layout.addWidget(bpm_box, alignment=TOP) layout.addWidget(bpm_box, alignment=TOP)
@@ -388,7 +411,7 @@ class TransportBar(QWidget):
button.refresh_icons() button.refresh_icons()
box_bg = theme.gray(theme.gray_value(self._prefs, "color_button")) box_bg = theme.gray(theme.gray_value(self._prefs, "color_button"))
for box in self._boxes: for box in self._boxes:
box.setStyleSheet(_box_style(box_bg)) box.setStyleSheet(_box_style(box_bg, button=not box.property("tiles")))
self._apply_now_playing_bg() self._apply_now_playing_bg()
self._apply_now_playing_font() self._apply_now_playing_font()
self._visualizer.update() # dim-mode bar color may have changed self._visualizer.update() # dim-mode bar color may have changed
+60 -28
View File
@@ -166,44 +166,76 @@ def apply_theme(app, prefs):
def emboss_gradient(color: QColor, pressed: bool = False) -> str: def emboss_gradient(color: QColor, pressed: bool = False) -> str:
"""A barely-there top-lit vertical gradient around ``color``. """A shallow emboss around ``color`` that hugs the edges: a crisp lit rim
along the top and a crisp shaded rim along the bottom, with the face
Computed from the live color rather than hard-coded, so it follows the itself nearly flat. Computed from the live color, so it follows the
palette (light or dark) and the user's grayscale overrides. ``pressed`` palette (light or dark) and the user's grayscale overrides. ``pressed``
flips it, which reads as the button sinking in.""" flips it, which reads as the button sinking in.
top, bottom = color.lighter(EMBOSS_LIGHTER), color.darker(EMBOSS_DARKER)
The color itself sits at the midpoint, so what the user picked is still
exactly what the middle of the button shows."""
rim_top, face_top = color.lighter(EMBOSS_RIM), color.lighter(EMBOSS_LIGHTER)
face_bottom, rim_bottom = color.darker(EMBOSS_DARKER), color.darker(EMBOSS_RIM)
if pressed: if pressed:
top, bottom = bottom, top rim_top, face_top, face_bottom, rim_bottom = (
# The color itself sits at the midpoint, so what the user picked is still rim_bottom, face_bottom, face_top, rim_top)
# exactly what the middle of the button shows.
return (f"qlineargradient(x1:0, y1:0, x2:0, y2:1, " return (f"qlineargradient(x1:0, y1:0, x2:0, y2:1, "
f"stop:0 {top.name()}, stop:0.5 {color.name()}, " f"stop:0 {rim_top.name()}, stop:{EMBOSS_RIM_WIDTH} {face_top.name()}, "
f"stop:1 {bottom.name()})") f"stop:0.5 {color.name()}, "
f"stop:{1 - EMBOSS_RIM_WIDTH:.2f} {face_bottom.name()}, "
f"stop:1 {rim_bottom.name()})")
# How far the emboss strays from the base color (Qt's lighter/darker factor, # How far the emboss strays from the base color (Qt's lighter/darker factor,
# 100 = unchanged). Kept small on purpose: "very slight, soft". # 100 = unchanged). The face barely moves; the rims are where the shape is.
EMBOSS_LIGHTER = 106 EMBOSS_LIGHTER = 103
EMBOSS_DARKER = 104 EMBOSS_DARKER = 103
EMBOSS_RIM = 116
EMBOSS_RIM_WIDTH = 0.08 # fraction of the height each rim takes
HOVER_DARKER = 107 # hover: the whole button, slightly darker
PRESSED_DARKER = 112
def emboss_edges(color: QColor) -> str:
"""A defined outline: a touch darker at the bottom, like a lit shape."""
edge = color.darker(135).name()
bottom = color.darker(155).name()
return f"border: 1px solid {edge}; border-bottom-color: {bottom};"
def button_face_qss(selector: str, color: QColor, *, radius: int = 4,
extra: str = "", checked_color: QColor | None = None,
checked_text: str = "") -> str:
"""The one look every button shares: the emboss, the outline, a hover that
darkens the whole button a little, and a pressed state that sinks in.
``extra`` carries per-button layout (padding, margins, corners);
``checked_color`` is for toggles like the Library button."""
hover = color.darker(HOVER_DARKER)
down = color.darker(PRESSED_DARKER)
qss = (f"{selector} {{ {emboss_edges(color)} border-radius: {radius}px;"
f" background: {emboss_gradient(color)}; {extra} }}"
f"{selector}:hover {{ background: {emboss_gradient(hover)}; }}"
f"{selector}:pressed {{ background: {emboss_gradient(down, pressed=True)}; }}")
if checked_color is not None:
lit = checked_color
text = f" color: {checked_text};" if checked_text else ""
qss += (f"{selector}:checked {{ background: {emboss_gradient(lit, pressed=True)};"
f" {emboss_edges(lit)}{text} }}"
f"{selector}:checked:hover {{ background:"
f" {emboss_gradient(lit.darker(HOVER_DARKER), pressed=True)}; }}")
return qss
def button_emboss_qss(button: QColor) -> str: def button_emboss_qss(button: QColor) -> str:
"""Global rule for every non-flat QPushButton. Flat buttons (the version """Global rule for every non-flat QPushButton. Flat buttons (the version
button, the cancel-transfer ✕, the bpm label) opt out by being flat.""" button, the cancel-transfer ✕) opt out by being flat; the transport's bpm
hover = button.lighter(103) button is flat inside a box that carries the look instead."""
return f""" return button_face_qss(
QPushButton:!flat {{ "QPushButton:!flat", button,
border: 1px solid palette(mid); border-radius: 4px; extra="padding: 3px 12px; min-height: 1.3em;",
padding: 3px 12px; min-height: 1.3em; checked_color=button.darker(PRESSED_DARKER)) + \
background: {emboss_gradient(button)}; "QPushButton:!flat:default { border-color: palette(highlight); }" \
}} "QPushButton:!flat:disabled { color: palette(mid); }"
QPushButton:!flat:hover {{ background: {emboss_gradient(hover)}; }}
QPushButton:!flat:pressed, QPushButton:!flat:checked {{
background: {emboss_gradient(button, pressed=True)};
}}
QPushButton:!flat:default {{ border-color: palette(highlight); }}
QPushButton:!flat:disabled {{ color: palette(mid); }}
"""
def installed_families() -> set[str]: def installed_families() -> set[str]:
+31 -3
View File
@@ -137,9 +137,37 @@ class TestEmboss:
color = QColor("#808080") color = QColor("#808080")
up = theme.emboss_gradient(color) up = theme.emboss_gradient(color)
down = theme.emboss_gradient(color, pressed=True) down = theme.emboss_gradient(color, pressed=True)
light = color.lighter(theme.EMBOSS_LIGHTER).name() rim = color.lighter(theme.EMBOSS_RIM).name()
assert up.index(light) < up.index("stop:1") assert up.startswith(f"qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 {rim}")
assert down.index(light) > down.index("stop:1") assert down.endswith(f"stop:1 {rim})")
def test_crisp_rims_hug_the_edges(self):
"""The shape lives in thin rims, not a wide soft slope."""
gradient = theme.emboss_gradient(QColor("#808080"))
assert f"stop:{theme.EMBOSS_RIM_WIDTH} " in gradient
assert theme.EMBOSS_RIM_WIDTH <= 0.1
assert theme.EMBOSS_RIM > theme.EMBOSS_LIGHTER
def test_hover_darkens_the_whole_button(self):
color = QColor("#c0c0c0")
qss = theme.button_face_qss("QPushButton", color)
hover = qss.split("QPushButton:hover {", 1)[1].split("}", 1)[0]
assert f"stop:0.5 {color.darker(theme.HOVER_DARKER).name()}" in hover
def test_every_button_shares_the_face(self, qapp):
from unittest.mock import MagicMock
from lintunes.gui.sidebar import SidebarPanel
from lintunes.gui.transport import _box_style
from lintunes.gui import cassette_art
manager = MagicMock()
manager.library.playlists = {}
sidebar = SidebarPanel(manager, MagicMock())
for qss in (sidebar.library_button.styleSheet(),
sidebar.friend_button.styleSheet(),
_box_style(QColor("#dddddd"), button=True),
cassette_art.friend_button_qss()):
assert ":hover" in qss
assert "border-bottom-color" in qss
def test_flat_buttons_opt_out(self): def test_flat_buttons_opt_out(self):
qss = theme.button_emboss_qss(QColor("#808080")) qss = theme.button_emboss_qss(QColor("#808080"))