From 39e3107db6c5d45f8246993e6927b01463f4e0e3 Mon Sep 17 00:00:00 2001 From: bbedward Date: Sat, 26 Jul 2025 00:27:59 -0400 Subject: [PATCH] notifications: reverse order top to bottom, fix warnings --- Modules/Notifications/NotificationPopup.qml | 650 +++++++++--------- .../NotificationPopupManager.qml | 210 ++---- Services/NotificationService.qml | 3 + 3 files changed, 381 insertions(+), 482 deletions(-) diff --git a/Modules/Notifications/NotificationPopup.qml b/Modules/Notifications/NotificationPopup.qml index 8491058a..06e72a38 100644 --- a/Modules/Notifications/NotificationPopup.qml +++ b/Modules/Notifications/NotificationPopup.qml @@ -8,32 +8,36 @@ import qs.Services import qs.Widgets PanelWindow { - id: root + id: win - required property var notificationData // Individual notification wrapper + required property var notificationData required property string notificationId readonly property bool isPopup: notificationData.popup readonly property int expireTimeout: notificationData.notification.expireTimeout - property int verticalOffset: notificationData && notificationData.initialOffset || 0 - property bool initialAnimation: true - property bool fadingOut: false - property bool slideOut: false - property bool entering: true + + property int screenY: 0 + onScreenYChanged: margins.top = Theme.barHeight + 16 + screenY + Behavior on screenY { + enabled: !exiting + NumberAnimation { + duration: 220 + easing.type: Easing.OutCubic + } + } + + property int rowHeight: 132 + property bool exiting: false signal entered() signal exitFinished() - visible: isPopup || fadingOut || slideOut + visible: true WlrLayershell.layer: WlrLayershell.Overlay WlrLayershell.exclusiveZone: -1 WlrLayershell.keyboardFocus: WlrKeyboardFocus.None color: "transparent" implicitWidth: 400 - implicitHeight: 116 // Individual notifications have fixed height - Component.onCompleted: { - initialAnimation = false; // kicks the right→left slide-in - enterDelay.start(); // start TTL after entrance - } + implicitHeight: 116 anchors { top: true @@ -41,372 +45,330 @@ PanelWindow { } margins { - top: Theme.barHeight + 16 + verticalOffset + top: Theme.barHeight + 16 right: 12 } - Timer { - id: enterDelay - - interval: Anims.durMed // must match the entrance duration - repeat: false - onTriggered: notificationData.timer.start() - } - - Timer { - id: forceHideTimer - - interval: 500 // Force hide after 500ms if stuck - repeat: false - onTriggered: { - console.warn("NotificationPopup: Forcing exit for stuck notification"); - exitFinished(); - } - } - - Connections { - function onPopupChanged() { - if (!notificationData.popup) { - if (notificationData.removedByLimit) - slideOut = true; - else - fadingOut = true; - // Start force hide timer as safety net - forceHideTimer.start(); - // When a notification is no longer a popup, we want to remove it from the visible list - // so that other notifications can move into its place. - NotificationService.removeFromVisibleNotifications(notificationData); - } - } - - target: notificationData - } - - Rectangle { - property var shadowLayers: [shadowLayer1, shadowLayer2, shadowLayer3] - + Item { + id: content anchors.fill: parent - anchors.margins: 4 - radius: Theme.cornerRadiusLarge - color: Theme.popupBackground() - border.color: 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.urgency === 2 ? 2 : 1 - clip: true - opacity: (fadingOut || slideOut) ? 0 : 1 - scale: slideOut ? 0.98 : 1 - // Shadow layers - marked for resource cleanup - 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 - } - - // Critical notification accent - Rectangle { - anchors.fill: parent - radius: parent.radius - visible: 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: 16 - height: 86 - - Rectangle { - id: iconContainer - - readonly property bool hasNotificationImage: notificationData.image && notificationData.image !== "" - readonly property bool appIconIsImage: notificationData.appIcon && (notificationData.appIcon.startsWith("file://") || notificationData.appIcon.startsWith("http://") || notificationData.appIcon.startsWith("https://")) - property alias iconImage: iconImage - - 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 (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 ""; + transform: Translate { + id: tx + x: 400 + Behavior on x { + NumberAnimation { + id: xAnim + duration: 240 + easing.type: Easing.OutCubic + onRunningChanged: { + if (!running && win && !win.exiting && Math.abs(tx.x) < 0.5) win.entered(); + if (!running && win && win.exiting && Math.abs(tx.x - 96) < 0.5) maybeFinishExit(); } - visible: status === Image.Ready } - - Text { - anchors.centerIn: parent - visible: !parent.hasNotificationImage && (!notificationData.appIcon || notificationData.appIcon === "") - text: { - const 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: closeButton.left - anchors.rightMargin: 8 - anchors.top: parent.top - anchors.bottom: parent.bottom - anchors.bottomMargin: 8 - color: "transparent" - - Column { - width: parent.width - spacing: 2 - anchors.verticalCenter: parent.verticalCenter - - Text { - width: parent.width - text: { - if (notificationData.timeStr.length > 0) - return notificationData.appName + " • " + notificationData.timeStr; - else - return notificationData.appName; - } - color: Theme.surfaceVariantText - font.pixelSize: Theme.fontSizeSmall - font.weight: Font.Medium - elide: Text.ElideRight - maximumLineCount: 1 - } - - Text { - text: 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 { - property bool hasUrls: { - const urlRegex = /(https?:\/\/[^\s]+)/g; - return urlRegex.test(notificationData.body); - } - - text: { - let bodyText = notificationData.body; - if (bodyText.length > 105) - bodyText = bodyText.substring(0, 102) + "..."; - - const urlRegex = /(https?:\/\/[^\s]+)/g; - return bodyText.replace(urlRegex, '$1'); - } - color: Theme.surfaceVariantText - font.pixelSize: Theme.fontSizeSmall - width: parent.width - elide: Text.ElideRight - maximumLineCount: 2 - wrapMode: Text.WordWrap - visible: text.length > 0 - textFormat: Text.RichText - onLinkActivated: function(link) { - Qt.openUrlExternally(link); - } - } - - } - - } - - DankActionButton { - id: closeButton - - anchors.right: parent.right - anchors.top: parent.top - iconName: "close" - iconSize: 14 - buttonSize: 20 - z: 15 - onClicked: { - notificationData.popup = false; - } - } - - } - - // Main hover area for persistence and click handling - MouseArea { - id: cardHoverArea - - anchors.fill: parent - hoverEnabled: true - acceptedButtons: Qt.LeftButton - propagateComposedEvents: true - z: 0 - onEntered: { - notificationData.timer.stop(); - } - onExited: { - if (notificationData.popup) - notificationData.timer.restart(); - - } - onClicked: { - notificationData.popup = false; } } + opacity: win.exiting ? 0 : 1 Behavior on opacity { NumberAnimation { - duration: 180 - easing.type: Easing.BezierSpline - easing.bezierCurve: Anims.emphasized - onRunningChanged: { - if (!running && opacity === 0) { - forceHideTimer.stop(); // Cancel force hide since animation completed normally - exitFinished(); - } - } + id: fadeAnim + duration: 200 + easing.type: Easing.OutCubic + onRunningChanged: if (!running && win && win.exiting && content && content.opacity === 0) maybeFinishExit() } - } + scale: win.exiting ? 0.98 : 1.0 Behavior on scale { NumberAnimation { duration: 160 easing.type: Easing.OutCubic } - } - transform: Translate { - x: { - if (initialAnimation) - return 400; - // start off-screen right - if (slideOut) - return 64; - // gentle nudge on exit (was 400) - return 0; + layer.enabled: (Math.abs(tx.x) > 0.5) || win.exiting + 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.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.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 } - Behavior on x { - enabled: initialAnimation || slideOut + 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 + } - NumberAnimation { - id: xAnim + 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 + } - duration: Anims.durMed - easing.type: Easing.BezierSpline - easing.bezierCurve: slideOut ? Anims.emphasized : Anims.emphasizedDecel - onRunningChanged: { - if (!running) { - if (!slideOut) { - // entrance finished - entering = false; - entered(); - } else { - // exit finished - forceHideTimer.stop(); - // Cancel force hide since slide completed normally - exitFinished(); + Rectangle { + anchors.fill: parent + radius: parent.radius + visible: 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: 16 + height: 86 + + Rectangle { + id: iconContainer + readonly property bool hasNotificationImage: notificationData.image && notificationData.image !== "" + readonly property bool appIconIsImage: notificationData.appIcon && (notificationData.appIcon.startsWith("file://") || notificationData.appIcon.startsWith("http://") || notificationData.appIcon.startsWith("https://")) + property alias iconImage: iconImage + + 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 (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.appIcon || notificationData.appIcon === "") + text: { + const 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: closeButton.left + anchors.rightMargin: 8 + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.bottomMargin: 8 + color: "transparent" + + Column { + width: parent.width + spacing: 2 + anchors.verticalCenter: parent.verticalCenter + + Text { + width: parent.width + text: { + if (notificationData.timeStr.length > 0) + return notificationData.appName + " • " + notificationData.timeStr; + else + return notificationData.appName; + } + color: Theme.surfaceVariantText + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.Medium + elide: Text.ElideRight + maximumLineCount: 1 + } + + Text { + text: 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 { + property bool hasUrls: { + const urlRegex = /(https?:\/\/[^\s]+)/g; + return urlRegex.test(notificationData.body); + } + + text: { + let bodyText = notificationData.body; + if (bodyText.length > 105) + bodyText = bodyText.substring(0, 102) + "..."; + + const urlRegex = /(https?:\/\/[^\s]+)/g; + return bodyText.replace(urlRegex, '$1'); + } + color: Theme.surfaceVariantText + font.pixelSize: Theme.fontSizeSmall + width: parent.width + elide: Text.ElideRight + maximumLineCount: 2 + wrapMode: Text.WordWrap + visible: text.length > 0 + textFormat: Text.RichText + onLinkActivated: function(link) { + Qt.openUrlExternally(link); } } } } + DankActionButton { + id: closeButton + anchors.right: parent.right + anchors.top: parent.top + iconName: "close" + iconSize: 14 + buttonSize: 20 + z: 15 + onClicked: { + notificationData.popup = false; + } + } } + MouseArea { + id: cardHoverArea + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.LeftButton + propagateComposedEvents: true + z: 0 + onEntered: { + notificationData.timer.stop(); + } + onExited: { + if (notificationData.popup) + notificationData.timer.restart(); + } + onClicked: { + notificationData.popup = false; + } + } } - } - Behavior on verticalOffset { - NumberAnimation { - duration: Anims.durMed - easing.type: Easing.BezierSpline - easing.bezierCurve: Anims.emphasized - } - + Component.onCompleted: { + enterDelay.start(); + Qt.callLater(() => { tx.x = 0; }); } -} + Timer { + id: enterDelay + interval: Anims.durMed + repeat: false + onTriggered: notificationData.timer.start() + } + + Connections { + target: notificationData + function onPopupChanged() { + if (!notificationData.popup && !win.exiting) { + win.exiting = true; + win.screenY = win.screenY; + tx.x = 96; + exitWatchdog.restart(); + forceCleanupTimer.restart(); + NotificationService.removeFromVisibleNotifications(notificationData); + } + } + } + + Timer { + id: exitWatchdog + interval: 500 + repeat: false + onTriggered: if (win) win.exitFinished() + } + + Timer { + id: forceCleanupTimer + interval: 2000 + repeat: false + onTriggered: if (win) win.exitFinished() + } + + function maybeFinishExit() { + if (win && win.exiting && content && Math.abs(tx.x - 96) < 0.5 && content.opacity === 0) { + exitWatchdog.stop(); + forceCleanupTimer.stop(); + win.exitFinished(); + } + } +} \ No newline at end of file diff --git a/Modules/Notifications/NotificationPopupManager.qml b/Modules/Notifications/NotificationPopupManager.qml index 80c2e2c7..17ee003d 100644 --- a/Modules/Notifications/NotificationPopupManager.qml +++ b/Modules/Notifications/NotificationPopupManager.qml @@ -6,166 +6,100 @@ import qs.Services QtObject { id: manager - property var popupLoaders: [] property int maxTargetNotifications: 3 property int baseNotificationHeight: 132 - property bool dismissalInProgress: false + property int topMargin: 0 + property var popupWindows: [] - property Timer dismissalTimer: Timer { - interval: 200 - repeat: false - onTriggered: dismissNextOldest() - } - - property Component popupLoaderComponent: Component { - Loader { - id: popupLoader - - property var notifWrapper - - active: false - asynchronous: true - - sourceComponent: NotificationPopup { - id: popup - - notificationData: popupLoader.notifWrapper - notificationId: popupLoader.notifWrapper ? popupLoader.notifWrapper.notification.id : "" - onEntered: manager._onPopupEntered(popupLoader) - onSlideOutChanged: { - if (slideOut) { - manager._onPopupExitStarted(popupLoader); - } - } - onExitFinished: manager._onPopupExitFinished(popupLoader) - } + property Component popupComponent: Component { + NotificationPopup { + property var wrapper + notificationData: wrapper + notificationId: wrapper.notification.id + rowHeight: manager.baseNotificationHeight + onEntered: manager._onPopupEntered(this) + onExitFinished: manager._onPopupExitFinished(this) } } property Connections notificationConnections: Connections { - function onVisibleNotificationsChanged() { - syncPopupsWithQueue(NotificationService.visibleNotifications); - } - target: NotificationService - } - - function _createPopupLoader(notifWrapper) { - const L = popupLoaderComponent.createObject(manager, { - "notifWrapper": notifWrapper - }); - popupLoaders.push(L); - return L; - } - - function _destroyPopupLoader(L) { - const i = popupLoaders.indexOf(L); - if (i !== -1) { - popupLoaders.splice(i, 1); - popupLoaders = popupLoaders.slice(); - } - L.active = false; - L.sourceComponent = null; - } - - function _activeItems() { - return popupLoaders.filter((L) => { - return L.item && L.item.notificationData && L.item.notificationData.popup; - }); - } - - function _stableItems() { - return _activeItems().filter((L) => { - return !L.item.entering; - }); - } - - function repositionAll() { - const stable = _stableItems(); - for (let i = 0; i < stable.length; ++i) { - const it = stable[i].item; - if (it) - it.verticalOffset = i * baseNotificationHeight; - + function onVisibleNotificationsChanged() { + manager._sync(NotificationService.visibleNotifications); } } - function syncPopupsWithQueue(newWrappers) { + function _hasWindowFor(w) { return popupWindows.some(p => p && p.notificationData === w); } + + function _sync(newWrappers) { for (let w of newWrappers) { - if (!popupLoaders.some((L) => { - return L.notifWrapper === w; - })) { - const L = _createPopupLoader(w); - const actives = _activeItems().length; - w.initialOffset = actives * baseNotificationHeight; - L.active = true; + if (!_hasWindowFor(w)) _insertNewestAtTop(w); + } + for (let p of popupWindows.slice()) { + if (newWrappers.indexOf(p.notificationData) === -1 && p && !p.exiting) { + p.notificationData.removedByLimit = true; + p.notificationData.popup = false; } } - for (let L of popupLoaders.slice()) { - if (newWrappers.indexOf(L.notifWrapper) === -1) - _destroyPopupLoader(L); - - } - repositionAll(); } - function _onPopupEntered(L) { - repositionAll(); - maybeStartOverflow(); - } - - function _onPopupExitStarted(L) { - const it = L.item; - if (!it) - return ; - - if (it.shadowLayers) { - for (let layer of it.shadowLayers) { - if (layer) - layer.visible = false; - + function _insertNewestAtTop(wrapper) { + for (let p of popupWindows) { + if (p && p.notificationData && p.notificationData.popup && !p.exiting) { + p.screenY = p.screenY + baseNotificationHeight; } } - if (it.iconContainer && it.iconContainer.iconImage) { - it.iconContainer.iconImage.source = ""; + + const win = popupComponent.createObject(null, { wrapper: wrapper, screenY: topMargin }); + if (!win) { + console.warn("Popup create failed"); + return; + } + popupWindows.push(win); + _maybeStartOverflow(); + } + + function _active() { + return popupWindows.filter(p => p && p.notificationData && p.notificationData.popup); + } + + function _bottom() { + let b = null, max = -1; + for (let p of _active()) { + if (!p.exiting && p.screenY > max) { + max = p.screenY; + b = p; + } + } + return b; + } + + function _maybeStartOverflow() { + if (_active().length <= maxTargetNotifications + 1) return; + const b = _bottom(); + if (b && !b.exiting) { + b.notificationData.removedByLimit = true; + b.notificationData.popup = false; } } - function _onPopupExitFinished(L) { - NotificationService.releaseWrapper(L.notifWrapper); - _destroyPopupLoader(L); - repositionAll(); - maybeStartOverflow(); + function _onPopupEntered(p) { + // Entry completed } - function maybeStartOverflow() { - const active = _activeItems(); - if (dismissalInProgress) - return ; - - if (active.length > maxTargetNotifications) - startSequentialDismissal(); - - } - - function startSequentialDismissal() { - dismissalInProgress = true; - dismissNextOldest(); - } - - function dismissNextOldest() { - const active = _activeItems(); - if (active.length <= maxTargetNotifications) { - dismissalInProgress = false; - return ; - } - const oldest = active[0].item; - if (oldest) { - oldest.notificationData.removedByLimit = true; - oldest.notificationData.popup = false; - dismissalTimer.restart(); + function _onPopupExitFinished(p) { + const i = popupWindows.indexOf(p); + if (i !== -1) { + popupWindows.splice(i, 1); + popupWindows = popupWindows.slice(); } + if (NotificationService.releaseWrapper) NotificationService.releaseWrapper(p.notificationData); + p.destroy(); + + const survivors = _active().filter(s => !s.exiting).sort((a,b) => a.screenY - b.screenY); + for (let k = 0; k < survivors.length; ++k) + survivors[k].screenY = topMargin + k * baseNotificationHeight; + + _maybeStartOverflow(); } - - -} +} \ No newline at end of file diff --git a/Services/NotificationService.qml b/Services/NotificationService.qml index 9d4366e7..319e5bea 100644 --- a/Services/NotificationService.qml +++ b/Services/NotificationService.qml @@ -20,6 +20,7 @@ Singleton { property int maxVisibleNotifications: 3 property bool addGateBusy: false property int enterAnimMs: 400 + property int seqCounter: 0 Timer { id: addGate @@ -82,6 +83,7 @@ Singleton { property bool removedByLimit: false property bool isPersistent: true property int initialOffset: 0 + property int seq: 0 onPopupChanged: { if (!popup) { @@ -217,6 +219,7 @@ Singleton { const [next, ...rest] = notificationQueue; notificationQueue = rest; + next.seq = ++seqCounter; visibleNotifications = [...visibleNotifications, next]; next.popup = true;