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

1# /////////////////////////////////////////////////////////////// 

2# LOADER_BUTTON - Loading Button Widget 

3# Project: ezqt_widgets 

4# /////////////////////////////////////////////////////////////// 

5 

6""" 

7Loader button widget module. 

8 

9Provides a button widget with integrated loading animation for PySide6 

10applications. 

11""" 

12 

13from __future__ import annotations 

14 

15# /////////////////////////////////////////////////////////////// 

16# IMPORTS 

17# /////////////////////////////////////////////////////////////// 

18# Standard library imports 

19from typing import Any 

20 

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 

32 

33from ...types import AnimationDuration, IconSourceExtended, WidgetParent 

34 

35# Local imports 

36from ..misc.theme_icon import ThemeIcon 

37 

38# /////////////////////////////////////////////////////////////// 

39# FUNCTIONS 

40# /////////////////////////////////////////////////////////////// 

41 

42 

43def _create_spinner_pixmap(size: int = 16, color: str = "#0078d4") -> QPixmap: 

44 """Create a spinner pixmap for loading animation. 

45 

46 Args: 

47 size: Size of the spinner (default: 16). 

48 color: Color of the spinner (default: "#0078d4"). 

49 

50 Returns: 

51 Spinner pixmap. 

52 """ 

53 pixmap = QPixmap(size, size) 

54 pixmap.fill(Qt.GlobalColor.transparent) 

55 

56 painter = QPainter(pixmap) 

57 painter.setRenderHint(QPainter.RenderHint.Antialiasing) 

58 

59 pen = QPen(QColor(color)) 

60 pen.setWidth(2) 

61 painter.setPen(pen) 

62 

63 center = size // 2 

64 radius = (size - 4) // 2 

65 

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 ) 

77 

78 painter.end() 

79 return pixmap 

80 

81 

82def _create_loading_icon(size: int = 16, color: str = "#0078d4") -> QIcon: 

83 """Create a loading icon with spinner. 

84 

85 Args: 

86 size: Size of the icon (default: 16). 

87 color: Color of the icon (default: "#0078d4"). 

88 

89 Returns: 

90 Loading icon. 

91 """ 

92 return QIcon(_create_spinner_pixmap(size, color)) 

93 

94 

95def _create_success_icon(size: int = 16, color: str = "#28a745") -> QIcon: 

96 """Create a success icon (checkmark). 

97 

98 Args: 

99 size: Size of the icon (default: 16). 

100 color: Color of the icon (default: "#28a745"). 

101 

102 Returns: 

103 Success icon. 

104 """ 

105 pixmap = QPixmap(size, size) 

106 pixmap.fill(Qt.GlobalColor.transparent) 

107 

108 painter = QPainter(pixmap) 

109 painter.setRenderHint(QPainter.RenderHint.Antialiasing) 

110 

111 pen = QPen(QColor(color)) 

112 pen.setWidth(2) 

113 painter.setPen(pen) 

114 

115 margin = size // 4 

116 painter.drawLine(margin, size // 2, size // 3, size - margin) 

117 painter.drawLine(size // 3, size - margin, size - margin, margin) 

118 

119 painter.end() 

120 return QIcon(pixmap) 

121 

122 

123def _create_error_icon(size: int = 16, color: str = "#dc3545") -> QIcon: 

124 """Create an error icon (X mark). 

125 

126 Args: 

127 size: Size of the icon (default: 16). 

128 color: Color of the icon (default: "#dc3545"). 

129 

130 Returns: 

131 Error icon. 

132 """ 

133 pixmap = QPixmap(size, size) 

134 pixmap.fill(Qt.GlobalColor.transparent) 

135 

136 painter = QPainter(pixmap) 

137 painter.setRenderHint(QPainter.RenderHint.Antialiasing) 

138 

139 pen = QPen(QColor(color)) 

140 pen.setWidth(2) 

141 painter.setPen(pen) 

142 

143 margin = size // 4 

144 painter.drawLine(margin, margin, size - margin, size - margin) 

145 painter.drawLine(size - margin, margin, margin, size - margin) 

146 

147 painter.end() 

148 return QIcon(pixmap) 

149 

150 

151# /////////////////////////////////////////////////////////////// 

152# CLASSES 

153# /////////////////////////////////////////////////////////////// 

154 

155 

156class LoaderButton(QToolButton): 

157 """Button widget with integrated loading animation. 

158 

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 

172 

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. 

197 

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). 

203 

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 """ 

213 

214 loadingStarted = Signal() 

215 loadingFinished = Signal() 

216 loadingFailed = Signal(str) 

217 progressChanged = Signal(int) 

218 

219 # /////////////////////////////////////////////////////////////// 

220 # INIT 

221 # /////////////////////////////////////////////////////////////// 

222 

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") 

247 

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 

271 

272 # Setup UI components 

273 self._text_label = QLabel() 

274 self._icon_label = QLabel() 

275 

276 # Configure labels 

277 self._text_label.setAlignment( 

278 Qt.AlignmentFlag.AlignCenter | Qt.AlignmentFlag.AlignVCenter 

279 ) 

280 self._text_label.setStyleSheet("background-color: transparent;") 

281 

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) 

291 

292 # Configure size policy 

293 self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) 

