Skip to content

Instantly share code, notes, and snippets.

@Pymmdrza
Created September 27, 2025 19:28
Show Gist options
  • Select an option

  • Save Pymmdrza/67faa014be8a8c1999ec54a0a8a89767 to your computer and use it in GitHub Desktop.

Select an option

Save Pymmdrza/67faa014be8a8c1999ec54a0a8a89767 to your computer and use it in GitHub Desktop.
Python PyQt5 Icon Provider From Local Path System
import os
import sys
import io
import base64
import hashlib
import logging
import threading
from pathlib import Path
from typing import Optional, Dict, List, Tuple, Iterable, Union
from PyQt5 import QtGui, QtCore
# ---------------------------------------------------------------------------
# IconProvider - Production-grade icon management
# ---------------------------------------------------------------------------
class IconProvider:
"""
A robust, theme-aware, HiDPI-friendly, thread-safe icon provider.
Features:
- Multi-source resolution (runtime dir, environment, package, PyInstaller)
- Theme fallback chain: requested_theme -> default -> root
- HiDPI lookup: [email protected] if devicePixelRatio > 1
- Supports SVG (preferred for scaling) and standard raster formats
- Transparent caching of QIcon and QPixmap
- Graceful placeholder generation for missing icons (no crashes)
- Optional base64 embedded fallback icons
- Preloading support for performance-sensitive UI surfaces
- Thread-safe, reconfigurable at runtime
Public API:
configure(...)
get_icon(name, theme=None, size=None)
get_pixmap(name, size=(w,h), theme=None, device_pixel_ratio=None)
preload(patterns=[...], recursive=True)
available_icons(theme=None)
clear_cache()
set_theme(theme)
add_search_path(path)
set_logger(logger)
Name Resolution Rules:
- You can call get_icon("folder") -> looks for folder.svg, folder.png, etc.
- You can include subfolders: "actions/refresh"
- If you pass an explicit extension (e.g. "logo.svg") it tries exactly that first.
- Themes live at: <base_dir>/themes/<theme>/<name>.<ext>
- Fallback order: requested theme -> "default" -> base_dir root -> embedded fallback -> placeholder
Environment Variables:
ICON_PROVIDER_BASE : override base directory
ICON_PROVIDER_THEME : default theme if not set via configure()
PyInstaller Support:
When frozen, attempts to use sys._MEIPASS if assets are bundled.
Thread Safety:
Locking is applied around caches and config mutations.
"""
# Supported extensions (search order). SVG prioritized for scalability.
RASTER_EXTS = (".png", ".jpg", ".jpeg", ".bmp", ".gif", ".ico", ".webp")
VECTOR_EXTS = (".svg",)
ALL_EXTS = VECTOR_EXTS + RASTER_EXTS
# Base64 embedded fallback icons (optional). Keep minimal to avoid bloat.
# Provide a small transparent 1x1 PNG as a generic fallback.
_EMBEDDED_FALLBACKS: Dict[str, bytes] = {
"transparent_1x1_png": base64.b64decode(
b"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8Xw8AApMBgWcsn4gAAAAASUVORK5CYII="
)
}
# Global state (protected by _lock)
_lock = threading.RLock()
_configured = False
_base_dir: Path = Path(".") # Possibly replaced by environment or configure()
_theme: str = "default"
_fallback_themes: List[str] = ["default"]
_extra_search_paths: List[Path] = [] # user-added search paths
_cache_icons: Dict[Tuple[str, Optional[str]], QtGui.QIcon] = {}
_cache_pixmaps: Dict[Tuple[str, Optional[str], Tuple[int, int]], QtGui.QPixmap] = {}
_logger: Optional[logging.Logger] = None
_enable_hidpi: bool = True
_enable_svg: bool = True
_strict: bool = False # If True, raises for missing icons (not recommended)
@classmethod
def set_logger(cls, logger: logging.Logger):
with cls._lock:
cls._logger = logger
@classmethod
def _log(cls, level: int, msg: str):
if cls._logger:
cls._logger.log(level, f"[IconProvider] {msg}")
# -----------------------------------------------------------------------
# Configuration
# -----------------------------------------------------------------------
@classmethod
def configure(
cls,
base_dir: Optional[Union[str, Path]] = None,
theme: Optional[str] = None,
fallback_themes: Optional[Iterable[str]] = None,
enable_hidpi: bool = True,
enable_svg: bool = True,
strict: bool = False,
clear_previous_cache: bool = True,
):
"""
Configure the provider.
If not called, environment variables or defaults are used.
base_dir:
Path to assets root. If None, tries:
1) ENV ICON_PROVIDER_BASE
2) PyInstaller sys._MEIPASS / 'assets'
3) current file directory / 'assets'
4) working directory / 'assets'
theme:
Active theme. If None, tries ENV ICON_PROVIDER_THEME else 'default'.
fallback_themes:
Additional themes to search if not found in the active theme.
strict:
If True, missing icons raise an exception. Otherwise fallback to placeholder.
clear_previous_cache:
Clears icon & pixmap cache to avoid stale resources.
"""
with cls._lock:
if base_dir is None:
env_base = os.environ.get("ICON_PROVIDER_BASE")
if env_base:
base_dir = env_base
else:
# PyInstaller case
if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"):
candidate = Path(sys._MEIPASS) / "assets"
if candidate.exists():
base_dir = candidate
else:
base_dir = Path(sys._MEIPASS)
else:
# Try relative to this file
# (Wrap in try in case __file__ not available)
try:
here = Path(__file__).parent
except Exception:
here = Path.cwd()
candidate = here / "assets"
if candidate.exists():
base_dir = candidate
else:
# fallback: working directory / assets
candidate2 = Path.cwd() / "assets"
base_dir = candidate2 if candidate2.exists() else Path.cwd()
cls._base_dir = Path(base_dir).resolve()
if theme is None:
theme = os.environ.get("ICON_PROVIDER_THEME", "default")
cls._theme = theme
if fallback_themes is None:
if "default" not in (theme,):
cls._fallback_themes = ["default"]
else:
cls._fallback_themes = []
else:
cls._fallback_themes = list(fallback_themes)
cls._enable_hidpi = enable_hidpi
cls._enable_svg = enable_svg
cls._strict = strict
if clear_previous_cache:
cls._cache_icons.clear()
cls._cache_pixmaps.clear()
cls._configured = True
cls._log(logging.INFO, f"Configured: base_dir={cls._base_dir}, theme={cls._theme}, fallback={cls._fallback_themes}")
@classmethod
def add_search_path(cls, path: Union[str, Path]):
with cls._lock:
p = Path(path)
if p.exists():
cls._extra_search_paths.append(p.resolve())
cls._log(logging.DEBUG, f"Added search path: {p}")
else:
cls._log(logging.WARNING, f"Search path does not exist: {p}")
@classmethod
def set_theme(cls, theme: str):
with cls._lock:
if cls._theme != theme:
cls._theme = theme
cls._cache_icons.clear()
cls._cache_pixmaps.clear()
cls._log(logging.INFO, f"Theme set to: {theme}")
@classmethod
def get_base_dir(cls) -> Path:
if not cls._configured:
cls.configure() # auto configure with defaults
return cls._base_dir
@classmethod
def get_theme(cls) -> str:
if not cls._configured:
cls.configure()
return cls._theme
# -----------------------------------------------------------------------
# Public retrieval
# -----------------------------------------------------------------------
@classmethod
def get_icon(
cls,
name: str,
theme: Optional[str] = None,
size: Optional[Tuple[int, int]] = None,
device_pixel_ratio: Optional[float] = None
) -> QtGui.QIcon:
"""
Return a QIcon for the given logical name.
If size is provided and only raster is available, ensures base pixmaps are loaded and scaled.
"""
with cls._lock:
if not cls._configured:
cls.configure()
theme_key = theme or cls._theme
cache_key = (name, theme_key)
if cache_key in cls._cache_icons:
icon = cls._cache_icons[cache_key]
if size:
# QIcon can scale internally; we trust it. Optionally, we could manually add pixmaps.
pass
return icon
# Build icon
icon = QtGui.QIcon()
path = cls._resolve_icon_file(name, theme_key, device_pixel_ratio=device_pixel_ratio)
if path:
if path.suffix.lower() == ".svg" and cls._enable_svg:
icon.addFile(str(path))
else:
# Optionally add HiDPI if available
pix = QtGui.QPixmap(str(path))
if not pix.isNull():
if cls._enable_hidpi and device_pixel_ratio and device_pixel_ratio > 1:
pix.setDevicePixelRatio(device_pixel_ratio)
icon.addPixmap(pix)
else:
# fallback: placeholder
placeholder = cls._generate_placeholder_pixmap(name, size or (64, 64))
icon.addPixmap(placeholder)
cls._log(logging.DEBUG, f"Using placeholder for icon '{name}'")
# cache
cls._cache_icons[cache_key] = icon
return icon
@classmethod
def get_pixmap(
cls,
name: str,
size: Tuple[int, int] = (64, 64),
theme: Optional[str] = None,
device_pixel_ratio: Optional[float] = None
) -> QtGui.QPixmap:
"""
Return a QPixmap (scaled) for the given icon name.
Attempts to load a vector or raster file, else placeholder.
"""
with cls._lock:
if not cls._configured:
cls.configure()
theme_key = theme or cls._theme
cache_key = (name, theme_key, size)
if cache_key in cls._cache_pixmaps:
return cls._cache_pixmaps[cache_key]
path = cls._resolve_icon_file(name, theme_key, device_pixel_ratio=device_pixel_ratio)
if path:
if path.suffix.lower() == ".svg" and cls._enable_svg:
# Render SVG at desired size using QSvgRenderer
try:
from PyQt5.QtSvg import QSvgRenderer
renderer = QSvgRenderer(str(path))
if renderer.isValid():
pix = QtGui.QPixmap(size[0], size[1])
pix.fill(QtCore.Qt.transparent)
painter = QtGui.QPainter(pix)
renderer.render(painter)
painter.end()
if cls._enable_hidpi and device_pixel_ratio and device_pixel_ratio > 1:
pix.setDevicePixelRatio(device_pixel_ratio)
else:
cls._log(logging.WARNING, f"Invalid SVG: {path}")
pix = cls._generate_placeholder_pixmap(name, size)
except Exception as e:
cls._log(logging.ERROR, f"SVG render failed for {path}: {e}")
pix = cls._generate_placeholder_pixmap(name, size)
else:
pix = QtGui.QPixmap(str(path))
if pix.isNull():
pix = cls._generate_placeholder_pixmap(name, size)
else:
if size and (pix.width() != size[0] or pix.height() != size[1]):
pix = pix.scaled(size[0], size[1], QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation)
if cls._enable_hidpi and device_pixel_ratio and device_pixel_ratio > 1:
pix.setDevicePixelRatio(device_pixel_ratio)
else:
pix = cls._generate_placeholder_pixmap(name, size)
cls._cache_pixmaps[cache_key] = pix
return pix
# -----------------------------------------------------------------------
# Internal resolution pipeline
# -----------------------------------------------------------------------
@classmethod
def _resolve_icon_file(
cls,
name: str,
theme: str,
device_pixel_ratio: Optional[float] = None
) -> Optional[Path]:
"""
Attempts to resolve a file on disk for the given icon name.
Order:
1) Name with explicit extension (if given)
2) Name tries with vector extensions
3) Name tries with raster extensions
4) HiDPI variant name@2x if DPR requested
5) Fallback themes
6) Extra search paths
7) Embedded fallback (returns None; we embed bytes only if implemented)
"""
name_clean = name.strip().lstrip("/\\")
# If user passed an absolute path that exists, just return it
p_candidate = Path(name_clean)
if p_candidate.is_absolute() and p_candidate.exists():
return p_candidate
# If user passed extension
name_root, ext = os.path.splitext(name_clean)
search_variants: List[str] = []
if ext:
search_variants.append(name_clean) # exact explicit
else:
# Build extension variants
search_variants.extend([name_clean + e for e in cls.VECTOR_EXTS])
search_variants.extend([name_clean + e for e in cls.RASTER_EXTS])
# HiDPI variant
hidpi_variants = []
if cls._enable_hidpi and device_pixel_ratio and device_pixel_ratio > 1:
# Insert @2x version before standard
# e.g. icon.png -> [email protected]
hi_variants: List[str] = []
for v in list(search_variants):
r, e = os.path.splitext(v)
hi_variants.append(f"{r}@2x{e}")
hidpi_variants = hi_variants
# Compose final search name order
final_order = hidpi_variants + search_variants
# Directories:
# theme path: base_dir/themes/<theme>/(subfolders)
# Fallback theme path(s)
theme_paths = [cls.get_base_dir() / "themes" / theme]
for ft in cls._fallback_themes:
if ft != theme:
theme_paths.append(cls.get_base_dir() / "themes" / ft)
# root
theme_paths.append(cls.get_base_dir())
# user extra search paths
theme_paths.extend(cls._extra_search_paths)
for path_root in theme_paths:
for variant in final_order:
candidate = (path_root / variant).resolve()
if candidate.exists():
return candidate
# Also consider that the name might have a subpath 'folder/open'
# Already covered by constructing a candidate above; no extra join needed.
# Embedded fallback? (Here we only have base64 for 'transparent_1x1_png')
# We do not convert to file; placeholder is simpler. Return None triggers placeholder.
if cls._strict:
raise FileNotFoundError(f"Icon '{name}' not found in any search location.")
return None
# -----------------------------------------------------------------------
# Placeholder generation for missing icons
# -----------------------------------------------------------------------
@classmethod
def _generate_placeholder_pixmap(cls, name: str, size: Tuple[int, int]) -> QtGui.QPixmap:
w, h = size
if w <= 0 or h <= 0:
w, h = 64, 64
pix = QtGui.QPixmap(w, h)
pix.fill(QtCore.Qt.transparent)
painter = QtGui.QPainter(pix)
# Deterministic color from hash
digest = hashlib.sha256(name.encode("utf-8")).hexdigest()
hue = int(digest[:2], 16) / 255.0 # 0..1
sat = 0.55 + (int(digest[2:4], 16) / 255.0) * 0.35
val = 0.55 + (int(digest[4:6], 16) / 255.0) * 0.35
color = QtGui.QColor.fromHsvF(hue, sat, val)
painter.setRenderHint(QtGui.QPainter.Antialiasing, True)
rect = QtCore.QRectF(0, 0, w, h)
path = QtGui.QPainterPath()
radius = min(w, h) * 0.18
path.addRoundedRect(rect, radius, radius)
painter.fillPath(path, color)
# Draw letter
text = name.strip().split("/")[-1][:1].upper() or "?"
painter.setPen(QtCore.Qt.white)
font = painter.font()
font.setBold(True)
font.setPointSize(int(min(w, h) * 0.45))
painter.setFont(font)
painter.drawText(rect, QtCore.Qt.AlignCenter, text)
painter.end()
return pix
# -----------------------------------------------------------------------
# Preloading and listing
# -----------------------------------------------------------------------
@classmethod
def preload(cls, patterns: Optional[List[str]] = None, recursive: bool = True, theme: Optional[str] = None):
"""
Preload icons matching patterns into the cache to eliminate runtime loads.
patterns: list of glob patterns (e.g. ["*.png", "*.svg"])
"""
with cls._lock:
if not cls._configured:
cls.configure()
theme_key = theme or cls._theme
search_dirs = [
cls.get_base_dir() / "themes" / theme_key,
*(cls.get_base_dir() / "themes" / ft for ft in cls._fallback_themes if ft != theme_key),
cls.get_base_dir(),
*cls._extra_search_paths
]
if patterns is None:
patterns = ["*"]
for d in search_dirs:
if not d.exists():
continue
files = []
if recursive:
for root, _, fnames in os.walk(d):
for f in fnames:
files.append(Path(root) / f)
else:
for f in d.iterdir():
if f.is_file():
files.append(f)
for f in files:
if any(f.match(pat) for pat in patterns):
relative_name = cls._relative_icon_name(f, d)
name_no_ext = str(relative_name.with_suffix(""))
# Remove theme folder parts if inside /themes/<theme>/
# For retrieval we use name_no_ext (with subfolders)
try:
cls.get_icon(name_no_ext, theme=theme_key)
except Exception as e:
cls._log(logging.DEBUG, f"Preload skipped {f}: {e}")
@classmethod
def _relative_icon_name(cls, file_path: Path, base_path: Path) -> Path:
try:
return file_path.relative_to(base_path)
except ValueError:
return file_path.name
@classmethod
def available_icons(cls, theme: Optional[str] = None, recursive: bool = True) -> List[str]:
"""
Return a list of icon logical names (without extension).
"""
with cls._lock:
if not cls._configured:
cls.configure()
theme_key = theme or cls._theme
seen = set()
results = []
search_dirs = [
cls.get_base_dir() / "themes" / theme_key,
*(cls.get_base_dir() / "themes" / ft for ft in cls._fallback_themes if ft != theme_key),
cls.get_base_dir(),
*cls._extra_search_paths
]
for d in search_dirs:
if not d.exists():
continue
if recursive:
for root, _, files in os.walk(d):
for f in files:
p = Path(root) / f
if p.suffix.lower() in cls.ALL_EXTS:
rel = cls._relative_icon_name(p, d)
logical = str(rel.with_suffix(""))
if logical not in seen:
seen.add(logical)
results.append(logical)
else:
for f in d.iterdir():
if f.is_file() and f.suffix.lower() in cls.ALL_EXTS:
logical = f.stem
if logical not in seen:
seen.add(logical)
results.append(logical)
return sorted(results)
# -----------------------------------------------------------------------
# Cache management
# -----------------------------------------------------------------------
@classmethod
def clear_cache(cls):
with cls._lock:
cls._cache_icons.clear()
cls._cache_pixmaps.clear()
cls._log(logging.INFO, "Caches cleared.")
@classmethod
def cache_stats(cls) -> Dict[str, int]:
with cls._lock:
return {
"icons": len(cls._cache_icons),
"pixmaps": len(cls._cache_pixmaps)
}
# ---------------------------------------------------------------------------
# Example usage (manual test)
# ---------------------------------------------------------------------------
# if __name__ == "__main__":
# # Minimal headless test (icons won't show, but ensures no exceptions)
# logging.basicConfig(level=logging.DEBUG)
# IconProvider.set_logger(logging.getLogger("Icons"))
# IconProvider.configure() # auto-detect base
# print("Base dir:", IconProvider.get_base_dir())
# print("Theme:", IconProvider.get_theme())
#
# app = QtGui.QGuiApplication(sys.argv)
#
# icon = IconProvider.get_icon("nonexistent-icon-example")
# pm = IconProvider.get_pixmap("nonexistent-icon-example", size=(128, 128))
# print("Cache stats:", IconProvider.cache_stats())
#
# # If you have an assets structure, test with a known icon:
# # IconProvider.configure(base_dir="assets", theme="dark")
# # icon2 = IconProvider.get_icon("folder")
# # print("Available icons (some):", IconProvider.available_icons()[:10])
from PyQt5 import QtCore, QtGui, QtWidgets
# Icon Provider
from icon_provider import IconProvider
# Set Config Icon Provider (default icon base directory: assets)
IconProvider.configure(base_dir="assets", theme="default")
class Ui_Form(object):
def setupUi(self, Form):
Form.setObjectName("Form")
Form.resize(750, 460)
Form.setMinimumSize(QtCore.QSize(730, 420))
# Set Window Icon (main App icon)
Form.setWindowIcon(IconProvider.get_icon("logo"))
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment