######################################################################################################################
# Copyright (C) 2017-2022 Spine project consortium
# Copyright Spine Toolbox contributors
# This file is part of Spine Toolbox.
# Spine Toolbox is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser General
# Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option)
# any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General
# Public License for more details. You should have received a copy of the GNU Lesser General Public License along with
# this program. If not, see <http://www.gnu.org/licenses/>.
######################################################################################################################
"""Contains a notification widget."""
from PySide6.QtWidgets import QFrame, QLabel, QHBoxLayout, QGraphicsOpacityEffect, QLayout, QSizePolicy, QPushButton
from PySide6.QtCore import Qt, Slot, QTimer, QPropertyAnimation, Property, QObject
from PySide6.QtGui import QFont, QColor
from spinetoolbox.helpers import color_from_index
[docs]class Notification(QFrame):
"""Custom pop-up notification widget with fade-in and fade-out effect."""
[docs] _FADE_IN_OUT_DURATION = 500
def __init__(
self, parent, txt, anim_duration=_FADE_IN_OUT_DURATION, life_span=None, word_wrap=True, corner=Qt.TopRightCorner
):
"""
Args:
parent (QWidget): Parent widget
txt (str): Text to display in notification
anim_duration (int): Duration of the animation in msecs
life_span (int): How long does the notification stays in place in msecs
word_wrap (bool)
corner (Qt.Corner)
"""
super().__init__()
if life_span is None:
word_count = len(txt.split(" "))
mspw = 60000 / 140 # Assume people can read ~140 words per minute
life_span = mspw * word_count
self.setFocusPolicy(Qt.NoFocus)
self.setWindowFlags(Qt.WindowType.Popup)
self.setParent(parent)
self._parent = parent
self._corner = corner
self.label = QLabel(txt)
self.label.setMaximumSize(parent.size())
self.label.setAlignment(Qt.AlignCenter)
self.label.setWordWrap(word_wrap)
self.label.setMargin(8)
self.label.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum)
font = QFont()
font.setBold(True)
self.label.setFont(font)
layout = QHBoxLayout()
layout.addWidget(self.label)
layout.setSizeConstraint(QLayout.SetMinimumSize)
layout.setContentsMargins(3, 3, 3, 3)
self.setLayout(layout)
self.adjustSize()
self.setAttribute(Qt.WA_DeleteOnClose)
self.setObjectName("Notification")
self._background_color = "#e6ffc2b3"
ss = (
"QFrame#Notification{"
f"background-color: {self._background_color};"
"border-width: 2px;"
"border-color: #ffebe6;"
"border-style: groove; border-radius: 8px;}"
)
self.setStyleSheet(ss)
self.setAcceptDrops(True)
self.effect = QGraphicsOpacityEffect()
self.setGraphicsEffect(self.effect)
self.effect.setOpacity(0.0)
self._opacity = 0.0
self.timer = QTimer(self)
self.timer.setInterval(life_span)
self.timer.timeout.connect(self.start_self_destruction)
# Fade in animation
self.fade_in_anim = QPropertyAnimation(self, b"opacity")
self.fade_in_anim.setDuration(anim_duration)
self.fade_in_anim.setStartValue(0.0)
self.fade_in_anim.setEndValue(1.0)
self.fade_in_anim.valueChanged.connect(self.update_opacity)
self.fade_in_anim.finished.connect(self.timer.start)
# Fade out animation
self.fade_out_anim = QPropertyAnimation(self, b"opacity")
self.fade_out_anim.setDuration(anim_duration)
self.fade_out_anim.setStartValue(1.0)
self.fade_out_anim.setEndValue(0)
self.fade_out_anim.valueChanged.connect(self.update_opacity)
self.fade_out_anim.finished.connect(self.close)
# Start fade in animation
self.fade_in_anim.start(QPropertyAnimation.DeleteWhenStopped)
[docs] def show(self):
"""Shows widget and moves it to the selected corner of the parent widget."""
super().show()
if self._corner in (Qt.TopRightCorner, Qt.BottomRightCorner):
x = self._parent.size().width() - self.width() - 2
else:
x = self.pos().x()
if self._corner in (Qt.BottomLeftCorner, Qt.BottomRightCorner):
y = self._parent.size().height() - self.height() - 2
else:
y = self.pos().y()
self.move(x, y)
[docs] def get_opacity(self):
"""opacity getter."""
return self._opacity
[docs] def set_opacity(self, op):
"""opacity setter."""
self._opacity = op
@Slot(float)
[docs] def update_opacity(self, value):
"""Updates graphics effect opacity."""
self.effect.setOpacity(value)
[docs] def start_self_destruction(self):
"""Starts fade-out animation and closing of the notification."""
self.fade_out_anim.start(QPropertyAnimation.DeleteWhenStopped)
self.setAttribute(Qt.WA_TransparentForMouseEvents)
[docs] def enterEvent(self, e):
"""Pauses timer as the mouse hovers the notification."""
super().enterEvent(e)
if self.remaining_time():
self.timer.stop()
[docs] def leaveEvent(self, e):
"""Starts self destruction after the mouse leaves the notification."""
super().leaveEvent(e)
self.start_self_destruction()
[docs] def remaining_time(self):
if self.timer.isActive():
return self.timer.remainingTime()
if self.fade_out_anim.state() == QPropertyAnimation.Running:
return 0
return self.timer.interval()
[docs] opacity = Property(float, get_opacity, set_opacity)
[docs]class LinkNotification(Notification):
"""A notification that may have a link."""
def __init__(self, *args, open_link=None, **kwargs):
super().__init__(*args, **kwargs)
self.label.setTextInteractionFlags(Qt.TextBrowserInteraction)
if open_link is None:
self.label.setOpenExternalLinks(True)
else:
self.label.linkActivated.connect(open_link)
[docs]class ChangeNotifier(QObject):
[docs] _ANIMATION_LIFE_SPAN = 5000
def __init__(self, parent, undo_stack, settings, settings_key, corner=Qt.BottomRightCorner):
"""
Args:
parent (QWidget)
undo_stack (QUndoStack)
settings (QSettings)
settings_key (str)
corner (int)
"""
super().__init__(undo_stack)
self._undo_stack = undo_stack
self._settings = settings
self._settings_key = settings_key
self._parent = parent
self._corner = corner
self._notification = None
self._undo_stack.indexChanged.connect(self._push_notification)
self._notified_commands = set()
@Slot(int)
[docs] def _push_notification(self, index):
if self._settings.value(self._settings_key, defaultValue="2") != "2":
return
if self._notification is not None:
try:
self._notification.start_self_destruction()
except RuntimeError:
# Already destroyed
pass
try:
cmd = self._undo_stack.command(index)
except RuntimeError:
return
if cmd is not None or index == 0:
return
cmd = self._undo_stack.command(index - 1)
if cmd in self._notified_commands:
return
self._notified_commands.add(cmd)
notification_text = cmd.actionText() + " successful"
button_text = "undo"
button_slot = self._undo_stack.undo
self._notification = ButtonNotification(
self._parent,
notification_text,
life_span=self._ANIMATION_LIFE_SPAN,
word_wrap=False,
corner=self._corner,
button_text=button_text,
button_slot=button_slot,
)
self._notification.show()
[docs] def tear_down(self):
"""Tears down the notifier."""
self._undo_stack.indexChanged.disconnect(self._push_notification)