294 

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 

300 

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") 

307 

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") 

312 

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") 

317 

318 # Setup animations 

319 self._setup_animations() 

320 

321 # Connect destroyed signal to clean up the timer safely (fix #18) 

322 self.destroyed.connect(self._cleanup_timer) 

323 

324 # Initial display 

325 self._update_display() 

326 

327 # /////////////////////////////////////////////////////////////// 

328 # PROPERTIES 

329 # /////////////////////////////////////////////////////////////// 

330 

331 @property 

332 @override 

333 def text( 

334 self, 

335 ) -> str: 

336 """Get or set the button text. 

337 

338 Returns: 

339 The current button text. 

340 """ 

341 return self._original_text 

342 

343 @text.setter 

344 def text(self, value: str) -> None: 

345 """Set the button text. 

346 

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() 

353 

354 @property 

355 @override 

356 def icon( 

357 self, 

358 ) -> QIcon | None: 

359 """Get or set the button icon. 

360 

361 Returns: 

362 The current button icon, or None if no icon is set. 

363 """ 

364 return self._original_icon 

365 

366 @icon.setter 

367 def icon(self, value: IconSourceExtended) -> None: 

368 """Set the button icon. 

369 

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() 

377 

378 @property 

379 def loading_text(self) -> str: 

380 """Get or set the loading text. 

381 

382 Returns: 

383 The current loading text. 

384 """ 

385 return self._loading_text 

386 

387 @loading_text.setter 

388 def loading_text(self, value: str) -> None: 

389 """Set the loading text. 

390 

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() 

397 

398 @property 

399 def loading_icon(self) -> QIcon | None: 

400 """Get or set the loading icon. 

401 

402 Returns: 

403 The current loading icon, or None if not set. 

404 """ 

405 return self._loading_icon 

406 

407 @loading_icon.setter 

408 def loading_icon(self, value: IconSourceExtended) -> None: 

409 """Set the loading icon. 

410 

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) 

416 

417 @property 

418 def success_icon(self) -> QIcon | None: 

419 """Get or set the success icon. 

420 

421 Returns: 

422 The current success icon, or None if not set. 

423 """ 

424 return self._success_icon 

425 

426 @success_icon.setter 

427 def success_icon(self, value: IconSourceExtended) -> None: 

428 """Set the success icon. 

429 

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) 

435 

436 @property 

437 def error_icon(self) -> QIcon | None: 

438 """Get or set the error icon. 

439 

440 Returns: 

441 The current error icon, or None if not set. 

442 """ 

443 return self._error_icon 

444 

445 @error_icon.setter 

446 def error_icon(self, value: IconSourceExtended) -> None: 

447 """Set the error icon. 

448 

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) 

454 

455 @property 

456 def success_text(self) -> str: 

457 """Get or set the text displayed on success. 

458 

459 Returns: 

460 The current success text. 

461 """ 

462 return self._success_text 

463 

464 @success_text.setter 

465 def success_text(self, value: str) -> None: 

466 """Set the text displayed on success. 

467 

468 Args: 

469 value: The new success text. 

470 """ 

471 self._success_text = str(value) 

472 

473 @property 

474 def error_text(self) -> str: 

475 """Get or set the base text displayed on error. 

476 

477 Returns: 

478 The current error text. 

479 """ 

480 return self._error_text 

481 

482 @error_text.setter 

483 def error_text(self, value: str) -> None: 

484 """Set the base text displayed on error. 

485 

486 Args: 

487 value: The new error text. 

488 """ 

489 self._error_text = str(value) 

490 

491 @property 

492 def icon_size(self) -> QSize: 

