""" 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.")