From aa8c07b1360ba49f4d0f352a81dcd796b4def7fe Mon Sep 17 00:00:00 2001 From: Kris Date: Mon, 2 Mar 2026 19:28:15 +0200 Subject: [PATCH] fancy notifs --- CONSTANTS.txt | 45 +- Shell/Notifications/NotificationCard.qml | 626 +++++++++--------- Shell/Notifications/NotificationGroup.qml | 179 +++++ Shell/Notifications/NotificationLayer.qml | 39 +- Shell/Notifications/NotificationModel.qml | 167 ++++- Shell/Notifications/NotificationTheme.qml | 46 ++ Shell/ShellStateManager.qml | 20 +- .../QuickSettings/QuickSettingsApp.qml | 49 +- 8 files changed, 811 insertions(+), 360 deletions(-) create mode 100644 Shell/Notifications/NotificationGroup.qml create mode 100644 Shell/Notifications/NotificationTheme.qml diff --git a/CONSTANTS.txt b/CONSTANTS.txt index abf3088..818f836 100644 --- a/CONSTANTS.txt +++ b/CONSTANTS.txt @@ -45,4 +45,47 @@ top bar: y: 767 without: x: 1154 - y: 504 \ No newline at end of file + y: 504 + +notifications: + panel: + margin top: 24 + margin right: 24 + topbar reserved height: 182 + + card: + width: 420 + padding: 14 + border width: 3 + icon box: 28 + app icon size: 24 + content gap: 10 + line gap: 2 + action gap: 8 + action height: 34 + action border width: 2 + collapsed body lines: 2 + expanded body lines: 8 + stack peek count: 2 + stack peek height: 36 + stack peek step: 18 + drag dismiss threshold: 140 + + colors: + background: #000000 + border: #ffffff + text: #ffffff + hover invert background: #ffffff + hover invert text: #000000 + + timeouts: + fallback default ms: 5000 + fallback low ms: 4500 + fallback normal ms: 5000 + fallback critical ms: 8000 + + animation: + enter ms: 170 + exit ms: 140 + fade ms: 120 + expand ms: 120 diff --git a/Shell/Notifications/NotificationCard.qml b/Shell/Notifications/NotificationCard.qml index 78a2a1f..1f6faca 100644 --- a/Shell/Notifications/NotificationCard.qml +++ b/Shell/Notifications/NotificationCard.qml @@ -1,392 +1,396 @@ import QtQuick -import Qt5Compat.GraphicalEffects import Quickshell +import Quickshell.Services.Notifications import Quickshell.Widgets Item { id: root + property NotificationTheme theme 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: ({}) + 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 highlighted: hoverHandler.hovered property bool closing: false - property string closeReason: "" - property real borderAlpha: 1 - property real textAlpha: 1 + property bool contextOpen: false 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; + 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); } - function hintString(name) { - if (!notifHints || typeof notifHints !== "object") - return ""; - const value = notifHints[name]; - return value === undefined || value === null ? "" : String(value); + 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; } - function textBlob() { - return (notifAppName + " " + notifTitle + " " + notifBody + " " + hintString("category")).toLowerCase(); + 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 parseVolumePercent() { - const source = (notifTitle + " " + notifBody).toUpperCase(); - const match = source.match(/VOLUME\s*(\d{1,3})%/); - if (!match || match.length < 2) - return -1; + function urgencyTimeoutMs() { + if (!notifObject) + return theme.fallbackNormalTimeoutMs; - const parsed = Number(match[1]); - if (!Number.isFinite(parsed)) - return -1; + const rawSeconds = Number(notifObject.expireTimeout); + if (Number.isFinite(rawSeconds) && rawSeconds > 0) + return Math.max(1000, Math.round(rawSeconds * 1000)); - return Math.max(0, Math.min(100, parsed)); + const urgency = notifObject.urgency; + if (urgency === NotificationUrgency.Critical) + return theme.fallbackCriticalTimeoutMs; + if (urgency === NotificationUrgency.Low) + return theme.fallbackLowTimeoutMs; + return theme.fallbackNormalTimeoutMs; } - 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"; + 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"; } - 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(); - } + closeReason = reason; + closeAnimation.start(); } - implicitWidth: 420 - implicitHeight: Math.max(1, contentColumn.implicitHeight * collapseFactor + padding * 2) + 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 - transformOrigin: Item.TopRight + opacity: 1 SequentialAnimation { - id: entryAnimation - running: true - - PropertyAction { - target: root - property: "opacity" - value: 0 - } - PropertyAction { - target: root - property: "scale" - value: 0.95 - } + 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: 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 - } + 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 } } } - 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") - } + 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.notifTimeoutMs + interval: root.urgencyTimeoutMs() repeat: false - running: !root.notifCritical && root.notifTimeoutMs > 0 + running: !root.peekMode 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) + color: theme ? theme.panelBackground : "#000000" + border.width: theme ? theme.borderWidth : 3 + border.color: theme ? theme.panelBorder : "#ffffff" radius: 0 antialiasing: false + visible: !peekMode } - 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 - } + 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 - x: root.padding + root.iconBox + 10 - y: root.padding - 1 - width: root.width - x - root.padding - spacing: 2 + 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 - 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 + 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 + } + } } - 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 + Column { + visible: root.contextOpen width: parent.width - } + spacing: theme ? theme.actionGap : 8 - 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 - } + 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) + } + } + } + } } - HoverHandler { - id: hoverHandler + 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.highlighted && !root.closing - onTapped: root.beginClose("click") + 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() } diff --git a/Shell/Notifications/NotificationGroup.qml b/Shell/Notifications/NotificationGroup.qml new file mode 100644 index 0000000..3cfc403 --- /dev/null +++ b/Shell/Notifications/NotificationGroup.qml @@ -0,0 +1,179 @@ +import QtQuick + +Item { + id: root + + property NotificationTheme theme + property var groupData: null + property bool expanded: false + property bool peekEnabled: false + + signal closeFinished(int notificationId, string reason) + signal expandRequested(string groupKey, bool expanded) + + readonly property var entries: groupData && groupData.notifications ? groupData.notifications : [] + readonly property int entryCount: entries.length + readonly property bool showCompressedStack: !expanded && entryCount > 1 + readonly property int visiblePeekCount: showCompressedStack && peekEnabled ? Math.min(theme ? theme.stackPeekCount : 2, Math.max(0, entryCount - 1)) : 0 + + width: theme ? theme.stackWidth : 420 + implicitHeight: { + if (expanded) + return expandedColumn.implicitHeight; + + const base = newestCard.implicitHeight; + const step = theme ? theme.stackPeekStep : 18; + return base + visiblePeekCount * step; + } + height: implicitHeight + + Item { + id: compressedStack + visible: !root.expanded + width: parent.width + height: parent.height + + Repeater { + model: root.visiblePeekCount + + NotificationCard { + property int peekIndex: index + + theme: root.theme + notificationId: root.entries[peekIndex + 1].notificationId + notifObject: root.entries[peekIndex + 1].notifObject + notifVersion: root.entries[peekIndex + 1].notifVersion + createdAtMs: root.entries[peekIndex + 1].createdAt + peekMode: true + interactive: false + width: root.width + y: Math.max(0, newestCard.implicitHeight - (theme ? theme.stackPeekHeight : 36) + (theme ? theme.stackPeekStep : 18) * (peekIndex + 1)) + z: 10 - peekIndex + + onCloseFinished: function (closedNotificationId, reason) { + root.closeFinished(closedNotificationId, reason); + } + } + } + + NotificationCard { + id: newestCard + theme: root.theme + notificationId: root.entryCount > 0 ? root.entries[0].notificationId : -1 + notifObject: root.entryCount > 0 ? root.entries[0].notifObject : null + notifVersion: root.entryCount > 0 ? root.entries[0].notifVersion : 0 + createdAtMs: root.entryCount > 0 ? root.entries[0].createdAt : Date.now() + interactive: root.entryCount <= 1 + width: root.width + y: 0 + z: 20 + + onCloseFinished: function (closedNotificationId, reason) { + root.closeFinished(closedNotificationId, reason); + } + } + + Rectangle { + visible: root.entryCount > 1 && root.peekEnabled + width: 82 + height: 18 + x: root.width - width - 18 + y: newestCard.implicitHeight + 1 + color: theme ? theme.panelBackground : "#000000" + border.width: 2 + border.color: theme ? theme.panelBorder : "#ffffff" + radius: 0 + antialiasing: false + z: 25 + + Text { + anchors.centerIn: parent + text: "+" + String(Math.max(0, root.entryCount - 1)) + color: theme ? theme.panelText : "#ffffff" + font.family: theme ? theme.fontFamily : "8bitoperator JVE" + font.pixelSize: 14 + font.letterSpacing: theme ? theme.fontLetterSpacing : 1 + renderType: Text.NativeRendering + font.hintingPreference: Font.PreferNoHinting + smooth: false + antialiasing: false + } + } + + TapHandler { + enabled: root.entryCount > 1 + acceptedButtons: Qt.LeftButton + onTapped: root.expandRequested(root.groupData.groupKey, true) + } + + TapHandler { + enabled: root.entryCount > 1 + acceptedButtons: Qt.RightButton + onTapped: { + if (root.entryCount > 0) + root.closeFinished(root.entries[0].notificationId, "dismiss"); + } + } + } + + Column { + id: expandedColumn + visible: root.expanded + width: parent.width + spacing: 0 + + Repeater { + model: root.entries + + NotificationCard { + theme: root.theme + notificationId: modelData.notificationId + notifObject: modelData.notifObject + notifVersion: modelData.notifVersion + createdAtMs: modelData.createdAt + width: root.width + forceExpanded: true + + onCloseFinished: function (closedNotificationId, reason) { + root.closeFinished(closedNotificationId, reason); + } + } + } + + Rectangle { + visible: root.entryCount > 1 + width: parent.width + height: theme ? theme.actionHeight : 34 + color: theme ? theme.panelBackground : "#000000" + border.width: theme ? theme.actionBorderWidth : 2 + border.color: theme ? theme.panelBorder : "#ffffff" + radius: 0 + antialiasing: false + + Text { + anchors.centerIn: parent + text: "Collapse" + color: 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 + onTapped: root.expandRequested(root.groupData.groupKey, false) + } + } + } + + Behavior on implicitHeight { + NumberAnimation { + duration: theme ? theme.expandMs : 120 + easing.type: Easing.InOutCubic + } + } +} diff --git a/Shell/Notifications/NotificationLayer.qml b/Shell/Notifications/NotificationLayer.qml index 6168f57..edc00f0 100644 --- a/Shell/Notifications/NotificationLayer.qml +++ b/Shell/Notifications/NotificationLayer.qml @@ -13,8 +13,8 @@ PanelWindow { } margins { - top: 24 - right: 24 + top: theme.panelMarginTop + right: theme.panelMarginRight } // Top layer keeps notifications above normal windows while still usually @@ -30,13 +30,18 @@ PanelWindow { visible: true color: "#00000000" - property int stackWidth: 420 - property int menuReservedHeight: 182 + NotificationTheme { + id: theme + } + + property int stackWidth: theme.stackWidth + property int menuReservedHeight: theme.topbarReservedHeight + readonly property bool stackPreviewEnabled: Boolean(ShellStateManager.global("notifications.stackPreviewEnabled", true)) readonly property int screenHeight: screen ? screen.height : 1080 readonly property bool topbarOpen: ShellStateManager.shellOpen - readonly property int stackOffsetY: topbarOpen ? menuReservedHeight + 24 : 0 - readonly property int maxStackHeight: Math.max(120, screenHeight - 24 - stackOffsetY - 24) + readonly property int stackOffsetY: topbarOpen ? menuReservedHeight + theme.panelMarginTop : 0 + readonly property int maxStackHeight: Math.max(120, screenHeight - theme.panelMarginTop - stackOffsetY - theme.panelMarginTop) implicitWidth: stackWidth implicitHeight: stackOffsetY + notificationViewport.height @@ -87,22 +92,20 @@ PanelWindow { spacing: 0 Repeater { - model: notificationModel.notifications + model: notificationModel.groupedNotifications - NotificationCard { - notificationId: rowNotificationId - notifTitle: rowTitle - notifBody: rowBody - notifUrgency: rowUrgency - notifAppName: rowAppName - notifAppIcon: rowAppIcon - notifImage: rowImage - notifTimeoutMs: rowTimeoutMs - notifCritical: rowCritical - notifHints: rowHints + NotificationGroup { + theme: theme + groupData: modelData + expanded: notificationModel.groupIsExpanded(modelData.groupKey) + peekEnabled: notificationLayer.stackPreviewEnabled width: notificationLayer.stackWidth + onExpandRequested: function (groupKey, shouldExpand) { + notificationModel.setGroupExpanded(groupKey, shouldExpand); + } + onCloseFinished: function (closedNotificationId, reason) { notificationModel.closeById(closedNotificationId, reason); } diff --git a/Shell/Notifications/NotificationModel.qml b/Shell/Notifications/NotificationModel.qml index 6184c81..d82bee4 100644 --- a/Shell/Notifications/NotificationModel.qml +++ b/Shell/Notifications/NotificationModel.qml @@ -5,6 +5,14 @@ QtObject { id: root property ListModel notifications: ListModel {} + property var groupedNotifications: [] + property var groupExpansion: ({}) + property int versionCounter: 0 + + function nextVersion() { + versionCounter += 1; + return versionCounter; + } function indexOfId(notificationId) { for (let i = 0; i < notifications.count; i++) { @@ -15,16 +23,104 @@ QtObject { return -1; } - function timeoutMsFor(notificationObject) { - const isCritical = notificationObject.urgency === NotificationUrgency.Critical; - if (isCritical) - return -1; + function normalizedString(value) { + if (value === undefined || value === null) + return ""; + return String(value); + } - const rawSeconds = Number(notificationObject.expireTimeout); - if (!Number.isFinite(rawSeconds) || rawSeconds <= 0) - return 5000; + function hintString(notificationObject, name) { + if (!notificationObject || !notificationObject.hints) + return ""; + const value = notificationObject.hints[name]; + return value === undefined || value === null ? "" : String(value); + } - return Math.max(1000, Math.round(rawSeconds * 1000)); + function groupKeyFor(notificationObject) { + const desktopEntry = normalizedString(hintString(notificationObject, "desktop-entry")).trim().toLowerCase(); + if (desktopEntry.length > 0) + return "desktop:" + desktopEntry; + + const appName = normalizedString(notificationObject ? notificationObject.appName : "").trim().toLowerCase(); + if (appName.length > 0) + return "app:" + appName; + + const appIcon = normalizedString(notificationObject ? notificationObject.appIcon : "").trim().toLowerCase(); + if (appIcon.length > 0) + return "icon:" + appIcon; + + const image = normalizedString(notificationObject ? notificationObject.image : "").trim().toLowerCase(); + if (image.length > 0) + return "image:" + image; + + return "id:" + String(notificationObject ? notificationObject.id : Date.now()); + } + + function sortRowsNewestFirst(left, right) { + return (right.createdAt || 0) - (left.createdAt || 0); + } + + function groupSortNewestFirst(left, right) { + return (right.newestAt || 0) - (left.newestAt || 0); + } + + function rebuildGroups() { + const grouped = ({}); + + for (let i = 0; i < notifications.count; i++) { + const row = notifications.get(i); + const key = row.rowGroupKey; + if (!grouped[key]) { + grouped[key] = { + groupKey: key, + appName: row.rowAppName, + appIcon: row.rowAppIcon, + desktopEntry: row.rowDesktopEntry, + newestAt: row.rowCreatedAt, + notifications: [] + }; + } + + grouped[key].notifications.push({ + notificationId: row.rowNotificationId, + notifObject: row.rowObject, + notifVersion: row.rowVersion, + createdAt: row.rowCreatedAt, + appName: row.rowAppName, + appIcon: row.rowAppIcon, + image: row.rowImage + }); + + if (row.rowCreatedAt > grouped[key].newestAt) + grouped[key].newestAt = row.rowCreatedAt; + } + + const groups = []; + for (const groupKey in grouped) { + if (!grouped.hasOwnProperty(groupKey)) + continue; + + const group = grouped[groupKey]; + group.notifications.sort(sortRowsNewestFirst); + groups.push(group); + } + + groups.sort(groupSortNewestFirst); + groupedNotifications = groups; + } + + function groupIsExpanded(groupKey) { + return Boolean(groupExpansion[groupKey]); + } + + function setGroupExpanded(groupKey, expanded) { + const next = Object.assign({}, groupExpansion); + next[groupKey] = Boolean(expanded); + groupExpansion = next; + } + + function toggleGroupExpanded(groupKey) { + setGroupExpanded(groupKey, !groupIsExpanded(groupKey)); } function addNotification(notificationObject) { @@ -33,32 +129,50 @@ QtObject { return; } - const existingIndex = indexOfId(notificationObject.id); - if (existingIndex >= 0) - notifications.remove(existingIndex); - notificationObject.tracked = true; const idCopy = notificationObject.id; + const objectCopy = notificationObject; notificationObject.closed.connect(function () { const rowIndex = indexOfId(idCopy); - if (rowIndex >= 0) + if (rowIndex >= 0 && notifications.get(rowIndex).rowObject === objectCopy) { notifications.remove(rowIndex); + rebuildGroups(); + } }); + const nowMs = Date.now(); + const groupKey = groupKeyFor(notificationObject); + + const existingIndex = indexOfId(notificationObject.id); + if (existingIndex >= 0) { + notifications.set(existingIndex, { + rowNotificationId: notificationObject.id, + rowObject: notificationObject, + rowVersion: nextVersion(), + rowCreatedAt: nowMs, + rowGroupKey: groupKey, + rowAppName: normalizedString(notificationObject.appName), + rowAppIcon: normalizedString(notificationObject.appIcon), + rowImage: normalizedString(notificationObject.image), + rowDesktopEntry: hintString(notificationObject, "desktop-entry") + }); + rebuildGroups(); + return; + } + notifications.append({ rowNotificationId: notificationObject.id, - rowTitle: String(notificationObject.summary || ""), - rowBody: String(notificationObject.body || ""), - rowUrgency: NotificationUrgency.toString(notificationObject.urgency), - rowAppName: String(notificationObject.appName || ""), - rowAppIcon: String(notificationObject.appIcon || ""), - rowImage: String(notificationObject.image || ""), - rowTimeoutMs: timeoutMsFor(notificationObject), - rowCritical: notificationObject.urgency === NotificationUrgency.Critical, - rowHints: notificationObject.hints || ({}), - rowObject: notificationObject + rowObject: notificationObject, + rowVersion: nextVersion(), + rowCreatedAt: nowMs, + rowGroupKey: groupKey, + rowAppName: normalizedString(notificationObject.appName), + rowAppIcon: normalizedString(notificationObject.appIcon), + rowImage: normalizedString(notificationObject.image), + rowDesktopEntry: hintString(notificationObject, "desktop-entry") }); + rebuildGroups(); } function closeById(notificationId, reason) { @@ -70,13 +184,15 @@ QtObject { const notifObject = row.rowObject; if (notifObject) { - if (reason === "timeout") + if (reason === "timeout") { notifObject.expire(); - else + } else if (reason === "click" || reason === "dismiss" || reason === "drag") { notifObject.dismiss(); + } } notifications.remove(rowIndex); + rebuildGroups(); } property NotificationServer server: NotificationServer { @@ -84,6 +200,7 @@ QtObject { bodySupported: true bodyMarkupSupported: false bodyHyperlinksSupported: false + actionsSupported: true // NotificationServer is Quickshell's DBus implementation for // org.freedesktop.Notifications, so this is the notification source. diff --git a/Shell/Notifications/NotificationTheme.qml b/Shell/Notifications/NotificationTheme.qml new file mode 100644 index 0000000..be4e3ea --- /dev/null +++ b/Shell/Notifications/NotificationTheme.qml @@ -0,0 +1,46 @@ +import QtQuick + +QtObject { + id: theme + + readonly property string fontFamily: "8bitoperator JVE" + readonly property int fontLetterSpacing: 1 + + readonly property color panelBackground: "#000000" + readonly property color panelBorder: "#ffffff" + readonly property color panelText: "#ffffff" + readonly property color hoverInvertBackground: "#ffffff" + readonly property color hoverInvertText: "#000000" + + readonly property int stackWidth: 420 + readonly property int panelMarginTop: 24 + readonly property int panelMarginRight: 24 + readonly property int topbarReservedHeight: 182 + + readonly property int cardPadding: 14 + readonly property int borderWidth: 3 + readonly property int iconBox: 28 + readonly property int appIconSize: 24 + readonly property int contentGap: 10 + readonly property int lineGap: 2 + readonly property int actionGap: 8 + readonly property int actionHeight: 34 + readonly property int actionBorderWidth: 2 + readonly property int collapsedBodyLines: 2 + readonly property int expandedBodyLines: 8 + + readonly property int stackPeekCount: 2 + readonly property int stackPeekHeight: 36 + readonly property int stackPeekStep: 18 + + readonly property int dragDismissThreshold: 140 + + readonly property int fallbackLowTimeoutMs: 4500 + readonly property int fallbackNormalTimeoutMs: 5000 + readonly property int fallbackCriticalTimeoutMs: 8000 + + readonly property int enterMs: 170 + readonly property int exitMs: 140 + readonly property int fadeMs: 120 + readonly property int expandMs: 120 +} diff --git a/Shell/ShellStateManager.qml b/Shell/ShellStateManager.qml index 62af2eb..1b2fc16 100644 --- a/Shell/ShellStateManager.qml +++ b/Shell/ShellStateManager.qml @@ -19,7 +19,9 @@ QtObject { property bool appUsageLoaded: false property var windowRequests: ({}) property var quickSettingsPayload: ({}) - property var globals: ({}) + property var globals: ({ + "notifications.stackPreviewEnabled": true + }) signal shellOpened signal shellClosed @@ -198,10 +200,24 @@ QtObject { } function setGlobal(key, value) { - globals[key] = value; + var updated = Object.assign({}, globals); + updated[key] = value; + globals = updated; } function global(key, defaultValue) { return globals.hasOwnProperty(key) ? globals[key] : defaultValue; } + + function notificationStackPreviewEnabled() { + return Boolean(global("notifications.stackPreviewEnabled", true)); + } + + function setNotificationStackPreviewEnabled(enabled) { + setGlobal("notifications.stackPreviewEnabled", Boolean(enabled)); + } + + function toggleNotificationStackPreviewEnabled() { + setNotificationStackPreviewEnabled(!notificationStackPreviewEnabled()); + } } diff --git a/Shell/Windows/QuickSettings/QuickSettingsApp.qml b/Shell/Windows/QuickSettings/QuickSettingsApp.qml index a8e5ff1..a1fe1df 100644 --- a/Shell/Windows/QuickSettings/QuickSettingsApp.qml +++ b/Shell/Windows/QuickSettings/QuickSettingsApp.qml @@ -36,6 +36,7 @@ Item { property int activeSelection: 0 property bool inBluetoothMenu: false property bool inWallpaperMenu: false + property bool inNotificationsMenu: false property bool isSelected: false @@ -81,7 +82,7 @@ Item { } function currentAction() { - if (root.inBluetoothMenu || root.inWallpaperMenu) + if (root.inBluetoothMenu || root.inWallpaperMenu || root.inNotificationsMenu) return null; return menuLength() > 0 ? menuAt(activeSelection) : null; } @@ -195,6 +196,7 @@ Item { property int bluetoothActionIndex: 1 property int wallpaperActionIndex: 2 + property int notificationsActionIndex: 3 property var actions: [ { name: "Master Volume", @@ -218,6 +220,7 @@ Item { ent: function () { root.inBluetoothMenu = true; root.inWallpaperMenu = false; + root.inNotificationsMenu = false; root.isSelected = false; root.activeSelection = 0; root.clampSelection(); @@ -231,6 +234,7 @@ Item { ent: function () { root.inWallpaperMenu = true; root.inBluetoothMenu = false; + root.inNotificationsMenu = false; root.isSelected = false; var currentIndex = root.currentWallpaperIndex(); root.activeSelection = currentIndex >= 0 ? currentIndex : 0; @@ -241,10 +245,39 @@ Item { getState: function () { return ""; } + }, + { + name: "Notifications", + ent: function () { + root.inNotificationsMenu = true; + root.inBluetoothMenu = false; + root.inWallpaperMenu = false; + root.isSelected = false; + root.activeSelection = 0; + root.clampSelection(); + }, + getState: function () { + return ""; + } } ] - property var menuModel: root.inBluetoothMenu ? Bluetooth.devices : (root.inWallpaperMenu ? wallpaperFolderModel : actions) + property var notificationActions: [ + { + name: "Stack Preview", + ent: function () { + if (manager && manager.toggleNotificationStackPreviewEnabled) + manager.toggleNotificationStackPreviewEnabled(); + }, + getState: function () { + if (!manager || !manager.notificationStackPreviewEnabled) + return "ON"; + return manager.notificationStackPreviewEnabled() ? "ON" : "OFF"; + } + } + ] + + property var menuModel: root.inBluetoothMenu ? Bluetooth.devices : (root.inWallpaperMenu ? wallpaperFolderModel : (root.inNotificationsMenu ? notificationActions : actions)) FolderListModel { id: wallpaperFolderModel @@ -320,7 +353,7 @@ Item { } Text { - text: root.inBluetoothMenu ? "BLUETOOTH" : (root.inWallpaperMenu ? "WALLPAPER" : "CONFIG") + text: root.inBluetoothMenu ? "BLUETOOTH" : (root.inWallpaperMenu ? "WALLPAPER" : (root.inNotificationsMenu ? "NOTIFICATIONS" : "CONFIG")) font.family: "8bitoperator JVE" font.pixelSize: 71 renderType: Text.NativeRendering @@ -440,6 +473,10 @@ Item { const wallpaperPath = wallpaperPathAt(activeSelection); if (wallpaperPath && wallpaperPath.length > 0) root.applyWallpaper(wallpaperPath); + } else if (root.inNotificationsMenu) { + const notificationAction = menuAt(activeSelection); + if (notificationAction && notificationAction.ent) + notificationAction.ent(); } else { const a = currentAction(); if (a && a.ent) { @@ -459,6 +496,11 @@ Item { root.scrollOffset = 0; root.activeSelection = root.wallpaperActionIndex; root.clampSelection(); + } else if (root.inNotificationsMenu) { + root.inNotificationsMenu = false; + root.isSelected = false; + root.activeSelection = root.notificationsActionIndex; + root.clampSelection(); } else if (root.inBluetoothMenu) { root.inBluetoothMenu = false; root.isSelected = false; @@ -481,6 +523,7 @@ Item { root.isSelected = false; root.inBluetoothMenu = false; root.inWallpaperMenu = false; + root.inNotificationsMenu = false; root.scrollOffset = 0; root.refreshCurrentWallpaper(); ShellInputManager.registerHandler("quickSettings", handleKey);