493 """Get or set the spinner and state icon size. 

494 

495 Returns: 

496 The current icon size. 

497 """ 

498 return self._icon_size 

499 

500 @icon_size.setter 

501 def icon_size(self, value: QSize | tuple[int, int]) -> None: 

502 """Set the spinner and state icon size. 

503 

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() 

510 

511 @property 

512 def progress(self) -> int: 

513 """Get or set the current progress value (0-100). 

514 

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. 

519 

520 Returns: 

521 The current progress value. 

522 """ 

523 return self._progress 

524 

525 @progress.setter 

526 def progress(self, value: int) -> None: 

527 """Set the current progress value. 

528 

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}%") 

541 

542 @property 

543 def success_display_time(self) -> AnimationDuration: 

544 """Get or set the success display time. 

545 

546 Returns: 

547 The success display time in milliseconds. 

548 """ 

549 return self._success_display_time 

550 

551 @success_display_time.setter 

552 def success_display_time(self, value: AnimationDuration) -> None: 

553 """Set the success display time. 

554 

555 Args: 

556 value: The display time in milliseconds. 

557 """ 

558 self._success_display_time = int(value) 

559 

560 @property 

561 def error_display_time(self) -> AnimationDuration: 

562 """Get or set the error display time. 

563 

564 Returns: 

565 The error display time in milliseconds. 

566 """ 

567 return self._error_display_time 

568 

569 @error_display_time.setter 

570 def error_display_time(self, value: AnimationDuration) -> None: 

571 """Set the error display time. 

572 

573 Args: 

574 value: The display time in milliseconds. 

575 """ 

576 self._error_display_time = int(value) 

577 

578 @property 

579 def is_loading(self) -> bool: 

580 """Get the current loading state. 

581 

582 Returns: 

583 True if loading, False otherwise. 

584 """ 

585 return self._is_loading 

586 

587 @property 

588 def animation_speed(self) -> AnimationDuration: 

589 """Get or set the animation speed. 

590 

591 Returns: 

592 The animation speed in milliseconds. 

593 """ 

594 return self._animation_speed 

595 

596 @animation_speed.setter 

597 def animation_speed(self, value: AnimationDuration) -> None: 

598 """Set the animation speed. 

599 

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) 

606 

607 @property 

608 def auto_reset(self) -> bool: 

609 """Get or set auto-reset behavior. 

610 

611 Returns: 

612 True if auto-reset is enabled, False otherwise. 

613 """ 

614 return self._auto_reset 

615 

616 @auto_reset.setter 

617 def auto_reset(self, value: bool) -> None: 

618 """Set auto-reset behavior. 

619 

620 Args: 

621 value: Whether to auto-reset after loading completes. 

622 """ 

623 self._auto_reset = bool(value) 

624 

625 @property 

626 def min_width(self) -> int | None: 

627 """Get or set the minimum width of the button. 

628 

629 Returns: 

630 The minimum width, or None if not set. 

631 """ 

632 return self._min_width 

633 

634 @min_width.setter 

635 def min_width(self, value: int | None) -> None: 

636 """Set the minimum width of the button. 

637 

638 Args: 

639 value: The minimum width, or None to auto-calculate. 

640 """ 

641 self._min_width = value 

642 self.updateGeometry() 

643 

644 @property 

645 def min_height(self) -> int | None: 

646 """Get or set the minimum height of the button. 

647 

648 Returns: 

649 The minimum height, or None if not set. 

650 """ 

651 return self._min_height 

652 

653 @min_height.setter 

654 def min_height(self, value: int | None) -> None: 

655 """Set the minimum height of the button. 

656 

657 Args: 

658 value: The minimum height, or None to auto-calculate. 

659 """ 

660 self._min_height = value 

661 self.updateGeometry() 

662 

663 # ------------------------------------------------ 

664 # PRIVATE METHODS 

665 # ------------------------------------------------ 

666 

667 def _cleanup_timer(self) -> None: 

668 """Stop and release the animation timer when the widget is destroyed. 

669 

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 

676 

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() 

685 

686 def _show_error_state(self, error_message: str = "") -> None: 

