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

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

2# TOGGLE_ICON - Toggle Icon Widget 

3# Project: ezqt_widgets 

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

5 

6""" 

7Toggle icon widget module. 

8 

9Provides a label with toggleable icons to indicate an open/closed state 

10for PySide6 applications. 

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

34 

35# Local imports 

36from ...types import ColorType, IconSourceExtended, WidgetParent 

37from ...utils._network_utils import fetch_url_bytes 

38from ..misc.theme_icon import ThemeIcon 

39 

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

41# FUNCTIONS 

42# /////////////////////////////////////////////////////////////// 

43 

44 

45def _colorize_pixmap(pixmap: QPixmap, color: QColor) -> QPixmap: 

46 """Apply a color to a QPixmap with opacity. 

47 

48 Args: 

49 pixmap: The pixmap to colorize. 

50 color: The color to apply. 

51 

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 

57 

58 colored_pixmap = QPixmap(pixmap.size()) 

59 colored_pixmap.fill(Qt.GlobalColor.transparent) 

60 

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

68 

69 return colored_pixmap 

70 

71 

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

76 

77 Args: 

78 source: Icon source (ThemeIcon, QIcon, QPixmap, file path, or URL). 

79 size: Desired size for the icon (default: None). 

80 

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) 

121 

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 ) 

128 

129 return pixmap 

130 

131 

132# /////////////////////////////////////////////////////////////// 

133# CLASSES 

134# /////////////////////////////////////////////////////////////// 

135 

136 

137class ToggleIcon(QLabel): 

138 """Label with toggleable icons to indicate an open/closed state. 

139 

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 

146 

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. 

163 

164 Signals: 

165 stateChanged(str): Emitted when the state changes ("opened" or "closed"). 

166 clicked(): Emitted when the widget is clicked. 

167 

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

175 

176 stateChanged = Signal(str) # "opened" or "closed" 

177 clicked = Signal() 

178 

179 # /////////////////////////////////////////////////////////////// 

180 # INIT 

181 # /////////////////////////////////////////////////////////////// 

182 

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

199 

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 

208 

209 # Setup icons 

210 self._use_custom_icons = opened_icon is not None or closed_icon is not None 

211 

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 

232 

233 # Setup widget 

234 self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) 

235 self._update_icon() 

236 self._apply_initial_state() 

237 

238 # /////////////////////////////////////////////////////////////// 

239 # PROPERTIES 

240 # /////////////////////////////////////////////////////////////// 

241 

242 @property 

243 def state(self) -> str: 

244 """Get the current state. 

245 

246 Returns: 

247 The current state ("opened" or "closed"). 

248 """ 

249 return self._state 

250 

251 @state.setter 

252 def state(self, value: str) -> None: 

253 """Set the current state. 

254 

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) 

264 

265 @property 

266 def opened_icon(self) -> QPixmap | None: 

267 """Get or set the opened state icon. 

268 

269 Returns: 

270 The opened icon pixmap, or None if using default. 

271 """ 

272 return self._opened_icon 

273 

274 @opened_icon.setter 

275 def opened_icon(self, value: IconSourceExtended) -> None: 

276 """Set the opened state icon. 

277 

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

286 

287 @property 

288 def closed_icon(self) -> QPixmap | None: 

289 """Get or set the closed state icon. 

290 

291 Returns: 

292 The closed icon pixmap, or None if using default. 

293 """ 

294 return self._closed_icon 

295 

296 @closed_icon.setter 

297 def closed_icon(self, value: IconSourceExtended) -> None: 

298 """Set the closed state icon. 

299 

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

308 

309 @property 

310 def icon_size(self) -> int: 

311 """Get or set the icon size. 

312 

313 Returns: 

314 The current icon size in pixels. 

315 """ 

316 return self._icon_size 

317 

318 @icon_size.setter 

319 def icon_size(self, value: int) -> None: 

320 """Set the icon size. 

321 

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

336 

337 @property 

338 def icon_color(self) -> QColor: 

339 """Get or set the icon color. 

340 

341 Returns: 

342 The current icon color. 

343 """ 

344 return self._icon_color 

345 

346 @icon_color.setter 

347 def icon_color(self, value: ColorType) -> None: 

348 """Set the icon color. 

349 

350 Args: 

351 value: The new icon color (QColor or str). 

352 """ 

353 self._icon_color = QColor(value) 

354 self._update_icon() 

355 

356 @property 

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

358 """Get or set the minimum width. 

359 

360 Returns: 

361 The minimum width, or None if not set. 

362 """ 

363 return self._min_width 

364 

365 @min_width.setter 

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

367 """Set the minimum width. 

368 

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

374 

375 @property 

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

377 """Get or set the minimum height. 

378 

379 Returns: 

380 The minimum height, or None if not set. 

381 """ 

382 return self._min_height 

383 

384 @min_height.setter 

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

386 """Set the minimum height. 

387 

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

393 

394 # /////////////////////////////////////////////////////////////// 

395 # EVENT HANDLERS 

396 # /////////////////////////////////////////////////////////////// 

397 

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

399 """Handle mouse press events. 

400 

401 Args: 

402 event: The mouse event. 

403 """ 

404 self.toggleState() 

405 self.clicked.emit() 

406 super().mousePressEvent(event) 

407 

408 def keyPressEvent(self, event: QKeyEvent) -> None: 

409 """Handle key press events. 

410 

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) 

422 

423 def paintEvent(self, event: QPaintEvent) -> None: 

424 """Draw the icon if no custom icon is provided, centered in a square. 

425 

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) 

461 

462 def minimumSizeHint(self) -> QSize: 

463 """Calculate a minimum square size based on icon and margins. 

464 

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) 

480 

481 # /////////////////////////////////////////////////////////////// 

482 # PUBLIC METHODS 

483 # /////////////////////////////////////////////////////////////// 

484 

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

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

487 

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

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

490 

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

499 

500 def toggleState(self) -> None: 

501 """Toggle between opened and closed states.""" 

502 self.state = "opened" if self._state == "closed" else "closed" 

503 

504 def setStateOpened(self) -> None: 

505 """Force the state to opened.""" 

506 self.state = "opened" 

507 

508 def setStateClosed(self) -> None: 

509 """Force the state to closed.""" 

510 self.state = "closed" 

511 

512 def isOpened(self) -> bool: 

513 """Check if the state is opened. 

514 

515 Returns: 

516 True if opened, False otherwise. 

517 """ 

518 return self._state == "opened" 

519 

520 def isClosed(self) -> bool: 

521 """Check if the state is closed. 

522 

523 Returns: 

524 True if closed, False otherwise. 

525 """ 

526 return self._state == "closed" 

527 

528 # ------------------------------------------------ 

529 # PRIVATE METHODS 

530 # ------------------------------------------------ 

531 

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

548 

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

556 

557 # /////////////////////////////////////////////////////////////// 

558 # STYLE METHODS 

559 # /////////////////////////////////////////////////////////////// 

560 

561 def refreshStyle(self) -> None: 

562 """Refresh the widget style. 

563 

564 Useful after dynamic stylesheet changes. 

565 """ 

566 self.style().unpolish(self) 

567 self.style().polish(self) 

568 self.update() 

569 

570 

571# /////////////////////////////////////////////////////////////// 

572# PUBLIC API 

573# /////////////////////////////////////////////////////////////// 

574 

575__all__ = ["ToggleIcon"]