import QtQuick import QtQuick.Layouts import Quickshell import Quickshell.Widgets import Quickshell.Wayland import Quickshell.Services.Notifications import qs.Commons import qs.Services import qs.Widgets // Simple notification popup - displays multiple notifications Variants { model: Quickshell.screens delegate: Loader { id: root required property ShellScreen modelData readonly property real scaling: ScalingService.getScreenScale(modelData) // Access the notification model from the service property ListModel notificationModel: NotificationService.notificationModel // Track notifications being removed for animation property var removingNotifications: ({}) // If no notification display activated in settings, then show them all active: Settings.isLoaded && modelData && (NotificationService.notificationModel.count > 0) ? (Settings.data.notifications.monitors.includes(modelData.name) || (Settings.data.notifications.monitors.length === 0)) : false visible: (NotificationService.notificationModel.count > 0) sourceComponent: PanelWindow { screen: modelData color: Color.transparent // Position based on bar location - always at top anchors.top: true anchors.right: Settings.data.bar.position === "right" || Settings.data.bar.position === "top" || Settings.data.bar.position === "bottom" anchors.left: Settings.data.bar.position === "left" margins.top: { switch (Settings.data.bar.position) { case "top": return (Style.barHeight + Style.marginM) * scaling + (Settings.data.bar.floating ? Settings.data.bar.marginVertical * Style.marginXL * scaling : 0) default: return Style.marginM * scaling } } margins.bottom: { switch (Settings.data.bar.position) { case "bottom": return (Style.barHeight + Style.marginM) * scaling + (Settings.data.bar.floating ? Settings.data.bar.marginVertical * Style.marginXL * scaling : 0) default: return 0 } } margins.left: { switch (Settings.data.bar.position) { case "left": return (Style.barHeight + Style.marginM) * scaling + (Settings.data.bar.floating ? Settings.data.bar.marginHorizontal * Style.marginXL * scaling : 0) default: return 0 } } margins.right: { switch (Settings.data.bar.position) { case "right": return (Style.barHeight + Style.marginM) * scaling + (Settings.data.bar.floating ? Settings.data.bar.marginHorizontal * Style.marginXL * scaling : 0) case "top": case "bottom": return Style.marginM * scaling default: return 0 } } implicitWidth: 360 * scaling implicitHeight: Math.min(notificationStack.implicitHeight, (NotificationService.maxVisible * 120) * scaling) //WlrLayershell.layer: WlrLayer.Overlay WlrLayershell.exclusionMode: ExclusionMode.Ignore // Connect to animation signal from service Component.onCompleted: { NotificationService.animateAndRemove.connect(function (notification, index) { // Prefer lookup by identity to avoid index mismatches var delegate = null if (notificationStack && notificationStack.children && notificationStack.children.length > 0) { for (var i = 0; i < notificationStack.children.length; i++) { var child = notificationStack.children[i] if (child && child.model && child.model.rawNotification === notification) { delegate = child break } } } // Fallback to index if identity lookup failed if (!delegate && notificationStack && notificationStack.children && notificationStack.children[index]) { delegate = notificationStack.children[index] } if (delegate && delegate.animateOut) { delegate.animateOut() } else { // As a last resort, force-remove without animation to avoid stuck popups NotificationService.forceRemoveNotification(notification) } }) } // Main notification container ColumnLayout { id: notificationStack // Position based on bar location - always at top anchors.top: parent.top anchors.right: (Settings.data.bar.position === "right" || Settings.data.bar.position === "top" || Settings.data.bar.position === "bottom") ? parent.right : undefined anchors.left: Settings.data.bar.position === "left" ? parent.left : undefined spacing: Style.marginS * scaling width: 360 * scaling visible: true // Multiple notifications display Repeater { model: notificationModel delegate: Rectangle { Layout.preferredWidth: 360 * scaling Layout.preferredHeight: notificationLayout.implicitHeight + (Style.marginL * 2 * scaling) Layout.maximumHeight: Layout.preferredHeight clip: true radius: Style.radiusL * scaling border.color: Color.mOutline border.width: Math.max(1, Style.borderS * scaling) color: Color.mSurface // Animation properties property real scaleValue: 0.8 property real opacityValue: 0.0 property bool isRemoving: false // Right-click to dismiss MouseArea { anchors.fill: parent acceptedButtons: Qt.RightButton onClicked: { if (mouse.button === Qt.RightButton) { animateOut() } } } // Scale and fade-in animation scale: scaleValue opacity: opacityValue // Animate in when the item is created Component.onCompleted: { scaleValue = 1.0 opacityValue = 1.0 } // Animate out when being removed function animateOut() { isRemoving = true scaleValue = 0.8 opacityValue = 0.0 } // Timer for delayed removal after animation Timer { id: removalTimer interval: Style.animationSlow repeat: false onTriggered: { NotificationService.forceRemoveNotification(model.rawNotification) } } // Check if this notification is being removed onIsRemovingChanged: { if (isRemoving) { // Remove from model after animation completes removalTimer.start() } } // Animation behaviors Behavior on scale { NumberAnimation { duration: Style.animationSlow easing.type: Easing.OutExpo //easing.type: Easing.OutBack looks better but notification get clipped on all sides } } Behavior on opacity { NumberAnimation { duration: Style.animationNormal easing.type: Easing.OutQuad } } ColumnLayout { id: notificationLayout anchors.fill: parent anchors.margins: Style.marginM * scaling anchors.rightMargin: (Style.marginM + 32) * scaling // Leave space for close button spacing: Style.marginM * scaling // Header section with app name and timestamp RowLayout { Layout.fillWidth: true spacing: Style.marginS * scaling NText { text: `${(model.appName || model.desktopEntry) || "Unknown App"} ยท ${NotificationService.formatTimestamp(model.timestamp)}` color: Color.mSecondary font.pointSize: Style.fontSizeXS * scaling } Rectangle { Layout.preferredWidth: 6 * scaling Layout.preferredHeight: 6 * scaling radius: Style.radiusXS * scaling color: (model.urgency === NotificationUrgency.Critical) ? Color.mError : (model.urgency === NotificationUrgency.Low) ? Color.mOnSurface : Color.mPrimary Layout.alignment: Qt.AlignVCenter } Item { Layout.fillWidth: true } } // Main content section RowLayout { Layout.fillWidth: true spacing: Style.marginM * scaling // Image NImageCircled { Layout.preferredWidth: 40 * scaling Layout.preferredHeight: 40 * scaling Layout.alignment: Qt.AlignTop imagePath: model.image && model.image !== "" ? model.image : "" borderColor: Color.transparent borderWidth: 0 visible: (model.image && model.image !== "") } // Text content ColumnLayout { Layout.fillWidth: true spacing: Style.marginS * scaling NText { text: model.summary || "No summary" font.pointSize: Style.fontSizeL * scaling font.weight: Style.fontWeightMedium color: Color.mOnSurface wrapMode: Text.WrapAtWordBoundaryOrAnywhere Layout.fillWidth: true maximumLineCount: 3 elide: Text.ElideRight } NText { text: model.body || "" font.pointSize: Style.fontSizeM * scaling color: Color.mOnSurface wrapMode: Text.WrapAtWordBoundaryOrAnywhere Layout.fillWidth: true maximumLineCount: 5 elide: Text.ElideRight visible: text.length > 0 } } } // Notification actions RowLayout { Layout.fillWidth: true spacing: Style.marginS * scaling visible: model.rawNotification && model.rawNotification.actions && model.rawNotification.actions.length > 0 property var notificationActions: model.rawNotification ? model.rawNotification.actions : [] Repeater { model: parent.notificationActions delegate: NButton { text: { var actionText = modelData.text || "Open" // If text contains comma, take the part after the comma (the display text) if (actionText.includes(",")) { return actionText.split(",")[1] || actionText } return actionText } fontSize: Style.fontSizeS * scaling backgroundColor: Color.mPrimary textColor: Color.mOnPrimary hoverColor: Color.mSecondary pressColor: Color.mTertiary outlined: false customHeight: 32 * scaling Layout.preferredHeight: 32 * scaling onClicked: { if (modelData && modelData.invoke) { modelData.invoke() } } } } // Spacer to push buttons to the left if needed Item { Layout.fillWidth: true } } } // Close button positioned absolutely NIconButton { icon: "close" tooltipText: "Close." baseSize: Style.baseWidgetSize * 0.6 anchors.top: parent.top anchors.topMargin: Style.marginM * scaling anchors.right: parent.right anchors.rightMargin: Style.marginM * scaling onClicked: { animateOut() } } } } } } } }