Coverage for src / ezqt_widgets / widgets / button / icon_button.py: 73.60%
243 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# ICON_BUTTON - Icon Button Widget
3# Project: ezqt_widgets
4# ///////////////////////////////////////////////////////////////
6"""
7Icon button widget module.
9Provides an enhanced button widget with icon and optional text support
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 QSize, Qt, Signal
23from PySide6.QtGui import QColor, QIcon, QPainter, QPixmap
24from PySide6.QtWidgets import QHBoxLayout, QLabel, QSizePolicy, QToolButton
25from typing_extensions import override
27# Local imports
28from ...types import IconSourceExtended, SizeType, WidgetParent
29from ...utils._network_utils import UrlFetcher, fetch_url_bytes
30from ..misc.theme_icon import ThemeIcon
32# ///////////////////////////////////////////////////////////////
33# FUNCTIONS
34# ///////////////////////////////////////////////////////////////
37def _colorize_pixmap(
38 pixmap: QPixmap, color: str = "#FFFFFF", opacity: float = 0.5
39) -> QPixmap:
40 """Recolor a QPixmap with the given color and opacity.
42 Args:
43 pixmap: The pixmap to recolor.
44 color: The color to apply (default: "#FFFFFF").
45 opacity: The opacity level (default: 0.5).
47 Returns:
48 The recolored pixmap.
49 """
50 result = QPixmap(pixmap.size())
51 result.fill(Qt.GlobalColor.transparent)
52 painter = QPainter(result)
53 painter.setOpacity(opacity)
54 painter.drawPixmap(0, 0, pixmap)
55 painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_SourceIn)
56 painter.fillRect(result.rect(), QColor(color))
57 painter.end()
58 return result
61def _load_icon_from_source(source: IconSourceExtended) -> QIcon | None:
62 """Load icon from various sources (ThemeIcon, QIcon, QPixmap, path, URL, etc.).
64 Args:
65 source: Icon source (ThemeIcon, QIcon, QPixmap, path, resource, URL, or SVG).
67 Returns:
68 Loaded icon or None if loading failed.
69 """
70 if source is None:
71 return None
72 elif isinstance(source, QPixmap): 72 ↛ 73line 72 didn't jump to line 73 because the condition on line 72 was never true
73 return QIcon(source)
74 elif isinstance(source, QIcon):
75 return source
76 elif isinstance(source, str): 76 ↛ 125line 76 didn't jump to line 125 because the condition on line 76 was always true
77 if source.startswith(("http://", "https://")):
78 image_data = fetch_url_bytes(source)
79 if not image_data:
80 return None
82 if source.lower().endswith(".svg"): 82 ↛ 83line 82 didn't jump to line 83 because the condition on line 82 was never true
83 from PySide6.QtCore import QByteArray
84 from PySide6.QtSvg import QSvgRenderer
86 renderer = QSvgRenderer(QByteArray(image_data))
87 pixmap = QPixmap(QSize(16, 16))
88 pixmap.fill(Qt.GlobalColor.transparent)
89 painter = QPainter(pixmap)
90 renderer.render(painter)
91 painter.end()
92 return QIcon(pixmap)
93 else:
94 pixmap = QPixmap()
95 if not pixmap.loadFromData(image_data): 95 ↛ 96line 95 didn't jump to line 96 because the condition on line 95 was never true
96 return None
97 pixmap = _colorize_pixmap(pixmap, "#FFFFFF", 0.5)
98 return QIcon(pixmap)
100 elif source.lower().endswith(".svg"):
101 try:
102 from PySide6.QtCore import QFile
103 from PySide6.QtSvg import QSvgRenderer
105 file = QFile(source)
106 if not file.open(QFile.OpenModeFlag.ReadOnly): 106 ↛ 107line 106 didn't jump to line 107 because the condition on line 106 was never true
107 raise ValueError(f"Cannot open SVG file: {source}")
108 svg_data = file.readAll()
109 file.close()
110 renderer = QSvgRenderer(svg_data)
111 pixmap = QPixmap(QSize(16, 16))
112 pixmap.fill(Qt.GlobalColor.transparent)
113 painter = QPainter(pixmap)
114 renderer.render(painter)
115 painter.end()
116 return QIcon(pixmap)
117 except Exception:
118 return None
120 else:
121 icon = QIcon(source)
122 if icon.isNull(): 122 ↛ 123line 122 didn't jump to line 123 because the condition on line 122 was never true
123 return None
124 return icon
125 return None
128def _icon_from_url_data(url: str, data: bytes) -> QIcon | None:
129 """Build a QIcon from raw URL fetch data.
131 Args:
132 url: The source URL (used to detect SVG by extension).
133 data: Raw bytes fetched from the URL.
135 Returns:
136 The built QIcon, or None if loading failed.
137 """
138 if url.lower().endswith(".svg"):
139 from PySide6.QtCore import QByteArray
140 from PySide6.QtSvg import QSvgRenderer
142 renderer = QSvgRenderer(QByteArray(data))
143 pixmap = QPixmap(QSize(16, 16))
144 pixmap.fill(Qt.GlobalColor.transparent)
145 painter = QPainter(pixmap)
146 renderer.render(painter)
147 painter.end()
148 return QIcon(pixmap)
150 pixmap = QPixmap()
151 if not pixmap.loadFromData(data):
152 return None
153 pixmap = _colorize_pixmap(pixmap, "#FFFFFF", 0.5)
154 return QIcon(pixmap)
157# ///////////////////////////////////////////////////////////////
158# CLASSES
159# ///////////////////////////////////////////////////////////////
162class IconButton(QToolButton):
163 """Enhanced button widget with icon and optional text support.
165 Features:
166 - Icon support from various sources (ThemeIcon, QIcon, QPixmap, path, URL, SVG)
167 - Optional text display with configurable visibility
168 - Customizable icon size and spacing
169 - Property-based access to icon and text
170 - Signals for icon and text changes
171 - Hover and click effects
173 Args:
174 parent: The parent widget (default: None).
175 icon: The icon to display (ThemeIcon, QIcon, QPixmap, path, resource, URL, or SVG).
176 text: The button text (default: "").
177 icon_size: Size of the icon (default: QSize(20, 20)).
178 text_visible: Whether the text is initially visible (default: True).
179 spacing: Spacing between icon and text in pixels (default: 10).
180 min_width: Minimum width of the button (default: None, auto-calculated).
181 min_height: Minimum height of the button (default: None, auto-calculated).
182 *args: Additional arguments passed to QToolButton.
183 **kwargs: Additional keyword arguments passed to QToolButton.
185 Signals:
186 iconChanged(QIcon): Emitted when the icon changes.
187 textChanged(str): Emitted when the text changes.
188 iconLoadFailed(str): Emitted when an icon URL fetch fails, with the URL.
190 Example:
191 >>> from ezqt_widgets import IconButton
192 >>> btn = IconButton(icon="path/to/icon.png", text="Open", icon_size=(20, 20))
193 >>> btn.iconChanged.connect(lambda icon: print("icon changed"))
194 >>> btn.text_visible = False
195 >>> btn.show()
196 """
198 iconChanged = Signal(QIcon)
199 textChanged = Signal(str)
200 iconLoadFailed = Signal(str)
202 # ///////////////////////////////////////////////////////////////
203 # INIT
204 # ///////////////////////////////////////////////////////////////
206 def __init__(
207 self,
208 parent: WidgetParent = None,
209 icon: IconSourceExtended = None,
210 text: str = "",
211 icon_size: SizeType = QSize(20, 20),
212 text_visible: bool = True,
213 spacing: int = 10,
214 min_width: int | None = None,
215 min_height: int | None = None,
216 *args: Any,
217 **kwargs: Any,
218 ) -> None:
219 """Initialize the icon button."""
220 super().__init__(parent, *args, **kwargs)
221 self.setProperty("type", "IconButton")
223 # Initialize properties
224 self._icon_size: QSize = (
225 QSize(*icon_size)
226 if isinstance(icon_size, (tuple, list))
227 else QSize(icon_size)
228 )
229 self._text_visible: bool = text_visible
230 self._spacing: int = spacing
231 self._current_icon: QIcon | None = None
232 self._min_width: int | None = min_width
233 self._min_height: int | None = min_height
234 self._pending_icon_url: str | None = None
235 self._url_fetcher: UrlFetcher | None = None
237 # Setup UI components
238 self._icon_label = QLabel()
239 self._text_label = QLabel()
241 # Configure text label
242 self._text_label.setAlignment(
243 Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter
244 )
245 self._text_label.setWordWrap(True)
246 self._text_label.setStyleSheet("background-color: transparent;")
248 # Setup layout
249 layout = QHBoxLayout(self)
250 layout.setContentsMargins(8, 2, 8, 2)
251 layout.setSpacing(spacing)
252 layout.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
253 layout.addWidget(self._icon_label)
254 layout.addWidget(self._text_label)
256 # Configure size policy
257 self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
259 # Set initial values
260 if icon:
261 self.icon = icon
262 if text:
263 self.text = text
264 self.text_visible = text_visible
266 # ------------------------------------------------
267 # PRIVATE METHODS
268 # ------------------------------------------------
270 def _start_icon_url_fetch(self, url: str) -> None:
271 if self._url_fetcher is None:
272 self._url_fetcher = UrlFetcher(self)
273 self._url_fetcher.fetched.connect(self._on_icon_url_fetched)
274 self._url_fetcher.fetch(url)
276 def _on_icon_url_fetched(self, url: str, data: bytes | None) -> None:
277 if url != self._pending_icon_url:
278 return
279 if data is None: 279 ↛ 283line 279 didn't jump to line 283 because the condition on line 279 was always true
280 self.iconLoadFailed.emit(url)
281 return
283 icon = _icon_from_url_data(url, data)
284 if icon is None:
285 return
287 themed_icon = ThemeIcon.from_source(icon)
288 if themed_icon is None:
289 raise ValueError(
290 "ThemeIcon.from_source returned None for a non-None icon source."
291 )
292 self._current_icon = themed_icon
293 self._icon_label.setPixmap(themed_icon.pixmap(self._icon_size))
294 self._icon_label.setFixedSize(self._icon_size)
295 self._icon_label.setStyleSheet("background-color: transparent;")
296 self.iconChanged.emit(themed_icon)
298 # ///////////////////////////////////////////////////////////////
299 # PROPERTIES
300 # ///////////////////////////////////////////////////////////////
302 @property
303 @override
304 def icon(
305 self,
306 ) -> QIcon | None:
307 """Get or set the button icon.
309 Returns:
310 The current icon, or None if no icon is set.
311 """
312 return self._current_icon
314 @icon.setter
315 def icon(self, value: IconSourceExtended) -> None:
316 """Set the button icon from various sources.
318 Args:
319 value: The icon source (ThemeIcon, QIcon, QPixmap, path, URL, or SVG).
320 """
321 if isinstance(value, str) and value.startswith(("http://", "https://")): 321 ↛ 322line 321 didn't jump to line 322 because the condition on line 321 was never true
322 self._pending_icon_url = value
323 self._start_icon_url_fetch(value)
324 return
326 icon = _load_icon_from_source(value)
327 if icon: 327 ↛ exitline 327 didn't return from function 'icon' because the condition on line 327 was always true
328 themed_icon = ThemeIcon.from_source(icon)
329 if themed_icon is None: 329 ↛ 330line 329 didn't jump to line 330 because the condition on line 329 was never true
330 raise ValueError(
331 "ThemeIcon.from_source returned None for a non-None icon source."
332 )
333 self._current_icon = themed_icon
334 self._icon_label.setPixmap(themed_icon.pixmap(self._icon_size))
335 self._icon_label.setFixedSize(self._icon_size)
336 self._icon_label.setStyleSheet("background-color: transparent;")
337 self.iconChanged.emit(themed_icon)
339 @property
340 @override
341 def text(
342 self,
343 ) -> str:
344 """Get or set the button text.
346 Returns:
347 The current button text.
348 """
349 return self._text_label.text()
351 @text.setter
352 def text(self, value: str) -> None:
353 """Set the button text.
355 Args:
356 value: The new button text.
357 """
358 if value != self._text_label.text(): 358 ↛ exitline 358 didn't return from function 'text' because the condition on line 358 was always true
359 self._text_label.setText(str(value))
360 self.textChanged.emit(str(value))
362 @property
363 def icon_size(self) -> QSize:
364 """Get or set the icon size.
366 Returns:
367 The current icon size.
368 """
369 return self._icon_size
371 @icon_size.setter
372 def icon_size(self, value: SizeType) -> None:
373 """Set the icon size.
375 Args:
376 value: The new icon size (QSize or tuple).
377 """
378 self._icon_size = (
379 QSize(*value) if isinstance(value, (tuple, list)) else QSize(value)
380 )
381 if self._current_icon: 381 ↛ exitline 381 didn't return from function 'icon_size' because the condition on line 381 was always true
382 self._icon_label.setPixmap(self._current_icon.pixmap(self._icon_size))
383 self._icon_label.setFixedSize(self._icon_size)
385 @property
386 def text_visible(self) -> bool:
387 """Get or set text visibility.
389 Returns:
390 True if text is visible, False otherwise.
391 """
392 return self._text_visible
394 @text_visible.setter
395 def text_visible(self, value: bool) -> None:
396 """Set text visibility.
398 Args:
399 value: Whether to show the text.
400 """
401 self._text_visible = bool(value)
402 if self._text_visible:
403 self._text_label.show()
404 else:
405 self._text_label.hide()
407 @property
408 def spacing(self) -> int:
409 """Get or set spacing between icon and text.
411 Returns:
412 The current spacing in pixels.
413 """
414 return self._spacing
416 @spacing.setter
417 def spacing(self, value: int) -> None:
418 """Set spacing between icon and text.
420 Args:
421 value: The new spacing in pixels.
422 """
423 self._spacing = int(value)
424 layout = self.layout()
425 if layout: 425 ↛ exitline 425 didn't return from function 'spacing' because the condition on line 425 was always true
426 layout.setSpacing(self._spacing)
428 @property
429 def min_width(self) -> int | None:
430 """Get or set the minimum width of the button.
432 Returns:
433 The minimum width, or None if not set.
434 """
435 return self._min_width
437 @min_width.setter
438 def min_width(self, value: int | None) -> None:
439 """Set the minimum width of the button.
441 Args:
442 value: The minimum width, or None to auto-calculate.
443 """
444 self._min_width = value
445 self.updateGeometry()
447 @property
448 def min_height(self) -> int | None:
449 """Get or set the minimum height of the button.
451 Returns:
452 The minimum height, or None if not set.
453 """
454 return self._min_height
456 @min_height.setter
457 def min_height(self, value: int | None) -> None:
458 """Set the minimum height of the button.
460 Args:
461 value: The minimum height, or None to auto-calculate.
462 """
463 self._min_height = value
464 self.updateGeometry()
466 # ///////////////////////////////////////////////////////////////
467 # PUBLIC METHODS
468 # ///////////////////////////////////////////////////////////////
470 def setTheme(self, theme: str) -> None:
471 """Update the icon color for the given theme.
473 Can be connected directly to a theme-change signal to keep
474 the icon in sync with the application's color scheme.
476 Args:
477 theme: The new theme (``"dark"`` or ``"light"``).
478 """
479 if isinstance(self._current_icon, ThemeIcon):
480 self._current_icon.setTheme(theme)
481 self._icon_label.setPixmap(self._current_icon.pixmap(self._icon_size))
483 def clearIcon(self) -> None:
484 """Remove the current icon."""
485 self._current_icon = None
486 self._icon_label.clear()
487 self.iconChanged.emit(QIcon())
489 def clearText(self) -> None:
490 """Clear the button text."""
491 self.text = ""
493 def toggleTextVisibility(self) -> None:
494 """Toggle text visibility."""
495 self.text_visible = not self.text_visible
497 def setIconColor(self, color: str = "#FFFFFF", opacity: float = 0.5) -> None:
498 """Apply color and opacity to the current icon.
500 Args:
501 color: The color to apply (default: "#FFFFFF").
502 opacity: The opacity level (default: 0.5).
503 """
504 if self._current_icon: 504 ↛ exitline 504 didn't return from function 'setIconColor' because the condition on line 504 was always true
505 pixmap = self._current_icon.pixmap(self._icon_size)
506 colored_pixmap = _colorize_pixmap(pixmap, color, opacity)
507 self._icon_label.setPixmap(colored_pixmap)
509 # ///////////////////////////////////////////////////////////////
510 # OVERRIDE METHODS
511 # ///////////////////////////////////////////////////////////////
513 def sizeHint(self) -> QSize:
514 """Get the recommended size for the button.
516 Returns:
517 The recommended size.
518 """
519 return QSize(100, 40)
521 def minimumSizeHint(self) -> QSize:
522 """Get the minimum size hint for the button.
524 Returns:
525 The minimum size hint.
526 """
527 base_size = super().minimumSizeHint()
529 icon_width = self._icon_size.width() if self._current_icon else 0
531 text_width = 0
532 if self._text_visible and self.text: 532 ↛ 535line 532 didn't jump to line 535 because the condition on line 532 was always true
533 text_width = self._text_label.fontMetrics().horizontalAdvance(self.text)
535 total_width = icon_width + text_width + self._spacing + 20 # margins
537 min_width = self._min_width if self._min_width is not None else total_width
538 min_height = (
539 self._min_height
540 if self._min_height is not None
541 else max(base_size.height(), self._icon_size.height() + 8)
542 )
544 return QSize(max(min_width, total_width), min_height)
546 # ///////////////////////////////////////////////////////////////
547 # STYLE METHODS
548 # ///////////////////////////////////////////////////////////////
550 def refreshStyle(self) -> None:
551 """Refresh the widget's style.
553 Useful after dynamic stylesheet changes.
554 """
555 self.style().unpolish(self)
556 self.style().polish(self)
557 self.update()
560# ///////////////////////////////////////////////////////////////
561# PUBLIC API
562# ///////////////////////////////////////////////////////////////
564__all__ = ["IconButton"]