From 487c56bc4772a3157dda5ecaaeb2ec85928a4e50 Mon Sep 17 00:00:00 2001 From: zach Date: Sun, 12 Apr 2026 19:28:20 +0200 Subject: [PATCH 1/2] test notif plugin --- Daemons/NotifServer.qml | 3 +- Modules/Notifications/Sidebar/Notif.qml | 65 +- .../Notifications/Sidebar/NotifActionList.qml | 4 +- Modules/Notifications/Sidebar/NotifDock.qml | 27 +- .../Notifications/Sidebar/NotifDockList.qml | 158 ++- Modules/Notifications/Sidebar/NotifGroup.qml | 2 +- .../Notifications/Sidebar/NotifGroupList.qml | 164 +-- Modules/Updates/UpdatesWidget.qml | 2 +- Plugins/ZShell/CMakeLists.txt | 1 + Plugins/ZShell/Components/CMakeLists.txt | 7 + Plugins/ZShell/Components/lazylistview.cpp | 1109 +++++++++++++++++ Plugins/ZShell/Components/lazylistview.hpp | 243 ++++ 12 files changed, 1564 insertions(+), 221 deletions(-) create mode 100644 Plugins/ZShell/Components/CMakeLists.txt create mode 100644 Plugins/ZShell/Components/lazylistview.cpp create mode 100644 Plugins/ZShell/Components/lazylistview.hpp diff --git a/Daemons/NotifServer.qml b/Daemons/NotifServer.qml index ca87549..4497370 100644 --- a/Daemons/NotifServer.qml +++ b/Daemons/NotifServer.qml @@ -113,11 +113,12 @@ Singleton { id: storage path: `${Paths.state}/notifs.json` + printErrors: false onLoadFailed: err => { if (err === FileViewError.FileNotFound) { root.loaded = true; - setText("[]"); + Qt.callLater(() => setText("[]")); } } onLoaded: { diff --git a/Modules/Notifications/Sidebar/Notif.qml b/Modules/Notifications/Sidebar/Notif.qml index 1ee3e11..1d99414 100644 --- a/Modules/Notifications/Sidebar/Notif.qml +++ b/Modules/Notifications/Sidebar/Notif.qml @@ -19,11 +19,12 @@ CustomRect { required property var visibilities color: { - const c = root.modelData.urgency === "critical" ? DynamicColors.palette.m3secondaryContainer : DynamicColors.layer(DynamicColors.palette.m3surfaceContainerHigh, 2); + const c = root.modelData?.urgency === "critical" ? DynamicColors.palette.m3secondaryContainer : DynamicColors.layer(DynamicColors.palette.m3surfaceContainerHigh, 2); return expanded ? c : Qt.alpha(c, 0); } implicitHeight: nonAnimHeight radius: 6 + state: expanded ? "expanded" : "" Behavior on implicitHeight { Anim { @@ -33,7 +34,6 @@ CustomRect { } states: State { name: "expanded" - when: root.expanded PropertyChanges { compactBody.anchors.margins: 10 @@ -63,10 +63,10 @@ CustomRect { anchors.left: parent.left anchors.top: parent.top - color: root.modelData.urgency === "critical" ? DynamicColors.palette.m3onSecondaryContainer : DynamicColors.palette.m3onSurface + color: root.modelData?.urgency === "critical" ? DynamicColors.palette.m3onSecondaryContainer : DynamicColors.palette.m3onSurface elide: Text.ElideRight maximumLineCount: 1 - text: root.modelData.summary + text: root.modelData?.summary ?? "" width: parent.width wrapMode: Text.WordWrap } @@ -76,7 +76,7 @@ CustomRect { anchors.left: parent.left anchors.top: parent.top - text: root.modelData.summary + text: root.modelData?.summary ?? "" visible: false } @@ -90,9 +90,9 @@ CustomRect { shouldBeActive: !root.expanded sourceComponent: CustomText { - color: root.modelData.urgency === "critical" ? DynamicColors.palette.m3secondary : DynamicColors.palette.m3outline + color: root.modelData?.urgency === "critical" ? DynamicColors.palette.m3secondary : DynamicColors.palette.m3outline elide: Text.ElideRight - text: root.modelData.body.replace(/\n/g, " ") + text: String(root.modelData?.body ?? "").replace(/\n/g, " ") textFormat: Text.StyledText } } @@ -108,7 +108,7 @@ CustomRect { animate: true color: DynamicColors.palette.m3outline font.pointSize: 11 - text: root.modelData.timeStr + text: root.modelData?.timeStr ?? "" } } @@ -130,8 +130,8 @@ CustomRect { id: body Layout.fillWidth: true - color: root.modelData.urgency === "critical" ? DynamicColors.palette.m3secondary : DynamicColors.palette.m3onSurface - text: root.modelData.body.replace(/(.)\n(?!\n)/g, "$1\n\n") || qsTr("No body given") + color: root.modelData?.urgency === "critical" ? DynamicColors.palette.m3secondary : DynamicColors.palette.m3onSurface + text: String(root.modelData?.body ?? "").replace(/(.)\n(?!\n)/g, "$1\n\n") || qsTr("No body given") textFormat: Text.MarkdownText wrapMode: Text.WordWrap @@ -148,14 +148,51 @@ CustomRect { } component WrappedLoader: Loader { + id: comp + required property bool shouldBeActive - active: opacity > 0 - opacity: shouldBeActive ? 1 : 0 + active: false + opacity: 0 - Behavior on opacity { - Anim { + states: State { + name: "active" + when: comp.shouldBeActive + + PropertyChanges { + comp.active: true + comp.opacity: 1 } } + transitions: [ + Transition { + from: "" + to: "active" + + SequentialAnimation { + PropertyAction { + property: "active" + } + + Anim { + property: "opacity" + } + } + }, + Transition { + from: "active" + to: "" + + SequentialAnimation { + Anim { + property: "opacity" + } + + PropertyAction { + property: "active" + } + } + } + ] } } diff --git a/Modules/Notifications/Sidebar/NotifActionList.qml b/Modules/Notifications/Sidebar/NotifActionList.qml index 3456d80..02ba10e 100644 --- a/Modules/Notifications/Sidebar/NotifActionList.qml +++ b/Modules/Notifications/Sidebar/NotifActionList.qml @@ -35,7 +35,7 @@ Item { { isClose: true }, - ...root.notif.actions, + ...(root.notif?.actions ?? ""), { isCopy: true } @@ -97,7 +97,7 @@ Item { id: actionInner anchors.centerIn: parent - sourceComponent: action.modelData.isClose || action.modelData.isCopy ? iconBtn : root.notif.hasActionIcons ? iconComp : textComp + sourceComponent: action.modelData.isClose || action.modelData.isCopy ? iconBtn : root.notif?.hasActionIcons ? iconComp : textComp } Component { diff --git a/Modules/Notifications/Sidebar/NotifDock.qml b/Modules/Notifications/Sidebar/NotifDock.qml index 34bbd93..8eb2ede 100644 --- a/Modules/Notifications/Sidebar/NotifDock.qml +++ b/Modules/Notifications/Sidebar/NotifDock.qml @@ -129,20 +129,27 @@ Item { Timer { id: clearTimer - interval: 50 + interval: Math.max(15, Math.min(80, 69.8 - 12.3 * Math.log(NotifServer.notClosed.length))) repeat: true + triggeredOnStart: true onTriggered: { - let next = null; - for (let i = 0; i < notifList.repeater.count; i++) { - next = notifList.repeater.itemAt(i); - if (!next?.closed) - break; - } - if (next) - next.closeAll(); - else + const first = NotifServer.notClosed[0]; + if (!first) { stop(); + return; + } + + const appName = first.appName; + let cleared = 0; + for (const n of NotifServer.notClosed.filter(n => n.appName === appName)) { + n.close(); + cleared++; + if (cleared > 30) { + interval = 5; + return; + } + } } } diff --git a/Modules/Notifications/Sidebar/NotifDockList.qml b/Modules/Notifications/Sidebar/NotifDockList.qml index 316b5eb..44f1788 100644 --- a/Modules/Notifications/Sidebar/NotifDockList.qml +++ b/Modules/Notifications/Sidebar/NotifDockList.qml @@ -1,60 +1,47 @@ pragma ComponentBehavior: Bound +import Quickshell +import QtQuick +import ZShell.Components import qs.Components import qs.Config import qs.Modules import qs.Daemons -import Quickshell -import QtQuick -Item { +LazyListView { id: root required property Flickable container - property bool flag required property Props props - readonly property alias repeater: repeater - readonly property int spacing: 8 - required property var visibilities + required property DrawerVisibilities visibilities - anchors.left: parent.left - anchors.right: parent.right - implicitHeight: { - const item = repeater.itemAt(repeater.count - 1); - return item ? item.y + item.implicitHeight : 0; - } - - Repeater { - id: repeater - - model: ScriptModel { - values: { - const map = new Map(); - for (const n of NotifServer.notClosed) - map.set(n.appName, null); - for (const n of NotifServer.list) - map.set(n.appName, null); - return [...map.keys()]; - } - - onValuesChanged: root.flagChanged() - } + anchors.left: parent?.left + anchors.right: parent?.right + asynchronous: true + cacheBuffer: 400 + implicitHeight: contentHeight + readyDelay: 1 + removeDuration: Appearance.anim.durations.normal + spacing: Appearance.spacing.small + useCustomViewport: true + viewport: Qt.rect(0, container.contentY, width, container.height) + delegate: Component { MouseArea { id: notif readonly property bool closed: notifInner.notifCount === 0 required property int index required property string modelData - readonly property alias nonAnimHeight: notifInner.nonAnimHeight property int startY function closeAll(): void { - for (const n of NotifServer.notClosed.filter(n => n.appName === modelData)) { - n.close(); - } + clearTimer.start(); } + LazyListView.preferredHeight: closed ? 0 : notifInner.nonAnimHeight + LazyListView.trackViewport: !notifInner.expanded && notifInner.nonAnimHeight < notifInner.implicitHeight + LazyListView.visibleHeight: notifInner.implicitHeight acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton cursorShape: pressed ? Qt.ClosedHandCursor : undefined drag.axis: Drag.XAxis @@ -62,36 +49,32 @@ Item { enabled: !closed hoverEnabled: true implicitHeight: notifInner.implicitHeight - implicitWidth: root.width + opacity: LazyListView.removing || closed || LazyListView.adding ? 0 : 1 preventStealing: true - y: { - root.flag; // Force update - let y = 0; - for (let i = 0; i < index; i++) { - const item = repeater.itemAt(i); - if (!item.closed) - y += item.nonAnimHeight + root.spacing; - } - return y; - } + scale: LazyListView.removing || closed ? 0.6 : LazyListView.adding ? 0 : 1 - containmentMask: QtObject { - function contains(p: point): bool { - if (!root.container.contains(notif.mapToItem(root.container, p))) - return false; - return notifInner.contains(p); + Behavior on opacity { + Anim { + } + } + Behavior on scale { + Anim { + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial } } Behavior on x { Anim { - duration: MaterialEasing.expressiveEffectsTime - easing.bezierCurve: MaterialEasing.expressiveEffects + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial } } Behavior on y { + enabled: notif.LazyListView.ready + Anim { - duration: MaterialEasing.expressiveEffectsTime - easing.bezierCurve: MaterialEasing.expressiveEffects + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial } } @@ -116,39 +99,22 @@ Item { closeAll(); } - ParallelAnimation { - running: true + Timer { + id: clearTimer - Anim { - from: 0 - property: "opacity" - target: notif - to: 1 - } + interval: 15 + repeat: true + triggeredOnStart: true - Anim { - duration: MaterialEasing.expressiveEffectsTime - easing.bezierCurve: MaterialEasing.expressiveEffects - from: 0 - property: "scale" - target: notif - to: 1 - } - } + onTriggered: { + const notifs = Notifs.notClosed.filter(n => n.appName === notif.modelData); + if (notifs.length === 0) { + stop(); + return; + } - ParallelAnimation { - running: notif.closed - - Anim { - property: "opacity" - target: notif - to: 0 - } - - Anim { - property: "scale" - target: notif - to: 0.6 + for (const n of notifs.slice(0, 30)) + n.close(); } } @@ -162,4 +128,30 @@ Item { } } } + model: ScriptModel { + values: { + const map = new Map(); + for (const n of Notifs.notClosed) + map.set(n.appName, null); + for (const n of Notifs.list) + map.set(n.appName, null); + return [...map.keys()]; + } + } + + onViewportAdjustNeeded: d => { + if (contentYAnim.running) + contentYAnim.complete(); + contentYAnim.to = Math.max(0, container.contentY + d); + contentYAnim.start(); + } + + Anim { + id: contentYAnim + + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + property: "contentY" + target: root.container + } } diff --git a/Modules/Notifications/Sidebar/NotifGroup.qml b/Modules/Notifications/Sidebar/NotifGroup.qml index d0b6052..14212d1 100644 --- a/Modules/Notifications/Sidebar/NotifGroup.qml +++ b/Modules/Notifications/Sidebar/NotifGroup.qml @@ -20,7 +20,7 @@ CustomRect { required property string modelData readonly property int nonAnimHeight: { const headerHeight = header.implicitHeight + (root.expanded ? Math.round(7 / 2) : 0); - const columnHeight = headerHeight + notifList.nonAnimHeight + column.Layout.topMargin + column.Layout.bottomMargin; + const columnHeight = headerHeight + notifList.layoutHeight + column.Layout.topMargin + column.Layout.bottomMargin; return Math.round(Math.max(Config.notifs.sizes.image, columnHeight) + 10 * 2); } readonly property int notifCount: notifs.reduce((acc, n) => n.closed ? acc : acc + 1, 0) diff --git a/Modules/Notifications/Sidebar/NotifGroupList.qml b/Modules/Notifications/Sidebar/NotifGroupList.qml index 0ba2a1f..0c10849 100644 --- a/Modules/Notifications/Sidebar/NotifGroupList.qml +++ b/Modules/Notifications/Sidebar/NotifGroupList.qml @@ -8,113 +8,51 @@ import Quickshell import QtQuick import QtQuick.Layouts -Item { +LazyListView { id: root required property Flickable container required property bool expanded - property bool flag - readonly property real nonAnimHeight: { - let h = -root.spacing; - for (let i = 0; i < repeater.count; i++) { - const item = repeater.itemAt(i); - if (!item.modelData.closed && !item.previewHidden) - h += item.nonAnimHeight + root.spacing; - } - return h; - } required property list notifs required property Props props - property bool showAllNotifs - readonly property int spacing: Math.round(7 / 2) - required property var visibilities + required property DrawerVisibilities visibilities signal requestToggleExpand(expand: bool) Layout.fillWidth: true - implicitHeight: nonAnimHeight - - Behavior on implicitHeight { - Anim { - duration: MaterialEasing.expressiveEffectsTime - easing.bezierCurve: MaterialEasing.expressiveEffects - } + asynchronous: true + cacheBuffer: 400 + implicitHeight: contentHeight + readyDelay: 1 + removeDuration: Appearance.anim.durations.normal + spacing: Math.round(Appearance.spacing.small / 2) + useCustomViewport: true + viewport: { + tWatcher.transform; // mapToItem is not reactive so use this to trigger updates + return Qt.rect(0, container.contentY - mapToItem(container.contentItem, 0, 0).y, width, container.height); } - onExpandedChanged: { - if (expanded) { - clearTimer.stop(); - showAllNotifs = true; - } else { - clearTimer.start(); - } - } - - Timer { - id: clearTimer - - interval: MaterialEasing.standardTime - - onTriggered: root.showAllNotifs = false - } - - Repeater { - id: repeater - - model: ScriptModel { - values: root.showAllNotifs ? root.notifs : root.notifs.slice(0, Config.notifs.groupPreviewNum + 1) - - onValuesChanged: root.flagChanged() - } - + delegate: Component { MouseArea { id: notif required property int index - required property NotifServer.Notif modelData - readonly property alias nonAnimHeight: notifInner.nonAnimHeight - readonly property bool previewHidden: { - if (root.expanded) - return false; - - let extraHidden = 0; - for (let i = 0; i < index; i++) - if (root.notifs[i].closed) - extraHidden++; - - return index >= Config.notifs.groupPreviewNum + extraHidden; - } + required property NotifData modelData property int startY + LazyListView.preferredHeight: modelData?.closed || LazyListView.removing ? 0 : notifInner.nonAnimHeight + LazyListView.visibleHeight: modelData?.closed || LazyListView.removing ? 0 : notifInner.implicitHeight acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton cursorShape: notifInner.body?.hoveredLink ? Qt.PointingHandCursor : pressed ? Qt.ClosedHandCursor : undefined drag.axis: Drag.XAxis drag.target: this - enabled: !modelData.closed + enabled: !(modelData?.closed ?? true) hoverEnabled: true implicitHeight: notifInner.implicitHeight - implicitWidth: root.width - opacity: previewHidden ? 0 : 1 + opacity: LazyListView.removing || LazyListView.adding ? 0 : 1 preventStealing: !root.expanded - scale: previewHidden ? 0.7 : 1 - y: { - root.flag; // Force update - let y = 0; - for (let i = 0; i < index; i++) { - const item = repeater.itemAt(i); - if (!item.modelData.closed && !item.previewHidden) - y += item.nonAnimHeight + root.spacing; - } - return y; - } + scale: LazyListView.removing || LazyListView.adding ? 0.7 : 1 - containmentMask: QtObject { - function contains(p: point): bool { - if (!root.container.contains(notif.mapToItem(root.container, p))) - return false; - return notifInner.contains(p); - } - } Behavior on opacity { Anim { } @@ -125,19 +63,21 @@ Item { } Behavior on x { Anim { - duration: MaterialEasing.expressiveEffectsTime - easing.bezierCurve: MaterialEasing.expressiveEffects + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial } } Behavior on y { + enabled: notif.LazyListView.ready + Anim { - duration: MaterialEasing.expressiveEffectsTime - easing.bezierCurve: MaterialEasing.expressiveEffects + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial } } - Component.onCompleted: modelData.lock(this) - Component.onDestruction: modelData.unlock(this) + Component.onCompleted: modelData?.lock(this) + Component.onDestruction: modelData?.unlock(this) onPositionChanged: event => { if (pressed && !root.expanded) { const diffY = event.y - startY; @@ -150,37 +90,19 @@ Item { if (event.button === Qt.RightButton) root.requestToggleExpand(!root.expanded); else if (event.button === Qt.MiddleButton) - modelData.close(); + modelData?.close(); } onReleased: event => { if (Math.abs(x) < width * Config.notifs.clearThreshold) x = 0; else - modelData.close(); + modelData?.close(); } ParallelAnimation { - Component.onCompleted: running = !notif.previewHidden + running: notif.modelData?.closed ?? false - Anim { - from: 0 - property: "opacity" - target: notif - to: 1 - } - - Anim { - from: 0.7 - property: "scale" - target: notif - to: 1 - } - } - - ParallelAnimation { - running: notif.modelData.closed - - onFinished: notif.modelData.unlock(notif) + onFinished: notif.modelData?.unlock(notif) Anim { property: "opacity" @@ -206,4 +128,28 @@ Item { } } } + model: ScriptModel { + values: { + if (root.expanded) + return root.notifs; + + let count = 0; + let i = 0; + const previewNum = Config.notifs.groupPreviewNum; + while (i < root.notifs.length && count < previewNum) { + if (!(root.notifs[i]?.closed ?? true)) + count++; + i++; + } + + return root.notifs.slice(0, i); + } + } + + TransformWatcher { + id: tWatcher + + a: root.container.contentItem + b: root + } } diff --git a/Modules/Updates/UpdatesWidget.qml b/Modules/Updates/UpdatesWidget.qml index 0eaa63a..b006086 100644 --- a/Modules/Updates/UpdatesWidget.qml +++ b/Modules/Updates/UpdatesWidget.qml @@ -13,7 +13,7 @@ CustomRect { color: DynamicColors.tPalette.m3surfaceContainer implicitHeight: Config.barConfig.height + Appearance.padding.smallest * 2 - implicitWidth: contentRow.implicitWidth + Appearance.spacing.smaller + implicitWidth: contentRow.implicitWidth + Appearance.spacing.small * 2 radius: height / 2 RowLayout { diff --git a/Plugins/ZShell/CMakeLists.txt b/Plugins/ZShell/CMakeLists.txt index fda2330..f540859 100644 --- a/Plugins/ZShell/CMakeLists.txt +++ b/Plugins/ZShell/CMakeLists.txt @@ -56,3 +56,4 @@ qml_module(ZShell add_subdirectory(Models) add_subdirectory(Internal) add_subdirectory(Services) +add_subdirectory(Components) diff --git a/Plugins/ZShell/Components/CMakeLists.txt b/Plugins/ZShell/Components/CMakeLists.txt new file mode 100644 index 0000000..f34e7fb --- /dev/null +++ b/Plugins/ZShell/Components/CMakeLists.txt @@ -0,0 +1,7 @@ +qml_module(ZShell-components + URI ZShell.Components + SOURCES + lazylistview.hpp lazylistview.cpp + LIBRARIES + Qt::Quick +) diff --git a/Plugins/ZShell/Components/lazylistview.cpp b/Plugins/ZShell/Components/lazylistview.cpp new file mode 100644 index 0000000..4638404 --- /dev/null +++ b/Plugins/ZShell/Components/lazylistview.cpp @@ -0,0 +1,1109 @@ +#include "lazylistview.hpp" + +#include +#include +#include + +namespace { + +constexpr int ASYNC_BATCH_CREATE = 2; +constexpr int ASYNC_BATCH_DESTROY = 4; + +} // namespace + +namespace ZShell::components { + +// --- LazyListViewAttached --- + +LazyListViewAttached::LazyListViewAttached(QObject* parent) + : QObject(parent) { +} + +qreal LazyListViewAttached::preferredHeight() const { + return m_preferredHeight; +} + +void LazyListViewAttached::setPreferredHeight(qreal height) { + if (qFuzzyCompare(m_preferredHeight + 1.0, height + 1.0)) + return; + m_preferredHeight = height; + emit preferredHeightChanged(); +} + +qreal LazyListViewAttached::visibleHeight() const { + return m_visibleHeight; +} + +void LazyListViewAttached::setVisibleHeight(qreal height) { + if (qFuzzyCompare(m_visibleHeight + 1.0, height + 1.0)) + return; + m_visibleHeight = height; + emit visibleHeightChanged(); +} + +bool LazyListViewAttached::ready() const { + return m_ready; +} + +void LazyListViewAttached::setReady(bool ready) { + if (m_ready == ready) + return; + m_ready = ready; + emit readyChanged(); +} + +bool LazyListViewAttached::adding() const { + return m_adding; +} + +void LazyListViewAttached::setAdding(bool adding) { + if (m_adding == adding) + return; + m_adding = adding; + emit addingChanged(); +} + +bool LazyListViewAttached::removing() const { + return m_removing; +} + +void LazyListViewAttached::setRemoving(bool removing) { + if (m_removing == removing) + return; + m_removing = removing; + emit removingChanged(); +} + +bool LazyListViewAttached::trackViewport() const { + return m_trackViewport; +} + +void LazyListViewAttached::setTrackViewport(bool track) { + if (m_trackViewport == track) + return; + m_trackViewport = track; + emit trackViewportChanged(); +} + +// --- LazyListView --- + +LazyListView::LazyListView(QQuickItem* parent) + : QQuickItem(parent) { + setFlag(ItemHasContents, false); +} + +LazyListViewAttached* LazyListView::qmlAttachedProperties(QObject* object) { + return new LazyListViewAttached(object); +} + +LazyListView::~LazyListView() { + for (auto& entry : m_delegates) + destroyDelegate(entry); + for (auto& entry : m_dyingDelegates) + destroyDelegate(entry); +} + +// --- Model & Delegate --- + +QAbstractItemModel* LazyListView::model() const { + return m_model; +} + +void LazyListView::setModel(QAbstractItemModel* model) { + if (m_model == model) + return; + + if (m_model) + disconnectModel(); + + m_model = model; + + if (m_model) + connectModel(); + + resetContent(); + emit modelChanged(); +} + +QQmlComponent* LazyListView::delegate() const { + return m_delegate; +} + +void LazyListView::setDelegate(QQmlComponent* delegate) { + if (m_delegate == delegate) + return; + + m_delegate = delegate; + resetContent(); + emit delegateChanged(); +} + +// --- Layout --- + +qreal LazyListView::spacing() const { + return m_spacing; +} + +void LazyListView::setSpacing(qreal spacing) { + if (qFuzzyCompare(m_spacing, spacing)) + return; + m_spacing = spacing; + emit spacingChanged(); + polish(); +} + +qreal LazyListView::contentHeight() const { + return m_contentHeight; +} + +qreal LazyListView::layoutHeight() const { + return m_layoutHeight; +} + +qreal LazyListView::contentY() const { + return m_contentY; +} + +void LazyListView::setContentY(qreal contentY) { + if (qFuzzyCompare(m_contentY, contentY)) + return; + m_contentY = contentY; + emit contentYChanged(); + polish(); +} + +// --- Viewport --- + +QRectF LazyListView::viewport() const { + return m_viewport; +} + +void LazyListView::setViewport(const QRectF& viewport) { + if (m_viewport == viewport) + return; + m_viewport = viewport; + emit viewportChanged(); + if (m_useCustomViewport) + polish(); +} + +bool LazyListView::useCustomViewport() const { + return m_useCustomViewport; +} + +void LazyListView::setUseCustomViewport(bool use) { + if (m_useCustomViewport == use) + return; + m_useCustomViewport = use; + emit useCustomViewportChanged(); + polish(); +} + +qreal LazyListView::cacheBuffer() const { + return m_cacheBuffer; +} + +void LazyListView::setCacheBuffer(qreal buffer) { + if (qFuzzyCompare(m_cacheBuffer, buffer)) + return; + m_cacheBuffer = buffer; + emit cacheBufferChanged(); + polish(); +} + +// --- Sizing --- + +qreal LazyListView::estimatedHeight() const { + return m_estimatedHeight; +} + +void LazyListView::setEstimatedHeight(qreal height) { + if (qFuzzyCompare(m_estimatedHeight, height)) + return; + m_estimatedHeight = height; + emit estimatedHeightChanged(); + polish(); +} + +bool LazyListView::asynchronous() const { + return m_asynchronous; +} + +void LazyListView::setAsynchronous(bool async) { + if (m_asynchronous == async) + return; + m_asynchronous = async; + emit asynchronousChanged(); +} + +qreal LazyListView::effectiveEstimatedHeight() const { + if (m_estimatedHeight >= 0) + return m_estimatedHeight; + if (m_knownHeightCount > 0) + return m_knownHeightSum / m_knownHeightCount; + return 40; +} + +void LazyListView::trackHeight(qreal height) { + m_knownHeightSum += height; + ++m_knownHeightCount; +} + +void LazyListView::untrackHeight(qreal height) { + m_knownHeightSum -= height; + --m_knownHeightCount; +} + +qreal LazyListView::delegateHeight(QQuickItem* item) { + if (!item) + return 0; + + auto* attached = qobject_cast(qmlAttachedPropertiesObject(item, false)); + if (attached && attached->preferredHeight() >= 0) + return attached->preferredHeight(); + + return item->implicitHeight(); +} + +qreal LazyListView::delegateVisibleHeight(QQuickItem* item) { + if (!item) + return 0; + + auto* attached = qobject_cast(qmlAttachedPropertiesObject(item, false)); + if (attached) { + if (attached->visibleHeight() >= 0) + return attached->visibleHeight(); + if (attached->preferredHeight() >= 0) + return attached->preferredHeight(); + } + + return item->implicitHeight(); +} + +bool LazyListView::isDelegateReady(QQuickItem* item) { + if (!item) + return false; + auto* att = qobject_cast(qmlAttachedPropertiesObject(item, false)); + return !att || att->ready(); +} + +// --- Animation Durations --- + +int LazyListView::removeDuration() const { + return m_removeDuration; +} + +void LazyListView::setRemoveDuration(int duration) { + if (m_removeDuration == duration) + return; + m_removeDuration = duration; + emit removeDurationChanged(); +} + +int LazyListView::readyDelay() const { + return m_readyDelay; +} + +void LazyListView::setReadyDelay(int delay) { + if (m_readyDelay == delay) + return; + m_readyDelay = delay; + emit readyDelayChanged(); +} + +// --- State --- + +int LazyListView::count() const { + return m_model ? m_model->rowCount() : 0; +} + +// --- QQuickItem Overrides --- + +void LazyListView::componentComplete() { + QQuickItem::componentComplete(); + m_componentComplete = true; + resetContent(); +} + +void LazyListView::geometryChange(const QRectF& newGeometry, const QRectF& oldGeometry) { + QQuickItem::geometryChange(newGeometry, oldGeometry); + + if (!m_componentComplete) + return; + + if (!qFuzzyCompare(newGeometry.width(), oldGeometry.width())) { + for (auto& entry : m_delegates) { + if (entry.item) + entry.item->setWidth(newGeometry.width()); + } + } + + polish(); +} + +void LazyListView::updatePolish() { + if (!m_componentComplete || !m_model || !m_delegate) + return; + + // Flush pending inserts — make items visible and clear the adding flag + // so enter animations begin. When readyDelay > 0 the entire insert is + // deferred so delegates have time to lay out before appearing. + for (auto& entry : m_delegates) { + if (!entry.pendingInsert || !entry.item) + continue; + + if (m_readyDelay > 0) { + if (!entry.readyDelayStarted) { + entry.readyDelayStarted = true; + auto* item = entry.item; + QTimer::singleShot(m_readyDelay, this, [this, item] { + auto indexIt = m_itemToIndex.find(item); + if (indexIt == m_itemToIndex.end()) + return; + const int idx = indexIt.value(); + auto it = m_delegates.find(idx); + if (it == m_delegates.end() || it->item != item || !it->pendingInsert) + return; + + it->pendingInsert = false; + it->readyDelayStarted = false; + + // Set initial y to visual position (based on current visible heights) + if (idx >= 0 && idx < static_cast(m_layout.size())) { + qreal visualY = 0; + bool hasVisItem = false; + for (int i = 0; i < static_cast(m_layout.size()); ++i) { + qreal h; + auto dit = m_delegates.find(i); + if (dit != m_delegates.end() && dit->item) + h = delegateVisibleHeight(dit->item); + else + h = m_layout[i].heightKnown ? m_layout[i].height : effectiveEstimatedHeight(); + if (h > 0) { + if (hasVisItem) + visualY += m_spacing; + hasVisItem = true; + } + if (i == idx) + break; + if (h > 0) + visualY += h; + } + item->setY(visualY - m_contentY); + } + + item->setVisible(true); + auto* att = + qobject_cast(qmlAttachedPropertiesObject(item, false)); + if (att) { + att->setAdding(false); + att->setReady(true); + } + + // Animate from visual position to layout position + if (idx >= 0 && idx < static_cast(m_layout.size())) + item->setProperty("y", m_layout[idx].targetY - m_contentY); + + polish(); + }); + } + continue; + } + + entry.pendingInsert = false; + entry.item->setVisible(true); + auto* att = qobject_cast(qmlAttachedPropertiesObject(entry.item, false)); + if (att) { + att->setAdding(false); + att->setReady(true); + } + } + + relayout(); + syncDelegates(); + + // Clear isNew flags — the add animation only plays for items created + // during the same polish cycle as their model insertion, not for + // delegates created later when scrolling items into the viewport. + for (auto& record : m_layout) + record.isNew = false; + + // Position delegates — QML Behavior on y handles the animation + for (auto& entry : m_delegates) { + if (!entry.item || entry.pendingRemoval || entry.pendingInsert) + continue; + + const int idx = entry.modelIndex; + if (idx < 0 || idx >= static_cast(m_layout.size())) + continue; + + if (m_layout[idx].heightKnown && qFuzzyIsNull(m_layout[idx].height)) + continue; + + // Use setProperty to go through the QML property system, + // which triggers Behaviors (setY bypasses them). + entry.item->setProperty("y", m_layout[idx].targetY - m_contentY); + } +} + +// --- Layout Engine --- + +void LazyListView::relayout() { + // Layout positioning uses preferredHeight (final/non-animated). + // Only add spacing between items with non-zero height. + qreal y = 0; + bool hasLayoutItem = false; + for (auto& record : m_layout) { + const qreal layoutH = record.heightKnown ? record.height : effectiveEstimatedHeight(); + if (layoutH > 0) { + if (hasLayoutItem) + y += m_spacing; + hasLayoutItem = true; + record.targetY = y; + y += layoutH; + } else { + record.targetY = y; + } + } + + if (!qFuzzyCompare(m_layoutHeight + 1.0, y + 1.0)) { + m_layoutHeight = y; + emit layoutHeightChanged(); + } + + // Content height tracks actual visible heights so scrolling follows animations. + // Only add spacing between items with non-zero visible height. + qreal visY = 0; + bool hasVisItem = false; + for (int i = 0; i < static_cast(m_layout.size()); ++i) { + qreal h; + auto dit = m_delegates.find(i); + if (dit != m_delegates.end() && dit->item) + h = delegateVisibleHeight(dit->item); + else + h = m_layout[i].heightKnown ? m_layout[i].height : effectiveEstimatedHeight(); + if (h > 0) { + if (hasVisItem) + visY += m_spacing; + hasVisItem = true; + visY += h; + } + } + + // Account for dying delegates still visually present + for (const auto& dying : std::as_const(m_dyingDelegates)) { + if (!dying.item) + continue; + const qreal dyingH = delegateVisibleHeight(dying.item); + if (dyingH > 0) + visY = std::max(visY, dying.item->y() + dyingH); + } + + if (!qFuzzyCompare(m_contentHeight + 1.0, visY + 1.0)) { + m_contentHeight = visY; + emit contentHeightChanged(); + } +} + +QRectF LazyListView::effectiveViewport() const { + QRectF vp; + if (m_useCustomViewport) + vp = m_viewport; + else + vp = QRectF(0, m_contentY, width(), height()); + + // During Flickable overshoot the viewport can extend entirely beyond content bounds, + // causing all delegates to be culled. Clamp so it always overlaps [0, layoutHeight]. + // Only needed for the built-in viewport — custom viewports represent the actual + // visible area and may legitimately lie entirely outside the content. + if (!m_useCustomViewport && m_layoutHeight > 0) { + const qreal top = std::min(vp.y(), m_layoutHeight); + const qreal bottom = std::max(vp.y() + vp.height(), 0.0); + if (bottom > top) + vp = QRectF(vp.x(), top, vp.width(), bottom - top); + } + + vp.adjust(0, -m_cacheBuffer, 0, m_cacheBuffer); + + // Trim the cache-buffered viewport to [0, layoutHeight]. No items exist outside + // those bounds, so extending past them wastes budget and can cause edge thrashing + // when a large cache buffer reaches the opposite end of the content. + if (m_layoutHeight > 0) { + const qreal top = std::max(vp.y(), 0.0); + const qreal bottom = std::min(vp.y() + vp.height(), m_layoutHeight); + if (top < bottom) + vp = QRectF(vp.x(), top, vp.width(), bottom - top); + else + return {}; + } + + return vp; +} + +std::pair LazyListView::computeVisibleRange() const { + if (m_layout.isEmpty()) + return { -1, -1 }; + + const auto vp = effectiveViewport(); + if (vp.isEmpty()) + return { -1, -1 }; + + const qreal vpTop = vp.y(); + const qreal vpBottom = vp.y() + vp.height(); + + // Binary search for first visible item + int lo = 0; + int hi = static_cast(m_layout.size()) - 1; + int first = static_cast(m_layout.size()); + + while (lo <= hi) { + const int mid = lo + (hi - lo) / 2; + const auto& record = m_layout[mid]; + const qreal itemBottom = record.targetY + (record.heightKnown ? record.height : effectiveEstimatedHeight()); + + if (itemBottom >= vpTop) { + first = mid; + hi = mid - 1; + } else { + lo = mid + 1; + } + } + + if (first >= static_cast(m_layout.size())) + return { -1, -1 }; + + // Linear scan for last visible item + int last = first; + for (int i = first; i < static_cast(m_layout.size()); ++i) { + if (m_layout[i].targetY > vpBottom) + break; + last = i; + } + + return { first, last }; +} + +// --- Delegate Lifecycle --- + +void LazyListView::syncDelegates() { + const auto [first, last] = computeVisibleRange(); + + // Collect indices that should be alive + QSet visibleIndices; + if (first >= 0) { + for (int i = first; i <= last; ++i) + visibleIndices.insert(i); + } + + // Collect delegates to destroy — only if visually outside the viewport + const auto vp = effectiveViewport(); + QList toRemove; + for (auto it = m_delegates.begin(); it != m_delegates.end(); ++it) { + if (visibleIndices.contains(it.key())) + continue; + if (!it->item || vp.isEmpty()) { + toRemove.append(it.key()); + continue; + } + const qreal itemTop = it->item->y(); + const qreal itemBottom = itemTop + delegateVisibleHeight(it->item); + if (itemBottom < vp.top() || itemTop > vp.bottom()) + toRemove.append(it.key()); + } + + // Batch destroy + const int destroyBudget = m_asynchronous ? ASYNC_BATCH_DESTROY : static_cast(toRemove.size()); + QVector removedEntries; + removedEntries.reserve(std::min(destroyBudget, static_cast(toRemove.size()))); + int destroyed = 0; + for (int idx : toRemove) { + if (destroyed >= destroyBudget) + break; + auto entry = m_delegates.take(idx); + if (entry.item) + m_itemToIndex.remove(entry.item); + removedEntries.append(std::move(entry)); + ++destroyed; + } + for (auto& entry : removedEntries) + destroyDelegate(entry); + + // Collect indices to create + QList toCreate; + if (first >= 0) { + for (int i = first; i <= last; ++i) { + if (!m_delegates.contains(i)) + toCreate.append(i); + } + } + + // Batch create + const int createBudget = m_asynchronous ? ASYNC_BATCH_CREATE : static_cast(toCreate.size()); + int created = 0; + for (int i : toCreate) { + if (created >= createBudget) + break; + + auto entry = createDelegate(i); + if (entry.item) { + // Height tracking and viewport compensation are deferred + // until the delegate signals ready via readyChanged. + entry.pendingInsert = true; + entry.item->setY(m_layout[i].targetY - m_contentY); + m_itemToIndex.insert(entry.item, i); + m_delegates.insert(i, std::move(entry)); + ++created; + } + } + + // Pending inserts need to become visible on the next frame, and + // async mode may have remaining create/destroy work. + if (created > 0 || (m_asynchronous && (destroyed < static_cast(toRemove.size()) || + created < static_cast(toCreate.size())))) + polish(); +} + +LazyListView::DelegateEntry LazyListView::createDelegate(int modelIndex) { + DelegateEntry entry; + entry.modelIndex = modelIndex; + + if (!m_delegate || !m_model) + return entry; + + const auto roleNames = m_model->roleNames(); + + // Use the delegate component's creation context for beginCreate + // so bound components (pragma ComponentBehavior: Bound) are accepted. + auto* compContext = m_delegate->creationContext(); + if (!compContext) + compContext = qmlContext(this); + if (!compContext) + return entry; + + auto* obj = m_delegate->beginCreate(compContext); + entry.item = qobject_cast(obj); + + if (!entry.item) { + if (obj) + m_delegate->completeCreate(); + delete obj; + return entry; + } + + // Build initial properties from model data + const auto index = m_model->index(modelIndex, 0); + QVariantMap initialProps; + bool hasModelData = false; + + for (auto it = roleNames.constBegin(); it != roleNames.constEnd(); ++it) { + const auto name = QString::fromUtf8(it.value()); + initialProps.insert(name, m_model->data(index, it.key())); + if (name == QStringLiteral("modelData")) + hasModelData = true; + } + initialProps.insert(QStringLiteral("index"), modelIndex); + + if (!hasModelData) { + const auto role = roleNames.isEmpty() ? Qt::DisplayRole : roleNames.constBegin().key(); + initialProps.insert(QStringLiteral("modelData"), m_model->data(index, role)); + } + + m_delegate->setInitialProperties(entry.item, initialProps); + + entry.item->setParentItem(this); + entry.item->setWidth(width()); + + // Only set adding = true for genuinely new model items (not viewport entries). + // Cleared on the next frame in updatePolish when the item becomes visible. + if (modelIndex < static_cast(m_layout.size()) && m_layout[modelIndex].isNew) { + auto* addingAttached = + qobject_cast(qmlAttachedPropertiesObject(entry.item, true)); + if (addingAttached) + addingAttached->setAdding(true); + } + + m_delegate->completeCreate(); + + // Keep adding=true and hide — flushed on the next frame in updatePolish + entry.item->setVisible(false); + + // Height-change handler — uses m_itemToIndex for O(1) lookup. + // Ignored while the delegate is not yet ready. + auto onHeightChanged = [this, item = entry.item] { + if (!isDelegateReady(item)) + return; + auto indexIt = m_itemToIndex.find(item); + if (indexIt == m_itemToIndex.end()) + return; + const int idx = indexIt.value(); + auto delegateIt = m_delegates.find(idx); + if (delegateIt == m_delegates.end() || delegateIt->item != item) + return; + const qreal h = delegateHeight(item); + if (idx < static_cast(m_layout.size()) && !qFuzzyCompare(m_layout[idx].height + 1.0, h + 1.0)) { + const qreal oldH = m_layout[idx].height; + const bool wasKnown = m_layout[idx].heightKnown; + m_layout[idx].height = h; + m_layout[idx].heightKnown = true; + if (wasKnown) + untrackHeight(oldH); + trackHeight(h); + + // If this tracked item is above the viewport, emit a + // compensation delta so the consumer can adjust scroll. + if (wasKnown) { + auto* att = qobject_cast(qmlAttachedPropertiesObject(item, false)); + if (att && att->trackViewport()) { + const qreal vpTop = m_useCustomViewport ? m_viewport.y() : m_contentY; + if (m_layout[idx].targetY < vpTop) + emit viewportAdjustNeeded(h - oldH); + } + } + + if (!m_relayoutPending) { + m_relayoutPending = true; + QTimer::singleShot(0, this, [this] { + m_relayoutPending = false; + relayout(); + polish(); + }); + } + } + }; + + // Watch implicitHeight as fallback + connect(entry.item, &QQuickItem::implicitHeightChanged, this, onHeightChanged); + + // Watch attached properties if the delegate uses them + auto* attached = qobject_cast(qmlAttachedPropertiesObject(entry.item, false)); + if (attached) { + connect(attached, &LazyListViewAttached::preferredHeightChanged, this, onHeightChanged); + connect(attached, &LazyListViewAttached::visibleHeightChanged, this, [this] { + polish(); + }); + connect(attached, &LazyListViewAttached::readyChanged, this, [this, item = entry.item] { + auto indexIt = m_itemToIndex.find(item); + if (indexIt == m_itemToIndex.end()) + return; + const int idx = indexIt.value(); + if (idx >= static_cast(m_layout.size())) + return; + auto* att = qobject_cast(qmlAttachedPropertiesObject(item, false)); + if (!att || !att->ready()) + return; + + const qreal h = delegateHeight(item); + const qreal oldLayoutH = m_layout[idx].heightKnown ? m_layout[idx].height : effectiveEstimatedHeight(); + if (m_layout[idx].heightKnown) + untrackHeight(m_layout[idx].height); + m_layout[idx].height = h; + m_layout[idx].heightKnown = true; + trackHeight(h); + + if (att->trackViewport() && !qFuzzyCompare(h + 1.0, oldLayoutH + 1.0)) { + const qreal vpTop = m_useCustomViewport ? m_viewport.y() : m_contentY; + if (m_layout[idx].targetY < vpTop) + emit viewportAdjustNeeded(h - oldLayoutH); + } + + polish(); + }); + } + + return entry; +} + +void LazyListView::destroyDelegate(DelegateEntry& entry) { + if (entry.item) { + entry.item->setParentItem(nullptr); + entry.item->setVisible(false); + entry.item->deleteLater(); + entry.item = nullptr; + } +} + +void LazyListView::updateDelegateData(DelegateEntry& entry) { + if (!m_model || !entry.item) + return; + + const auto roleNames = m_model->roleNames(); + const auto index = m_model->index(entry.modelIndex, 0); + bool hasModelData = false; + + for (auto it = roleNames.constBegin(); it != roleNames.constEnd(); ++it) { + const auto name = QString::fromUtf8(it.value()); + entry.item->setProperty(name.toUtf8().constData(), m_model->data(index, it.key())); + if (name == QStringLiteral("modelData")) + hasModelData = true; + } + + entry.item->setProperty("index", entry.modelIndex); + + if (!hasModelData) { + const auto role = roleNames.isEmpty() ? Qt::DisplayRole : roleNames.constBegin().key(); + entry.item->setProperty("modelData", m_model->data(index, role)); + } +} + +// --- Model Connection --- + +void LazyListView::connectModel() { + if (!m_model) + return; + + m_modelConnections = { + connect(m_model, &QAbstractItemModel::rowsInserted, this, &LazyListView::onRowsInserted), + connect(m_model, &QAbstractItemModel::rowsAboutToBeRemoved, this, &LazyListView::onRowsAboutToBeRemoved), + connect(m_model, &QAbstractItemModel::rowsRemoved, this, &LazyListView::onRowsRemoved), + connect(m_model, &QAbstractItemModel::rowsMoved, this, &LazyListView::onRowsMoved), + connect(m_model, &QAbstractItemModel::dataChanged, this, &LazyListView::onDataChanged), + connect(m_model, &QAbstractItemModel::modelReset, this, &LazyListView::onModelReset), + connect(m_model, &QAbstractItemModel::layoutChanged, this, + [this] { + for (auto& entry : m_delegates) + updateDelegateData(entry); + polish(); + }), + connect(m_model, &QObject::destroyed, this, + [this] { + m_model = nullptr; + resetContent(); + emit modelChanged(); + }), + }; +} + +void LazyListView::disconnectModel() { + for (auto& conn : m_modelConnections) + disconnect(conn); + m_modelConnections.clear(); +} + +void LazyListView::resetContent() { + // Stop all animations and destroy all delegates + for (auto& entry : m_delegates) + destroyDelegate(entry); + m_delegates.clear(); + m_itemToIndex.clear(); + + for (auto& entry : m_dyingDelegates) + destroyDelegate(entry); + m_dyingDelegates.clear(); + + // Reset pending state + m_knownHeightSum = 0; + m_knownHeightCount = 0; + + // Rebuild layout from model + m_layout.clear(); + if (m_model && m_componentComplete) { + const int rows = m_model->rowCount(); + m_layout.resize(rows); + for (int i = 0; i < rows; ++i) { + m_layout[i].height = 0; + m_layout[i].heightKnown = false; + } + emit countChanged(); + } + + polish(); +} + +void LazyListView::onRowsInserted(const QModelIndex& parent, int first, int last) { + if (parent.isValid()) + return; + + const int insertCount = last - first + 1; + // Insert new layout records + m_layout.insert(first, insertCount, ItemRecord{ 0, 0, false, true }); + + // Shift existing delegate indices + QHash shifted; + for (auto it = m_delegates.begin(); it != m_delegates.end(); ++it) { + int newIdx = it.key() >= first ? it.key() + insertCount : it.key(); + auto entry = std::move(it.value()); + entry.modelIndex = newIdx; + if (entry.item) { + entry.item->setProperty("index", newIdx); + m_itemToIndex[entry.item] = newIdx; + } + shifted.insert(newIdx, std::move(entry)); + } + m_delegates = std::move(shifted); + + emit countChanged(); + polish(); +} + +void LazyListView::onRowsAboutToBeRemoved(const QModelIndex& parent, int first, int last) { + if (parent.isValid()) + return; + + for (int i = first; i <= last; ++i) { + if (!m_delegates.contains(i)) + continue; + + auto entry = m_delegates.take(i); + if (entry.item) + m_itemToIndex.remove(entry.item); + entry.pendingRemoval = true; + + // Never made visible — skip remove animation + if (entry.pendingInsert) { + destroyDelegate(entry); + continue; + } + + if (m_removeDuration > 0 && entry.item) { + auto* attached = + qobject_cast(qmlAttachedPropertiesObject(entry.item, false)); + if (attached) + attached->setRemoving(true); + + // Schedule destruction after the remove animation duration + auto* item = entry.item; + QTimer::singleShot(m_removeDuration, this, [this, item] { + for (auto it = m_dyingDelegates.begin(); it != m_dyingDelegates.end(); ++it) { + if (it->item == item) { + destroyDelegate(*it); + m_dyingDelegates.erase(it); + return; + } + } + }); + m_dyingDelegates.append(std::move(entry)); + } else { + destroyDelegate(entry); + } + } +} + +void LazyListView::onRowsRemoved(const QModelIndex& parent, int first, int last) { + if (parent.isValid()) + return; + + const int removeCount = last - first + 1; + + // Untrack known heights being removed + for (int i = first; i <= last; ++i) { + if (m_layout[i].heightKnown) + untrackHeight(m_layout[i].height); + } + + // Remove layout records + m_layout.remove(first, removeCount); + + // Shift remaining delegate indices down + QHash shifted; + for (auto it = m_delegates.begin(); it != m_delegates.end(); ++it) { + int newIdx = it.key() > last ? it.key() - removeCount : it.key(); + auto entry = std::move(it.value()); + entry.modelIndex = newIdx; + if (entry.item) { + entry.item->setProperty("index", newIdx); + m_itemToIndex[entry.item] = newIdx; + } + shifted.insert(newIdx, std::move(entry)); + } + m_delegates = std::move(shifted); + + emit countChanged(); + polish(); +} + +void LazyListView::onRowsMoved(const QModelIndex& parent, int start, int end, const QModelIndex& destination, int row) { + if (parent.isValid() || destination.isValid()) + return; + + const int count = end - start + 1; + const int dest = row > start ? row - count : row; + + // Reorder layout records + QVector moved; + moved.reserve(count); + for (int i = start; i <= end; ++i) + moved.append(m_layout[i]); + m_layout.remove(start, count); + for (int i = 0; i < count; ++i) + m_layout.insert(dest + i, moved[i]); + + // Remap delegate indices to match new model order + QHash remapped; + for (auto it = m_delegates.begin(); it != m_delegates.end(); ++it) { + int oldIdx = it.key(); + int newIdx = oldIdx; + + if (oldIdx >= start && oldIdx <= end) { + newIdx = dest + (oldIdx - start); + } else { + if (oldIdx > end) + newIdx -= count; + if (newIdx >= dest) + newIdx += count; + } + + auto entry = std::move(it.value()); + entry.modelIndex = newIdx; + if (entry.item) { + entry.item->setProperty("index", newIdx); + m_itemToIndex[entry.item] = newIdx; + } + remapped.insert(newIdx, std::move(entry)); + } + m_delegates = std::move(remapped); + + polish(); +} + +void LazyListView::onDataChanged(const QModelIndex& topLeft, const QModelIndex& bottomRight, const QList& roles) { + Q_UNUSED(roles) + + if (topLeft.parent().isValid()) + return; + + for (int i = topLeft.row(); i <= bottomRight.row(); ++i) { + if (m_delegates.contains(i)) + updateDelegateData(m_delegates[i]); + } +} + +void LazyListView::onModelReset() { + if (!m_model) { + resetContent(); + return; + } + + const int newRows = m_model->rowCount(); + const int oldRows = static_cast(m_layout.size()); + + // Check if the model data actually changed + if (newRows == oldRows) { + const auto roleNames = m_model->roleNames(); + const auto role = roleNames.isEmpty() ? Qt::DisplayRole : roleNames.constBegin().key(); + bool changed = false; + + for (auto it = m_delegates.constBegin(); it != m_delegates.constEnd(); ++it) { + if (!it->item || it.key() >= newRows) { + changed = true; + break; + } + const auto newData = m_model->data(m_model->index(it.key(), 0), role); + const auto oldData = it->item->property("modelData"); + if (newData != oldData) { + changed = true; + break; + } + } + + if (!changed) { + // Model content unchanged, just refresh delegate data + for (auto& entry : m_delegates) + updateDelegateData(entry); + return; + } + } + + resetContent(); +} + +} // namespace ZShell::components diff --git a/Plugins/ZShell/Components/lazylistview.hpp b/Plugins/ZShell/Components/lazylistview.hpp new file mode 100644 index 0000000..c1bc987 --- /dev/null +++ b/Plugins/ZShell/Components/lazylistview.hpp @@ -0,0 +1,243 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ZShell::components { + +class LazyListViewAttached : public QObject { +Q_OBJECT + +Q_PROPERTY(qreal preferredHeight READ preferredHeight WRITE setPreferredHeight NOTIFY preferredHeightChanged) +Q_PROPERTY(qreal visibleHeight READ visibleHeight WRITE setVisibleHeight NOTIFY visibleHeightChanged) +Q_PROPERTY(bool ready READ ready NOTIFY readyChanged) +Q_PROPERTY(bool adding READ adding NOTIFY addingChanged) +Q_PROPERTY(bool removing READ removing NOTIFY removingChanged) +Q_PROPERTY(bool trackViewport READ trackViewport WRITE setTrackViewport NOTIFY trackViewportChanged) + +public: +explicit LazyListViewAttached(QObject* parent = nullptr); + +[[nodiscard]] qreal preferredHeight() const; +void setPreferredHeight(qreal height); + +[[nodiscard]] qreal visibleHeight() const; +void setVisibleHeight(qreal height); + +[[nodiscard]] bool ready() const; +void setReady(bool ready); + +[[nodiscard]] bool adding() const; +void setAdding(bool adding); + +[[nodiscard]] bool removing() const; +void setRemoving(bool removing); + +[[nodiscard]] bool trackViewport() const; +void setTrackViewport(bool track); + +signals: +void preferredHeightChanged(); +void visibleHeightChanged(); +void readyChanged(); +void addingChanged(); +void removingChanged(); +void trackViewportChanged(); + +private: +qreal m_preferredHeight = -1; +qreal m_visibleHeight = -1; +bool m_ready = false; +bool m_adding = false; +bool m_removing = false; +bool m_trackViewport = false; +}; + +class LazyListView : public QQuickItem { +Q_OBJECT +QML_ELEMENT +QML_ATTACHED(LazyListViewAttached) + +// Model & Delegate +Q_PROPERTY(QAbstractItemModel* model READ model WRITE setModel NOTIFY modelChanged) +Q_PROPERTY(QQmlComponent* delegate READ delegate WRITE setDelegate NOTIFY delegateChanged) + +// Layout +Q_PROPERTY(qreal spacing READ spacing WRITE setSpacing NOTIFY spacingChanged) +Q_PROPERTY(qreal contentHeight READ contentHeight NOTIFY contentHeightChanged) +Q_PROPERTY(qreal layoutHeight READ layoutHeight NOTIFY layoutHeightChanged) +Q_PROPERTY(qreal contentY READ contentY WRITE setContentY NOTIFY contentYChanged) + +// Viewport & Lazy Loading +Q_PROPERTY(QRectF viewport READ viewport WRITE setViewport NOTIFY viewportChanged) +Q_PROPERTY(bool useCustomViewport READ useCustomViewport WRITE setUseCustomViewport NOTIFY useCustomViewportChanged) +Q_PROPERTY(qreal cacheBuffer READ cacheBuffer WRITE setCacheBuffer NOTIFY cacheBufferChanged) + +// Sizing +Q_PROPERTY(qreal estimatedHeight READ estimatedHeight WRITE setEstimatedHeight NOTIFY estimatedHeightChanged) + +// Async +Q_PROPERTY(bool asynchronous READ asynchronous WRITE setAsynchronous NOTIFY asynchronousChanged) + +// Animation Durations +Q_PROPERTY(int removeDuration READ removeDuration WRITE setRemoveDuration NOTIFY removeDurationChanged) +Q_PROPERTY(int readyDelay READ readyDelay WRITE setReadyDelay NOTIFY readyDelayChanged) + +// State +Q_PROPERTY(int count READ count NOTIFY countChanged) + +public: +explicit LazyListView(QQuickItem* parent = nullptr); +~LazyListView() override; + +static LazyListViewAttached* qmlAttachedProperties(QObject* object); + +// Model & Delegate +[[nodiscard]] QAbstractItemModel* model() const; +void setModel(QAbstractItemModel* model); + +[[nodiscard]] QQmlComponent* delegate() const; +void setDelegate(QQmlComponent* delegate); + +// Layout +[[nodiscard]] qreal spacing() const; +void setSpacing(qreal spacing); + +[[nodiscard]] qreal contentHeight() const; +[[nodiscard]] qreal layoutHeight() const; + +[[nodiscard]] qreal contentY() const; +void setContentY(qreal contentY); + +// Viewport +[[nodiscard]] QRectF viewport() const; +void setViewport(const QRectF& viewport); + +[[nodiscard]] bool useCustomViewport() const; +void setUseCustomViewport(bool use); + +[[nodiscard]] qreal cacheBuffer() const; +void setCacheBuffer(qreal buffer); + +// Sizing +[[nodiscard]] qreal estimatedHeight() const; +void setEstimatedHeight(qreal height); + +// Async +[[nodiscard]] bool asynchronous() const; +void setAsynchronous(bool async); + +// Animation Durations +[[nodiscard]] int removeDuration() const; +void setRemoveDuration(int duration); + +[[nodiscard]] int readyDelay() const; +void setReadyDelay(int delay); + +// State +[[nodiscard]] int count() const; +signals: +void modelChanged(); +void delegateChanged(); +void spacingChanged(); +void contentHeightChanged(); +void layoutHeightChanged(); +void contentYChanged(); +void viewportChanged(); +void useCustomViewportChanged(); +void cacheBufferChanged(); +void estimatedHeightChanged(); +void asynchronousChanged(); +void removeDurationChanged(); +void readyDelayChanged(); +void countChanged(); +void viewportAdjustNeeded(qreal delta); + +protected: +void componentComplete() override; +void geometryChange(const QRectF& newGeometry, const QRectF& oldGeometry) override; +void updatePolish() override; + +private: +struct ItemRecord { + qreal targetY = 0; + qreal height = 0; + bool heightKnown = false; + bool isNew = false; +}; + +struct DelegateEntry { + int modelIndex = -1; + QQuickItem* item = nullptr; + bool pendingRemoval = false; + bool pendingInsert = false; + bool readyDelayStarted = false; +}; + +// Layout +void relayout(); +[[nodiscard]] std::pair computeVisibleRange() const; +[[nodiscard]] QRectF effectiveViewport() const; +[[nodiscard]] qreal effectiveEstimatedHeight() const; +[[nodiscard]] static qreal delegateHeight(QQuickItem* item); +[[nodiscard]] static qreal delegateVisibleHeight(QQuickItem* item); +[[nodiscard]] static bool isDelegateReady(QQuickItem* item); +void trackHeight(qreal height); +void untrackHeight(qreal height); + +// Delegate lifecycle +void syncDelegates(); +DelegateEntry createDelegate(int modelIndex); +void destroyDelegate(DelegateEntry& entry); +void updateDelegateData(DelegateEntry& entry); + +// Model connection +void connectModel(); +void disconnectModel(); +void resetContent(); +void onRowsInserted(const QModelIndex& parent, int first, int last); +void onRowsAboutToBeRemoved(const QModelIndex& parent, int first, int last); +void onRowsRemoved(const QModelIndex& parent, int first, int last); +void onRowsMoved(const QModelIndex& parent, int start, int end, const QModelIndex& destination, int row); +void onDataChanged(const QModelIndex& topLeft, const QModelIndex& bottomRight, const QList& roles); +void onModelReset(); + +// Members +QAbstractItemModel* m_model = nullptr; +QQmlComponent* m_delegate = nullptr; + +qreal m_spacing = 0; +qreal m_contentHeight = 0; +qreal m_layoutHeight = 0; +qreal m_contentY = 0; + +QRectF m_viewport; +bool m_useCustomViewport = false; +qreal m_cacheBuffer = 0; + +qreal m_estimatedHeight = -1; +qreal m_knownHeightSum = 0; +int m_knownHeightCount = 0; +bool m_asynchronous = false; + +int m_removeDuration = 300; +int m_readyDelay = 0; + +QVector m_layout; +QHash m_delegates; +QHash m_itemToIndex; +QVector m_dyingDelegates; + +bool m_componentComplete = false; +bool m_relayoutPending = false; + +QList m_modelConnections; +}; + +} // namespace ZShell::components From 585128c4475d7c9c83c6ab99c81afc389ce0dce9 Mon Sep 17 00:00:00 2001 From: zach Date: Sun, 12 Apr 2026 19:41:10 +0200 Subject: [PATCH 2/2] test notif plugin --- Modules/Notifications/Sidebar/NotifDockList.qml | 8 ++++---- Modules/Notifications/Sidebar/NotifGroupList.qml | 11 ++++++----- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/Modules/Notifications/Sidebar/NotifDockList.qml b/Modules/Notifications/Sidebar/NotifDockList.qml index 44f1788..7cb4f81 100644 --- a/Modules/Notifications/Sidebar/NotifDockList.qml +++ b/Modules/Notifications/Sidebar/NotifDockList.qml @@ -13,7 +13,7 @@ LazyListView { required property Flickable container required property Props props - required property DrawerVisibilities visibilities + required property PersistentProperties visibilities anchors.left: parent?.left anchors.right: parent?.right @@ -107,7 +107,7 @@ LazyListView { triggeredOnStart: true onTriggered: { - const notifs = Notifs.notClosed.filter(n => n.appName === notif.modelData); + const notifs = NotifServer.notClosed.filter(n => n.appName === notif.modelData); if (notifs.length === 0) { stop(); return; @@ -131,9 +131,9 @@ LazyListView { model: ScriptModel { values: { const map = new Map(); - for (const n of Notifs.notClosed) + for (const n of NotifServer.notClosed) map.set(n.appName, null); - for (const n of Notifs.list) + for (const n of NotifServer.list) map.set(n.appName, null); return [...map.keys()]; } diff --git a/Modules/Notifications/Sidebar/NotifGroupList.qml b/Modules/Notifications/Sidebar/NotifGroupList.qml index 0c10849..a04c3d8 100644 --- a/Modules/Notifications/Sidebar/NotifGroupList.qml +++ b/Modules/Notifications/Sidebar/NotifGroupList.qml @@ -1,12 +1,13 @@ pragma ComponentBehavior: Bound +import Quickshell +import QtQuick +import QtQuick.Layouts +import ZShell.Components import qs.Components import qs.Config import qs.Modules import qs.Daemons -import Quickshell -import QtQuick -import QtQuick.Layouts LazyListView { id: root @@ -15,7 +16,7 @@ LazyListView { required property bool expanded required property list notifs required property Props props - required property DrawerVisibilities visibilities + required property PersistentProperties visibilities signal requestToggleExpand(expand: bool) @@ -37,7 +38,7 @@ LazyListView { id: notif required property int index - required property NotifData modelData + required property NotifServer.Notif modelData property int startY LazyListView.preferredHeight: modelData?.closed || LazyListView.removing ? 0 : notifInner.nonAnimHeight