From 0d8c9dd03360235b196871891b58c8f9b4166313 Mon Sep 17 00:00:00 2001 From: trav Date: Thu, 2 Jul 2026 16:52:59 -0400 Subject: [PATCH] Visualizer: gray-mode color slider + mode persists across launches - New prefs: visualizer_mode (on/dim/off, saved on every click) and color_visualizer_gray (None = keep the derived alternateBase look). - VisualizerWidget takes prefs (optional, so existing call sites/tests keep working); restores its mode at startup and validates junk values. - New "Visualizer (gray mode):" row in the Preferences color sliders; theme.default_gray mirrors the widget's 0.88-lightness derivation so an untouched slider shows the current appearance. - TransportBar.refresh_theme() repaints the visualizer so slider drags preview live. Co-Authored-By: Claude Fable 5 --- lintunes/gui/preferences_dialog.py | 1 + lintunes/gui/transport.py | 3 +- lintunes/gui/visualizer.py | 14 +++- lintunes/preferences.py | 2 + lintunes/theme.py | 12 +++- tests/test_round18.py | 101 +++++++++++++++++++++++++++++ 6 files changed, 129 insertions(+), 4 deletions(-) create mode 100644 tests/test_round18.py diff --git a/lintunes/gui/preferences_dialog.py b/lintunes/gui/preferences_dialog.py index 3940d49..10c5947 100644 --- a/lintunes/gui/preferences_dialog.py +++ b/lintunes/gui/preferences_dialog.py @@ -19,6 +19,7 @@ _COLOR_ROWS = [ ("color_now_playing_bg", "Now-playing panel:"), ("color_now_playing_text", "Now-playing text:"), ("color_stripe", "Stripes:"), + ("color_visualizer_gray", "Visualizer (gray mode):"), ] diff --git a/lintunes/gui/transport.py b/lintunes/gui/transport.py index 4f25d17..074f1d6 100644 --- a/lintunes/gui/transport.py +++ b/lintunes/gui/transport.py @@ -255,7 +255,7 @@ class TransportBar(QWidget): layout.addWidget(shuffle_box) layout.addSpacing(10) - self._visualizer = VisualizerWidget(player) + self._visualizer = VisualizerWidget(player, prefs) self._visualizer.setFixedHeight(CONTROL_HEIGHT) layout.addWidget(self._visualizer) layout.addSpacing(10) @@ -359,6 +359,7 @@ class TransportBar(QWidget): box.setStyleSheet(_box_style(box_bg)) self._apply_now_playing_bg() self._apply_now_playing_font() + self._visualizer.update() # dim-mode bar color may have changed def _apply_now_playing_bg(self): bg = theme.gray(theme.gray_value(self._prefs, "color_now_playing_bg")) diff --git a/lintunes/gui/visualizer.py b/lintunes/gui/visualizer.py index 5e5fc47..5858cfc 100644 --- a/lintunes/gui/visualizer.py +++ b/lintunes/gui/visualizer.py @@ -11,6 +11,8 @@ from PyQt6.QtCore import Qt, QTimer, QRectF from PyQt6.QtGui import QPainter, QColor, QPainterPath, QPen from PyQt6.QtMultimedia import QAudioFormat +from lintunes import theme + BANDS = 20 STEPS = 20 # vertical granularity per bar @@ -41,10 +43,12 @@ class VisualizerWidget(QWidget): panel; off = blank. """ - def __init__(self, player, parent=None): + def __init__(self, player, prefs=None, parent=None): super().__init__(parent) self._player = player - self._mode = MODE_ON + self._prefs = prefs + saved = prefs.get("visualizer_mode") if prefs is not None else None + self._mode = saved if saved in _MODE_CYCLE else MODE_ON self._levels = np.zeros(BANDS) self._bars = [0] * BANDS # quantized 0..STEPS self._samples = np.zeros(FFT_SIZE, dtype=np.float32) @@ -113,6 +117,8 @@ class VisualizerWidget(QWidget): # Cycle on -> dim -> off -> on. idx = _MODE_CYCLE.index(self._mode) self._mode = _MODE_CYCLE[(idx + 1) % len(_MODE_CYCLE)] + if self._prefs is not None: + self._prefs.set("visualizer_mode", self._mode) if self._mode == MODE_OFF: self._timer.stop() self._clear() # off = blank, not the last frame held in place @@ -150,6 +156,10 @@ class VisualizerWidget(QWidget): def _bar_color(self): if self._mode == MODE_DIM: + if self._prefs is not None: + saved = self._prefs.get("color_visualizer_gray") + if saved is not None: + return theme.gray(saved) # Light gray just darker than the panel: take alternateBase and # drop its lightness ~12% so it tracks theme/scale changes. base = QColor(self.palette().alternateBase().color()) diff --git a/lintunes/preferences.py b/lintunes/preferences.py index 6865c99..40595b4 100644 --- a/lintunes/preferences.py +++ b/lintunes/preferences.py @@ -11,6 +11,7 @@ DEFAULTS = { "now_playing_font": None, # None=prefer Century Gothic, ""=use app # default, "Family"=use app "volume": 0.8, # logical 0..1 master output volume + "visualizer_mode": "on", # on (color) | dim (gray) | off — click cycle # 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. @@ -19,6 +20,7 @@ DEFAULTS = { "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) + "color_visualizer_gray": None, # visualizer bars in dim (gray) mode "lastfm": { "api_key": "", "api_secret": "", diff --git a/lintunes/theme.py b/lintunes/theme.py index 34f1c6b..840b272 100644 --- a/lintunes/theme.py +++ b/lintunes/theme.py @@ -70,7 +70,17 @@ def default_gray(key: str) -> int: 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]) + if key == "color_visualizer_gray": + # Dim-mode bars derive from alternateBase dropped ~12% in lightness + # (see VisualizerWidget._bar_color); mirror that so the slider shows + # the current appearance. + color = _base().color(QPalette.ColorGroup.Active, + QPalette.ColorRole.AlternateBase) + h, s, l, a = color.getHslF() + color.setHslF(h, s, l * 0.88, a) + else: + color = _base().color(QPalette.ColorGroup.Active, + _PALETTE_GRAY_ROLE[key]) return round(0.299 * color.red() + 0.587 * color.green() + 0.114 * color.blue()) diff --git a/tests/test_round18.py b/tests/test_round18.py new file mode 100644 index 0000000..0a84c95 --- /dev/null +++ b/tests/test_round18.py @@ -0,0 +1,101 @@ +"""Round 18: the 2026-07-02 "New backlog" batch. + +1. Visualizer: gray-mode color slider pref + mode persists across launches. +2. External file drops insert at the drop position in a playlist. +3. Renaming artist/album/album_artist moves the file to keep the music + folder organized (Artist/Album, Unknown Artist fallback). +""" + +from PyQt6.QtCore import QObject, pyqtSignal +from PyQt6.QtGui import QColor + +from lintunes import theme +from lintunes.preferences import Preferences +from lintunes.gui.visualizer import ( + VisualizerWidget, MODE_ON, MODE_DIM, MODE_OFF) + + +class _FakePlayer(QObject): + audio_buffer = pyqtSignal(object) + playing_changed = pyqtSignal(bool) + track_changed = pyqtSignal(object) + + def __init__(self): + super().__init__() + self._playing = False + + def is_playing(self): + return self._playing + + +# -------------------------------------------------------------------------- +# 1. Visualizer: gray color pref + persistent mode +# -------------------------------------------------------------------------- + +class TestVisualizerModePersistence: + def test_no_prefs_defaults_on(self, qapp): + vis = VisualizerWidget(_FakePlayer()) + assert vis._mode == MODE_ON + + def test_saved_mode_restored(self, qapp, tmp_path): + prefs = Preferences(tmp_path) + prefs.set("visualizer_mode", MODE_DIM) + vis = VisualizerWidget(_FakePlayer(), prefs) + assert vis._mode == MODE_DIM + + def test_saved_off_restored_and_stays_idle(self, qapp, tmp_path): + prefs = Preferences(tmp_path) + prefs.set("visualizer_mode", MODE_OFF) + vis = VisualizerWidget(_FakePlayer(), prefs) + assert vis._mode == MODE_OFF + vis._on_playing_changed(True) # playback starts + assert not vis._timer.isActive() + + def test_bogus_saved_mode_falls_back_to_on(self, qapp, tmp_path): + prefs = Preferences(tmp_path) + prefs.set("visualizer_mode", "sparkle") # hand-edited prefs file + vis = VisualizerWidget(_FakePlayer(), prefs) + assert vis._mode == MODE_ON + + def test_click_persists_mode_to_disk(self, qapp, tmp_path): + prefs = Preferences(tmp_path) + vis = VisualizerWidget(_FakePlayer(), prefs) + + vis.mousePressEvent(None) # on -> dim + + assert prefs.get("visualizer_mode") == MODE_DIM + # A fresh Preferences (= next launch) sees it too. + assert Preferences(tmp_path).get("visualizer_mode") == MODE_DIM + + +class TestVisualizerGrayColor: + def test_default_gray_resolves_for_visualizer_key(self, qapp): + value = theme.default_gray("color_visualizer_gray") + assert isinstance(value, int) + assert 0 <= value <= 255 + + def test_slider_pref_drives_dim_color(self, qapp, tmp_path): + prefs = Preferences(tmp_path) + prefs.set("color_visualizer_gray", 100) + vis = VisualizerWidget(_FakePlayer(), prefs) + vis._mode = MODE_DIM + assert vis._bar_color() == QColor(100, 100, 100) + + def test_unset_pref_keeps_derived_color(self, qapp, tmp_path): + prefs = Preferences(tmp_path) + with_prefs = VisualizerWidget(_FakePlayer(), prefs) + without = VisualizerWidget(_FakePlayer()) + with_prefs._mode = MODE_DIM + without._mode = MODE_DIM + assert with_prefs._bar_color() == without._bar_color() + + def test_on_mode_ignores_gray_pref(self, qapp, tmp_path): + prefs = Preferences(tmp_path) + prefs.set("color_visualizer_gray", 100) + vis = VisualizerWidget(_FakePlayer(), prefs) + assert vis._mode == MODE_ON + assert vis._bar_color() == QColor(vis.palette().highlight().color()) + + def test_visualizer_row_in_preferences_dialog(self, qapp): + from lintunes.gui.preferences_dialog import _COLOR_ROWS + assert "color_visualizer_gray" in [key for key, _label in _COLOR_ROWS]