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>
160 lines
5.6 KiB
Python
160 lines
5.6 KiB
Python
"""
|
||
Application-wide button polish
|
||
==============================
|
||
|
||
Two problems, both spread across every file in the GUI:
|
||
|
||
- **Emoji standing in for icons.** `cards.download` is the single character
|
||
"⬇" on a button with no text and no tooltip; the queue's clear button is
|
||
"✕"; the downloads folder button is "📁". These depend entirely on a system
|
||
emoji font, and render as tofu boxes without one.
|
||
- **Almost nothing has a tooltip.** Three widgets in the whole application set
|
||
one, and nothing sets an accessible name -- so icon-only buttons are
|
||
unidentifiable to sighted users and invisible to a screen reader alike.
|
||
|
||
Fixing that at the call sites would mean editing well over a hundred of them,
|
||
most in files owned by upstream YTSage, which would conflict on the next merge.
|
||
|
||
Instead this installs one event filter on the QApplication and handles
|
||
`QEvent.Polish`, which Qt sends to every widget exactly once before it is first
|
||
shown -- including widgets inside dialogs built by upstream code, and including
|
||
any added later. One hook, whole tree, no call-site edits.
|
||
|
||
New code does not need the glyph map: give a button a `sageIcon` property and
|
||
this will resolve it.
|
||
|
||
btn.setProperty("sageIcon", "download")
|
||
"""
|
||
|
||
from typing import Dict, Optional
|
||
|
||
from PySide6.QtCore import QEvent, QObject
|
||
from PySide6.QtWidgets import QAbstractButton, QApplication
|
||
|
||
from . import ytsage_icons as icons
|
||
from . import ytsage_theme as theme
|
||
from ..utils.ytsage_logger import logger
|
||
|
||
#: Emoji that are standing in for icons, and what they actually mean.
|
||
#: Matched as the whole label, or as a leading glyph followed by real text
|
||
#: ("▶ Play"), never mid-string.
|
||
GLYPH_ICONS: Dict[str, str] = {
|
||
"⬇": "download",
|
||
"▶": "play",
|
||
"⏸": "pause",
|
||
"⏹": "stop",
|
||
"✕": "x",
|
||
"✖": "x",
|
||
"×": "x",
|
||
"+": "plus",
|
||
"+": "plus",
|
||
"📁": "folder-open",
|
||
"📂": "folder-open",
|
||
"🔄": "refresh",
|
||
"🗑": "trash",
|
||
"⚙": "settings",
|
||
"🔍": "search",
|
||
}
|
||
|
||
#: Tooltips for buttons whose label is only artwork once the glyph is
|
||
#: replaced, so they do not end up with no description at all.
|
||
ICON_TOOLTIPS: Dict[str, str] = {
|
||
"download": "Download",
|
||
"play": "Play",
|
||
"pause": "Pause",
|
||
"stop": "Stop",
|
||
"x": "Clear",
|
||
"plus": "Add",
|
||
"folder-open": "Open folder",
|
||
"refresh": "Refresh",
|
||
"trash": "Delete",
|
||
"settings": "Settings",
|
||
"search": "Search",
|
||
"maximize": "Fullscreen",
|
||
"minimize": "Leave fullscreen",
|
||
"volume-mute": "Mute",
|
||
"skip-forward": "Next",
|
||
"skip-back": "Previous",
|
||
}
|
||
|
||
|
||
def _strip_accelerator(text: str) -> str:
|
||
""""&Yes" -> "Yes". Qt's mnemonic markers must not defeat matching."""
|
||
return text.replace("&&", "\x00").replace("&", "").replace("\x00", "&")
|
||
|
||
|
||
class ButtonPolisher(QObject):
|
||
"""Turns placeholder glyphs into icons and fills in missing descriptions."""
|
||
|
||
def eventFilter(self, obj: QObject, event: QEvent) -> bool:
|
||
if event.type() == QEvent.Type.Polish and isinstance(obj, QAbstractButton):
|
||
try:
|
||
self._polish_button(obj)
|
||
except Exception as e:
|
||
# Cosmetics must never break a dialog from opening.
|
||
logger.debug(f"Button polish skipped: {e}")
|
||
return False
|
||
|
||
# ------------------------------------------------------------------
|
||
|
||
def _polish_button(self, btn: QAbstractButton) -> None:
|
||
icon_name: Optional[str] = None
|
||
|
||
# 1. Explicit opt-in wins.
|
||
declared = btn.property("sageIcon")
|
||
if declared:
|
||
icon_name = str(declared)
|
||
|
||
raw_text = btn.text() or ""
|
||
text = _strip_accelerator(raw_text).strip()
|
||
|
||
# 2. Otherwise infer from a placeholder glyph. Buttons carrying a
|
||
# mnemonic are left alone: those are standard dialog buttons.
|
||
if icon_name is None and "&" not in raw_text and text:
|
||
first = text[0]
|
||
if text in GLYPH_ICONS:
|
||
icon_name = GLYPH_ICONS[text]
|
||
text = "" # the glyph *was* the whole label
|
||
elif first in GLYPH_ICONS and len(text) > 1 and text[1] in " \t":
|
||
icon_name = GLYPH_ICONS[first]
|
||
text = text[1:].strip()
|
||
|
||
if icon_name is not None and btn.icon().isNull():
|
||
# White reads on the accent-coloured buttons; the muted token
|
||
# would disappear into them.
|
||
btn.setIcon(icons.icon(icon_name, theme.ICON_ON_ACCENT))
|
||
if text != _strip_accelerator(raw_text).strip():
|
||
btn.setText(text)
|
||
|
||
# 3. Every button gets something to describe it.
|
||
label = _strip_accelerator(btn.text() or "").strip()
|
||
if not btn.toolTip():
|
||
if label:
|
||
btn.setToolTip(label)
|
||
elif icon_name and icon_name in ICON_TOOLTIPS:
|
||
btn.setToolTip(ICON_TOOLTIPS[icon_name])
|
||
elif not btn.icon().isNull():
|
||
# An icon-only button nobody has described. Name it so the
|
||
# gap is findable rather than silent.
|
||
logger.debug(f"Icon-only button without a tooltip: {btn.objectName() or btn!r}")
|
||
|
||
if not btn.accessibleName():
|
||
btn.setAccessibleName(label or btn.toolTip())
|
||
|
||
|
||
_polisher: Optional[ButtonPolisher] = None
|
||
|
||
|
||
def install(app: Optional[QApplication] = None) -> None:
|
||
"""Install once, before the main window is built."""
|
||
global _polisher
|
||
if _polisher is not None:
|
||
return
|
||
app = app or QApplication.instance()
|
||
if app is None:
|
||
logger.debug("UI polish not installed: no QApplication yet.")
|
||
return
|
||
_polisher = ButtonPolisher(app)
|
||
app.installEventFilter(_polisher)
|
||
logger.debug("Button polish filter installed.")
|