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-03-31 10:03 +0000

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

2# ICON_BUTTON - Icon Button Widget 

3# Project: ezqt_widgets 

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

5 

6""" 

7Icon button widget module. 

8 

9Provides an enhanced button widget with icon and optional text support 

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 QSize, Qt, Signal 

23from PySide6.QtGui import QColor, QIcon, QPainter, QPixmap 

24from PySide6.QtWidgets import QHBoxLayout, QLabel, QSizePolicy, QToolButton 

25from typing_extensions import override 

26 

27# Local imports 

28from ...types import IconSourceExtended, SizeType, WidgetParent 

29from ...utils._network_utils import UrlFetcher, fetch_url_bytes 

30from ..misc.theme_icon import ThemeIcon 

31 

32# /////////////////////////////////////////////////////////////// 

33# FUNCTIONS 

34# /////////////////////////////////////////////////////////////// 

35 

36 

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. 

41 

42 Args: 

43 pixmap: The pixmap to recolor. 

44 color: The color to apply (default: "#FFFFFF"). 

45 opacity: The opacity level (default: 0.5). 

46 

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 

59 

60 

61def _load_icon_from_source(source: IconSourceExtended) -> QIcon | None: 

62 """Load icon from various sources (ThemeIcon, QIcon, QPixmap, path, URL, etc.). 

63 

64 Args: 

65 source: Icon source (ThemeIcon, QIcon, QPixmap, path, resource, URL, or SVG). 

66 

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 

81 

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 

85 

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) 

99 

100 elif source.lower().endswith(".svg"): 

101 try: 

102 from PySide6.QtCore import QFile 

103 from PySide6.QtSvg import QSvgRenderer 

104 

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 

119 

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 

126 

127 

128def _icon_from_url_data(url: str, data: bytes) -> QIcon | None: 

129 """Build a QIcon from raw URL fetch data. 

130 

131 Args: 

132 url: The source URL (used to detect SVG by extension). 

133 data: Raw bytes fetched from the URL. 

134 

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 

141 

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) 

149 

150 pixmap = QPixmap() 

151 if not pixmap.loadFromData(data): 

152 return None 

153 pixmap = _colorize_pixmap(pixmap, "#FFFFFF", 0.5) 

154 return QIcon(pixmap) 

155 

156 

157# /////////////////////////////////////////////////////////////// 

158# CLASSES 

159# /////////////////////////////////////////////////////////////// 

160 

161 

162class IconButton(QToolButton): 

163 """Enhanced button widget with icon and optional text support. 

164 

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 

172 

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. 

184 

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. 

189 

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

197 

198 iconChanged = Signal(QIcon) 

199 textChanged = Signal(str) 

200 iconLoadFailed = Signal(str) 

201 

202 # /////////////////////////////////////////////////////////////// 

203 # INIT 

204 # /////////////////////////////////////////////////////////////// 

205 

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

222 

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 

236 

237 # Setup UI components 

238 self._icon_label = QLabel() 

239 self._text_label = QLabel() 

240 

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

247 

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) 

255 

256 # Configure size policy 

257 self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) 

258 

259 # Set initial values 

260 if icon: 

261 self.icon = icon 

262 if text: 

263 self.text = text 

264 self.text_visible = text_visible 

265 

266 # ------------------------------------------------ 

267 # PRIVATE METHODS 

268 # ------------------------------------------------ 

269 

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) 

275 

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 

282 

283 icon = _icon_from_url_data(url, data) 

284 if icon is None: 

285 return 

286 

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) 

297 

298 # /////////////////////////////////////////////////////////////// 

299 # PROPERTIES 

300 # /////////////////////////////////////////////////////////////// 

301 

302 @property 

303 @override 

304 def icon( 

305 self, 

306 ) -> QIcon | None: 

307 """Get or set the button icon. 

308 

309 Returns: 

310 The current icon, or None if no icon is set. 

311 """ 

312 return self._current_icon 

313 

314 @icon.setter 

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

