"""
Central icon lookup for the spacr Qt GUI.
Two sources of icons, both made theme-aware here:
* **qtawesome glyphs** — vector, painted in whatever colour we ask for.
Wrapped so callers stay decoupled from Font Awesome glyph names. When the
optional font is unavailable a bundled, theme-aware glyph is returned;
toolbar affordances never silently turn into blank buttons.
* **bundled PNGs** in ``spacr/resources/icons`` — flat monochrome
artwork baked at a fixed colour.
The bundled PNGs were *theme-blind*, and it showed: ``convert.png``
(Format Converter) shipped as solid black with an alpha mask, so on the
black home page it rendered as nothing at all. The other twenty-odd were
solid white, which is the same bug pointed the other way — invisible on
the light theme, nobody had noticed because nobody used it. A third
theme made this unavoidable, so :func:`themed_qimage` now re-inks every
bundled PNG for the active theme. **This is what makes the artwork
swappable**: seventeen icons were redrawn and reinstalled without a line
of change here, because nothing in this module knows what any of them
look like.
* the artwork's ink polarity is detected from its own alpha-weighted
mean luminance, so black-on-transparent and white-on-transparent are
both handled without a per-file table;
* the ink is remapped onto a band running from the theme's foreground
down to a ``veil`` colour computed to clear :data:`MIN_ICON_CONTRAST`
against the *hardest* surface the icon can land on, so internal shading
survives instead of being flattened to a silhouette;
* genuinely polychrome artwork keeps its hue and is only re-levelled.
:func:`icon_contrast` exposes the measured ratio so the test suite can
assert visibility numerically rather than checking that a file exists.
"""
from __future__ import annotations
import hashlib
import os
import threading
from functools import lru_cache
from pathlib import Path
from typing import Optional, Tuple
from PySide6.QtGui import QIcon
from ..logging_util import _spacr_home
from .theme import (
contrast_ratio,
effective_surface,
palette_for,
relative_luminance,
)
#: WCAG 1.4.11 (non-text contrast) minimum for a UI graphic.
MIN_ICON_CONTRAST = 3.0
#: Surfaces a bundled icon can end up sitting on. The veil is solved
#: against whichever of these is hardest for the theme's ink.
ICON_SURFACES = ("bg", "surface", "surface_alt", "surface_hi")
#: Max per-pixel chroma (max channel − min channel, 0-255) for artwork to
#: count as monochrome and be re-inked outright. Every bundled spaCR
#: icon measures ≤ 13; the flow-chart diagram measures 150 and is
#: correctly treated as polychrome.
CHROMA_MONO_MAX = 32.0
#: Fraction of the luminance range an icon's RGB must span before it is
#: treated as carrying shading rather than being a flat mask. Below
#: this, RGB is noise from the exporter — several bundled icons vary by
#: ~0.03 across their "solid white" fill, and stretching that across
#: the ink band paints visible banding into a flat glyph.
MIN_TONAL_RANGE = 0.12
#: Longest edge an icon is processed at. The largest bundled-icon consumer
#: is the legacy 64 px tile; the supported 200 % accessibility scale and a
#: 2x display make that 256 physical pixels. Processing the 1024 px masters
#: any larger cannot add a pixel Qt can show on that supported path. The old
#: 512 px ceiling accounted for 2.9 s (37 %) of a measured cold Home launch;
#: this display-derived ceiling cuts the same pass to about 1.0 s without
#: asking Qt to upscale at the maximum supported tile size.
MAX_WORK_SIZE = 256
#: Where the bundled PNGs live.
RESOURCE_DIR = os.path.normpath(
os.path.join(os.path.dirname(os.path.abspath(__file__)),
"..", "resources", "icons"))
@lru_cache(maxsize=128)
def _try_qta():
"""Return the ``qtawesome`` module, or ``None`` when it isn't installed."""
try:
import qtawesome as qta
return qta
except Exception:
return None
def _glyph_provider(qta, glyph):
"""Load only a requested QtAwesome font when its bundled metadata is known.
:param qta: the optional QtAwesome module.
:param glyph: a prefixed glyph name such as ``fa5s.cog``.
:returns: a per-application IconicFont, or the unchanged QtAwesome API
when its metadata/API differs, system fonts are requested or loading
fails. The selected font keeps QtAwesome's checksum verification and
rendering engine. Invalidated font IDs cause a fresh provider load.
"""
from PySide6.QtGui import QFontDatabase
from PySide6.QtWidgets import QApplication
app = QApplication.instance()
if app is None or getattr(qta, "_SYSTEM_FONTS", False):
return qta
prefix = glyph.partition(".")[0]
providers = getattr(app, "_spacr_glyph_providers", {})
key = (id(qta), prefix)
cached = providers.get(key)
if cached is not None:
provider = cached[1]
if all(QFontDatabase.applicationFontFamilies(font_id)
for font_id in provider.fontids.values()):
return provider
shared = getattr(qta, "_resource", None)
if isinstance(shared, dict) and shared.get("iconic") is not None:
return qta
try:
entry = next(row for row in qta._BUNDLED_FONTS if row[0] == prefix)
if len(entry) != 3:
return qta
directory = Path(qta.__file__).resolve().parent / "fonts"
expected = getattr(qta, "_MD5_HASHES", {}).get(entry[1])
if expected and hashlib.md5((directory / entry[1]).read_bytes()).hexdigest() != expected:
return qta
provider = qta.IconicFont(entry)
if not provider.fontids or not all(QFontDatabase.applicationFontFamilies(font_id)
for font_id in provider.fontids.values()):
return qta
provider.setParent(app)
except Exception:
return qta
providers[key] = (qta, provider)
app._spacr_glyph_providers = providers
return provider
[docs]
def active_theme() -> str:
"""The theme icons should be drawn for, resolved from preferences.
Falls back to ``"dark"`` if preferences can't be read at all —
icon lookup must never be the thing that stops the GUI booting.
"""
try:
from .preferences import resolve_effective_theme
return resolve_effective_theme()
except Exception:
return "dark"
def _theme_palette(theme: Optional[str]) -> dict:
"""Return a theme's palette.
:param theme: the theme name; ``None`` uses the active one.
:returns: the palette.
"""
return palette_for(theme or active_theme())
[docs]
def icon(name: str, color: Optional[str] = None, size: int = 16,
theme: Optional[str] = None) -> QIcon:
"""Return a QIcon for the named glyph, with a bundled fallback.
`name` is a semantic key (e.g. "open", "run", "brush") mapped to a
Font Awesome glyph. Unknown names fall back to a puzzle piece. The
default fill follows the active theme rather than the dark palette,
which is why a light-theme sidebar no longer draws pale-grey icons
on white.
:param name: semantic icon key looked up in the module's glyph table,
e.g. ``"open"`` or ``"run"``.
"""
qta = _try_qta()
if qta is None:
return _fallback_icon(name, theme, color=color, size=size)
glyph = _NAME_TO_GLYPH.get(name, "fa5s.puzzle-piece")
fill = color or _theme_palette(theme)["fg_muted"]
try:
resolved = _glyph_provider(qta, glyph).icon(glyph, color=fill)
if resolved is not None and not resolved.isNull():
return resolved
except Exception:
pass
return _fallback_icon(name, theme, color=color, size=size)
[docs]
def accent_icon(name: str, theme: Optional[str] = None) -> QIcon:
"""Icon painted in the accent color (used for primary buttons).
:param name: semantic icon key, as for :func:`icon`.
"""
return icon(name, color=_theme_palette(theme)["accent"], theme=theme)
[docs]
def contrast_icon(name: str, theme: Optional[str] = None) -> QIcon:
"""Icon painted for use inside a filled (PrimaryButton) button,
where the button background IS the accent fill.
:param name: semantic icon key, as for :func:`icon`.
"""
return icon(name, color=_theme_palette(theme)["button_accent_ink"],
theme=theme)
def _blend(a: str, b: str, t: float) -> str:
"""Linear blend from colour ``a`` (t=0) to colour ``b`` (t=1)."""
def ch(c):
"""One hex colour as its three integer channels."""
c = c.lstrip("#")
return [int(c[i:i + 2], 16) for i in (0, 2, 4)]
ca, cb = ch(a), ch(b)
return "#%02x%02x%02x" % tuple(
int(round(x + (y - x) * t)) for x, y in zip(ca, cb))
[docs]
def hardest_surface(theme: str) -> str:
"""The surface colour an icon has the least contrast against.
:param theme: theme name, one of :data:`spacr.qt.theme.THEMES`; the
theme's ``fg`` ink is compared against each role in
:data:`ICON_SURFACES`.
"""
palette = _theme_palette(theme)
ink = palette["fg"]
return min((effective_surface(theme, role) for role in ICON_SURFACES),
key=lambda s: contrast_ratio(ink, s))
@lru_cache(maxsize=16)
[docs]
def veil_color(theme: str) -> str:
"""Dimmest ink allowed in a themed icon.
Solved, not guessed: bisect the blend from the hardest surface
toward the theme foreground until the contrast crosses
:data:`MIN_ICON_CONTRAST`. That way the shadow end of an icon's
tonal range is still a visible shape, and the answer tracks the
palette instead of being a magic grey someone eyeballed once.
:param theme: theme name, one of :data:`spacr.qt.theme.THEMES`; the
result is cached per theme.
"""
palette = _theme_palette(theme)
ink = palette["fg"]
surface = hardest_surface(theme)
if contrast_ratio(surface, ink) < MIN_ICON_CONTRAST:
return ink
lo, hi = 0.0, 1.0
for _ in range(24):
mid = (lo + hi) / 2.0
if contrast_ratio(_blend(surface, ink, mid), surface) < MIN_ICON_CONTRAST:
lo = mid
else:
hi = mid
return _blend(surface, ink, hi)
def _load_rgba(path: str):
"""Decode PNG or SVG artwork to a bounded RGBA array, or None.
Downscaled to :data:`MAX_WORK_SIZE` first. Some bundled assets are
enormous for icon artwork — ``logo_spacr.png`` is 3334x3334, which
is 356 MB as a float64 RGBA array and about half a second to
re-ink, for something drawn into a 52 px slot. 256 px covers the largest
64 px tile at the supported 200 % UI scale on a 2x display.
"""
try:
import numpy as np
from PIL import Image
if str(path).lower().endswith(".svg"):
from PySide6.QtCore import Qt
from PySide6.QtGui import QImage, QPainter
from PySide6.QtSvg import QSvgRenderer
renderer = QSvgRenderer(str(path))
if not renderer.isValid():
return None
size = renderer.defaultSize().scaled(
MAX_WORK_SIZE, MAX_WORK_SIZE, Qt.KeepAspectRatio)
image = QImage(size, QImage.Format_RGBA8888)
image.fill(0)
painter = QPainter(image)
try:
renderer.render(painter)
finally:
painter.end()
return np.frombuffer(image.constBits(), dtype=np.uint8).reshape(
image.height(), image.bytesPerLine())[:, :image.width() * 4].reshape(
image.height(), image.width(), 4).astype(np.float64)
with Image.open(path) as im:
im = im.convert("RGBA")
if max(im.size) > MAX_WORK_SIZE:
im.thumbnail((MAX_WORK_SIZE, MAX_WORK_SIZE),
Image.LANCZOS, reducing_gap=2.0)
return np.asarray(im, dtype=np.float64)
except Exception:
return None
def _file_stamp(path: str):
"""``(path, mtime, size)`` — the cache key for a decoded icon.
Some bundled assets are large for icon artwork (``activation.png``
is 1024x1024, 1.5 MB on disk), and re-decoding one every time a
sidebar rebuilds is pure waste. Keying on mtime+size means an
artwork swap invalidates the entry on its own.
"""
try:
stat = os.stat(path)
return (path, stat.st_mtime_ns, stat.st_size)
except OSError:
return (path, 0, 0)
@lru_cache(maxsize=192)
def _source_digest(stamp) -> str:
"""Stable digest of an icon's compressed source bytes.
``stamp`` still includes mtime so an edit invalidates this small in-process
memo immediately. The persistent cache key uses the resulting content
digest, however: reinstalling or checking out byte-identical artwork can
change every mtime without forcing spaCR to decode, resize and re-ink every
icon again.
Reading the compressed PNG once is deliberately cheaper than decoding it.
If the source cannot be read, return a deterministic fallback; the normal
loader will then fail softly as it did before.
"""
digest = hashlib.sha256()
try:
with open(stamp[0], "rb") as source:
for block in iter(lambda: source.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest()
except OSError:
fallback = f"missing|{stamp[0]}|{stamp[1]}|{stamp[2]}"
return hashlib.sha256(
fallback.encode("utf-8", "replace")).hexdigest()
def _hex_to_array(color: str):
"""Convert a hex colour to an RGB array.
:param color: the colour.
:returns: its channels, for compositing an icon.
"""
import numpy as np
text = color.lstrip("#")
return np.array([int(text[i:i + 2], 16) for i in (0, 2, 4)],
dtype=np.float64)
[docs]
def carries_tonal_structure(rgba) -> bool:
"""True when an icon's RGB channels carry shading worth preserving.
Measured, not assumed. Every bundled spaCR icon is **a monochrome
mask**: the alpha channel holds the entire shape and the RGB is a
uniform fill (white for most of them, black for ``convert.png``).
For those, the RGB carries no information at all and the right
answer is to paint the alpha mask in the theme's ink.
A couple of assets (``umap.png``, ``activation.png``) genuinely do
put shading in RGB, and flattening those to a silhouette would
destroy the picture. The discriminator is whether the visible-pixel
luminance spans a meaningful fraction of the range — 2 % of
variation is noise from whatever exported the file, not shading.
:param rgba: RGBA pixel array of shape (H, W, 4) with 0-255 values;
only pixels whose alpha exceeds 2 % are measured, and a fully
transparent array gives ``False``.
"""
import numpy as np
alpha = rgba[:, :, 3] / 255.0
visible = alpha > 0.02
if not visible.any():
return False
rgb = rgba[:, :, :3]
lum = (0.2126 * rgb[:, :, 0] + 0.7152 * rgb[:, :, 1]
+ 0.0722 * rgb[:, :, 2])[visible] / 255.0
lo, hi = np.percentile(lum, (2.0, 98.0))
return float(hi - lo) >= MIN_TONAL_RANGE
[docs]
def reink(rgba, theme: str):
"""Re-ink an RGBA array for ``theme``. Returns a uint8 RGBA array.
The **alpha channel is the shape**, always. RGB is consulted only
when :func:`carries_tonal_structure` says it holds real shading, so
swapping in different artwork later cannot break this — a redrawn
monochrome mask keeps working with no code change.
:param rgba: (h, w, 4) float array, channels 0-255.
:param theme: theme name.
"""
import numpy as np
palette = _theme_palette(theme)
ink = palette["fg"]
veil = veil_color(theme)
alpha = rgba[:, :, 3] / 255.0
rgb = rgba[:, :, :3]
visible = alpha > 0.02
out = rgba.copy()
if not visible.any():
return out.astype(np.uint8)
ink_rgb, veil_rgb = _hex_to_array(ink), _hex_to_array(veil)
lum = (0.2126 * rgb[:, :, 0] + 0.7152 * rgb[:, :, 1]
+ 0.0722 * rgb[:, :, 2])
if not carries_tonal_structure(rgba):
new = np.broadcast_to(ink_rgb, rgb.shape).copy()
else:
weights = alpha[visible]
mean_lum = float((lum[visible] * weights).sum()
/ max(weights.sum(), 1e-9))
ink_is_bright = mean_lum > 127.5
t = lum / 255.0 if ink_is_bright else 1.0 - lum / 255.0
lo = float(np.percentile(t[visible], 2))
hi = float(np.percentile(t[visible], 98))
t = np.clip((t - lo) / max(hi - lo, 1e-6), 0.0, 1.0)
chroma = rgb.max(axis=2) - rgb.min(axis=2)
polychrome = float(np.percentile(chroma[visible], 98)) > CHROMA_MONO_MAX
if polychrome:
ink_l = relative_luminance(ink)
veil_l = relative_luminance(veil)
target = (veil_l + (ink_l - veil_l) * t) * 255.0
scale = target / np.maximum(lum, 1.0)
new = np.clip(rgb * scale[:, :, None], 0.0, 255.0)
flat = lum < 1.0
new[flat] = np.clip(target[flat], 0.0, 255.0)[:, None]
else:
new = veil_rgb[None, None, :] + \
(ink_rgb - veil_rgb)[None, None, :] * t[:, :, None]
out[:, :, :3] = np.where(visible[:, :, None], new, rgb)
return np.clip(out, 0, 255).astype(np.uint8)
#: Where re-inked icons are kept between launches. Honours
#: ``$SPACR_ICON_CACHE`` so tests and read-only homes can redirect it,
#: matching what `spacr.qt.space.cache_dir` does for backgrounds.
ENV_ICON_CACHE = "SPACR_ICON_CACHE"
#: Bumped when the re-inking maths changes. A cached icon from an older
#: formula is WRONG rather than merely stale, and a version in the name is
#: cheaper than trying to detect that.
ICON_CACHE_VERSION = 2
[docs]
def icon_cache_dir() -> Path:
"""Directory holding re-inked icons, one PNG per (file, theme)."""
override = os.environ.get(ENV_ICON_CACHE)
if override:
return Path(override)
return _spacr_home() / "icons"
def _cache_path(stamp, theme: str) -> Path:
"""Cache filename for one source-content digest at one theme.
The in-process stamp notices edits immediately. The disk key is based on
bytes, not mtime or install path, so unchanged artwork survives editable
checkouts, reinstalls and archive extraction while changed artwork still
gets a different entry automatically.
"""
key = f"{_source_digest(stamp)}|{theme}|v{ICON_CACHE_VERSION}"
digest = hashlib.sha1(key.encode("utf-8", "replace")).hexdigest()[:20]
return icon_cache_dir() / f"{Path(stamp[0]).stem}-{digest}.png"
def _read_cached_icon(path: Path):
"""The cached re-inked RGBA at ``path``, or None.
Any failure returns None and the caller re-renders. A cache is an
optimisation, and one that can break icon loading is a liability --
INVARIANTS 10.
"""
try:
if not path.is_file():
return None
import numpy as np
from PIL import Image
with Image.open(path) as im:
return np.asarray(im.convert("RGBA"), dtype=np.uint8)
except Exception:
return None
def _write_cached_icon(path: Path, array) -> None:
"""Store a re-inked icon, atomically. Failure is silent by design."""
try:
from PIL import Image
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(f".{os.getpid()}.{threading.get_ident()}.part")
Image.fromarray(array, "RGBA").save(tmp, format="PNG", optimize=False)
os.replace(tmp, path)
except Exception:
pass
@lru_cache(maxsize=192)
def _themed_array(stamp, theme: str):
"""Re-inked RGBA for one (file, theme).
Cached twice over. The `lru_cache` covers repeats within one run -- the
home grid asks for 160 icons that are only 50 distinct files -- and the
PNG on disk covers repeats ACROSS runs, which the lru_cache cannot.
That second cache is worth having: re-inking 50 icons cold was measured
at 2.8 s of a 4.8 s startup, because the source art is large
(`logo_spacr.png` is 3334x3334) and every launch was paying full decode
plus LANCZOS downscale plus re-ink. Reading the finished PNGs back is
19x faster and the files are 42x smaller than the arrays -- 0.5 MB for
20 icons -- and the round trip is lossless, which is asserted by
`tests/qt/test_icon_cache.py`.
"""
path = _cache_path(stamp, theme)
cached = _read_cached_icon(path)
if cached is not None:
return cached
rgba = _load_rgba(stamp[0])
if rgba is None:
return None
inked = reink(rgba, theme)
_write_cached_icon(path, inked)
return inked
[docs]
def themed_array(path: str, theme: Optional[str] = None):
"""Re-inked ``(h, w, 4)`` uint8 array for ``path``, or ``None``.
:param path: path to the icon artwork (PNG or SVG); its modification
time and size key both the in-memory and the on-disk cache.
"""
return _themed_array(_file_stamp(path), theme or active_theme())
def _warm_the_bundled_icons(theme: str) -> int:
"""Re-ink every bundled PNG for ``theme`` into both caches, off the GUI.
FOR A WORKER THREAD, started once the window is up. A module's fold
strip, an organism page and the command palette each ask for app icons
Home never drew, and on a first run -- or after a theme change -- each
one was decoded, downscaled and re-inked on the GUI thread inside the
module's opening freeze: 15-25 % of the worst event-loop gap on Mask,
Classify, Regression and Toxoplasma, measured. Nothing here touches a
widget: it fills :func:`_themed_array`'s ``lru_cache`` and the on-disk
icon cache, which the GUI then reads.
SVG artwork is left for the GUI thread, since it is rendered through
Qt's painter rather than PIL.
:param theme: the theme to ink for, resolved by the caller on the GUI
thread (:func:`active_theme` reads preferences).
:returns: how many icons were warmed.
"""
warmed = 0
for path in bundled_icon_paths():
try:
if _themed_array(_file_stamp(path), theme) is not None:
warmed += 1
except Exception: # noqa: BLE001
continue
return warmed
[docs]
def themed_qimage(path: str, theme: Optional[str] = None):
"""Return the bundled PNG at ``path``, re-inked for ``theme``.
``None`` when the file can't be read. Returns a ``QImage``, which
(unlike ``QPixmap``) needs no running QGuiApplication, so this is
safe to call from a headless test.
:param path: path to the icon artwork (PNG or SVG); its modification
time and size key both the in-memory and the on-disk cache.
"""
import numpy as np
from PySide6.QtGui import QImage
arr = themed_array(path, theme)
if arr is None:
return None
arr = np.ascontiguousarray(arr)
h, w = arr.shape[:2]
img = QImage(arr.data, w, h, 4 * w, QImage.Format_RGBA8888)
return img.copy()
[docs]
def themed_pixmap(path: str, theme: Optional[str] = None):
""":func:`themed_qimage` as a ``QPixmap``, or ``None``.
:param path: path to the icon artwork (PNG or SVG); its modification
time and size key both the in-memory and the on-disk cache.
"""
from PySide6.QtGui import QPixmap
img = themed_qimage(path, theme)
if img is None:
return None
pix = QPixmap.fromImage(img)
return None if pix.isNull() else pix
[docs]
def icon_ink_color(path: str, theme: Optional[str] = None) -> Optional[str]:
"""Alpha-weighted mean colour of the re-inked artwork, as hex.
This is the colour the eye integrates when the icon is small, which
is what makes it the right thing to measure contrast on.
:param path: path to the icon artwork (PNG or SVG); its modification
time and size key both the in-memory and the on-disk cache.
"""
import numpy as np
themed = themed_array(path, theme)
if themed is None:
return None
arr = themed.astype(np.float64)
alpha = arr[:, :, 3] / 255.0
total = float(alpha.sum())
if total <= 0.0:
return None
mean = [(arr[:, :, c] * alpha).sum() / total for c in range(3)]
return "#%02x%02x%02x" % tuple(int(round(v)) for v in mean)
[docs]
def icon_contrast(path: str, theme: Optional[str] = None) -> float:
"""Worst-case contrast of a themed icon against the theme's surfaces.
``0.0`` when the file can't be read or is fully transparent.
:param path: path to the icon artwork (PNG or SVG); its modification
time and size key both the in-memory and the on-disk cache.
"""
theme = theme or active_theme()
ink = icon_ink_color(path, theme)
if ink is None:
return 0.0
return min(contrast_ratio(ink, effective_surface(theme, role))
for role in ICON_SURFACES)
[docs]
def bundled_icon_paths() -> Tuple[str, ...]:
"""Every bundled PNG, sorted. Used by the theme-visibility test."""
try:
names = sorted(n for n in os.listdir(RESOURCE_DIR)
if n.lower().endswith(".png"))
except OSError:
return ()
return tuple(os.path.join(RESOURCE_DIR, n) for n in names)
#: Keys that deliberately draw ANOTHER key's bundled artwork, as
#: ``key -> filename``.
#:
#: NOT a synonym for :data:`spacr.qt.app._ICON_OVERRIDES`. That table is
#: read by ``app._icon_for_app``, which is the Home tile and the sidebar;
#: a fold button and a folded module's settings heading both call
#: :func:`app_icon` bare, so a sharing recorded only in ``app`` gives one
#: key two different pictures depending on where it is drawn. Sharing that
#: is about the SUBJECT rather than about one screen belongs here, where
#: every caller sees it.
#:
#: ``investigate_hit`` is the case this exists for. ``hit_list.png`` was
#: drawn for the Hit List tile, and the Hit List has since folded onto
#: Regression -- so the mark now names *a hit* rather than a tile that no
#: longer exists, and the module that takes one hit apart is what a user
#: reaches for it expecting. The Hit List keeps it as well: ``hit_list``
#: still resolves to the same file under its own name, so its fold button
#: on Regression is unchanged. Two keys, one asset, ON PURPOSE -- they are
#: the same subject, and the alias is what says so instead of leaving it
#: to look like a filename collision.
#:
#: Consulted AFTER ``<key>.png``, so installing artwork named for the key
#: retires its alias with no code change here.
#: ``classify_merged`` is the same shape. ``classify.png`` was drawn for
#: Classify (CV), and the two Classify tiles have since become one merged
#: screen -- so the mark names *classification* rather than one of the two
#: routes into it, and the merged screen is the only thing left that a user
#: reaches for it expecting. Neither ``classify`` nor ``classify_ml`` is a
#: registered key any more, so nothing else is claiming the file.
#:
#: ``explain_cv`` is the third of the same shape, and it is the same again. Of the 208 buttons that carry an icon at all -- counted
#: by walking every ``QAbstractButton`` in a booted window with all 36
#: module screens opened -- seven were still falling through to the puzzle
#: piece, and two of the seven were Explain CV Model: its dock row and its
#: fold button on Classify's masthead. ``ml_analyze.png`` is a
#: six-node decision tree drawn for the ML-classification route that folded
#: into ``classify_merged``; no GUI key resolves to it any more (measured:
#: zero of the 61 registered + folded keys), so it is retired artwork rather
#: than a borrowing from a live tile. It is also literally the right
#: picture: :func:`spacr.surrogate.fit_surrogate` fits a random forest, a
#: histogram gradient booster or XGBoost -- all tree ensembles -- to
#: reproduce the CV model's decisions from measured features, so a tree of
#: nodes is what this screen builds, not a metaphor for it.
SHARED_ICON_ASSETS = {
"toxoplasma": "replication.png",
"plasmodium": "organism_plasmodium.svg",
"candida": "organism_candida.svg",
"trypanosoma": "organism_gliding.svg",
"leishmania": "organism_leishmania.svg",
"giardia": "organism_giardia.svg",
"virus": "organism_virus.svg",
"mammalian": "organism_mammalian.svg",
"investigate_hit": "hit_list.png",
"classify_merged": "classify.png",
"explain_cv": "ml_analyze.png",
}
[docs]
def bundled_icon_path(key: str, override: Optional[str] = None
) -> Optional[str]:
"""Resolve an app key to its bundled PNG, or ``None``.
Tried in order: the caller's ``override``, ``<key>.png``, the same
with underscores as spaces, and finally whatever
:data:`SHARED_ICON_ASSETS` says this key borrows.
:param key: application registry key whose bundled artwork is requested;
it is also used verbatim to form the first conventional filename.
:param override: explicit filename to try first. The per-screen key →
filename table lives in :mod:`spacr.qt.app` next to the app
registry it describes; this module only knows how to *render*
what it's pointed at, plus the handful of keys in
:data:`SHARED_ICON_ASSETS` that share one picture everywhere.
"""
candidates = [override] if override else []
candidates += [f"{key}.png", f"{key.replace('_', ' ')}.png"]
alias = SHARED_ICON_ASSETS.get(key)
if alias:
candidates.append(alias)
for candidate in candidates:
path = os.path.join(RESOURCE_DIR, candidate)
if os.path.isfile(path):
return path
return None
def _fallback_icon(name: str, theme: Optional[str] = None, *,
color: Optional[str] = None, size: int = 16) -> QIcon:
"""Draw a small visible glyph without fonts, files, or mutable caches.
This is intentionally self-contained. A transient failed font import must
not route the fallback through the image cache that diagnostic tests may
also be exercising; that was how toolbar icons still became blank only in
the full suite. The rounded diamond is generic but honest and keeps every
icon-only button usable.
"""
from PySide6.QtCore import Qt
from PySide6.QtGui import QColor, QPainter, QPen, QPixmap
side = max(12, int(size or 16))
pixmap = QPixmap(side, side)
pixmap.fill(Qt.transparent)
painter = QPainter(pixmap)
try:
painter.setRenderHint(QPainter.Antialiasing, True)
ink = QColor(color or _theme_palette(theme)["fg_muted"])
painter.setPen(QPen(ink, max(1.5, side / 8.0),
Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin))
inset = max(2, side // 5)
painter.drawRoundedRect(inset, inset, side - 2 * inset,
side - 2 * inset, 2, 2)
painter.drawPoint(side // 2, side // 2)
finally:
painter.end()
return QIcon(pixmap)
[docs]
def app_icon(key: str, override: Optional[str] = None,
theme: Optional[str] = None) -> QIcon:
"""Icon for an app key: the bundled PNG re-inked for the theme,
falling back to the themed qtawesome glyph.
:param key: application registry key; resolved to bundled artwork by
:func:`bundled_icon_path`, and used as the :func:`icon` name when no
artwork is found or it cannot be read.
"""
path = bundled_icon_path(key, override)
if path is not None:
pix = themed_pixmap(path, theme)
if pix is not None:
return QIcon(pix)
return icon(key, theme=theme)
_NAME_TO_GLYPH = {
"open": "fa5s.folder-open",
"folder": "fa5s.folder",
"file": "fa5s.file",
"save": "fa5s.save",
"import": "fa5s.file-import",
"export": "fa5s.file-export",
"report": "fa5s.file-alt",
"invasion": "fa5s.sign-in-alt",
"prev": "fa5s.chevron-left",
"next": "fa5s.chevron-right",
"up": "fa5s.chevron-up",
"down": "fa5s.chevron-down",
"home": "fa5s.home",
"skip": "fa5s.forward",
"brush": "fa5s.paint-brush",
"erase": "fa5s.eraser",
"erase_object": "fa5s.trash-alt",
"trash": "fa5s.trash",
"wand": "fa5s.magic",
"wand_add": "fa5s.plus-circle",
"wand_erase": "fa5s.minus-circle",
"draw": "fa5s.draw-polygon",
"divide": "fa5s.cut",
"recrop": "fa5s.crop-alt",
"zoom": "fa5s.search-plus",
"zoom_reset": "fa5s.compress-arrows-alt",
"undo": "fa5s.undo",
"redo": "fa5s.redo",
"fill": "fa5s.fill",
"invert": "fa5s.adjust",
"relabel": "fa5s.tags",
"remove": "fa5s.filter",
"clear": "fa5s.times-circle",
"run": "fa5s.play",
"stop": "fa5s.stop",
"settings": "fa5s.cog",
"info": "fa5s.info-circle",
"check": "fa5s.check",
"warning": "fa5s.exclamation-triangle",
"chart": "fa5s.chart-bar",
"tag": "fa5s.tag",
"search": "fa5s.search",
"mask": "fa5s.mask",
"measure": "fa5s.ruler",
"annotate": "fa5s.tag",
"make_masks": "fa5s.paint-brush",
"classify_merged": "fa5s.sitemap",
"classify": "fa5s.layer-group",
"umap": "fa5s.project-diagram",
"embeddings": "fa5s.vector-square",
"ml_analyze": "fa5s.chart-line",
"regression": "fa5s.wave-square",
"recruitment": "fa5s.crosshairs",
"host_pathogen": "fa5s.object-group",
"activation": "fa5s.bolt",
"run_history": "fa5s.history",
"distributed_jobs": "fa5s.cloud-upload-alt",
"classifier_evaluation": "fa5s.clipboard-check",
"analyze_plaques": "fa5s.microscope",
"train_cellpose": "fa5s.brain",
"cellpose_masks": "fa5s.shapes",
"align": "fa5s.border-all",
"import_images": "fa5s.images",
"map_barcodes": "fa5s.barcode",
"ops": "fa5s.braille",
"ai_console": "fa5s.robot",
"data_manager": "fa5s.hdd",
}