687 """Show error state with error icon. 

688 

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) 

696 

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() 

702 

703 def _reset_to_original(self) -> None: 

704 """Reset to original state after auto-reset delay.""" 

705 self._update_display() 

706 

707 def _setup_animations(self) -> None: 

708 """Setup the spinner rotation animation.""" 

709 self._opacity_effect = QGraphicsOpacityEffect(self) 

710 self.setGraphicsEffect(self._opacity_effect) 

711 

712 self._rotation_angle = 0 

713 

714 def _rotate_spinner(self) -> None: 

715 """Rotate the spinner icon.""" 

716 if not self._is_loading: 

717 return 

718 

719 self._rotation_angle = (self._rotation_angle + 10) % 360 

720 

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) 

726 

727 painter = QPainter(rotated_pixmap) 

728 painter.setRenderHint(QPainter.RenderHint.Antialiasing) 

729 

730 painter.translate(pixmap.width() / 2, pixmap.height() / 2) 

731 painter.rotate(self._rotation_angle) 

732 painter.translate(-pixmap.width() / 2, -pixmap.height() / 2) 

733 

734 painter.drawPixmap(0, 0, pixmap) 

735 painter.end() 

736 

737 self._icon_label.setPixmap(rotated_pixmap) 

738 

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() 

755 

756 # /////////////////////////////////////////////////////////////// 

757 # PUBLIC METHODS 

758 # /////////////////////////////////////////////////////////////// 

759 

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 

764 

765 self._is_loading = True 

766 self._progress = 0 

767 self.setEnabled(False) 

768 self._update_display() 

769 

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) 

775 

776 self.loadingStarted.emit() 

777 

778 def stopLoading(self, success: bool = True, error_message: str = "") -> None: 

779 """Stop the loading animation. 

780 

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 

787 

788 self._is_loading = False 

789 

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 

795 

796 # Show result state 

797 if success: 

798 self._show_success_state() 

799 else: 

800 self._show_error_state(error_message) 

801 

802 # Enable button 

803 self.setEnabled(True) 

804 

805 if success: 

806 self.loadingFinished.emit() 

807 else: 

808 self.loadingFailed.emit(error_message) 

809 

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) 

816 

817 def resetLoading(self) -> None: 

818 """Reset the button to its original state. 

819 

820 Can be called manually when auto_reset is False. 

821 """ 

822 self._is_loading = False 

823 self._reset_to_original() 

824 

825 def setTheme(self, theme: str) -> None: 

826 """Update all icons' color for the given theme. 

827 

828 Can be connected directly to a theme-change signal to keep 

829 icons in sync with the application's color scheme. 

830 

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() 

843 

844 # /////////////////////////////////////////////////////////////// 

845 # EVENT HANDLERS 

846 # /////////////////////////////////////////////////////////////// 

847 

848 def mousePressEvent(self, event: QMouseEvent) -> None: 

849 """Handle mouse press events. 

850 

851 Args: 

852 event: The mouse event. 

853 """ 

854 if not self._is_loading and event.button() == Qt.MouseButton.LeftButton: 

855 super().mousePressEvent(event) 

856 

857 # /////////////////////////////////////////////////////////////// 

858 # OVERRIDE METHODS 

859 # /////////////////////////////////////////////////////////////// 

860 

861 def sizeHint(self) -> QSize: 

862 """Get the recommended size for the button. 

863 

864 Returns: 

865 The recommended size. 

866 """ 

867 return QSize(120, 30) 

868 

869 def minimumSizeHint(self) -> QSize: 

870 """Get the minimum size hint for the button. 

871 

872 Returns: 

873 The minimum size hint. 

874 """ 

875 base_size = super().minimumSizeHint() 

876 

877 text_width = self._text_label.fontMetrics().horizontalAdvance( 

878 self._loading_text if self._is_loading else self._original_text 

879 ) 

880 

881 icon_width = ( 

882 self._icon_size.width() 

883 if (self._loading_icon or self._original_icon) 

884 else 0 

885 ) 

886 

887 total_width = text_width + icon_width + 16 + 8 # margins + spacing 

888 

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 ) 

895 

896 return QSize(max(min_width, total_width), min_height) 

897 

898 # /////////////////////////////////////////////////////////////// 

899 # STYLE METHODS 

900 # /////////////////////////////////////////////////////////////// 

901 

902 def refreshStyle(self) -> None: 

903 """Refresh the widget's style. 

904 

905 Useful after dynamic stylesheet changes. 

906 """ 

907 self.style().unpolish(self) 

908 self.style().polish(self) 

909 self.update() 

910 

911 

912# /////////////////////////////////////////////////////////////// 

913# PUBLIC API 

914# /////////////////////////////////////////////////////////////// 

915 

916__all__ = ["LoaderButton"]