Give the app icons, tooltips and a finished theme
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>
This commit is contained in:
@@ -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": '<polygon points="7 4 20 12 7 20" fill="%COLOR%" stroke-linejoin="round"/>',
|
||||
"pause": '<rect x="6" y="4" width="4" height="16" rx="1" fill="%COLOR%"/>'
|
||||
'<rect x="14" y="4" width="4" height="16" rx="1" fill="%COLOR%"/>',
|
||||
"stop": '<rect x="5" y="5" width="14" height="14" rx="2" fill="%COLOR%"/>',
|
||||
"skip-back": '<polygon points="19 5 9 12 19 19" fill="%COLOR%" stroke-linejoin="round"/>'
|
||||
'<line x1="6" y1="5" x2="6" y2="19"/>',
|
||||
"skip-forward": '<polygon points="5 5 15 12 5 19" fill="%COLOR%" stroke-linejoin="round"/>'
|
||||
'<line x1="18" y1="5" x2="18" y2="19"/>',
|
||||
"rewind-10": '<path d="M11 20a8 8 0 1 0-8-8"/><polyline points="3 8 3 12 7 12"/>'
|
||||
'<text x="12" y="16" font-size="8" fill="%COLOR%" stroke="none"'
|
||||
' text-anchor="middle" font-family="sans-serif">10</text>',
|
||||
"forward-10": '<path d="M13 20a8 8 0 1 1 8-8"/><polyline points="21 8 21 12 17 12"/>'
|
||||
'<text x="12" y="16" font-size="8" fill="%COLOR%" stroke="none"'
|
||||
' text-anchor="middle" font-family="sans-serif">10</text>',
|
||||
# --- audio -----------------------------------------------------------
|
||||
"volume-high": '<polygon points="11 5 6 9 2 9 2 15 6 15 11 19" fill="%COLOR%" stroke-linejoin="round"/>'
|
||||
'<path d="M15.5 8.5a5 5 0 0 1 0 7"/><path d="M19 5a10 10 0 0 1 0 14"/>',
|
||||
"volume-low": '<polygon points="11 5 6 9 2 9 2 15 6 15 11 19" fill="%COLOR%" stroke-linejoin="round"/>'
|
||||
'<path d="M15.5 8.5a5 5 0 0 1 0 7"/>',
|
||||
"volume-mute": '<polygon points="11 5 6 9 2 9 2 15 6 15 11 19" fill="%COLOR%" stroke-linejoin="round"/>'
|
||||
'<line x1="16" y1="9" x2="22" y2="15"/><line x1="22" y1="9" x2="16" y2="15"/>',
|
||||
"captions": '<rect x="2" y="5" width="20" height="14" rx="3"/>'
|
||||
'<path d="M10 10.2a2.6 2.6 0 1 0 0 3.6"/><path d="M17.5 10.2a2.6 2.6 0 1 0 0 3.6"/>',
|
||||
# --- window ----------------------------------------------------------
|
||||
"maximize": '<path d="M8 3H5a2 2 0 0 0-2 2v3"/><path d="M21 8V5a2 2 0 0 0-2-2h-3"/>'
|
||||
'<path d="M3 16v3a2 2 0 0 0 2 2h3"/><path d="M16 21h3a2 2 0 0 0 2-2v-3"/>',
|
||||
"minimize": '<path d="M8 3v3a2 2 0 0 1-2 2H3"/><path d="M21 8h-3a2 2 0 0 1-2-2V3"/>'
|
||||
'<path d="M3 16h3a2 2 0 0 1 2 2v3"/><path d="M16 21v-3a2 2 0 0 1 2-2h3"/>',
|
||||
# --- tabs / navigation -----------------------------------------------
|
||||
"rss": '<path d="M4 11a9 9 0 0 1 9 9"/><path d="M4 4a16 16 0 0 1 16 16"/>'
|
||||
'<circle cx="5" cy="19" r="1.6" fill="%COLOR%" stroke="none"/>',
|
||||
"search": '<circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.6" y2="16.6"/>',
|
||||
"library": '<line x1="4" y1="4" x2="4" y2="20"/><line x1="9" y1="6" x2="9" y2="20"/>'
|
||||
'<path d="M14 6.5l4.5 13"/>',
|
||||
"download": '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>'
|
||||
'<polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>',
|
||||
"user": '<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>',
|
||||
"user-check": '<path d="M15 21v-2a4 4 0 0 0-4-4H7a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/>'
|
||||
'<polyline points="16 11 18 13 22 9"/>',
|
||||
# --- actions ---------------------------------------------------------
|
||||
"queue": '<line x1="3" y1="6" x2="16" y2="6"/><line x1="3" y1="12" x2="11" y2="12"/>'
|
||||
'<line x1="3" y1="18" x2="11" y2="18"/><line x1="18" y1="9" x2="18" y2="15"/>'
|
||||
'<line x1="21" y1="12" x2="15" y2="12"/>',
|
||||
"refresh": '<path d="M20.5 12a8.5 8.5 0 1 1-2.5-6"/><polyline points="21 3 21 9 15 9"/>',
|
||||
"folder-open": '<path d="M4 20a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4.2a2 2 0 0 1 1.6.8l1 1.4a2 2 0 0 0 1.6.8H18a2 2 0 0 1 2 2v1"/>'
|
||||
'<path d="M4 20l2.2-7a2 2 0 0 1 1.9-1.4h12a1.6 1.6 0 0 1 1.55 2l-1.7 5.4A2 2 0 0 1 18 20z"/>',
|
||||
"x": '<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>',
|
||||
"check": '<polyline points="20 6 9 17 4 12"/>',
|
||||
"plus": '<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>',
|
||||
"trash": '<polyline points="3 6 21 6"/>'
|
||||
'<path d="M19 6v13a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/>'
|
||||
'<path d="M9 6V4a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2"/>',
|
||||
"settings": '<line x1="4" y1="7" x2="20" y2="7"/><line x1="4" y1="17" x2="20" y2="17"/>'
|
||||
'<circle cx="10" cy="7" r="2.4" fill="%COLOR%" stroke="none"/>'
|
||||
'<circle cx="15" cy="17" r="2.4" fill="%COLOR%" stroke="none"/>',
|
||||
"info": '<circle cx="12" cy="12" r="9"/><line x1="12" y1="11" x2="12" y2="16"/>'
|
||||
'<circle cx="12" cy="7.8" r="1.1" fill="%COLOR%" stroke="none"/>',
|
||||
"clock": '<circle cx="12" cy="12" r="9"/><polyline points="12 6.8 12 12 15.5 14"/>',
|
||||
"scissors": '<circle cx="6" cy="6" r="2.6"/><circle cx="6" cy="18" r="2.6"/>'
|
||||
'<line x1="20" y1="4" x2="8.1" y2="15.9"/><line x1="14.5" y1="14.5" x2="20" y2="20"/>'
|
||||
'<line x1="8.1" y1="8.1" x2="12" y2="12"/>',
|
||||
"external-link": '<path d="M14 3h7v7"/><line x1="10" y1="14" x2="21" y2="3"/>'
|
||||
'<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>',
|
||||
"chevron-down": '<polyline points="6 9 12 15 18 9"/>',
|
||||
"chevron-right": '<polyline points="9 6 15 12 9 18"/>',
|
||||
"clipboard": '<rect x="8" y="3" width="8" height="4" rx="1"/>'
|
||||
'<path d="M16 5h2a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h2"/>',
|
||||
"play-circle": '<circle cx="12" cy="12" r="9"/><polygon points="10 8.5 16 12 10 15.5" fill="%COLOR%" stroke="none"/>',
|
||||
}
|
||||
|
||||
_DOC = (
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" '
|
||||
'fill="none" stroke="%COLOR%" stroke-width="2" stroke-linecap="round" '
|
||||
'stroke-linejoin="round">%BODY%</svg>'
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user