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:
@@ -1,3 +1,3 @@
|
||||
"""LinTunes — iTunes-style music library manager and player for Linux."""
|
||||
|
||||
__version__ = "0.31.0"
|
||||
__version__ = "0.31.1"
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"))
|
||||
|
||||
Reference in New Issue
Block a user