3aca43b372
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>
328 lines
11 KiB
Python
328 lines
11 KiB
Python
"""
|
|
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}; }}
|
|
"""
|