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

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()