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
« 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# ///////////////////////////////////////////////////////////////
6"""
7Hover label widget module.
9Provides an interactive QLabel that displays a floating icon when hovered
10for PySide6 applications.
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 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
35# Local imports
36from ...types import ColorType, IconSourceExtended, SizeType, WidgetParent
37from ...utils._network_utils import UrlFetcher
38from ..misc.theme_icon import ThemeIcon
40# ///////////////////////////////////////////////////////////////
41# CLASSES
42# ///////////////////////////////////////////////////////////////
45class HoverLabel(QLabel):
46 """Interactive QLabel that displays a floating icon when hovered.
48 This widget is useful for adding contextual actions or visual cues to
49 labels in a Qt interface.
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
61 Use cases:
62 - Contextual action button in a label
63 - Info or help icon on hover
64 - Visual feedback for interactive labels
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.
80 Signals:
81 hoverIconClicked(): Emitted when the hover icon is clicked.
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 """
94 hoverIconClicked = Signal()
96 # ///////////////////////////////////////////////////////////////
97 # INIT
98 # ///////////////////////////////////////////////////////////////
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")
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
131 # State variables
132 self._show_hover_icon: bool = False
134 # Setup widget
135 self.setMouseTracking(True)
136 self.setCursor(Qt.CursorShape.ArrowCursor)
138 # Set minimum width
139 if self._min_width:
140 self.setMinimumWidth(self._min_width)
142 # Set icon if provided
143 if icon:
144 self.hover_icon = icon
146 # ///////////////////////////////////////////////////////////////
147 # PROPERTIES
148 # ///////////////////////////////////////////////////////////////
150 @property
151 def opacity(self) -> float:
152 """Get the opacity of the hover icon.
154 Returns:
155 The current opacity level.
156 """
157 return self._opacity
159 @opacity.setter
160 def opacity(self, value: float) -> None:
161 """Set the opacity of the hover icon.
163 Args:
164 value: The new opacity level.
165 """
166 self._opacity = float(value)
167 self.update()
169 @property
170 def hover_icon(self) -> QIcon | None:
171 """Get the hover icon.
173 Returns:
174 The current hover icon, or None if not set.
175 """
176 return self._hover_icon
178 @hover_icon.setter
179 def hover_icon(self, value: IconSourceExtended) -> None:
180 """Set the icon displayed on hover.
182 Accepts ThemeIcon, QIcon, QPixmap, str (path, resource, URL, or SVG), or None.
184 Args:
185 value: The icon source.
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
204 # Handle local SVG
205 elif value.lower().endswith(".svg"):
206 try:
207 from PySide6.QtCore import QFile
208 from PySide6.QtSvg import QSvgRenderer
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
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 )
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)
239 self._update_padding_style()
240 self.update()
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)
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
252 icon = self._icon_from_url_data(url, data)
253 if icon is None:
254 return
256 self._hover_icon = ThemeIcon.from_source(icon)
257 self._update_padding_style()
258 self.update()
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
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)
273 pixmap = QPixmap()
274 if not pixmap.loadFromData(data):
275 return None
276 return QIcon(pixmap)
278 @property
279 def icon_size(self) -> QSize:
280 """Get or set the size of the hover icon.
282 Returns:
283 The current icon size.
284 """
285 return self._icon_size
287 @icon_size.setter
288 def icon_size(self, value: SizeType) -> None:
289 """Set the size of the hover icon.
291 Args:
292 value: The new icon size (QSize or tuple).
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()
308 @property
309 def icon_color(self) -> QColor | str | None:
310 """Get or set the color overlay of the hover icon.
312 Returns:
313 The current icon color (QColor, str, or None).
314 """
315 return self._icon_color
317 @icon_color.setter
318 def icon_color(self, value: ColorType | None) -> None:
319 """Set the color overlay of the hover icon.
321 Args:
322 value: The new icon color (QColor, str, or None).
323 """
324 self._icon_color = value
325 self.update()
327 @property
328 def icon_padding(self) -> int:
329 """Get or set the right padding for the icon.
331 Returns:
332 The current icon padding in pixels.
333 """
334 return self._icon_padding
336 @icon_padding.setter
337 def icon_padding(self, value: int) -> None:
338 """Set the right padding for the icon.
340 Args:
341 value: The new padding in pixels.
342 """
343 self._icon_padding = int(value)
344 self._update_padding_style()
345 self.update()
347 @property
348 def icon_enabled(self) -> bool:
349 """Enable or disable the hover icon.
351 Returns:
352 True if icon is enabled, False otherwise.
353 """
354 return self._icon_enabled
356 @icon_enabled.setter
357 def icon_enabled(self, value: bool) -> None:
358 """Set whether the icon is enabled.
360 Args:
361 value: Whether to enable the icon.
362 """
363 self._icon_enabled = bool(value)
364 self._update_padding_style()
365 self.update()
367 # ///////////////////////////////////////////////////////////////
368 # PUBLIC METHODS
369 # ///////////////////////////////////////////////////////////////
371 def setTheme(self, theme: str) -> None:
372 """Update the hover icon color for the given theme.
374 Can be connected directly to a theme-change signal to keep
375 the icon in sync with the application's color scheme.
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()
384 def clearIcon(self) -> None:
385 """Remove the hover icon."""
386 self._hover_icon = None
387 self._update_padding_style()
388 self.update()
390 # ------------------------------------------------
391 # PRIVATE METHODS
392 # ------------------------------------------------
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;")
403 # ///////////////////////////////////////////////////////////////
404 # EVENT HANDLERS
405 # ///////////////////////////////////////////////////////////////
407 def mouseMoveEvent(self, event: QMouseEvent) -> None:
408 """Handle mouse movement events.
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
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 )
423 if icon_rect.contains(event.pos()):
424 self.setCursor(Qt.CursorShape.PointingHandCursor)
425 else:
426 self.setCursor(Qt.CursorShape.ArrowCursor)
428 super().mouseMoveEvent(event)
430 def mousePressEvent(self, event: QMouseEvent) -> None:
431 """Handle mouse press events.
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
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 )
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)
454 def enterEvent(self, event: QEnterEvent) -> None:
455 """Handle enter events.
457 Args:
458 event: The enter event.
459 """
460 self._show_hover_icon = True
461 self.update()
462 super().enterEvent(event)
464 def leaveEvent(self, event: QEvent) -> None:
465 """Handle leave events.
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)
475 def paintEvent(self, event: QPaintEvent) -> None:
476 """Paint the widget.
478 Args:
479 event: The paint event.
480 """
481 super().paintEvent(event)
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)
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 )
495 icon_pixmap = self._hover_icon.pixmap(self._icon_size)
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)
517 # ///////////////////////////////////////////////////////////////
518 # OVERRIDE METHODS
519 # ///////////////////////////////////////////////////////////////
521 def resizeEvent(self, event: QResizeEvent) -> None:
522 """Handle resize events.
524 Args:
525 event: The resize event.
526 """
527 super().resizeEvent(event)
528 self.update()
530 def minimumSizeHint(self) -> QSize:
531 """Get the minimum size hint for the widget.
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())
540 # ///////////////////////////////////////////////////////////////
541 # STYLE METHODS
542 # ///////////////////////////////////////////////////////////////
544 def refreshStyle(self) -> None:
545 """Refresh the widget's style.
547 Useful after dynamic stylesheet changes.
548 """
549 self.style().unpolish(self)
550 self.style().polish(self)
551 self.update()
554# ///////////////////////////////////////////////////////////////
555# PUBLIC API
556# ///////////////////////////////////////////////////////////////
558__all__ = ["HoverLabel"]