Files
lintunes/lintunes/theme.py
T
travandClaude Opus 5 800e8ffb5d v0.11.0: web mixes pick their own color
The export dialog gains an accent color — the hover background on links and
tracklist rows, and the player's progress fill — and the rest of the bar loses
its 2010 gold so that choice is the only color in it.

The accent travels as a `:root { --accent }` custom property declared in
index.html, which player.css reads as `var(--accent, #8c764a)`. That keeps the
stylesheet in the verbatim copyfile loop: index.html is still the only rendered
template. `normalize_accent` is the injection gate — the value lands raw inside
a <style> block and string.Template escapes nothing — and `contrast_text` flips
the hover text black or white, since the old page hard-coded white and a pale
accent made it unreadable.

The bar itself is now fixed light gray with black text. Its sprite glyphs are
pale lavender and yellow, drawn for the dark gold bar, so they're recolored
with `filter: brightness(0)` rather than by editing the GIF — which still ships
byte-identical, spinner and all.

Not persisted: the picker opens on #8c764a every time, so an export nobody
touches looks exactly like the mixes already online.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JBSM2bFC6UToiEg8BE4dqj
2026-08-22 15:14:36 -04:00

199 lines
7.5 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, QIcon, QPixmap
# The font the now-playing panel wants by default. Not bundled (it's a
# proprietary Monotype face), so it's simply the head of the fallback chain
# below: whatever geometric sans this machine actually has wins, silently.
# Startup never asks — the user can override in Edit ▸ Preferences.
CENTURY_GOTHIC = "Century Gothic"
# Closest-first substitutes for Century Gothic. URW Gothic is the ITC Avant
# Garde clone CG derives from and ships with ghostscript, so it's present on
# most Linux boxes; TeX Gyre Adventor is its TeX-world descendant. Names must
# be spelled exactly as the family is registered — QFontDatabase does no fuzzy
# matching, which is why "Jost*" appears with its asterisk.
NOW_PLAYING_FALLBACKS = (
CENTURY_GOTHIC,
"URW Gothic", "URW Gothic L", "URW Gothic Book",
"TeX Gyre Adventor",
"Questrial", "Didact Gothic", "Jost*", "Jost", "Josefin Sans",
"Poppins", "Futura", "Avant Garde", "ITC Avant Garde Gothic",
"Montserrat", "Nunito Sans", "Nunito",
)
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 swatch_icon(color_hex: str, size: int = 18) -> QIcon:
"""A flat square of `color_hex` — the icon on a color-choosing control."""
pixmap = QPixmap(size, size)
pixmap.fill(QColor(color_hex))
return QIcon(pixmap)
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 installed_families() -> set[str]:
"""Families Qt can actually resolve on this machine."""
return set(QFontDatabase.families())
def auto_now_playing_family() -> str | None:
"""Best available face from NOW_PLAYING_FALLBACKS, or None for the app font.
Deliberately uncached: a cache would go stale for tests that stub
QFontDatabase, and for a font installed while the app is running.
"""
families = installed_families()
return next((f for f in NOW_PLAYING_FALLBACKS if f in families), None)
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 : automatic → best available face from the fallback chain
- "" : user chose "same as the app" → always the app default
- "Family" : user picked a family → use it, or fall back to automatic
if it has since been uninstalled
Never prompts and never blocks; an unavailable wish quietly becomes the
nearest thing installed.
"""
saved = prefs.get("now_playing_font")
if saved == "":
return None
if saved: # a previously chosen family
return saved if saved in installed_families() else auto_now_playing_family()
return auto_now_playing_family()