From 1dd128b7f476eeeb43d3349ab49d6764f09b8237 Mon Sep 17 00:00:00 2001 From: Trav Date: Fri, 26 Jun 2026 21:41:49 -0400 Subject: [PATCH] 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 --- TASKS.md | 4 +- lintunes/gui/preferences_dialog.py | 44 +++++++- lintunes/gui/transport.py | 70 ++++++++---- lintunes/preferences.py | 17 ++- lintunes/theme.py | 67 +++++++++++- tests/test_round11.py | 168 +++++++++++++++++++++++++++++ 6 files changed, 340 insertions(+), 30 deletions(-) create mode 100644 tests/test_round11.py diff --git a/TASKS.md b/TASKS.md index 3dd33fb..8d38b06 100644 --- a/TASKS.md +++ b/TASKS.md @@ -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 diff --git a/lintunes/gui/preferences_dialog.py b/lintunes/gui/preferences_dialog.py index 23afdcb..3940d49 100644 --- a/lintunes/gui/preferences_dialog.py +++ b/lintunes/gui/preferences_dialog.py @@ -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: diff --git a/lintunes/gui/transport.py b/lintunes/gui/transport.py index 5e57bf0..4f25d17 100644 --- a/lintunes/gui/transport.py +++ b/lintunes/gui/transport.py @@ -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 ---- diff --git a/lintunes/preferences.py b/lintunes/preferences.py index 51dbb82..6865c99 100644 --- a/lintunes/preferences.py +++ b/lintunes/preferences.py @@ -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() diff --git a/lintunes/theme.py b/lintunes/theme.py index 7f200fd..34f1c6b 100644 --- a/lintunes/theme.py +++ b/lintunes/theme.py @@ -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) diff --git a/tests/test_round11.py b/tests/test_round11.py new file mode 100644 index 0000000..9cfebac --- /dev/null +++ b/tests/test_round11.py @@ -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()