import QtQuick import QtQuick.Controls import Quickshell import Quickshell.Wayland import Quickshell.Widgets import qs.Common import qs.Services import qs.Widgets PanelWindow { id: win required property var notificationData required property string notificationId readonly property bool hasValidData: notificationData && notificationData.notification visible: hasValidData WlrLayershell.layer: WlrLayershell.Overlay WlrLayershell.exclusiveZone: -1 WlrLayershell.keyboardFocus: WlrKeyboardFocus.None color: "transparent" implicitWidth: 400 implicitHeight: 122 anchors { top: true right: true } margins { top: Theme.barHeight + 4 right: 12 } property int screenY: 0 onScreenYChanged: margins.top = Theme.barHeight + 4 + screenY Behavior on screenY { id: screenYAnim enabled: !exiting && !_isDestroying NumberAnimation { duration: Anims.durShort easing.type: Easing.BezierSpline easing.bezierCurve: Anims.standardDecel } } property bool exiting: false property bool _isDestroying: false property bool _finalized: false signal entered() signal exitFinished() onHasValidDataChanged: { if (!hasValidData && !exiting && !_isDestroying) { console.warn("NotificationPopup: Data became invalid, forcing exit"); forceExit(); } } Item { id: content anchors.fill: parent visible: win.hasValidData transform: Translate { id: tx; x: Anims.slidePx } layer.enabled: (enterX.running || exitAnim.running) layer.smooth: true Rectangle { property var shadowLayers: [shadowLayer1, shadowLayer2, shadowLayer3] anchors.fill: parent anchors.margins: 4 radius: Theme.cornerRadiusLarge color: Theme.popupBackground() border.color: notificationData && notificationData.urgency === 2 ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.3) : Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.08) border.width: notificationData && notificationData.urgency === 2 ? 2 : 1 clip: true Rectangle { id: shadowLayer1 anchors.fill: parent anchors.margins: -3 color: "transparent" radius: parent.radius + 3 border.color: Qt.rgba(0, 0, 0, 0.05) border.width: 1 z: -3 } Rectangle { id: shadowLayer2 anchors.fill: parent anchors.margins: -2 color: "transparent" radius: parent.radius + 2 border.color: Qt.rgba(0, 0, 0, 0.08) border.width: 1 z: -2 } Rectangle { id: shadowLayer3 anchors.fill: parent color: "transparent" border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.12) border.width: 1 radius: parent.radius z: -1 } Rectangle { anchors.fill: parent radius: parent.radius visible: notificationData && notificationData.urgency === 2 opacity: 1 gradient: Gradient { orientation: Gradient.Horizontal GradientStop { position: 0 color: Theme.primary } GradientStop { position: 0.02 color: Theme.primary } GradientStop { position: 0.021 color: "transparent" } } } Item { id: notificationContent anchors.top: parent.top anchors.left: parent.left anchors.right: parent.right anchors.topMargin: 12 anchors.leftMargin: 16 anchors.rightMargin: 56 height: 98 Rectangle { id: iconContainer readonly property bool hasNotificationImage: notificationData && notificationData.image && notificationData.image !== "" width: 55 height: 55 radius: 27.5 color: Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.1) border.color: "transparent" border.width: 0 anchors.left: parent.left anchors.verticalCenter: parent.verticalCenter IconImage { id: iconImage anchors.fill: parent anchors.margins: 2 asynchronous: true source: { if (!notificationData) return ""; if (parent.hasNotificationImage) return notificationData.cleanImage || ""; if (notificationData.appIcon) { const appIcon = notificationData.appIcon; if (appIcon.startsWith("file://") || appIcon.startsWith("http://") || appIcon.startsWith("https://")) return appIcon; return Quickshell.iconPath(appIcon, ""); } return ""; } visible: status === Image.Ready } Text { anchors.centerIn: parent visible: !parent.hasNotificationImage && (!notificationData || !notificationData.appIcon || notificationData.appIcon === "") text: { const appName = notificationData && notificationData.appName ? notificationData.appName : "?"; return appName.charAt(0).toUpperCase(); } font.pixelSize: 20 font.weight: Font.Bold color: Theme.primaryText } } Rectangle { id: textContainer anchors.left: iconContainer.right anchors.leftMargin: 12 anchors.right: parent.right anchors.rightMargin: 0 anchors.top: parent.top anchors.bottom: parent.bottom anchors.bottomMargin: 8 color: "transparent" Item { width: parent.width height: parent.height anchors.top: parent.top anchors.topMargin: -4 Column { width: parent.width spacing: 2 Text { width: parent.width text: { if (!notificationData) return ""; const appName = notificationData.appName || ""; const timeStr = notificationData.timeStr || ""; if (timeStr.length > 0) return appName + " • " + timeStr; else return appName; } color: Theme.surfaceVariantText font.pixelSize: Theme.fontSizeSmall font.weight: Font.Medium elide: Text.ElideRight maximumLineCount: 1 } Text { text: notificationData ? (notificationData.summary || "") : "" color: Theme.surfaceText font.pixelSize: Theme.fontSizeMedium font.weight: Font.Medium width: parent.width elide: Text.ElideRight maximumLineCount: 1 visible: text.length > 0 } Text { text: notificationData ? (notificationData.htmlBody || "") : "" color: Theme.surfaceVariantText font.pixelSize: Theme.fontSizeSmall width: parent.width elide: Text.ElideRight maximumLineCount: 2 wrapMode: Text.WordWrap visible: text.length > 0 linkColor: Theme.primary onLinkActivated: Qt.openUrlExternally(link) MouseArea { anchors.fill: parent acceptedButtons: Qt.NoButton cursorShape: parent.hoveredLink ? Qt.PointingHandCursor : Qt.ArrowCursor } } } } } } DankActionButton { id: closeButton anchors.right: parent.right anchors.top: parent.top anchors.topMargin: 12 anchors.rightMargin: 16 iconName: "close" iconSize: 18 buttonSize: 28 z: 15 onClicked: { if (notificationData && !win.exiting) notificationData.popup = false; } } Row { anchors.right: dismissButton.left anchors.rightMargin: 8 anchors.bottom: parent.bottom anchors.bottomMargin: 8 spacing: 8 z: 20 Repeater { model: notificationData ? (notificationData.actions || []) : [] Rectangle { property bool isHovered: false width: Math.max(actionText.implicitWidth + 12, 50) height: 24 radius: 4 color: isHovered ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.1) : "transparent" Text { id: actionText text: modelData.text || "" color: parent.isHovered ? Theme.primary : Theme.surfaceVariantText font.pixelSize: Theme.fontSizeSmall font.weight: Font.Medium anchors.centerIn: parent elide: Text.ElideRight } MouseArea { anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor acceptedButtons: Qt.LeftButton onEntered: parent.isHovered = true onExited: parent.isHovered = false onClicked: { if (modelData && modelData.invoke) { modelData.invoke(); } if (notificationData && !win.exiting) { notificationData.popup = false; } } } } } } Rectangle { id: dismissButton property bool isHovered: false anchors.right: parent.right anchors.rightMargin: 16 anchors.bottom: parent.bottom anchors.bottomMargin: 8 width: Math.max(dismissText.implicitWidth + 12, 50) height: 24 radius: 4 color: isHovered ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.1) : "transparent" z: 20 Text { id: dismissText text: "Dismiss" color: dismissButton.isHovered ? Theme.primary : Theme.surfaceVariantText font.pixelSize: Theme.fontSizeSmall font.weight: Font.Medium anchors.centerIn: parent } MouseArea { anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor acceptedButtons: Qt.LeftButton onEntered: dismissButton.isHovered = true onExited: dismissButton.isHovered = false onClicked: { if (notificationData && !win.exiting) { NotificationService.dismissNotification(notificationData); } } } } MouseArea { id: cardHoverArea anchors.fill: parent hoverEnabled: true acceptedButtons: Qt.LeftButton propagateComposedEvents: true z: -1 onEntered: { if (notificationData && notificationData.timer) notificationData.timer.stop(); } onExited: { if (notificationData && notificationData.popup && notificationData.timer) notificationData.timer.restart(); } onClicked: { if (notificationData && !win.exiting) notificationData.popup = false; } } } } NumberAnimation { id: enterX target: tx; property: "x"; from: Anims.slidePx; to: 0 duration: Anims.durMed easing.type: Easing.BezierSpline easing.bezierCurve: Anims.emphasizedDecel onStopped: if (!win.exiting && !win._isDestroying && Math.abs(tx.x) < 0.5) win.entered(); } ParallelAnimation { id: exitAnim PropertyAnimation { target: tx; property: "x"; from: 0; to: Anims.slidePx duration: Anims.durShort easing.type: Easing.BezierSpline easing.bezierCurve: Anims.emphasizedAccel } NumberAnimation { target: content; property: "opacity"; from: 1; to: 0 duration: Anims.durShort easing.type: Easing.BezierSpline easing.bezierCurve: Anims.standardAccel } NumberAnimation { target: content; property: "scale"; from: 1; to: 0.98 duration: Anims.durShort easing.type: Easing.BezierSpline easing.bezierCurve: Anims.emphasizedAccel } onStopped: finalizeExit("animStopped") } Component.onCompleted: { if (hasValidData) { Qt.callLater(() => enterX.restart()) } else { console.warn("NotificationPopup created with invalid data"); forceExit(); } } Connections { id: wrapperConn target: win.notificationData || null ignoreUnknownSignals: true enabled: !win._isDestroying function onPopupChanged() { if (!win.notificationData || win._isDestroying) return; if (!win.notificationData.popup && !win.exiting) { startExit(); } } } onNotificationDataChanged: { if (!_isDestroying) { wrapperConn.target = win.notificationData || null; } } Timer { id: enterDelay interval: 160 repeat: false onTriggered: { if (notificationData && notificationData.timer && !exiting && !_isDestroying) notificationData.timer.start(); } } onEntered: { if (!_isDestroying) enterDelay.start(); } function startExit() { if (exiting || _isDestroying) return; exiting = true; exitAnim.restart(); exitWatchdog.restart(); if (NotificationService.removeFromVisibleNotifications) { NotificationService.removeFromVisibleNotifications(win.notificationData); } } function forceExit() { if (_isDestroying) return; _isDestroying = true; exiting = true; visible = false; exitWatchdog.stop(); finalizeExit("forced"); } function finalizeExit(reason) { if (_finalized) return; _finalized = true; _isDestroying = true; exitWatchdog.stop(); wrapperConn.enabled = false; wrapperConn.target = null; win.exitFinished(); } Timer { id: exitWatchdog interval: 600 repeat: false onTriggered: finalizeExit("watchdog") } Component.onDestruction: { _isDestroying = true; exitWatchdog.stop(); if (notificationData && notificationData.timer) { notificationData.timer.stop(); } } }