toast_notification.py

 avatar
fperson
python
10 months ago
17 kB
20
Indexable
# toast_notification.py
# code FPERSON
"""
ToastNotification - Eleganckie powiadomienia dla aplikacji PySide6

=== PODSTAWOWE UŻYCIE ===

from toast_notification import ToastNotification

# Prosty toast
ToastNotification("Operacja zakończona!", parent=self)

# Toast z typem
ToastNotification("Zapisano pomyślnie!", parent=self, 
                 toast_type=ToastNotification.SUCCESS)

# Toast z pozycją
ToastNotification("Nowe powiadomienie", parent=self,
                 position=ToastNotification.TOP_RIGHT,
                 toast_type=ToastNotification.INFO)

# Toast z własnym czasem trwania (w milisekundach)
ToastNotification("Ważna wiadomość", parent=self,
                 duration=5000,  # 5 sekund
                 toast_type=ToastNotification.WARNING)


=== TYPY TOASTÓW ===

ToastNotification.SUCCESS   # ✓ Zielony - operacje zakończone sukcesem
ToastNotification.ERROR     # ✗ Czerwony - błędy
ToastNotification.WARNING   # ⚠ Pomarańczowy - ostrzeżenia
ToastNotification.INFO      # ℹ Niebieski - informacje (domyślny)


=== POZYCJE ===

ToastNotification.TOP_LEFT          # Górny lewy róg
ToastNotification.TOP_CENTER        # Górny środek (domyślne)
ToastNotification.TOP_RIGHT         # Górny prawy róg
ToastNotification.BOTTOM_LEFT       # Dolny lewy róg
ToastNotification.BOTTOM_CENTER     # Dolny środek
ToastNotification.BOTTOM_RIGHT      # Dolny prawy róg
ToastNotification.CENTER            # Środek ekranu


=== PEŁNY PRZYKŁAD W APLIKACJI ===

from PySide6.QtWidgets import QApplication, QMainWindow, QPushButton
from toast_notification import ToastNotification

class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("Toast Demo")
        self.setGeometry(100, 100, 800, 600)
        
        # Przycisk testowy
        btn = QPushButton("Pokaż Toast", self)
        btn.clicked.connect(self.show_toast)
        self.setCentralWidget(btn)
    
    def show_toast(self):
        ToastNotification("Cześć! Jestem toast!", parent=self)
    
    def save_file(self):
        try:
            # Twój kod zapisu...
            ToastNotification("Plik zapisany!", parent=self,
                            toast_type=ToastNotification.SUCCESS,
                            position=ToastNotification.BOTTOM_RIGHT)
        except Exception as e:
            ToastNotification(f"Błąd: {e}", parent=self,
                            toast_type=ToastNotification.ERROR,
                            duration=5000)

if __name__ == "__main__":
    app = QApplication([])
    window = MainWindow()
    window.show()
    app.exec()


=== ZAAWANSOWANE UŻYCIE ===

# Wiele toastów naraz (automatycznie się stackują)
ToastNotification("Toast 1", parent=self)
ToastNotification("Toast 2", parent=self)
ToastNotification("Toast 3", parent=self)

# Długi toast z zawijaniem tekstu
ToastNotification(
    "To jest bardzo długa wiadomość, która automatycznie "
    "zawinie się do następnej linii, jeśli będzie za długa",
    parent=self,
    duration=6000
)

# Różne toasty w różnych miejscach jednocześnie
ToastNotification("Góra", parent=self, position=ToastNotification.TOP_CENTER)
ToastNotification("Dół", parent=self, position=ToastNotification.BOTTOM_CENTER)
ToastNotification("Prawo", parent=self, position=ToastNotification.TOP_RIGHT)


=== CECHY ===

✓ Automatyczne stackowanie wielu toastów
✓ Płynne animacje slide-in/slide-out
✓ Kliknięcie zamyka toast wcześniej
✓ Efekt hover (rozjaśnienie)
✓ Zawijanie długiego tekstu
✓ Zawsze na wierzchu aplikacji
✓ Przezroczyste tło z cieniem
"""

from PySide6.QtWidgets import QWidget, QLabel, QGraphicsOpacityEffect, QHBoxLayout, QGraphicsDropShadowEffect
from PySide6.QtCore import QTimer, QPropertyAnimation, QEasingCurve, Qt, QRect, QParallelAnimationGroup, QPoint
from PySide6.QtGui import QPainter, QColor, QFont


