Coverage for src / ezqt_widgets / widgets / button / loader_button.py: 86.39%
340 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-31 10:03 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-31 10:03 +0000
1# ///////////////////////////////////////////////////////////////
2# LOADER_BUTTON - Loading Button Widget
3# Project: ezqt_widgets
4# ///////////////////////////////////////////////////////////////
6"""
7Loader button widget module.
9Provides a button widget with integrated loading animation for PySide6
10applications.
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, QTimer, Signal
23from PySide6.QtGui import QColor, QIcon, QMouseEvent, QPainter, QPen, QPixmap
24from PySide6.QtWidgets import (
25 QGraphicsOpacityEffect,
26 QHBoxLayout,
27 QLabel,
28 QSizePolicy,
29 QToolButton,
30)
31from typing_extensions import override
33from ...types import AnimationDuration, IconSourceExtended, WidgetParent
35# Local imports
36from ..misc.theme_icon import ThemeIcon
38# ///////////////////////////////////////////////////////////////
39# FUNCTIONS
40# ///////////////////////////////////////////////////////////////
43def _create_spinner_pixmap(size: int = 16, color: str = "#0078d4") -> QPixmap:
44 """Create a spinner pixmap for loading animation.
46 Args:
47 size: Size of the spinner (default: 16).
48 color: Color of the spinner (default: "#0078d4").
50 Returns:
51 Spinner pixmap.
52 """
53 pixmap = QPixmap(size, size)
54 pixmap.fill(Qt.GlobalColor.transparent)
56 painter = QPainter(pixmap)
57 painter.setRenderHint(QPainter.RenderHint.Antialiasing)
59 pen = QPen(QColor(color))
60 pen.setWidth(2)
61 painter.setPen(pen)
63 center = size // 2
64 radius = (size - 4) // 2
66 for i in range(8):
67 angle = i * 45
68 painter.setOpacity(0.1 + (i * 0.1))
69 painter.drawArc(
70 center - radius,
71 center - radius,
72 radius * 2,
73 radius * 2,
74 angle * 16,
75 30 * 16,
76 )
78 painter.end()
79 return pixmap
82def _create_loading_icon(size: int = 16, color: str = "#0078d4") -> QIcon:
83 """Create a loading icon with spinner.
85 Args:
86 size: Size of the icon (default: 16).
87 color: Color of the icon (default: "#0078d4").
89 Returns:
90 Loading icon.
91 """
92 return QIcon(_create_spinner_pixmap(size, color))
95def _create_success_icon(size: int = 16, color: str = "#28a745") -> QIcon:
96 """Create a success icon (checkmark).
98 Args:
99 size: Size of the icon (default: 16).
100 color: Color of the icon (default: "#28a745").
102 Returns:
103 Success icon.
104 """
105 pixmap = QPixmap(size, size)
106 pixmap.fill(Qt.GlobalColor.transparent)
108 painter = QPainter(pixmap)
109 painter.setRenderHint(QPainter.RenderHint.Antialiasing)
111 pen = QPen(QColor(color))
112 pen.setWidth(2)
113 painter.setPen(pen)
115 margin = size // 4
116 painter.drawLine(margin, size // 2, size // 3, size - margin)
117 painter.drawLine(size // 3, size - margin, size - margin, margin)
119 painter.end()
120 return QIcon(pixmap)
123def _create_error_icon(size: int = 16, color: str = "#dc3545") -> QIcon:
124 """Create an error icon (X mark).
126 Args:
127 size: Size of the icon (default: 16).
128 color: Color of the icon (default: "#dc3545").
130 Returns:
131 Error icon.
132 """
133 pixmap = QPixmap(size, size)
134 pixmap.fill(Qt.GlobalColor.transparent)
136 painter = QPainter(pixmap)
137 painter.setRenderHint(QPainter.RenderHint.Antialiasing)
139 pen = QPen(QColor(color))
140 pen.setWidth(2)
141 painter.setPen(pen)
143 margin = size // 4
144 painter.drawLine(margin, margin, size - margin, size - margin)
145 painter.drawLine(size - margin, margin, margin, size - margin)
147 painter.end()
148 return QIcon(pixmap)
151# ///////////////////////////////////////////////////////////////
152# CLASSES
153# ///////////////////////////////////////////////////////////////
156class LoaderButton(QToolButton):
157 """Button widget with integrated loading animation.
159 Features:
160 - Loading state with animated spinner
161 - Success state with checkmark icon
162 - Error state with X icon
163 - Configurable loading, success, and error text/icons
164 - Configurable success and error result texts
165 - Smooth transitions between states
166 - Disabled state during loading
167 - Customizable animation speed
168 - Progress indication support (0-100)
169 - Auto-reset after completion with configurable display times
170 - Configurable spinner icon size
171 - Safe timer cleanup on widget destruction
173 Args:
174 parent: The parent widget (default: None).
175 text: Button text (default: "").
176 icon: Button icon (ThemeIcon, QIcon, QPixmap, or path, default: None).
177 loading_text: Text to display during loading (default: "Loading...").
178 loading_icon: Icon to display during loading
179 (ThemeIcon, QIcon, QPixmap, or path, default: None, auto-generated).
180 success_icon: Icon to display on success
181 (ThemeIcon, QIcon, QPixmap, or path, default: None, auto-generated checkmark).
182 error_icon: Icon to display on error
183 (ThemeIcon, QIcon, QPixmap, or path, default: None, auto-generated X mark).
184 success_text: Text to display when loading succeeds (default: "Success!").
185 error_text: Text to display when loading fails (default: "Error").
186 icon_size: Size of spinner and state icons (default: QSize(16, 16)).
187 animation_speed: Animation speed in milliseconds (default: 100).
188 auto_reset: Whether to auto-reset after loading (default: True).
189 success_display_time: Time to display success state in milliseconds
190 (default: 1000).
191 error_display_time: Time to display error state in milliseconds
192 (default: 2000).
193 min_width: Minimum width of the button (default: None, auto-calculated).
194 min_height: Minimum height of the button (default: None, auto-calculated).
195 *args: Additional arguments passed to QToolButton.
196 **kwargs: Additional keyword arguments passed to QToolButton.
198 Signals:
199 loadingStarted(): Emitted when loading starts.
200 loadingFinished(): Emitted when loading finishes successfully.
201 loadingFailed(str): Emitted when loading fails with error message.
202 progressChanged(int): Emitted when progress value changes (0-100).
204 Example:
205 >>> from ezqt_widgets import LoaderButton
206 >>> btn = LoaderButton(text="Submit", loading_text="Sending...")
207 >>> btn.loadingFinished.connect(lambda: print("done"))
208 >>> btn.startLoading()
209 >>> # After completion:
210 >>> btn.stopLoading(success=True)
211 >>> btn.show()
212 """
214 loadingStarted = Signal()
215 loadingFinished = Signal()
216 loadingFailed = Signal(str)
217 progressChanged = Signal(int)
219 # ///////////////////////////////////////////////////////////////
220 # INIT
221 # ///////////////////////////////////////////////////////////////
223 def __init__(
224 self,
225 parent: WidgetParent = None,
226 text: str = "",
227 icon: IconSourceExtended = None,
228 loading_text: str = "Loading...",
229 loading_icon: IconSourceExtended = None,
230 success_icon: IconSourceExtended = None,
231 error_icon: IconSourceExtended = None,
232 success_text: str = "Success!",
233 error_text: str = "Error",
234 icon_size: QSize | tuple[int, int] = QSize(16, 16),
235 animation_speed: AnimationDuration = 100,
236 auto_reset: bool = True,
237 success_display_time: AnimationDuration = 1000,
238 error_display_time: AnimationDuration = 2000,
239 min_width: int | None = None,
240 min_height: int | None = None,
241 *args: Any,
242 **kwargs: Any,
243 ) -> None:
244 """Initialize the loader button."""
245 super().__init__(parent, *args, **kwargs)
246 self.setProperty("type", "LoaderButton")
248 # Initialize properties
249 self._original_text = text
250 self._original_icon: QIcon | None = None
251 self._loading_text = loading_text
252 self._loading_icon: QIcon | None = None
253 self._success_icon: QIcon | None = None
254 self._error_icon: QIcon | None = None
255 self._success_text = success_text
256 self._error_text = error_text
257 self._icon_size: QSize = (
258 QSize(*icon_size) if isinstance(icon_size, tuple) else QSize(icon_size)
259 )
260 self._is_loading = False
261 self._progress: int = 0
262 self._animation_speed = animation_speed
263 self._auto_reset = auto_reset
264 self._success_display_time = success_display_time
265 self._error_display_time = error_display_time
266 self._min_width = min_width
267 self._min_height = min_height
268 self._animation_group = None
269 self._spinner_animation = None
270 self._animation_timer: QTimer | None = None
272 # Setup UI components
273 self._text_label = QLabel()
274 self._icon_label = QLabel()
276 # Configure labels
277 self._text_label.setAlignment(
278 Qt.AlignmentFlag.AlignCenter | Qt.AlignmentFlag.AlignVCenter
279 )
280 self._text_label.setStyleSheet("background-color: transparent;")
282 # Setup layout
283 layout = QHBoxLayout(self)
284 layout.setContentsMargins(8, 2, 8, 2)
285 layout.setSpacing(8)
286 layout.setAlignment(
287 Qt.AlignmentFlag.AlignCenter | Qt.AlignmentFlag.AlignVCenter
288 )
289 layout.addWidget(self._icon_label)
290 layout.addWidget(self._text_label)
292 # Configure size policy
293 self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
295 # Set initial values
296 if icon: 296 ↛ 297line 296 didn't jump to line 297 because the condition on line 296 was never true
297 self.icon = icon
298 if text:
299 self.text = text
301 # Setup icons using the resolved _icon_size
302 _sz = self._icon_size.width()
303 if loading_icon: 303 ↛ 304line 303 didn't jump to line 304 because the condition on line 303 was never true
304 self.loading_icon = loading_icon
305 else:
306 self._loading_icon = _create_loading_icon(_sz, "#0078d4")
308 if success_icon: 308 ↛ 309line 308 didn't jump to line 309 because the condition on line 308 was never true
309 self.success_icon = success_icon
310 else:
311 self._success_icon = _create_success_icon(_sz, "#28a745")
313 if error_icon: 313 ↛ 314line 313 didn't jump to line 314 because the condition on line 313 was never true
314 self.error_icon = error_icon
315 else:
316 self._error_icon = _create_error_icon(_sz, "#dc3545")
318 # Setup animations
319 self._setup_animations()
321 # Connect destroyed signal to clean up the timer safely (fix #18)
322 self.destroyed.connect(self._cleanup_timer)
324 # Initial display
325 self._update_display()
327 # ///////////////////////////////////////////////////////////////
328 # PROPERTIES
329 # ///////////////////////////////////////////////////////////////
331 @property
332 @override
333 def text(
334 self,
335 ) -> str:
336 """Get or set the button text.
338 Returns:
339 The current button text.
340 """
341 return self._original_text
343 @text.setter
344 def text(self, value: str) -> None:
345 """Set the button text.
347 Args:
348 value: The new button text.
349 """
350 self._original_text = str(value)
351 if not self._is_loading: 351 ↛ exitline 351 didn't return from function 'text' because the condition on line 351 was always true
352 self._update_display()
354 @property
355 @override
356 def icon(
357 self,
358 ) -> QIcon | None:
359 """Get or set the button icon.
361 Returns:
362 The current button icon, or None if no icon is set.
363 """
364 return self._original_icon
366 @icon.setter
367 def icon(self, value: IconSourceExtended) -> None:
368 """Set the button icon.
370 Args:
371 value: The icon source (ThemeIcon, QIcon, QPixmap, path, or URL).
372 """
373 icon = QIcon(value) if isinstance(value, (str, QPixmap)) else value
374 self._original_icon = ThemeIcon.from_source(icon)
375 if not self._is_loading: 375 ↛ exitline 375 didn't return from function 'icon' because the condition on line 375 was always true
376 self._update_display()
378 @property
379 def loading_text(self) -> str:
380 """Get or set the loading text.
382 Returns:
383 The current loading text.
384 """
385 return self._loading_text
387 @loading_text.setter
388 def loading_text(self, value: str) -> None:
389 """Set the loading text.
391 Args:
392 value: The new loading text.
393 """
394 self._loading_text = str(value)
395 if self._is_loading: 395 ↛ 396line 395 didn't jump to line 396 because the condition on line 395 was never true
396 self._update_display()
398 @property
399 def loading_icon(self) -> QIcon | None:
400 """Get or set the loading icon.
402 Returns:
403 The current loading icon, or None if not set.
404 """
405 return self._loading_icon
407 @loading_icon.setter
408 def loading_icon(self, value: IconSourceExtended) -> None:
409 """Set the loading icon.
411 Args:
412 value: The icon source (ThemeIcon, QIcon, QPixmap, path, or URL).
413 """
414 icon = QIcon(value) if isinstance(value, (str, QPixmap)) else value
415 self._loading_icon = ThemeIcon.from_source(icon)
417 @property
418 def success_icon(self) -> QIcon | None:
419 """Get or set the success icon.
421 Returns:
422 The current success icon, or None if not set.
423 """
424 return self._success_icon
426 @success_icon.setter
427 def success_icon(self, value: IconSourceExtended) -> None:
428 """Set the success icon.
430 Args:
431 value: The icon source (ThemeIcon, QIcon, QPixmap, path, or URL).
432 """
433 icon = QIcon(value) if isinstance(value, (str, QPixmap)) else value
434 self._success_icon = ThemeIcon.from_source(icon)
436 @property
437 def error_icon(self) -> QIcon | None:
438 """Get or set the error icon.
440 Returns:
441 The current error icon, or None if not set.
442 """
443 return self._error_icon
445 @error_icon.setter
446 def error_icon(self, value: IconSourceExtended) -> None:
447 """Set the error icon.
449 Args:
450 value: The icon source (ThemeIcon, QIcon, QPixmap, path, or URL).
451 """
452 icon = QIcon(value) if isinstance(value, (str, QPixmap)) else value
453 self._error_icon = ThemeIcon.from_source(icon)
455 @property
456 def success_text(self) -> str:
457 """Get or set the text displayed on success.
459 Returns:
460 The current success text.
461 """
462 return self._success_text
464 @success_text.setter
465 def success_text(self, value: str) -> None:
466 """Set the text displayed on success.
468 Args:
469 value: The new success text.
470 """
471 self._success_text = str(value)
473 @property
474 def error_text(self) -> str:
475 """Get or set the base text displayed on error.
477 Returns:
478 The current error text.
479 """
480 return self._error_text
482 @error_text.setter
483 def error_text(self, value: str) -> None:
484 """Set the base text displayed on error.
486 Args:
487 value: The new error text.
488 """
489 self._error_text = str(value)
491 @property
492 def icon_size(self) -> QSize:
493 """Get or set the spinner and state icon size.
495 Returns:
496 The current icon size.
497 """
498 return self._icon_size
500 @icon_size.setter
501 def icon_size(self, value: QSize | tuple[int, int]) -> None:
502 """Set the spinner and state icon size.
504 Args:
505 value: The new icon size (QSize or (width, height) tuple).
506 """
507 self._icon_size = QSize(*value) if isinstance(value, tuple) else QSize(value)
508 if not self._is_loading: 508 ↛ exitline 508 didn't return from function 'icon_size' because the condition on line 508 was always true
509 self._update_display()
511 @property
512 def progress(self) -> int:
513 """Get or set the current progress value (0-100).
515 When set during loading, the progress percentage is shown in the
516 text label instead of the generic loading text. The spinner is
517 kept visible. Setting this property outside of loading state is
518 silently ignored.
520 Returns:
521 The current progress value.
522 """
523 return self._progress
525 @progress.setter
526 def progress(self, value: int) -> None:
527 """Set the current progress value.
529 Args:
530 value: The progress value to set (clamped to 0-100).
531 Silently ignored if the button is not in loading state.
532 """
533 if not self._is_loading:
534 return
535 clamped = max(0, min(100, int(value)))
536 if clamped != self._progress: 536 ↛ exitline 536 didn't return from function 'progress' because the condition on line 536 was always true
537 self._progress = clamped
538 self.progressChanged.emit(self._progress)
539 # Refresh the label to show the percentage
540 self._text_label.setText(f"{self._progress}%")
542 @property
543 def success_display_time(self) -> AnimationDuration:
544 """Get or set the success display time.
546 Returns:
547 The success display time in milliseconds.
548 """
549 return self._success_display_time
551 @success_display_time.setter
552 def success_display_time(self, value: AnimationDuration) -> None:
553 """Set the success display time.
555 Args:
556 value: The display time in milliseconds.
557 """
558 self._success_display_time = int(value)
560 @property
561 def error_display_time(self) -> AnimationDuration:
562 """Get or set the error display time.
564 Returns:
565 The error display time in milliseconds.
566 """
567 return self._error_display_time
569 @error_display_time.setter
570 def error_display_time(self, value: AnimationDuration) -> None:
571 """Set the error display time.
573 Args:
574 value: The display time in milliseconds.
575 """
576 self._error_display_time = int(value)
578 @property
579 def is_loading(self) -> bool:
580 """Get the current loading state.
582 Returns:
583 True if loading, False otherwise.
584 """
585 return self._is_loading
587 @property
588 def animation_speed(self) -> AnimationDuration:
589 """Get or set the animation speed.
591 Returns:
592 The animation speed in milliseconds.
593 """
594 return self._animation_speed
596 @animation_speed.setter
597 def animation_speed(self, value: AnimationDuration) -> None:
598 """Set the animation speed.
600 Args:
601 value: The animation speed in milliseconds.
602 """
603 self._animation_speed = int(value)
604 if self._spinner_animation: 604 ↛ 605line 604 didn't jump to line 605 because the condition on line 604 was never true
605 self._spinner_animation.setDuration(self._animation_speed)
607 @property
608 def auto_reset(self) -> bool:
609 """Get or set auto-reset behavior.
611 Returns:
612 True if auto-reset is enabled, False otherwise.
613 """
614 return self._auto_reset
616 @auto_reset.setter
617 def auto_reset(self, value: bool) -> None:
618 """Set auto-reset behavior.
620 Args:
621 value: Whether to auto-reset after loading completes.
622 """
623 self._auto_reset = bool(value)
625 @property
626 def min_width(self) -> int | None:
627 """Get or set the minimum width of the button.
629 Returns:
630 The minimum width, or None if not set.
631 """
632 return self._min_width
634 @min_width.setter
635 def min_width(self, value: int | None) -> None:
636 """Set the minimum width of the button.
638 Args:
639 value: The minimum width, or None to auto-calculate.
640 """
641 self._min_width = value
642 self.updateGeometry()
644 @property
645 def min_height(self) -> int | None:
646 """Get or set the minimum height of the button.
648 Returns:
649 The minimum height, or None if not set.
650 """
651 return self._min_height
653 @min_height.setter
654 def min_height(self, value: int | None) -> None:
655 """Set the minimum height of the button.
657 Args:
658 value: The minimum height, or None to auto-calculate.
659 """
660 self._min_height = value
661 self.updateGeometry()
663 # ------------------------------------------------
664 # PRIVATE METHODS
665 # ------------------------------------------------
667 def _cleanup_timer(self) -> None:
668 """Stop and release the animation timer when the widget is destroyed.
670 Connected to the ``destroyed`` signal to prevent the timer from
671 firing on a dead C++ object.
672 """
673 if self._animation_timer is not None:
674 self._animation_timer.stop()
675 self._animation_timer = None
677 def _show_success_state(self) -> None:
678 """Show success state with success icon."""
679 self._text_label.setText(self._success_text)
680 if self._success_icon: 680 ↛ 684line 680 didn't jump to line 684 because the condition on line 680 was always true
681 self._icon_label.setPixmap(self._success_icon.pixmap(self._icon_size))
682 self._icon_label.show()
683 else:
684 self._icon_label.hide()
686 def _show_error_state(self, error_message: str = "") -> None:
687 """Show error state with error icon.
689 Args:
690 error_message: Optional error message to display.
691 """
692 if error_message:
693 self._text_label.setText(f"{self._error_text}: {error_message}")
694 else:
695 self._text_label.setText(self._error_text)
697 if self._error_icon: 697 ↛ 701line 697 didn't jump to line 701 because the condition on line 697 was always true
698 self._icon_label.setPixmap(self._error_icon.pixmap(self._icon_size))
699 self._icon_label.show()
700 else:
701 self._icon_label.hide()
703 def _reset_to_original(self) -> None:
704 """Reset to original state after auto-reset delay."""
705 self._update_display()
707 def _setup_animations(self) -> None:
708 """Setup the spinner rotation animation."""
709 self._opacity_effect = QGraphicsOpacityEffect(self)
710 self.setGraphicsEffect(self._opacity_effect)
712 self._rotation_angle = 0
714 def _rotate_spinner(self) -> None:
715 """Rotate the spinner icon."""
716 if not self._is_loading:
717 return
719 self._rotation_angle = (self._rotation_angle + 10) % 360
721 if self._loading_icon:
722 pixmap = self._loading_icon.pixmap(self._icon_size)
723 if pixmap:
724 rotated_pixmap = QPixmap(pixmap.size())
725 rotated_pixmap.fill(Qt.GlobalColor.transparent)
727 painter = QPainter(rotated_pixmap)
728 painter.setRenderHint(QPainter.RenderHint.Antialiasing)
730 painter.translate(pixmap.width() / 2, pixmap.height() / 2)
731 painter.rotate(self._rotation_angle)
732 painter.translate(-pixmap.width() / 2, -pixmap.height() / 2)
734 painter.drawPixmap(0, 0, pixmap)
735 painter.end()
737 self._icon_label.setPixmap(rotated_pixmap)
739 def _update_display(self) -> None:
740 """Update the display based on current state."""
741 if self._is_loading:
742 self._text_label.setText(self._loading_text)
743 if self._loading_icon: 743 ↛ 747line 743 didn't jump to line 747 because the condition on line 743 was always true
744 self._icon_label.setPixmap(self._loading_icon.pixmap(self._icon_size))
745 self._icon_label.show()
746 else:
747 self._icon_label.hide()
748 else:
749 self._text_label.setText(self._original_text)
750 if self._original_icon:
751 self._icon_label.setPixmap(self._original_icon.pixmap(self._icon_size))
752 self._icon_label.show()
753 else:
754 self._icon_label.hide()
756 # ///////////////////////////////////////////////////////////////
757 # PUBLIC METHODS
758 # ///////////////////////////////////////////////////////////////
760 def startLoading(self) -> None:
761 """Start the loading animation."""
762 if self._is_loading: 762 ↛ 763line 762 didn't jump to line 763 because the condition on line 762 was never true
763 return
765 self._is_loading = True
766 self._progress = 0
767 self.setEnabled(False)
768 self._update_display()
770 # Start spinner animation using timer
771 self._rotation_angle = 0
772 self._animation_timer = QTimer()
773 self._animation_timer.timeout.connect(self._rotate_spinner)
774 self._animation_timer.start(self._animation_speed // 10)
776 self.loadingStarted.emit()
778 def stopLoading(self, success: bool = True, error_message: str = "") -> None:
779 """Stop the loading animation.
781 Args:
782 success: Whether the operation succeeded (default: True).
783 error_message: Error message if operation failed (default: "").
784 """
785 if not self._is_loading:
786 return
788 self._is_loading = False
790 # Stop spinner animation
791 if self._animation_timer is not None: 791 ↛ 797line 791 didn't jump to line 797 because the condition on line 791 was always true
792 self._animation_timer.stop()
793 self._animation_timer.deleteLater()
794 self._animation_timer = None
796 # Show result state
797 if success:
798 self._show_success_state()
799 else:
800 self._show_error_state(error_message)
802 # Enable button
803 self.setEnabled(True)
805 if success:
806 self.loadingFinished.emit()
807 else:
808 self.loadingFailed.emit(error_message)
810 # Auto-reset if enabled
811 if self._auto_reset:
812 display_time = (
813 self._success_display_time if success else self._error_display_time
814 )
815 QTimer.singleShot(display_time, self._reset_to_original)
817 def resetLoading(self) -> None:
818 """Reset the button to its original state.
820 Can be called manually when auto_reset is False.
821 """
822 self._is_loading = False
823 self._reset_to_original()
825 def setTheme(self, theme: str) -> None:
826 """Update all icons' color for the given theme.
828 Can be connected directly to a theme-change signal to keep
829 icons in sync with the application's color scheme.
831 Args:
832 theme: The new theme (``"dark"`` or ``"light"``).
833 """
834 for icon in (
835 self._original_icon,
836 self._loading_icon,
837 self._success_icon,
838 self._error_icon,
839 ):
840 if isinstance(icon, ThemeIcon):
841 icon.setTheme(theme)
842 self._update_display()
844 # ///////////////////////////////////////////////////////////////
845 # EVENT HANDLERS
846 # ///////////////////////////////////////////////////////////////
848 def mousePressEvent(self, event: QMouseEvent) -> None:
849 """Handle mouse press events.
851 Args:
852 event: The mouse event.
853 """
854 if not self._is_loading and event.button() == Qt.MouseButton.LeftButton:
855 super().mousePressEvent(event)
857 # ///////////////////////////////////////////////////////////////
858 # OVERRIDE METHODS
859 # ///////////////////////////////////////////////////////////////
861 def sizeHint(self) -> QSize:
862 """Get the recommended size for the button.
864 Returns:
865 The recommended size.
866 """
867 return QSize(120, 30)
869 def minimumSizeHint(self) -> QSize:
870 """Get the minimum size hint for the button.
872 Returns:
873 The minimum size hint.
874 """
875 base_size = super().minimumSizeHint()
877 text_width = self._text_label.fontMetrics().horizontalAdvance(
878 self._loading_text if self._is_loading else self._original_text
879 )
881 icon_width = (
882 self._icon_size.width()
883 if (self._loading_icon or self._original_icon)
884 else 0
885 )
887 total_width = text_width + icon_width + 16 + 8 # margins + spacing
889 min_width = self._min_width if self._min_width is not None else total_width
890 min_height = (
891 self._min_height
892 if self._min_height is not None
893 else max(base_size.height(), 30)
894 )
896 return QSize(max(min_width, total_width), min_height)
898 # ///////////////////////////////////////////////////////////////
899 # STYLE METHODS
900 # ///////////////////////////////////////////////////////////////
902 def refreshStyle(self) -> None:
903 """Refresh the widget's style.
905 Useful after dynamic stylesheet changes.
906 """
907 self.style().unpolish(self)
908 self.style().polish(self)
909 self.update()
912# ///////////////////////////////////////////////////////////////
913# PUBLIC API
914# ///////////////////////////////////////////////////////////////
916__all__ = ["LoaderButton"]