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."""
__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:
"""Light purple, embossed like every other button."""
light = FRIEND_PURPLE.lighter(106).name()
dark = FRIEND_PURPLE.darker(104).name()
return (f"{selector} {{ background: qlineargradient(x1:0, y1:0, x2:0, y2:1,"
f" stop:0 {light}, stop:0.5 {FRIEND_PURPLE.name()}, stop:1 {dark});"
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()}; }}")
"""Light purple, with the same face as every other button."""
from lintunes import theme
return theme.button_face_qss(
selector, FRIEND_PURPLE,
extra=f"color: black; padding: {padding}; min-height: 0;")
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
friend selector showing, the two share a border and have square
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()
base = palette.color(QPalette.ColorRole.AlternateBase)
lit = palette.color(QPalette.ColorRole.Highlight)
split = not self.friend_button.isHidden()
border = "palette(mid)"
checked_text = palette.color(QPalette.ColorRole.HighlightedText).name()
if self.friend_mode:
base = lit = FRIEND_PURPLE
border = FRIEND_PURPLE_DARK.name()
checked_text = "black" if self.friend_mode else "palette(highlighted-text)"
checked_text = "black"
split = not self.friend_button.isHidden()
joint = ("border-top-right-radius: 0; border-bottom-right-radius: 0;"
" margin: 2px 0 2px 8px;" if split else
"margin: 2px 4px 2px 8px;")
self.library_button.setStyleSheet(
"QPushButton { text-align: left; padding-left: 10px;"
f" border: 1px solid {border}; border-radius: 4px; {joint}"
f" background: {theme.emboss_gradient(base)}; }}"
"QPushButton:checked {"
f" background: {theme.emboss_gradient(lit, pressed=True)};"
f" color: {checked_text}; }}")
self.friend_button.setStyleSheet(
"QPushButton { padding: 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)}; }}")
self.library_button.setStyleSheet(theme.button_face_qss(
"QPushButton", base, extra=f"text-align: left; padding-left: 10px; {joint}",
checked_color=lit, checked_text=checked_text))
# The ▾ is the same button cut off at the end: same face, no left
# border (it shares the Library button's), square where they meet.
self.friend_button.setStyleSheet(theme.button_face_qss(
"QPushButton", base,
extra="padding: 0; border-left: none; border-top-left-radius: 0;"
" border-bottom-left-radius: 0; margin: 2px 4px 2px 0;"))
class FriendPlaylistList(QListWidget):
"""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).
TOP = Qt.AlignmentFlag.AlignTop
def _box_style(bg: QColor) -> str:
return f"""
QFrame#transportBox {{
border: 1px solid palette(mid);
border-radius: 8px;
background: {theme.emboss_gradient(bg)};
}}
def _box_style(bg: QColor, button: bool = False) -> str:
"""A transport box. A box that *is* one button (shuffle, bpm) gets the
shared button face — emboss, outline, whole-box darkening on hover; the
three-tile play box gets the same face, and each tile darkens on its own
(see TILE_STYLE)."""
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."""
frame = QFrame()
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.setContentsMargins(*margins)
if split:
@@ -180,7 +202,7 @@ class BpmButton(QPushButton):
self._manager = manager
self._tempo = TapTempo()
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.setFocusPolicy(Qt.FocusPolicy.NoFocus)
self.setEnabled(False)
@@ -356,8 +378,9 @@ class TransportBar(QWidget):
# 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.
self._bpm_button = BpmButton(player, manager)
bpm_box = _box(self._bpm_button, bg=box_bg, margins=(8, 8, 8, 8),
hug=True, height=CONTROL_HEIGHT)
# The whole box is the tap target, so it darkens (and taps) as one.
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)
layout.addWidget(bpm_box, alignment=TOP)
@@ -388,7 +411,7 @@ class TransportBar(QWidget):
button.refresh_icons()
box_bg = theme.gray(theme.gray_value(self._prefs, "color_button"))
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_font()
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:
"""A barely-there top-lit vertical gradient around ``color``.
Computed from the live color rather than hard-coded, so it follows the
"""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
itself nearly flat. Computed from the live color, so it follows the
palette (light or dark) and the user's grayscale overrides. ``pressed``
flips it, which reads as the button sinking in."""
top, bottom = color.lighter(EMBOSS_LIGHTER), color.darker(EMBOSS_DARKER)
flips it, which reads as the button sinking in.
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:
top, bottom = bottom, top
# 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, face_bottom, rim_bottom = (
rim_bottom, face_bottom, face_top, rim_top)
return (f"qlineargradient(x1:0, y1:0, x2:0, y2:1, "
f"stop:0 {top.name()}, stop:0.5 {color.name()}, "
f"stop:1 {bottom.name()})")
f"stop:0 {rim_top.name()}, stop:{EMBOSS_RIM_WIDTH} {face_top.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,
# 100 = unchanged). Kept small on purpose: "very slight, soft".
EMBOSS_LIGHTER = 106
EMBOSS_DARKER = 104
# 100 = unchanged). The face barely moves; the rims are where the shape is.
EMBOSS_LIGHTER = 103
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:
"""Global rule for every non-flat QPushButton. Flat buttons (the version
button, the cancel-transfer ✕, the bpm label) opt out by being flat."""
hover = button.lighter(103)
return f"""
QPushButton:!flat {{
border: 1px solid palette(mid); border-radius: 4px;
padding: 3px 12px; min-height: 1.3em;
background: {emboss_gradient(button)};
}}
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); }}
"""
button, the cancel-transfer ✕) opt out by being flat; the transport's bpm
button is flat inside a box that carries the look instead."""
return button_face_qss(
"QPushButton:!flat", button,
extra="padding: 3px 12px; min-height: 1.3em;",
checked_color=button.darker(PRESSED_DARKER)) + \
"QPushButton:!flat:default { border-color: palette(highlight); }" \
"QPushButton:!flat:disabled { color: palette(mid); }"
def installed_families() -> set[str]:
+31 -3
View File
@@ -137,9 +137,37 @@ class TestEmboss:
color = QColor("#808080")
up = theme.emboss_gradient(color)
down = theme.emboss_gradient(color, pressed=True)
light = color.lighter(theme.EMBOSS_LIGHTER).name()
assert up.index(light) < up.index("stop:1")
assert down.index(light) > down.index("stop:1")
rim = color.lighter(theme.EMBOSS_RIM).name()
assert up.startswith(f"qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 {rim}")
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):
qss = theme.button_emboss_qss(QColor("#808080"))