import QtQuick import Qt5Compat.GraphicalEffects import Quickshell import Quickshell.Widgets Item { id: root property int notificationId: -1 property string notifTitle: "" property string notifBody: "" property string notifUrgency: "Normal" property string notifAppName: "" property string notifAppIcon: "" property string notifImage: "" property int notifTimeoutMs: 5000 property bool notifCritical: false property var notifHints: ({}) signal closeFinished(int notificationId, string reason) property bool highlighted: hoverHandler.hovered property bool closing: false property string closeReason: "" property real borderAlpha: 1 property real textAlpha: 1 property real collapseFactor: 1 readonly property color normalInk: "#ffffff" readonly property color highlightInk: "#ffc90e" readonly property color activeInk: highlighted ? highlightInk : normalInk readonly property int padding: 14 readonly property int iconBox: 28 readonly property int appIconSize: 24 readonly property string appIconSource: { if (notifImage && notifImage.length > 0) return notifImage; if (!notifAppIcon || notifAppIcon.length === 0) return ""; if (notifAppIcon.indexOf("/") >= 0 || notifAppIcon.indexOf("file://") === 0) return notifAppIcon; return Quickshell.iconPath(notifAppIcon); } function hintString(name) { if (!notifHints || typeof notifHints !== "object") return ""; const value = notifHints[name]; return value === undefined || value === null ? "" : String(value); } function textBlob() { return (notifAppName + " " + notifTitle + " " + notifBody + " " + hintString("category")).toLowerCase(); } function parseVolumePercent() { const source = (notifTitle + " " + notifBody).toUpperCase(); const match = source.match(/VOLUME\s*(\d{1,3})%/); if (!match || match.length < 2) return -1; const parsed = Number(match[1]); if (!Number.isFinite(parsed)) return -1; return Math.max(0, Math.min(100, parsed)); } readonly property int volumePercent: parseVolumePercent() readonly property bool isVolumeLayout: volumePercent >= 0 function soulColor() { const full = textBlob(); const urgencyText = String(notifUrgency || "").toLowerCase(); if (urgencyText === "critical") return "#fff27a"; if (full.indexOf("network") >= 0 || full.indexOf("wifi") >= 0 || full.indexOf("ethernet") >= 0 || full.indexOf("bluetooth") >= 0) return "#4ca4ff"; if (full.indexOf("success") >= 0 || full.indexOf("completed") >= 0 || full.indexOf("saved") >= 0 || full.indexOf("done") >= 0) return "#47d66b"; return "#ff2a2a"; } readonly property color soulInk: soulColor() function beginClose(reason) { if (closing) return; closing = true; closeReason = reason; timeoutTimer.stop(); if (reason === "click") { heartFlash.restart(); clickClose.start(); } else { timeoutClose.start(); } } implicitWidth: 420 implicitHeight: Math.max(1, contentColumn.implicitHeight * collapseFactor + padding * 2) width: implicitWidth height: implicitHeight transformOrigin: Item.TopRight SequentialAnimation { id: entryAnimation running: true PropertyAction { target: root property: "opacity" value: 0 } PropertyAction { target: root property: "scale" value: 0.95 } ParallelAnimation { NumberAnimation { target: root property: "opacity" to: 0.45 duration: 55 } NumberAnimation { target: root property: "scale" to: 0.97 duration: 55 } } ParallelAnimation { NumberAnimation { target: root property: "opacity" to: 0.75 duration: 55 } NumberAnimation { target: root property: "scale" to: 0.99 duration: 55 } } ParallelAnimation { NumberAnimation { target: root property: "opacity" to: 1 duration: 70 } NumberAnimation { target: root property: "scale" to: 1 duration: 70 } } } SequentialAnimation { id: heartFlash NumberAnimation { target: soulFlashOverlay property: "opacity" to: 1 duration: 35 } NumberAnimation { target: soulFlashOverlay property: "opacity" to: 0 duration: 75 } } SequentialAnimation { id: clickClose ParallelAnimation { NumberAnimation { target: root property: "y" to: -8 duration: 160 easing.type: Easing.InCubic } NumberAnimation { target: root property: "opacity" to: 0 duration: 160 } } ScriptAction { script: root.closeFinished(root.notificationId, "click") } } SequentialAnimation { id: timeoutClose NumberAnimation { target: root property: "borderAlpha" to: 0.35 duration: 90 } NumberAnimation { target: root property: "textAlpha" to: 0 duration: 110 } ParallelAnimation { NumberAnimation { target: root property: "collapseFactor" to: 0 duration: 150 easing.type: Easing.InCubic } NumberAnimation { target: root property: "opacity" to: 0 duration: 150 } } ScriptAction { script: root.closeFinished(root.notificationId, "timeout") } } Timer { id: timeoutTimer interval: root.notifTimeoutMs repeat: false running: !root.notifCritical && root.notifTimeoutMs > 0 onTriggered: root.beginClose("timeout") } Rectangle { anchors.fill: parent color: "#000000" border.width: 3 border.color: Qt.rgba(root.activeInk.r, root.activeInk.g, root.activeInk.b, root.borderAlpha) radius: 0 antialiasing: false } Item { id: soulContainer x: { if (!root.isVolumeLayout) return root.padding; const left = root.padding; const right = Math.max(left, root.width - root.padding - root.iconBox); return left + (right - left) * (root.volumePercent / 100); } y: root.padding + 2 width: root.iconBox height: root.iconBox scale: 1 Behavior on x { NumberAnimation { duration: 150 easing.type: Easing.OutCubic } } IconImage { visible: root.appIconSource.length > 0 anchors.centerIn: parent implicitSize: root.appIconSize source: root.appIconSource asynchronous: true } Item { visible: root.appIconSource.length === 0 anchors.fill: parent Image { id: soulImage anchors.fill: parent source: "../Topbar/topbar/soul_small.png" smooth: false antialiasing: false } ColorOverlay { anchors.fill: soulImage source: soulImage color: root.soulInk } } Rectangle { id: soulFlashOverlay anchors.fill: parent color: "#ffffff" opacity: 0 radius: 0 antialiasing: false } } Column { id: contentColumn x: root.padding + root.iconBox + 10 y: root.padding - 1 width: root.width - x - root.padding spacing: 2 Text { visible: !root.isVolumeLayout text: String(root.notifTitle || "") color: Qt.rgba(root.activeInk.r, root.activeInk.g, root.activeInk.b, root.textAlpha) font.family: "8bitoperator JVE" font.pixelSize: 28 font.letterSpacing: 1 wrapMode: Text.NoWrap elide: Text.ElideRight renderType: Text.NativeRendering font.hintingPreference: Font.PreferNoHinting smooth: false antialiasing: false width: parent.width } Text { visible: !root.isVolumeLayout text: String(root.notifBody || "") color: Qt.rgba(root.activeInk.r, root.activeInk.g, root.activeInk.b, root.textAlpha) font.family: "8bitoperator JVE" font.pixelSize: 24 font.letterSpacing: 1 wrapMode: Text.Wrap maximumLineCount: 4 elide: Text.ElideRight textFormat: Text.PlainText renderType: Text.NativeRendering font.hintingPreference: Font.PreferNoHinting smooth: false antialiasing: false width: parent.width } Text { visible: root.isVolumeLayout text: "VOLUME " + root.volumePercent + "%" color: Qt.rgba(root.activeInk.r, root.activeInk.g, root.activeInk.b, root.textAlpha) font.family: "8bitoperator JVE" font.pixelSize: 30 font.letterSpacing: 1 wrapMode: Text.NoWrap elide: Text.ElideRight renderType: Text.NativeRendering font.hintingPreference: Font.PreferNoHinting smooth: false antialiasing: false width: parent.width } } HoverHandler { id: hoverHandler } TapHandler { acceptedButtons: Qt.LeftButton enabled: root.highlighted && !root.closing onTapped: root.beginClose("click") } }