Add per-area color preference sliders

Five grayscale (black->white) sliders in Preferences that update live:
outer-chrome background (QPalette.Window), the rounded transport button
boxes, the now-playing panel, the now-playing text, and the row stripes
(QPalette.AlternateBase). Defaults are None ("inherit current") so nothing
changes until the user drags. Decouples the button boxes from the stripe
color (they used to share palette alternate-base). Live preview via
Preferences.set_live (no disk write); persisted ~0.4s after the drag.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 21:41:49 -04:00
parent bb31e9e5cc
commit 1dd128b7f4
6 changed files with 340 additions and 30 deletions

View File

@ -5,9 +5,9 @@
- [x] confirm last fm works
- [ ] do do a volume slider, this can some space between the visualizer and the timeline.
- [x] do do a volume slider, this can some space between the visualizer and the timeline.
- [ ] I'd like in the preferences to have a colors adjustment for each area setting: background, round-rec of buttons, the (currently white) behind the track/artist/album text, the color of that text itself, and the color of the gray of the stripes in the tracklisting. Each of those should have a slider from black to white that appears immediately so the user can tweak it to their liking.
- [x] I'd like in the preferences to have a colors adjustment for each area setting: background, round-rec of buttons, the (currently white) behind the track/artist/album text, the color of that text itself, and the color of the gray of the stripes in the tracklisting. Each of those should have a slider from black to white that appears immediately so the user can tweak it to their liking.
- [x] visualizer should have more discrete mode that's a light gray on light gray

View File

@ -3,14 +3,24 @@ from PyQt6.QtWidgets import (
QRadioButton, QButtonGroup, QLineEdit, QPushButton, QCheckBox,
QFormLayout,
)
from PyQt6.QtCore import Qt
from PyQt6.QtCore import Qt, QTimer
from PyQt6.QtGui import QPixmap, QColor, QIcon
from lintunes import theme
from lintunes.theme import HIGHLIGHT_COLORS
_SCALES = ["small", "medium", "large"]
# (preference key, row label) for the per-area grayscale sliders.
_COLOR_ROWS = [
("color_background", "Background:"),
("color_button", "Buttons:"),
("color_now_playing_bg", "Now-playing panel:"),
("color_now_playing_text", "Now-playing text:"),
("color_stripe", "Stripes:"),
]
def _swatch(color_hex: str) -> QIcon:
pixmap = QPixmap(18, 18)
@ -26,8 +36,16 @@ class PreferencesDialog(QDialog):
self.setWindowTitle("Preferences")
self.setMinimumWidth(420)
# Color sliders preview live (set_live emits without writing); persist
# once the user pauses so a drag doesn't hammer the disk.
self._color_save_timer = QTimer(self)
self._color_save_timer.setSingleShot(True)
self._color_save_timer.setInterval(400)
self._color_save_timer.timeout.connect(self._prefs.save)
layout = QVBoxLayout(self)
layout.addWidget(self._build_general_group())
layout.addWidget(self._build_colors_group())
layout.addWidget(self._build_lastfm_group())
layout.addStretch()
@ -81,6 +99,30 @@ class PreferencesDialog(QDialog):
self._size_label.setText(scale.capitalize())
self._prefs.set("ui_scale", scale)
# ---- Colors ----
def _build_colors_group(self) -> QGroupBox:
group = QGroupBox("Colors")
form = QFormLayout(group)
note = QLabel("Each slider runs black → white and updates instantly.")
note.setWordWrap(True)
form.addRow(note)
self._color_sliders = {}
for key, label in _COLOR_ROWS:
slider = QSlider(Qt.Orientation.Horizontal)
slider.setRange(0, 255)
slider.setValue(theme.gray_value(self._prefs, key)) # before connect
slider.valueChanged.connect(
lambda value, k=key: self._on_color_changed(k, value))
self._color_sliders[key] = slider
form.addRow(label, slider)
return group
def _on_color_changed(self, key: str, value: int):
self._prefs.set_live(key, value) # live preview, no disk write yet
self._color_save_timer.start() # persist once the drag settles
# ---- Last.fm ----
def _build_lastfm_group(self) -> QGroupBox:

View File

