Coverage for src / ezqt_widgets / widgets / label / hover_label.py: 67.78%

218 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-01 22:46 +0000

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

2# HOVER_LABEL - Hover Label Widget 

3# Project: ezqt_widgets 

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

5 

6""" 

7Hover label widget module. 

8 

9Provides an interactive QLabel that displays a floating icon when hovered 

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

23from PySide6.QtGui import ( 

24 QColor, 

25 QEnterEvent, 

26 QIcon, 

27 QMouseEvent, 

28 QPainter, 

29 QPaintEvent, 

30 QPixmap, 

31 QResizeEvent, 

32) 

33from PySide6.QtWidgets import QLabel 

34 

35# Local imports 

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

37from ...utils._network_utils import UrlFetcher 

38from ..misc.theme_icon import ThemeIcon 

39 

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

41# CLASSES 

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

43 

44 

45class HoverLabel(QLabel): 

46 """Interactive QLabel that displays a floating icon when hovered. 

47 

48 This widget is useful for adding contextual actions or visual cues to 

49 labels in a Qt interface. 

50 

51 Features: 

52 - Displays a custom icon on hover with configurable opacity, size, 

53 color overlay, and padding 

54 - Emits a hoverIconClicked signal when the icon is clicked 

55 - Handles mouse events and cursor changes for better UX 

56 - Text and icon can be set at construction or via properties 

57 - Icon can be enabled/disabled dynamically 

58 - Supports PNG/JPG and SVG icons (local, resource, URL) 

59 - Robust error handling for icon loading 

60 

61 Use cases: 

62 - Contextual action button in a label 

63 - Info or help icon on hover 

64 - Visual feedback for interactive labels 

65 

66 Args: 

67 parent: The parent widget (default: None). 

68 icon: The icon to display on hover (ThemeIcon, QIcon, QPixmap, path, resource, URL, or SVG). 

69 text: The label text (default: ""). 

70 opacity: The opacity of the hover icon (default: 0.5). 

71 icon_size: The size of the hover icon (default: QSize(16, 16)). 

72 icon_color: Optional color overlay to apply to the icon (default: None). 

73 icon_padding: Padding (in px) to the right of the text for the icon 

74 (default: 8). 

75 icon_enabled: Whether the icon is shown on hover (default: True). 

76 min_width: Minimum width of the widget (default: None). 

77 *args: Additional arguments passed to QLabel. 

78 **kwargs: Additional keyword arguments passed to QLabel. 

79 

80 Signals: 

81 hoverIconClicked(): Emitted when the hover icon is clicked. 

82 

83 Example: 

84 >>> label = HoverLabel( 

85 ... text="Hover me!", 

86 ... icon="/path/to/icon.png", 

87 ... icon_color="#00BFFF" 

88 ... ) 

89 >>> label.icon_enabled = True 

90 >>> label.icon_padding = 12 

91 >>> label.clearIcon() 

92 """ 

93 

94 hoverIconClicked = Signal() 

95 

96 # /////////////////////////////////////////////////////////////// 

97 # INIT 

98 # /////////////////////////////////////////////////////////////// 

99 

100 def __init__( 

101 self, 

102 parent: WidgetParent = None, 

103 icon: IconSourceExtended = None, 

104 text: str = "", 

105 opacity: float = 0.5, 

106 icon_size: SizeType = QSize(16, 16), 

107 icon_color: ColorType | None = None, 

108 icon_padding: int = 8, 

109 icon_enabled: bool = True, 

110 min_width: int | None = None, 

111 *args: Any, 

112 **kwargs: Any, 

113 ) -> None: 

114 """Initialize the hover label.""" 

115 super().__init__(parent, *args, text=text or "", **kwargs) 

116 self.setProperty("type", "HoverLabel") 

117 

118 # Initialize properties 

119 self._opacity: float = opacity 

120 self._hover_icon: QIcon | None = None 

121 self._icon_size: QSize = ( 

122 QSize(*icon_size) if isinstance(icon_size, (tuple, list)) else icon_size 

123 ) 

124 self._icon_color: QColor | str | None = icon_color 

125 self._icon_padding: int = icon_padding 

126 self._icon_enabled: bool = icon_enabled 

127 self._min_width: int | None = min_width 

128 self._pending_icon_url: str | None = None 

129 self._url_fetcher: UrlFetcher | None = None 

130 

131 # State variables 

132 self._show_hover_icon: bool = False 

133 

134 # Setup widget 

135 self.setMouseTracking(True) 

136 self.setCursor(Qt.CursorShape.ArrowCursor) 

137 

138 # Set minimum width 

139 if self._min_width: 

140 self.setMinimumWidth(self._min_width) 

141 

142 # Set icon if provided 

143 if icon: 

144 self.hover_icon = icon 

145 

146 # /////////////////////////////////////////////////////////////// 

147 # PROPERTIES 

148 # /////////////////////////////////////////////////////////////// 

149 

150 @property 

151 def opacity(self) -> float: 

152 """Get the opacity of the hover icon. 

