Give the app icons, tooltips and a finished theme

There was no icon system at all. Transport buttons called
QStyle.standardIcon(SP_MediaPlay), which returns the platform theme's dark
monochrome glyph -- painted onto the app's saturated red buttons at a fixed
36px with no text, that is the black square. Everything else called an icon
was an emoji baked into en.json, which is tofu wherever the emoji font is
missing. Qt stylesheets cannot recolour a QIcon, so the colour is an argument
to the new helper; that is the whole fix.

The SVGs are drawn here rather than vendored, which keeps a third-party
licence out of the tree, and they live in the module rather than as asset
files, which keeps them out of package-data and safe in a frozen build.

Tooltips were the other half: three widgets in the entire application had one
and nothing set an accessible name. Filling that in at the call sites would
have meant editing well over a hundred of them, most in upstream-owned files.
Instead one application-level event filter handles QEvent.Polish, which Qt
sends to every widget once before it is shown -- so it also reaches dialogs
built by upstream code, and survives the next merge. It maps placeholder
emoji to icons, fills empty tooltips from the button text, and logs icon-only
buttons that still have none so the gaps are findable.

StyleSheet.MAIN styles the window, inputs, buttons and tables and nothing
else, so the main tab bar, combos, sliders, lists, menus, splitters, tooltips
and the horizontal scrollbar fell through to the platform style. EXTRA_QSS
covers them, in a fork-owned module appended at the one application site.
SmoothTabWidget names its frame "tabContent" with the comment "We draw border
on content instead" -- that rule existed only inside two dialogs, and now
exists for the main window too.

Two corrections to rules that were already there: the pressed style changed
the padding, shifting every label two pixels and clipping fixed-width icon
buttons, and checkboxes were fully rounded, which reads as a radio button
rather than an on/off toggle.

