Coverage for src / ezqt_widgets / widgets / misc / toggle_icon.py: 70.33%
215 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# TOGGLE_ICON - Toggle Icon Widget
3# Project: ezqt_widgets
4# ///////////////////////////////////////////////////////////////
6"""
7Toggle icon widget module.
9Provides a label with toggleable icons to indicate an open/closed state
10for PySide6 applications.
11"""
13from __future__ import annotations
15# ///////////////////////////////////////////////////////////////
16# IMPORTS
17# ///////////////////////////////////////////////////////////////
18# Standard library imports
19from typing import Any
21# Third-party imports
22from PySide6.QtCore import QPointF, QRectF, QSize, Qt, Signal
23from PySide6.QtGui import (
24 QColor,
25 QIcon,
26 QKeyEvent,
27 QMouseEvent,
28 QPainter,
29 QPaintEvent,
30 QPixmap,
31)
32from PySide6.QtSvg import QSvgRenderer
33from PySide6.QtWidgets import QLabel
35# Local imports
36from ...types import ColorType, IconSourceExtended, WidgetParent
37from ...utils._network_utils import fetch_url_bytes
38from ..misc.theme_icon import ThemeIcon
40# ///////////////////////////////////////////////////////////////
41# FUNCTIONS
42# ///////////////////////////////////////////////////////////////
45def _colorize_pixmap(pixmap: QPixmap, color: QColor) -> QPixmap:
46 """Apply a color to a QPixmap with opacity.
48 Args:
49 pixmap: The pixmap to colorize.
50 color: The color to apply.
52 Returns:
53 The colorized pixmap.
54 """
55 if pixmap.isNull(): 55 ↛ 58line 55 didn't jump to line 58 because the condition on line 55 was always true
56 return pixmap
58 colored_pixmap = QPixmap(pixmap.size())
59 colored_pixmap.fill(Qt.GlobalColor.transparent)
61 painter = QPainter(colored_pixmap)
62 painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_SourceOver)
63 painter.setOpacity(color.alphaF())
64 painter.fillRect(colored_pixmap.rect(), color)
65 painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_DestinationIn)
66 painter.drawPixmap(0, 0, pixmap)
67 painter.end()
69 return colored_pixmap
72def _load_icon_from_source(
73 source: IconSourceExtended, size: QSize | None = None
74) -> QPixmap:
75 """Load an icon from various sources (path, URL, QIcon, QPixmap).
77 Args:
78 source: Icon source (ThemeIcon, QIcon, QPixmap, file path, or URL).
79 size: Desired size for the icon (default: None).
81 Returns:
82 The loaded icon pixmap.
83 """
84 if source is None: 84 ↛ 85line 84 didn't jump to line 85 because the condition on line 84 was never true
85 pixmap = QPixmap(16, 16)
86 pixmap.fill(Qt.GlobalColor.transparent)
87 elif isinstance(source, QPixmap): 87 ↛ 88line 87 didn't jump to line 88 because the condition on line 87 was never true
88 pixmap = source
89 elif isinstance(source, QIcon): 89 ↛ 90line 89 didn't jump to line 90 because the condition on line 89 was never true
90 themed_icon = ThemeIcon.from_source(source)
91 if themed_icon is None:
92 raise ValueError(
93 "ThemeIcon.from_source returned None for a non-None QIcon source."
94 )
95 pixmap = themed_icon.pixmap(size or QSize(16, 16))
96 elif isinstance(source, str): 96 ↛ 119line 96 didn't jump to line 119 because the condition on line 96 was always true
97 if source.startswith(("http://", "https://")): 97 ↛ 98line 97 didn't jump to line 98 because the condition on line 97 was never true
98 image_data = fetch_url_bytes(source)
99 if image_data:
100 pixmap = QPixmap()
101 pixmap.loadFromData(image_data)
102 else:
103 pixmap = QPixmap(16, 16)
104 pixmap.fill(Qt.GlobalColor.transparent)
105 elif source.lower().endswith(".svg"): 105 ↛ 106line 105 didn't jump to line 106 because the condition on line 105 was never true
106 renderer = QSvgRenderer(source)
107 if renderer.isValid():
108 pixmap = QPixmap(size or QSize(16, 16))
109 pixmap.fill(Qt.GlobalColor.transparent)
110 painter = QPainter(pixmap)
111 renderer.render(painter)
112 painter.end()
113 else:
114 pixmap = QPixmap(16, 16)
115 pixmap.fill(Qt.GlobalColor.transparent)
116 else:
117 pixmap = QPixmap(source)
118 else:
119 pixmap = QPixmap(16, 16)
120 pixmap.fill(Qt.GlobalColor.transparent)
122 if not pixmap.isNull() and size: 122 ↛ 123line 122 didn't jump to line 123 because the condition on line 122 was never true
123 pixmap = pixmap.scaled(
124 size,
125 Qt.AspectRatioMode.KeepAspectRatio,
126 Qt.TransformationMode.SmoothTransformation,
127 )
129 return pixmap
132# ///////////////////////////////////////////////////////////////
133# CLASSES
134# ///////////////////////////////////////////////////////////////
137class ToggleIcon(QLabel):
138 """Label with toggleable icons to indicate an open/closed state.
140 Features:
141 - Toggleable icons for open/closed states
142 - Custom icons or default painted icons
143 - Configurable icon size and color
144 - Click and keyboard events for toggling
145 - Property-based state management
147 Args:
148 parent: Parent widget (default: None).
149 opened_icon: Icon to display when state is "opened"
150 (ThemeIcon, QIcon, QPixmap, path, or URL).
151 If None, uses paintEvent (default: None).
152 closed_icon: Icon to display when state is "closed"
153 (ThemeIcon, QIcon, QPixmap, path, or URL).
154 If None, uses paintEvent (default: None).
155 icon_size: Icon size in pixels (default: 16).
156 icon_color: Color to apply to icons
157 (default: white with 0.5 opacity).
158 initial_state: Initial state ("opened" or "closed", default: "closed").
159 min_width: Minimum width of the widget (default: None).
160 min_height: Minimum height of the widget (default: None).
161 *args: Additional arguments passed to QLabel.
162 **kwargs: Additional keyword arguments passed to QLabel.
164 Signals:
165 stateChanged(str): Emitted when the state changes ("opened" or "closed").
166 clicked(): Emitted when the widget is clicked.
168 Example:
169 >>> from ezqt_widgets import ToggleIcon
170 >>> toggle = ToggleIcon(initial_state="closed", icon_size=20)
171 >>> toggle.stateChanged.connect(lambda s: print(f"State: {s}"))
172 >>> toggle.toggleState()
173 >>> toggle.show()
174 """
176 stateChanged = Signal(str) # "opened" or "closed"
177 clicked = Signal()
179 # ///////////////////////////////////////////////////////////////
180 # INIT
181 # ///////////////////////////////////////////////////////////////
183 def __init__(
184 self,
185 parent: WidgetParent = None,
186 opened_icon: IconSourceExtended = None,
187 closed_icon: IconSourceExtended = None,
188 icon_size: int = 16,
189 icon_color: ColorType | None = None,
190 initial_state: str = "closed",
191 min_width: int | None = None,
192 min_height: int | None = None,
193 *args: Any,
194 **kwargs: Any,
195 ) -> None:
196 """Initialize the toggle icon."""
197 super().__init__(parent, *args, **kwargs)
198 self.setProperty("type", "ToggleIcon")
200 # Initialize variables
201 self._icon_size = icon_size
202 self._icon_color = (
203 QColor(255, 255, 255, 128) if icon_color is None else QColor(icon_color)
204 )
205 self._min_width = min_width
206 self._min_height = min_height
207 self._state = initial_state
209 # Setup icons
210 self._use_custom_icons = opened_icon is not None or closed_icon is not None
212 if self._use_custom_icons:
213 # Use provided icons
214 self._opened_icon = (
215 _load_icon_from_source(
216 opened_icon, QSize(self._icon_size, self._icon_size)
217 )
218 if opened_icon is not None
219 else None
220 )
221 self._closed_icon = (
222 _load_icon_from_source(
223 closed_icon, QSize(self._icon_size, self._icon_size)
224 )
225 if closed_icon is not None
226 else None
227 )
228 else:
229 # Use paintEvent to draw icons
230 self._opened_icon = None
231 self._closed_icon = None
233 # Setup widget
234 self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
235 self._update_icon()
236 self._apply_initial_state()
238 # ///////////////////////////////////////////////////////////////
239 # PROPERTIES
240 # ///////////////////////////////////////////////////////////////
242 @property
243 def state(self) -> str:
244 """Get the current state.
246 Returns:
247 The current state ("opened" or "closed").
248 """
249 return self._state
251 @state.setter
252 def state(self, value: str) -> None:
253 """Set the current state.
255 Args:
256 value: The new state ("opened" or "closed").
257 """
258 if value not in ("opened", "closed"): 258 ↛ 259line 258 didn't jump to line 259 because the condition on line 258 was never true
259 value = "closed"
260 if self._state != value: 260 ↛ exitline 260 didn't return from function 'state' because the condition on line 260 was always true
261 self._state = value
262 self._update_icon()
263 self.stateChanged.emit(self._state)
265 @property
266 def opened_icon(self) -> QPixmap | None:
267 """Get or set the opened state icon.
269 Returns:
270 The opened icon pixmap, or None if using default.
271 """
272 return self._opened_icon
274 @opened_icon.setter
275 def opened_icon(self, value: IconSourceExtended) -> None:
276 """Set the opened state icon.
278 Args:
279 value: The icon source (str, QIcon, or QPixmap).
280 """
281 self._opened_icon = _load_icon_from_source(
282 value, QSize(self._icon_size, self._icon_size)
283 )
284 if self._state == "opened": 284 ↛ 285line 284 didn't jump to line 285 because the condition on line 284 was never true
285 self._update_icon()
287 @property
288 def closed_icon(self) -> QPixmap | None:
289 """Get or set the closed state icon.
291 Returns:
292 The closed icon pixmap, or None if using default.
293 """
294 return self._closed_icon
296 @closed_icon.setter
297 def closed_icon(self, value: IconSourceExtended) -> None:
298 """Set the closed state icon.
300 Args:
301 value: The icon source (str, QIcon, or QPixmap).
302 """
303 self._closed_icon = _load_icon_from_source(
304 value, QSize(self._icon_size, self._icon_size)
305 )
306 if self._state == "closed": 306 ↛ exitline 306 didn't return from function 'closed_icon' because the condition on line 306 was always true
307 self._update_icon()
309 @property
310 def icon_size(self) -> int:
311 """Get or set the icon size.
313 Returns:
314 The current icon size in pixels.
315 """
316 return self._icon_size
318 @icon_size.setter
319 def icon_size(self, value: int) -> None:
320 """Set the icon size.
322 Args:
323 value: The new icon size in pixels.
324 """
325 self._icon_size = int(value)
326 # Reload icons with new size
327 if hasattr(self, "_opened_icon") and self._opened_icon is not None: 327 ↛ 328line 327 didn't jump to line 328 because the condition on line 327 was never true
328 self._opened_icon = _load_icon_from_source(
329 self._opened_icon, QSize(self._icon_size, self._icon_size)
330 )
331 if hasattr(self, "_closed_icon") and self._closed_icon is not None: 331 ↛ 332line 331 didn't jump to line 332 because the condition on line 331 was never true
332 self._closed_icon = _load_icon_from_source(
333 self._closed_icon, QSize(self._icon_size, self._icon_size)
334 )
335 self._update_icon()
337 @property
338 def icon_color(self) -> QColor:
339 """Get or set the icon color.
341 Returns:
342 The current icon color.
343 """
344 return self._icon_color
346 @icon_color.setter
347 def icon_color(self, value: ColorType) -> None:
348 """Set the icon color.
350 Args:
351 value: The new icon color (QColor or str).
352 """
353 self._icon_color = QColor(value)
354 self._update_icon()
356 @property
357 def min_width(self) -> int | None:
358 """Get or set the minimum width.
360 Returns:
361 The minimum width, or None if not set.
362 """
363 return self._min_width
365 @min_width.setter
366 def min_width(self, value: int | None) -> None:
367 """Set the minimum width.
369 Args:
370 value: The new minimum width, or None to auto-calculate.
371 """
372 self._min_width = int(value) if value is not None else None
373 self.updateGeometry()
375 @property
376 def min_height(self) -> int | None:
377 """Get or set the minimum height.
379 Returns:
380 The minimum height, or None if not set.
381 """
382 return self._min_height
384 @min_height.setter
385 def min_height(self, value: int | None) -> None:
386 """Set the minimum height.
388 Args:
389 value: The new minimum height, or None to auto-calculate.
390 """
391 self._min_height = int(value) if value is not None else None
392 self.updateGeometry()
394 # ///////////////////////////////////////////////////////////////
395 # EVENT HANDLERS
396 # ///////////////////////////////////////////////////////////////
398 def mousePressEvent(self, event: QMouseEvent) -> None:
399 """Handle mouse press events.
401 Args:
402 event: The mouse event.
403 """
404 self.toggleState()
405 self.clicked.emit()
406 super().mousePressEvent(event)
408 def keyPressEvent(self, event: QKeyEvent) -> None:
409 """Handle key press events.
411 Args:
412 event: The key event.
413 """
414 if event.key() in [
415 Qt.Key.Key_Return,
416 Qt.Key.Key_Enter,
417 Qt.Key.Key_Space,
418 ]:
419 self.toggleState()
420 self.clicked.emit()
421 super().keyPressEvent(event)
423 def paintEvent(self, event: QPaintEvent) -> None:
424 """Draw the icon if no custom icon is provided, centered in a square.
426 Args:
427 event: The paint event.
428 """
429 if not self._use_custom_icons: 429 ↛ 460line 429 didn't jump to line 460 because the condition on line 429 was always true
430 painter = QPainter(self)
431 painter.setRenderHint(QPainter.RenderHint.Antialiasing)
432 try:
433 rect = self.rect()
434 # Calculate centered square
435 side = min(rect.width(), rect.height())
436 x0 = rect.center().x() - side // 2
437 y0 = rect.center().y() - side // 2
438 square = QRectF(x0, y0, side, side)
439 center_x = square.center().x()
440 center_y = square.center().y()
441 arrow_size = max(2, self._icon_size // 4)
442 painter.setPen(Qt.PenStyle.NoPen)
443 painter.setBrush(self._icon_color)
444 if self._state == "opened": 444 ↛ 445line 444 didn't jump to line 445 because the condition on line 444 was never true
445 points = [
446 QPointF(center_x - arrow_size, center_y - arrow_size // 2),
447 QPointF(center_x + arrow_size, center_y - arrow_size // 2),
448 QPointF(center_x, center_y + arrow_size // 2),
449 ]
450 else:
451 points = [
452 QPointF(center_x - arrow_size, center_y + arrow_size // 2),
453 QPointF(center_x + arrow_size, center_y + arrow_size // 2),
454 QPointF(center_x, center_y - arrow_size // 2),
455 ]
456 painter.drawPolygon(points)
457 finally:
458 painter.end()
459 else:
460 super().paintEvent(event)
462 def minimumSizeHint(self) -> QSize:
463 """Calculate a minimum square size based on icon and margins.
465 Returns:
466 The minimum size hint.
467 """
468 icon_size = self._icon_size
469 margins = self.contentsMargins()
470 base = icon_size + max(
471 margins.left() + margins.right(),
472 margins.top() + margins.bottom(),
473 )
474 min_side = base
475 if self._min_width is not None: 475 ↛ 476line 475 didn't jump to line 476 because the condition on line 475 was never true
476 min_side = max(min_side, self._min_width)
477 if self._min_height is not None: 477 ↛ 478line 477 didn't jump to line 478 because the condition on line 477 was never true
478 min_side = max(min_side, self._min_height)
479 return QSize(min_side, min_side)
481 # ///////////////////////////////////////////////////////////////
482 # PUBLIC METHODS
483 # ///////////////////////////////////////////////////////////////
485 def setTheme(self, theme: str) -> None:
486 """Update all icons' color for the given theme.
488 Can be connected directly to a theme-change signal to keep
489 icons in sync with the application's color scheme.
491 Args:
492 theme: The new theme (``"dark"`` or ``"light"``).
493 """
494 if isinstance(self._opened_icon, ThemeIcon): 494 ↛ 495line 494 didn't jump to line 495 because the condition on line 494 was never true
495 self._opened_icon.setTheme(theme)
496 if isinstance(self._closed_icon, ThemeIcon): 496 ↛ 497line 496 didn't jump to line 497 because the condition on line 496 was never true
497 self._closed_icon.setTheme(theme)
498 self._update_icon()
500 def toggleState(self) -> None:
501 """Toggle between opened and closed states."""
502 self.state = "opened" if self._state == "closed" else "closed"
504 def setStateOpened(self) -> None:
505 """Force the state to opened."""
506 self.state = "opened"
508 def setStateClosed(self) -> None:
509 """Force the state to closed."""
510 self.state = "closed"
512 def isOpened(self) -> bool:
513 """Check if the state is opened.
515 Returns:
516 True if opened, False otherwise.
517 """
518 return self._state == "opened"
520 def isClosed(self) -> bool:
521 """Check if the state is closed.
523 Returns:
524 True if closed, False otherwise.
525 """
526 return self._state == "closed"
528 # ------------------------------------------------
529 # PRIVATE METHODS
530 # ------------------------------------------------
532 def _update_icon(self) -> None:
533 """Update the displayed icon based on current state and center the QPixmap."""
534 if self._state == "opened":
535 self.setProperty("class", "drop_down")
536 else:
537 self.setProperty("class", "drop_up")
538 if self._use_custom_icons:
539 icon = self._opened_icon if self._state == "opened" else self._closed_icon
540 if icon is not None: 540 ↛ 543line 540 didn't jump to line 543 because the condition on line 540 was always true
541 colored_icon = _colorize_pixmap(icon, self._icon_color)
542 self.setPixmap(colored_icon)
543 self.setAlignment(Qt.AlignmentFlag.AlignCenter)
544 else:
545 self.setPixmap(QPixmap())
546 self.update()
547 self.refreshStyle()
549 def _apply_initial_state(self) -> None:
550 """Apply the initial state and update QSS properties."""
551 if self._state == "opened":
552 self.setProperty("class", "drop_down")
553 else:
554 self.setProperty("class", "drop_up")
555 self.refreshStyle()
557 # ///////////////////////////////////////////////////////////////
558 # STYLE METHODS
559 # ///////////////////////////////////////////////////////////////
561 def refreshStyle(self) -> None:
562 """Refresh the widget style.
564 Useful after dynamic stylesheet changes.
565 """
566 self.style().unpolish(self)
567 self.style().polish(self)
568 self.update()
571# ///////////////////////////////////////////////////////////////
572# PUBLIC API
573# ///////////////////////////////////////////////////////////////
575__all__ = ["ToggleIcon"]