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.
This commit is contained in:
2026-06-26 21:41:49 -04:00
parent 6a9027fb9b
commit dcbd288b31
6 changed files with 340 additions and 30 deletions
+43 -1
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:
+49 -21
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 ----