Coverage for src / ezqt_widgets / widgets / misc / notification_banner.py: 85.00%

140 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-03-31 10:03 +0000

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

2# NOTIFICATION_BANNER - Notification Banner Widget 

3# Project: ezqt_widgets 

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

5 

6""" 

7Notification banner widget module. 

8 

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

13 

14from __future__ import annotations 

15 

16# /////////////////////////////////////////////////////////////// 

17# IMPORTS 

18# /////////////////////////////////////////////////////////////// 

19# Standard library imports 

20import base64 

21import contextlib 

22from enum import Enum 

23 

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) 

45 

46# Local imports 

47from ..shared import SVG_ERROR, SVG_INFO, SVG_SUCCESS, SVG_WARNING 

48from .theme_icon import ThemeIcon 

49 

50# /////////////////////////////////////////////////////////////// 

51# CONSTANTS 

52# /////////////////////////////////////////////////////////////// 

53 

54_BANNER_HEIGHT: int = 48 

55 

56_LEVEL_COLORS: dict[str, str] = { 

57 "INFO": "#3b82f6", 

58 "WARNING": "#f59e0b", 

59 "ERROR": "#ef4444", 

60 "SUCCESS": "#22c55e", 

61} 

62 

63_LEVEL_SVG: dict[str, bytes] = { 

64 "INFO": SVG_INFO, 

65 "WARNING": SVG_WARNING, 

66 "ERROR": SVG_ERROR, 

67 "SUCCESS": SVG_SUCCESS, 

68} 

69 

70# /////////////////////////////////////////////////////////////// 

71# CLASSES 

72# /////////////////////////////////////////////////////////////// 

73 

74 

75class NotificationLevel(Enum): 

76 """Severity level for a notification banner. 

77 

78 Attributes: 

79 INFO: Informational message (blue). 

80 WARNING: Warning message (amber). 

81 ERROR: Error message (red). 

82 SUCCESS: Success message (green). 

83 """ 

84 

85 INFO = "INFO" 

86 WARNING = "WARNING" 

87 ERROR = "ERROR" 

88 SUCCESS = "SUCCESS" 

89 

90 

91class NotificationBanner(QWidget): 

92 """Animated slide-down notification banner overlaying a parent widget. 

93 

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. 

98 

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 

106 

107 Args: 

108 parent: The parent widget inside which the banner is displayed. 

109 Must be a valid QWidget (not None). 

110 

111 Signals: 

112 dismissed(): Emitted when the banner is hidden (any cause). 

113 

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

120 

121 dismissed = Signal() 

122 

123 # /////////////////////////////////////////////////////////////// 

124 # INIT 

125 # /////////////////////////////////////////////////////////////// 

126 

127 def __init__(self, parent: QWidget) -> None: 

128 """Initialize the notification banner. 

129 

130 Args: 

131 parent: The parent widget that hosts the banner overlay. 

132 """ 

133 super().__init__(parent) 

134 self.setProperty("type", "NotificationBanner") 

135 

136 # Initialize private state 

137 self._duration: int = 3000 

138 self._dismiss_timer: QTimer | None = None 

139 self._animation: QPropertyAnimation | None = None 

140 

141 # Build UI before hiding 

142 self._setup_widget() 

143 

144 # Start hidden 

145 self.setGeometry(0, 0, parent.width(), 0) 

146 self.hide() 

147 

148 # Install event filter on parent to track resizes 

149 parent.installEventFilter(self) 

150 

151 # ------------------------------------------------ 

152 # PRIVATE METHODS 

153 # ------------------------------------------------ 

154 

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 

159 

160 # Icon label 

161 self._icon_label = QLabel() 

162 self._icon_label.setFixedSize(QSize(20, 20)) 

163 self._icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter) 

164 

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) 

174 

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) 

185 

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) 

194 

195 # Animation target property: geometry 

196 self._animation = QPropertyAnimation(self, b"geometry") 

197 self._animation.setDuration(250) 

198 self._animation.setEasingCurve(QEasingCurve.Type.OutCubic) 

199 

200 @staticmethod 

201 def _build_icon(level: NotificationLevel) -> ThemeIcon | None: 

202 """Build a ThemeIcon from the inline SVG for the given level. 

203 

204 Args: 

205 level: The notification level. 

206 

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 

216 

217 pixmap = QPixmap(QSize(16, 16)) 

218 pixmap.fill(Qt.GlobalColor.transparent) 

219 painter = QPainter(pixmap) 

220 renderer.render(painter) 

221 painter.end() 

222 

223 icon = QIcon(pixmap) 

224 return ThemeIcon.from_source(icon) 

225 

226 def _apply_level_style(self, level: NotificationLevel) -> None: 

227 """Apply background color and icon for the given level. 

