- 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 <noreply@anthropic.com>
164 lines
6.1 KiB
Python
164 lines
6.1 KiB
Python
"""App-wide theming: highlight color and UI/text scale, driven by Preferences.
|
|
|
|
Everything that shows the highlight color (table/sidebar selection, the seek
|
|
slider's filled groove, the visualizer bars) reads QPalette.Highlight, so
|
|
recoloring is a palette swap. UI scale adjusts the app font, scrollbar
|
|
dimensions, and (via MainWindow.apply_ui_metrics) table row heights.
|
|
"""
|
|
from PyQt6.QtGui import QPalette, QColor, QFontDatabase
|
|
|
|
|
|
# The font the now-playing panel wants by default. Not bundled (it's a
|
|
# proprietary Monotype face); the user is asked to install it and, if missing,
|
|
# to pick a substitute on first run (see main.ensure_now_playing_font).
|
|
CENTURY_GOTHIC = "Century Gothic"
|
|
|
|
|
|
HIGHLIGHT_COLORS = {
|
|
"blue": "#3584E4",
|
|
"marigold": "#F5A623",
|
|
"magenta": "#D6409F",
|
|
"seafoam": "#7FD1AE",
|
|
"gray": "#8A8A8A",
|
|
}
|
|
|
|
UI_SCALES = {
|
|
"small": {"font_pt": 9, "scrollbar": 10, "row_height": 20, "header_strip": 28,
|
|
"status_height": 22},
|
|
"medium": {"font_pt": 11, "scrollbar": 14, "row_height": 24, "header_strip": 34,
|
|
"status_height": 26},
|
|
"large": {"font_pt": 13, "scrollbar": 18, "row_height": 28, "header_strip": 40,
|
|
"status_height": 32},
|
|
}
|
|
|
|
_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]
|
|
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())
|
|
|
|
|
|
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"]))
|
|
|
|
|
|
def scale_metrics(prefs) -> dict:
|
|
return UI_SCALES.get(prefs.get("ui_scale"), UI_SCALES["medium"])
|
|
|
|
|
|
def apply_theme(app, prefs):
|
|
base = _base()
|
|
|
|
color = highlight_color(prefs)
|
|
# Black text on light highlights, white on dark
|
|
luminance = (0.299 * color.red() + 0.587 * color.green()
|
|
+ 0.114 * color.blue()) / 255
|
|
text = QColor("black") if luminance > 0.6 else QColor("white")
|
|
|
|
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)
|
|
font = app.font()
|
|
font.setPointSize(metrics["font_pt"])
|
|
app.setFont(font)
|
|
|
|
bar = metrics["scrollbar"]
|
|
app.setStyleSheet(f"""
|
|
QScrollBar:vertical {{ width: {bar}px; }}
|
|
QScrollBar:horizontal {{ height: {bar}px; }}
|
|
QScrollBar::handle {{ min-height: {bar * 2}px; min-width: {bar * 2}px; }}
|
|
""")
|
|
|
|
|
|
def century_gothic_available() -> bool:
|
|
"""True if the Century Gothic family is installed and resolvable by Qt."""
|
|
return CENTURY_GOTHIC in QFontDatabase.families()
|
|
|
|
|
|
def now_playing_font_family(prefs) -> str | None:
|
|
"""Family the now-playing panel should use, or None for the app default.
|
|
|
|
prefs["now_playing_font"] semantics:
|
|
- missing/None : never set → prefer Century Gothic, fall back to default
|
|
- "" : user chose "use default" → always the app default
|
|
- "Family" : user picked a family → use it (if still installed)
|
|
"""
|
|
saved = prefs.get("now_playing_font")
|
|
if saved == "":
|
|
return None
|
|
if saved: # a previously chosen family
|
|
return saved if saved in QFontDatabase.families() else None
|
|
# Never set: prefer Century Gothic when present, else default.
|
|
return CENTURY_GOTHIC if century_gothic_available() else None
|