Files
pax 314bcf90fe feat(gui): SVG for the remaining text glyph icons
grid star, multi-site tick, loop once, layout grips, folder and stepper buttons
2026-09-30 18:34:35 -05:00

132 lines
5.9 KiB
Python

"""Toolbar icons drawn from inline SVG, tinted to the button's text colour.
The preview and popout toolbars used to be text glyphs (☆ ↓ ⊘ ⊗ ⧉). A glyph is
drawn by whatever font the active Qt style supplies, so its size and weight
changed with the theme: under AeroThemePlasma ⊘ and ⊗ came out large and ↓
tiny, next to a small ☆. An SVG has one shape under every style; only its colour
follows the theme, read from the button's palette each time the palette or
style changes (a custom.qss `color:` lands in the palette when Qt polishes the
widget, so themed buttons tint correctly too).
Drawn for this app on a 24 unit grid, stroke based, no third party icon set.
"""
from __future__ import annotations
from PySide6.QtCore import QByteArray, QEvent, QRectF, QSize, Qt
from PySide6.QtGui import QColor, QIcon, QPainter, QPalette, QPixmap
from PySide6.QtSvg import QSvgRenderer
from PySide6.QtWidgets import QPushButton
_STROKE = ('fill="none" stroke="currentColor" stroke-width="2" '
'stroke-linecap="round" stroke-linejoin="round"')
_STAR = "12,2.8 14.8,8.6 21.2,9.5 16.6,14 17.7,20.4 12,17.4 6.3,20.4 7.4,14 2.8,9.5 9.2,8.6"
# floppy outline, its metal shutter and its label, as one evenodd path so the
# filled variant keeps shutter and label as cut outs
_FLOPPY_BODY = "M4 5a1 1 0 0 1 1-1h11.6l3.4 3.4V19a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1z"
_FLOPPY_SHUTTER = "M8 4v4.5h7V4"
_FLOPPY_LABEL = "M7.5 20v-6h9v6"
SVG: dict[str, str] = {
"star": f'<polygon points="{_STAR}" {_STROKE}/>',
"star-filled": f'<polygon points="{_STAR}" fill="currentColor" stroke="currentColor" '
'stroke-width="2" stroke-linejoin="round"/>',
"floppy": (f'<path d="{_FLOPPY_BODY}" {_STROKE}/>'
f'<path d="{_FLOPPY_SHUTTER}" {_STROKE}/>'
f'<path d="{_FLOPPY_LABEL}" {_STROKE}/>'),
"floppy-filled": (f'<path fill-rule="evenodd" fill="currentColor" d="{_FLOPPY_BODY} '
'M8.5 5v3h6V5z M8.5 15.5v4h7v-4z"/>'
f'<path d="{_FLOPPY_BODY}" {_STROKE}/>'),
# a price tag struck through: blacklist a tag
"tag-off": (f'<path d="M3.5 12 9.5 5.5H20a1 1 0 0 1 1 1v11a1 1 0 0 1-1 1H9.5z" {_STROKE}/>'
f'<circle cx="9" cy="12" r="1.2" fill="currentColor"/>'
f'<path d="M5 20 19.5 4" {_STROKE}/>'),
# an eye struck through: blacklist this post, i.e. hide it from results
"eye-off": (f'<path d="M2.5 12s3.5-6.5 9.5-6.5 9.5 6.5 9.5 6.5-3.5 6.5-9.5 6.5S2.5 12 2.5 12z" {_STROKE}/>'
f'<circle cx="12" cy="12" r="2.8" {_STROKE}/>'
f'<path d="M4.5 20 19.5 4" {_STROKE}/>'),
# small marks drawn into other widgets: selected site, grip, steppers, loop once
"check": f'<path d="M5 12.5l4.5 4.5L19 7.5" {_STROKE}/>',
"grip": f'<path d="M5 8h14M5 12h14M5 16h14" {_STROKE}/>',
"plus": f'<path d="M12 5v14M5 12h14" {_STROKE}/>',
"minus": f'<path d="M5 12h14" {_STROKE}/>',
# play through once, then stop: an arrow running into a bar
"play-once": (f'<path d="M4 12h11M11 7.5l4.5 4.5-4.5 4.5" {_STROKE}/>'
f'<path d="M19.5 6v12" {_STROKE}/>'),
# the search field's history and saved searches dropdown
"chevron-down": f'<path d="M6 9.5l6 6 6-6" {_STROKE}/>',
# a window with an arrow leaving it: open in the popout
"popout": (f'<path d="M11 5H5a1 1 0 0 0-1 1v13a1 1 0 0 0 1 1h13a1 1 0 0 0 1-1v-6" {_STROKE}/>'
f'<path d="M14 4h6v6M20 4l-8.5 8.5" {_STROKE}/>'),
}
ICON_PX = 16
def render(name: str, color: QColor, px: int = ICON_PX, dpr: float = 1.0) -> QPixmap:
"""Rasterise icon ``name`` at ``px`` logical pixels in ``color``."""
body = SVG[name].replace("currentColor", color.name())
doc = f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">{body}</svg>'
renderer = QSvgRenderer(QByteArray(doc.encode()))
pm = QPixmap(round(px * dpr), round(px * dpr))
pm.fill(Qt.GlobalColor.transparent)
pm.setDevicePixelRatio(dpr)
painter = QPainter(pm)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
renderer.render(painter, QRectF(0, 0, px, px))
painter.end()
return pm
class IconButton(QPushButton):
"""Square toolbar button showing one of ``SVG``, recoloured with the theme."""
def __init__(self, icon: str, name: str, tip: str, size: int = 24) -> None:
super().__init__()
self.setObjectName(name)
self.setFixedSize(size, size)
# Matches QPushButton[iconBtn="true"] in the base QSS: zero padding so a
# theme's button padding cannot squeeze the icon.
self.setProperty("iconBtn", True)
self.setToolTip(tip)
self.setIconSize(QSize(ICON_PX, ICON_PX))
self._icon_name = icon
self._refresh()
def set_icon(self, icon: str) -> None:
if icon != self._icon_name:
self._icon_name = icon
self._refresh()
def icon_name(self) -> str:
return self._icon_name
def _refresh(self) -> None:
color = self.palette().color(QPalette.ColorRole.ButtonText)
self.setIcon(QIcon(render(self._icon_name, color, ICON_PX, self.devicePixelRatioF())))
def changeEvent(self, event) -> None:
if event.type() in (QEvent.Type.PaletteChange, QEvent.Type.StyleChange):
self._refresh()
super().changeEvent(event)
class LabelledIconButton(QPushButton):
"""Text button with an SVG icon before the label, tinted like the label."""
def __init__(self, icon: str, text: str) -> None:
super().__init__(text)
self.setIconSize(QSize(ICON_PX - 2, ICON_PX - 2))
self._icon_name = icon
self._refresh()
def _refresh(self) -> None:
color = self.palette().color(QPalette.ColorRole.ButtonText)
self.setIcon(QIcon(render(self._icon_name, color, ICON_PX - 2, self.devicePixelRatioF())))
def changeEvent(self, event) -> None:
if event.type() in (QEvent.Type.PaletteChange, QEvent.Type.StyleChange):
self._refresh()
super().changeEvent(event)