From c5067c4e7ffabf719c204ba4137bd11ba14aa68d Mon Sep 17 00:00:00 2001 From: Damocles Date: Mon, 13 Apr 2026 20:14:11 +0200 Subject: [PATCH] perf: replace notifcenter repeater with listview for virtualization --- modules/NotifCenter.qml | 560 +++++++++++++++++++--------------------- 1 file changed, 272 insertions(+), 288 deletions(-) diff --git a/modules/NotifCenter.qml b/modules/NotifCenter.qml index 139922b..50c95d6 100644 --- a/modules/NotifCenter.qml +++ b/modules/NotifCenter.qml @@ -64,24 +64,32 @@ M.PopupPanel { } } - property var _delegates: [] - property var _pendingDismissIds: [] function _cascadeDismiss() { - const dels = _delegates.filter(d => d && d.modelData && !d.modelData.closed); - if (dels.length === 0) + const list = M.NotifService.list; + if (list.length === 0) return; - _pendingDismissIds = dels.map(d => d.modelData.id); + _pendingDismissIds = list.map(n => n.id); - for (let i = 0; i < dels.length; i++) { - const d = dels[i]; - const isLast = (i === dels.length - 1); + const visibles = []; + for (let i = 0; i < list.length; i++) { + const d = notifList.itemAtIndex(i); + if (d && d.modelData?.state !== "dismissing") + visibles.push(d); + } + + if (visibles.length === 0) { + _finishCascade(); + return; + } + + for (let i = 0; i < visibles.length; i++) { _cascadeTimer.createObject(menuWindow, { - _target: d, + _target: visibles[i], _delay: i * 60, - _isLast: isLast + _isLast: i === visibles.length - 1 }); } } @@ -114,7 +122,7 @@ M.PopupPanel { property Component _bulkTimer: Component { Timer { - interval: 400 // swipe (200) + collapse (150) + margin + interval: 400 // swipe (200) + collapse (150) + margin running: true onTriggered: { menuWindow._finishCascade(); @@ -132,297 +140,273 @@ M.PopupPanel { } // Notification list (scrollable) - Item { + ListView { + id: notifList width: menuWindow.panelWidth - height: Math.min(notifFlick.contentHeight, _maxHeight) - readonly property real _itemHeight: 60 - readonly property real _maxHeight: _itemHeight * (M.Modules.notifications.maxVisible || 10) + height: Math.min(contentHeight, 60 * (M.Modules.notifications.maxVisible || 10)) + clip: true + boundsBehavior: Flickable.StopAtBounds + model: M.NotifService.list - Flickable { - id: notifFlick - anchors.fill: parent - contentWidth: width - contentHeight: notifCol.implicitHeight + delegate: Item { + id: notifItem + required property var modelData + required property int index + + width: menuWindow.panelWidth + height: _targetHeight * _heightScale + opacity: 0 clip: true - boundsBehavior: Flickable.StopAtBounds - Column { - id: notifCol - width: parent.width + readonly property real _targetHeight: notifContent.height + 12 + property real _heightScale: 1 + property bool _skipDismiss: false - Repeater { - model: M.NotifService.list + function dismiss() { + if (notifItem.modelData.state === "dismissing") + return; + notifItem.modelData.beginDismiss(); + _dismissAnim.start(); + } - delegate: Item { - id: notifItem - required property var modelData - required property int index + function dismissVisualOnly() { + if (notifItem.modelData.state === "dismissing") + return; + notifItem.modelData.beginDismiss(); + _skipDismiss = true; + _dismissAnim.start(); + } - width: menuWindow.panelWidth - height: _targetHeight * _heightScale - opacity: 0 - clip: true + Component.onCompleted: fadeIn.start() - readonly property real _targetHeight: notifContent.height + 12 - property real _heightScale: 1 - property bool _skipDismiss: false + NumberAnimation { + id: fadeIn + target: notifItem + property: "opacity" + to: 1 + duration: 150 + easing.type: Easing.OutCubic + } - function dismiss() { - if (notifItem.modelData.state === "dismissing") - return; - notifItem.modelData.beginDismiss(); - _dismissAnim.start(); - } + Rectangle { + anchors.fill: parent + anchors.leftMargin: 4 + anchors.rightMargin: 4 + color: notifArea.containsMouse ? M.Theme.base02 : "transparent" + radius: M.Theme.radius - function dismissVisualOnly() { - if (notifItem.modelData.state === "dismissing") - return; - notifItem.modelData.beginDismiss(); - _skipDismiss = true; - _dismissAnim.start(); - } - - Component.onCompleted: { - menuWindow._delegates.push(notifItem); - fadeIn.start(); - } - Component.onDestruction: { - const idx = menuWindow._delegates.indexOf(notifItem); - if (idx >= 0) - menuWindow._delegates.splice(idx, 1); - } + Rectangle { + visible: (notifItem.modelData.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, (notifItem.modelData.hints?.value ?? 0) / 100)) + color: M.Theme.base02 + radius: parent.radius + Behavior on width { NumberAnimation { - id: fadeIn - target: notifItem - property: "opacity" - to: 1 - duration: 150 - easing.type: Easing.OutCubic - } - - Rectangle { - 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 - 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: notifItem.modelData.image || notifItem.modelData.appIcon || "" - 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 - } - } - - Text { - width: parent.width - text: notifItem.modelData.summary || "" - color: M.Theme.base05 - font.pixelSize: M.Theme.fontSize - font.family: M.Theme.fontFamily - font.bold: true - elide: Text.ElideRight - } - - Text { - width: parent.width - text: notifItem.modelData.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: 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); - } - } - } - } - } - } - - // 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: _dismissAnim.start() - } - } - - SequentialAnimation { - id: _dismissAnim - ParallelAnimation { - NumberAnimation { - target: notifItem - property: "x" - to: menuWindow.panelWidth - duration: 200 - easing.type: Easing.InCubic - } - NumberAnimation { - target: notifItem - property: "opacity" - to: 0 - duration: 200 - easing.type: Easing.InCubic - } - } - NumberAnimation { - target: notifItem - property: "_heightScale" - to: 0 - duration: 150 - easing.type: Easing.OutCubic - } - ScriptAction { - script: { - if (!notifItem._skipDismiss) - M.NotifService.dismiss(notifItem.modelData.id); - } - } - } - - MouseArea { - id: notifArea - anchors.fill: parent - z: -1 - hoverEnabled: true - acceptedButtons: Qt.RightButton - onClicked: _dismissAnim.start() + duration: 200 } } - } // Repeater - } // Column - } // Flickable - } // Item + } + } + + // 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: notifItem.modelData.image || notifItem.modelData.appIcon || "" + 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 + } + } + + Text { + width: parent.width + text: notifItem.modelData.summary || "" + color: M.Theme.base05 + font.pixelSize: M.Theme.fontSize + font.family: M.Theme.fontFamily + font.bold: true + elide: Text.ElideRight + } + + Text { + width: parent.width + text: notifItem.modelData.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: 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); + } + } + } + } + } + } + + // 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: _dismissAnim.start() + } + } + + SequentialAnimation { + id: _dismissAnim + ParallelAnimation { + NumberAnimation { + target: notifItem + property: "x" + to: menuWindow.panelWidth + duration: 200 + easing.type: Easing.InCubic + } + NumberAnimation { + target: notifItem + property: "opacity" + to: 0 + duration: 200 + easing.type: Easing.InCubic + } + } + NumberAnimation { + target: notifItem + property: "_heightScale" + to: 0 + duration: 150 + easing.type: Easing.OutCubic + } + ScriptAction { + script: { + if (!notifItem._skipDismiss) + M.NotifService.dismiss(notifItem.modelData.id); + } + } + } + + MouseArea { + id: notifArea + anchors.fill: parent + z: -1 + hoverEnabled: true + acceptedButtons: Qt.RightButton + onClicked: _dismissAnim.start() + } + } + } // Empty state Text {