diff --git a/CHANGELOG.md b/CHANGELOG.md index 89f2482..a3760c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,8 +10,36 @@ records. ## Unreleased +### Added + +- **Real icons.** The app had no icon system: transport buttons used the + platform style's dark monochrome glyphs painted on saturated red, and + everything else called an icon was an emoji baked into the English strings. + A drawn SVG set is now rendered through QtSvg and recoloured at load — Qt + stylesheets cannot recolour a `QIcon`, which is exactly why the old ones were + unreadable. Buttons across the Downloads tab gained icons too. +- **Tooltips and accessible names on every button.** Three widgets in the whole + application had a tooltip. An application-level polish filter now fills them + in as each button is first shown, and swaps placeholder emoji for icons — + which covers dialogs owned by upstream without editing them. + ### Fixed +- **The rest of the interface is themed.** `StyleSheet.MAIN` styled the window, + inputs, buttons and tables and nothing else, so the main tab bar, combo + boxes, sliders, lists, menus, splitters, tooltips and the horizontal + scrollbar were drawn by the platform — the tab bar with white text forced + onto system-coloured tabs, tooltips in the system's light style on a black + app. All of them now match. `QFrame#tabContent` finally has the rule the tab + widget's own comment promised. +- Checkboxes were round, which reads as a radio button — "one of these" rather + than "on or off". They are square with a tick. +- Clicking any button shifted its label two pixels down and right, and clipped + the artwork on fixed-width icon buttons: the pressed style changed the + padding. The background change alone reads as pressed. +- The window icon shipped only at 48px and was upscaled everywhere; the 256px + master now ships beside it. Its fallback was the platform's download arrow + standing in for the application's identity. - **The player no longer crashes the app.** Qt destroys and recreates a widget's OpenGL context whenever it moves to another top-level window, and calls `initializeGL()` again. libmpv allows one render context per handle, so diff --git a/pyproject.toml b/pyproject.toml index b555a97..f591740 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,6 +66,7 @@ include = ["ytsage*"] [tool.setuptools.package-data] ytsage = [ "assets/Icon/icon.png", + "assets/Icon/icon-256.png", "assets/sound/notification.mp3", "languages/*.json", ] \ No newline at end of file diff --git a/ytsage/assets/Icon/icon-256.png b/ytsage/assets/Icon/icon-256.png new file mode 100644 index 0000000..a6b6590 Binary files /dev/null and b/ytsage/assets/Icon/icon-256.png differ diff --git a/ytsage/gui/ytsage_gui_browse.py b/ytsage/gui/ytsage_gui_browse.py index 240bf05..7934846 100644 --- a/ytsage/gui/ytsage_gui_browse.py +++ b/ytsage/gui/ytsage_gui_browse.py @@ -132,6 +132,7 @@ class BrowsePage(QWidget): self.url_input.setMinimumHeight(38) bar.addWidget(self.url_input, stretch=1) self.open_btn = QPushButton(_("browse.open")) + self.open_btn.setToolTip(_("browse.open_tooltip")) self.open_btn.setMinimumHeight(38) self.open_btn.clicked.connect(self._open_from_input) bar.addWidget(self.open_btn) @@ -142,14 +143,17 @@ class BrowsePage(QWidget): self.title_label.setStyleSheet("font-size: 16px; font-weight: bold;") header.addWidget(self.title_label, stretch=1) self.subscribe_btn = QPushButton(_("browse.subscribe")) + self.subscribe_btn.setToolTip(_("browse.subscribe_tooltip")) self.subscribe_btn.setVisible(False) self.subscribe_btn.clicked.connect(self._on_subscribe_clicked) header.addWidget(self.subscribe_btn) self.playall_btn = QPushButton(_("browse.play_all")) + self.playall_btn.setToolTip(_("browse.play_all_tooltip")) self.playall_btn.setVisible(False) self.playall_btn.clicked.connect(self._on_play_all) header.addWidget(self.playall_btn) self.download_btn = QPushButton(_("browse.download_playlist")) + self.download_btn.setToolTip(_("browse.download_playlist_tooltip")) self.download_btn.setVisible(False) self.download_btn.clicked.connect(self._on_download_playlist) header.addWidget(self.download_btn) diff --git a/ytsage/gui/ytsage_gui_cards.py b/ytsage/gui/ytsage_gui_cards.py index fcbd3a6..d1dd37e 100644 --- a/ytsage/gui/ytsage_gui_cards.py +++ b/ytsage/gui/ytsage_gui_cards.py @@ -147,14 +147,17 @@ class VideoCard(QFrame): actions.setSpacing(6) self.play_btn = QPushButton(_("cards.play")) + self.play_btn.setToolTip(_("cards.play_tooltip")) self.play_btn.clicked.connect(lambda: self._router.playVideo.emit(self._routed_entry())) actions.addWidget(self.play_btn) self.queue_btn = QPushButton(_("cards.queue")) + self.queue_btn.setToolTip(_("cards.queue_tooltip")) self.queue_btn.clicked.connect(lambda: self._router.queueVideo.emit(self._routed_entry())) actions.addWidget(self.queue_btn) self.download_btn = QPushButton(_("cards.download")) + self.download_btn.setToolTip(_("cards.download_tooltip")) self.download_btn.clicked.connect(self._emit_download) actions.addWidget(self.download_btn) @@ -220,6 +223,7 @@ class VideoCardGrid(QScrollArea): outer.addWidget(self._grid_widget) self.load_more_btn = QPushButton(_("cards.load_more")) + self.load_more_btn.setToolTip(_("cards.load_more_tooltip")) self.load_more_btn.clicked.connect(self.loadMoreRequested.emit) self.load_more_btn.setVisible(False) outer.addWidget(self.load_more_btn, alignment=Qt.AlignmentFlag.AlignCenter) diff --git a/ytsage/gui/ytsage_gui_feed.py b/ytsage/gui/ytsage_gui_feed.py index 8655710..0dc9e18 100644 --- a/ytsage/gui/ytsage_gui_feed.py +++ b/ytsage/gui/ytsage_gui_feed.py @@ -84,6 +84,7 @@ class FeedPage(QWidget): bar = QHBoxLayout() self.mode_combo = QComboBox() + self.mode_combo.setToolTip(_("feed.mode_tooltip")) self.mode_combo.addItem(_("feed.mode_local"), "local") self.mode_combo.addItem(_("feed.mode_account"), "account") self.mode_combo.setCurrentIndex(0 if (ConfigManager.get("feed.mode") or "local") == "local" else 1) @@ -91,6 +92,7 @@ class FeedPage(QWidget): bar.addWidget(self.mode_combo) self.refresh_btn = QPushButton(_("feed.refresh")) + self.refresh_btn.setToolTip(_("feed.refresh_tooltip")) self.refresh_btn.clicked.connect(self.refresh) bar.addWidget(self.refresh_btn) diff --git a/ytsage/gui/ytsage_gui_main.py b/ytsage/gui/ytsage_gui_main.py index 1e7f17e..a48f0f9 100644 --- a/ytsage/gui/ytsage_gui_main.py +++ b/ytsage/gui/ytsage_gui_main.py @@ -56,6 +56,7 @@ from .ytsage_gui_analysis import AnalysisMixin from .ytsage_smooth_tab_widget import SmoothTabWidget from ..utils.ytsage_constants import ( ICON_PATH, + ICON_PATH_LARGE, SOUND_PATH, SUBPROCESS_CREATIONFLAGS, VIDEO_EXTENSIONS, @@ -67,6 +68,9 @@ from ..utils.ytsage_config_manager import ConfigManager from ..utils.ytsage_localization import LocalizationManager, _ from ..utils.ytsage_history_manager import HistoryManager from .ytsage_stylesheet import StyleSheet +from .ytsage_theme import build_extra_qss +from . import ytsage_icons as icons +from . import ytsage_theme as theme class UpdateCheckThread(QThread): @@ -122,12 +126,18 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin): self.version = APP_VERSION load_saved_path(self) - # Load custom icon - if ICON_PATH.exists(): - self.setWindowIcon(QIcon(str(ICON_PATH))) - else: - logger.warning(f"Icon file not found at {ICON_PATH}. Using default icon.") - self.setWindowIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_ArrowDown)) # Fallback + # Load custom icon. Both sizes go in so the window manager and taskbar + # pick rather than upscale the 48px one. + app_icon = QIcon() + for path in (ICON_PATH, ICON_PATH_LARGE): + if path.exists(): + app_icon.addFile(str(path)) + if app_icon.isNull(): + logger.warning(f"Icon file not found at {ICON_PATH}. Using a drawn fallback.") + # A drawn icon beats the platform's download arrow standing in for + # the application's identity. + app_icon = icons.icon("play-circle", theme.ACCENT, 64) + self.setWindowIcon(app_icon) self.signals = SignalManager() self.download_paused = False self.current_download = None @@ -184,7 +194,12 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin): # Defer heavy start-up tasks to ensure UI renders immediately QTimer.singleShot(100, self._perform_startup_checks) - self.setStyleSheet(StyleSheet.MAIN) + # StyleSheet.MAIN is upstream's and covers the window, inputs, buttons + # and tables. EXTRA_QSS adds everything it never styled -- the tab bar, + # combos, sliders, lists, menus, splitters, tooltips and the horizontal + # scrollbar -- and corrects two of its rules. Kept in a separate, + # fork-owned module so this stays a one-line change here. + self.setStyleSheet(StyleSheet.MAIN + build_extra_qss()) self.signals.update_progress.connect(self.update_progress_bar) # After adding format buttons @@ -314,6 +329,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin): # Analyze button with app's red theme self.analyze_button = QPushButton(_("buttons.analyze")) + self.analyze_button.setProperty("sageIcon", "search") self.analyze_button.clicked.connect(self.analyze_url) self.analyze_button.setEnabled(False) # Disabled until URL is entered self.analyze_button.setMinimumHeight(42) @@ -435,33 +451,41 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin): # Replace the two separate buttons with a single Custom Options button self.custom_options_btn = QPushButton(_("buttons.custom_options")) + self.custom_options_btn.setProperty("sageIcon", "settings") self.custom_options_btn.clicked.connect(self.show_custom_options) self.about_btn = QPushButton(_("buttons.about")) + self.about_btn.setProperty("sageIcon", "info") self.about_btn.clicked.connect(self.show_about_dialog) self.history_btn = QPushButton(_("buttons.history")) + self.history_btn.setProperty("sageIcon", "clock") self.history_btn.clicked.connect(self.show_history_dialog) # Add new Time Range button self.time_range_btn = QPushButton(_("buttons.trim_video")) + self.time_range_btn.setProperty("sageIcon", "scissors") self.time_range_btn.clicked.connect(self.show_time_range_dialog) # --- Rename Path Button to Settings Button --- self.settings_button = QPushButton(_("buttons.download_settings")) # Renamed button + self.settings_button.setProperty("sageIcon", "settings") self.settings_button.clicked.connect(self.show_download_settings_dialog) # Renamed method self._update_settings_tooltip() # --- End Settings Button --- self.download_btn = QPushButton(_("buttons.download")) + self.download_btn.setProperty("sageIcon", "download") self.download_btn.clicked.connect(self.start_download) # Add pause and cancel buttons self.pause_btn = QPushButton(_("buttons.pause")) + self.pause_btn.setProperty("sageIcon", "pause") self.pause_btn.clicked.connect(self.toggle_pause) self.pause_btn.setVisible(False) # Hidden initially self.cancel_btn = QPushButton(_("buttons.cancel")) + self.cancel_btn.setProperty("sageIcon", "x") self.cancel_btn.clicked.connect(self.cancel_download) self.cancel_btn.setVisible(False) # Hidden initially diff --git a/ytsage/gui/ytsage_gui_player.py b/ytsage/gui/ytsage_gui_player.py index 0b94c2f..b9d3259 100644 --- a/ytsage/gui/ytsage_gui_player.py +++ b/ytsage/gui/ytsage_gui_player.py @@ -35,6 +35,8 @@ from PySide6.QtWidgets import ( QWidget, ) +from . import ytsage_icons as icons +from . import ytsage_theme as theme from ..core.ytsage_mpv import probe_player from ..core.ytsage_yt_dlp import get_yt_dlp_path from ..utils.ytsage_config_manager import ConfigManager @@ -454,8 +456,9 @@ class PlayerPanel(QWidget): controls.setContentsMargins(6, 0, 6, 4) self.play_btn = QPushButton() - self.play_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MediaPlay)) + self.play_btn.setIcon(icons.icon("play", theme.ICON_ON_ACCENT)) self.play_btn.setFixedWidth(36) + self.play_btn.setToolTip(_("player.play_pause")) self.play_btn.clicked.connect(self.toggle_pause) controls.addWidget(self.play_btn) @@ -464,6 +467,7 @@ class PlayerPanel(QWidget): self.seek_slider = QSlider(Qt.Orientation.Horizontal) self.seek_slider.setRange(0, 1000) + self.seek_slider.setToolTip(_("player.seek_tooltip")) self.seek_slider.sliderPressed.connect(self._on_slider_pressed) self.seek_slider.sliderReleased.connect(self._on_slider_released) controls.addWidget(self.seek_slider, stretch=1) @@ -474,6 +478,7 @@ class PlayerPanel(QWidget): default_q = ConfigManager.get("player.default_quality") idx = next((i for i, (_l, h) in enumerate(QUALITY_CHOICES) if h == default_q), 0) self.quality_combo.setCurrentIndex(idx) + self.quality_combo.setToolTip(_("player.quality_tooltip")) self.quality_combo.currentIndexChanged.connect(self._on_quality_changed) controls.addWidget(self.quality_combo) @@ -481,6 +486,7 @@ class PlayerPanel(QWidget): for s in SPEED_CHOICES: self.speed_combo.addItem(f"{s:g}x", s) self.speed_combo.setCurrentIndex(SPEED_CHOICES.index(1.0)) + self.speed_combo.setToolTip(_("player.speed_tooltip")) self.speed_combo.currentIndexChanged.connect(self._on_speed_changed) controls.addWidget(self.speed_combo) @@ -488,17 +494,20 @@ class PlayerPanel(QWidget): self.volume_slider.setRange(0, 100) self.volume_slider.setFixedWidth(90) self.volume_slider.setValue(int(ConfigManager.get("player.volume") or 100)) + self.volume_slider.setToolTip(_("player.volume_tooltip")) self.volume_slider.valueChanged.connect(self._on_volume_changed) controls.addWidget(self.volume_slider) self.subs_btn = QPushButton(_("player.subtitles")) self.subs_btn.setCheckable(True) + self.subs_btn.setToolTip(_("player.subtitles_tooltip")) self.subs_btn.toggled.connect(self._on_subs_toggled) controls.addWidget(self.subs_btn) self.fullscreen_btn = QPushButton() - self.fullscreen_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_TitleBarMaxButton)) + self.fullscreen_btn.setIcon(icons.icon("maximize", theme.ICON_ON_ACCENT)) self.fullscreen_btn.setFixedWidth(36) + self.fullscreen_btn.setToolTip(_("player.fullscreen")) self.fullscreen_btn.clicked.connect(self.toggle_fullscreen) controls.addWidget(self.fullscreen_btn) @@ -619,8 +628,8 @@ class PlayerPanel(QWidget): @Slot(bool) def _on_paused_changed(self, paused: bool) -> None: - icon = QStyle.StandardPixmap.SP_MediaPlay if paused else QStyle.StandardPixmap.SP_MediaPause - self.play_btn.setIcon(self.style().standardIcon(icon)) + self.play_btn.setIcon(icons.icon("play" if paused else "pause", theme.ICON_ON_ACCENT)) + self.play_btn.setToolTip(_("player.play") if paused else _("player.pause")) @Slot(str) def _on_end_reached(self, reason: str) -> None: diff --git a/ytsage/gui/ytsage_gui_search.py b/ytsage/gui/ytsage_gui_search.py index 737446d..e371e5c 100644 --- a/ytsage/gui/ytsage_gui_search.py +++ b/ytsage/gui/ytsage_gui_search.py @@ -33,6 +33,7 @@ class SearchPage(QWidget): bar.addWidget(self.query_input, stretch=1) self.search_btn = QPushButton(_("search.button")) + self.search_btn.setToolTip(_("search.button_tooltip")) self.search_btn.clicked.connect(self.start_search) self.search_btn.setMinimumHeight(38) bar.addWidget(self.search_btn) diff --git a/ytsage/gui/ytsage_icons.py b/ytsage/gui/ytsage_icons.py new file mode 100644 index 0000000..a4720af --- /dev/null +++ b/ytsage/gui/ytsage_icons.py @@ -0,0 +1,169 @@ +""" +Icons +===== + +The app had no icon system. Transport buttons used +`QStyle.standardIcon(SP_MediaPlay)`, which returns the platform style's dark +monochrome glyph -- painted onto the app's saturated red buttons, at a fixed +36px with no text and no tooltip, that reads as a black or empty square. On +styles that return a null icon for some standard pixmaps it *was* an empty +square. Everything else called an "icon" was an emoji baked into the English +translation file, which renders as tofu wherever the emoji font is missing. + +Approach: a small set of hand-drawn SVGs rendered through QtSvg (part of +PySide6-Essentials, so no new dependency), recoloured at load. Qt stylesheets +cannot recolour a QIcon, which is the whole reason the old icons were +unreadable -- so the colour is an argument here. + +The sources live in this module rather than as asset files on purpose: no +package-data to keep in sync, no path resolution, and nothing to go missing +from a wheel or a frozen build. + +Drawing conventions: 24x24 viewBox, 2px round strokes, `%COLOR%` wherever the +colour goes. Shapes that must read as solid at 16px (the play triangle) carry +their own fill. +""" + +from functools import lru_cache +from typing import Dict, Optional + +from PySide6.QtCore import QByteArray, QRectF, Qt +from PySide6.QtGui import QIcon, QPainter, QPixmap +from PySide6.QtSvg import QSvgRenderer + +from ..utils.ytsage_logger import logger + +#: Default stroke colour. Overridden per call; kept in step with ytsage_theme. +DEFAULT_COLOR = "#e8eaed" + +_SVG: Dict[str, str] = { + # --- transport ------------------------------------------------------- + "play": '', + "pause": '' + '', + "stop": '', + "skip-back": '' + '', + "skip-forward": '' + '', + "rewind-10": '' + '10', + "forward-10": '' + '10', + # --- audio ----------------------------------------------------------- + "volume-high": '' + '', + "volume-low": '' + '', + "volume-mute": '' + '', + "captions": '' + '', + # --- window ---------------------------------------------------------- + "maximize": '' + '', + "minimize": '' + '', + # --- tabs / navigation ----------------------------------------------- + "rss": '' + '', + "search": '', + "library": '' + '', + "download": '' + '', + "user": '', + "user-check": '' + '', + # --- actions --------------------------------------------------------- + "queue": '' + '' + '', + "refresh": '', + "folder-open": '' + '', + "x": '', + "check": '', + "plus": '', + "trash": '' + '' + '', + "settings": '' + '' + '', + "info": '' + '', + "clock": '', + "scissors": '' + '' + '', + "external-link": '' + '', + "chevron-down": '', + "chevron-right": '', + "clipboard": '' + '', + "play-circle": '', +} + +_DOC = ( + '%BODY%' +) + + +def available() -> list: + """Every icon name this module can draw. Useful when adding call sites.""" + return sorted(_SVG) + + +def _document(name: str, color: str) -> Optional[bytes]: + body = _SVG.get(name) + if body is None: + return None + return _DOC.replace("%BODY%", body).replace("%COLOR%", color).encode("utf-8") + + +@lru_cache(maxsize=512) +def pixmap(name: str, color: str = DEFAULT_COLOR, size: int = 20, dpr: float = 1.0) -> QPixmap: + """One rendered pixmap. A missing name yields a transparent one, never an error.""" + px = QPixmap(max(1, int(size * dpr)), max(1, int(size * dpr))) + px.setDevicePixelRatio(dpr) + px.fill(Qt.GlobalColor.transparent) + + document = _document(name, color) + if document is None: + # A typo in a call site should show a gap, not take a dialog down. + logger.debug(f"Unknown icon name: {name!r}") + return px + + renderer = QSvgRenderer(QByteArray(document)) + if not renderer.isValid(): + logger.debug(f"Icon {name!r} failed to parse") + return px + + painter = QPainter(px) + painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) + try: + renderer.render(painter, QRectF(0, 0, size * dpr, size * dpr)) + finally: + painter.end() + return px + + +@lru_cache(maxsize=512) +def icon(name: str, color: str = DEFAULT_COLOR, size: int = 20, disabled_color: str = "#6b7075") -> QIcon: + """ + A QIcon with its own Normal and Disabled artwork. + + Both 1x and 2x are added so the icon stays sharp on a HiDPI screen without + Qt upscaling a small bitmap. + """ + result = QIcon() + for dpr in (1.0, 2.0): + result.addPixmap(pixmap(name, color, size, dpr), QIcon.Mode.Normal, QIcon.State.Off) + result.addPixmap(pixmap(name, disabled_color, size, dpr), QIcon.Mode.Disabled, QIcon.State.Off) + return result diff --git a/ytsage/gui/ytsage_stylesheet.py b/ytsage/gui/ytsage_stylesheet.py index c1f7841..c574e41 100644 --- a/ytsage/gui/ytsage_stylesheet.py +++ b/ytsage/gui/ytsage_stylesheet.py @@ -224,20 +224,26 @@ class StyleSheet: padding: 5px; margin-left: 20px; } + /* Square, not round: a fully-rounded indicator reads as a radio + button, i.e. "one of these", when these are independent + on/off options. */ QCheckBox::indicator { - width: 18px; - height: 18px; - border-radius: 9px; + width: 16px; + height: 16px; + border-radius: 4px; } QCheckBox::indicator:unchecked { border: 2px solid #666666; background: #1d1e22; - border-radius: 9px; + border-radius: 4px; + } + QCheckBox::indicator:unchecked:hover { + border-color: #9aa0a6; } QCheckBox::indicator:checked { border: 2px solid #c90000; background: #c90000; - border-radius: 9px; + border-radius: 4px; } QCheckBox:disabled { color: #888888; } QCheckBox::indicator:disabled { border-color: #555555; background: #444444; } diff --git a/ytsage/gui/ytsage_theme.py b/ytsage/gui/ytsage_theme.py new file mode 100644 index 0000000..26bbdbf --- /dev/null +++ b/ytsage/gui/ytsage_theme.py @@ -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}; }} +""" diff --git a/ytsage/gui/ytsage_ui_polish.py b/ytsage/gui/ytsage_ui_polish.py new file mode 100644 index 0000000..88a7a8d --- /dev/null +++ b/ytsage/gui/ytsage_ui_polish.py @@ -0,0 +1,159 @@ +""" +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.") diff --git a/ytsage/languages/en.json b/ytsage/languages/en.json index 628e987..386c829 100644 --- a/ytsage/languages/en.json +++ b/ytsage/languages/en.json @@ -651,14 +651,25 @@ "queue": "Queue", "play": "Play", "pause": "Pause", - "stream_stalled": "Stream failed to start after several attempts - YouTube may be throttling. Try again or pick a lower quality." + "stream_stalled": "Stream failed to start after several attempts - YouTube may be throttling. Try again or pick a lower quality.", + "play_pause": "Play / pause (Space)", + "fullscreen": "Fullscreen (F)", + "seek_tooltip": "Seek through the video", + "quality_tooltip": "Maximum playback resolution", + "speed_tooltip": "Playback speed", + "volume_tooltip": "Volume", + "subtitles_tooltip": "Toggle subtitles (C)" }, "cards": { "play": "▶ Play", "queue": "+ Queue", "download": "⬇", "load_more": "Load more", - "empty": "Nothing here yet" + "empty": "Nothing here yet", + "play_tooltip": "Play this video now", + "queue_tooltip": "Add to the play queue", + "download_tooltip": "Open in Downloads with this video ready to analyse", + "load_more_tooltip": "Load the next page of results" }, "main_tabs": { "watch": "Watch", @@ -672,7 +683,8 @@ "button": "Search", "searching": "Searching...", "results_count": "{count} results", - "failed": "Search failed: {error}" + "failed": "Search failed: {error}", + "button_tooltip": "Search YouTube" }, "watch": { "clear_queue": "Clear queue" @@ -690,7 +702,12 @@ "loading": "Loading...", "entry_count": "{count} videos", "failed": "Failed to load: {error}", - "hint": "Open a channel (youtube.com/@name) or playlist URL, or navigate here from Search." + "hint": "Open a channel (youtube.com/@name) or playlist URL, or navigate here from Search.", + "open_tooltip": "Open this channel or playlist", + "subscribe_tooltip": "Follow this channel so its uploads appear in your Feed", + "unsubscribe_tooltip": "Stop following this channel", + "play_all_tooltip": "Queue everything loaded here", + "download_playlist_tooltip": "Open this playlist in Downloads" }, "feed": { "mode_local": "Local subscriptions", @@ -705,6 +722,8 @@ "account_failed": "Account feed failed (are cookies valid?): {error}", "subscribed": "Subscribed to {title}", "unsubscribed": "Unsubscribed from {title}", - "open_channel": "Open channel" + "open_channel": "Open channel", + "refresh_tooltip": "Fetch the latest uploads from every subscribed channel", + "mode_tooltip": "Where the feed comes from: your local subscriptions, or your signed-in YouTube account" } -} \ No newline at end of file +} diff --git a/ytsage/main.py b/ytsage/main.py index a4247c8..204b9d6 100644 --- a/ytsage/main.py +++ b/ytsage/main.py @@ -57,6 +57,12 @@ def main(): app.setApplicationName("SageTube") app.setDesktopFileName("sagetube") + # Before the main window: this retro-fits icons, tooltips and + # accessible names onto every button as it is first polished. + from .gui.ytsage_ui_polish import install as install_ui_polish + + install_ui_polish(app) + window = YTSageApp() # Instantiate the main application class window.show() logger.info("Application window shown, entering main loop") diff --git a/ytsage/utils/ytsage_constants.py b/ytsage/utils/ytsage_constants.py index 9984293..12cec8e 100644 --- a/ytsage/utils/ytsage_constants.py +++ b/ytsage/utils/ytsage_constants.py @@ -78,6 +78,8 @@ def get_asset_path(asset_relative_path: str) -> Path: # Assets Constants ICON_PATH: Path = get_asset_path("assets/Icon/icon.png") +# The 48px original is what the taskbar and window manager had to upscale. +ICON_PATH_LARGE: Path = get_asset_path("assets/Icon/icon-256.png") SOUND_PATH: Path = get_asset_path("assets/sound/notification.mp3") OS_NAME: str = platform.system() # Windows ; Darwin ; Linux