Verified by screenshot on the real display: tab bar, buttons, combo carets
and checkboxes all render as intended, and all 35 icons rasterise non-empty.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-09 00:58:38 +02:00
parent 5ae2817eea
commit 3aca43b372
16 changed files with 783 additions and 22 deletions
+327
View File
@@ -0,0 +1,327 @@
"""
Theme tokens and the stylesheet rules the app never had
=======================================================
`StyleSheet.MAIN` (upstream's, in ytsage_stylesheet.py) styles the window,
line edits, buttons, tables, progress bars and vertical scrollbars -- and
nothing else. Everything it misses falls through to `QWidget { background:
#15181b; color: #ffffff }` plus the platform style, which is why the app looked
half-finished: the main tab bar was drawn by Fusion from the system palette
with white text forced onto it, combo boxes and sliders were native, tooltips
came out in the system's light style on a black app, and a native light
horizontal scrollbar appeared under any wide view.
Notably `SmoothTabWidget` names its content frame `tabContent` with the comment
"We draw border on content instead" -- and the only `QFrame#tabContent` rules
in the codebase were inside two dialogs. The main window's tab bar got none of
it.
This module holds the colour tokens and `EXTRA_QSS`, appended to
`StyleSheet.MAIN` at the single application site. Keeping it separate means one
changed line in an upstream-owned file, so the next merge from YTSage does not
fight over it.
The two dialogs that define their own tab rules keep them. Unifying would cost
more in merge conflicts than the duplication does.
"""
from ..utils.ytsage_logger import logger
# --- colour tokens -------------------------------------------------------
# Names, not hexes, at the call sites. These match what StyleSheet.MAIN
# already uses so the two halves agree.
BG = "#15181b" # window
SURFACE = "#1b2021" # panels, inputs, list backgrounds
SURFACE_ALT = "#1d2124" # menus, tooltips, elevated surfaces
SURFACE_HOVER = "#252a2d"
BORDER = "#2a2d2e"
BORDER_STRONG = "#3d3d3d"
TEXT = "#ffffff"
TEXT_MUTED = "#9aa0a6"
TEXT_DISABLED = "#6b7075"
ACCENT = "#c90000"
ACCENT_HOVER = "#a50000"
ACCENT_PRESSED = "#800000"
FOCUS = "#ff6b6b"
#: Default icon colour: near-white, deliberately not pure #ffffff so it does
#: not out-glare the text beside it.
ICON = "#e8eaed"
#: Icons sitting on an accent-coloured button.
ICON_ON_ACCENT = "#ffffff"
def ensure_ui_assets() -> dict:
"""
Write the few bitmaps Qt stylesheets can only reference as files.
QSS cannot draw a shape: `::down-arrow` and `::indicator` need an actual
`image:`. Everything else here is drawn with borders and radii, but a
caret and a tick are not expressible that way -- restyling a combo box
without supplying one leaves it with a blank square where its arrow was.
Rendered from the same SVG set as every other icon and cached next to the
thumbnails, so nothing extra ships and nothing is fetched.
"""
from . import ytsage_icons as icons
from ..utils.ytsage_constants import APP_DATA_DIR
out_dir = APP_DATA_DIR / "ui"
# Rendered larger than the box they are drawn into (see the QSS below):
# a 24-unit viewBox squeezed straight into 12px leaves a 1px stroke that
# all but disappears on this background.
wanted = {
"chevron": ("chevron-down", TEXT_MUTED, 14),
"chevron_disabled": ("chevron-down", TEXT_DISABLED, 14),
"check": ("check", TEXT, 12),
}
paths = {}
try:
out_dir.mkdir(parents=True, exist_ok=True)
for key, (name, color, size) in wanted.items():
target = out_dir / f"{key}.png"
if not target.exists():
# dpr 1.0 and the final pixel size: Qt draws a QSS `image:` at
# its natural size, so anything else is scaled or clipped.
icons.pixmap(name, color, size, 1.0).save(str(target), "PNG")
# QSS wants forward slashes even on Windows.
paths[key] = str(target).replace("\\", "/")
except Exception as e: # pragma: no cover - cosmetic only
logger.debug(f"Could not write UI assets, falling back to plain styling: {e}")
return {}
return paths
def build_extra_qss() -> str:
"""EXTRA_QSS plus the rules that need generated images, when available."""
qss = EXTRA_QSS
assets = ensure_ui_assets()
if not assets:
return qss
return qss + f"""
QComboBox::down-arrow {{
image: url("{assets['chevron']}");
border: none;
}}
QComboBox::down-arrow:disabled {{ image: url("{assets['chevron_disabled']}"); }}
/* Upstream styles checkboxes as filled circles (border-radius: 9px), which
reads as a radio button -- a control that means "one of these" rather than
"on or off". Square them off and show an actual tick. Widget-level
stylesheets win over this one, so StyleSheet.CHECKBOX is corrected at
source; these rules cover every checkbox that does not use it. */
QCheckBox::indicator {{
width: 16px;
height: 16px;
border-radius: 4px;
border: 2px solid {BORDER_STRONG};
background-color: {SURFACE};
}}
QCheckBox::indicator:hover {{ border-color: {TEXT_MUTED}; }}
QCheckBox::indicator:checked {{
border-color: {ACCENT};
background-color: {ACCENT};
image: url("{assets['check']}");
}}
QCheckBox::indicator:checked:hover {{ border-color: {FOCUS}; }}
QCheckBox::indicator:disabled {{ border-color: {BORDER}; background-color: {BG}; }}
"""
EXTRA_QSS = f"""
/* ---- main tab bar (SmoothTabWidget) --------------------------------- */
QTabBar {{
background-color: {BG};
border: none;
}}
QTabBar::tab {{
background-color: transparent;
color: {TEXT_MUTED};
padding: 9px 18px;
margin-right: 2px;
border: none;
border-bottom: 2px solid transparent;
font-size: 13px;
}}
QTabBar::tab:hover:!selected {{
color: {TEXT};
background-color: {SURFACE};
}}
QTabBar::tab:selected {{
color: {TEXT};
background-color: {SURFACE};
border-bottom: 2px solid {ACCENT};
}}
QTabBar::tab:disabled {{
color: {TEXT_DISABLED};
}}
QFrame#tabContent {{
background-color: {BG};
border: none;
border-top: 1px solid {BORDER};
}}
/* ---- combo boxes ---------------------------------------------------- */
QComboBox {{
background-color: {SURFACE};
color: {TEXT};
border: 1px solid {BORDER};
border-radius: 4px;
padding: 5px 10px;
min-height: 18px;
}}
QComboBox:hover {{ border-color: {BORDER_STRONG}; }}
QComboBox:focus {{ border-color: {FOCUS}; }}
QComboBox:disabled {{ color: {TEXT_DISABLED}; background-color: {BG}; }}
QComboBox::drop-down {{
border: none;
width: 22px;
subcontrol-origin: padding;
subcontrol-position: center right;
}}
/* ::down-arrow is deliberately not styled here -- QSS cannot draw a caret,
and setting only a border leaves a small square where the arrow was.
build_extra_qss() supplies a real image; without it Qt's native arrow is
the better fallback. */
QComboBox QAbstractItemView {{
background-color: {SURFACE_ALT};
color: {TEXT};
border: 1px solid {BORDER};
selection-background-color: {ACCENT};
selection-color: {TEXT};
outline: none;
padding: 2px;
}}
/* ---- lists ---------------------------------------------------------- */
QListWidget, QListView {{
background-color: {SURFACE};
color: {TEXT};
border: 1px solid {BORDER};
border-radius: 4px;
outline: none;
}}
QListWidget::item, QListView::item {{
padding: 6px 8px;
border-radius: 3px;
}}
QListWidget::item:hover, QListView::item:hover {{ background-color: {SURFACE_HOVER}; }}
QListWidget::item:selected, QListView::item:selected {{
background-color: {ACCENT};
color: {TEXT};
}}
/* ---- menus ---------------------------------------------------------- */
QMenu {{
background-color: {SURFACE_ALT};
color: {TEXT};
border: 1px solid {BORDER};
padding: 4px;
}}
QMenu::item {{ padding: 6px 22px 6px 14px; border-radius: 3px; }}
QMenu::item:selected {{ background-color: {ACCENT}; }}
QMenu::item:disabled {{ color: {TEXT_DISABLED}; }}
QMenu::separator {{ height: 1px; background-color: {BORDER}; margin: 4px 6px; }}
/* ---- sliders (seek and volume were fully native) --------------------- */
QSlider::groove:horizontal {{
height: 4px;
background-color: {BORDER};
border-radius: 2px;
}}
QSlider::sub-page:horizontal {{
background-color: {ACCENT};
border-radius: 2px;
}}
QSlider::handle:horizontal {{
background-color: {TEXT};
width: 12px;
height: 12px;
margin: -5px 0;
border-radius: 6px;
}}
QSlider::handle:horizontal:hover {{ background-color: {FOCUS}; }}
QSlider::handle:horizontal:disabled {{ background-color: {TEXT_DISABLED}; }}
QSlider::groove:vertical {{ width: 4px; background-color: {BORDER}; border-radius: 2px; }}
QSlider::handle:vertical {{
background-color: {TEXT}; height: 12px; margin: 0 -5px; border-radius: 6px;
}}
/* ---- splitters ------------------------------------------------------- */
QSplitter::handle {{ background-color: {BORDER}; }}
QSplitter::handle:horizontal {{ width: 4px; }}
QSplitter::handle:vertical {{ height: 4px; }}
QSplitter::handle:hover {{ background-color: {ACCENT}; }}
/* ---- tooltips (were rendering in the system's light style) ----------- */
QToolTip {{
background-color: {SURFACE_ALT};
color: {TEXT};
border: 1px solid {BORDER};
padding: 5px 8px;
border-radius: 4px;
}}
/* ---- horizontal scrollbar (only the vertical one was styled) --------- */
QScrollBar:horizontal {{
background-color: {BG};
height: 12px;
margin: 0;
border: none;
}}
QScrollBar::handle:horizontal {{
background-color: {BORDER_STRONG};
min-width: 24px;
border-radius: 6px;
}}
QScrollBar::handle:horizontal:hover {{ background-color: {ACCENT}; }}
QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal {{
width: 0; border: none; background: none;
}}
QScrollBar::add-page:horizontal, QScrollBar::sub-page:horizontal {{ background: none; }}
/* ---- assorted controls left unstyled --------------------------------- */
QRadioButton, QCheckBox {{ color: {TEXT}; spacing: 7px; }}
QRadioButton:disabled, QCheckBox:disabled {{ color: {TEXT_DISABLED}; }}
QGroupBox {{
border: 1px solid {BORDER};
border-radius: 5px;
margin-top: 10px;
padding-top: 8px;
color: {TEXT};
}}
QGroupBox::title {{
subcontrol-origin: margin;
left: 10px;
padding: 0 5px;
color: {TEXT_MUTED};
}}
QSpinBox, QDoubleSpinBox {{
background-color: {SURFACE};
color: {TEXT};
border: 1px solid {BORDER};
border-radius: 4px;
padding: 4px 6px;
}}
/* ---- corrections to rules that already exist ------------------------- */
/* Upstream's QPushButton:pressed changes the padding, which shifts every
label two pixels down-and-right on click and clips the artwork on the
fixed-width icon buttons. The background change alone reads as pressed. */
QPushButton:pressed {{
padding: 8px 15px;
}}
/* One consistent icon size everywhere, since icons are now real artwork. */
QPushButton, QToolButton {{
qproperty-iconSize: 18px 18px;
}}
QToolButton {{
background-color: transparent;
border: none;
border-radius: 4px;
padding: 5px;
color: {TEXT};
}}
QToolButton:hover {{ background-color: {SURFACE_HOVER}; }}
QToolButton:pressed {{ background-color: {BORDER}; }}
"""