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.
154 lines
5.7 KiB
Python
154 lines
5.7 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]
|
|
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
|