diff --git a/modules/NotifCenter.qml b/modules/NotifCenter.qml index f89a2e7..7c3f2f8 100644 --- a/modules/NotifCenter.qml +++ b/modules/NotifCenter.qml @@ -68,25 +68,73 @@ M.HoverPanel { property var _pendingDismissIds: [] - function _cascadeDismiss() { - const list = M.NotifService.list; - if (list.length === 0) - return; - - _pendingDismissIds = list.map(n => n.id); - - const visibles = []; - for (let i = 0; i < list.length; i++) { - const d = notifList.itemAtIndex(i); - if (d && d.modelData?.state !== "dismissing") - visibles.push(d); + // Group notifications by appName, sorted by max urgency desc then most recent time desc + readonly property var _groups: { + const map = {}; + for (const n of M.NotifService.list) { + const key = n.appName || ""; + if (!map[key]) + map[key] = { + appName: key, + appIcon: n.appIcon, + notifs: [], + maxUrgency: 0, + maxTime: 0 + }; + map[key].notifs.push(n); + if (n.urgency > map[key].maxUrgency) + map[key].maxUrgency = n.urgency; + if (n.time > map[key].maxTime) + map[key].maxTime = n.time; } + return Object.values(map).sort((a, b) => { + if (b.maxUrgency !== a.maxUrgency) + return b.maxUrgency - a.maxUrgency; + return b.maxTime - a.maxTime; + }); + } + // Flat model: group header followed by its notifications + readonly property var _flatModel: { + const arr = []; + for (const g of _groups) { + arr.push({ + type: "header", + appName: g.appName, + appIcon: g.appIcon, + count: g.notifs.length + }); + for (const n of g.notifs) + arr.push({ + type: "notif", + data: n + }); + } + return arr; + } + + // Collect visible (non-dismissing) notif delegates, optionally filtered by appName + function _getVisibleNotifDelegates(appName) { + const result = []; + for (let i = 0; i < _flatModel.length; i++) { + const item = _flatModel[i]; + if (item.type !== "notif") + continue; + if (appName !== undefined && item.data.appName !== appName) + continue; + const d = notifList.itemAtIndex(i); + if (d && d._type === "notif" && d._notif?.state !== "dismissing") + result.push(d); + } + return result; + } + + function _startCascade(visibles, ids) { + _pendingDismissIds = ids; if (visibles.length === 0) { _finishCascade(); return; } - for (let i = 0; i < visibles.length; i++) { _cascadeTimer.createObject(menuWindow, { _target: visibles[i], @@ -96,6 +144,20 @@ M.HoverPanel { } } + function _cascadeDismiss() { + if (M.NotifService.list.length === 0) + return; + const ids = M.NotifService.list.map(n => n.id); + _startCascade(_getVisibleNotifDelegates(), ids); + } + + function _cascadeGroupDismiss(appName) { + const ids = M.NotifService.list.filter(n => n.appName === appName).map(n => n.id); + if (ids.length === 0) + return; + _startCascade(_getVisibleNotifDelegates(appName), ids); + } + function _finishCascade() { const ids = _pendingDismissIds; _pendingDismissIds = []; @@ -113,10 +175,8 @@ M.HoverPanel { onTriggered: { if (_target && _target.dismissVisualOnly) _target.dismissVisualOnly(); - if (_isLast) { - // Wait for the last animation to finish, then bulk dismiss + if (_isLast) _bulkTimer.createObject(menuWindow, {}); - } destroy(); } } @@ -148,33 +208,36 @@ M.HoverPanel { height: Math.min(contentHeight, 60 * (M.Modules.notifications.maxVisible || 10)) clip: true boundsBehavior: Flickable.StopAtBounds - model: M.NotifService.list + model: menuWindow._flatModel delegate: Item { - id: notifItem + id: notifDelegate required property var modelData required property int index + readonly property string _type: modelData.type + readonly property var _notif: _type === "notif" ? modelData.data : null + width: menuWindow.contentWidth height: _targetHeight * _heightScale - opacity: 0 clip: true + opacity: 0 - readonly property real _targetHeight: notifContent.height + 12 + readonly property real _targetHeight: _type === "header" ? 28 : (notifContent.implicitHeight + 12) property real _heightScale: 1 property bool _skipDismiss: false function dismiss() { - if (notifItem.modelData.state === "dismissing") + if (_type !== "notif" || _notif.state === "dismissing") return; - notifItem.modelData.beginDismiss(); + _notif.beginDismiss(); _dismissAnim.start(); } function dismissVisualOnly() { - if (notifItem.modelData.state === "dismissing") + if (_type !== "notif" || _notif.state === "dismissing") return; - notifItem.modelData.beginDismiss(); + _notif.beginDismiss(); _skipDismiss = true; _dismissAnim.start(); } @@ -183,194 +246,264 @@ M.HoverPanel { NumberAnimation { id: fadeIn - target: notifItem + target: notifDelegate property: "opacity" to: 1 duration: 150 easing.type: Easing.OutCubic } - Rectangle { + // ---- Group header ---- + Item { + visible: notifDelegate._type === "header" anchors.fill: parent - anchors.leftMargin: 4 - anchors.rightMargin: 4 - color: notifArea.containsMouse ? M.Theme.base02 : "transparent" - radius: M.Theme.radius - Rectangle { - visible: (notifItem.modelData.hints?.value ?? -1) >= 0 - anchors.top: parent.top - anchors.bottom: parent.bottom + Image { + id: _headerIcon anchors.left: parent.left - width: parent.width * Math.min(1, Math.max(0, (notifItem.modelData.hints?.value ?? 0) / 100)) - color: M.Theme.base02 - radius: parent.radius - - Behavior on width { - NumberAnimation { - duration: 200 - } - } - } - } - - // Urgency accent - Rectangle { - anchors.left: parent.left - anchors.leftMargin: 4 - anchors.top: parent.top - anchors.topMargin: 4 - anchors.bottom: parent.bottom - anchors.bottomMargin: 4 - width: 2 - radius: 1 - color: { - const u = notifItem.modelData.urgency; - return u === NotificationUrgency.Critical ? M.Theme.base08 : u === NotificationUrgency.Low ? M.Theme.base04 : M.Theme.base0D; - } - } - - Image { - id: ncIcon - anchors.left: parent.left - anchors.leftMargin: 14 - anchors.top: parent.top - anchors.topMargin: 6 - width: 24 - height: 24 - source: { - const img = notifItem.modelData.image; - if (img) - return img; - const ic = notifItem.modelData.appIcon; - if (!ic) - return ""; - return ic.startsWith("/") ? ic : Quickshell.iconPath(ic, "dialog-information"); - } - visible: status === Image.Ready - fillMode: Image.PreserveAspectFit - sourceSize: Qt.size(24, 24) - asynchronous: true - } - - Column { - id: notifContent - anchors.left: ncIcon.visible ? ncIcon.right : parent.left - anchors.right: dismissBtn.left - anchors.top: parent.top - anchors.leftMargin: ncIcon.visible ? 6 : 14 - anchors.topMargin: 6 - spacing: 1 - - // App + time - Row { - width: parent.width - Text { - text: notifItem.modelData.appName || "Notification" - color: M.Theme.base04 - font.pixelSize: M.Theme.fontSize - 2 - font.family: M.Theme.fontFamily - elide: Text.ElideRight - width: parent.width - ageText.width - 4 - } - Text { - id: ageText - text: { - const diff = Math.floor((Date.now() - notifItem.modelData.time) / 60000); - if (diff < 1) - return "now"; - if (diff < 60) - return diff + "m"; - if (diff < 1440) - return Math.floor(diff / 60) + "h"; - return Math.floor(diff / 1440) + "d"; - } - color: M.Theme.base03 - font.pixelSize: M.Theme.fontSize - 2 - font.family: M.Theme.fontFamily + anchors.leftMargin: 10 + anchors.verticalCenter: parent.verticalCenter + width: M.Theme.fontSize + 2 + height: M.Theme.fontSize + 2 + source: { + if (notifDelegate._type !== "header") + return ""; + const ic = notifDelegate.modelData.appIcon; + if (!ic) + return ""; + return ic.startsWith("/") ? ic : Quickshell.iconPath(ic, "dialog-information"); } + visible: status === Image.Ready + fillMode: Image.PreserveAspectFit + sourceSize: Qt.size(M.Theme.fontSize + 2, M.Theme.fontSize + 2) + asynchronous: true } Text { - width: parent.width - text: notifItem.modelData.summary || "" + anchors.left: _headerIcon.visible ? _headerIcon.right : parent.left + anchors.leftMargin: _headerIcon.visible ? 6 : 10 + anchors.right: _groupDismissBtn.left + anchors.rightMargin: 6 + anchors.verticalCenter: parent.verticalCenter + text: notifDelegate._type === "header" ? (notifDelegate.modelData.appName || "Unknown") : "" color: M.Theme.base05 - font.pixelSize: M.Theme.fontSize + font.pixelSize: M.Theme.fontSize - 1 font.family: M.Theme.fontFamily font.bold: true elide: Text.ElideRight } Text { - width: parent.width - text: notifItem.modelData.body || "" - color: M.Theme.base04 + id: _groupDismissBtn + anchors.right: parent.right + anchors.rightMargin: 10 + anchors.verticalCenter: parent.verticalCenter + text: "\uF1F8" + color: _groupDismissArea.containsMouse ? M.Theme.base08 : M.Theme.base04 font.pixelSize: M.Theme.fontSize - 1 - font.family: M.Theme.fontFamily - wrapMode: Text.WordWrap - maximumLineCount: 2 - elide: Text.ElideRight - visible: text !== "" - } + font.family: M.Theme.iconFontFamily - // Actions - Row { - spacing: 4 - visible: notifItem.modelData.actions && notifItem.modelData.actions.length > 0 - - Repeater { - model: notifItem.modelData.actions || [] - delegate: Rectangle { - required property var modelData - width: actText.implicitWidth + 10 - height: actText.implicitHeight + 4 - radius: M.Theme.radius - color: actArea.containsMouse ? M.Theme.base02 : "transparent" - border.color: M.Theme.base03 - border.width: 1 - - Text { - id: actText - anchors.centerIn: parent - text: parent.modelData.text - color: M.Theme.base0D - font.pixelSize: M.Theme.fontSize - 2 - font.family: M.Theme.fontFamily - } - MouseArea { - id: actArea - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: { - parent.modelData.invoke(); - M.NotifService.dismiss(notifItem.modelData.id); - } - } + MouseArea { + id: _groupDismissArea + anchors.fill: parent + anchors.margins: -4 + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + if (notifDelegate._type === "header") + menuWindow._cascadeGroupDismiss(notifDelegate.modelData.appName); } } } } - // Dismiss button - Text { - id: dismissBtn - anchors.right: parent.right - anchors.rightMargin: 10 - anchors.top: parent.top - anchors.topMargin: 8 - text: "\uF00D" - color: dismissArea.containsMouse ? M.Theme.base08 : M.Theme.base03 - font.pixelSize: M.Theme.fontSize - 1 - font.family: M.Theme.iconFontFamily + // ---- Individual notification ---- + Item { + id: _notifRow + visible: notifDelegate._type === "notif" + anchors.fill: parent - MouseArea { - id: dismissArea + // Hover + progress bar background + Rectangle { anchors.fill: parent - anchors.margins: -4 + anchors.leftMargin: 4 + anchors.rightMargin: 4 + color: notifArea.containsMouse ? M.Theme.base02 : "transparent" + radius: M.Theme.radius + + Rectangle { + visible: (notifDelegate._notif?.hints?.value ?? -1) >= 0 + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.left: parent.left + width: parent.width * Math.min(1, Math.max(0, (notifDelegate._notif?.hints?.value ?? 0) / 100)) + color: M.Theme.base02 + radius: parent.radius + + Behavior on width { + NumberAnimation { + duration: 200 + } + } + } + } + + // Urgency accent bar + Rectangle { + anchors.left: parent.left + anchors.leftMargin: 4 + anchors.top: parent.top + anchors.topMargin: 4 + anchors.bottom: parent.bottom + anchors.bottomMargin: 4 + width: 2 + radius: 1 + color: { + const u = notifDelegate._notif?.urgency ?? NotificationUrgency.Normal; + return u === NotificationUrgency.Critical ? M.Theme.base08 : u === NotificationUrgency.Low ? M.Theme.base04 : M.Theme.base0D; + } + } + + // App icon / image + Image { + id: ncIcon + anchors.left: parent.left + anchors.leftMargin: 14 + anchors.top: parent.top + anchors.topMargin: 6 + width: 24 + height: 24 + source: { + if (notifDelegate._type !== "notif") + return ""; + const img = notifDelegate._notif?.image; + if (img) + return img; + const ic = notifDelegate._notif?.appIcon; + if (!ic) + return ""; + return ic.startsWith("/") ? ic : Quickshell.iconPath(ic, "dialog-information"); + } + visible: status === Image.Ready + fillMode: Image.PreserveAspectFit + sourceSize: Qt.size(24, 24) + asynchronous: true + } + + Column { + id: notifContent + anchors.left: ncIcon.visible ? ncIcon.right : parent.left + anchors.right: dismissBtn.left + anchors.top: parent.top + anchors.leftMargin: ncIcon.visible ? 6 : 14 + anchors.topMargin: 6 + spacing: 1 + + // Summary + time on the same row + Row { + width: parent.width + + Text { + text: notifDelegate._notif?.summary ?? "" + color: M.Theme.base05 + font.pixelSize: M.Theme.fontSize + font.family: M.Theme.fontFamily + font.bold: true + elide: Text.ElideRight + width: parent.width - _notifTime.implicitWidth - 4 + } + + Text { + id: _notifTime + text: notifDelegate._notif?.timeStr ?? "" + color: M.Theme.base03 + font.pixelSize: M.Theme.fontSize - 2 + font.family: M.Theme.fontFamily + } + } + + Text { + width: parent.width + text: notifDelegate._notif?.body ?? "" + color: M.Theme.base04 + font.pixelSize: M.Theme.fontSize - 1 + font.family: M.Theme.fontFamily + wrapMode: Text.WordWrap + maximumLineCount: 2 + elide: Text.ElideRight + visible: text !== "" + } + + // Actions + Row { + spacing: 4 + visible: notifDelegate._notif?.actions && notifDelegate._notif.actions.length > 0 + + Repeater { + model: notifDelegate._notif?.actions ?? [] + delegate: Rectangle { + required property var modelData + width: actText.implicitWidth + 10 + height: actText.implicitHeight + 4 + radius: M.Theme.radius + color: actArea.containsMouse ? M.Theme.base02 : "transparent" + border.color: M.Theme.base03 + border.width: 1 + + Text { + id: actText + anchors.centerIn: parent + text: parent.modelData.text + color: M.Theme.base0D + font.pixelSize: M.Theme.fontSize - 2 + font.family: M.Theme.fontFamily + } + + MouseArea { + id: actArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + parent.modelData.invoke(); + M.NotifService.dismiss(notifDelegate._notif.id); + } + } + } + } + } + } + + // Per-notification dismiss button + Text { + id: dismissBtn + anchors.right: parent.right + anchors.rightMargin: 10 + anchors.top: parent.top + anchors.topMargin: 8 + text: "\uF00D" + color: dismissArea.containsMouse ? M.Theme.base08 : M.Theme.base03 + font.pixelSize: M.Theme.fontSize - 1 + font.family: M.Theme.iconFontFamily + + MouseArea { + id: dismissArea + anchors.fill: parent + anchors.margins: -4 + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: notifDelegate.dismiss() + } + } + + // Right-click to dismiss + MouseArea { + id: notifArea + anchors.fill: parent + z: -1 hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: _dismissAnim.start() + acceptedButtons: Qt.RightButton + onClicked: notifDelegate.dismiss() } } @@ -378,14 +511,14 @@ M.HoverPanel { id: _dismissAnim ParallelAnimation { NumberAnimation { - target: notifItem + target: notifDelegate property: "x" to: menuWindow.contentWidth duration: 200 easing.type: Easing.InCubic } NumberAnimation { - target: notifItem + target: notifDelegate property: "opacity" to: 0 duration: 200 @@ -393,7 +526,7 @@ M.HoverPanel { } } NumberAnimation { - target: notifItem + target: notifDelegate property: "_heightScale" to: 0 duration: 150 @@ -401,20 +534,11 @@ M.HoverPanel { } ScriptAction { script: { - if (!notifItem._skipDismiss) - M.NotifService.dismiss(notifItem.modelData.id); + if (notifDelegate._notif && !notifDelegate._skipDismiss) + M.NotifService.dismiss(notifDelegate._notif.id); } } } - - MouseArea { - id: notifArea - anchors.fill: parent - z: -1 - hoverEnabled: true - acceptedButtons: Qt.RightButton - onClicked: _dismissAnim.start() - } } }