153 

154 Returns: 

155 The current opacity level. 

156 """ 

157 return self._opacity 

158 

159 @opacity.setter 

160 def opacity(self, value: float) -> None: 

161 """Set the opacity of the hover icon. 

162 

163 Args: 

164 value: The new opacity level. 

165 """ 

166 self._opacity = float(value) 

167 self.update() 

168 

169 @property 

170 def hover_icon(self) -> QIcon | None: 

171 """Get the hover icon. 

172 

173 Returns: 

174 The current hover icon, or None if not set. 

175 """ 

176 return self._hover_icon 

177 

178 @hover_icon.setter 

179 def hover_icon(self, value: IconSourceExtended) -> None: 

180 """Set the icon displayed on hover. 

181 

182 Accepts ThemeIcon, QIcon, QPixmap, str (path, resource, URL, or SVG), or None. 

183 

184 Args: 

185 value: The icon source. 

186 

187 Raises: 

188 ValueError: If icon loading fails. 

189 TypeError: If value is not a valid type. 

190 """ 

191 if value is None: 191 ↛ 192line 191 didn't jump to line 192 because the condition on line 191 was never true

192 self._hover_icon = None 

193 elif isinstance(value, QPixmap): 193 ↛ 194line 193 didn't jump to line 194 because the condition on line 193 was never true

194 self._hover_icon = QIcon(value) 

195 elif isinstance(value, QIcon): 

196 self._hover_icon = value 

197 elif isinstance(value, str): 197 ↛ 232line 197 didn't jump to line 232 because the condition on line 197 was always true

198 # Handle URL 

199 if value.startswith(("http://", "https://")): 

200 self._pending_icon_url = value 

201 self._start_icon_url_fetch(value) 

202 return 

203 

204 # Handle local SVG 

205 elif value.lower().endswith(".svg"): 

206 try: 

207 from PySide6.QtCore import QFile 

208 from PySide6.QtSvg import QSvgRenderer 

209 

210 file = QFile(value) 

211 if not file.open(QFile.OpenModeFlag.ReadOnly): 211 ↛ 212line 211 didn't jump to line 212 because the condition on line 211 was never true

212 raise ValueError(f"Cannot open SVG file: {value}") 

213 svg_data = file.readAll() 

214 file.close() 

215 renderer = QSvgRenderer(svg_data) 

216 pixmap = QPixmap(self._icon_size) 

217 pixmap.fill(Qt.GlobalColor.transparent) 

218 painter = QPainter(pixmap) 

219 renderer.render(painter) 

220 painter.end() 

221 self._hover_icon = QIcon(pixmap) 

222 except Exception as e: 

223 raise ValueError(f"Failed to load SVG icon: {e}") from e 

224 

225 # Handle local/resource raster image 

226 else: 

227 icon = QIcon(value) 

228 if icon.isNull(): 228 ↛ 229line 228 didn't jump to line 229 because the condition on line 228 was never true

229 raise ValueError(f"Invalid icon path: {value}") 

230 self._hover_icon = icon 

231 else: 

232 raise TypeError( 

233 "hover_icon must be a ThemeIcon, QIcon, QPixmap, a path string, or None." 

234 ) 

235 

236 if self._hover_icon is not None: 236 ↛ 239line 236 didn't jump to line 239 because the condition on line 236 was always true

237 self._hover_icon = ThemeIcon.from_source(self._hover_icon) 

238 

239 self._update_padding_style() 

240 self.update() 

241 

242 def _start_icon_url_fetch(self, url: str) -> None: 

243 if self._url_fetcher is None: 243 ↛ 246line 243 didn't jump to line 246 because the condition on line 243 was always true

244 self._url_fetcher = UrlFetcher(self) 

245 self._url_fetcher.fetched.connect(self._on_icon_url_fetched) 

246 self._url_fetcher.fetch(url) 

247 

248 def _on_icon_url_fetched(self, url: str, data: bytes | None) -> None: 

249 if url != self._pending_icon_url or data is None: 

250 return 

251 

252 icon = self._icon_from_url_data(url, data) 

253 if icon is None: 

254 return 

255 

256 self._hover_icon = ThemeIcon.from_source(icon) 

257 self._update_padding_style() 

258 self.update() 

259 

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

261 if url.lower().endswith(".svg"): 

262 from PySide6.QtCore import QByteArray 

263 from PySide6.QtSvg import QSvgRenderer 

264 

265 renderer = QSvgRenderer(QByteArray(data)) 

266 pixmap = QPixmap(self._icon_size) 

267 pixmap.fill(Qt.GlobalColor.transparent) 

268 painter = QPainter(pixmap) 

269 renderer.render(painter) 

270 painter.end() 

271 return QIcon(pixmap) 

272 

273 pixmap = QPixmap() 

274 if not pixmap.loadFromData(data): 

275 return None 

276 return QIcon(pixmap) 

277 

278 @property 

279 def icon_size(self) -> QSize: 

280 """Get or set the size of the hover icon. 

