Files
noctalia-shell/Modules/Notification/Notification.qml
T

816 lines
31 KiB
QML

import QtQuick
import QtQuick.Effects
import QtQuick.Layouts
import Quickshell
import Quickshell.Services.Notifications
import Quickshell.Wayland
import Quickshell.Widgets
import qs.Commons
import qs.Services.System
import qs.Widgets
// Simple notification popup - displays multiple notifications
Variants {
model: {
const screens = Quickshell.screens.filter(screen => Settings.data.notifications.monitors.includes(screen.name));
// Empty list can mean two things :
// - No (visible) notification display activated in settings
// - One or more (not visible) displays are activated but unplugged
// In both cases we fallback to show notification on all screens
return screens.length === 0 ? Quickshell.screens : screens;
}
delegate: Loader {
id: root
required property ShellScreen modelData
property ListModel notificationModel: NotificationService.popupModel
// Deferred activation to prevent re-entrant QML incubation crash.
// Direct binding to notificationModel.count would activate the Loader
// synchronously during ListModel.insert(), causing nested incubation
// (Loader + inner Repeater both processing the model) which crashes
// the V4 engine in QV4::Object::insertMember.
property bool shouldBeActive: false
active: shouldBeActive || delayTimer.running
// Keep loader active briefly after last notification to allow animations to complete
Timer {
id: delayTimer
interval: Style.animationSlow + 200
repeat: false
}
// Deferred activation timer - activates Loader on next event loop iteration
Timer {
id: activationTimer
interval: 0
repeat: false
onTriggered: root.shouldBeActive = true
}
Connections {
target: notificationModel
function onCountChanged() {
if (notificationModel.count > 0) {
if (!root.shouldBeActive) {
activationTimer.restart();
}
} else if (root.shouldBeActive) {
root.shouldBeActive = false;
delayTimer.restart();
}
}
}
sourceComponent: PanelWindow {
id: notifWindow
screen: modelData
WlrLayershell.namespace: "noctalia-notifications-" + (screen?.name || "unknown")
WlrLayershell.layer: (Settings.data.notifications?.overlayLayer) ? WlrLayer.Overlay : WlrLayer.Top
WlrLayershell.exclusionMode: ExclusionMode.Ignore
color: "transparent"
// Make shadow area click-through, only notification content is clickable
mask: Region {
x: 0
y: 0
width: notifWindow.width
height: notifWindow.height
intersection: Intersection.Xor
Region {
// The clickable content area is inset by shadowPadding from all edges
x: notifWindow.shadowPadding
y: notifWindow.shadowPadding
width: notifWindow.notifWidth
height: Math.max(0, notifWindow.height - notifWindow.shadowPadding * 2)
intersection: Intersection.Subtract
}
}
// Parse location setting
readonly property string location: Settings.data.notifications.location || "top_right"
readonly property bool isTop: location.startsWith("top")
readonly property bool isBottom: location.startsWith("bottom")
readonly property bool isLeft: location.endsWith("_left")
readonly property bool isRight: location.endsWith("_right")
readonly property bool isCentered: location === "top" || location === "bottom"
readonly property string barPos: Settings.getBarPositionForScreen(notifWindow.screen?.name)
readonly property bool isFloating: Settings.data.bar.floating
readonly property real barHeight: Style.getBarHeightForScreen(notifWindow.screen?.name)
readonly property bool isFramed: Settings.data.bar.barType === "framed"
readonly property real frameThickness: Settings.data.bar.frameThickness ?? 8
readonly property bool isCompact: Settings.data.notifications.density === "compact"
readonly property int notifWidth: Math.round((isCompact ? 320 : 440) * Style.uiScaleRatio)
readonly property int shadowPadding: Style.shadowBlurMax + Style.marginL
// Calculate bar and frame offsets for each edge separately
readonly property int barOffsetTop: {
if (barPos !== "top")
return isFramed ? frameThickness : 0;
const floatMarginV = isFloating ? Math.ceil(Settings.data.bar.marginVertical) : 0;
return barHeight + floatMarginV;
}
readonly property int barOffsetBottom: {
if (barPos !== "bottom")
return isFramed ? frameThickness : 0;
const floatMarginV = isFloating ? Math.ceil(Settings.data.bar.marginVertical) : 0;
return barHeight + floatMarginV;
}
readonly property int barOffsetLeft: {
if (barPos !== "left")
return isFramed ? frameThickness : 0;
const floatMarginH = isFloating ? Math.ceil(Settings.data.bar.marginHorizontal) : 0;
return barHeight + floatMarginH;
}
readonly property int barOffsetRight: {
if (barPos !== "right")
return isFramed ? frameThickness : 0;
const floatMarginH = isFloating ? Math.ceil(Settings.data.bar.marginHorizontal) : 0;
return barHeight + floatMarginH;
}
// Anchoring
anchors.top: isTop
anchors.bottom: isBottom
anchors.left: isLeft
anchors.right: isRight
// Margins for PanelWindow - only apply bar offset for the specific edge where the bar is
margins.top: isTop ? barOffsetTop - shadowPadding + Style.marginM : 0
margins.bottom: isBottom ? barOffsetBottom - shadowPadding : 0
margins.left: isLeft ? barOffsetLeft - shadowPadding + Style.marginM : 0
margins.right: isRight ? barOffsetRight - shadowPadding + Style.marginM : 0
implicitWidth: notifWidth + shadowPadding * 2
implicitHeight: notificationStack.implicitHeight + Style.marginL
property var animateConnection: null
Component.onCompleted: {
animateConnection = function (notificationId) {
var delegate = null;
if (notificationRepeater) {
for (var i = 0; i < notificationRepeater.count; i++) {
var item = notificationRepeater.itemAt(i);
if (item?.notificationId === notificationId) {
delegate = item;
break;
}
}
}
try {
if (delegate && typeof delegate.animateOut === "function" && !delegate.isRemoving) {
delegate.animateOut();
}
} catch (e) {
// Service fallback if delegate is already invalid
NotificationService.dismissPopup(notificationId);
}
};
NotificationService.animateAndRemove.connect(animateConnection);
}
Component.onDestruction: {
if (animateConnection) {
NotificationService.animateAndRemove.disconnect(animateConnection);
animateConnection = null;
}
}
ColumnLayout {
id: notificationStack
anchors {
top: parent.isTop ? parent.top : undefined
bottom: parent.isBottom ? parent.bottom : undefined
left: parent.isLeft ? parent.left : undefined
right: parent.isRight ? parent.right : undefined
horizontalCenter: parent.isCentered ? parent.horizontalCenter : undefined
}
spacing: -notifWindow.shadowPadding * 2 + Style.marginM
Behavior on implicitHeight {
enabled: !Settings.data.general.animationDisabled
SpringAnimation {
spring: 2.0
damping: 0.4
epsilon: 0.01
mass: 0.8
}
}
Repeater {
id: notificationRepeater
model: notificationModel
delegate: Item {
id: card
property string notificationId: model.id
property var notificationData: model
property bool isHovered: false
property bool isRemoving: false
readonly property int animationDelay: index * 100
readonly property int slideDistance: 300
Layout.preferredWidth: notifWidth + notifWindow.shadowPadding * 2
Layout.preferredHeight: (notifWindow.isCompact ? compactContent.implicitHeight : notificationContent.implicitHeight) + Style.margin2M + notifWindow.shadowPadding * 2
Layout.maximumHeight: Layout.preferredHeight
// Animation properties
property real scaleValue: 0.8
property real opacityValue: 0.0
property real slideOffset: 0
property real swipeOffset: 0
property real swipeOffsetY: 0
property real pressGlobalX: 0
property real pressGlobalY: 0
property bool isSwiping: false
property bool suppressClick: false
readonly property bool useVerticalSwipe: notifWindow.location === "bottom" || notifWindow.location === "top"
readonly property real swipeStartThreshold: Math.round(18 * Style.uiScaleRatio)
readonly property real swipeDismissThreshold: Math.max(110, cardBackground.width * 0.32)
readonly property real verticalSwipeDismissThreshold: Math.max(70, cardBackground.height * 0.35)
scale: scaleValue
opacity: opacityValue
transform: Translate {
x: card.swipeOffset
y: card.slideOffset + card.swipeOffsetY
}
readonly property real slideInOffset: notifWindow.isTop ? -slideDistance : slideDistance
readonly property real slideOutOffset: slideInOffset
function clampSwipeDelta(deltaX) {
if (notifWindow.isRight)
return Math.max(0, deltaX);
if (notifWindow.isLeft)
return Math.min(0, deltaX);
return deltaX;
}
function clampVerticalSwipeDelta(deltaY) {
if (notifWindow.isBottom)
return Math.max(0, deltaY);
if (notifWindow.isTop)
return Math.min(0, deltaY);
return deltaY;
}
// Animation setup
function triggerEntryAnimation() {
animInDelayTimer.stop();
removalTimer.stop();
resumeTimer.stop();
isRemoving = false;
isHovered = false;
isSwiping = false;
swipeOffset = 0;
swipeOffsetY = 0;
if (Settings.data.general.animationDisabled) {
slideOffset = 0;
scaleValue = 1.0;
opacityValue = 1.0;
return;
}
slideOffset = slideInOffset;
scaleValue = 0.8;
opacityValue = 0.0;
animInDelayTimer.interval = animationDelay;
animInDelayTimer.start();
}
Component.onCompleted: triggerEntryAnimation()
onNotificationIdChanged: triggerEntryAnimation()
Timer {
id: animInDelayTimer
interval: 0
repeat: false
onTriggered: {
if (card.isRemoving)
return;
slideOffset = 0;
scaleValue = 1.0;
opacityValue = 1.0;
}
}
function animateOut() {
if (isRemoving)
return;
animInDelayTimer.stop();
resumeTimer.stop();
isRemoving = true;
isSwiping = false;
swipeOffset = 0;
swipeOffsetY = 0;
if (!Settings.data.general.animationDisabled) {
slideOffset = slideOutOffset;
scaleValue = 0.8;
opacityValue = 0.0;
}
}
function dismissBySwipe() {
if (isRemoving)
return;
animInDelayTimer.stop();
resumeTimer.stop();
isRemoving = true;
isSwiping = false;
if (!Settings.data.general.animationDisabled) {
if (useVerticalSwipe) {
swipeOffset = 0;
swipeOffsetY = swipeOffsetY >= 0 ? cardBackground.height + Style.marginXL : -cardBackground.height - Style.marginXL;
} else {
swipeOffset = swipeOffset >= 0 ? cardBackground.width + Style.marginXL : -cardBackground.width - Style.marginXL;
swipeOffsetY = 0;
}
scaleValue = 0.8;
opacityValue = 0.0;
} else {
swipeOffset = 0;
swipeOffsetY = 0;
}
}
function runAction(actionId, isDismissed) {
if (!isDismissed) {
NotificationService.focusSenderWindow(model.appName);
NotificationService.invokeActionAndSuppressClose(notificationId, actionId);
} else if (Settings.data.notifications.clearDismissed) {
NotificationService.removeFromHistory(notificationId);
}
card.animateOut();
}
Timer {
id: removalTimer
interval: Style.animationSlow
repeat: false
onTriggered: {
NotificationService.dismissPopup(notificationId);
}
}
onIsRemovingChanged: {
if (isRemoving) {
removalTimer.start();
}
}
Behavior on scale {
enabled: !Settings.data.general.animationDisabled
SpringAnimation {
spring: 3
damping: 0.4
epsilon: 0.01
mass: 0.8
}
}
Behavior on opacity {
enabled: !Settings.data.general.animationDisabled
NumberAnimation {
duration: Style.animationNormal
easing.type: Easing.OutCubic
}
}
Behavior on slideOffset {
enabled: !Settings.data.general.animationDisabled
SpringAnimation {
spring: 2.5
damping: 0.3
epsilon: 0.01
mass: 0.6
}
}
Behavior on swipeOffset {
enabled: !Settings.data.general.animationDisabled && !card.isSwiping
NumberAnimation {
duration: Style.animationFast
easing.type: Easing.OutCubic
}
}
Behavior on swipeOffsetY {
enabled: !Settings.data.general.animationDisabled && !card.isSwiping
NumberAnimation {
duration: Style.animationFast
easing.type: Easing.OutCubic
}
}
// Sub item with the right dimensions, really usefull for the
// HoverHandler: card items are overlapping because of the
// negative spacing of notificationStack.
Item {
id: displayedCard
anchors.fill: parent
anchors.margins: notifWindow.shadowPadding
HoverHandler {
onHoveredChanged: {
isHovered = hovered;
if (isHovered) {
resumeTimer.stop();
NotificationService.pauseTimeout(notificationId);
} else {
resumeTimer.start();
}
}
}
Timer {
id: resumeTimer
interval: 50
repeat: false
onTriggered: {
if (!isHovered) {
NotificationService.resumeTimeout(notificationId);
}
}
}
// Right-click to dismiss
MouseArea {
id: cardDragArea
anchors.fill: cardBackground
acceptedButtons: Qt.LeftButton | Qt.RightButton
hoverEnabled: true
onPressed: mouse => {
if (mouse.button === Qt.LeftButton) {
const globalPoint = cardDragArea.mapToGlobal(mouse.x, mouse.y);
card.pressGlobalX = globalPoint.x;
card.pressGlobalY = globalPoint.y;
card.isSwiping = false;
card.suppressClick = false;
}
}
onPositionChanged: mouse => {
if (!(mouse.buttons & Qt.LeftButton) || card.isRemoving)
return;
const globalPoint = cardDragArea.mapToGlobal(mouse.x, mouse.y);
const rawDeltaX = globalPoint.x - card.pressGlobalX;
const rawDeltaY = globalPoint.y - card.pressGlobalY;
const deltaX = card.clampSwipeDelta(rawDeltaX);
const deltaY = card.clampVerticalSwipeDelta(rawDeltaY);
if (!card.isSwiping) {
if (card.useVerticalSwipe) {
if (Math.abs(deltaY) < card.swipeStartThreshold)
return;
card.isSwiping = true;
} else {
if (Math.abs(deltaX) < card.swipeStartThreshold)
return;
card.isSwiping = true;
}
}
if (card.useVerticalSwipe) {
card.swipeOffset = 0;
card.swipeOffsetY = deltaY;
} else {
card.swipeOffset = deltaX;
card.swipeOffsetY = 0;
}
}
onReleased: mouse => {
if (mouse.button === Qt.RightButton) {
card.animateOut();
if (Settings.data.notifications.clearDismissed) {
NotificationService.removeFromHistory(notificationId);
}
return;
}
if (mouse.button !== Qt.LeftButton)
return;
if (card.isSwiping) {
const dismissDistance = card.useVerticalSwipe ? Math.abs(card.swipeOffsetY) : Math.abs(card.swipeOffset);
const threshold = card.useVerticalSwipe ? card.verticalSwipeDismissThreshold : card.swipeDismissThreshold;
if (dismissDistance >= threshold) {
card.dismissBySwipe();
if (Settings.data.notifications.clearDismissed) {
NotificationService.removeFromHistory(notificationId);
}
} else {
card.swipeOffset = 0;
card.swipeOffsetY = 0;
}
card.suppressClick = true;
card.isSwiping = false;
return;
}
if (card.suppressClick)
return;
var actions = model.actionsJson ? JSON.parse(model.actionsJson) : [];
var hasDefault = actions.some(function (a) {
return a.identifier === "default";
});
if (hasDefault) {
card.runAction("default", false);
} else {
NotificationService.focusSenderWindow(model.appName);
card.animateOut();
}
}
onCanceled: {
card.isSwiping = false;
card.swipeOffset = 0;
card.swipeOffsetY = 0;
}
}
// Background with border
Rectangle {
id: cardBackground
anchors.fill: parent
radius: Style.radiusL
border.color: Qt.alpha(Color.mOutline, Settings.data.notifications.backgroundOpacity || 1.0)
border.width: Style.borderS
color: Qt.alpha(Color.mSurface, Settings.data.notifications.backgroundOpacity || 1.0)
// Progress bar
Rectangle {
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
height: 2
color: "transparent"
Rectangle {
id: progressBar
readonly property real progressWidth: cardBackground.width - (2 * cardBackground.radius)
height: parent.height
x: cardBackground.radius + (progressWidth * (1 - model.progress)) / 2
width: progressWidth * model.progress
color: {
var baseColor = model.urgency === 2 ? Color.mError : model.urgency === 0 ? Color.mOnSurface : Color.mPrimary;
return Qt.alpha(baseColor, Settings.data.notifications.backgroundOpacity || 1.0);
}
antialiasing: true
Behavior on width {
enabled: !card.isRemoving
NumberAnimation {
duration: 100
easing.type: Easing.Linear
}
}
Behavior on x {
enabled: !card.isRemoving
NumberAnimation {
duration: 100
easing.type: Easing.Linear
}
}
}
}
}
NDropShadow {
anchors.fill: cardBackground
source: cardBackground
autoPaddingEnabled: true
}
// Content
ColumnLayout {
id: notificationContent
visible: !notifWindow.isCompact
anchors.fill: cardBackground
anchors.margins: Style.marginM
spacing: Style.marginM
RowLayout {
Layout.fillWidth: true
spacing: Style.marginL
Layout.leftMargin: Style.marginM
Layout.rightMargin: Style.marginM
Layout.topMargin: Style.marginM
Layout.bottomMargin: Style.marginM
NImageRounded {
Layout.preferredWidth: Math.round(40 * Style.uiScaleRatio)
Layout.preferredHeight: Math.round(40 * Style.uiScaleRatio)
Layout.alignment: Qt.AlignVCenter
radius: Math.min(Style.radiusL, Layout.preferredWidth / 2)
imagePath: model.originalImage || ""
borderColor: "transparent"
borderWidth: 0
fallbackIcon: "bell"
fallbackIconSize: 24
}
ColumnLayout {
Layout.fillWidth: true
spacing: Style.marginS
// Header with urgency indicator
RowLayout {
Layout.fillWidth: true
spacing: Style.marginS
Rectangle {
Layout.preferredWidth: 6
Layout.preferredHeight: 6
Layout.alignment: Qt.AlignVCenter
radius: Style.radiusXS
color: model.urgency === 2 ? Color.mError : model.urgency === 0 ? Color.mOnSurface : Color.mPrimary
}
NText {
text: model.appName || "Unknown App"
pointSize: Style.fontSizeXS
font.weight: Style.fontWeightBold
color: Color.mSecondary
}
NText {
textFormat: Text.PlainText
text: " " + Time.formatRelativeTime(model.timestamp)
pointSize: Style.fontSizeXXS
color: Color.mOnSurfaceVariant
Layout.alignment: Qt.AlignBottom
}
Item {
Layout.fillWidth: true
}
}
NText {
text: model.summary || I18n.tr("common.no-summary")
pointSize: Style.fontSizeM
font.weight: Style.fontWeightMedium
color: Color.mOnSurface
textFormat: Text.StyledText
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
maximumLineCount: 3
elide: Text.ElideRight
visible: text.length > 0
Layout.fillWidth: true
Layout.rightMargin: Style.marginM
}
NText {
text: model.body || ""
pointSize: Style.fontSizeM
color: Color.mOnSurface
textFormat: Text.StyledText
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
maximumLineCount: 5
elide: Text.ElideRight
visible: text.length > 0
Layout.fillWidth: true
Layout.rightMargin: Style.marginXL
}
// Actions
Flow {
Layout.fillWidth: true
spacing: Style.marginS
Layout.topMargin: Style.marginM
flow: Flow.LeftToRight
property string parentNotificationId: notificationId
property var parsedActions: {
try {
return model.actionsJson ? JSON.parse(model.actionsJson) : [];
} catch (e) {
return [];
}
}
visible: parsedActions.length > 0
Repeater {
model: parent.parsedActions
delegate: NButton {
property var actionData: modelData
text: {
var actionText = actionData.text || "Open";
if (actionText.includes(",")) {
return actionText.split(",")[1] || actionText;
}
return actionText;
}
fontSize: Style.fontSizeS
backgroundColor: Color.mPrimary
textColor: hovered ? Color.mOnHover : Color.mOnPrimary
hoverColor: Color.mHover
outlined: false
implicitHeight: 24
onClicked: {
card.runAction(actionData.identifier, false);
}
}
}
}
}
}
}
// Close button
NIconButton {
visible: !notifWindow.isCompact
icon: "close"
tooltipText: I18n.tr("tooltips.dismiss-notification")
baseSize: Style.baseWidgetSize * 0.6
anchors.top: cardBackground.top
anchors.topMargin: Style.marginXL
anchors.right: cardBackground.right
anchors.rightMargin: Style.marginXL
onClicked: {
card.runAction("", true);
}
}
// Compact content
RowLayout {
id: compactContent
visible: notifWindow.isCompact
anchors.fill: cardBackground
anchors.margins: Style.marginM
spacing: Style.marginS
NImageRounded {
Layout.preferredWidth: Math.round(24 * Style.uiScaleRatio)
Layout.preferredHeight: Math.round(24 * Style.uiScaleRatio)
Layout.alignment: Qt.AlignVCenter
radius: Style.radiusXS
imagePath: model.originalImage || ""
borderColor: "transparent"
borderWidth: 0
fallbackIcon: "bell"
fallbackIconSize: 16
}
ColumnLayout {
Layout.fillWidth: true
spacing: Style.marginXS
NText {
text: model.summary || I18n.tr("common.no-summary")
pointSize: Style.fontSizeM
font.weight: Style.fontWeightMedium
color: Color.mOnSurface
textFormat: Text.StyledText
maximumLineCount: 1
elide: Text.ElideRight
Layout.fillWidth: true
}
NText {
visible: model.body && model.body.length > 0
Layout.fillWidth: true
text: model.body || ""
pointSize: Style.fontSizeS
color: Color.mOnSurfaceVariant
textFormat: Text.StyledText
wrapMode: Text.Wrap
maximumLineCount: 2
elide: Text.ElideRight
}
}
}
}
}
}
}
}
}
}