Coverage for src / ezqt_widgets / widgets / misc / notification_banner.py: 83.33%
146 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# NOTIFICATION_BANNER - Notification Banner Widget
3# Project: ezqt_widgets
4# ///////////////////////////////////////////////////////////////
6"""
7Notification banner widget module.
9Provides an animated slide-down notification banner that overlays the top
10of a parent widget, supporting INFO, WARNING, ERROR, and SUCCESS levels
11with theme-aware icons and auto-dismiss behavior.
12"""
14from __future__ import annotations
16# ///////////////////////////////////////////////////////////////
17# IMPORTS
18# ///////////////////////////////////////////////////////////////
19# Standard library imports
20import base64
21import contextlib
22from enum import Enum
24# Third-party imports
25from PySide6.QtCore import (
26 QByteArray,
27 QEasingCurve,
28 QEvent,
29 QPropertyAnimation,
30 QRect,
31 QSize,
32 Qt,
33 QTimer,
34 Signal,
35)
36from PySide6.QtGui import QIcon, QPainter, QPixmap
37from PySide6.QtSvg import QSvgRenderer
38from PySide6.QtWidgets import (
39 QHBoxLayout,
40 QLabel,
41 QSizePolicy,
42 QToolButton,
43 QWidget,
44)
46# Local imports
47from ..shared import SVG_ERROR, SVG_INFO, SVG_SUCCESS, SVG_WARNING
48from .theme_icon import ThemeIcon
50# ///////////////////////////////////////////////////////////////
51# CONSTANTS
52# ///////////////////////////////////////////////////////////////
54_BANNER_HEIGHT: int = 48
56_LEVEL_COLORS: dict[str, str] = {
57 "INFO": "#3b82f6",
58 "WARNING": "#f59e0b",
59 "ERROR": "#ef4444",
60 "SUCCESS": "#22c55e",
61}
63_LEVEL_SVG: dict[str, bytes] = {
64 "INFO": SVG_INFO,
65 "WARNING": SVG_WARNING,
66 "ERROR": SVG_ERROR,
67 "SUCCESS": SVG_SUCCESS,
68}
70# ///////////////////////////////////////////////////////////////
71# CLASSES
72# ///////////////////////////////////////////////////////////////
75class NotificationLevel(Enum):
76 """Severity level for a notification banner.
78 Attributes:
79 INFO: Informational message (blue).
80 WARNING: Warning message (amber).
81 ERROR: Error message (red).
82 SUCCESS: Success message (green).
83 """
85 INFO = "INFO"
86 WARNING = "WARNING"
87 ERROR = "ERROR"
88 SUCCESS = "SUCCESS"
91class NotificationBanner(QWidget):
92 """Animated slide-down notification banner overlaying a parent widget.
94 The banner slides in from the top of its parent widget and can
95 auto-dismiss after a configurable duration. A close button is always
96 visible for manual dismissal. The banner repositions itself when the
97 parent is resized via event filtering.
99 Features:
100 - Slide-down animation via QPropertyAnimation on geometry
101 - Four severity levels: INFO, WARNING, ERROR, SUCCESS
102 - Auto-dismiss via QTimer when duration > 0
103 - Manual close button (×)
104 - Level icon via inline SVG rendered to ThemeIcon
105 - Parent resize tracking via event filter
107 Args:
108 parent: The parent widget inside which the banner is displayed.
109 Must be a valid QWidget (not None).
111 Signals:
112 dismissed(): Emitted when the banner is hidden (any cause).
114 Example:
115 >>> from ezqt_widgets import NotificationBanner, NotificationLevel
116 >>> banner = NotificationBanner(parent=main_widget)
117 >>> banner.dismissed.connect(lambda: print("Banner closed"))
118 >>> banner.showNotification("File saved!", NotificationLevel.SUCCESS)
119 """
121 dismissed = Signal()
123 # ///////////////////////////////////////////////////////////////
124 # INIT
125 # ///////////////////////////////////////////////////////////////
127 def __init__(self, parent: QWidget) -> None:
128 """Initialize the notification banner.
130 Args:
131 parent: The parent widget that hosts the banner overlay.
132 """
133 super().__init__(parent)
134 self.setProperty("type", "NotificationBanner")
136 # Initialize private state
137 self._duration: int = 3000
138 self._dismiss_timer: QTimer | None = None
139 self._animation: QPropertyAnimation | None = None
141 # Build UI before hiding
142 self._setup_widget()
144 # Start hidden
145 self.setGeometry(0, 0, parent.width(), 0)
146 self.hide()
148 # Install event filter on parent to track resizes
149 parent.installEventFilter(self)
151 # ------------------------------------------------
152 # PRIVATE METHODS
153 # ------------------------------------------------
155 def _setup_widget(self) -> None:
156 """Setup the widget layout and child components."""
157 self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
158 self.setFixedHeight(0) # Hidden initially
160 # Icon label
161 self._icon_label = QLabel()
162 self._icon_label.setFixedSize(QSize(20, 20))
163 self._icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
165 # Message label
166 self._message_label = QLabel()
167 self._message_label.setStyleSheet(
168 "color: white; font-weight: 500; background: transparent;"
169 )
170 self._message_label.setSizePolicy(
171 QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed
172 )
173 self._message_label.setWordWrap(False)
175 # Close button
176 self._close_btn = QToolButton()
177 self._close_btn.setText("×")
178 self._close_btn.setFixedSize(QSize(24, 24))
179 self._close_btn.setStyleSheet(
180 "QToolButton { color: white; border: none; font-size: 16px; "
181 "background: transparent; } "
182 "QToolButton:hover { background: rgba(255,255,255,0.2); border-radius: 4px; }"
183 )
184 self._close_btn.clicked.connect(self._dismiss)
186 # Layout
187 layout = QHBoxLayout(self)
188 layout.setContentsMargins(12, 8, 8, 8)
189 layout.setSpacing(8)
190 layout.addWidget(self._icon_label)
191 layout.addWidget(self._message_label)
192 layout.addStretch()
193 layout.addWidget(self._close_btn)
195 # Animation target property: geometry
196 self._animation = QPropertyAnimation(self, b"geometry")
197 self._animation.setDuration(250)
198 self._animation.setEasingCurve(QEasingCurve.Type.OutCubic)
200 @staticmethod
201 def _build_icon(level: NotificationLevel) -> ThemeIcon | None:
202 """Build a ThemeIcon from the inline SVG for the given level.
204 Args:
205 level: The notification level.
207 Returns:
208 A ThemeIcon with white coloring, or None on failure.
209 """
210 svg_bytes = _LEVEL_SVG.get(level.value, SVG_INFO)
211 encoded = base64.b64encode(svg_bytes)
212 decoded = base64.b64decode(encoded)
213 renderer = QSvgRenderer(QByteArray(decoded))
214 if not renderer.isValid(): 214 ↛ 215line 214 didn't jump to line 215 because the condition on line 214 was never true
215 return None
217 pixmap = QPixmap(QSize(16, 16))
218 pixmap.fill(Qt.GlobalColor.transparent)
219 painter = QPainter(pixmap)
220 renderer.render(painter)
221 painter.end()
223 icon = QIcon(pixmap)
224 return ThemeIcon.from_source(icon)
226 def _apply_level_style(self, level: NotificationLevel) -> None:
227 """Apply background color and icon for the given level.
229 Args:
230 level: The notification level to apply.
231 """
232 color = _LEVEL_COLORS.get(level.value, "#3b82f6")
233 self.setStyleSheet(
234 f"NotificationBanner {{ background-color: {color}; border-radius: 0px; }}"
235 )
237 icon = self._build_icon(level)
238 if icon is not None: 238 ↛ 241line 238 didn't jump to line 241 because the condition on line 238 was always true
239 self._icon_label.setPixmap(icon.pixmap(QSize(16, 16)))
240 else:
241 self._icon_label.clear()
243 def _slide_in(self) -> None:
244 """Animate the banner sliding down to full height."""
245 if self._animation is None: 245 ↛ 246line 245 didn't jump to line 246 because the condition on line 245 was never true
246 return
247 parent = self.parentWidget()
248 if parent is None: 248 ↛ 249line 248 didn't jump to line 249 because the condition on line 248 was never true
249 return
251 start_rect = QRect(0, 0, parent.width(), 0)
252 end_rect = QRect(0, 0, parent.width(), _BANNER_HEIGHT)
254 self.setGeometry(start_rect)
255 self.show()
256 self.raise_()
258 self._animation.setStartValue(start_rect)
259 self._animation.setEndValue(end_rect)
260 self._animation.start()
262 def _slide_out(self) -> None:
263 """Animate the banner sliding up and then emit dismissed."""
264 animation = self._animation
265 if animation is None: 265 ↛ 266line 265 didn't jump to line 266 because the condition on line 265 was never true
266 self._finish_dismiss()
267 return
268 parent = self.parentWidget()
269 if parent is None: 269 ↛ 270line 269 didn't jump to line 270 because the condition on line 269 was never true
270 self._finish_dismiss()
271 return
273 current = self.geometry()
274 end_rect = QRect(0, 0, parent.width(), 0)
276 animation.setStartValue(current)
277 animation.setEndValue(end_rect)
278 animation.finished.connect(self._finish_dismiss)
279 animation.start()
281 def _finish_dismiss(self) -> None:
282 """Hide the widget and emit the dismissed signal."""
283 # Disconnect to avoid cumulative connections on next show
284 animation = self._animation
285 if animation is None: 285 ↛ 286line 285 didn't jump to line 286 because the condition on line 285 was never true
286 self.hide()
287 self.dismissed.emit()
288 return
289 with contextlib.suppress(RuntimeError):
290 animation.finished.disconnect(self._finish_dismiss)
291 self.hide()
292 self.dismissed.emit()
294 def _stop_timer(self) -> None:
295 """Stop the auto-dismiss timer if active."""
296 if self._dismiss_timer is not None: 296 ↛ 297line 296 didn't jump to line 297 because the condition on line 296 was never true
297 self._dismiss_timer.stop()
298 self._dismiss_timer.deleteLater()
299 self._dismiss_timer = None
301 def _dismiss(self) -> None:
302 """Dismiss the banner with slide-out animation."""
303 self._stop_timer()
304 self._slide_out()
306 # ///////////////////////////////////////////////////////////////
307 # PUBLIC METHODS
308 # ///////////////////////////////////////////////////////////////
310 def showNotification(
311 self,
312 message: str,
313 level: NotificationLevel = NotificationLevel.INFO,
314 duration: int = 3000,
315 ) -> None:
316 """Display a notification banner with the given message and level.
318 Args:
319 message: The text to display in the banner.
320 level: The severity level (default: NotificationLevel.INFO).
321 duration: Display duration in milliseconds. Use 0 for a
322 permanent banner that requires manual dismissal
323 (default: 3000).
324 """
325 self._stop_timer()
326 self._duration = duration
328 self._message_label.setText(message)
329 self._apply_level_style(level)
330 self._slide_in()
332 if duration > 0:
333 self._dismiss_timer = QTimer(self)
334 self._dismiss_timer.setSingleShot(True)
335 self._dismiss_timer.timeout.connect(self._dismiss)
336 self._dismiss_timer.start(duration)
338 # ///////////////////////////////////////////////////////////////
339 # EVENT HANDLERS
340 # ///////////////////////////////////////////////////////////////
342 def eventFilter(self, obj: object, event: QEvent) -> bool:
343 """Track parent resize events to reposition the banner.
345 Args:
346 obj: The object that generated the event.
347 event: The event.
349 Returns:
350 False to allow normal event propagation.
351 """
352 if obj is self.parentWidget() and event.type() == QEvent.Type.Resize: 352 ↛ 353line 352 didn't jump to line 353 because the condition on line 352 was never true
353 parent = self.parentWidget()
354 if parent is not None and self.isVisible():
355 self.setGeometry(0, 0, parent.width(), _BANNER_HEIGHT)
356 return False
358 # ///////////////////////////////////////////////////////////////
359 # STYLE METHODS
360 # ///////////////////////////////////////////////////////////////
362 def refreshStyle(self) -> None:
363 """Refresh the widget style.
365 Useful after dynamic stylesheet changes.
366 """
367 self.style().unpolish(self)
368 self.style().polish(self)
369 self.update()
372# ///////////////////////////////////////////////////////////////
373# PUBLIC API
374# ///////////////////////////////////////////////////////////////
376__all__ = ["NotificationBanner", "NotificationLevel"]