diff --git a/lintunes/__init__.py b/lintunes/__init__.py index 67a2df6..2340247 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.31.0" +__version__ = "0.31.1" diff --git a/lintunes/gui/cassette_art.py b/lintunes/gui/cassette_art.py index e5f74fd..3e3cb39 100644 --- a/lintunes/gui/cassette_art.py +++ b/lintunes/gui/cassette_art.py @@ -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: diff --git a/lintunes/gui/sidebar.py b/lintunes/gui/sidebar.py index edd53f5..3694aa5 100644 --- a/lintunes/gui/sidebar.py +++ b/lintunes/gui/sidebar.py @@ -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 diff --git a/lintunes/gui/transport.py b/lintunes/gui/transport.py index 9624af3..e628dd0 100644 --- a/lintunes/gui/transport.py +++ b/lintunes/gui/transport.py @@ -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 diff --git a/lintunes/theme.py b/lintunes/theme.py index f9cc200..5adc071 100644 --- a/lintunes/theme.py +++ b/lintunes/theme.py @@ -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]: diff --git a/tests/test_round63.py b/tests/test_round63.py index cc3e350..bb7fc2d 100644 --- a/tests/test_round63.py +++ b/tests/test_round63.py @@ -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"))