228 

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 ) 

236 

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

242 

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 

250 

251 start_rect = QRect(0, 0, parent.width(), 0) 

252 end_rect = QRect(0, 0, parent.width(), _BANNER_HEIGHT) 

253 

254 self.setGeometry(start_rect) 

255 self.show() 

256 self.raise_() 

257 

258 self._animation.setStartValue(start_rect) 

259 self._animation.setEndValue(end_rect) 

260 self._animation.start() 

261 

262 def _slide_out(self) -> None: 

263 """Animate the banner sliding up and then emit dismissed.""" 

264 if self._animation is None: 264 ↛ 265line 264 didn't jump to line 265 because the condition on line 264 was never true

265 self._finish_dismiss() 

266 return 

267 parent = self.parentWidget() 

268 if parent is None: 268 ↛ 269line 268 didn't jump to line 269 because the condition on line 268 was never true

269 self._finish_dismiss() 

270 return 

271 

272 current = self.geometry() 

273 end_rect = QRect(0, 0, parent.width(), 0) 

274 

275 self._animation.setStartValue(current) 

276 self._animation.setEndValue(end_rect) 

277 self._animation.finished.connect(self._finish_dismiss) 

278 self._animation.start() 

279 

280 def _finish_dismiss(self) -> None: 

281 """Hide the widget and emit the dismissed signal.""" 

282 # Disconnect to avoid cumulative connections on next show 

283 with contextlib.suppress(RuntimeError): 

284 self._animation.finished.disconnect(self._finish_dismiss) # type: ignore[union-attr] 

285 self.hide() 

286 self.dismissed.emit() 

287 

288 def _stop_timer(self) -> None: 

289 """Stop the auto-dismiss timer if active.""" 

290 if self._dismiss_timer is not None: 290 ↛ 291line 290 didn't jump to line 291 because the condition on line 290 was never true

291 self._dismiss_timer.stop() 

292 self._dismiss_timer.deleteLater() 

293 self._dismiss_timer = None 

294 

295 def _dismiss(self) -> None: 

296 """Dismiss the banner with slide-out animation.""" 

297 self._stop_timer() 

298 self._slide_out() 

299 

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

301 # PUBLIC METHODS 

302 # /////////////////////////////////////////////////////////////// 

303 

304 def showNotification( 

305 self, 

306 message: str, 

307 level: NotificationLevel = NotificationLevel.INFO, 

308 duration: int = 3000, 

309 ) -> None: 

310 """Display a notification banner with the given message and level. 

311 

312 Args: 

313 message: The text to display in the banner. 

314 level: The severity level (default: NotificationLevel.INFO). 

315 duration: Display duration in milliseconds. Use 0 for a 

316 permanent banner that requires manual dismissal 

317 (default: 3000). 

318 """ 

319 self._stop_timer() 

320 self._duration = duration 

321 

322 self._message_label.setText(message) 

323 self._apply_level_style(level) 

324 self._slide_in() 

325 

326 if duration > 0: 

327 self._dismiss_timer = QTimer(self) 

328 self._dismiss_timer.setSingleShot(True) 

329 self._dismiss_timer.timeout.connect(self._dismiss) 

330 self._dismiss_timer.start(duration) 

331 

332 # /////////////////////////////////////////////////////////////// 

333 # EVENT HANDLERS 

334 # /////////////////////////////////////////////////////////////// 

335 

336 def eventFilter(self, obj: object, event: QEvent) -> bool: 

337 """Track parent resize events to reposition the banner. 

338 

339 Args: 

340 obj: The object that generated the event. 

341 event: The event. 

342 

343 Returns: 

344 False to allow normal event propagation. 

345 """ 

346 if obj is self.parentWidget() and event.type() == QEvent.Type.Resize: 346 ↛ 347line 346 didn't jump to line 347 because the condition on line 346 was never true

347 parent = self.parentWidget() 

348 if parent is not None and self.isVisible(): 

349 self.setGeometry(0, 0, parent.width(), _BANNER_HEIGHT) 

350 return False 

351 

352 # /////////////////////////////////////////////////////////////// 

353 # STYLE METHODS 

354 # /////////////////////////////////////////////////////////////// 

355 

356 def refreshStyle(self) -> None: 

357 """Refresh the widget style. 

358 

359 Useful after dynamic stylesheet changes. 

360 """ 

361 self.style().unpolish(self) 

362 self.style().polish(self) 

363 self.update() 

364 

365 

366# /////////////////////////////////////////////////////////////// 

367# PUBLIC API 

368# /////////////////////////////////////////////////////////////// 

369 

370__all__ = ["NotificationBanner", "NotificationLevel"]