Coverage for src / ezqt_app / utils / icon_utils.py: 84.54%
77 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-06 13:12 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-06 13:12 +0000
1# ///////////////////////////////////////////////////////////////
2# ICON_UTILS - Icon and pixmap utility functions
3# Project: ezqt_app
4# ///////////////////////////////////////////////////////////////
5"""
6Utility functions for manipulating icons and QPixmap objects.
7Migrated from widgets/extended/menu_button.py.
8"""
10from __future__ import annotations
12# ///////////////////////////////////////////////////////////////
13# IMPORTS
14# ///////////////////////////////////////////////////////////////
15# Third-party imports
16import requests
17from PySide6.QtCore import QByteArray, QFile, QObject, QSize, Qt, QThread, Signal
18from PySide6.QtGui import QColor, QIcon, QPainter, QPixmap
19from PySide6.QtSvg import QSvgRenderer
22# ///////////////////////////////////////////////////////////////
23# CLASSES
24# ///////////////////////////////////////////////////////////////
25class IconLoaderWorker(QThread):
26 """Background thread that fetches an icon from a URL.
28 Signals
29 -------
30 icon_loaded:
31 Emitted with the resulting ``QIcon`` on success.
32 load_failed:
33 Emitted when the download or decoding fails.
34 """
36 icon_loaded = Signal(QIcon)
37 load_failed = Signal()
39 def __init__(self, url: str, parent: QObject | None = None) -> None:
40 super().__init__(parent)
41 self._url = url
43 def run(self) -> None:
44 try:
45 response = requests.get(self._url, timeout=5)
46 response.raise_for_status()
47 if "image" not in response.headers.get("Content-Type", ""):
48 self.load_failed.emit()
49 return
50 image_data = response.content
51 if self._url.lower().endswith(".svg"): 51 ↛ 60line 51 didn't jump to line 60 because the condition on line 51 was always true
52 renderer = QSvgRenderer(QByteArray(image_data))
53 pixmap = QPixmap(QSize(16, 16))
54 pixmap.fill(Qt.GlobalColor.transparent)
55 painter = QPainter(pixmap)
56 renderer.render(painter)
57 painter.end()
58 self.icon_loaded.emit(QIcon(pixmap))
59 else:
60 pixmap = QPixmap()
61 if not pixmap.loadFromData(image_data):
62 self.load_failed.emit()
63 return
64 pixmap = colorize_pixmap(pixmap, "#FFFFFF", 0.5)
65 self.icon_loaded.emit(QIcon(pixmap))
66 except Exception:
67 self.load_failed.emit()
70# ///////////////////////////////////////////////////////////////
71# FUNCTIONS
72# ///////////////////////////////////////////////////////////////
75def colorize_pixmap(
76 pixmap: QPixmap, color: str = "#FFFFFF", opacity: float = 0.5
77) -> QPixmap:
78 """
79 Colorize a QPixmap with the given color and opacity.
80 Args:
81 pixmap: QPixmap to colorize.
82 color: Color to apply (default: "#FFFFFF").
83 opacity: Opacity to apply (default: 0.5).
84 Returns:
85 QPixmap: The colorized pixmap.
86 """
87 result = QPixmap(pixmap.size())
88 result.fill(Qt.GlobalColor.transparent)
89 painter = QPainter(result)
90 painter.setOpacity(opacity)
91 painter.drawPixmap(0, 0, pixmap)
92 painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_SourceIn)
93 painter.fillRect(result.rect(), QColor(color))
94 painter.end()
95 return result
98def load_icon_from_source(source: QIcon | str | None) -> QIcon | None:
99 """Load an icon from a local source (QIcon, file path, or Qt resource path).
101 Note: URL sources are not supported here — use :func:`load_icon_from_url_async`
102 for HTTP/HTTPS URLs to avoid blocking the main thread.
104 Args:
105 source: QIcon instance or local file/resource path.
106 Returns:
107 QIcon or None: The loaded icon, or None if loading failed or source is a URL.
108 """
109 if source is None:
110 return None
111 if isinstance(source, QIcon):
112 return source
113 if isinstance(source, str): 113 ↛ 135line 113 didn't jump to line 135 because the condition on line 113 was always true
114 if source.startswith(("http://", "https://")):
115 return None
116 if source.lower().endswith(".svg"):
117 try:
118 file = QFile(source)
119 if not file.open(QFile.OpenModeFlag.ReadOnly): 119 ↛ 120line 119 didn't jump to line 120 because the condition on line 119 was never true
120 raise ValueError("Failed to open SVG file.")
121 renderer = QSvgRenderer(file)
122 pixmap = QPixmap(QSize(16, 16))
123 pixmap.fill(Qt.GlobalColor.transparent)
124 painter = QPainter(pixmap)
125 renderer.render(painter)
126 painter.end()
127 return QIcon(pixmap)
128 except Exception:
129 return None
130 pixmap = QPixmap(source)
131 if pixmap.isNull():
132 return None
133 pixmap = colorize_pixmap(pixmap, "#FFFFFF", 0.5)
134 return QIcon(pixmap)
135 return None
138def load_icon_from_url_async(
139 url: str, parent: QObject | None = None
140) -> IconLoaderWorker:
141 """Start an asynchronous icon download from a URL.
143 The caller must connect to the worker's signals before the download
144 completes, and must keep a reference to the returned worker to prevent
145 garbage collection.
147 Args:
148 url: HTTP/HTTPS URL pointing to an image (PNG, JPEG, SVG, …).
149 parent: Optional Qt parent for the worker thread.
150 Returns:
151 IconLoaderWorker: Running worker. Connect ``icon_loaded(QIcon)`` and
152 ``load_failed()`` to react to the result.
154 Example::
156 worker = load_icon_from_url_async("https://example.com/icon.png", parent=self)
157 worker.icon_loaded.connect(self._on_icon_ready)
158 worker.load_failed.connect(self._on_icon_error)
159 """
160 worker = IconLoaderWorker(url, parent)
161 worker.start()
162 return worker