diff --git a/Modules/Notifications/NotificationPopup.qml b/Modules/Notifications/NotificationPopup.qml index 842d40d2..8491058a 100644 --- a/Modules/Notifications/NotificationPopup.qml +++ b/Modules/Notifications/NotificationPopup.qml @@ -10,12 +10,11 @@ import qs.Widgets PanelWindow { id: root - required property var notificationData // Individual notification wrapper + required property var notificationData // Individual notification wrapper required property string notificationId readonly property bool isPopup: notificationData.popup readonly property int expireTimeout: notificationData.notification.expireTimeout - - property int verticalOffset: 0 + property int verticalOffset: notificationData && notificationData.initialOffset || 0 property bool initialAnimation: true property bool fadingOut: false property bool slideOut: false @@ -29,9 +28,12 @@ PanelWindow { WlrLayershell.exclusiveZone: -1 WlrLayershell.keyboardFocus: WlrKeyboardFocus.None color: "transparent" - implicitWidth: 400 - implicitHeight: 116 // Individual notifications have fixed height + implicitHeight: 116 // Individual notifications have fixed height + Component.onCompleted: { + initialAnimation = false; // kicks the right→left slide-in + enterDelay.start(); // start TTL after entrance + } anchors { top: true @@ -43,43 +45,46 @@ PanelWindow { right: 12 } - Behavior on verticalOffset { - NumberAnimation { - duration: Anims.durMed - easing.type: Easing.BezierSpline - easing.bezierCurve: Anims.emphasized - } - } - Timer { id: enterDelay - interval: Anims.durMed // must match the entrance duration + + interval: Anims.durMed // must match the entrance duration repeat: false onTriggered: notificationData.timer.start() } - Component.onCompleted: { - initialAnimation = false; // kicks the right→left slide-in - enterDelay.start(); // start TTL after entrance + Timer { + id: forceHideTimer + + interval: 500 // Force hide after 500ms if stuck + repeat: false + onTriggered: { + console.warn("NotificationPopup: Forcing exit for stuck notification"); + exitFinished(); + } } Connections { - target: notificationData function onPopupChanged() { if (!notificationData.popup) { - if (notificationData.removedByLimit) { + if (notificationData.removedByLimit) slideOut = true; - } else { + 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] + anchors.fill: parent anchors.margins: 4 radius: Theme.cornerRadiusLarge @@ -88,50 +93,12 @@ PanelWindow { border.width: notificationData.urgency === 2 ? 2 : 1 clip: true opacity: (fadingOut || slideOut) ? 0 : 1 - scale: slideOut ? 0.98 : 1.0 + scale: slideOut ? 0.98 : 1 - Behavior on opacity { - NumberAnimation { - duration: 180 - easing.type: Easing.BezierSpline - easing.bezierCurve: Anims.emphasized - onRunningChanged: if (!running && opacity === 0) root.visible = false - } - } - - 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; - } - Behavior on x { - enabled: initialAnimation || slideOut - NumberAnimation { - id: xAnim - 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 - exitFinished(); - } - } - } - } - } - } - - // Shadow layers + // Shadow layers - marked for resource cleanup Rectangle { + id: shadowLayer1 + anchors.fill: parent anchors.margins: -3 color: "transparent" @@ -142,6 +109,8 @@ PanelWindow { } Rectangle { + id: shadowLayer2 + anchors.fill: parent anchors.margins: -2 color: "transparent" @@ -152,6 +121,8 @@ PanelWindow { } Rectangle { + id: shadowLayer3 + anchors.fill: parent color: "transparent" border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.12) @@ -184,11 +155,14 @@ PanelWindow { position: 0.021 color: "transparent" } + } + } Item { id: notificationContent + anchors.top: parent.top anchors.left: parent.left anchors.right: parent.right @@ -199,8 +173,10 @@ PanelWindow { 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 @@ -212,8 +188,11 @@ PanelWindow { anchors.verticalCenter: parent.verticalCenter IconImage { + id: iconImage + anchors.fill: parent anchors.margins: 2 + asynchronous: true source: { if (parent.hasNotificationImage) return notificationData.cleanImage; @@ -222,6 +201,7 @@ PanelWindow { const appIcon = notificationData.appIcon; if (appIcon.startsWith("file://") || appIcon.startsWith("http://") || appIcon.startsWith("https://")) return appIcon; + return Quickshell.iconPath(appIcon, ""); } return ""; @@ -240,10 +220,12 @@ PanelWindow { font.weight: Font.Bold color: Theme.primaryText } + } Rectangle { id: textContainer + anchors.left: iconContainer.right anchors.leftMargin: 12 anchors.right: closeButton.left @@ -310,11 +292,14 @@ PanelWindow { Qt.openUrlExternally(link); } } + } + } DankActionButton { id: closeButton + anchors.right: parent.right anchors.top: parent.top iconName: "close" @@ -325,11 +310,13 @@ PanelWindow { notificationData.popup = false; } } + } // Main hover area for persistence and click handling MouseArea { id: cardHoverArea + anchors.fill: parent hoverEnabled: true acceptedButtons: Qt.LeftButton @@ -341,10 +328,85 @@ PanelWindow { onExited: { if (notificationData.popup) notificationData.timer.restart(); + } onClicked: { notificationData.popup = false; } } + + 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(); + } + } + } + + } + + 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; + } + + Behavior on x { + enabled: initialAnimation || slideOut + + NumberAnimation { + id: xAnim + + 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(); + } + } + } + } + + } + + } + } + + Behavior on verticalOffset { + NumberAnimation { + duration: Anims.durMed + easing.type: Easing.BezierSpline + easing.bezierCurve: Anims.emphasized + } + + } + } diff --git a/Modules/Notifications/NotificationPopupManager.qml b/Modules/Notifications/NotificationPopupManager.qml index 061a3ad0..80c2e2c7 100644 --- a/Modules/Notifications/NotificationPopupManager.qml +++ b/Modules/Notifications/NotificationPopupManager.qml @@ -1,115 +1,171 @@ import QtQuick +import Quickshell import qs.Common import qs.Services -Item { - id: root +QtObject { + id: manager - property list popupInstances: [] - property int baseNotificationHeight: 130 // Height of a single notification + margins + property var popupLoaders: [] + property int maxTargetNotifications: 3 + property int baseNotificationHeight: 132 property bool dismissalInProgress: false - property int maxTargetNotifications: 3 // Target number of notifications to maintain - - Timer { - id: dismissalTimer - interval: 500 // Half second delay between dismissals + + property Timer dismissalTimer: Timer { + interval: 200 + repeat: false onTriggered: dismissNextOldest() } - - Timer { - id: dismissalDelayTimer - interval: 1 // Start immediately - onTriggered: startSequentialDismissal() - } - Component { - id: popupComponent - NotificationPopup {} - } + property Component popupLoaderComponent: Component { + Loader { + id: popupLoader - Connections { - target: NotificationService - function onNotificationQueueChanged() { - syncPopupsWithQueue(); - repositionAll(); + 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 Connections notificationConnections: Connections { function onVisibleNotificationsChanged() { - repositionAll(); + syncPopupsWithQueue(NotificationService.visibleNotifications); } + + target: NotificationService } - - function syncPopupsWithQueue() { - const queue = NotificationService.notificationQueue.filter(n => n && n.popup); - - // Clean up destroyed popups first - popupInstances = popupInstances.filter(p => p && p.notificationId); - - // DON'T aggressively destroy popups - let them handle their own lifecycle - // Only remove popups that are actually destroyed/invalid - // The popup will destroy itself when notificationData.popup becomes false - - // Only create NEW notifications, don't touch existing ones AT ALL - for (const notif of queue) { - const existingPopup = popupInstances.find(p => p.notificationId === notif.notification.id); - if (existingPopup) { - // CRITICAL: Do absolutely NOTHING to existing popups - // Don't change their verticalOffset, don't touch any properties - continue; - } - - // Calculate position for NEW notification only - at the bottom of ACTIVE stack - const currentActive = popupInstances.filter(p => p && p.notificationData && p.notificationData.popup).length; - const popup = popupComponent.createObject(root, { - notificationData: notif, - notificationId: notif.notification.id, - verticalOffset: currentActive * baseNotificationHeight // ✅ bottom of active stack - }); - - if (popup) { - popupInstances.push(popup); - - // Pin it until entrance finishes, then maybe start overflow - popup.entered.connect(function() { - repositionAll(); // it's now "stable"; allow vertical compaction - maybeStartOverflow(); // defer overflow until after slot N is fully occupied - }); - } - } - - // Overflow dismissal handled in Connections now + 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() { - // Only compact stable (non-entering) active popups - const stable = popupInstances.filter(p => p && p.notificationData && p.notificationData.popup && !p.entering); - for (let i = 0; i < stable.length; ++i) - stable[i].verticalOffset = i * baseNotificationHeight; + const stable = _stableItems(); + for (let i = 0; i < stable.length; ++i) { + const it = stable[i].item; + if (it) + it.verticalOffset = i * baseNotificationHeight; - // Newcomers keep their creation-time offset (slot N) until `entered()` + } } - + + function syncPopupsWithQueue(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; + } + } + 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; + + } + } + if (it.iconContainer && it.iconContainer.iconImage) { + it.iconContainer.iconImage.source = ""; + } + } + + function _onPopupExitFinished(L) { + NotificationService.releaseWrapper(L.notifWrapper); + _destroyPopupLoader(L); + repositionAll(); + maybeStartOverflow(); + } + function maybeStartOverflow() { - const active = popupInstances.filter(p => p && p.notificationData && p.notificationData.popup); - if (active.length > maxTargetNotifications && !dismissalInProgress) + const active = _activeItems(); + if (dismissalInProgress) + return ; + + if (active.length > maxTargetNotifications) startSequentialDismissal(); + } - + function startSequentialDismissal() { - if (dismissalInProgress) return; // Don't start multiple dismissals - dismissalInProgress = true; dismissNextOldest(); } - + function dismissNextOldest() { - const active = popupInstances.filter(p => p && p.notificationData.popup); - if (active.length <= maxTargetNotifications) { dismissalInProgress = false; return; } - const oldest = active[0]; + 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; // triggers slide-out in popup - dismissalTimer.restart(); // your existing 500ms is fine + oldest.notificationData.popup = false; + dismissalTimer.restart(); } } -} \ No newline at end of file + + +} diff --git a/Services/NotificationService.qml b/Services/NotificationService.qml index 04066fb8..9d4366e7 100644 --- a/Services/NotificationService.qml +++ b/Services/NotificationService.qml @@ -15,6 +15,8 @@ Singleton { property list notificationQueue: [] property list visibleNotifications: [] + property list history: [] + property int maxHistory: 200 property int maxVisibleNotifications: 3 property bool addGateBusy: false property int enterAnimMs: 400 @@ -78,6 +80,8 @@ Singleton { property bool popup: false property bool removedByLimit: false + property bool isPersistent: true + property int initialOffset: 0 onPopupChanged: { if (!popup) { @@ -229,6 +233,36 @@ Singleton { } } + function releaseWrapper(w) { + // Remove from visible + let v = visibleNotifications.slice(); + const vi = v.indexOf(w); + if (vi !== -1) { + v.splice(vi, 1); + visibleNotifications = v; + } + + // Remove from queue + let q = notificationQueue.slice(); + const qi = q.indexOf(w); + if (qi !== -1) { + q.splice(qi, 1); + notificationQueue = q; + } + + // Push to bounded history or destroy if non-persistent + if (w && w.isPersistent) { + let h = history.slice(); + h.push(w); + if (h.length > maxHistory) { + h.splice(0, h.length - maxHistory); + } + history = h; + } else if (w && w.destroy) { + w.destroy(); + } + } + // Android 16-style notification grouping functions function getGroupKey(wrapper) {