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