316 """Set the button icon from various sources. 

317 

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 

325 

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) 

338 

339 @property 

340 @override 

341 def text( 

342 self, 

343 ) -> str: 

344 """Get or set the button text. 

345 

346 Returns: 

347 The current button text. 

348 """ 

349 return self._text_label.text() 

350 

351 @text.setter 

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

353 """Set the button text. 

354 

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

361 

362 @property 

363 def icon_size(self) -> QSize: 

364 """Get or set the icon size. 

365 

366 Returns: 

367 The current icon size. 

368 """ 

369 return self._icon_size 

370 

371 @icon_size.setter 

372 def icon_size(self, value: SizeType) -> None: 

373 """Set the icon size. 

374 

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) 

384 

385 @property 

386 def text_visible(self) -> bool: 

387 """Get or set text visibility. 

388 

389 Returns: 

390 True if text is visible, False otherwise. 

391 """ 

392 return self._text_visible 

393 

394 @text_visible.setter 

395 def text_visible(self, value: bool) -> None: 

396 """Set text visibility. 

397 

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

406 

407 @property 

408 def spacing(self) -> int: 

409 """Get or set spacing between icon and text. 

410 

411 Returns: 

412 The current spacing in pixels. 

413 """ 

414 return self._spacing 

415 

416 @spacing.setter 

417 def spacing(self, value: int) -> None: 

418 """Set spacing between icon and text. 

419 

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) 

427 

428 @property 

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

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

431 

432 Returns: 

433 The minimum width, or None if not set. 

434 """ 

435 return self._min_width 

436 

437 @min_width.setter 

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

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

440 

441 Args: 

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

443 """ 

444 self._min_width = value 

445 self.updateGeometry() 

446 

447 @property 

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

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

450 

451 Returns: 

452 The minimum height, or None if not set. 

453 """ 

454 return self._min_height 

455 

456 @min_height.setter 

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

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

459 

460 Args: 

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

462 """ 

463 self._min_height = value 

464 self.updateGeometry() 

465 

466 # /////////////////////////////////////////////////////////////// 

467 # PUBLIC METHODS 

468 # /////////////////////////////////////////////////////////////// 

469 

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

471 """Update the icon color for the given theme. 

472 

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

474 the icon in sync with the application's color scheme. 

475 

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

482 

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

488 

489 def clearText(self) -> None: 

490 """Clear the button text.""" 

491 self.text = "" 

492 

493 def toggleTextVisibility(self) -> None: 

494 """Toggle text visibility.""" 

495 self.text_visible = not self.text_visible 

496 

497 def setIconColor(self, color: str = "#FFFFFF", opacity: float = 0.5) -> None: 

498 """Apply color and opacity to the current icon. 

499 

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) 

508 

509 # /////////////////////////////////////////////////////////////// 

510 # OVERRIDE METHODS 

511 # /////////////////////////////////////////////////////////////// 

512 

513 def sizeHint(self) -> QSize: 

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

515 

516 Returns: 

517 The recommended size. 

518 """ 

519 return QSize(100, 40) 

520 

521 def minimumSizeHint(self) -> QSize: 

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

523 

524 Returns: 

525 The minimum size hint. 

526 """ 

527 base_size = super().minimumSizeHint() 

528 

529 icon_width = self._icon_size.width() if self._current_icon else 0 

530 

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) 

534 

535 total_width = icon_width + text_width + self._spacing + 20 # margins 

536 

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 ) 

543 

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

545 

546 # /////////////////////////////////////////////////////////////// 

547 # STYLE METHODS 

548 # /////////////////////////////////////////////////////////////// 

549 

550 def refreshStyle(self) -> None: 

551 """Refresh the widget's style. 

552 

553 Useful after dynamic stylesheet changes. 

554 """ 

555 self.style().unpolish(self) 

556 self.style().polish(self) 

557 self.update() 

558 

559 

560# /////////////////////////////////////////////////////////////// 

561# PUBLIC API 

562# /////////////////////////////////////////////////////////////// 

563 

564__all__ = ["IconButton"]