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

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

272 

273 current = self.geometry() 

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

275 

276 animation.setStartValue(current) 

277 animation.setEndValue(end_rect) 

278 animation.finished.connect(self._finish_dismiss) 

279 animation.start() 

280 

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

293 

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 

300 

301 def _dismiss(self) -> None: 

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

303 self._stop_timer() 

304 self._slide_out() 

305 

306 # /////////////////////////////////////////////////////////////// 

307 # PUBLIC METHODS 

308 # /////////////////////////////////////////////////////////////// 

309 

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. 

317 

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 

327 

328 self._message_label.setText(message) 

329 self._apply_level_style(level) 

330 self._slide_in() 

331 

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) 

337 

338 # /////////////////////////////////////////////////////////////// 

339 # EVENT HANDLERS 

340 # /////////////////////////////////////////////////////////////// 

341 

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

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

344 

345 Args: 

346 obj: The object that generated the event. 

347 event: The event. 

348 

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 

357 

358 # /////////////////////////////////////////////////////////////// 

359 # STYLE METHODS 

360 # /////////////////////////////////////////////////////////////// 

361 

362 def refreshStyle(self) -> None: 

363 """Refresh the widget style. 

364 

365 Useful after dynamic stylesheet changes. 

366 """ 

367 self.style().unpolish(self) 

368 self.style().polish(self) 

369 self.update() 

370 

371 

372# /////////////////////////////////////////////////////////////// 

373# PUBLIC API 

374# /////////////////////////////////////////////////////////////// 

375 

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