Created
September 27, 2025 19:28
-
-
Save Pymmdrza/67faa014be8a8c1999ec54a0a8a89767 to your computer and use it in GitHub Desktop.
Python PyQt5 Icon Provider From Local Path System
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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]) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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