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 = (
+ ''
+)
+
+
+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