Sync: decoding runs ahead of the speakers — by up to ~0.8 s after a GUI stall, when buffers arrive in a burst — and the visualizer drew the newest audio. It now keeps timestamped history and draws what the player says is playing, plus a per-machine Sync delay (config.json) for latency Qt can't see, like Bluetooth. Every style has one Fade slider (0 = off); bars now leave fading steps. Laser: afterimage time, line width down to 1 px, glow on/off, tip dot on/off and size (a resting beam always shows its dot), and an oscilloscope dot grid under the beam. Visualizer sliders run the full window width. Highlight: click a swatch for a full color picker; the color picked replaces that slot (right-click restores it).
328 lines
14 KiB
Python
328 lines
14 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_palette(prefs) -> dict:
|
|
"""The highlight swatches as this user has them: the stock colors, with
|
|
any slot they repainted (Preferences → a swatch → color picker) in its
|
|
place. Saved in ``highlight_palette`` as {slot name: "#rrggbb"}."""
|
|
palette = dict(HIGHLIGHT_COLORS)
|
|
saved = prefs.get("highlight_palette") or {}
|
|
if isinstance(saved, dict):
|
|
for name, value in saved.items():
|
|
if name in palette and QColor.isValidColorName(str(value)):
|
|
palette[name] = QColor(str(value)).name()
|
|
return palette
|
|
|
|
|
|
def highlight_color(prefs) -> QColor:
|
|
palette = highlight_palette(prefs)
|
|
return QColor(palette.get(prefs.get("highlight"), palette["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"]
|
|
button = palette.color(QPalette.ColorRole.Button)
|
|
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; }}
|
|
{button_emboss_qss(button)}
|
|
""")
|
|
|
|
|
|
def emboss_stops(color: QColor, pressed: bool = False) -> list:
|
|
"""[(position, QColor)] of the emboss: a crisp lit rim along the top and
|
|
a crisp shaded rim along the bottom, with the face itself nearly flat.
|
|
``pressed`` flips it — a button sinking in, or a debossed panel. The
|
|
color itself sits at the midpoint, so what the user picked is still
|
|
exactly what the middle shows."""
|
|
rim_top, face_top = color.lighter(EMBOSS_RIM), color.lighter(EMBOSS_LIGHTER)
|
|
face_bottom, rim_bottom = color.darker(EMBOSS_DARKER), color.darker(EMBOSS_RIM)
|
|
if pressed:
|
|
rim_top, face_top, face_bottom, rim_bottom = (
|
|
rim_bottom, face_bottom, face_top, rim_top)
|
|
return [(0, rim_top), (EMBOSS_RIM_WIDTH, face_top), (0.5, color),
|
|
(round(1 - EMBOSS_RIM_WIDTH, 2), face_bottom), (1, rim_bottom)]
|
|
|
|
|
|
def emboss_gradient(color: QColor, pressed: bool = False) -> str:
|
|
"""``emboss_stops`` as a style-sheet gradient. Computed from the live
|
|
color, so it follows the palette and the user's grayscale overrides."""
|
|
stops = ", ".join(f"stop:{pos} {c.name()}"
|
|
for pos, c in emboss_stops(color, pressed))
|
|
return f"qlineargradient(x1:0, y1:0, x2:0, y2:1, {stops})"
|
|
|
|
|
|
# How far the emboss strays from the base color (Qt's lighter/darker factor,
|
|
# 100 = unchanged). The face barely moves; the rims are where the shape is.
|
|
EMBOSS_LIGHTER = 103
|
|
EMBOSS_DARKER = 103
|
|
EMBOSS_RIM = 116
|
|
EMBOSS_RIM_WIDTH = 0.08 # fraction of the height each rim takes
|
|
HOVER_DARKER = 107 # hover: the whole button, slightly darker
|
|
PRESSED_DARKER = 112
|
|
|
|
|
|
def emboss_edges(color: QColor) -> str:
|
|
"""A defined outline: a touch darker at the bottom, like a lit shape."""
|
|
edge = color.darker(135).name()
|
|
bottom = color.darker(155).name()
|
|
return f"border: 1px solid {edge}; border-bottom-color: {bottom};"
|
|
|
|
|
|
def deboss_edges(color: QColor) -> str:
|
|
"""The emboss outline turned over: darker along the top, as a panel set
|
|
into the surface catches shadow there."""
|
|
edge = color.darker(135).name()
|
|
top = color.darker(155).name()
|
|
return f"border: 1px solid {edge}; border-top-color: {top};"
|
|
|
|
|
|
def deboss_qss(selector: str, color: QColor, *, radius: int = 8,
|
|
extra: str = "") -> str:
|
|
"""Pushed in, by exactly as much as a button is pushed out: the same
|
|
rims and outline, flipped. For panels that show things rather than do
|
|
things (the now-playing panel, the visualizer)."""
|
|
return (f"{selector} {{ {deboss_edges(color)} border-radius: {radius}px;"
|
|
f" background: {emboss_gradient(color, pressed=True)}; {extra} }}")
|
|
|
|
|
|
def paint_deboss(painter, rect, color: QColor, radius: float):
|
|
"""``deboss_qss`` for a widget that paints itself: fills ``rect`` (a
|
|
QRectF) as a debossed rounded panel and returns its path, for clipping."""
|
|
from PyQt6.QtCore import QPointF
|
|
from PyQt6.QtGui import QLinearGradient, QPainterPath, QPen
|
|
path = QPainterPath()
|
|
path.addRoundedRect(rect, radius, radius)
|
|
gradient = QLinearGradient(QPointF(0, rect.top()), QPointF(0, rect.bottom()))
|
|
for pos, stop in emboss_stops(color, pressed=True):
|
|
gradient.setColorAt(pos, stop)
|
|
painter.fillPath(path, gradient)
|
|
painter.setPen(QPen(color.darker(135), 1))
|
|
painter.drawPath(path)
|
|
# the darker top edge
|
|
painter.save()
|
|
painter.setClipRect(rect.adjusted(-1, -1, 1, -rect.height() / 2))
|
|
painter.setPen(QPen(color.darker(155), 1))
|
|
painter.drawPath(path)
|
|
painter.restore()
|
|
return path
|
|
|
|
|
|
def button_face_qss(selector: str, color: QColor, *, radius: int = 4,
|
|
extra: str = "", checked_color: QColor | None = None,
|
|
checked_text: str = "") -> str:
|
|
"""The one look every button shares: the emboss, the outline, a hover that
|
|
darkens the whole button a little, and a pressed state that sinks in.
|
|
``extra`` carries per-button layout (padding, margins, corners);
|
|
``checked_color`` is for toggles like the Library button."""
|
|
hover = color.darker(HOVER_DARKER)
|
|
down = color.darker(PRESSED_DARKER)
|
|
qss = (f"{selector} {{ {emboss_edges(color)} border-radius: {radius}px;"
|
|
f" background: {emboss_gradient(color)}; {extra} }}"
|
|
f"{selector}:hover {{ background: {emboss_gradient(hover)}; }}"
|
|
f"{selector}:pressed {{ background: {emboss_gradient(down, pressed=True)}; }}")
|
|
if checked_color is not None:
|
|
lit = checked_color
|
|
text = f" color: {checked_text};" if checked_text else ""
|
|
qss += (f"{selector}:checked {{ background: {emboss_gradient(lit, pressed=True)};"
|
|
f" {emboss_edges(lit)}{text} }}"
|
|
f"{selector}:checked:hover {{ background:"
|
|
f" {emboss_gradient(lit.darker(HOVER_DARKER), pressed=True)}; }}")
|
|
return qss
|
|
|
|
|
|
def button_emboss_qss(button: QColor) -> str:
|
|
"""Global rule for every non-flat QPushButton. Flat buttons (the version
|
|
button, the cancel-transfer ✕) opt out by being flat; the transport's bpm
|
|
button is flat inside a box that carries the look instead."""
|
|
return button_face_qss(
|
|
"QPushButton:!flat", button,
|
|
extra="padding: 3px 12px; min-height: 1.3em;",
|
|
checked_color=button.darker(PRESSED_DARKER)) + \
|
|
"QPushButton:!flat:default { border-color: palette(highlight); }" \
|
|
"QPushButton:!flat:disabled { color: palette(mid); }"
|
|
|
|
|
|
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()
|