Coverage for src / ezqt_widgets / widgets / misc / theme_icon.py: 89.47%
101 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-01 22:46 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-01 22:46 +0000
1# ///////////////////////////////////////////////////////////////
2# THEME_ICON - Theme-Aware Icon Widget
3# Project: ezqt_widgets
4# ///////////////////////////////////////////////////////////////
6"""
7Theme-aware icon module.
9Provides a QIcon subclass that automatically adapts its color to match the
10current application theme (light/dark) for PySide6 applications.
11"""
13from __future__ import annotations
15# ///////////////////////////////////////////////////////////////
16# IMPORTS
17# ///////////////////////////////////////////////////////////////
18# Standard library imports
19import warnings
21# Third-party imports
22from PySide6.QtCore import Qt
23from PySide6.QtGui import QColor, QIcon, QPainter, QPixmap
25# Local imports
26from ...types import IconSourceExtended
28# ///////////////////////////////////////////////////////////////
29# CLASSES
30# ///////////////////////////////////////////////////////////////
33class ThemeIcon(QIcon):
34 """QIcon subclass with automatic theme-based color adaptation.
36 This icon adapts its color based on the specified theme:
37 - Dark theme: icon rendered in the resolved dark color.
38 - Light theme: icon rendered in the resolved light color.
40 The icon can be updated dynamically by calling :meth:`setTheme`
41 when the application theme changes.
43 Args:
44 icon: The source icon (``QIcon``, ``QPixmap``, or path string).
45 theme: The initial theme (``"dark"`` or ``"light"``, default: ``"dark"``).
46 dark_color: Optional color for dark theme (hex or ``rgb(...)`` string).
47 light_color: Optional color for light theme (hex or ``rgb(...)`` string).
49 Raises:
50 TypeError: If ``icon`` is ``None``.
52 Properties:
53 theme: Get or set the active theme.
54 original_icon: Get or set the source icon.
56 Example:
57 >>> from ezqt_widgets.widgets.misc.theme_icon import ThemeIcon
58 >>> # Basic usage with automatic white/black color adaptation
59 >>> icon = ThemeIcon("path/to/icon.png", theme="dark")
60 >>> button.setIcon(icon)
61 >>> # Custom colors for each theme
62 >>> icon = ThemeIcon("icon.png", dark_color="#FFFFFF", light_color="#333333")
63 >>> # Factory method from any source (QIcon, QPixmap, path, or None)
64 >>> themed = ThemeIcon.from_source("icon.svg", theme="light")
65 >>> # Adapt to a new theme dynamically
66 >>> icon.setTheme("light")
67 """
69 # ///////////////////////////////////////////////////////////////
70 # INIT
71 # ///////////////////////////////////////////////////////////////
73 def __init__(
74 self,
75 icon: IconSourceExtended,
76 theme: str = "dark",
77 dark_color: QColor | str | None = None,
78 light_color: QColor | str | None = None,
79 ) -> None:
80 """Initialize the theme icon.
82 Args:
83 icon: The source icon (``QIcon``, ``QPixmap``, or path string).
84 theme: The initial theme (``"dark"`` or ``"light"``).
85 dark_color: Optional color for dark theme (hex or ``rgb(...)`` string).
86 light_color: Optional color for light theme (hex or ``rgb(...)`` string).
88 Raises:
89 TypeError: If ``icon`` is ``None``.
90 """
91 super().__init__()
92 self._original_icon: QIcon = self._to_qicon(icon)
93 self._theme: str = theme
94 self._dark_color, self._light_color = self._resolve_theme_colors(
95 dark_color, light_color
96 )
97 self._update_icon()
99 # ///////////////////////////////////////////////////////////////
100 # PROPERTIES
101 # ///////////////////////////////////////////////////////////////
103 @property
104 def theme(self) -> str:
105 """Get the current theme.
107 Returns:
108 The current theme (``"dark"`` or ``"light"``).
109 """
110 return self._theme
112 @theme.setter
113 def theme(self, value: str) -> None:
114 """Set the current theme and update the icon color.
116 Args:
117 value: The new theme (``"dark"`` or ``"light"``).
118 """
119 if value not in ("dark", "light"):
120 warnings.warn(
121 f"ThemeIcon: invalid theme '{value}', expected 'dark' or 'light'.",
122 stacklevel=2,
123 )
124 return
125 self._theme = value
126 self._update_icon()
128 @property
129 def original_icon(self) -> QIcon:
130 """Get the original (uncolored) source icon.
132 Returns:
133 The original icon.
134 """
135 return self._original_icon
137 @original_icon.setter
138 def original_icon(self, value: IconSourceExtended) -> None:
139 """Set the source icon and refresh the themed version.
141 Args:
142 value: The new icon source (``QIcon``, ``QPixmap``, or path string).
144 Raises:
145 TypeError: If ``value`` is ``None``.
146 """
147 self._original_icon = self._to_qicon(value)
148 self._update_icon()
150 # ///////////////////////////////////////////////////////////////
151 # PUBLIC METHODS
152 # ///////////////////////////////////////////////////////////////
154 @classmethod
155 def from_source(
156 cls,
157 source: IconSourceExtended,
158 theme: str = "dark",
159 dark_color: QColor | str | None = None,
160 light_color: QColor | str | None = None,
161 ) -> ThemeIcon | None:
162 """Create a ThemeIcon from any supported source.
164 Args:
165 source: The icon source (ThemeIcon, QIcon, QPixmap, or path string).
166 theme: The initial theme (``"dark"`` or ``"light"``).
167 dark_color: Optional color for dark theme (hex or ``rgb(...)`` string).
168 light_color: Optional color for light theme (hex or ``rgb(...)`` string).
170 Returns:
171 A ThemeIcon instance or None if ``source`` is None.
172 """
173 if source is None:
174 return None
175 if isinstance(source, cls):
176 return source
177 return cls(
178 source,
179 theme=theme,
180 dark_color=dark_color,
181 light_color=light_color,
182 )
184 def setTheme(self, theme: str) -> None:
185 """Update the icon color for the given theme.
187 Convenience method equivalent to setting the :attr:`theme` property.
189 Args:
190 theme: The new theme (``"dark"`` or ``"light"``).
191 """
192 self.theme = theme
194 # ------------------------------------------------
195 # PRIVATE METHODS
196 # ------------------------------------------------
198 @staticmethod
199 def _normalize_color(value: QColor | str | None) -> QColor | None:
200 """Normalize an input color value.
202 Args:
203 value: Color input (QColor, hex string, or ``rgb(...)`` string).
205 Returns:
206 A valid QColor, or None if input is invalid.
207 """
208 if value is None:
209 return None
210 if isinstance(value, QColor):
211 return value if value.isValid() else None
212 color = QColor(value)
213 if not color.isValid():
214 warnings.warn(
215 f"ThemeIcon: invalid color '{value}', expected hex or rgb(...).",
216 stacklevel=2,
217 )
218 return None
219 return color
221 @staticmethod
222 def _invert_color(color: QColor) -> QColor:
223 """Invert an RGB color.
225 Args:
226 color: The color to invert.
228 Returns:
229 The inverted color, preserving alpha.
230 """
231 return QColor(
232 255 - color.red(),
233 255 - color.green(),
234 255 - color.blue(),
235 color.alpha(),
236 )
238 def _resolve_theme_colors(
239 self, dark_color: QColor | str | None, light_color: QColor | str | None
240 ) -> tuple[QColor, QColor]:
241 """Resolve theme colors with fallback and auto-inversion.
243 If both are None, defaults to white/black.
244 If only one is provided, the other is auto-inverted.
245 """
246 normalized_dark = self._normalize_color(dark_color)
247 normalized_light = self._normalize_color(light_color)
249 if normalized_dark is None and normalized_light is None:
250 return QColor(Qt.GlobalColor.white), QColor(Qt.GlobalColor.black)
252 if normalized_dark is None and normalized_light is not None:
253 return self._invert_color(normalized_light), normalized_light
255 if normalized_dark is not None and normalized_light is None:
256 return normalized_dark, self._invert_color(normalized_dark)
258 if normalized_dark is None or normalized_light is None: 258 ↛ 259line 258 didn't jump to line 259 because the condition on line 258 was never true
259 raise RuntimeError(
260 "ThemeIcon: unexpected state in _resolve_theme_colors; "
261 "both colors should be non-None at this point."
262 )
263 return normalized_dark, normalized_light
265 def _to_qicon(self, source: IconSourceExtended) -> QIcon:
266 """Convert an icon source to a QIcon instance.
268 Args:
269 source: The icon source to convert.
271 Returns:
272 A QIcon instance.
274 Raises:
275 TypeError: If ``source`` is ``None``.
276 """
277 if source is None:
278 raise TypeError(
279 "ThemeIcon requires a non-None icon source "
280 "(QIcon, QPixmap, or path string)."
281 )
282 if isinstance(source, str):
283 return QIcon(source)
284 if isinstance(source, QPixmap):
285 return QIcon(source)
286 if isinstance(source, bytes): 286 ↛ 287line 286 didn't jump to line 287 because the condition on line 286 was never true
287 from PySide6.QtCore import QByteArray
288 from PySide6.QtSvg import QSvgRenderer
290 renderer = QSvgRenderer(QByteArray(source))
291 pixmap = QPixmap(16, 16)
292 pixmap.fill(Qt.GlobalColor.transparent)
293 painter = QPainter(pixmap)
294 renderer.render(painter)
295 painter.end()
296 return QIcon(pixmap)
297 return source # QIcon or ThemeIcon (subclass of QIcon)
299 def _update_icon(self) -> None:
300 """Recolor the icon to match the current theme.
302 - Dark theme: renders the icon in the resolved dark color.
303 - Light theme: renders the icon in the resolved light color.
304 """
305 if self._original_icon.isNull():
306 return
308 available_sizes = self._original_icon.availableSizes()
309 if not available_sizes: 309 ↛ 310line 309 didn't jump to line 310 because the condition on line 309 was never true
310 return
312 # Determine target color
313 new_color = self._dark_color if self._theme == "dark" else self._light_color
315 # Build the recolored pixmap
316 pixmap = self._original_icon.pixmap(available_sizes[0])
317 image = pixmap.toImage()
319 new_pixmap = QPixmap(image.size())
320 new_pixmap.fill(Qt.GlobalColor.transparent)
322 painter = QPainter(new_pixmap)
323 painter.drawImage(0, 0, image)
324 painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_SourceIn)
325 painter.fillRect(image.rect(), new_color)
326 painter.end()
328 # Replace the current icon content with the recolored version
329 self.swap(QIcon())
330 self.addPixmap(new_pixmap)
333# ///////////////////////////////////////////////////////////////
334# PUBLIC API
335# ///////////////////////////////////////////////////////////////
337__all__ = ["ThemeIcon"]