@ -29,19 +29,22 @@ BAR_HEIGHT = 84
# at ~46px. Tune to taste.
CONTROL_HEIGHT = 62
_BOX_STYLE = """
QFrame#transportBox {
def _box_style(bg: QColor) -> str:
return f"""
QFrame#transportBox {{
border: 1px solid palette(mid);
border-radius: 8px;
background: palette(alternate-base);
}
background: {bg.name()};
}}
"""
_NOWPLAYING_STYLE = """
QFrame#nowPlayingBox {
background: white;
def _nowplaying_style(bg: QColor) -> str:
return f"""
QFrame#nowPlayingBox {{
background: {bg.name()};
border-radius: 8px;
}
}}
"""
# Title sits this many points above the artist/album line, both following the
@ -86,15 +89,17 @@ class FlashButton(QToolButton):
self.setIcon(transport_icon(self._kind, color))
def _box(*widgets, margins=(10, 3, 10, 3), hug=False, height=None) -> QFrame:
def _box(*widgets, bg: QColor, margins=(10, 3, 10, 3), hug=False,
height=None) -> QFrame:
"""A small rounded frame with its content centered inside.
With hug=True the frame stays at its size hint instead of stretching
to fill the row it sits in. height pins the frame's height (width is
left untouched), so several boxes can be lined up to the same height."""
left untouched), so several boxes can be lined up to the same height.
bg is the rounded-rectangle fill (a user-tunable grayscale)."""
frame = QFrame()
frame.setObjectName("transportBox")
frame.setStyleSheet(_BOX_STYLE)
frame.setStyleSheet(_box_style(bg))
inner = QHBoxLayout(frame)
inner.setContentsMargins(*margins)
inner.addStretch()
@ -229,16 +234,25 @@ class TransportBar(QWidget):
layout = QHBoxLayout(self)
layout.setContentsMargins(8, 3, 8, 3)
# Rounded button boxes carry a user-tunable grayscale fill, decoupled
# from the stripe color they used to share via palette(alternate-base).
self._boxes = []
box_bg = theme.gray(theme.gray_value(prefs, "color_button"))
self._prev_btn = FlashButton("previous")
self._play_btn = FlashButton("play")
self._next_btn = FlashButton("next")
layout.addWidget(_box(self._prev_btn, self._play_btn, self._next_btn,
height=CONTROL_HEIGHT))
transport_box = _box(self._prev_btn, self._play_btn, self._next_btn,
bg=box_bg, height=CONTROL_HEIGHT)
self._boxes.append(transport_box)
layout.addWidget(transport_box)
self._shuffle_btn = FlashButton("shuffle")
self._shuffle_btn.setCheckable(True)
self._shuffle_btn.setToolTip("Shuffle (doesn't change playlist order)")
layout.addWidget(_box(self._shuffle_btn, height=CONTROL_HEIGHT))
shuffle_box = _box(self._shuffle_btn, bg=box_bg, height=CONTROL_HEIGHT)
self._boxes.append(shuffle_box)
layout.addWidget(shuffle_box)
layout.addSpacing(10)
self._visualizer = VisualizerWidget(player)
@ -274,7 +288,7 @@ class TransportBar(QWidget):
# artist/album line, centered, filling the zone above the seek row.
self._now_playing = QFrame()
self._now_playing.setObjectName("nowPlayingBox")
self._now_playing.setStyleSheet(_NOWPLAYING_STYLE)
self._apply_now_playing_bg()
np = QVBoxLayout(self._now_playing)
np.setContentsMargins(12, 6, 12, 6)
np.setSpacing(2)
@ -315,8 +329,10 @@ class TransportBar(QWidget):
# centered 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)
layout.addWidget(_box(self._bpm_button, margins=(8, 8, 8, 8), hug=True,
height=CONTROL_HEIGHT))
bpm_box = _box(self._bpm_button, bg=box_bg, margins=(8, 8, 8, 8),
hug=True, height=CONTROL_HEIGHT)
self._boxes.append(bpm_box)
layout.addWidget(bpm_box)
# Wiring
self._prev_btn.clicked.connect(player.previous)
@ -332,12 +348,22 @@ class TransportBar(QWidget):
player.duration_changed.connect(self._on_duration_changed)
def refresh_theme(self):
"""Rebuild button glyphs after a highlight-color or font change."""
"""Re-apply everything driven by Preferences: button glyphs (highlight
color), the tunable grayscale button boxes, and the now-playing panel
background + text color."""
for button in (self._prev_btn, self._play_btn, self._next_btn,
self._shuffle_btn):
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))
self._apply_now_playing_bg()
self._apply_now_playing_font()
def _apply_now_playing_bg(self):
bg = theme.gray(theme.gray_value(self._prefs, "color_now_playing_bg"))
self._now_playing.setStyleSheet(_nowplaying_style(bg))
def _on_volume_changed(self, value: int):
"""Slider moved: update audio live, defer the (theme-reapplying) save."""
self._player.set_volume(value / 100)
@ -348,16 +374,18 @@ class TransportBar(QWidget):
def _apply_now_playing_font(self):
"""Set the now-playing labels to Century Gothic (or the user's chosen
substitute), black text; title slightly larger than artist/album.
Sizes track the app font so they follow the UI-scale preference."""
substitute); title slightly larger than artist/album. Sizes track the
app font so they follow the UI-scale preference; the text color is the
user-tunable grayscale (black by default)."""
family = theme.now_playing_font_family(self._prefs)
base_pt = self.font().pointSize()
text_color = theme.gray(theme.gray_value(self._prefs, "color_now_playing_text"))
for label, delta in ((self._title_label, TITLE_PT_BUMP),
(self._artist_label, 0)):
font = QFont(family) if family else QFont(self.font())
font.setPointSize(max(1, base_pt + delta))
label.setFont(font)
label.setStyleSheet("color: black;")
label.setStyleSheet(f"color: {text_color.name()};")
# ---- player events ----

