Files
lintunes/lintunes/theme.py
T
trav dcbd288b31 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.
2026-06-26 21:41:49 -04:00

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