class ToastNotification(QWidget):
    """Konfigurowalny toast z różnymi pozycjami i czasami"""

    # Typy toastów
    SUCCESS = "success"
    ERROR = "error"
    INFO = "info"
    WARNING = "warning"

    # Pozycje
    TOP_RIGHT = "top_right"
    TOP_CENTER = "top_center"
    TOP_LEFT = "top_left"
    BOTTOM_RIGHT = "bottom_right"
    BOTTOM_CENTER = "bottom_center"
    BOTTOM_LEFT = "bottom_left"
    CENTER = "center"

    # Domyślne ustawienia
    DEFAULT_DURATION = 3500  # 3.5 sekundy
    DEFAULT_POSITION = TOP_CENTER

    # Globalny rejestr aktywnych toastów dla stackowania
    active_toasts = []

    def __init__(self, message, parent=None, duration=None, toast_type=INFO, position=None):
        super().__init__(parent)
        self.toast_type = toast_type
        self.duration = duration or self.DEFAULT_DURATION
        self.position = position or self.DEFAULT_POSITION

        self.setWindowFlags(Qt.FramelessWindowHint | Qt.Tool | Qt.WindowStaysOnTopHint)
        self.setAttribute(Qt.WA_TranslucentBackground)

        self.setup_ui(message)
        self.setup_animations()
        self.setup_shadow()

        # Dodaj do rejestru aktywnych toastów
        ToastNotification.active_toasts.append(self)

        # Pozycjonowanie z uwzględnieniem innych toastów
        if parent:
            self.position_toast(parent)

        # Kliknięcie żeby zamknąć
        self.mousePressEvent = self.close_on_click

        # Uruchom animację i timer
        self.show_toast()

    def setup_ui(self, message):
        """Konfiguruje interfejs toast"""
        layout = QHBoxLayout(self)
        layout.setContentsMargins(0, 0, 0, 0)

        # Ikona + wiadomość
        icon, colors = self.get_style_for_type()
        full_message = f"{icon}  {message}"

        self.label = QLabel(full_message)
        self.label.setAlignment(Qt.AlignCenter)
        self.label.setWordWrap(True)  # Zawijanie tekstu
        self.label.setMaximumWidth(400)  # Maksymalna szerokość
        self.label.setMinimumWidth(250)  # Minimalna szerokość

        # Ustaw styl na podstawie typu
        gradient_start, gradient_end, text_color = colors

        self.label.setStyleSheet(f"""
        QLabel {{
            color: {text_color};
            font-family: 'Segoe UI', 'SF Pro Display', sans-serif;
            font-weight: 600;
            font-size: 12px;
            background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
                        stop:0 {gradient_start}, stop:1 {gradient_end});
            border-radius: 10px;
            border: 1px solid rgba(255, 255, 255, 0.2);
            padding: 15px 20px;
            line-height: 1.3;
        }}
        """)

        layout.addWidget(self.label)

        # Dopasuj rozmiar toast do etykiety
        self.adjustSize()

    def get_style_for_type(self):
        """Zwraca ikonę i kolory dla typu toast"""
        styles = {
            self.SUCCESS: ("✓", ("rgba(39, 174, 96, 240)", "rgba(46, 204, 113, 240)", "white")),
            self.ERROR: ("✗", ("rgba(231, 76, 60, 240)", "rgba(192, 57, 43, 240)", "white")),
            self.WARNING: ("⚠", ("rgba(243, 156, 18, 240)", "rgba(230, 126, 34, 240)", "white")),
            self.INFO: ("ℹ", ("rgba(52, 152, 219, 240)", "rgba(41, 128, 185, 240)", "white"))
        }
        return styles.get(self.toast_type, styles[self.INFO])

    def position_toast(self, parent):
        """Pozycjonuje toast względem okna rodzica - POPRAWIONE"""
        # Pobierz globalną pozycję okna rodzica
        parent_global_pos = parent.mapToGlobal(QPoint(0, 0))
        parent_size = parent.size()

        # Stwórz rect z globalną pozycją
        parent_rect = QRect(
            parent_global_pos.x(),
            parent_global_pos.y(),
            parent_size.width(),
            parent_size.height()
        )

        self.adjustSize()
        toast_size = self.size()

        # Oblicz offset dla stackowanych toastów
        stack_offset = self.calculate_stack_offset()

        # Bazowe pozycje - WZGLĘDEM OKNA APLIKACJI
        positions = {
            self.TOP_CENTER: (
                parent_rect.x() + (parent_rect.width() - toast_size.width()) // 2,
                parent_rect.y() + 20 + stack_offset
            ),
            self.TOP_RIGHT: (
                parent_rect.x() + parent_rect.width() - toast_size.width() - 20,
                parent_rect.y() + 20 + stack_offset
            ),
            self.TOP_LEFT: (
                parent_rect.x() + 20,
                parent_rect.y() + 20 + stack_offset
            ),
            self.BOTTOM_CENTER: (
                parent_rect.x() + (parent_rect.width() - toast_size.width()) // 2,
                parent_rect.y() + parent_rect.height() - toast_size.height() - 20 - stack_offset
            ),
            self.BOTTOM_RIGHT: (
                parent_rect.x() + parent_rect.width() - toast_size.width() - 20,
                parent_rect.y() + parent_rect.height() - toast_size.height() - 20 - stack_offset
            ),
            self.BOTTOM_LEFT: (
                parent_rect.x() + 20,
                parent_rect.y() + parent_rect.height() - toast_size.height() - 20 - stack_offset
            ),
            self.CENTER: (
                parent_rect.x() + (parent_rect.width() - toast_size.width()) // 2,
                parent_rect.y() + (parent_rect.height() - toast_size.height()) // 2 + stack_offset
            )
        }

        target_x, target_y = positions.get(self.position, positions[self.TOP_CENTER])

        # Pozycje dla animacji w zależności od kierunku
        if self.position in [self.TOP_RIGHT, self.BOTTOM_RIGHT]:
            # Slide z prawej
            start_x = parent_rect.x() + parent_rect.width() + 50
            start_y = target_y
        elif self.position in [self.TOP_LEFT, self.BOTTOM_LEFT]:
            # Slide z lewej
            start_x = parent_rect.x() - toast_size.width() - 50
            start_y = target_y
        elif self.position in [self.TOP_CENTER]:
            # Slide z góry
            start_x = target_x
            start_y = parent_rect.y() - toast_size.height() - 50
        elif self.position in [self.BOTTOM_CENTER]:
            # Slide z dołu
            start_x = target_x
            start_y = parent_rect.y() + parent_rect.height() + 50
        else:  # CENTER
            # Fade in na miejscu
            start_x = target_x
            start_y = target_y

        self.start_rect = QRect(start_x, start_y, toast_size.width(), toast_size.height())
        self.target_rect = QRect(target_x, target_y, toast_size.width(), toast_size.height())

        self.setGeometry(self.start_rect)

    def calculate_stack_offset(self):
        """Oblicza offset dla stackowanych toastów tej samej pozycji"""
        offset = 0
        for toast in ToastNotification.active_toasts:
            if toast != self and toast.position == self.position:
                if self.position in [self.TOP_CENTER, self.TOP_LEFT, self.TOP_RIGHT]:
                    offset += toast.height() + 10  # Stack w dół
                elif self.position in [self.BOTTOM_CENTER, self.BOTTOM_LEFT, self.BOTTOM_RIGHT]:
                    offset += toast.height() + 10  # Stack w górę
                else:  # CENTER
                    offset += 30  # Lekkie przesunięcie
        return offset

    def setup_shadow(self):
        """Dodaje cień do toast"""
        shadow = QGraphicsDropShadowEffect()
        shadow.setBlurRadius(20)
        shadow.setXOffset(0)
        shadow.setYOffset(5)
        shadow.setColor(QColor(0, 0, 0, 60))
        self.setGraphicsEffect(shadow)

    def setup_animations(self):
        """Konfiguruje animacje"""
        # Grupa animacji dla pojawienia się
        self.show_group = QParallelAnimationGroup()

        # Animacja przezroczystości
        self.opacity_effect = QGraphicsOpacityEffect()
        self.opacity_animation = QPropertyAnimation(self.opacity_effect, b"opacity")
        self.opacity_animation.setDuration(500)
        self.opacity_animation.setEasingCurve(QEasingCurve.OutCubic)

        # Animacja pozycji
        self.pos_animation = QPropertyAnimation(self, b"geometry")
        self.pos_animation.setDuration(500)
        self.pos_animation.setEasingCurve(
            QEasingCurve.OutBack if self.position != self.CENTER else QEasingCurve.OutCubic)

        self.show_group.addAnimation(self.opacity_animation)
        if self.position != self.CENTER:  # CENTER nie ma animacji pozycji
            self.show_group.addAnimation(self.pos_animation)

        # Grupa animacji dla ukrycia
        self.hide_group = QParallelAnimationGroup()

        self.hide_opacity = QPropertyAnimation(self.opacity_effect, b"opacity")
        self.hide_opacity.setDuration(300)
        self.hide_opacity.setEasingCurve(QEasingCurve.InCubic)

        self.hide_pos = QPropertyAnimation(self, b"geometry")
        self.hide_pos.setDuration(300)
        self.hide_pos.setEasingCurve(QEasingCurve.InCubic)

        self.hide_group.addAnimation(self.hide_opacity)
        self.hide_group.addAnimation(self.hide_pos)

        # Timer do ukrycia
        self.timer = QTimer()
        self.timer.timeout.connect(self.hide_toast)
        self.timer.setSingleShot(True)

        self.hide_group.finished.connect(self.cleanup)

    def show_toast(self):
        """Pokazuje toast z animacją"""
        self.show()

        # Animacja opacity: 0 -> 1
        self.opacity_animation.setStartValue(0.0)
        self.opacity_animation.setEndValue(1.0)

        # Animacja pozycji: slide in
        if self.position != self.CENTER:
            self.pos_animation.setStartValue(self.start_rect)
            self.pos_animation.setEndValue(self.target_rect)

        # Ustaw efekt przezroczystości
        self.label.setGraphicsEffect(self.opacity_effect)

        # Uruchom animacje
        self.show_group.start()
        self.timer.start(self.duration)

    def hide_toast(self):
        """Ukrywa toast z animacją slide-out"""
        current_rect = self.geometry()

        # Pozycja ukrycia w zależności od pozycji
        if self.position in [self.TOP_RIGHT, self.BOTTOM_RIGHT]:
            hide_x = current_rect.x() + 350  # Slide out w prawo
            hide_y = current_rect.y()
        elif self.position in [self.TOP_LEFT, self.BOTTOM_LEFT]:
            hide_x = current_rect.x() - 350  # Slide out w lewo
            hide_y = current_rect.y()
        elif self.position in [self.TOP_CENTER]:
            hide_x = current_rect.x()
            hide_y = current_rect.y() - 100  # Slide out w górę
        elif self.position in [self.BOTTOM_CENTER]:
            hide_x = current_rect.x()
            hide_y = current_rect.y() + 100  # Slide out w dół
        else:  # CENTER i inne
            hide_x = current_rect.x() + 350
            hide_y = current_rect.y()

        hide_rect = QRect(hide_x, hide_y, current_rect.width(), current_rect.height())

        # Animacja opacity: 1 -> 0
        self.hide_opacity.setStartValue(1.0)
        self.hide_opacity.setEndValue(0.0)

        # Animacja pozycji: slide out
        self.hide_pos.setStartValue(current_rect)
        self.hide_pos.setEndValue(hide_rect)

        self.hide_group.start()

    def cleanup(self):
        """Czyści toast z rejestru i zamyka widget"""
        try:
            ToastNotification.active_toasts.remove(self)
        except ValueError:
            pass  # Toast już usunięty z listy

        self.close()
        self.deleteLater()

    def close_on_click(self, event):
        """Zamyka toast po kliknięciu"""
        self.timer.stop()  # Zatrzymaj automatyczne ukrywanie
        self.hide_toast()

    def enterEvent(self, event):
        """Efekt hover - lekko rozjaśnij"""
        if hasattr(self, 'label'):
            current_style = self.label.styleSheet()
            if "240)" in current_style:
                self.label.setStyleSheet(current_style.replace("240)", "255)"))

    def leaveEvent(self, event):
        """Przywróć normalny wygląd po hover"""
        if hasattr(self, 'label'):
            current_style = self.label.styleSheet()
            if "255)" in current_style:
                self.label.setStyleSheet(current_style.replace("255)", "240)"))
Editor is loading...
Leave a Comment