281 

282 Returns: 

283 The current icon size. 

284 """ 

285 return self._icon_size 

286 

287 @icon_size.setter 

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

289 """Set the size of the hover icon. 

290 

291 Args: 

292 value: The new icon size (QSize or tuple). 

293 

294 Raises: 

295 TypeError: If value is not a valid type. 

296 """ 

297 if isinstance(value, QSize): 

298 self._icon_size = value 

299 elif isinstance(value, (tuple, list)) and len(value) == 2: 299 ↛ 302line 299 didn't jump to line 302 because the condition on line 299 was always true

300 self._icon_size = QSize(*value) 

301 else: 

302 raise TypeError( 

303 "icon_size must be a QSize or a tuple/list of two integers." 

304 ) 

305 self._update_padding_style() 

306 self.update() 

307 

308 @property 

309 def icon_color(self) -> QColor | str | None: 

310 """Get or set the color overlay of the hover icon. 

311 

312 Returns: 

313 The current icon color (QColor, str, or None). 

314 """ 

315 return self._icon_color 

316 

317 @icon_color.setter 

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

319 """Set the color overlay of the hover icon. 

320 

321 Args: 

322 value: The new icon color (QColor, str, or None). 

323 """ 

324 self._icon_color = value 

325 self.update() 

326 

327 @property 

328 def icon_padding(self) -> int: 

329 """Get or set the right padding for the icon. 

330 

331 Returns: 

332 The current icon padding in pixels. 

333 """ 

334 return self._icon_padding 

335 

336 @icon_padding.setter 

337 def icon_padding(self, value: int) -> None: 

338 """Set the right padding for the icon. 

339 

340 Args: 

341 value: The new padding in pixels. 

342 """ 

343 self._icon_padding = int(value) 

344 self._update_padding_style() 

345 self.update() 

346 

347 @property 

348 def icon_enabled(self) -> bool: 

349 """Enable or disable the hover icon. 

350 

351 Returns: 

352 True if icon is enabled, False otherwise. 

353 """ 

354 return self._icon_enabled 

355 

356 @icon_enabled.setter 

357 def icon_enabled(self, value: bool) -> None: 

358 """Set whether the icon is enabled. 

359 

360 Args: 

361 value: Whether to enable the icon. 

362 """ 

363 self._icon_enabled = bool(value) 

364 self._update_padding_style() 

365 self.update() 

366 

367 # /////////////////////////////////////////////////////////////// 

368 # PUBLIC METHODS 

369 # /////////////////////////////////////////////////////////////// 

370 

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

372 """Update the hover icon color for the given theme. 

373 

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

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

376 

377 Args: 

378 theme: The new theme (``"dark"`` or ``"light"``). 

379 """ 

380 if isinstance(self._hover_icon, ThemeIcon): 

381 self._hover_icon.setTheme(theme) 

382 self.update() 

383 

384 def clearIcon(self) -> None: 

385 """Remove the hover icon.""" 

386 self._hover_icon = None 

387 self._update_padding_style() 

388 self.update() 

389 

390 # ------------------------------------------------ 

391 # PRIVATE METHODS 

392 # ------------------------------------------------ 

393 

394 def _update_padding_style(self) -> None: 

395 """Update the padding style based on icon state.""" 

396 padding = ( 

397 self._icon_size.width() + self._icon_padding 

398 if self._hover_icon and self._icon_enabled 

399 else 0 

400 ) 

401 self.setStyleSheet(f"padding-right: {padding}px;") 

402 

403 # /////////////////////////////////////////////////////////////// 

404 # EVENT HANDLERS 

405 # /////////////////////////////////////////////////////////////// 

406 

407 def mouseMoveEvent(self, event: QMouseEvent) -> None: 

408 """Handle mouse movement events. 

409 

410 Args: 

411 event: The mouse event. 

412 """ 

413 if not self._icon_enabled or not self._hover_icon: 413 ↛ 417line 413 didn't jump to line 417 because the condition on line 413 was always true

414 super().mouseMoveEvent(event) 

415 return 

416 

417 icon_x = self.width() - self._icon_size.width() - 4 

418 icon_y = (self.height() - self._icon_size.height()) // 2 

419 icon_rect = QRect( 

420 icon_x, icon_y, self._icon_size.width(), self._icon_size.height() 

421 ) 

422 

423 if icon_rect.contains(event.pos()): 

424 self.setCursor(Qt.CursorShape.PointingHandCursor) 

425 else: 

426 self.setCursor(Qt.CursorShape.ArrowCursor) 

427 

428 super().mouseMoveEvent(event) 

429 

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

431 """Handle mouse press events. 

