"""Notification system widgets."""
from __future__ import annotations
from enum import Enum
from typing import TYPE_CHECKING, Any
from PySide6.QtCore import QObject, QPropertyAnimation, Qt, QTimer, Signal
from PySide6.QtGui import QFont
from PySide6.QtWidgets import QFrame, QHBoxLayout, QLabel, QVBoxLayout
from qtframework.widgets.buttons import CloseButton
if TYPE_CHECKING:
from collections.abc import Callable
from PySide6.QtWidgets import QWidget
[docs]
class NotificationType(Enum):
"""Notification types."""
INFO = "info"
SUCCESS = "success"
WARNING = "warning"
ERROR = "error"
[docs]
class NotificationPosition(Enum):
"""Notification position."""
TOP_LEFT = "top_left"
TOP_CENTER = "top_center"
TOP_RIGHT = "top_right"
BOTTOM_LEFT = "bottom_left"
BOTTOM_CENTER = "bottom_center"
BOTTOM_RIGHT = "bottom_right"
[docs]
class Notification(QFrame):
"""Notification widget."""
closed = Signal()
clicked = Signal()
def __init__(
self,
title: str = "",
message: str = "",
notification_type: NotificationType = NotificationType.INFO,
duration: int = 5000,
closable: bool = True,
parent: QWidget | None = None,
) -> None:
"""Initialize notification.
Args:
title: Notification title
message: Notification message
notification_type: Type of notification
duration: Display duration in ms (0 for persistent)
closable: Show close button
parent: Parent widget
"""
super().__init__(parent)
self._title = title
self._message = message
self._type = notification_type
self._duration = duration
self._closable = closable
self._setup_ui()
self._apply_style()
if duration > 0:
QTimer.singleShot(duration, self.close)
def _setup_ui(self) -> None:
"""Setup UI components."""
self.setFrameStyle(QFrame.Shape.Box)
self.setWindowFlags(Qt.WindowType.FramelessWindowHint | Qt.WindowType.WindowStaysOnTopHint)
# Removed WA_TranslucentBackground to ensure background is visible
self.setAutoFillBackground(True)
layout = QHBoxLayout(self)
layout.setContentsMargins(12, 8, 12, 8)
layout.setSpacing(8)
# Icon
icon_label = QLabel()
icon_text = {
NotificationType.INFO: "ℹ",
NotificationType.SUCCESS: "✓",
NotificationType.WARNING: "⚠",
NotificationType.ERROR: "✕",
}
icon_label.setText(icon_text.get(self._type, ""))
icon_label.setStyleSheet("font-size: 16px; font-weight: bold; color: white;")
layout.addWidget(icon_label)
# Content
content_layout = QVBoxLayout()
content_layout.setSpacing(2)
if self._title:
title_label = QLabel(self._title)
title_font = QFont()
title_font.setBold(True)
title_font.setPointSize(9)
title_label.setFont(title_font)
content_layout.addWidget(title_label)
if self._message:
message_label = QLabel(self._message)
message_label.setWordWrap(True)
message_font = QFont()
message_font.setPointSize(8)
message_label.setFont(message_font)
content_layout.addWidget(message_label)
layout.addLayout(content_layout, 1)
# Close button
if self._closable:
close_btn = CloseButton(size=16, style="light")
close_btn.clicked.connect(self.close)
layout.addWidget(close_btn, 0, Qt.AlignmentFlag.AlignTop)
def _apply_style(self) -> None:
"""Apply style based on notification type."""
# Get current theme from application if available
from PySide6.QtWidgets import QApplication
app = QApplication.instance()
theme_name = "light"
if app and hasattr(app, "theme_manager"):
current_theme = app.theme_manager.get_current_theme()
if current_theme:
theme_name = current_theme.name
# Use basic colors for notification types
if "dark" in theme_name or "monokai" in theme_name:
# Dark theme colors
color_map = {
NotificationType.INFO: "#2196f3",
NotificationType.SUCCESS: "#4caf50",
NotificationType.WARNING: "#ff9800",
NotificationType.ERROR: "#f44336",
}
text_color = "#ffffff"
else:
# Light theme colors
color_map = {
NotificationType.INFO: "#0288d1",
NotificationType.SUCCESS: "#388e3c",
NotificationType.WARNING: "#f57c00",
NotificationType.ERROR: "#d32f2f",
}
text_color = "#ffffff"
bg_color = color_map.get(self._type, "#2196f3")
# Simple consistent styling
self.setStyleSheet(f"""
Notification {{
background-color: {bg_color};
border: none;
border-radius: 6px;
min-width: 280px;
max-width: 450px;
padding: 8px;
}}
QLabel {{
color: {text_color};
background: transparent;
padding: 0px;
margin: 0px;
}}
""")
[docs]
def show_at(self, position: NotificationPosition, offset: tuple[int, int] = (20, 20)) -> None:
"""Show notification at specific position.
Args:
position: Position to show at
offset: Offset from edge (x, y)
"""
self.adjustSize() # Ensure proper sizing
self.show()
self.raise_()
parent = self.parentWidget()
if parent is None:
return
parent_rect = parent.rect()
x, y = offset
if position == NotificationPosition.TOP_LEFT:
self.move(x, y)
elif position == NotificationPosition.TOP_CENTER:
self.move((parent_rect.width() - self.width()) // 2, y)
elif position == NotificationPosition.TOP_RIGHT:
self.move(parent_rect.width() - self.width() - x, y)
elif position == NotificationPosition.BOTTOM_LEFT:
self.move(x, parent_rect.height() - self.height() - y)
elif position == NotificationPosition.BOTTOM_CENTER:
self.move(
(parent_rect.width() - self.width()) // 2,
parent_rect.height() - self.height() - y,
)
elif position == NotificationPosition.BOTTOM_RIGHT:
self.move(
parent_rect.width() - self.width() - x,
parent_rect.height() - self.height() - y,
)
[docs]
def animate_in(self) -> None:
"""Animate notification appearance."""
from PySide6.QtCore import QEasingCurve
from PySide6.QtWidgets import QGraphicsOpacityEffect
# Create opacity effect
self._opacity_effect = QGraphicsOpacityEffect()
self.setGraphicsEffect(self._opacity_effect)
# Animate opacity from 0 to 1
self._animation = QPropertyAnimation(self._opacity_effect, b"opacity")
self._animation.setDuration(200)
self._animation.setStartValue(0.0)
self._animation.setEndValue(1.0)
self._animation.setEasingCurve(QEasingCurve.Type.OutCubic)
self._animation.start()
[docs]
def animate_out(self) -> None:
"""Animate notification disappearance."""
from PySide6.QtCore import QEasingCurve
if hasattr(self, "_opacity_effect"):
# Animate opacity from 1 to 0
self._animation = QPropertyAnimation(self._opacity_effect, b"opacity")
self._animation.setDuration(200)
self._animation.setStartValue(1.0)
self._animation.setEndValue(0.0)
self._animation.setEasingCurve(QEasingCurve.Type.InCubic)
self._animation.finished.connect(self.deleteLater)
self._animation.start()
else:
# If no opacity effect, just delete
self.deleteLater()
[docs]
def close(self) -> bool:
"""Close notification."""
self.closed.emit()
self.animate_out()
return True
[docs]
def mousePressEvent(self, event: Any) -> None:
"""Handle mouse press.
Args:
event: Mouse event
"""
self.clicked.emit()
super().mousePressEvent(event)
[docs]
class NotificationManager(QObject):
"""Manager for handling notifications."""
def __init__(self, parent: QWidget | None = None) -> None:
"""Initialize notification manager.
Args:
parent: Parent widget
"""
super().__init__(parent)
self._parent = parent
self._notifications: list[Notification] = []
self._position = NotificationPosition.TOP_RIGHT
self._offset = (20, 20)
self._spacing = 10
self._max_notifications = 5
[docs]
def set_position(self, position: NotificationPosition) -> None:
"""Set default notification position.
Args:
position: Notification position
"""
self._position = position
[docs]
def set_offset(self, x: int, y: int) -> None:
"""Set notification offset.
Args:
x: X offset
y: Y offset
"""
self._offset = (x, y)
[docs]
def notify(
self,
title: str = "",
message: str = "",
notification_type: NotificationType = NotificationType.INFO,
duration: int = 5000,
closable: bool = True,
on_click: Callable[[], None] | None = None,
) -> Notification:
"""Show a notification.
Args:
title: Notification title
message: Notification message
notification_type: Type of notification
duration: Display duration
closable: Show close button
on_click: Click callback
Returns:
Notification widget
"""
# Remove oldest if at max
if len(self._notifications) >= self._max_notifications:
oldest = self._notifications.pop(0)
oldest.close()
notification = Notification(
title=title,
message=message,
notification_type=notification_type,
duration=duration,
closable=closable,
parent=self._parent,
)
if on_click:
notification.clicked.connect(on_click)
notification.closed.connect(lambda: self._remove_notification(notification))
self._notifications.append(notification)
self._position_notifications()
notification.show()
notification.animate_in()
return notification
def _remove_notification(self, notification: Notification) -> None:
"""Remove notification from list.
Args:
notification: Notification to remove
"""
if notification in self._notifications:
self._notifications.remove(notification)
self._position_notifications()
def _position_notifications(self) -> None:
"""Reposition all notifications."""
if not self._parent:
return
x, y = self._offset
current_y = y
for notification in self._notifications:
if self._position in {
NotificationPosition.TOP_LEFT,
NotificationPosition.TOP_CENTER,
NotificationPosition.TOP_RIGHT,
}:
notification.show_at(self._position, (x, current_y))
current_y += notification.height() + self._spacing
else:
# Bottom positions - stack upward
notification.show_at(self._position, (x, current_y))
current_y += notification.height() + self._spacing
[docs]
def info(self, title: str, message: str, **kwargs: Any) -> Notification:
"""Show info notification."""
return self.notify(title, message, NotificationType.INFO, **kwargs)
[docs]
def success(self, title: str, message: str, **kwargs: Any) -> Notification:
"""Show success notification."""
return self.notify(title, message, NotificationType.SUCCESS, **kwargs)
[docs]
def warning(self, title: str, message: str, **kwargs: Any) -> Notification:
"""Show warning notification."""
return self.notify(title, message, NotificationType.WARNING, **kwargs)
[docs]
def error(self, title: str, message: str, **kwargs: Any) -> Notification:
"""Show error notification."""
return self.notify(title, message, NotificationType.ERROR, **kwargs)
[docs]
def clear_all(self) -> None:
"""Clear all notifications."""
for notification in self._notifications[:]:
notification.close()