import QtQuick import Quickshell import Quickshell.Services.Notifications import Quickshell.Widgets Item { id: root property NotificationTheme theme property int notificationId: -1 property var notifObject: null property int notifVersion: 0 property real createdAtMs: Date.now() property bool interactive: true property bool forceExpanded: false property bool peekMode: false signal closeFinished(int notificationId, string reason) property bool closing: false property bool contextOpen: false property real collapseFactor: 1 readonly property string notifTitle: notifObject ? String(notifObject.summary || "") : "" readonly property string notifBody: notifObject ? String(notifObject.body || "") : "" readonly property string notifAppName: notifObject ? String(notifObject.appName || "") : "" readonly property string notifAppIcon: notifObject ? String(notifObject.appIcon || "") : "" readonly property string notifImage: notifObject ? String(notifObject.image || "") : "" readonly property bool isExpanded: forceExpanded || contextOpen readonly property string appIconSource: { if (!notifAppIcon || notifAppIcon.length === 0) return ""; if (notifAppIcon.indexOf("/") >= 0 || notifAppIcon.indexOf("file://") === 0) return notifAppIcon; return Quickshell.iconPath(notifAppIcon); } readonly property string customImageSource: notifImage readonly property string primaryIconSource: customImageSource.length > 0 ? customImageSource : appIconSource readonly property bool showBadgeIcon: customImageSource.length > 0 && appIconSource.length > 0 readonly property int iconBoxSize: (theme ? theme.iconBox : 28) + 12 readonly property var notifActions: { if (!notifObject || !notifObject.actions) return []; const normalized = []; for (let i = 0; i < notifObject.actions.length; i++) { const actionObject = notifObject.actions[i]; if (!actionObject) continue; const actionText = String(actionObject.text || "").trim(); if (actionText.length === 0) continue; normalized.push({ actionObject: actionObject, actionText: actionText }); } return normalized; } readonly property int actionCount: notifActions.length readonly property var contextItems: { const items = []; for (let i = 0; i < notifActions.length; i++) items.push(notifActions[i]); items.push({ actionObject: null, actionText: "Dismiss" }); return items; } function urgencyTimeoutMs() { if (!notifObject) return theme.fallbackNormalTimeoutMs; const rawSeconds = Number(notifObject.expireTimeout); if (Number.isFinite(rawSeconds) && rawSeconds > 0) return Math.max(1000, Math.round(rawSeconds * 1000)); const urgency = notifObject.urgency; if (urgency === NotificationUrgency.Critical) return theme.fallbackCriticalTimeoutMs; if (urgency === NotificationUrgency.Low) return theme.fallbackLowTimeoutMs; return theme.fallbackNormalTimeoutMs; } function relativeTime() { const elapsedSeconds = Math.max(0, Math.floor((Date.now() - createdAtMs) / 1000)); if (elapsedSeconds < 60) return "now"; if (elapsedSeconds < 3600) return Math.floor(elapsedSeconds / 60) + "m"; return Math.floor(elapsedSeconds / 3600) + "h"; } function beginClose(reason) { if (closing) return; closing = true; timeoutTimer.stop(); closeReason = reason; closeAnimation.start(); } function openContextPanel() { if (!interactive || closing || peekMode) return; contextOpen = !contextOpen; restartTimeout(); } function invokeAction(actionObject) { if (closing) return; if (actionObject) actionObject.invoke(); beginClose(actionObject ? "click" : "dismiss"); } function restartTimeout() { if (closing || isExpanded || peekMode) return; timeoutTimer.interval = urgencyTimeoutMs(); timeoutTimer.restart(); } property string closeReason: "" implicitWidth: theme ? theme.stackWidth : 420 implicitHeight: peekMode ? (theme ? theme.stackPeekHeight : 36) : Math.max(1, Math.round((contentColumn.implicitHeight + ((theme ? theme.cardPadding : 14) * 2)) * collapseFactor)) width: implicitWidth height: implicitHeight opacity: 1 SequentialAnimation { id: enterAnimation running: !peekMode PropertyAction { target: root; property: "opacity"; value: 0 } PropertyAction { target: root; property: "x"; value: 20 } ParallelAnimation { NumberAnimation { target: root; property: "opacity"; to: 1; duration: theme ? theme.enterMs : 170; easing.type: Easing.OutCubic } NumberAnimation { target: root; property: "x"; to: 0; duration: theme ? theme.enterMs : 170; easing.type: Easing.OutCubic } } } ParallelAnimation { id: closeAnimation NumberAnimation { target: root; property: "opacity"; to: 0; duration: theme ? theme.fadeMs : 120; easing.type: Easing.InCubic } NumberAnimation { target: root; property: "x"; to: width; duration: theme ? theme.exitMs : 140; easing.type: Easing.InCubic } onFinished: root.closeFinished(root.notificationId, root.closeReason) } Timer { id: timeoutTimer interval: root.urgencyTimeoutMs() repeat: false running: !root.peekMode onTriggered: root.beginClose("timeout") } Rectangle { anchors.fill: parent color: theme ? theme.panelBackground : "#000000" border.width: theme ? theme.borderWidth : 3 border.color: theme ? theme.panelBorder : "#ffffff" radius: 0 antialiasing: false visible: !peekMode } Rectangle { anchors.fill: parent color: theme ? theme.panelBackground : "#000000" border.width: theme ? theme.borderWidth : 3 border.color: theme ? theme.panelBorder : "#ffffff" radius: 0 antialiasing: false visible: peekMode opacity: 0.95 } Column { id: contentColumn visible: !peekMode x: (theme ? theme.cardPadding : 14) y: (theme ? theme.cardPadding : 14) width: root.width - ((theme ? theme.cardPadding : 14) * 2) spacing: theme ? theme.lineGap : 2 Row { id: mainRow width: parent.width spacing: theme ? theme.contentGap : 10 anchors.left: parent.left anchors.right: parent.right Item { width: root.iconBoxSize height: root.iconBoxSize IconImage { anchors.fill: parent source: root.primaryIconSource visible: root.primaryIconSource.length > 0 asynchronous: true } Rectangle { anchors.fill: parent visible: root.primaryIconSource.length === 0 color: "#000000" border.width: 1 border.color: theme ? theme.panelBorder : "#ffffff" radius: 0 antialiasing: false } Image { anchors.centerIn: parent width: parent.width - 2 height: parent.height - 2 source: "../Topbar/topbar/soul_small.png" visible: root.primaryIconSource.length === 0 smooth: false antialiasing: false fillMode: Image.PreserveAspectFit } IconImage { visible: root.showBadgeIcon width: Math.max(12, Math.round(parent.width * 0.42)) height: Math.max(12, Math.round(parent.height * 0.42)) source: root.appIconSource x: parent.width - width y: parent.height - height asynchronous: true } } Column { id: textColumn width: parent.width - root.iconBoxSize - (theme ? theme.contentGap : 10) spacing: theme ? theme.lineGap : 2 Row { width: parent.width Text { width: parent.width - timeLabel.implicitWidth - 6 text: root.notifTitle color: theme ? theme.panelText : "#ffffff" elide: Text.ElideRight maximumLineCount: 1 font.family: theme ? theme.fontFamily : "8bitoperator JVE" font.pixelSize: 24 font.letterSpacing: theme ? theme.fontLetterSpacing : 1 renderType: Text.NativeRendering font.hintingPreference: Font.PreferNoHinting smooth: false antialiasing: false } Text { id: timeLabel text: root.relativeTime() color: theme ? theme.panelText : "#ffffff" font.family: theme ? theme.fontFamily : "8bitoperator JVE" font.pixelSize: 16 font.letterSpacing: theme ? theme.fontLetterSpacing : 1 renderType: Text.NativeRendering font.hintingPreference: Font.PreferNoHinting smooth: false antialiasing: false } } Text { width: parent.width text: root.notifBody color: theme ? theme.panelText : "#ffffff" wrapMode: Text.Wrap maximumLineCount: root.isExpanded ? (theme ? theme.expandedBodyLines : 8) : (theme ? theme.collapsedBodyLines : 2) elide: Text.ElideRight font.family: theme ? theme.fontFamily : "8bitoperator JVE" font.pixelSize: 22 font.letterSpacing: theme ? theme.fontLetterSpacing : 1 renderType: Text.NativeRendering font.hintingPreference: Font.PreferNoHinting smooth: false antialiasing: false } } } Column { visible: root.contextOpen width: parent.width spacing: theme ? theme.actionGap : 8 Repeater { model: root.contextItems Rectangle { width: parent.width height: theme ? theme.actionHeight : 34 color: actionHover.hovered ? (theme ? theme.hoverInvertBackground : "#ffffff") : (theme ? theme.panelBackground : "#000000") border.width: theme ? theme.actionBorderWidth : 2 border.color: theme ? theme.panelBorder : "#ffffff" radius: 0 antialiasing: false HoverHandler { id: actionHover } Text { anchors.centerIn: parent text: modelData.actionText color: actionHover.hovered ? (theme ? theme.hoverInvertText : "#000000") : (theme ? theme.panelText : "#ffffff") font.family: theme ? theme.fontFamily : "8bitoperator JVE" font.pixelSize: 18 font.letterSpacing: theme ? theme.fontLetterSpacing : 1 renderType: Text.NativeRendering font.hintingPreference: Font.PreferNoHinting smooth: false antialiasing: false } TapHandler { acceptedButtons: Qt.LeftButton enabled: root.interactive && !root.closing onTapped: root.invokeAction(modelData.actionObject) } } } } } DragHandler { id: swipeHandler enabled: root.interactive && !root.peekMode && !root.closing xAxis.enabled: true yAxis.enabled: false onTranslationChanged: { root.x = Math.max(0, translation.x); } onActiveChanged: { if (active) return; const threshold = theme ? theme.dragDismissThreshold : 140; if (root.x >= threshold) { root.beginClose("drag"); return; } snapBackAnimation.restart(); } } NumberAnimation { id: snapBackAnimation target: root property: "x" to: 0 duration: theme ? theme.expandMs : 120 easing.type: Easing.OutCubic } TapHandler { acceptedButtons: Qt.LeftButton enabled: root.interactive && !root.peekMode && !root.closing onTapped: root.openContextPanel() } TapHandler { acceptedButtons: Qt.RightButton enabled: root.interactive && !root.peekMode && !root.closing onTapped: root.beginClose("dismiss") } onNotifObjectChanged: restartTimeout() onNotifVersionChanged: restartTimeout() onContextOpenChanged: { if (!contextOpen) restartTimeout(); else timeoutTimer.stop(); } Component.onCompleted: restartTimeout() }