432 

433 Args: 

434 event: The mouse event. 

435 """ 

436 if not self._icon_enabled or not self._hover_icon: 

437 super().mousePressEvent(event) 

438 return 

439 

440 icon_x = self.width() - self._icon_size.width() - 4 

441 icon_y = (self.height() - self._icon_size.height()) // 2 

442 icon_rect = QRect( 

443 icon_x, icon_y, self._icon_size.width(), self._icon_size.height() 

444 ) 

445 

446 if ( 446 ↛ 452line 446 didn't jump to line 452 because the condition on line 446 was always true

447 icon_rect.contains(event.position().toPoint()) 

448 and event.button() == Qt.MouseButton.LeftButton 

449 ): 

450 self.hoverIconClicked.emit() 

451 else: 

452 super().mousePressEvent(event) 

453 

454 def enterEvent(self, event: QEnterEvent) -> None: 

455 """Handle enter events. 

456 

457 Args: 

458 event: The enter event. 

459 """ 

460 self._show_hover_icon = True 

461 self.update() 

462 super().enterEvent(event) 

463 

464 def leaveEvent(self, event: QEvent) -> None: 

465 """Handle leave events. 

466 

467 Args: 

468 event: The leave event. 

469 """ 

470 self._show_hover_icon = False 

471 self.setCursor(Qt.CursorShape.ArrowCursor) 

472 self.update() 

473 super().leaveEvent(event) 

474 

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

476 """Paint the widget. 

477 

478 Args: 

479 event: The paint event. 

480 """ 

481 super().paintEvent(event) 

482 

483 # Draw hover icon if needed 

484 if self._show_hover_icon and self._hover_icon and self._icon_enabled: 484 ↛ 485line 484 didn't jump to line 485 because the condition on line 484 was never true

485 painter = QPainter(self) 

486 painter.setRenderHint(QPainter.RenderHint.Antialiasing) 

487 painter.setOpacity(self._opacity) 

488 

489 icon_x = self.width() - self._icon_size.width() - 4 

490 icon_y = (self.height() - self._icon_size.height()) // 2 

491 icon_rect = QRect( 

492 icon_x, icon_y, self._icon_size.width(), self._icon_size.height() 

493 ) 

494 

495 icon_pixmap = self._hover_icon.pixmap(self._icon_size) 

496 

497 # Apply color overlay if specified 

498 if self._icon_color and not icon_pixmap.isNull(): 

499 colored_pixmap = QPixmap(icon_pixmap.size()) 

500 colored_pixmap.fill(Qt.GlobalColor.transparent) 

501 overlay_painter = QPainter(colored_pixmap) 

502 overlay_painter.setCompositionMode( 

503 QPainter.CompositionMode.CompositionMode_SourceOver 

504 ) 

505 overlay_painter.fillRect( 

506 colored_pixmap.rect(), QColor(self._icon_color) 

507 ) 

508 overlay_painter.setCompositionMode( 

509 QPainter.CompositionMode.CompositionMode_DestinationIn 

510 ) 

511 overlay_painter.drawPixmap(0, 0, icon_pixmap) 

512 overlay_painter.end() 

513 painter.drawPixmap(icon_rect, colored_pixmap) 

514 elif not icon_pixmap.isNull(): 

515 painter.drawPixmap(icon_rect, icon_pixmap) 

516 

517 # /////////////////////////////////////////////////////////////// 

518 # OVERRIDE METHODS 

519 # /////////////////////////////////////////////////////////////// 

520 

521 def resizeEvent(self, event: QResizeEvent) -> None: 

522 """Handle resize events. 

523 

524 Args: 

525 event: The resize event. 

526 """ 

527 super().resizeEvent(event) 

528 self.update() 

529 

530 def minimumSizeHint(self) -> QSize: 

531 """Get the minimum size hint for the widget. 

532 

533 Returns: 

534 The minimum size hint. 

535 """ 

536 base = super().minimumSizeHint() 

537 min_width = self._min_width if self._min_width is not None else base.width() 

538 return QSize(min_width, base.height()) 

539 

540 # /////////////////////////////////////////////////////////////// 

541 # STYLE METHODS 

542 # /////////////////////////////////////////////////////////////// 

543 

544 def refreshStyle(self) -> None: 

545 """Refresh the widget's style. 

546 

547 Useful after dynamic stylesheet changes. 

548 """ 

549 self.style().unpolish(self) 

550 self.style().polish(self) 

551 self.update() 

552 

553 

554# /////////////////////////////////////////////////////////////// 

555# PUBLIC API 

556# /////////////////////////////////////////////////////////////// 

557 

558__all__ = ["HoverLabel"]