View File

@ -9,8 +9,16 @@ DEFAULTS = {
"ui_scale": "medium", # small | medium | large
"highlight": "blue", # see theme.HIGHLIGHT_COLORS
"now_playing_font": None, # None=prefer Century Gothic, ""=use app
# default, "Family"=user-chosen substitute
# default, "Family"=use app
"volume": 0.8, # logical 0..1 master output volume
# Per-area grayscale overrides (0=black .. 255=white). None = inherit the
# current/default look for that surface, so nothing changes until the user
# drags a slider in Preferences.
"color_background": None, # outer chrome (QPalette.Window)
"color_button": None, # rounded transport button boxes
"color_now_playing_bg": None, # panel behind the now-playing text
"color_now_playing_text": None, # the now-playing title/artist text
"color_stripe": None, # alternating row stripes (QPalette.AlternateBase)
"lastfm": {
"api_key": "",
"api_secret": "",
@ -49,6 +57,13 @@ class Preferences(QObject):
self.save()
self.changed.emit()
def set_live(self, key: str, value):
"""Update a preference and notify listeners *without* writing to disk —
for live drag-preview (e.g. the color sliders). Call save() to persist
once the user stops dragging."""
self._data[key] = value
self.changed.emit()
def update_lastfm(self, **fields):
self._data["lastfm"].update(fields)
self.save()

View File

@ -34,6 +34,54 @@ UI_SCALES = {
_base_palette = None
# The five user-tunable grayscale surfaces. now-playing bg/text have fixed
# defaults (white/black); the rest default to whatever the base palette uses,
# so an untouched install looks exactly as it did before the feature existed.
_PALETTE_GRAY_ROLE = {
"color_background": QPalette.ColorRole.Window,
"color_stripe": QPalette.ColorRole.AlternateBase,
"color_button": QPalette.ColorRole.AlternateBase,
}
_FIXED_GRAY_DEFAULT = {
"color_now_playing_bg": 255,
"color_now_playing_text": 0,
}
def _base() -> QPalette:
"""The app's original palette, captured once, used as the reference for
highlight/grayscale overrides and for the sliders' 'current' defaults."""
global _base_palette
if _base_palette is None:
from PyQt6.QtWidgets import QApplication
app = QApplication.instance()
_base_palette = QPalette(app.palette()) if app is not None else QPalette()
return _base_palette
def gray(value) -> QColor:
"""A 0..255 lightness as an opaque gray QColor."""
v = max(0, min(255, int(value)))
return QColor(v, v, v)
def default_gray(key: str) -> int:
"""The slider's value when this surface hasn't been overridden — i.e. its
current appearance, so showing the slider doesn't imply a change."""
if key in _FIXED_GRAY_DEFAULT:
return _FIXED_GRAY_DEFAULT[key]
color = _base().color(QPalette.ColorGroup.Active, _PALETTE_GRAY_ROLE[key])
return round(0.299 * color.red() + 0.587 * color.green()
+ 0.114 * color.blue())
def gray_value(prefs, key: str) -> int:
"""The 0..255 value to show/apply for a surface: the saved override, or the
current default when the user hasn't touched it."""
saved = prefs.get(key)
return default_gray(key) if saved is None else max(0, min(255, int(saved)))
def highlight_color(prefs) -> QColor:
return QColor(HIGHLIGHT_COLORS.get(prefs.get("highlight"),
HIGHLIGHT_COLORS["blue"]))
@ -44,9 +92,7 @@ def scale_metrics(prefs) -> dict:
def apply_theme(app, prefs):
global _base_palette
if _base_palette is None:
_base_palette = QPalette(app.palette())
base = _base()
color = highlight_color(prefs)
# Black text on light highlights, white on dark
@ -54,11 +100,22 @@ def apply_theme(app, prefs):
+ 0.114 * color.blue()) / 255
text = QColor("black") if luminance > 0.6 else QColor("white")
palette = QPalette(_base_palette)
for group in (QPalette.ColorGroup.Active, QPalette.ColorGroup.Inactive):
palette = QPalette(base)
groups = (QPalette.ColorGroup.Active, QPalette.ColorGroup.Inactive)
for group in groups:
palette.setColor(group, QPalette.ColorRole.Highlight, color)
palette.setColor(group, QPalette.ColorRole.HighlightedText, text)
palette.setColor(group, QPalette.ColorRole.Accent, color)
# User grayscale overrides (None = leave the base color untouched). The
# button-box color lives in transport.py, not the palette.
background = prefs.get("color_background")
if background is not None:
for group in groups:
palette.setColor(group, QPalette.ColorRole.Window, gray(background))
stripe = prefs.get("color_stripe")
if stripe is not None:
for group in groups:
palette.setColor(group, QPalette.ColorRole.AlternateBase, gray(stripe))
app.setPalette(palette)
metrics = scale_metrics(prefs)

168
tests/test_round11.py Normal file
View File

@ -0,0 +1,168 @@
"""Round 11: per-area grayscale color preferences.
Five sliders (black->white) tune: the outer chrome background, the rounded
button boxes, the now-playing panel, the now-playing text, and the row stripes.
Defaults are None ("inherit current") so nothing changes until the user drags.
"""
from unittest.mock import MagicMock, patch
from PyQt6.QtCore import QObject, pyqtSignal
from PyQt6.QtGui import QPalette, QColor
from lintunes import theme
from lintunes.models import Library, Track
from lintunes.library_manager import LibraryManager
from lintunes.preferences import Preferences
from lintunes import player as player_module
from lintunes.player import Player
from lintunes.gui.transport import TransportBar
from lintunes.gui.preferences_dialog import PreferencesDialog, _COLOR_ROWS
def _mock_player():
library = Library(tracks={1: Track(track_id=1, name="One")})
with patch.multiple(
player_module,
QMediaPlayer=MagicMock(), QAudioOutput=MagicMock(),
QAudioBufferOutput=MagicMock(), QMediaDevices=MagicMock(),
):
return Player(MagicMock(library=library))
# --------------------------------------------------------------------------
# theme grayscale helpers
# --------------------------------------------------------------------------
class TestThemeGrays:
def test_gray_builds_opaque_gray_clamped(self):
assert theme.gray(128) == QColor(128, 128, 128)
assert theme.gray(-5) == QColor(0, 0, 0)
assert theme.gray(999) == QColor(255, 255, 255)
def test_fixed_defaults(self):
assert theme.default_gray("color_now_playing_bg") == 255
assert theme.default_gray("color_now_playing_text") == 0
def test_gray_value_uses_default_when_unset(self, qapp, tmp_path):
prefs = Preferences(tmp_path)
assert prefs.get("color_now_playing_text") is None
assert theme.gray_value(prefs, "color_now_playing_text") == 0
prefs.set("color_now_playing_text", 200)
assert theme.gray_value(prefs, "color_now_playing_text") == 200
# --------------------------------------------------------------------------
# apply_theme palette overrides (background -> Window, stripe -> AlternateBase)
# --------------------------------------------------------------------------
class TestApplyThemeOverrides:
def test_background_and_stripe_recolor_palette(self, qapp, tmp_path):
prefs = Preferences(tmp_path)
prefs.set("color_background", 100)
prefs.set("color_stripe", 40)
saved = QPalette(qapp.palette())
try:
theme.apply_theme(qapp, prefs)
pal = qapp.palette()
assert pal.color(QPalette.ColorGroup.Active,
QPalette.ColorRole.Window) == theme.gray(100)
assert pal.color(QPalette.ColorGroup.Active,
QPalette.ColorRole.AlternateBase) == theme.gray(40)
finally:
qapp.setPalette(saved)
def test_unset_leaves_base_window(self, qapp, tmp_path):
prefs = Preferences(tmp_path)
saved = QPalette(qapp.palette())
try:
theme.apply_theme(qapp, prefs) # no overrides set
base_window = theme._base().color(
QPalette.ColorGroup.Active, QPalette.ColorRole.Window)
assert qapp.palette().color(QPalette.ColorGroup.Active,
QPalette.ColorRole.Window) == base_window
finally:
qapp.setPalette(saved)
# --------------------------------------------------------------------------
# TransportBar applies the stylesheet-side colors (button boxes, panel, text)
# --------------------------------------------------------------------------
class TestTransportColors:
def _build(self, tmp_path, prefs):
manager = LibraryManager(
Library(tracks={1: Track(track_id=1, name="One")}), tmp_path)
return TransportBar(_mock_player(), manager, prefs)
def test_colors_reflected_at_build(self, qapp, tmp_path):
prefs = Preferences(tmp_path)
prefs.set("color_button", 50)
prefs.set("color_now_playing_bg", 100)
prefs.set("color_now_playing_text", 200)
transport = self._build(tmp_path, prefs)
assert transport._boxes # transport, shuffle, bpm
for box in transport._boxes:
assert theme.gray(50).name() in box.styleSheet()
assert theme.gray(100).name() in transport._now_playing.styleSheet()
assert theme.gray(200).name() in transport._title_label.styleSheet()
def test_refresh_theme_repaints_after_change(self, qapp, tmp_path):
prefs = Preferences(tmp_path)
transport = self._build(tmp_path, prefs)
assert "#000000" in transport._title_label.styleSheet() # default black
prefs.set_live("color_now_playing_text", 255)
transport.refresh_theme()
assert "#ffffff" in transport._title_label.styleSheet()
# --------------------------------------------------------------------------
# Preferences.set_live: notify without persisting
# --------------------------------------------------------------------------
class TestSetLive:
def test_updates_memory_and_emits_without_writing(self, tmp_path):
prefs = Preferences(tmp_path)
seen = []
prefs.changed.connect(lambda: seen.append(True))
prefs.set_live("color_background", 123)
assert prefs.get("color_background") == 123
assert seen == [True]
assert not (tmp_path / "preferences.json").exists()
prefs.save()
assert Preferences(tmp_path).get("color_background") == 123
# --------------------------------------------------------------------------
# PreferencesDialog colors group
# --------------------------------------------------------------------------
class _FakeLastFm(QObject):
login_finished = pyqtSignal(bool, str)
status_message = pyqtSignal(str)
def is_logged_in(self):
return False
class TestPreferencesDialogColors:
def test_sliders_built_and_drive_set_live(self, qapp, tmp_path):
prefs = Preferences(tmp_path)
dialog = PreferencesDialog(prefs, _FakeLastFm())
keys = [k for k, _ in _COLOR_ROWS]
assert set(dialog._color_sliders) == set(keys)
for key in keys:
assert dialog._color_sliders[key].value() == theme.gray_value(prefs, key)
slider = dialog._color_sliders["color_stripe"]
target = 0 if slider.value() != 0 else 255
slider.setValue(target)
assert prefs.get("color_stripe") == target
assert dialog._color_save_timer.isActive()