From 736eda671589c418e3b6169d92fdbc4a2e3ff37a Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Fri, 3 Apr 2026 02:13:47 +1100 Subject: [PATCH 01/37] feat: c++ resizable list view component Partially fixes notif dock lag --- modules/sidebar/NotifDock.qml | 14 +- modules/sidebar/NotifDockList.qml | 238 ++--- plugin/src/Caelestia/CMakeLists.txt | 1 + .../src/Caelestia/Components/CMakeLists.txt | 7 + .../src/Caelestia/Components/lazylistview.cpp | 936 ++++++++++++++++++ .../src/Caelestia/Components/lazylistview.hpp | 244 +++++ 6 files changed, 1292 insertions(+), 148 deletions(-) create mode 100644 plugin/src/Caelestia/Components/CMakeLists.txt create mode 100644 plugin/src/Caelestia/Components/lazylistview.cpp create mode 100644 plugin/src/Caelestia/Components/lazylistview.hpp diff --git a/modules/sidebar/NotifDock.qml b/modules/sidebar/NotifDock.qml index 9c677f7e..c00f2bd4 100644 --- a/modules/sidebar/NotifDock.qml +++ b/modules/sidebar/NotifDock.qml @@ -1,5 +1,3 @@ -pragma ComponentBehavior: Bound - import QtQuick import QtQuick.Layouts import Quickshell.Widgets @@ -153,14 +151,10 @@ Item { repeat: true interval: 50 onTriggered: { - let next = null; - for (let i = 0; i < notifList.repeater.count; i++) { - next = notifList.repeater.itemAt(i); - if (!next?.closed) // qmllint disable missing-property - break; - } - if (next) { - next.closeAll(); // qmllint disable missing-property + const first = Notifs.notClosed[0]; + if (first) { + for (const n of Notifs.notClosed.filter(n => n.appName === first.appName)) + n.close(); } else { stop(); } diff --git a/modules/sidebar/NotifDockList.qml b/modules/sidebar/NotifDockList.qml index 7ffb6418..c7d1ebaf 100644 --- a/modules/sidebar/NotifDockList.qml +++ b/modules/sidebar/NotifDockList.qml @@ -1,168 +1,130 @@ -pragma ComponentBehavior: Bound - import QtQuick import Quickshell +import Caelestia.Components import qs.components import qs.services import qs.config -Item { +LazyListView { id: root required property Props props required property Flickable container required property DrawerVisibilities visibilities - readonly property alias repeater: repeater - readonly property int spacing: Appearance.spacing.small - property bool flag + anchors.left: parent?.left + anchors.right: parent?.right + implicitHeight: contentHeight - anchors.left: parent.left - anchors.right: parent.right - implicitHeight: { - const item = repeater.itemAt(repeater.count - 1); - return item ? item.y + item.implicitHeight : 0; + spacing: Appearance.spacing.small + cacheBuffer: 200 + + useCustomViewport: true + viewport: Qt.rect(0, container.contentY, width, container.height) + + addDuration: Appearance.anim.durations.expressiveDefaultSpatial + addCurve.type: Easing.BezierSpline + addCurve.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + addFromOpacity: 0 + addFromScale: 0 + + removeDuration: Appearance.anim.durations.normal + removeCurve.type: Easing.BezierSpline + removeCurve.bezierCurve: Appearance.anim.curves.standard + removeToOpacity: 0 + removeToScale: 0.6 + + moveDuration: Appearance.anim.durations.expressiveDefaultSpatial + moveCurve.type: Easing.BezierSpline + moveCurve.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + + 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()]; + } } - Repeater { - id: repeater + delegate: Component { + MouseArea { + id: notif - 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()]; + required property int index + required property string modelData + + readonly property bool closed: notifInner.notifCount === 0 + property int startY + + function closeAll(): void { + for (const n of Notifs.notClosed.filter(n => n.appName === modelData)) + n.close(); } - onValuesChanged: root.flagChanged() - } - delegate: NotifGroupDelegate {} - } - - component NotifGroupDelegate: MouseArea { - id: notif - - required property int index - required property string modelData - - readonly property bool closed: notifInner.notifCount === 0 - readonly property alias nonAnimHeight: notifInner.nonAnimHeight - property int startY - - function closeAll(): void { - for (const n of Notifs.notClosed.filter(n => n.appName === modelData)) - n.close(); - } - - y: { - root.flag; // Force update - let y = 0; - for (let i = 0; i < index; i++) { - const item = repeater.itemAt(i) as NotifGroupDelegate; - if (item && !item.closed) - y += item.nonAnimHeight + root.spacing; + containmentMask: QtObject { + function contains(p: point): bool { + if (!root.container.contains(notif.mapToItem(root.container, p))) + return false; + return notifInner.contains(p); + } } - return y; - } - containmentMask: QtObject { - function contains(p: point): bool { - if (!root.container.contains(notif.mapToItem(root.container, p))) - return false; - return notifInner.contains(p); + implicitHeight: closed ? 0 : notifInner.implicitHeight + + hoverEnabled: true + cursorShape: pressed ? Qt.ClosedHandCursor : undefined + acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton + preventStealing: true + enabled: !closed + + drag.target: this + drag.axis: Drag.XAxis + + onPressed: event => { + startY = event.y; + if (event.button === Qt.RightButton) + notifInner.toggleExpand(!notifInner.expanded); + else if (event.button === Qt.MiddleButton) + closeAll(); } - } - - implicitWidth: root.width - implicitHeight: notifInner.implicitHeight - - hoverEnabled: true - cursorShape: pressed ? Qt.ClosedHandCursor : undefined - acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton - preventStealing: true - enabled: !closed - - drag.target: this - drag.axis: Drag.XAxis - - onPressed: event => { - startY = event.y; - if (event.button === Qt.RightButton) - notifInner.toggleExpand(!notifInner.expanded); - else if (event.button === Qt.MiddleButton) - closeAll(); - } - onPositionChanged: event => { - if (pressed) { - const diffY = event.y - startY; - if (Math.abs(diffY) > Config.notifs.expandThreshold) - notifInner.toggleExpand(diffY > 0); + onPositionChanged: event => { + if (pressed) { + const diffY = event.y - startY; + if (Math.abs(diffY) > Config.notifs.expandThreshold) + notifInner.toggleExpand(diffY > 0); + } } - } - onReleased: event => { - if (Math.abs(x) < width * Config.notifs.clearThreshold) - x = 0; - else - closeAll(); - } - - ParallelAnimation { - running: true - - Anim { - target: notif - property: "opacity" - from: 0 - to: 1 + onReleased: event => { + if (Math.abs(x) < width * Config.notifs.clearThreshold) + x = 0; + else + closeAll(); } - Anim { - target: notif - property: "scale" - from: 0 - to: 1 - duration: Appearance.anim.durations.expressiveDefaultSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + + NotifGroup { + id: notifInner + + modelData: notif.modelData + props: root.props + container: root.container + visibilities: root.visibilities } - } - ParallelAnimation { - running: notif.closed - - Anim { - target: notif - property: "opacity" - to: 0 + Behavior on x { + Anim { + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } } - Anim { - target: notif - property: "scale" - to: 0.6 - } - } - NotifGroup { - id: notifInner - - modelData: notif.modelData - props: root.props - container: root.container - visibilities: root.visibilities - } - - Behavior on x { - Anim { - duration: Appearance.anim.durations.expressiveDefaultSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial - } - } - - Behavior on y { - Anim { - duration: Appearance.anim.durations.expressiveDefaultSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + Behavior on implicitHeight { + Anim { + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } } } } diff --git a/plugin/src/Caelestia/CMakeLists.txt b/plugin/src/Caelestia/CMakeLists.txt index 1b7d0e49..426b3acd 100644 --- a/plugin/src/Caelestia/CMakeLists.txt +++ b/plugin/src/Caelestia/CMakeLists.txt @@ -57,6 +57,7 @@ qml_module(caelestia PkgConfig::Qalculate ) +add_subdirectory(Components) add_subdirectory(Internal) add_subdirectory(Models) add_subdirectory(Services) diff --git a/plugin/src/Caelestia/Components/CMakeLists.txt b/plugin/src/Caelestia/Components/CMakeLists.txt new file mode 100644 index 00000000..f880d318 --- /dev/null +++ b/plugin/src/Caelestia/Components/CMakeLists.txt @@ -0,0 +1,7 @@ +qml_module(caelestia-components + URI Caelestia.Components + SOURCES + lazylistview.hpp lazylistview.cpp + LIBRARIES + Qt::Quick +) diff --git a/plugin/src/Caelestia/Components/lazylistview.cpp b/plugin/src/Caelestia/Components/lazylistview.cpp new file mode 100644 index 00000000..126c1202 --- /dev/null +++ b/plugin/src/Caelestia/Components/lazylistview.cpp @@ -0,0 +1,936 @@ +#include "lazylistview.hpp" + +#include +#include + +namespace caelestia::components { + +LazyListView::LazyListView(QQuickItem* parent) + : QQuickItem(parent) { + setFlag(ItemHasContents, false); + setClip(true); +} + +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::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(); +} + +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; +} + +// --- Add Animation --- + +int LazyListView::addDuration() const { + return m_addDuration; +} + +void LazyListView::setAddDuration(int duration) { + if (m_addDuration == duration) + return; + m_addDuration = duration; + emit addDurationChanged(); +} + +QEasingCurve LazyListView::addCurve() const { + return m_addCurve; +} + +void LazyListView::setAddCurve(const QEasingCurve& curve) { + if (m_addCurve == curve) + return; + m_addCurve = curve; + emit addCurveChanged(); +} + +qreal LazyListView::addFromOpacity() const { + return m_addFromOpacity; +} + +void LazyListView::setAddFromOpacity(qreal opacity) { + if (qFuzzyCompare(m_addFromOpacity, opacity)) + return; + m_addFromOpacity = opacity; + emit addFromOpacityChanged(); +} + +qreal LazyListView::addFromScale() const { + return m_addFromScale; +} + +void LazyListView::setAddFromScale(qreal scale) { + if (qFuzzyCompare(m_addFromScale, scale)) + return; + m_addFromScale = scale; + emit addFromScaleChanged(); +} + +// --- Remove Animation --- + +int LazyListView::removeDuration() const { + return m_removeDuration; +} + +void LazyListView::setRemoveDuration(int duration) { + if (m_removeDuration == duration) + return; + m_removeDuration = duration; + emit removeDurationChanged(); +} + +QEasingCurve LazyListView::removeCurve() const { + return m_removeCurve; +} + +void LazyListView::setRemoveCurve(const QEasingCurve& curve) { + if (m_removeCurve == curve) + return; + m_removeCurve = curve; + emit removeCurveChanged(); +} + +qreal LazyListView::removeToOpacity() const { + return m_removeToOpacity; +} + +void LazyListView::setRemoveToOpacity(qreal opacity) { + if (qFuzzyCompare(m_removeToOpacity, opacity)) + return; + m_removeToOpacity = opacity; + emit removeToOpacityChanged(); +} + +qreal LazyListView::removeToScale() const { + return m_removeToScale; +} + +void LazyListView::setRemoveToScale(qreal scale) { + if (qFuzzyCompare(m_removeToScale, scale)) + return; + m_removeToScale = scale; + emit removeToScaleChanged(); +} + +// --- Move Animation --- + +int LazyListView::moveDuration() const { + return m_moveDuration; +} + +void LazyListView::setMoveDuration(int duration) { + if (m_moveDuration == duration) + return; + m_moveDuration = duration; + emit moveDurationChanged(); +} + +QEasingCurve LazyListView::moveCurve() const { + return m_moveCurve; +} + +void LazyListView::setMoveCurve(const QEasingCurve& curve) { + if (m_moveCurve == curve) + return; + m_moveCurve = curve; + emit moveCurveChanged(); +} + +// --- State --- + +int LazyListView::count() const { + return m_model ? m_model->rowCount() : 0; +} + +bool LazyListView::settled() const { + return m_activeAnimations == 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; + + relayout(); + syncDelegates(); + positionDelegates(); +} + +// --- Layout Engine --- + +void LazyListView::relayout() { + qreal y = 0; + for (auto& record : m_layout) { + record.targetY = y; + y += (record.heightKnown ? record.height : effectiveEstimatedHeight()) + m_spacing; + } + + const qreal newHeight = m_layout.isEmpty() ? 0 : y - m_spacing; + if (!qFuzzyCompare(m_contentHeight, newHeight)) { + m_contentHeight = newHeight; + emit contentHeightChanged(); + } +} + +QRectF LazyListView::effectiveViewport() const { + if (m_useCustomViewport) + return m_viewport.adjusted(0, -m_cacheBuffer, 0, m_cacheBuffer); + + return QRectF(0, m_contentY - m_cacheBuffer, width(), height() + 2 * m_cacheBuffer); +} + +std::pair LazyListView::computeVisibleRange() const { + if (m_layout.isEmpty()) + return { -1, -1 }; + + const auto vp = effectiveViewport(); + 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); + } + + // Destroy delegates outside visible range (if not animating) + QList toRemove; + for (auto it = m_delegates.begin(); it != m_delegates.end(); ++it) { + if (!visibleIndices.contains(it.key()) && !it->animation) { + toRemove.append(it.key()); + } + } + for (int idx : toRemove) { + auto entry = m_delegates.take(idx); + destroyDelegate(entry); + } + + // Create delegates for newly visible indices + if (first >= 0) { + for (int i = first; i <= last; ++i) { + if (m_delegates.contains(i)) + continue; + + auto entry = createDelegate(i); + if (entry.item) { + // Measure height + const qreal h = entry.item->implicitHeight(); + if (h > 0 && !m_layout[i].heightKnown) { + m_layout[i].height = h; + m_layout[i].heightKnown = true; + trackHeight(h); + } + m_delegates.insert(i, std::move(entry)); + } + } + } +} + +LazyListView::DelegateEntry LazyListView::createDelegate(int modelIndex) { + DelegateEntry entry; + entry.modelIndex = modelIndex; + + if (!m_delegate || !m_model) + return entry; + + // Use the delegate component's creation context so the delegate + // can access ids and properties from the scope where it was defined. + auto* compContext = m_delegate->creationContext(); + auto* parentContext = compContext ? compContext : qmlContext(this); + if (!parentContext) + return entry; + + entry.context = new QQmlContext(parentContext, this); + + // Build property map for both context properties and initial properties + const auto roleNames = m_model->roleNames(); + 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()); + const auto value = m_model->data(index, it.key()); + entry.context->setContextProperty(name, value); + initialProps.insert(name, value); + if (name == QStringLiteral("modelData")) + hasModelData = true; + } + entry.context->setContextProperty(QStringLiteral("index"), modelIndex); + initialProps.insert(QStringLiteral("index"), modelIndex); + + // Provide modelData for single-role models or if not already provided by role names + if (!hasModelData) { + const auto role = roleNames.isEmpty() ? Qt::DisplayRole : roleNames.constBegin().key(); + const auto value = m_model->data(index, role); + entry.context->setContextProperty(QStringLiteral("modelData"), value); + initialProps.insert(QStringLiteral("modelData"), value); + } + + auto* obj = m_delegate->beginCreate(entry.context); + entry.item = qobject_cast(obj); + + if (!entry.item) { + delete obj; + delete entry.context; + entry.context = nullptr; + return entry; + } + + // Set initial properties to satisfy required property declarations + m_delegate->setInitialProperties(entry.item, initialProps); + + entry.item->setParentItem(this); + entry.item->setWidth(width()); + m_delegate->completeCreate(); + + // Watch for height changes + connect(entry.item, &QQuickItem::implicitHeightChanged, this, [this, modelIndex] { + if (!m_delegates.contains(modelIndex)) + return; + auto& e = m_delegates[modelIndex]; + if (!e.item) + return; + const qreal h = e.item->implicitHeight(); + if (modelIndex < static_cast(m_layout.size()) && !qFuzzyCompare(m_layout[modelIndex].height, h)) { + const qreal oldH = m_layout[modelIndex].height; + const bool wasKnown = m_layout[modelIndex].heightKnown; + m_layout[modelIndex].height = h; + m_layout[modelIndex].heightKnown = true; + if (wasKnown) + untrackHeight(oldH); + trackHeight(h); + polish(); + } + }); + + return entry; +} + +void LazyListView::destroyDelegate(DelegateEntry& entry) { + if (entry.animation) { + entry.animation->stop(); + entry.animation = nullptr; + } + delete entry.item; + entry.item = nullptr; + delete entry.context; + entry.context = nullptr; +} + +void LazyListView::updateDelegateData(DelegateEntry& entry) { + if (!m_model) + 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()); + const auto value = m_model->data(index, it.key()); + if (entry.context) + entry.context->setContextProperty(name, value); + if (entry.item) + entry.item->setProperty(name.toUtf8().constData(), value); + if (name == QStringLiteral("modelData")) + hasModelData = true; + } + + if (entry.context) + entry.context->setContextProperty(QStringLiteral("index"), entry.modelIndex); + if (entry.item) + entry.item->setProperty("index", entry.modelIndex); + + if (!hasModelData) { + const auto role = roleNames.isEmpty() ? Qt::DisplayRole : roleNames.constBegin().key(); + const auto value = m_model->data(index, role); + if (entry.context) + entry.context->setContextProperty(QStringLiteral("modelData"), value); + if (entry.item) + entry.item->setProperty("modelData", value); + } +} + +void LazyListView::positionDelegates() { + for (auto& entry : m_delegates) { + if (!entry.item || entry.pendingRemoval) + continue; + + // Don't reposition if a move animation is running on this delegate + if (entry.animation) + continue; + + const int idx = entry.modelIndex; + if (idx < 0 || idx >= static_cast(m_layout.size())) + continue; + + entry.item->setY(m_layout[idx].targetY - m_contentY); + } +} + +// --- 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, &LazyListView::onModelReset), + 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(); + + for (auto& entry : m_dyingDelegates) + destroyDelegate(entry); + m_dyingDelegates.clear(); + + if (m_activeAnimations != 0) { + m_activeAnimations = 0; + emit settledChanged(); + } + + // Reset height tracking + 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; + + // Capture old positions of existing delegates for move animation + QHash oldPositions; + for (auto it = m_delegates.begin(); it != m_delegates.end(); ++it) { + if (it.key() >= first) + oldPositions.insert(it.key(), m_layout[it.key()].targetY); + } + + // Insert new layout records + m_layout.insert(first, insertCount, ItemRecord{ 0, 0, false }); + + // 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.context) + entry.context->setContextProperty(QStringLiteral("index"), newIdx); + shifted.insert(newIdx, std::move(entry)); + } + m_delegates = std::move(shifted); + + relayout(); + syncDelegates(); + positionDelegates(); + + // Animate new items + for (int i = first; i <= last; ++i) { + if (m_delegates.contains(i) && m_addDuration > 0) + startAddAnimation(m_delegates[i]); + } + + // Animate displaced items + for (auto it = oldPositions.begin(); it != oldPositions.end(); ++it) { + const int newIdx = it.key() + insertCount; + if (m_delegates.contains(newIdx) && m_moveDuration > 0) { + const qreal oldY = it.value() - m_contentY; + startMoveAnimation(m_delegates[newIdx], oldY); + } + } + + emit countChanged(); +} + +void LazyListView::onRowsAboutToBeRemoved(const QModelIndex& parent, int first, int last) { + if (parent.isValid()) + return; + + // Start remove animations for visible delegates being removed + for (int i = first; i <= last; ++i) { + if (!m_delegates.contains(i)) + continue; + + auto entry = m_delegates.take(i); + entry.pendingRemoval = true; + + if (m_removeDuration > 0 && entry.item) { + startRemoveAnimation(entry); + 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; + + // Capture old positions for displaced animation + QHash oldPositions; + for (auto it = m_delegates.begin(); it != m_delegates.end(); ++it) { + if (it.key() > last) + oldPositions.insert(it.key(), m_layout[it.key()].targetY); + } + + // 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.context) + entry.context->setContextProperty(QStringLiteral("index"), newIdx); + shifted.insert(newIdx, std::move(entry)); + } + m_delegates = std::move(shifted); + + relayout(); + syncDelegates(); + positionDelegates(); + + // Animate displaced items + for (auto it = oldPositions.begin(); it != oldPositions.end(); ++it) { + const int newIdx = it.key() - removeCount; + if (m_delegates.contains(newIdx) && m_moveDuration > 0) { + const qreal oldY = it.value() - m_contentY; + startMoveAnimation(m_delegates[newIdx], oldY); + } + } + + emit countChanged(); +} + +void LazyListView::onRowsMoved(const QModelIndex& parent, int start, int end, const QModelIndex& destination, int row) { + Q_UNUSED(parent) + Q_UNUSED(start) + Q_UNUSED(end) + Q_UNUSED(destination) + Q_UNUSED(row) + + // Full reset for moves — complex index remapping + onModelReset(); +} + +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() { + resetContent(); +} + +// --- Animation --- + +void LazyListView::startAddAnimation(DelegateEntry& entry) { + if (!entry.item || m_addDuration <= 0) + return; + + stopAnimation(entry); + + auto* group = new QParallelAnimationGroup(this); + + if (!qFuzzyCompare(m_addFromOpacity, 1.0)) { + auto* opacityAnim = new QPropertyAnimation(entry.item, "opacity"); + opacityAnim->setDuration(m_addDuration); + opacityAnim->setEasingCurve(m_addCurve); + opacityAnim->setStartValue(m_addFromOpacity); + opacityAnim->setEndValue(1.0); + group->addAnimation(opacityAnim); + entry.item->setOpacity(m_addFromOpacity); + } + + if (!qFuzzyCompare(m_addFromScale, 1.0)) { + auto* scaleAnim = new QPropertyAnimation(entry.item, "scale"); + scaleAnim->setDuration(m_addDuration); + scaleAnim->setEasingCurve(m_addCurve); + scaleAnim->setStartValue(m_addFromScale); + scaleAnim->setEndValue(1.0); + group->addAnimation(scaleAnim); + entry.item->setScale(m_addFromScale); + } + + if (group->animationCount() == 0) { + delete group; + return; + } + + entry.animation = group; + ++m_activeAnimations; + if (m_activeAnimations == 1) + emit settledChanged(); + + connect(group, &QAbstractAnimation::finished, this, &LazyListView::onAnimationFinished); + group->start(QAbstractAnimation::DeleteWhenStopped); +} + +void LazyListView::startRemoveAnimation(DelegateEntry& entry) { + if (!entry.item || m_removeDuration <= 0) + return; + + stopAnimation(entry); + + auto* group = new QParallelAnimationGroup(this); + + if (!qFuzzyCompare(m_removeToOpacity, 1.0)) { + auto* opacityAnim = new QPropertyAnimation(entry.item, "opacity"); + opacityAnim->setDuration(m_removeDuration); + opacityAnim->setEasingCurve(m_removeCurve); + opacityAnim->setStartValue(entry.item->opacity()); + opacityAnim->setEndValue(m_removeToOpacity); + group->addAnimation(opacityAnim); + } + + if (!qFuzzyCompare(m_removeToScale, 1.0)) { + auto* scaleAnim = new QPropertyAnimation(entry.item, "scale"); + scaleAnim->setDuration(m_removeDuration); + scaleAnim->setEasingCurve(m_removeCurve); + scaleAnim->setStartValue(entry.item->scale()); + scaleAnim->setEndValue(m_removeToScale); + group->addAnimation(scaleAnim); + } + + if (group->animationCount() == 0) { + delete group; + return; + } + + entry.animation = group; + ++m_activeAnimations; + if (m_activeAnimations == 1) + emit settledChanged(); + + connect(group, &QAbstractAnimation::finished, this, &LazyListView::onAnimationFinished); + group->start(QAbstractAnimation::DeleteWhenStopped); +} + +void LazyListView::startMoveAnimation(DelegateEntry& entry, qreal fromY) { + if (!entry.item || m_moveDuration <= 0) + return; + + const int idx = entry.modelIndex; + if (idx < 0 || idx >= static_cast(m_layout.size())) + return; + + const qreal toY = m_layout[idx].targetY - m_contentY; + if (qFuzzyCompare(fromY, toY)) + return; + + stopAnimation(entry); + + auto* group = new QParallelAnimationGroup(this); + + auto* yAnim = new QPropertyAnimation(entry.item, "y"); + yAnim->setDuration(m_moveDuration); + yAnim->setEasingCurve(m_moveCurve); + yAnim->setStartValue(fromY); + yAnim->setEndValue(toY); + group->addAnimation(yAnim); + + entry.item->setY(fromY); + entry.animation = group; + ++m_activeAnimations; + if (m_activeAnimations == 1) + emit settledChanged(); + + connect(group, &QAbstractAnimation::finished, this, &LazyListView::onAnimationFinished); + group->start(QAbstractAnimation::DeleteWhenStopped); +} + +void LazyListView::stopAnimation(DelegateEntry& entry) { + if (!entry.animation) + return; + + entry.animation->stop(); + entry.animation = nullptr; + + --m_activeAnimations; + if (m_activeAnimations == 0) + emit settledChanged(); +} + +void LazyListView::onAnimationFinished() { + auto* group = qobject_cast(sender()); + + // Clear animation pointer from live delegates + for (auto& entry : m_delegates) { + if (entry.animation == group) + entry.animation = nullptr; + } + + // Clean up dying delegates whose animation finished + m_dyingDelegates.erase(std::remove_if(m_dyingDelegates.begin(), m_dyingDelegates.end(), + [this, group](DelegateEntry& entry) { + if (entry.animation == group) { + entry.animation = nullptr; + destroyDelegate(entry); + return true; + } + return false; + }), + m_dyingDelegates.end()); + + --m_activeAnimations; + if (m_activeAnimations == 0) + emit settledChanged(); + + // Re-sync in case viewport changed during animation + polish(); +} + +} // namespace caelestia::components diff --git a/plugin/src/Caelestia/Components/lazylistview.hpp b/plugin/src/Caelestia/Components/lazylistview.hpp new file mode 100644 index 00000000..a310f96f --- /dev/null +++ b/plugin/src/Caelestia/Components/lazylistview.hpp @@ -0,0 +1,244 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace caelestia::components { + +class LazyListView : public QQuickItem { + Q_OBJECT + QML_ELEMENT + + // 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 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) + + // Add Animation + Q_PROPERTY(int addDuration READ addDuration WRITE setAddDuration NOTIFY addDurationChanged) + Q_PROPERTY(QEasingCurve addCurve READ addCurve WRITE setAddCurve NOTIFY addCurveChanged) + Q_PROPERTY(qreal addFromOpacity READ addFromOpacity WRITE setAddFromOpacity NOTIFY addFromOpacityChanged) + Q_PROPERTY(qreal addFromScale READ addFromScale WRITE setAddFromScale NOTIFY addFromScaleChanged) + + // Remove Animation + Q_PROPERTY(int removeDuration READ removeDuration WRITE setRemoveDuration NOTIFY removeDurationChanged) + Q_PROPERTY(QEasingCurve removeCurve READ removeCurve WRITE setRemoveCurve NOTIFY removeCurveChanged) + Q_PROPERTY(qreal removeToOpacity READ removeToOpacity WRITE setRemoveToOpacity NOTIFY removeToOpacityChanged) + Q_PROPERTY(qreal removeToScale READ removeToScale WRITE setRemoveToScale NOTIFY removeToScaleChanged) + + // Move/Displaced Animation + Q_PROPERTY(int moveDuration READ moveDuration WRITE setMoveDuration NOTIFY moveDurationChanged) + Q_PROPERTY(QEasingCurve moveCurve READ moveCurve WRITE setMoveCurve NOTIFY moveCurveChanged) + + // State + Q_PROPERTY(int count READ count NOTIFY countChanged) + Q_PROPERTY(bool settled READ settled NOTIFY settledChanged) + +public: + explicit LazyListView(QQuickItem* parent = nullptr); + ~LazyListView() override; + + // 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 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); + + // Add Animation + [[nodiscard]] int addDuration() const; + void setAddDuration(int duration); + + [[nodiscard]] QEasingCurve addCurve() const; + void setAddCurve(const QEasingCurve& curve); + + [[nodiscard]] qreal addFromOpacity() const; + void setAddFromOpacity(qreal opacity); + + [[nodiscard]] qreal addFromScale() const; + void setAddFromScale(qreal scale); + + // Remove Animation + [[nodiscard]] int removeDuration() const; + void setRemoveDuration(int duration); + + [[nodiscard]] QEasingCurve removeCurve() const; + void setRemoveCurve(const QEasingCurve& curve); + + [[nodiscard]] qreal removeToOpacity() const; + void setRemoveToOpacity(qreal opacity); + + [[nodiscard]] qreal removeToScale() const; + void setRemoveToScale(qreal scale); + + // Move Animation + [[nodiscard]] int moveDuration() const; + void setMoveDuration(int duration); + + [[nodiscard]] QEasingCurve moveCurve() const; + void setMoveCurve(const QEasingCurve& curve); + + // State + [[nodiscard]] int count() const; + [[nodiscard]] bool settled() const; + +signals: + void modelChanged(); + void delegateChanged(); + void spacingChanged(); + void contentHeightChanged(); + void contentYChanged(); + void viewportChanged(); + void useCustomViewportChanged(); + void cacheBufferChanged(); + void estimatedHeightChanged(); + void addDurationChanged(); + void addCurveChanged(); + void addFromOpacityChanged(); + void addFromScaleChanged(); + void removeDurationChanged(); + void removeCurveChanged(); + void removeToOpacityChanged(); + void removeToScaleChanged(); + void moveDurationChanged(); + void moveCurveChanged(); + void countChanged(); + void settledChanged(); + +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; + }; + + struct DelegateEntry { + int modelIndex = -1; + QQuickItem* item = nullptr; + QQmlContext* context = nullptr; + bool pendingRemoval = false; + QParallelAnimationGroup* animation = nullptr; + }; + + // Layout + void relayout(); + [[nodiscard]] std::pair computeVisibleRange() const; + [[nodiscard]] QRectF effectiveViewport() const; + [[nodiscard]] qreal effectiveEstimatedHeight() const; + void trackHeight(qreal height); + void untrackHeight(qreal height); + + // Delegate lifecycle + void syncDelegates(); + DelegateEntry createDelegate(int modelIndex); + void destroyDelegate(DelegateEntry& entry); + void updateDelegateData(DelegateEntry& entry); + void positionDelegates(); + + // 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(); + + // Animation + void startAddAnimation(DelegateEntry& entry); + void startRemoveAnimation(DelegateEntry& entry); + void startMoveAnimation(DelegateEntry& entry, qreal fromY); + void stopAnimation(DelegateEntry& entry); + void onAnimationFinished(); + + // Members + QAbstractItemModel* m_model = nullptr; + QQmlComponent* m_delegate = nullptr; + + qreal m_spacing = 0; + qreal m_contentHeight = 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; + + int m_addDuration = 300; + QEasingCurve m_addCurve; + qreal m_addFromOpacity = 0; + qreal m_addFromScale = 1; + + int m_removeDuration = 300; + QEasingCurve m_removeCurve; + qreal m_removeToOpacity = 0; + qreal m_removeToScale = 1; + + int m_moveDuration = 300; + QEasingCurve m_moveCurve; + + QVector m_layout; + QHash m_delegates; + QVector m_dyingDelegates; + + int m_activeAnimations = 0; + bool m_componentComplete = false; + + QList m_modelConnections; +}; + +} // namespace caelestia::components From 9e35e93f76ab0510c5f136b06521c150d672e674 Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Fri, 3 Apr 2026 02:27:53 +1100 Subject: [PATCH 02/37] fix: model change sigabrt crash + resize anim --- modules/sidebar/NotifDockList.qml | 10 +- .../src/Caelestia/Components/lazylistview.cpp | 183 +++++++++++------- .../src/Caelestia/Components/lazylistview.hpp | 26 ++- 3 files changed, 137 insertions(+), 82 deletions(-) diff --git a/modules/sidebar/NotifDockList.qml b/modules/sidebar/NotifDockList.qml index c7d1ebaf..f2dea1c2 100644 --- a/modules/sidebar/NotifDockList.qml +++ b/modules/sidebar/NotifDockList.qml @@ -72,7 +72,8 @@ LazyListView { } } - implicitHeight: closed ? 0 : notifInner.implicitHeight + LazyListView.preferredHeight: closed ? 0 : notifInner.implicitHeight + implicitHeight: notifInner.implicitHeight hoverEnabled: true cursorShape: pressed ? Qt.ClosedHandCursor : undefined @@ -119,13 +120,6 @@ LazyListView { easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial } } - - Behavior on implicitHeight { - Anim { - duration: Appearance.anim.durations.expressiveDefaultSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial - } - } } } } diff --git a/plugin/src/Caelestia/Components/lazylistview.cpp b/plugin/src/Caelestia/Components/lazylistview.cpp index 126c1202..ea059a10 100644 --- a/plugin/src/Caelestia/Components/lazylistview.cpp +++ b/plugin/src/Caelestia/Components/lazylistview.cpp @@ -5,12 +5,34 @@ namespace caelestia::components { +// --- LazyListViewAttached --- + +LazyListViewAttached::LazyListViewAttached(QObject* parent) + : QObject(parent) {} + +qreal LazyListViewAttached::preferredHeight() const { + return m_preferredHeight; +} + +void LazyListViewAttached::setPreferredHeight(qreal height) { + if (qFuzzyCompare(m_preferredHeight, height)) + return; + m_preferredHeight = height; + emit preferredHeightChanged(); +} + +// --- LazyListView --- + LazyListView::LazyListView(QQuickItem* parent) : QQuickItem(parent) { setFlag(ItemHasContents, false); setClip(true); } +LazyListViewAttached* LazyListView::qmlAttachedProperties(QObject* object) { + return new LazyListViewAttached(object); +} + LazyListView::~LazyListView() { for (auto& entry : m_delegates) destroyDelegate(entry); @@ -154,6 +176,18 @@ void LazyListView::untrackHeight(qreal 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(); +} + // --- Add Animation --- int LazyListView::addDuration() const { @@ -310,7 +344,37 @@ void LazyListView::updatePolish() { relayout(); syncDelegates(); - positionDelegates(); + + // Animate newly created delegates that were pending add animation + QSet pendingAdds; + m_pendingAddAnimations.swap(pendingAdds); + for (int idx : std::as_const(pendingAdds)) { + if (m_delegates.contains(idx) && m_addDuration > 0) + startAddAnimation(m_delegates[idx]); + } + + // Position delegates, animating displacement if a model change occurred + const bool animate = m_animateDisplacement; + m_animateDisplacement = false; + + for (auto& entry : m_delegates) { + if (!entry.item || entry.pendingRemoval || entry.animation) + continue; + + const int idx = entry.modelIndex; + if (idx < 0 || idx >= static_cast(m_layout.size())) + continue; + + const qreal targetY = m_layout[idx].targetY - m_contentY; + const qreal currentY = entry.item->y(); + + if (animate && !qFuzzyCompare(currentY, targetY) && m_moveDuration > 0 + && !pendingAdds.contains(idx)) { + startMoveAnimation(entry, currentY); + } else if (!entry.animation) { + entry.item->setY(targetY); + } + } } // --- Layout Engine --- @@ -408,8 +472,8 @@ void LazyListView::syncDelegates() { auto entry = createDelegate(i); if (entry.item) { - // Measure height - const qreal h = entry.item->implicitHeight(); + // Measure height (prefer attached preferredHeight, fall back to implicitHeight) + const qreal h = delegateHeight(entry.item); if (h > 0 && !m_layout[i].heightKnown) { m_layout[i].height = h; m_layout[i].heightKnown = true; @@ -479,14 +543,14 @@ LazyListView::DelegateEntry LazyListView::createDelegate(int modelIndex) { entry.item->setWidth(width()); m_delegate->completeCreate(); - // Watch for height changes - connect(entry.item, &QQuickItem::implicitHeightChanged, this, [this, modelIndex] { + // Shared height-change handler + auto onHeightChanged = [this, modelIndex] { if (!m_delegates.contains(modelIndex)) return; auto& e = m_delegates[modelIndex]; if (!e.item) return; - const qreal h = e.item->implicitHeight(); + const qreal h = delegateHeight(e.item); if (modelIndex < static_cast(m_layout.size()) && !qFuzzyCompare(m_layout[modelIndex].height, h)) { const qreal oldH = m_layout[modelIndex].height; const bool wasKnown = m_layout[modelIndex].heightKnown; @@ -497,20 +561,45 @@ LazyListView::DelegateEntry LazyListView::createDelegate(int modelIndex) { trackHeight(h); polish(); } - }); + }; + + // Watch implicitHeight as fallback + connect(entry.item, &QQuickItem::implicitHeightChanged, this, onHeightChanged); + + // Watch attached preferredHeight if the delegate uses it + auto* attached = qobject_cast( + qmlAttachedPropertiesObject(entry.item, false)); + if (attached) { + entry.attachedConnection = connect(attached, &LazyListViewAttached::preferredHeightChanged, + this, onHeightChanged); + } return entry; } void LazyListView::destroyDelegate(DelegateEntry& entry) { if (entry.animation) { + // Disconnect before stopping to prevent re-entrant onAnimationFinished + disconnect(entry.animation, &QAbstractAnimation::finished, + this, &LazyListView::onAnimationFinished); entry.animation->stop(); entry.animation = nullptr; + --m_activeAnimations; + if (m_activeAnimations == 0) + emit settledChanged(); + } + if (entry.attachedConnection) + disconnect(entry.attachedConnection); + if (entry.item) { + entry.item->setParentItem(nullptr); + entry.item->setVisible(false); + entry.item->deleteLater(); + entry.item = nullptr; + } + if (entry.context) { + entry.context->deleteLater(); + entry.context = nullptr; } - delete entry.item; - entry.item = nullptr; - delete entry.context; - entry.context = nullptr; } void LazyListView::updateDelegateData(DelegateEntry& entry) { @@ -547,23 +636,6 @@ void LazyListView::updateDelegateData(DelegateEntry& entry) { } } -void LazyListView::positionDelegates() { - for (auto& entry : m_delegates) { - if (!entry.item || entry.pendingRemoval) - continue; - - // Don't reposition if a move animation is running on this delegate - if (entry.animation) - continue; - - const int idx = entry.modelIndex; - if (idx < 0 || idx >= static_cast(m_layout.size())) - continue; - - entry.item->setY(m_layout[idx].targetY - m_contentY); - } -} - // --- Model Connection --- void LazyListView::connectModel() { @@ -608,9 +680,11 @@ void LazyListView::resetContent() { emit settledChanged(); } - // Reset height tracking + // Reset pending state m_knownHeightSum = 0; m_knownHeightCount = 0; + m_pendingAddAnimations.clear(); + m_animateDisplacement = false; // Rebuild layout from model m_layout.clear(); @@ -633,13 +707,6 @@ void LazyListView::onRowsInserted(const QModelIndex& parent, int first, int last const int insertCount = last - first + 1; - // Capture old positions of existing delegates for move animation - QHash oldPositions; - for (auto it = m_delegates.begin(); it != m_delegates.end(); ++it) { - if (it.key() >= first) - oldPositions.insert(it.key(), m_layout[it.key()].targetY); - } - // Insert new layout records m_layout.insert(first, insertCount, ItemRecord{ 0, 0, false }); @@ -655,26 +722,13 @@ void LazyListView::onRowsInserted(const QModelIndex& parent, int first, int last } m_delegates = std::move(shifted); - relayout(); - syncDelegates(); - positionDelegates(); - - // Animate new items - for (int i = first; i <= last; ++i) { - if (m_delegates.contains(i) && m_addDuration > 0) - startAddAnimation(m_delegates[i]); - } - - // Animate displaced items - for (auto it = oldPositions.begin(); it != oldPositions.end(); ++it) { - const int newIdx = it.key() + insertCount; - if (m_delegates.contains(newIdx) && m_moveDuration > 0) { - const qreal oldY = it.value() - m_contentY; - startMoveAnimation(m_delegates[newIdx], oldY); - } - } + // Queue add animations and mark displacement + for (int i = first; i <= last; ++i) + m_pendingAddAnimations.insert(i); + m_animateDisplacement = true; emit countChanged(); + polish(); } void LazyListView::onRowsAboutToBeRemoved(const QModelIndex& parent, int first, int last) { @@ -704,13 +758,6 @@ void LazyListView::onRowsRemoved(const QModelIndex& parent, int first, int last) const int removeCount = last - first + 1; - // Capture old positions for displaced animation - QHash oldPositions; - for (auto it = m_delegates.begin(); it != m_delegates.end(); ++it) { - if (it.key() > last) - oldPositions.insert(it.key(), m_layout[it.key()].targetY); - } - // Untrack known heights being removed for (int i = first; i <= last; ++i) { if (m_layout[i].heightKnown) @@ -732,20 +779,10 @@ void LazyListView::onRowsRemoved(const QModelIndex& parent, int first, int last) } m_delegates = std::move(shifted); - relayout(); - syncDelegates(); - positionDelegates(); - - // Animate displaced items - for (auto it = oldPositions.begin(); it != oldPositions.end(); ++it) { - const int newIdx = it.key() - removeCount; - if (m_delegates.contains(newIdx) && m_moveDuration > 0) { - const qreal oldY = it.value() - m_contentY; - startMoveAnimation(m_delegates[newIdx], oldY); - } - } + m_animateDisplacement = true; emit countChanged(); + polish(); } void LazyListView::onRowsMoved(const QModelIndex& parent, int start, int end, const QModelIndex& destination, int row) { diff --git a/plugin/src/Caelestia/Components/lazylistview.hpp b/plugin/src/Caelestia/Components/lazylistview.hpp index a310f96f..fdc0907f 100644 --- a/plugin/src/Caelestia/Components/lazylistview.hpp +++ b/plugin/src/Caelestia/Components/lazylistview.hpp @@ -14,9 +14,28 @@ namespace caelestia::components { +class LazyListViewAttached : public QObject { + Q_OBJECT + + Q_PROPERTY(qreal preferredHeight READ preferredHeight WRITE setPreferredHeight NOTIFY preferredHeightChanged) + +public: + explicit LazyListViewAttached(QObject* parent = nullptr); + + [[nodiscard]] qreal preferredHeight() const; + void setPreferredHeight(qreal height); + +signals: + void preferredHeightChanged(); + +private: + qreal m_preferredHeight = -1; +}; + class LazyListView : public QQuickItem { Q_OBJECT QML_ELEMENT + QML_ATTACHED(LazyListViewAttached) // Model & Delegate Q_PROPERTY(QAbstractItemModel* model READ model WRITE setModel NOTIFY modelChanged) @@ -59,6 +78,8 @@ public: explicit LazyListView(QQuickItem* parent = nullptr); ~LazyListView() override; + static LazyListViewAttached* qmlAttachedProperties(QObject* object); + // Model & Delegate [[nodiscard]] QAbstractItemModel* model() const; void setModel(QAbstractItemModel* model); @@ -167,6 +188,7 @@ private: QQmlContext* context = nullptr; bool pendingRemoval = false; QParallelAnimationGroup* animation = nullptr; + QMetaObject::Connection attachedConnection; }; // Layout @@ -174,6 +196,7 @@ private: [[nodiscard]] std::pair computeVisibleRange() const; [[nodiscard]] QRectF effectiveViewport() const; [[nodiscard]] qreal effectiveEstimatedHeight() const; + [[nodiscard]] static qreal delegateHeight(QQuickItem* item); void trackHeight(qreal height); void untrackHeight(qreal height); @@ -182,7 +205,6 @@ private: DelegateEntry createDelegate(int modelIndex); void destroyDelegate(DelegateEntry& entry); void updateDelegateData(DelegateEntry& entry); - void positionDelegates(); // Model connection void connectModel(); @@ -237,6 +259,8 @@ private: int m_activeAnimations = 0; bool m_componentComplete = false; + bool m_animateDisplacement = false; + QSet m_pendingAddAnimations; QList m_modelConnections; }; From 2acd8f13f8d8be759e7eb559e91cdf04f28971c2 Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Fri, 3 Apr 2026 02:55:04 +1100 Subject: [PATCH 03/37] fix: don't recreate all delegates on model change --- .../src/Caelestia/Components/lazylistview.cpp | 142 +++++++++++++++--- .../src/Caelestia/Components/lazylistview.hpp | 1 + 2 files changed, 122 insertions(+), 21 deletions(-) diff --git a/plugin/src/Caelestia/Components/lazylistview.cpp b/plugin/src/Caelestia/Components/lazylistview.cpp index ea059a10..d1e2570c 100644 --- a/plugin/src/Caelestia/Components/lazylistview.cpp +++ b/plugin/src/Caelestia/Components/lazylistview.cpp @@ -375,6 +375,18 @@ void LazyListView::updatePolish() { entry.item->setY(targetY); } } + + // Flush delegate pool — any delegates not reclaimed are truly removed + for (auto& pooled : m_delegatePool) { + pooled.pendingRemoval = true; + if (m_removeDuration > 0 && pooled.item) { + startRemoveAnimation(pooled); + m_dyingDelegates.append(std::move(pooled)); + } else { + destroyDelegate(pooled); + } + } + m_delegatePool.clear(); } // --- Layout Engine --- @@ -492,6 +504,27 @@ LazyListView::DelegateEntry LazyListView::createDelegate(int modelIndex) { if (!m_delegate || !m_model) return entry; + // Try to reclaim a delegate from the pool (reuse after remove+insert cycle) + const auto roleNames = m_model->roleNames(); + const auto role = roleNames.isEmpty() ? Qt::DisplayRole : roleNames.constBegin().key(); + const auto targetData = m_model->data(m_model->index(modelIndex, 0), role); + + for (auto it = m_delegatePool.begin(); it != m_delegatePool.end(); ++it) { + if (!it->item) + continue; + const auto poolData = it->item->property("modelData"); + if (poolData == targetData) { + entry = std::move(*it); + m_delegatePool.erase(it); + entry.modelIndex = modelIndex; + entry.pendingRemoval = false; + updateDelegateData(entry); + entry.item->setParentItem(this); + entry.item->setWidth(width()); + return entry; + } + } + // Use the delegate component's creation context so the delegate // can access ids and properties from the scope where it was defined. auto* compContext = m_delegate->creationContext(); @@ -502,7 +535,6 @@ LazyListView::DelegateEntry LazyListView::createDelegate(int modelIndex) { entry.context = new QQmlContext(parentContext, this); // Build property map for both context properties and initial properties - const auto roleNames = m_model->roleNames(); const auto index = m_model->index(modelIndex, 0); QVariantMap initialProps; @@ -520,7 +552,6 @@ LazyListView::DelegateEntry LazyListView::createDelegate(int modelIndex) { // Provide modelData for single-role models or if not already provided by role names if (!hasModelData) { - const auto role = roleNames.isEmpty() ? Qt::DisplayRole : roleNames.constBegin().key(); const auto value = m_model->data(index, role); entry.context->setContextProperty(QStringLiteral("modelData"), value); initialProps.insert(QStringLiteral("modelData"), value); @@ -649,7 +680,11 @@ void LazyListView::connectModel() { 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, &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; @@ -675,6 +710,10 @@ void LazyListView::resetContent() { destroyDelegate(entry); m_dyingDelegates.clear(); + for (auto& entry : m_delegatePool) + destroyDelegate(entry); + m_delegatePool.clear(); + if (m_activeAnimations != 0) { m_activeAnimations = 0; emit settledChanged(); @@ -706,7 +745,6 @@ void LazyListView::onRowsInserted(const QModelIndex& parent, int first, int last return; const int insertCount = last - first + 1; - // Insert new layout records m_layout.insert(first, insertCount, ItemRecord{ 0, 0, false }); @@ -735,20 +773,14 @@ void LazyListView::onRowsAboutToBeRemoved(const QModelIndex& parent, int first, if (parent.isValid()) return; - // Start remove animations for visible delegates being removed + // Pool removed delegates — they may be reused if the model re-inserts the same data for (int i = first; i <= last; ++i) { if (!m_delegates.contains(i)) continue; auto entry = m_delegates.take(i); - entry.pendingRemoval = true; - - if (m_removeDuration > 0 && entry.item) { - startRemoveAnimation(entry); - m_dyingDelegates.append(std::move(entry)); - } else { - destroyDelegate(entry); - } + stopAnimation(entry); + m_delegatePool.append(std::move(entry)); } } @@ -785,15 +817,48 @@ void LazyListView::onRowsRemoved(const QModelIndex& parent, int first, int last) polish(); } -void LazyListView::onRowsMoved(const QModelIndex& parent, int start, int end, const QModelIndex& destination, int row) { - Q_UNUSED(parent) - Q_UNUSED(start) - Q_UNUSED(end) - Q_UNUSED(destination) - Q_UNUSED(row) +void LazyListView::onRowsMoved(const QModelIndex& parent, int start, int end, + const QModelIndex& destination, int row) { + if (parent.isValid() || destination.isValid()) + return; - // Full reset for moves — complex index remapping - onModelReset(); + 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.context) + entry.context->setContextProperty(QStringLiteral("index"), newIdx); + remapped.insert(newIdx, std::move(entry)); + } + m_delegates = std::move(remapped); + + m_animateDisplacement = true; + polish(); } void LazyListView::onDataChanged(const QModelIndex& topLeft, const QModelIndex& bottomRight, const QList& roles) { @@ -809,6 +874,41 @@ void LazyListView::onDataChanged(const QModelIndex& topLeft, const QModelIndex& } 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(); } diff --git a/plugin/src/Caelestia/Components/lazylistview.hpp b/plugin/src/Caelestia/Components/lazylistview.hpp index fdc0907f..c4770552 100644 --- a/plugin/src/Caelestia/Components/lazylistview.hpp +++ b/plugin/src/Caelestia/Components/lazylistview.hpp @@ -256,6 +256,7 @@ private: QVector m_layout; QHash m_delegates; QVector m_dyingDelegates; + QVector m_delegatePool; int m_activeAnimations = 0; bool m_componentComplete = false; From 0252be370ea7d2c6437995af36521bc5c32f73d9 Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Fri, 3 Apr 2026 02:57:15 +1100 Subject: [PATCH 04/37] fix: delegate size not being tracked after rearrange --- .../src/Caelestia/Components/lazylistview.cpp | 35 ++++++++++--------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/plugin/src/Caelestia/Components/lazylistview.cpp b/plugin/src/Caelestia/Components/lazylistview.cpp index d1e2570c..d4baab49 100644 --- a/plugin/src/Caelestia/Components/lazylistview.cpp +++ b/plugin/src/Caelestia/Components/lazylistview.cpp @@ -574,23 +574,26 @@ LazyListView::DelegateEntry LazyListView::createDelegate(int modelIndex) { entry.item->setWidth(width()); m_delegate->completeCreate(); - // Shared height-change handler - auto onHeightChanged = [this, modelIndex] { - if (!m_delegates.contains(modelIndex)) + // Shared height-change handler — captures item pointer instead of index + // so it remains valid after model inserts/removes/moves shift indices. + auto onHeightChanged = [this, item = entry.item] { + // Find the delegate entry by item pointer + for (auto it = m_delegates.begin(); it != m_delegates.end(); ++it) { + if (it->item != item) + continue; + const int idx = it.key(); + const qreal h = delegateHeight(item); + if (idx < static_cast(m_layout.size()) && !qFuzzyCompare(m_layout[idx].height, h)) { + 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); + polish(); + } return; - auto& e = m_delegates[modelIndex]; - if (!e.item) - return; - const qreal h = delegateHeight(e.item); - if (modelIndex < static_cast(m_layout.size()) && !qFuzzyCompare(m_layout[modelIndex].height, h)) { - const qreal oldH = m_layout[modelIndex].height; - const bool wasKnown = m_layout[modelIndex].heightKnown; - m_layout[modelIndex].height = h; - m_layout[modelIndex].heightKnown = true; - if (wasKnown) - untrackHeight(oldH); - trackHeight(h); - polish(); } }; From 051b8caee6d742cd5602120a9e6b3c13da9b5fd4 Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Fri, 3 Apr 2026 03:51:33 +1100 Subject: [PATCH 05/37] fix: move anim logic to qml Fixes closing in quick succession not updating movement correctly --- modules/sidebar/NotifDockList.qml | 23 ++- .../src/Caelestia/Components/lazylistview.cpp | 162 ++++++++---------- .../src/Caelestia/Components/lazylistview.hpp | 15 +- 3 files changed, 101 insertions(+), 99 deletions(-) diff --git a/modules/sidebar/NotifDockList.qml b/modules/sidebar/NotifDockList.qml index f2dea1c2..8517870b 100644 --- a/modules/sidebar/NotifDockList.qml +++ b/modules/sidebar/NotifDockList.qml @@ -72,9 +72,12 @@ LazyListView { } } - LazyListView.preferredHeight: closed ? 0 : notifInner.implicitHeight + LazyListView.preferredHeight: closed ? 0 : notifInner.nonAnimHeight implicitHeight: notifInner.implicitHeight + opacity: LazyListView.removing || closed || LazyListView.adding ? 0 : 1 + scale: LazyListView.removing || closed ? 0.6 : LazyListView.adding ? 0 : 1 + hoverEnabled: true cursorShape: pressed ? Qt.ClosedHandCursor : undefined acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton @@ -114,6 +117,24 @@ LazyListView { visibilities: root.visibilities } + Behavior on y { + Anim { + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } + } + + Behavior on opacity { + Anim {} + } + + Behavior on scale { + Anim { + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } + } + Behavior on x { Anim { duration: Appearance.anim.durations.expressiveDefaultSpatial diff --git a/plugin/src/Caelestia/Components/lazylistview.cpp b/plugin/src/Caelestia/Components/lazylistview.cpp index d4baab49..c92f6963 100644 --- a/plugin/src/Caelestia/Components/lazylistview.cpp +++ b/plugin/src/Caelestia/Components/lazylistview.cpp @@ -2,6 +2,7 @@ #include #include +#include namespace caelestia::components { @@ -21,12 +22,33 @@ void LazyListViewAttached::setPreferredHeight(qreal height) { emit preferredHeightChanged(); } +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(); +} + // --- LazyListView --- LazyListView::LazyListView(QQuickItem* parent) : QQuickItem(parent) { setFlag(ItemHasContents, false); - setClip(true); } LazyListViewAttached* LazyListView::qmlAttachedProperties(QObject* object) { @@ -345,48 +367,22 @@ void LazyListView::updatePolish() { relayout(); syncDelegates(); - // Animate newly created delegates that were pending add animation - QSet pendingAdds; - m_pendingAddAnimations.swap(pendingAdds); - for (int idx : std::as_const(pendingAdds)) { - if (m_delegates.contains(idx) && m_addDuration > 0) - startAddAnimation(m_delegates[idx]); - } - - // Position delegates, animating displacement if a model change occurred - const bool animate = m_animateDisplacement; - m_animateDisplacement = false; - + // Position delegates — QML Behavior on y handles the animation for (auto& entry : m_delegates) { - if (!entry.item || entry.pendingRemoval || entry.animation) + if (!entry.item || entry.pendingRemoval) continue; const int idx = entry.modelIndex; if (idx < 0 || idx >= static_cast(m_layout.size())) continue; - const qreal targetY = m_layout[idx].targetY - m_contentY; - const qreal currentY = entry.item->y(); + if (m_layout[idx].heightKnown && qFuzzyIsNull(m_layout[idx].height)) + continue; - if (animate && !qFuzzyCompare(currentY, targetY) && m_moveDuration > 0 - && !pendingAdds.contains(idx)) { - startMoveAnimation(entry, currentY); - } else if (!entry.animation) { - entry.item->setY(targetY); - } + // 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); } - - // Flush delegate pool — any delegates not reclaimed are truly removed - for (auto& pooled : m_delegatePool) { - pooled.pendingRemoval = true; - if (m_removeDuration > 0 && pooled.item) { - startRemoveAnimation(pooled); - m_dyingDelegates.append(std::move(pooled)); - } else { - destroyDelegate(pooled); - } - } - m_delegatePool.clear(); } // --- Layout Engine --- @@ -491,6 +487,8 @@ void LazyListView::syncDelegates() { m_layout[i].heightKnown = true; trackHeight(h); } + // Position immediately so it doesn't flash at y=0 + entry.item->setY(m_layout[i].targetY - m_contentY); m_delegates.insert(i, std::move(entry)); } } @@ -504,26 +502,8 @@ LazyListView::DelegateEntry LazyListView::createDelegate(int modelIndex) { if (!m_delegate || !m_model) return entry; - // Try to reclaim a delegate from the pool (reuse after remove+insert cycle) const auto roleNames = m_model->roleNames(); const auto role = roleNames.isEmpty() ? Qt::DisplayRole : roleNames.constBegin().key(); - const auto targetData = m_model->data(m_model->index(modelIndex, 0), role); - - for (auto it = m_delegatePool.begin(); it != m_delegatePool.end(); ++it) { - if (!it->item) - continue; - const auto poolData = it->item->property("modelData"); - if (poolData == targetData) { - entry = std::move(*it); - m_delegatePool.erase(it); - entry.modelIndex = modelIndex; - entry.pendingRemoval = false; - updateDelegateData(entry); - entry.item->setParentItem(this); - entry.item->setWidth(width()); - return entry; - } - } // Use the delegate component's creation context so the delegate // can access ids and properties from the scope where it was defined. @@ -572,8 +552,19 @@ LazyListView::DelegateEntry LazyListView::createDelegate(int modelIndex) { entry.item->setParentItem(this); entry.item->setWidth(width()); + + // Set adding = true before completeCreate so bindings see it during initial evaluation. + // Cleared after creation so the transition from true→false triggers QML Behaviors. + auto* addingAttached = qobject_cast( + qmlAttachedPropertiesObject(entry.item, true)); + if (addingAttached) + addingAttached->setAdding(true); + m_delegate->completeCreate(); + if (addingAttached) + addingAttached->setAdding(false); + // Shared height-change handler — captures item pointer instead of index // so it remains valid after model inserts/removes/moves shift indices. auto onHeightChanged = [this, item = entry.item] { @@ -713,10 +704,6 @@ void LazyListView::resetContent() { destroyDelegate(entry); m_dyingDelegates.clear(); - for (auto& entry : m_delegatePool) - destroyDelegate(entry); - m_delegatePool.clear(); - if (m_activeAnimations != 0) { m_activeAnimations = 0; emit settledChanged(); @@ -726,7 +713,6 @@ void LazyListView::resetContent() { m_knownHeightSum = 0; m_knownHeightCount = 0; m_pendingAddAnimations.clear(); - m_animateDisplacement = false; // Rebuild layout from model m_layout.clear(); @@ -766,7 +752,6 @@ void LazyListView::onRowsInserted(const QModelIndex& parent, int first, int last // Queue add animations and mark displacement for (int i = first; i <= last; ++i) m_pendingAddAnimations.insert(i); - m_animateDisplacement = true; emit countChanged(); polish(); @@ -776,14 +761,36 @@ void LazyListView::onRowsAboutToBeRemoved(const QModelIndex& parent, int first, if (parent.isValid()) return; - // Pool removed delegates — they may be reused if the model re-inserts the same data for (int i = first; i <= last; ++i) { if (!m_delegates.contains(i)) continue; auto entry = m_delegates.take(i); + entry.pendingRemoval = true; stopAnimation(entry); - m_delegatePool.append(std::move(entry)); + + if (m_removeDuration > 0 && entry.item) { + // Signal the delegate via attached property — QML handles the visual transition + 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); + } } } @@ -814,7 +821,6 @@ void LazyListView::onRowsRemoved(const QModelIndex& parent, int first, int last) } m_delegates = std::move(shifted); - m_animateDisplacement = true; emit countChanged(); polish(); @@ -860,7 +866,6 @@ void LazyListView::onRowsMoved(const QModelIndex& parent, int start, int end, } m_delegates = std::move(remapped); - m_animateDisplacement = true; polish(); } @@ -999,39 +1004,6 @@ void LazyListView::startRemoveAnimation(DelegateEntry& entry) { group->start(QAbstractAnimation::DeleteWhenStopped); } -void LazyListView::startMoveAnimation(DelegateEntry& entry, qreal fromY) { - if (!entry.item || m_moveDuration <= 0) - return; - - const int idx = entry.modelIndex; - if (idx < 0 || idx >= static_cast(m_layout.size())) - return; - - const qreal toY = m_layout[idx].targetY - m_contentY; - if (qFuzzyCompare(fromY, toY)) - return; - - stopAnimation(entry); - - auto* group = new QParallelAnimationGroup(this); - - auto* yAnim = new QPropertyAnimation(entry.item, "y"); - yAnim->setDuration(m_moveDuration); - yAnim->setEasingCurve(m_moveCurve); - yAnim->setStartValue(fromY); - yAnim->setEndValue(toY); - group->addAnimation(yAnim); - - entry.item->setY(fromY); - entry.animation = group; - ++m_activeAnimations; - if (m_activeAnimations == 1) - emit settledChanged(); - - connect(group, &QAbstractAnimation::finished, this, &LazyListView::onAnimationFinished); - group->start(QAbstractAnimation::DeleteWhenStopped); -} - void LazyListView::stopAnimation(DelegateEntry& entry) { if (!entry.animation) return; diff --git a/plugin/src/Caelestia/Components/lazylistview.hpp b/plugin/src/Caelestia/Components/lazylistview.hpp index c4770552..b934a2f4 100644 --- a/plugin/src/Caelestia/Components/lazylistview.hpp +++ b/plugin/src/Caelestia/Components/lazylistview.hpp @@ -18,6 +18,8 @@ class LazyListViewAttached : public QObject { Q_OBJECT Q_PROPERTY(qreal preferredHeight READ preferredHeight WRITE setPreferredHeight NOTIFY preferredHeightChanged) + Q_PROPERTY(bool adding READ adding NOTIFY addingChanged) + Q_PROPERTY(bool removing READ removing NOTIFY removingChanged) public: explicit LazyListViewAttached(QObject* parent = nullptr); @@ -25,11 +27,21 @@ public: [[nodiscard]] qreal preferredHeight() const; void setPreferredHeight(qreal height); + [[nodiscard]] bool adding() const; + void setAdding(bool adding); + + [[nodiscard]] bool removing() const; + void setRemoving(bool removing); + signals: void preferredHeightChanged(); + void addingChanged(); + void removingChanged(); private: qreal m_preferredHeight = -1; + bool m_adding = false; + bool m_removing = false; }; class LazyListView : public QQuickItem { @@ -220,7 +232,6 @@ private: // Animation void startAddAnimation(DelegateEntry& entry); void startRemoveAnimation(DelegateEntry& entry); - void startMoveAnimation(DelegateEntry& entry, qreal fromY); void stopAnimation(DelegateEntry& entry); void onAnimationFinished(); @@ -256,11 +267,9 @@ private: QVector m_layout; QHash m_delegates; QVector m_dyingDelegates; - QVector m_delegatePool; int m_activeAnimations = 0; bool m_componentComplete = false; - bool m_animateDisplacement = false; QSet m_pendingAddAnimations; QList m_modelConnections; From 916b70256293500e7f9c761b28860a8df6172946 Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Fri, 3 Apr 2026 16:04:51 +1100 Subject: [PATCH 06/37] fix: use visible height to calc contentHeight --- modules/sidebar/NotifDockList.qml | 1 + .../src/Caelestia/Components/lazylistview.cpp | 55 +++++++++++++++++-- .../src/Caelestia/Components/lazylistview.hpp | 7 +++ 3 files changed, 59 insertions(+), 4 deletions(-) diff --git a/modules/sidebar/NotifDockList.qml b/modules/sidebar/NotifDockList.qml index 8517870b..98ad0d4d 100644 --- a/modules/sidebar/NotifDockList.qml +++ b/modules/sidebar/NotifDockList.qml @@ -73,6 +73,7 @@ LazyListView { } LazyListView.preferredHeight: closed ? 0 : notifInner.nonAnimHeight + LazyListView.visibleHeight: notifInner.implicitHeight implicitHeight: notifInner.implicitHeight opacity: LazyListView.removing || closed || LazyListView.adding ? 0 : 1 diff --git a/plugin/src/Caelestia/Components/lazylistview.cpp b/plugin/src/Caelestia/Components/lazylistview.cpp index c92f6963..84073315 100644 --- a/plugin/src/Caelestia/Components/lazylistview.cpp +++ b/plugin/src/Caelestia/Components/lazylistview.cpp @@ -22,6 +22,17 @@ void LazyListViewAttached::setPreferredHeight(qreal height) { emit preferredHeightChanged(); } +qreal LazyListViewAttached::visibleHeight() const { + return m_visibleHeight; +} + +void LazyListViewAttached::setVisibleHeight(qreal height) { + if (qFuzzyCompare(m_visibleHeight, height)) + return; + m_visibleHeight = height; + emit visibleHeightChanged(); +} + bool LazyListViewAttached::adding() const { return m_adding; } @@ -210,6 +221,22 @@ qreal LazyListView::delegateHeight(QQuickItem* item) { 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(); +} + // --- Add Animation --- int LazyListView::addDuration() const { @@ -388,15 +415,33 @@ void LazyListView::updatePolish() { // --- Layout Engine --- void LazyListView::relayout() { + // Layout positioning uses preferredHeight (final/non-animated) qreal y = 0; for (auto& record : m_layout) { record.targetY = y; y += (record.heightKnown ? record.height : effectiveEstimatedHeight()) + m_spacing; } - const qreal newHeight = m_layout.isEmpty() ? 0 : y - m_spacing; - if (!qFuzzyCompare(m_contentHeight, newHeight)) { - m_contentHeight = newHeight; + // Content height tracks actual visible heights so scrolling follows animations + qreal visY = 0; + for (int i = 0; i < static_cast(m_layout.size()); ++i) { + qreal h; + if (m_delegates.contains(i) && m_delegates[i].item) + h = delegateVisibleHeight(m_delegates[i].item); + else + h = m_layout[i].heightKnown ? m_layout[i].height : effectiveEstimatedHeight(); + visY += h + m_spacing; + } + qreal maxBottom = m_layout.isEmpty() ? 0 : visY - m_spacing; + + // Account for dying delegates still visually present + for (const auto& dying : m_dyingDelegates) { + if (dying.item) + maxBottom = std::max(maxBottom, dying.item->y() + delegateVisibleHeight(dying.item)); + } + + if (!qFuzzyCompare(m_contentHeight, maxBottom)) { + m_contentHeight = maxBottom; emit contentHeightChanged(); } } @@ -591,12 +636,14 @@ LazyListView::DelegateEntry LazyListView::createDelegate(int modelIndex) { // Watch implicitHeight as fallback connect(entry.item, &QQuickItem::implicitHeightChanged, this, onHeightChanged); - // Watch attached preferredHeight if the delegate uses it + // Watch attached properties if the delegate uses them auto* attached = qobject_cast( qmlAttachedPropertiesObject(entry.item, false)); if (attached) { entry.attachedConnection = connect(attached, &LazyListViewAttached::preferredHeightChanged, this, onHeightChanged); + connect(attached, &LazyListViewAttached::visibleHeightChanged, + this, [this] { polish(); }); } return entry; diff --git a/plugin/src/Caelestia/Components/lazylistview.hpp b/plugin/src/Caelestia/Components/lazylistview.hpp index b934a2f4..d2cc4fe6 100644 --- a/plugin/src/Caelestia/Components/lazylistview.hpp +++ b/plugin/src/Caelestia/Components/lazylistview.hpp @@ -18,6 +18,7 @@ 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 adding READ adding NOTIFY addingChanged) Q_PROPERTY(bool removing READ removing NOTIFY removingChanged) @@ -27,6 +28,9 @@ public: [[nodiscard]] qreal preferredHeight() const; void setPreferredHeight(qreal height); + [[nodiscard]] qreal visibleHeight() const; + void setVisibleHeight(qreal height); + [[nodiscard]] bool adding() const; void setAdding(bool adding); @@ -35,11 +39,13 @@ public: signals: void preferredHeightChanged(); + void visibleHeightChanged(); void addingChanged(); void removingChanged(); private: qreal m_preferredHeight = -1; + qreal m_visibleHeight = -1; bool m_adding = false; bool m_removing = false; }; @@ -209,6 +215,7 @@ private: [[nodiscard]] QRectF effectiveViewport() const; [[nodiscard]] qreal effectiveEstimatedHeight() const; [[nodiscard]] static qreal delegateHeight(QQuickItem* item); + [[nodiscard]] static qreal delegateVisibleHeight(QQuickItem* item); void trackHeight(qreal height); void untrackHeight(qreal height); From faaae7be44b6cc348397ea3acba695d264b51ba4 Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Fri, 3 Apr 2026 16:09:00 +1100 Subject: [PATCH 07/37] chore: format --- .../src/Caelestia/Components/lazylistview.cpp | 46 +++++++++---------- 1 file changed, 21 insertions(+), 25 deletions(-) diff --git a/plugin/src/Caelestia/Components/lazylistview.cpp b/plugin/src/Caelestia/Components/lazylistview.cpp index 84073315..47db7556 100644 --- a/plugin/src/Caelestia/Components/lazylistview.cpp +++ b/plugin/src/Caelestia/Components/lazylistview.cpp @@ -213,8 +213,7 @@ qreal LazyListView::delegateHeight(QQuickItem* item) { if (!item) return 0; - auto* attached = qobject_cast( - qmlAttachedPropertiesObject(item, false)); + auto* attached = qobject_cast(qmlAttachedPropertiesObject(item, false)); if (attached && attached->preferredHeight() >= 0) return attached->preferredHeight(); @@ -225,8 +224,7 @@ qreal LazyListView::delegateVisibleHeight(QQuickItem* item) { if (!item) return 0; - auto* attached = qobject_cast( - qmlAttachedPropertiesObject(item, false)); + auto* attached = qobject_cast(qmlAttachedPropertiesObject(item, false)); if (attached) { if (attached->visibleHeight() >= 0) return attached->visibleHeight(); @@ -435,7 +433,7 @@ void LazyListView::relayout() { qreal maxBottom = m_layout.isEmpty() ? 0 : visY - m_spacing; // Account for dying delegates still visually present - for (const auto& dying : m_dyingDelegates) { + for (const auto& dying : std::as_const(m_dyingDelegates)) { if (dying.item) maxBottom = std::max(maxBottom, dying.item->y() + delegateVisibleHeight(dying.item)); } @@ -600,8 +598,8 @@ LazyListView::DelegateEntry LazyListView::createDelegate(int modelIndex) { // Set adding = true before completeCreate so bindings see it during initial evaluation. // Cleared after creation so the transition from true→false triggers QML Behaviors. - auto* addingAttached = qobject_cast( - qmlAttachedPropertiesObject(entry.item, true)); + auto* addingAttached = + qobject_cast(qmlAttachedPropertiesObject(entry.item, true)); if (addingAttached) addingAttached->setAdding(true); @@ -637,13 +635,13 @@ LazyListView::DelegateEntry LazyListView::createDelegate(int modelIndex) { connect(entry.item, &QQuickItem::implicitHeightChanged, this, onHeightChanged); // Watch attached properties if the delegate uses them - auto* attached = qobject_cast( - qmlAttachedPropertiesObject(entry.item, false)); + auto* attached = qobject_cast(qmlAttachedPropertiesObject(entry.item, false)); if (attached) { - entry.attachedConnection = connect(attached, &LazyListViewAttached::preferredHeightChanged, - this, onHeightChanged); - connect(attached, &LazyListViewAttached::visibleHeightChanged, - this, [this] { polish(); }); + entry.attachedConnection = + connect(attached, &LazyListViewAttached::preferredHeightChanged, this, onHeightChanged); + connect(attached, &LazyListViewAttached::visibleHeightChanged, this, [this] { + polish(); + }); } return entry; @@ -652,8 +650,7 @@ LazyListView::DelegateEntry LazyListView::createDelegate(int modelIndex) { void LazyListView::destroyDelegate(DelegateEntry& entry) { if (entry.animation) { // Disconnect before stopping to prevent re-entrant onAnimationFinished - disconnect(entry.animation, &QAbstractAnimation::finished, - this, &LazyListView::onAnimationFinished); + disconnect(entry.animation, &QAbstractAnimation::finished, this, &LazyListView::onAnimationFinished); entry.animation->stop(); entry.animation = nullptr; --m_activeAnimations; @@ -721,11 +718,12 @@ void LazyListView::connectModel() { 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, &QAbstractItemModel::layoutChanged, this, + [this] { + for (auto& entry : m_delegates) + updateDelegateData(entry); + polish(); + }), connect(m_model, &QObject::destroyed, this, [this] { m_model = nullptr; @@ -818,8 +816,8 @@ void LazyListView::onRowsAboutToBeRemoved(const QModelIndex& parent, int first, if (m_removeDuration > 0 && entry.item) { // Signal the delegate via attached property — QML handles the visual transition - auto* attached = qobject_cast( - qmlAttachedPropertiesObject(entry.item, false)); + auto* attached = + qobject_cast(qmlAttachedPropertiesObject(entry.item, false)); if (attached) attached->setRemoving(true); @@ -868,13 +866,11 @@ void LazyListView::onRowsRemoved(const QModelIndex& parent, int first, int last) } m_delegates = std::move(shifted); - emit countChanged(); polish(); } -void LazyListView::onRowsMoved(const QModelIndex& parent, int start, int end, - const QModelIndex& destination, int row) { +void LazyListView::onRowsMoved(const QModelIndex& parent, int start, int end, const QModelIndex& destination, int row) { if (parent.isValid() || destination.isValid()) return; From cf8b68be3b308a44bea3374c45cce046489ada0d Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Fri, 3 Apr 2026 17:03:18 +1100 Subject: [PATCH 08/37] fix: add null guards to notifs --- modules/sidebar/Notif.qml | 18 +++++++++--------- modules/sidebar/NotifActionList.qml | 2 +- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/modules/sidebar/Notif.qml b/modules/sidebar/Notif.qml index fe56798f..88bd0305 100644 --- a/modules/sidebar/Notif.qml +++ b/modules/sidebar/Notif.qml @@ -22,7 +22,7 @@ StyledRect { radius: Appearance.rounding.small color: { - const c = root.modelData.urgency === "critical" ? Colours.palette.m3secondaryContainer : Colours.layer(Colours.palette.m3surfaceContainerHigh, 2); + const c = root.modelData?.urgency === "critical" ? Colours.palette.m3secondaryContainer : Colours.layer(Colours.palette.m3surfaceContainerHigh, 2); return expanded ? c : Qt.alpha(c, 0); } @@ -61,8 +61,8 @@ StyledRect { anchors.left: parent.left width: parent.width - text: root.modelData.summary - color: root.modelData.urgency === "critical" ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurface + text: root.modelData?.summary ?? "" + color: root.modelData?.urgency === "critical" ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurface elide: Text.ElideRight wrapMode: Text.WordWrap maximumLineCount: 1 @@ -75,7 +75,7 @@ StyledRect { anchors.left: parent.left visible: false - text: root.modelData.summary + text: root.modelData?.summary ?? "" } WrappedLoader { @@ -88,8 +88,8 @@ StyledRect { anchors.leftMargin: Appearance.spacing.small sourceComponent: StyledText { - text: root.modelData.body.replace(/\n/g, " ") - color: root.modelData.urgency === "critical" ? Colours.palette.m3secondary : Colours.palette.m3outline + text: (root.modelData?.body ?? "").replace(/\n/g, " ") + color: root.modelData?.urgency === "critical" ? Colours.palette.m3secondary : Colours.palette.m3outline elide: Text.ElideRight } } @@ -103,7 +103,7 @@ StyledRect { sourceComponent: StyledText { animate: true - text: root.modelData.timeStr + text: root.modelData?.timeStr ?? "" color: Colours.palette.m3outline font.pointSize: Appearance.font.size.small } @@ -138,8 +138,8 @@ StyledRect { Layout.fillWidth: true textFormat: Text.MarkdownText - text: root.modelData.body.replace(/(.)\n(?!\n)/g, "$1\n\n") || qsTr("No body here! :/") - color: root.modelData.urgency === "critical" ? Colours.palette.m3secondary : Colours.palette.m3outline + text: (root.modelData?.body ?? "").replace(/(.)\n(?!\n)/g, "$1\n\n") || qsTr("No body here! :/") + color: root.modelData?.urgency === "critical" ? Colours.palette.m3secondary : Colours.palette.m3outline wrapMode: Text.WordWrap onLinkActivated: link => { diff --git a/modules/sidebar/NotifActionList.qml b/modules/sidebar/NotifActionList.qml index 370a79cb..dfc83bd2 100644 --- a/modules/sidebar/NotifActionList.qml +++ b/modules/sidebar/NotifActionList.qml @@ -101,7 +101,7 @@ Item { { isClose: true }, - ...root.notif.actions, + ...(root.notif?.actions ?? []), { isCopy: true } From 20983ffa6e128fedabbce104630172f2af12ff46 Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Fri, 3 Apr 2026 17:59:27 +1100 Subject: [PATCH 09/37] feat: replace notif group list with lazy list view --- modules/sidebar/NotifGroupList.qml | 254 +++++++----------- .../src/Caelestia/Components/lazylistview.cpp | 51 +++- .../src/Caelestia/Components/lazylistview.hpp | 4 + 3 files changed, 146 insertions(+), 163 deletions(-) diff --git a/modules/sidebar/NotifGroupList.qml b/modules/sidebar/NotifGroupList.qml index fe7510b5..e33b3eea 100644 --- a/modules/sidebar/NotifGroupList.qml +++ b/modules/sidebar/NotifGroupList.qml @@ -1,13 +1,12 @@ -pragma ComponentBehavior: Bound - import QtQuick import QtQuick.Layouts import Quickshell +import Caelestia.Components import qs.components import qs.services import qs.config -Item { +LazyListView { id: root required property Props props @@ -16,19 +15,8 @@ Item { required property Flickable container required property DrawerVisibilities visibilities - readonly property real nonAnimHeight: { - let h = -root.spacing; - for (let i = 0; i < repeater.count; i++) { - const item = repeater.itemAt(i) as NotifDelegate; - if (item && !item.modelData.closed && !item.previewHidden) - h += item.nonAnimHeight + root.spacing; - } - return h; - } - - readonly property int spacing: Math.round(Appearance.spacing.small / 2) + readonly property real nonAnimHeight: layoutHeight property bool showAllNotifs - property bool flag signal requestToggleExpand(expand: bool) @@ -42,7 +30,15 @@ Item { } Layout.fillWidth: true - implicitHeight: nonAnimHeight + implicitHeight: contentHeight + + spacing: Math.round(Appearance.spacing.small / 2) + + removeDuration: Appearance.anim.durations.normal + + useCustomViewport: true + viewport: Qt.rect(0, container.contentY - mapToItem(container.contentItem, 0, 0).y, + width, container.height) Timer { id: clearTimer @@ -51,164 +47,116 @@ Item { 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: NotifDelegate {} + model: ScriptModel { + values: root.showAllNotifs ? root.notifs : root.notifs.slice(0, Config.notifs.groupPreviewNum + 1) } - Behavior on implicitHeight { - Anim { - duration: Appearance.anim.durations.expressiveDefaultSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial - } - } + delegate: Component { + MouseArea { + id: notif - component NotifDelegate: MouseArea { - id: notif + required property int index + required property NotifData modelData - required property int index - required property NotifData 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; - } - property int startY - - y: { - root.flag; // Force update - let y = 0; - for (let i = 0; i < index; i++) { - const item = repeater.itemAt(i) as NotifDelegate; - if (item && !item.modelData.closed && !item.previewHidden) - y += item.nonAnimHeight + root.spacing; - } - return y; - } - - containmentMask: QtObject { - function contains(p: point): bool { - if (!root.container.contains(notif.mapToItem(root.container, p))) + readonly property bool previewHidden: { + if (root.expanded) return false; - return notifInner.contains(p); + + let extraHidden = 0; + for (let i = 0; i < index; i++) + if (root.notifs[i]?.closed) + extraHidden++; + + return index >= Config.notifs.groupPreviewNum + extraHidden; } - } + property int startY - opacity: previewHidden ? 0 : 1 - scale: previewHidden ? 0.7 : 1 + Component.onCompleted: modelData?.lock(this) + Component.onDestruction: modelData?.unlock(this) - implicitWidth: root.width - implicitHeight: notifInner.implicitHeight + LazyListView.preferredHeight: modelData?.closed || previewHidden ? 0 : notifInner.nonAnimHeight + LazyListView.visibleHeight: modelData?.closed || previewHidden ? 0 : notifInner.implicitHeight + implicitHeight: notifInner.implicitHeight - hoverEnabled: true - cursorShape: notifInner.body?.hoveredLink ? Qt.PointingHandCursor : pressed ? Qt.ClosedHandCursor : undefined - acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton - preventStealing: !root.expanded - enabled: !modelData.closed + opacity: LazyListView.removing || modelData?.closed || previewHidden || LazyListView.adding ? 0 : 1 + scale: LazyListView.removing || previewHidden ? 0.7 : LazyListView.adding ? 0.7 : 1 - drag.target: this - drag.axis: Drag.XAxis + hoverEnabled: true + cursorShape: notifInner.body?.hoveredLink ? Qt.PointingHandCursor : pressed ? Qt.ClosedHandCursor : undefined + acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton + preventStealing: !root.expanded + enabled: !(modelData?.closed ?? true) - onPressed: event => { - startY = event.y; - if (event.button === Qt.RightButton) - root.requestToggleExpand(!root.expanded); - else if (event.button === Qt.MiddleButton) - modelData.close(); - } - onPositionChanged: event => { - if (pressed && !root.expanded) { - const diffY = event.y - startY; - if (Math.abs(diffY) > Config.notifs.expandThreshold) - root.requestToggleExpand(diffY > 0); + drag.target: this + drag.axis: Drag.XAxis + + onPressed: event => { + startY = event.y; + if (event.button === Qt.RightButton) + root.requestToggleExpand(!root.expanded); + else if (event.button === Qt.MiddleButton) + modelData?.close(); } - } - onReleased: event => { - if (Math.abs(x) < width * Config.notifs.clearThreshold) - x = 0; - else - modelData.close(); - } - - Component.onCompleted: modelData.lock(this) - Component.onDestruction: modelData.unlock(this) - - ParallelAnimation { - Component.onCompleted: running = !notif.previewHidden - - Anim { - target: notif - property: "opacity" - from: 0 - to: 1 + onPositionChanged: event => { + if (pressed && !root.expanded) { + const diffY = event.y - startY; + if (Math.abs(diffY) > Config.notifs.expandThreshold) + root.requestToggleExpand(diffY > 0); + } } - Anim { - target: notif - property: "scale" - from: 0.7 - to: 1 + onReleased: event => { + if (Math.abs(x) < width * Config.notifs.clearThreshold) + x = 0; + else + modelData?.close(); } - } - ParallelAnimation { - running: notif.modelData.closed - onFinished: notif.modelData.unlock(notif) + ParallelAnimation { + running: notif.modelData?.closed ?? false + onFinished: notif.modelData?.unlock(notif) - Anim { - target: notif - property: "opacity" - to: 0 + Anim { + target: notif + property: "opacity" + to: 0 + } + Anim { + target: notif + property: "x" + to: notif.x >= 0 ? notif.width : -notif.width + } } - Anim { - target: notif - property: "x" - to: notif.x >= 0 ? notif.width : -notif.width + + Notif { + id: notifInner + + anchors.fill: parent + modelData: notif.modelData + props: root.props + expanded: root.expanded + visibilities: root.visibilities } - } - Notif { - id: notifInner - - anchors.fill: parent - modelData: notif.modelData - props: root.props - expanded: root.expanded - visibilities: root.visibilities - } - - Behavior on opacity { - Anim {} - } - - Behavior on scale { - Anim {} - } - - Behavior on x { - Anim { - duration: Appearance.anim.durations.expressiveDefaultSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + Behavior on y { + Anim { + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } } - } - Behavior on y { - Anim { - duration: Appearance.anim.durations.expressiveDefaultSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + Behavior on opacity { + Anim {} + } + + Behavior on scale { + Anim {} + } + + Behavior on x { + Anim { + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } } } } diff --git a/plugin/src/Caelestia/Components/lazylistview.cpp b/plugin/src/Caelestia/Components/lazylistview.cpp index 47db7556..88703a7f 100644 --- a/plugin/src/Caelestia/Components/lazylistview.cpp +++ b/plugin/src/Caelestia/Components/lazylistview.cpp @@ -126,6 +126,10 @@ qreal LazyListView::contentHeight() const { return m_contentHeight; } +qreal LazyListView::layoutHeight() const { + return m_layoutHeight; +} + qreal LazyListView::contentY() const { return m_contentY; } @@ -413,33 +417,55 @@ void LazyListView::updatePolish() { // --- Layout Engine --- void LazyListView::relayout() { - // Layout positioning uses preferredHeight (final/non-animated) + // 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) { record.targetY = y; - y += (record.heightKnown ? record.height : effectiveEstimatedHeight()) + m_spacing; + const qreal layoutH = record.heightKnown ? record.height : effectiveEstimatedHeight(); + if (layoutH > 0) { + if (hasLayoutItem) + y += m_spacing; + hasLayoutItem = true; + y += layoutH; + } } - // Content height tracks actual visible heights so scrolling follows animations + if (!qFuzzyCompare(m_layoutHeight, y)) { + 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; if (m_delegates.contains(i) && m_delegates[i].item) h = delegateVisibleHeight(m_delegates[i].item); else h = m_layout[i].heightKnown ? m_layout[i].height : effectiveEstimatedHeight(); - visY += h + m_spacing; + if (h > 0) { + if (hasVisItem) + visY += m_spacing; + hasVisItem = true; + visY += h; + } } - qreal maxBottom = m_layout.isEmpty() ? 0 : visY - m_spacing; // Account for dying delegates still visually present for (const auto& dying : std::as_const(m_dyingDelegates)) { - if (dying.item) - maxBottom = std::max(maxBottom, dying.item->y() + delegateVisibleHeight(dying.item)); + 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, maxBottom)) { - m_contentHeight = maxBottom; + if (!qFuzzyCompare(m_contentHeight, visY)) { + m_contentHeight = visY; emit contentHeightChanged(); } } @@ -525,7 +551,9 @@ void LazyListView::syncDelegates() { if (entry.item) { // Measure height (prefer attached preferredHeight, fall back to implicitHeight) const qreal h = delegateHeight(entry.item); - if (h > 0 && !m_layout[i].heightKnown) { + if (!m_layout[i].heightKnown || !qFuzzyCompare(m_layout[i].height, h)) { + if (m_layout[i].heightKnown) + untrackHeight(m_layout[i].height); m_layout[i].height = h; m_layout[i].heightKnown = true; trackHeight(h); @@ -625,6 +653,9 @@ LazyListView::DelegateEntry LazyListView::createDelegate(int modelIndex) { if (wasKnown) untrackHeight(oldH); trackHeight(h); + // Relayout immediately so layoutHeight/contentHeight update + // synchronously for parent bindings, then polish for delegate sync. + relayout(); polish(); } return; diff --git a/plugin/src/Caelestia/Components/lazylistview.hpp b/plugin/src/Caelestia/Components/lazylistview.hpp index d2cc4fe6..f3f92208 100644 --- a/plugin/src/Caelestia/Components/lazylistview.hpp +++ b/plugin/src/Caelestia/Components/lazylistview.hpp @@ -62,6 +62,7 @@ class LazyListView : public QQuickItem { // 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 @@ -110,6 +111,7 @@ public: void setSpacing(qreal spacing); [[nodiscard]] qreal contentHeight() const; + [[nodiscard]] qreal layoutHeight() const; [[nodiscard]] qreal contentY() const; void setContentY(qreal contentY); @@ -170,6 +172,7 @@ signals: void delegateChanged(); void spacingChanged(); void contentHeightChanged(); + void layoutHeightChanged(); void contentYChanged(); void viewportChanged(); void useCustomViewportChanged(); @@ -248,6 +251,7 @@ private: qreal m_spacing = 0; qreal m_contentHeight = 0; + qreal m_layoutHeight = 0; qreal m_contentY = 0; QRectF m_viewport; From ea776c20e09b9189eefc2b816be9e50cc69c3aa0 Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Fri, 3 Apr 2026 18:14:49 +1100 Subject: [PATCH 10/37] fix: lazy list view spacing + debounce relayout --- .../src/Caelestia/Components/lazylistview.cpp | 18 +++++++++++++----- .../src/Caelestia/Components/lazylistview.hpp | 1 + 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/plugin/src/Caelestia/Components/lazylistview.cpp b/plugin/src/Caelestia/Components/lazylistview.cpp index 88703a7f..b8c37976 100644 --- a/plugin/src/Caelestia/Components/lazylistview.cpp +++ b/plugin/src/Caelestia/Components/lazylistview.cpp @@ -422,13 +422,15 @@ void LazyListView::relayout() { qreal y = 0; bool hasLayoutItem = false; for (auto& record : m_layout) { - record.targetY = y; 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; } } @@ -653,10 +655,16 @@ LazyListView::DelegateEntry LazyListView::createDelegate(int modelIndex) { if (wasKnown) untrackHeight(oldH); trackHeight(h); - // Relayout immediately so layoutHeight/contentHeight update - // synchronously for parent bindings, then polish for delegate sync. - relayout(); - polish(); + // Batch relayout: multiple height changes in the same event loop + // iteration are coalesced into a single relayout + polish. + if (!m_relayoutPending) { + m_relayoutPending = true; + QTimer::singleShot(0, this, [this] { + m_relayoutPending = false; + relayout(); + polish(); + }); + } } return; } diff --git a/plugin/src/Caelestia/Components/lazylistview.hpp b/plugin/src/Caelestia/Components/lazylistview.hpp index f3f92208..7147169c 100644 --- a/plugin/src/Caelestia/Components/lazylistview.hpp +++ b/plugin/src/Caelestia/Components/lazylistview.hpp @@ -281,6 +281,7 @@ private: int m_activeAnimations = 0; bool m_componentComplete = false; + bool m_relayoutPending = false; QSet m_pendingAddAnimations; QList m_modelConnections; From 700ad61969ac9ae9339b4335f0b119ad552bb103 Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Fri, 3 Apr 2026 18:33:30 +1100 Subject: [PATCH 11/37] feat: listview async delegate creation and destruction --- modules/sidebar/NotifGroupList.qml | 1 + .../src/Caelestia/Components/lazylistview.cpp | 97 ++++++++++++++----- .../src/Caelestia/Components/lazylistview.hpp | 9 ++ 3 files changed, 81 insertions(+), 26 deletions(-) diff --git a/modules/sidebar/NotifGroupList.qml b/modules/sidebar/NotifGroupList.qml index e33b3eea..fab8c9b0 100644 --- a/modules/sidebar/NotifGroupList.qml +++ b/modules/sidebar/NotifGroupList.qml @@ -33,6 +33,7 @@ LazyListView { implicitHeight: contentHeight spacing: Math.round(Appearance.spacing.small / 2) + asynchronous: true removeDuration: Appearance.anim.durations.normal diff --git a/plugin/src/Caelestia/Components/lazylistview.cpp b/plugin/src/Caelestia/Components/lazylistview.cpp index b8c37976..07c4d31e 100644 --- a/plugin/src/Caelestia/Components/lazylistview.cpp +++ b/plugin/src/Caelestia/Components/lazylistview.cpp @@ -4,6 +4,13 @@ #include #include +namespace { + +constexpr int ASYNC_BATCH_CREATE = 2; +constexpr int ASYNC_BATCH_DESTROY = 4; + +} // namespace + namespace caelestia::components { // --- LazyListViewAttached --- @@ -195,6 +202,17 @@ void LazyListView::setEstimatedHeight(qreal height) { 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; @@ -531,41 +549,68 @@ void LazyListView::syncDelegates() { visibleIndices.insert(i); } - // Destroy delegates outside visible range (if not animating) + // Collect delegates to destroy (outside visible range and not animating) QList toRemove; for (auto it = m_delegates.begin(); it != m_delegates.end(); ++it) { - if (!visibleIndices.contains(it.key()) && !it->animation) { + if (!visibleIndices.contains(it.key()) && !it->animation) toRemove.append(it.key()); - } - } - for (int idx : toRemove) { - auto entry = m_delegates.take(idx); - destroyDelegate(entry); } - // Create delegates for newly visible indices + // 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; + removedEntries.append(m_delegates.take(idx)); + ++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)) - continue; - - auto entry = createDelegate(i); - if (entry.item) { - // Measure height (prefer attached preferredHeight, fall back to implicitHeight) - const qreal h = delegateHeight(entry.item); - if (!m_layout[i].heightKnown || !qFuzzyCompare(m_layout[i].height, h)) { - if (m_layout[i].heightKnown) - untrackHeight(m_layout[i].height); - m_layout[i].height = h; - m_layout[i].heightKnown = true; - trackHeight(h); - } - // Position immediately so it doesn't flash at y=0 - entry.item->setY(m_layout[i].targetY - m_contentY); - m_delegates.insert(i, std::move(entry)); - } + 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; + bool layoutChanged = false; + for (int i : toCreate) { + if (created >= createBudget) + break; + + auto entry = createDelegate(i); + if (entry.item) { + const qreal h = delegateHeight(entry.item); + if (!m_layout[i].heightKnown || !qFuzzyCompare(m_layout[i].height, h)) { + if (m_layout[i].heightKnown) + untrackHeight(m_layout[i].height); + m_layout[i].height = h; + m_layout[i].heightKnown = true; + trackHeight(h); + layoutChanged = true; + } + entry.item->setY(m_layout[i].targetY - m_contentY); + m_delegates.insert(i, std::move(entry)); + ++created; + } + } + + if (layoutChanged) + relayout(); + + // If async and there's remaining work, schedule another pass + if (m_asynchronous && + (destroyed < static_cast(toRemove.size()) || created < static_cast(toCreate.size()))) + polish(); } LazyListView::DelegateEntry LazyListView::createDelegate(int modelIndex) { diff --git a/plugin/src/Caelestia/Components/lazylistview.hpp b/plugin/src/Caelestia/Components/lazylistview.hpp index 7147169c..e24be426 100644 --- a/plugin/src/Caelestia/Components/lazylistview.hpp +++ b/plugin/src/Caelestia/Components/lazylistview.hpp @@ -73,6 +73,9 @@ class LazyListView : public QQuickItem { // Sizing Q_PROPERTY(qreal estimatedHeight READ estimatedHeight WRITE setEstimatedHeight NOTIFY estimatedHeightChanged) + // Async + Q_PROPERTY(bool asynchronous READ asynchronous WRITE setAsynchronous NOTIFY asynchronousChanged) + // Add Animation Q_PROPERTY(int addDuration READ addDuration WRITE setAddDuration NOTIFY addDurationChanged) Q_PROPERTY(QEasingCurve addCurve READ addCurve WRITE setAddCurve NOTIFY addCurveChanged) @@ -130,6 +133,10 @@ public: [[nodiscard]] qreal estimatedHeight() const; void setEstimatedHeight(qreal height); + // Async + [[nodiscard]] bool asynchronous() const; + void setAsynchronous(bool async); + // Add Animation [[nodiscard]] int addDuration() const; void setAddDuration(int duration); @@ -178,6 +185,7 @@ signals: void useCustomViewportChanged(); void cacheBufferChanged(); void estimatedHeightChanged(); + void asynchronousChanged(); void addDurationChanged(); void addCurveChanged(); void addFromOpacityChanged(); @@ -261,6 +269,7 @@ private: qreal m_estimatedHeight = -1; qreal m_knownHeightSum = 0; int m_knownHeightCount = 0; + bool m_asynchronous = false; int m_addDuration = 300; QEasingCurve m_addCurve; From 1c09222dd7475b8e39cb7722f6f2393b0954b59b Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Fri, 3 Apr 2026 18:37:14 +1100 Subject: [PATCH 12/37] fix: only destroy delegates when visually out of viewport --- plugin/src/Caelestia/Components/lazylistview.cpp | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/plugin/src/Caelestia/Components/lazylistview.cpp b/plugin/src/Caelestia/Components/lazylistview.cpp index 07c4d31e..50340ca7 100644 --- a/plugin/src/Caelestia/Components/lazylistview.cpp +++ b/plugin/src/Caelestia/Components/lazylistview.cpp @@ -549,10 +549,19 @@ void LazyListView::syncDelegates() { visibleIndices.insert(i); } - // Collect delegates to destroy (outside visible range and not animating) + // 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()) && !it->animation) + if (visibleIndices.contains(it.key()) || it->animation) + continue; + if (!it->item) { + 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()); } From 8f3f1b905657dddb8b05706155a2f6f9536a7031 Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Fri, 3 Apr 2026 18:49:07 +1100 Subject: [PATCH 13/37] feat: map listview delegates to indexes --- .../src/Caelestia/Components/lazylistview.cpp | 72 +++++++++++-------- .../src/Caelestia/Components/lazylistview.hpp | 1 + 2 files changed, 42 insertions(+), 31 deletions(-) diff --git a/plugin/src/Caelestia/Components/lazylistview.cpp b/plugin/src/Caelestia/Components/lazylistview.cpp index 50340ca7..d3c85c28 100644 --- a/plugin/src/Caelestia/Components/lazylistview.cpp +++ b/plugin/src/Caelestia/Components/lazylistview.cpp @@ -463,8 +463,9 @@ void LazyListView::relayout() { bool hasVisItem = false; for (int i = 0; i < static_cast(m_layout.size()); ++i) { qreal h; - if (m_delegates.contains(i) && m_delegates[i].item) - h = delegateVisibleHeight(m_delegates[i].item); + 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) { @@ -573,7 +574,10 @@ void LazyListView::syncDelegates() { for (int idx : toRemove) { if (destroyed >= destroyBudget) break; - removedEntries.append(m_delegates.take(idx)); + auto entry = m_delegates.take(idx); + if (entry.item) + m_itemToIndex.remove(entry.item); + removedEntries.append(std::move(entry)); ++destroyed; } for (auto& entry : removedEntries) @@ -608,6 +612,7 @@ void LazyListView::syncDelegates() { layoutChanged = true; } entry.item->setY(m_layout[i].targetY - m_contentY); + m_itemToIndex.insert(entry.item, i); m_delegates.insert(i, std::move(entry)); ++created; } @@ -692,35 +697,32 @@ LazyListView::DelegateEntry LazyListView::createDelegate(int modelIndex) { if (addingAttached) addingAttached->setAdding(false); - // Shared height-change handler — captures item pointer instead of index - // so it remains valid after model inserts/removes/moves shift indices. + // Height-change handler — uses m_itemToIndex for O(1) lookup auto onHeightChanged = [this, item = entry.item] { - // Find the delegate entry by item pointer - for (auto it = m_delegates.begin(); it != m_delegates.end(); ++it) { - if (it->item != item) - continue; - const int idx = it.key(); - const qreal h = delegateHeight(item); - if (idx < static_cast(m_layout.size()) && !qFuzzyCompare(m_layout[idx].height, h)) { - 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); - // Batch relayout: multiple height changes in the same event loop - // iteration are coalesced into a single relayout + polish. - if (!m_relayoutPending) { - m_relayoutPending = true; - QTimer::singleShot(0, this, [this] { - m_relayoutPending = false; - relayout(); - polish(); - }); - } - } + 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, h)) { + 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 (!m_relayoutPending) { + m_relayoutPending = true; + QTimer::singleShot(0, this, [this] { + m_relayoutPending = false; + relayout(); + polish(); + }); + } } }; @@ -837,6 +839,7 @@ void LazyListView::resetContent() { for (auto& entry : m_delegates) destroyDelegate(entry); m_delegates.clear(); + m_itemToIndex.clear(); for (auto& entry : m_dyingDelegates) destroyDelegate(entry); @@ -883,6 +886,8 @@ void LazyListView::onRowsInserted(const QModelIndex& parent, int first, int last entry.modelIndex = newIdx; if (entry.context) entry.context->setContextProperty(QStringLiteral("index"), newIdx); + if (entry.item) + m_itemToIndex[entry.item] = newIdx; shifted.insert(newIdx, std::move(entry)); } m_delegates = std::move(shifted); @@ -904,11 +909,12 @@ void LazyListView::onRowsAboutToBeRemoved(const QModelIndex& parent, int first, continue; auto entry = m_delegates.take(i); + if (entry.item) + m_itemToIndex.remove(entry.item); entry.pendingRemoval = true; stopAnimation(entry); if (m_removeDuration > 0 && entry.item) { - // Signal the delegate via attached property — QML handles the visual transition auto* attached = qobject_cast(qmlAttachedPropertiesObject(entry.item, false)); if (attached) @@ -955,6 +961,8 @@ void LazyListView::onRowsRemoved(const QModelIndex& parent, int first, int last) entry.modelIndex = newIdx; if (entry.context) entry.context->setContextProperty(QStringLiteral("index"), newIdx); + if (entry.item) + m_itemToIndex[entry.item] = newIdx; shifted.insert(newIdx, std::move(entry)); } m_delegates = std::move(shifted); @@ -998,6 +1006,8 @@ void LazyListView::onRowsMoved(const QModelIndex& parent, int start, int end, co entry.modelIndex = newIdx; if (entry.context) entry.context->setContextProperty(QStringLiteral("index"), newIdx); + if (entry.item) + m_itemToIndex[entry.item] = newIdx; remapped.insert(newIdx, std::move(entry)); } m_delegates = std::move(remapped); diff --git a/plugin/src/Caelestia/Components/lazylistview.hpp b/plugin/src/Caelestia/Components/lazylistview.hpp index e24be426..f4f28c63 100644 --- a/plugin/src/Caelestia/Components/lazylistview.hpp +++ b/plugin/src/Caelestia/Components/lazylistview.hpp @@ -286,6 +286,7 @@ private: QVector m_layout; QHash m_delegates; + QHash m_itemToIndex; QVector m_dyingDelegates; int m_activeAnimations = 0; From 08809320dee040adf8ae45806762bad22a9554dc Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Fri, 3 Apr 2026 19:30:42 +1100 Subject: [PATCH 14/37] fix: qFuzzyCompare + 1.0 --- plugin/src/Caelestia/Components/lazylistview.cpp | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/plugin/src/Caelestia/Components/lazylistview.cpp b/plugin/src/Caelestia/Components/lazylistview.cpp index d3c85c28..d76ae3a7 100644 --- a/plugin/src/Caelestia/Components/lazylistview.cpp +++ b/plugin/src/Caelestia/Components/lazylistview.cpp @@ -23,7 +23,7 @@ qreal LazyListViewAttached::preferredHeight() const { } void LazyListViewAttached::setPreferredHeight(qreal height) { - if (qFuzzyCompare(m_preferredHeight, height)) + if (qFuzzyCompare(m_preferredHeight + 1.0, height + 1.0)) return; m_preferredHeight = height; emit preferredHeightChanged(); @@ -34,7 +34,7 @@ qreal LazyListViewAttached::visibleHeight() const { } void LazyListViewAttached::setVisibleHeight(qreal height) { - if (qFuzzyCompare(m_visibleHeight, height)) + if (qFuzzyCompare(m_visibleHeight + 1.0, height + 1.0)) return; m_visibleHeight = height; emit visibleHeightChanged(); @@ -452,7 +452,7 @@ void LazyListView::relayout() { } } - if (!qFuzzyCompare(m_layoutHeight, y)) { + if (!qFuzzyCompare(m_layoutHeight + 1.0, y + 1.0)) { m_layoutHeight = y; emit layoutHeightChanged(); } @@ -485,7 +485,7 @@ void LazyListView::relayout() { visY = std::max(visY, dying.item->y() + dyingH); } - if (!qFuzzyCompare(m_contentHeight, visY)) { + if (!qFuzzyCompare(m_contentHeight + 1.0, visY + 1.0)) { m_contentHeight = visY; emit contentHeightChanged(); } @@ -603,7 +603,7 @@ void LazyListView::syncDelegates() { auto entry = createDelegate(i); if (entry.item) { const qreal h = delegateHeight(entry.item); - if (!m_layout[i].heightKnown || !qFuzzyCompare(m_layout[i].height, h)) { + if (!m_layout[i].heightKnown || !qFuzzyCompare(m_layout[i].height + 1.0, h + 1.0)) { if (m_layout[i].heightKnown) untrackHeight(m_layout[i].height); m_layout[i].height = h; @@ -707,7 +707,7 @@ LazyListView::DelegateEntry LazyListView::createDelegate(int modelIndex) { 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, h)) { + 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; From 2fbdfad472b5e13bb13f423625d08e060315db59 Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Fri, 3 Apr 2026 19:59:14 +1100 Subject: [PATCH 15/37] fix: notif group collapse lag It sort of breaks the anim but it's a worthwhile tradeoff Without this it is just wayyy too laggy with large amounts of notifs --- modules/sidebar/NotifGroupList.qml | 22 ++-------------------- 1 file changed, 2 insertions(+), 20 deletions(-) diff --git a/modules/sidebar/NotifGroupList.qml b/modules/sidebar/NotifGroupList.qml index fab8c9b0..fce03a50 100644 --- a/modules/sidebar/NotifGroupList.qml +++ b/modules/sidebar/NotifGroupList.qml @@ -16,19 +16,9 @@ LazyListView { required property DrawerVisibilities visibilities readonly property real nonAnimHeight: layoutHeight - property bool showAllNotifs signal requestToggleExpand(expand: bool) - onExpandedChanged: { - if (expanded) { - clearTimer.stop(); - showAllNotifs = true; - } else { - clearTimer.start(); - } - } - Layout.fillWidth: true implicitHeight: contentHeight @@ -38,18 +28,10 @@ LazyListView { removeDuration: Appearance.anim.durations.normal useCustomViewport: true - viewport: Qt.rect(0, container.contentY - mapToItem(container.contentItem, 0, 0).y, - width, container.height) - - Timer { - id: clearTimer - - interval: Appearance.anim.durations.normal - onTriggered: root.showAllNotifs = false - } + viewport: Qt.rect(0, container.contentY - mapToItem(container.contentItem, 0, 0).y, width, container.height) model: ScriptModel { - values: root.showAllNotifs ? root.notifs : root.notifs.slice(0, Config.notifs.groupPreviewNum + 1) + values: root.expanded ? root.notifs : root.notifs.slice(0, Config.notifs.groupPreviewNum + 1) } delegate: Component { From b83ac6e50a92fea7e85bf8ddab26da36b1cb01b6 Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Fri, 3 Apr 2026 20:25:04 +1100 Subject: [PATCH 16/37] feat: use curve for notif clear all anim Speeds up clearing large numbers of notifications --- modules/sidebar/NotifDock.qml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/sidebar/NotifDock.qml b/modules/sidebar/NotifDock.qml index c00f2bd4..177b6147 100644 --- a/modules/sidebar/NotifDock.qml +++ b/modules/sidebar/NotifDock.qml @@ -149,7 +149,7 @@ Item { id: clearTimer repeat: true - interval: 50 + interval: Math.max(15, Math.min(80, 69.8 - 12.3 * Math.log(Notifs.notClosed.length))) onTriggered: { const first = Notifs.notClosed[0]; if (first) { From 5b153fde8f31061099ed5844f809068f2c6c4513 Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Fri, 3 Apr 2026 20:47:13 +1100 Subject: [PATCH 17/37] fix: batch clear all notifs to prevent blocking --- modules/sidebar/NotifDock.qml | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/modules/sidebar/NotifDock.qml b/modules/sidebar/NotifDock.qml index 177b6147..dd88ec3d 100644 --- a/modules/sidebar/NotifDock.qml +++ b/modules/sidebar/NotifDock.qml @@ -149,14 +149,24 @@ Item { id: clearTimer repeat: true + triggeredOnStart: true interval: Math.max(15, Math.min(80, 69.8 - 12.3 * Math.log(Notifs.notClosed.length))) onTriggered: { const first = Notifs.notClosed[0]; - if (first) { - for (const n of Notifs.notClosed.filter(n => n.appName === first.appName)) - n.close(); - } else { + if (!first) { stop(); + return; + } + + const appName = first.appName; + let cleared = 0; + for (const n of Notifs.notClosed.filter(n => n.appName === appName)) { + n.close(); + cleared++; + if (cleared > 30) { + interval = 5; + return; + } } } } From a40b1c2c77d615efda7089e843e675e219ebc9ec Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Fri, 3 Apr 2026 22:28:10 +1100 Subject: [PATCH 18/37] fix: use bound component context --- modules/sidebar/Notif.qml | 4 ++-- modules/sidebar/NotifDock.qml | 2 ++ modules/sidebar/NotifDockList.qml | 2 ++ modules/sidebar/NotifGroupList.qml | 2 ++ plugin/src/Caelestia/Components/lazylistview.cpp | 12 +++++++++--- 5 files changed, 17 insertions(+), 5 deletions(-) diff --git a/modules/sidebar/Notif.qml b/modules/sidebar/Notif.qml index 88bd0305..d646c4f0 100644 --- a/modules/sidebar/Notif.qml +++ b/modules/sidebar/Notif.qml @@ -88,7 +88,7 @@ StyledRect { anchors.leftMargin: Appearance.spacing.small sourceComponent: StyledText { - text: (root.modelData?.body ?? "").replace(/\n/g, " ") + text: String(root.modelData?.body ?? "").replace(/\n/g, " ") color: root.modelData?.urgency === "critical" ? Colours.palette.m3secondary : Colours.palette.m3outline elide: Text.ElideRight } @@ -138,7 +138,7 @@ StyledRect { Layout.fillWidth: true textFormat: Text.MarkdownText - text: (root.modelData?.body ?? "").replace(/(.)\n(?!\n)/g, "$1\n\n") || qsTr("No body here! :/") + text: String(root.modelData?.body ?? "").replace(/(.)\n(?!\n)/g, "$1\n\n") || qsTr("No body here! :/") color: root.modelData?.urgency === "critical" ? Colours.palette.m3secondary : Colours.palette.m3outline wrapMode: Text.WordWrap diff --git a/modules/sidebar/NotifDock.qml b/modules/sidebar/NotifDock.qml index dd88ec3d..11ce4dac 100644 --- a/modules/sidebar/NotifDock.qml +++ b/modules/sidebar/NotifDock.qml @@ -1,3 +1,5 @@ +pragma ComponentBehavior: Bound + import QtQuick import QtQuick.Layouts import Quickshell.Widgets diff --git a/modules/sidebar/NotifDockList.qml b/modules/sidebar/NotifDockList.qml index 98ad0d4d..a799cf1a 100644 --- a/modules/sidebar/NotifDockList.qml +++ b/modules/sidebar/NotifDockList.qml @@ -1,3 +1,5 @@ +pragma ComponentBehavior: Bound + import QtQuick import Quickshell import Caelestia.Components diff --git a/modules/sidebar/NotifGroupList.qml b/modules/sidebar/NotifGroupList.qml index fce03a50..a54a13b6 100644 --- a/modules/sidebar/NotifGroupList.qml +++ b/modules/sidebar/NotifGroupList.qml @@ -1,3 +1,5 @@ +pragma ComponentBehavior: Bound + import QtQuick import QtQuick.Layouts import Quickshell diff --git a/plugin/src/Caelestia/Components/lazylistview.cpp b/plugin/src/Caelestia/Components/lazylistview.cpp index d76ae3a7..d05ca095 100644 --- a/plugin/src/Caelestia/Components/lazylistview.cpp +++ b/plugin/src/Caelestia/Components/lazylistview.cpp @@ -637,8 +637,9 @@ LazyListView::DelegateEntry LazyListView::createDelegate(int modelIndex) { const auto roleNames = m_model->roleNames(); const auto role = roleNames.isEmpty() ? Qt::DisplayRole : roleNames.constBegin().key(); - // Use the delegate component's creation context so the delegate - // can access ids and properties from the scope where it was defined. + // Use the delegate component's creation context directly for beginCreate + // so bound components (pragma ComponentBehavior: Bound) are accepted. + // A per-delegate child context is kept for data updates. auto* compContext = m_delegate->creationContext(); auto* parentContext = compContext ? compContext : qmlContext(this); if (!parentContext) @@ -669,10 +670,15 @@ LazyListView::DelegateEntry LazyListView::createDelegate(int modelIndex) { initialProps.insert(QStringLiteral("modelData"), value); } - auto* obj = m_delegate->beginCreate(entry.context); + // Use the creation context for beginCreate to satisfy bound component checks + // (pragma ComponentBehavior: Bound). Data is passed via setInitialProperties. + auto* creationCtx = compContext ? compContext : parentContext; + auto* obj = m_delegate->beginCreate(creationCtx); entry.item = qobject_cast(obj); if (!entry.item) { + if (obj) + m_delegate->completeCreate(); delete obj; delete entry.context; entry.context = nullptr; From c7a943b1c779872309380d1d968fd252c17544b5 Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Thu, 9 Apr 2026 16:14:36 +1000 Subject: [PATCH 19/37] fix: close notif group notifs in batches --- modules/sidebar/NotifActionList.qml | 2 +- modules/sidebar/NotifDockList.qml | 29 +++++++++++++++++++---------- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/modules/sidebar/NotifActionList.qml b/modules/sidebar/NotifActionList.qml index dfc83bd2..84e6103c 100644 --- a/modules/sidebar/NotifActionList.qml +++ b/modules/sidebar/NotifActionList.qml @@ -150,7 +150,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/sidebar/NotifDockList.qml b/modules/sidebar/NotifDockList.qml index a799cf1a..11dde291 100644 --- a/modules/sidebar/NotifDockList.qml +++ b/modules/sidebar/NotifDockList.qml @@ -62,16 +62,7 @@ LazyListView { property int startY function closeAll(): void { - for (const n of Notifs.notClosed.filter(n => n.appName === modelData)) - n.close(); - } - - containmentMask: QtObject { - function contains(p: point): bool { - if (!root.container.contains(notif.mapToItem(root.container, p))) - return false; - return notifInner.contains(p); - } + clearTimer.start(); } LazyListView.preferredHeight: closed ? 0 : notifInner.nonAnimHeight @@ -111,6 +102,24 @@ LazyListView { closeAll(); } + Timer { + id: clearTimer + + interval: 15 + repeat: true + triggeredOnStart: true + onTriggered: { + const notifs = Notifs.notClosed.filter(n => n.appName === notif.modelData); + if (notifs.length === 0) { + stop(); + return; + } + + for (const n of notifs.slice(0, 30)) + n.close(); + } + } + NotifGroup { id: notifInner From 06823973844090ea13a15b6139c40c1089b1d93f Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Thu, 9 Apr 2026 17:36:05 +1000 Subject: [PATCH 20/37] fix: watch transform for changes + async dock list --- modules/sidebar/NotifDockList.qml | 1 + modules/sidebar/NotifGroup.qml | 2 +- modules/sidebar/NotifGroupList.qml | 15 ++++++++++++--- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/modules/sidebar/NotifDockList.qml b/modules/sidebar/NotifDockList.qml index 11dde291..3c6a5398 100644 --- a/modules/sidebar/NotifDockList.qml +++ b/modules/sidebar/NotifDockList.qml @@ -20,6 +20,7 @@ LazyListView { spacing: Appearance.spacing.small cacheBuffer: 200 + asynchronous: true useCustomViewport: true viewport: Qt.rect(0, container.contentY, width, container.height) diff --git a/modules/sidebar/NotifGroup.qml b/modules/sidebar/NotifGroup.qml index f1d1502f..0efea542 100644 --- a/modules/sidebar/NotifGroup.qml +++ b/modules/sidebar/NotifGroup.qml @@ -52,7 +52,7 @@ StyledRect { readonly property int nonAnimHeight: { const headerHeight = header.implicitHeight + (root.expanded ? Math.round(Appearance.spacing.small / 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) + Appearance.padding.normal * 2); } readonly property bool expanded: props.expandedNotifs.includes(modelData) diff --git a/modules/sidebar/NotifGroupList.qml b/modules/sidebar/NotifGroupList.qml index a54a13b6..a4401697 100644 --- a/modules/sidebar/NotifGroupList.qml +++ b/modules/sidebar/NotifGroupList.qml @@ -17,8 +17,6 @@ LazyListView { required property Flickable container required property DrawerVisibilities visibilities - readonly property real nonAnimHeight: layoutHeight - signal requestToggleExpand(expand: bool) Layout.fillWidth: true @@ -27,10 +25,14 @@ LazyListView { spacing: Math.round(Appearance.spacing.small / 2) asynchronous: true + cacheBuffer: 200 removeDuration: Appearance.anim.durations.normal useCustomViewport: true - viewport: Qt.rect(0, container.contentY - mapToItem(container.contentItem, 0, 0).y, width, container.height) + 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); + } model: ScriptModel { values: root.expanded ? root.notifs : root.notifs.slice(0, Config.notifs.groupPreviewNum + 1) @@ -145,4 +147,11 @@ LazyListView { } } } + + TransformWatcher { + id: tWatcher + + a: root.container.contentItem + b: root + } } From 3f72dde918a1180adb23ff4e0041fe3f149d8aaa Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Thu, 9 Apr 2026 17:49:03 +1000 Subject: [PATCH 21/37] fix: lazy list view overshoot --- plugin/src/Caelestia/Components/lazylistview.cpp | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/plugin/src/Caelestia/Components/lazylistview.cpp b/plugin/src/Caelestia/Components/lazylistview.cpp index d05ca095..14fb4bb8 100644 --- a/plugin/src/Caelestia/Components/lazylistview.cpp +++ b/plugin/src/Caelestia/Components/lazylistview.cpp @@ -492,10 +492,22 @@ void LazyListView::relayout() { } QRectF LazyListView::effectiveViewport() const { + QRectF vp; if (m_useCustomViewport) - return m_viewport.adjusted(0, -m_cacheBuffer, 0, m_cacheBuffer); + vp = m_viewport; + else + vp = QRectF(0, m_contentY, width(), height()); - return QRectF(0, m_contentY - m_cacheBuffer, width(), height() + 2 * m_cacheBuffer); + // During Flickable overshoot the viewport can extend entirely beyond content bounds, + // causing all delegates to be culled. Clamp so it always overlaps [0, layoutHeight]. + if (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); + } + + return vp.adjusted(0, -m_cacheBuffer, 0, m_cacheBuffer); } std::pair LazyListView::computeVisibleRange() const { From 1223b5338bd8d97c31bf02e60bb6ff65792ad371 Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Thu, 9 Apr 2026 19:10:34 +1000 Subject: [PATCH 22/37] fix: prevent cacheBuffer from extending viewport over height --- plugin/src/Caelestia/Components/lazylistview.cpp | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/plugin/src/Caelestia/Components/lazylistview.cpp b/plugin/src/Caelestia/Components/lazylistview.cpp index 14fb4bb8..edcef17d 100644 --- a/plugin/src/Caelestia/Components/lazylistview.cpp +++ b/plugin/src/Caelestia/Components/lazylistview.cpp @@ -507,7 +507,19 @@ QRectF LazyListView::effectiveViewport() const { vp = QRectF(vp.x(), top, vp.width(), bottom - top); } - return vp.adjusted(0, -m_cacheBuffer, 0, m_cacheBuffer); + 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); + } + + return vp; } std::pair LazyListView::computeVisibleRange() const { From 82f177fa3fe0aca87a91c711a325b97be3bd1285 Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Thu, 9 Apr 2026 19:11:46 +1000 Subject: [PATCH 23/37] fix: increase cache buffers to reduce visual glitches Also set initial notif state and notif content non async --- modules/sidebar/Notif.qml | 4 ++-- modules/sidebar/NotifDockList.qml | 2 +- modules/sidebar/NotifGroupList.qml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/modules/sidebar/Notif.qml b/modules/sidebar/Notif.qml index d646c4f0..e23460f1 100644 --- a/modules/sidebar/Notif.qml +++ b/modules/sidebar/Notif.qml @@ -26,9 +26,10 @@ StyledRect { return expanded ? c : Qt.alpha(c, 0); } + state: expanded ? "expanded" : "" + states: State { name: "expanded" - when: root.expanded PropertyChanges { summary.anchors.margins: Appearance.padding.normal @@ -156,7 +157,6 @@ StyledRect { component WrappedLoader: Loader { required property bool shouldBeActive - asynchronous: true opacity: shouldBeActive ? 1 : 0 active: opacity > 0 diff --git a/modules/sidebar/NotifDockList.qml b/modules/sidebar/NotifDockList.qml index 3c6a5398..ef60c4f8 100644 --- a/modules/sidebar/NotifDockList.qml +++ b/modules/sidebar/NotifDockList.qml @@ -19,7 +19,7 @@ LazyListView { implicitHeight: contentHeight spacing: Appearance.spacing.small - cacheBuffer: 200 + cacheBuffer: 400 asynchronous: true useCustomViewport: true diff --git a/modules/sidebar/NotifGroupList.qml b/modules/sidebar/NotifGroupList.qml index a4401697..78c6ce59 100644 --- a/modules/sidebar/NotifGroupList.qml +++ b/modules/sidebar/NotifGroupList.qml @@ -25,7 +25,7 @@ LazyListView { spacing: Math.round(Appearance.spacing.small / 2) asynchronous: true - cacheBuffer: 200 + cacheBuffer: 800 removeDuration: Appearance.anim.durations.normal useCustomViewport: true From 8fbf85da86efc1607807304c11ef4cd604b2a533 Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Thu, 9 Apr 2026 19:52:54 +1000 Subject: [PATCH 24/37] fix: notif list viewport jumping around on delegate creation --- modules/sidebar/NotifDockList.qml | 2 ++ .../src/Caelestia/Components/lazylistview.cpp | 33 +++++++++++++++++++ .../src/Caelestia/Components/lazylistview.hpp | 7 ++++ 3 files changed, 42 insertions(+) diff --git a/modules/sidebar/NotifDockList.qml b/modules/sidebar/NotifDockList.qml index ef60c4f8..4e2ae6e8 100644 --- a/modules/sidebar/NotifDockList.qml +++ b/modules/sidebar/NotifDockList.qml @@ -22,6 +22,7 @@ LazyListView { cacheBuffer: 400 asynchronous: true + onViewportAdjustNeeded: d => container.contentY += d useCustomViewport: true viewport: Qt.rect(0, container.contentY, width, container.height) @@ -66,6 +67,7 @@ LazyListView { clearTimer.start(); } + LazyListView.trackViewport: notifInner.expanded || notifInner.nonAnimHeight < notifInner.implicitHeight LazyListView.preferredHeight: closed ? 0 : notifInner.nonAnimHeight LazyListView.visibleHeight: notifInner.implicitHeight implicitHeight: notifInner.implicitHeight diff --git a/plugin/src/Caelestia/Components/lazylistview.cpp b/plugin/src/Caelestia/Components/lazylistview.cpp index edcef17d..c35dbb6b 100644 --- a/plugin/src/Caelestia/Components/lazylistview.cpp +++ b/plugin/src/Caelestia/Components/lazylistview.cpp @@ -62,6 +62,17 @@ void LazyListViewAttached::setRemoving(bool 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) @@ -628,11 +639,21 @@ void LazyListView::syncDelegates() { if (entry.item) { const qreal h = delegateHeight(entry.item); if (!m_layout[i].heightKnown || !qFuzzyCompare(m_layout[i].height + 1.0, h + 1.0)) { + const qreal oldLayoutH = m_layout[i].heightKnown ? m_layout[i].height : effectiveEstimatedHeight(); if (m_layout[i].heightKnown) untrackHeight(m_layout[i].height); m_layout[i].height = h; m_layout[i].heightKnown = true; trackHeight(h); + + // Compensate if tracked item materializes above viewport + auto* att = + qobject_cast(qmlAttachedPropertiesObject(entry.item, false)); + if (att && att->trackViewport()) { + const qreal vpTop = m_useCustomViewport ? m_viewport.y() : m_contentY; + if (m_layout[i].targetY < vpTop) + emit viewportAdjustNeeded(h - oldLayoutH); + } layoutChanged = true; } entry.item->setY(m_layout[i].targetY - m_contentY); @@ -745,6 +766,18 @@ LazyListView::DelegateEntry LazyListView::createDelegate(int modelIndex) { 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] { diff --git a/plugin/src/Caelestia/Components/lazylistview.hpp b/plugin/src/Caelestia/Components/lazylistview.hpp index f4f28c63..891960d3 100644 --- a/plugin/src/Caelestia/Components/lazylistview.hpp +++ b/plugin/src/Caelestia/Components/lazylistview.hpp @@ -21,6 +21,7 @@ class LazyListViewAttached : public QObject { Q_PROPERTY(qreal visibleHeight READ visibleHeight WRITE setVisibleHeight NOTIFY visibleHeightChanged) 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); @@ -37,17 +38,22 @@ public: [[nodiscard]] bool removing() const; void setRemoving(bool removing); + [[nodiscard]] bool trackViewport() const; + void setTrackViewport(bool track); + signals: void preferredHeightChanged(); void visibleHeightChanged(); void addingChanged(); void removingChanged(); + void trackViewportChanged(); private: qreal m_preferredHeight = -1; qreal m_visibleHeight = -1; bool m_adding = false; bool m_removing = false; + bool m_trackViewport = false; }; class LazyListView : public QQuickItem { @@ -198,6 +204,7 @@ signals: void moveCurveChanged(); void countChanged(); void settledChanged(); + void viewportAdjustNeeded(qreal delta); protected: void componentComplete() override; From e3b3a6687719870dd3f92761e42184cbf6817200 Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Thu, 9 Apr 2026 21:56:51 +1000 Subject: [PATCH 25/37] fix: notif fileview warnings on first launch --- services/Notifs.qml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/services/Notifs.qml b/services/Notifs.qml index 92e609be..9ea5beff 100644 --- a/services/Notifs.qml +++ b/services/Notifs.qml @@ -105,6 +105,7 @@ Singleton { FileView { id: storage + printErrors: false path: `${Paths.state}/notifs.json` onLoaded: { const data = JSON.parse(text()); @@ -116,7 +117,7 @@ Singleton { onLoadFailed: err => { if (err === FileViewError.FileNotFound) { root.loaded = true; - setText("[]"); + Qt.callLater(() => setText("[]")); } } } From 9145f83639254d9d067815298c19ea7aa37ce80b Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Thu, 9 Apr 2026 22:06:46 +1000 Subject: [PATCH 26/37] fix: remove dead list view anim code --- modules/sidebar/NotifDockList.qml | 14 - .../src/Caelestia/Components/lazylistview.cpp | 255 +----------------- .../src/Caelestia/Components/lazylistview.hpp | 76 ------ 3 files changed, 1 insertion(+), 344 deletions(-) diff --git a/modules/sidebar/NotifDockList.qml b/modules/sidebar/NotifDockList.qml index 4e2ae6e8..b0e529c8 100644 --- a/modules/sidebar/NotifDockList.qml +++ b/modules/sidebar/NotifDockList.qml @@ -26,21 +26,7 @@ LazyListView { useCustomViewport: true viewport: Qt.rect(0, container.contentY, width, container.height) - addDuration: Appearance.anim.durations.expressiveDefaultSpatial - addCurve.type: Easing.BezierSpline - addCurve.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial - addFromOpacity: 0 - addFromScale: 0 - removeDuration: Appearance.anim.durations.normal - removeCurve.type: Easing.BezierSpline - removeCurve.bezierCurve: Appearance.anim.curves.standard - removeToOpacity: 0 - removeToScale: 0.6 - - moveDuration: Appearance.anim.durations.expressiveDefaultSpatial - moveCurve.type: Easing.BezierSpline - moveCurve.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial model: ScriptModel { values: { diff --git a/plugin/src/Caelestia/Components/lazylistview.cpp b/plugin/src/Caelestia/Components/lazylistview.cpp index c35dbb6b..9e03b03e 100644 --- a/plugin/src/Caelestia/Components/lazylistview.cpp +++ b/plugin/src/Caelestia/Components/lazylistview.cpp @@ -1,7 +1,6 @@ #include "lazylistview.hpp" #include -#include #include namespace { @@ -268,52 +267,6 @@ qreal LazyListView::delegateVisibleHeight(QQuickItem* item) { return item->implicitHeight(); } -// --- Add Animation --- - -int LazyListView::addDuration() const { - return m_addDuration; -} - -void LazyListView::setAddDuration(int duration) { - if (m_addDuration == duration) - return; - m_addDuration = duration; - emit addDurationChanged(); -} - -QEasingCurve LazyListView::addCurve() const { - return m_addCurve; -} - -void LazyListView::setAddCurve(const QEasingCurve& curve) { - if (m_addCurve == curve) - return; - m_addCurve = curve; - emit addCurveChanged(); -} - -qreal LazyListView::addFromOpacity() const { - return m_addFromOpacity; -} - -void LazyListView::setAddFromOpacity(qreal opacity) { - if (qFuzzyCompare(m_addFromOpacity, opacity)) - return; - m_addFromOpacity = opacity; - emit addFromOpacityChanged(); -} - -qreal LazyListView::addFromScale() const { - return m_addFromScale; -} - -void LazyListView::setAddFromScale(qreal scale) { - if (qFuzzyCompare(m_addFromScale, scale)) - return; - m_addFromScale = scale; - emit addFromScaleChanged(); -} - // --- Remove Animation --- int LazyListView::removeDuration() const { @@ -327,73 +280,12 @@ void LazyListView::setRemoveDuration(int duration) { emit removeDurationChanged(); } -QEasingCurve LazyListView::removeCurve() const { - return m_removeCurve; -} - -void LazyListView::setRemoveCurve(const QEasingCurve& curve) { - if (m_removeCurve == curve) - return; - m_removeCurve = curve; - emit removeCurveChanged(); -} - -qreal LazyListView::removeToOpacity() const { - return m_removeToOpacity; -} - -void LazyListView::setRemoveToOpacity(qreal opacity) { - if (qFuzzyCompare(m_removeToOpacity, opacity)) - return; - m_removeToOpacity = opacity; - emit removeToOpacityChanged(); -} - -qreal LazyListView::removeToScale() const { - return m_removeToScale; -} - -void LazyListView::setRemoveToScale(qreal scale) { - if (qFuzzyCompare(m_removeToScale, scale)) - return; - m_removeToScale = scale; - emit removeToScaleChanged(); -} - -// --- Move Animation --- - -int LazyListView::moveDuration() const { - return m_moveDuration; -} - -void LazyListView::setMoveDuration(int duration) { - if (m_moveDuration == duration) - return; - m_moveDuration = duration; - emit moveDurationChanged(); -} - -QEasingCurve LazyListView::moveCurve() const { - return m_moveCurve; -} - -void LazyListView::setMoveCurve(const QEasingCurve& curve) { - if (m_moveCurve == curve) - return; - m_moveCurve = curve; - emit moveCurveChanged(); -} - // --- State --- int LazyListView::count() const { return m_model ? m_model->rowCount() : 0; } -bool LazyListView::settled() const { - return m_activeAnimations == 0; -} - // --- QQuickItem Overrides --- void LazyListView::componentComplete() { @@ -589,7 +481,7 @@ void LazyListView::syncDelegates() { const auto vp = effectiveViewport(); QList toRemove; for (auto it = m_delegates.begin(); it != m_delegates.end(); ++it) { - if (visibleIndices.contains(it.key()) || it->animation) + if (visibleIndices.contains(it.key())) continue; if (!it->item) { toRemove.append(it.key()); @@ -806,15 +698,6 @@ LazyListView::DelegateEntry LazyListView::createDelegate(int modelIndex) { } void LazyListView::destroyDelegate(DelegateEntry& entry) { - if (entry.animation) { - // Disconnect before stopping to prevent re-entrant onAnimationFinished - disconnect(entry.animation, &QAbstractAnimation::finished, this, &LazyListView::onAnimationFinished); - entry.animation->stop(); - entry.animation = nullptr; - --m_activeAnimations; - if (m_activeAnimations == 0) - emit settledChanged(); - } if (entry.attachedConnection) disconnect(entry.attachedConnection); if (entry.item) { @@ -908,15 +791,9 @@ void LazyListView::resetContent() { destroyDelegate(entry); m_dyingDelegates.clear(); - if (m_activeAnimations != 0) { - m_activeAnimations = 0; - emit settledChanged(); - } - // Reset pending state m_knownHeightSum = 0; m_knownHeightCount = 0; - m_pendingAddAnimations.clear(); // Rebuild layout from model m_layout.clear(); @@ -955,10 +832,6 @@ void LazyListView::onRowsInserted(const QModelIndex& parent, int first, int last } m_delegates = std::move(shifted); - // Queue add animations and mark displacement - for (int i = first; i <= last; ++i) - m_pendingAddAnimations.insert(i); - emit countChanged(); polish(); } @@ -975,7 +848,6 @@ void LazyListView::onRowsAboutToBeRemoved(const QModelIndex& parent, int first, if (entry.item) m_itemToIndex.remove(entry.item); entry.pendingRemoval = true; - stopAnimation(entry); if (m_removeDuration > 0 && entry.item) { auto* attached = @@ -1129,129 +1001,4 @@ void LazyListView::onModelReset() { resetContent(); } -// --- Animation --- - -void LazyListView::startAddAnimation(DelegateEntry& entry) { - if (!entry.item || m_addDuration <= 0) - return; - - stopAnimation(entry); - - auto* group = new QParallelAnimationGroup(this); - - if (!qFuzzyCompare(m_addFromOpacity, 1.0)) { - auto* opacityAnim = new QPropertyAnimation(entry.item, "opacity"); - opacityAnim->setDuration(m_addDuration); - opacityAnim->setEasingCurve(m_addCurve); - opacityAnim->setStartValue(m_addFromOpacity); - opacityAnim->setEndValue(1.0); - group->addAnimation(opacityAnim); - entry.item->setOpacity(m_addFromOpacity); - } - - if (!qFuzzyCompare(m_addFromScale, 1.0)) { - auto* scaleAnim = new QPropertyAnimation(entry.item, "scale"); - scaleAnim->setDuration(m_addDuration); - scaleAnim->setEasingCurve(m_addCurve); - scaleAnim->setStartValue(m_addFromScale); - scaleAnim->setEndValue(1.0); - group->addAnimation(scaleAnim); - entry.item->setScale(m_addFromScale); - } - - if (group->animationCount() == 0) { - delete group; - return; - } - - entry.animation = group; - ++m_activeAnimations; - if (m_activeAnimations == 1) - emit settledChanged(); - - connect(group, &QAbstractAnimation::finished, this, &LazyListView::onAnimationFinished); - group->start(QAbstractAnimation::DeleteWhenStopped); -} - -void LazyListView::startRemoveAnimation(DelegateEntry& entry) { - if (!entry.item || m_removeDuration <= 0) - return; - - stopAnimation(entry); - - auto* group = new QParallelAnimationGroup(this); - - if (!qFuzzyCompare(m_removeToOpacity, 1.0)) { - auto* opacityAnim = new QPropertyAnimation(entry.item, "opacity"); - opacityAnim->setDuration(m_removeDuration); - opacityAnim->setEasingCurve(m_removeCurve); - opacityAnim->setStartValue(entry.item->opacity()); - opacityAnim->setEndValue(m_removeToOpacity); - group->addAnimation(opacityAnim); - } - - if (!qFuzzyCompare(m_removeToScale, 1.0)) { - auto* scaleAnim = new QPropertyAnimation(entry.item, "scale"); - scaleAnim->setDuration(m_removeDuration); - scaleAnim->setEasingCurve(m_removeCurve); - scaleAnim->setStartValue(entry.item->scale()); - scaleAnim->setEndValue(m_removeToScale); - group->addAnimation(scaleAnim); - } - - if (group->animationCount() == 0) { - delete group; - return; - } - - entry.animation = group; - ++m_activeAnimations; - if (m_activeAnimations == 1) - emit settledChanged(); - - connect(group, &QAbstractAnimation::finished, this, &LazyListView::onAnimationFinished); - group->start(QAbstractAnimation::DeleteWhenStopped); -} - -void LazyListView::stopAnimation(DelegateEntry& entry) { - if (!entry.animation) - return; - - entry.animation->stop(); - entry.animation = nullptr; - - --m_activeAnimations; - if (m_activeAnimations == 0) - emit settledChanged(); -} - -void LazyListView::onAnimationFinished() { - auto* group = qobject_cast(sender()); - - // Clear animation pointer from live delegates - for (auto& entry : m_delegates) { - if (entry.animation == group) - entry.animation = nullptr; - } - - // Clean up dying delegates whose animation finished - m_dyingDelegates.erase(std::remove_if(m_dyingDelegates.begin(), m_dyingDelegates.end(), - [this, group](DelegateEntry& entry) { - if (entry.animation == group) { - entry.animation = nullptr; - destroyDelegate(entry); - return true; - } - return false; - }), - m_dyingDelegates.end()); - - --m_activeAnimations; - if (m_activeAnimations == 0) - emit settledChanged(); - - // Re-sync in case viewport changed during animation - polish(); -} - } // namespace caelestia::components diff --git a/plugin/src/Caelestia/Components/lazylistview.hpp b/plugin/src/Caelestia/Components/lazylistview.hpp index 891960d3..1d0e8433 100644 --- a/plugin/src/Caelestia/Components/lazylistview.hpp +++ b/plugin/src/Caelestia/Components/lazylistview.hpp @@ -1,10 +1,8 @@ #pragma once #include -#include #include #include -#include #include #include #include @@ -82,25 +80,11 @@ class LazyListView : public QQuickItem { // Async Q_PROPERTY(bool asynchronous READ asynchronous WRITE setAsynchronous NOTIFY asynchronousChanged) - // Add Animation - Q_PROPERTY(int addDuration READ addDuration WRITE setAddDuration NOTIFY addDurationChanged) - Q_PROPERTY(QEasingCurve addCurve READ addCurve WRITE setAddCurve NOTIFY addCurveChanged) - Q_PROPERTY(qreal addFromOpacity READ addFromOpacity WRITE setAddFromOpacity NOTIFY addFromOpacityChanged) - Q_PROPERTY(qreal addFromScale READ addFromScale WRITE setAddFromScale NOTIFY addFromScaleChanged) - // Remove Animation Q_PROPERTY(int removeDuration READ removeDuration WRITE setRemoveDuration NOTIFY removeDurationChanged) - Q_PROPERTY(QEasingCurve removeCurve READ removeCurve WRITE setRemoveCurve NOTIFY removeCurveChanged) - Q_PROPERTY(qreal removeToOpacity READ removeToOpacity WRITE setRemoveToOpacity NOTIFY removeToOpacityChanged) - Q_PROPERTY(qreal removeToScale READ removeToScale WRITE setRemoveToScale NOTIFY removeToScaleChanged) - - // Move/Displaced Animation - Q_PROPERTY(int moveDuration READ moveDuration WRITE setMoveDuration NOTIFY moveDurationChanged) - Q_PROPERTY(QEasingCurve moveCurve READ moveCurve WRITE setMoveCurve NOTIFY moveCurveChanged) // State Q_PROPERTY(int count READ count NOTIFY countChanged) - Q_PROPERTY(bool settled READ settled NOTIFY settledChanged) public: explicit LazyListView(QQuickItem* parent = nullptr); @@ -143,43 +127,12 @@ public: [[nodiscard]] bool asynchronous() const; void setAsynchronous(bool async); - // Add Animation - [[nodiscard]] int addDuration() const; - void setAddDuration(int duration); - - [[nodiscard]] QEasingCurve addCurve() const; - void setAddCurve(const QEasingCurve& curve); - - [[nodiscard]] qreal addFromOpacity() const; - void setAddFromOpacity(qreal opacity); - - [[nodiscard]] qreal addFromScale() const; - void setAddFromScale(qreal scale); - // Remove Animation [[nodiscard]] int removeDuration() const; void setRemoveDuration(int duration); - [[nodiscard]] QEasingCurve removeCurve() const; - void setRemoveCurve(const QEasingCurve& curve); - - [[nodiscard]] qreal removeToOpacity() const; - void setRemoveToOpacity(qreal opacity); - - [[nodiscard]] qreal removeToScale() const; - void setRemoveToScale(qreal scale); - - // Move Animation - [[nodiscard]] int moveDuration() const; - void setMoveDuration(int duration); - - [[nodiscard]] QEasingCurve moveCurve() const; - void setMoveCurve(const QEasingCurve& curve); - // State [[nodiscard]] int count() const; - [[nodiscard]] bool settled() const; - signals: void modelChanged(); void delegateChanged(); @@ -192,18 +145,8 @@ signals: void cacheBufferChanged(); void estimatedHeightChanged(); void asynchronousChanged(); - void addDurationChanged(); - void addCurveChanged(); - void addFromOpacityChanged(); - void addFromScaleChanged(); void removeDurationChanged(); - void removeCurveChanged(); - void removeToOpacityChanged(); - void removeToScaleChanged(); - void moveDurationChanged(); - void moveCurveChanged(); void countChanged(); - void settledChanged(); void viewportAdjustNeeded(qreal delta); protected: @@ -223,7 +166,6 @@ private: QQuickItem* item = nullptr; QQmlContext* context = nullptr; bool pendingRemoval = false; - QParallelAnimationGroup* animation = nullptr; QMetaObject::Connection attachedConnection; }; @@ -254,11 +196,6 @@ private: void onDataChanged(const QModelIndex& topLeft, const QModelIndex& bottomRight, const QList& roles); void onModelReset(); - // Animation - void startAddAnimation(DelegateEntry& entry); - void startRemoveAnimation(DelegateEntry& entry); - void stopAnimation(DelegateEntry& entry); - void onAnimationFinished(); // Members QAbstractItemModel* m_model = nullptr; @@ -278,28 +215,15 @@ private: int m_knownHeightCount = 0; bool m_asynchronous = false; - int m_addDuration = 300; - QEasingCurve m_addCurve; - qreal m_addFromOpacity = 0; - qreal m_addFromScale = 1; - int m_removeDuration = 300; - QEasingCurve m_removeCurve; - qreal m_removeToOpacity = 0; - qreal m_removeToScale = 1; - - int m_moveDuration = 300; - QEasingCurve m_moveCurve; QVector m_layout; QHash m_delegates; QHash m_itemToIndex; QVector m_dyingDelegates; - int m_activeAnimations = 0; bool m_componentComplete = false; bool m_relayoutPending = false; - QSet m_pendingAddAnimations; QList m_modelConnections; }; From cc54d9bc5bafa255e62409ddc4990fc47b309d68 Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Thu, 9 Apr 2026 22:20:38 +1000 Subject: [PATCH 27/37] fix: remove unnecessary qml context creation --- .../src/Caelestia/Components/lazylistview.cpp | 102 +++++++----------- .../src/Caelestia/Components/lazylistview.hpp | 3 - 2 files changed, 37 insertions(+), 68 deletions(-) diff --git a/plugin/src/Caelestia/Components/lazylistview.cpp b/plugin/src/Caelestia/Components/lazylistview.cpp index 9e03b03e..94e37687 100644 --- a/plugin/src/Caelestia/Components/lazylistview.cpp +++ b/plugin/src/Caelestia/Components/lazylistview.cpp @@ -1,6 +1,7 @@ #include "lazylistview.hpp" #include +#include #include namespace { @@ -572,57 +573,43 @@ LazyListView::DelegateEntry LazyListView::createDelegate(int modelIndex) { return entry; const auto roleNames = m_model->roleNames(); - const auto role = roleNames.isEmpty() ? Qt::DisplayRole : roleNames.constBegin().key(); - // Use the delegate component's creation context directly for beginCreate + // Use the delegate component's creation context for beginCreate // so bound components (pragma ComponentBehavior: Bound) are accepted. - // A per-delegate child context is kept for data updates. auto* compContext = m_delegate->creationContext(); - auto* parentContext = compContext ? compContext : qmlContext(this); - if (!parentContext) + if (!compContext) + compContext = qmlContext(this); + if (!compContext) return entry; - entry.context = new QQmlContext(parentContext, this); - - // Build property map for both context properties and initial properties - 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()); - const auto value = m_model->data(index, it.key()); - entry.context->setContextProperty(name, value); - initialProps.insert(name, value); - if (name == QStringLiteral("modelData")) - hasModelData = true; - } - entry.context->setContextProperty(QStringLiteral("index"), modelIndex); - initialProps.insert(QStringLiteral("index"), modelIndex); - - // Provide modelData for single-role models or if not already provided by role names - if (!hasModelData) { - const auto value = m_model->data(index, role); - entry.context->setContextProperty(QStringLiteral("modelData"), value); - initialProps.insert(QStringLiteral("modelData"), value); - } - - // Use the creation context for beginCreate to satisfy bound component checks - // (pragma ComponentBehavior: Bound). Data is passed via setInitialProperties. - auto* creationCtx = compContext ? compContext : parentContext; - auto* obj = m_delegate->beginCreate(creationCtx); + auto* obj = m_delegate->beginCreate(compContext); entry.item = qobject_cast(obj); if (!entry.item) { if (obj) m_delegate->completeCreate(); delete obj; - delete entry.context; - entry.context = nullptr; return entry; } - // Set initial properties to satisfy required property declarations + // 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); @@ -706,14 +693,10 @@ void LazyListView::destroyDelegate(DelegateEntry& entry) { entry.item->deleteLater(); entry.item = nullptr; } - if (entry.context) { - entry.context->deleteLater(); - entry.context = nullptr; - } } void LazyListView::updateDelegateData(DelegateEntry& entry) { - if (!m_model) + if (!m_model || !entry.item) return; const auto roleNames = m_model->roleNames(); @@ -722,27 +705,16 @@ void LazyListView::updateDelegateData(DelegateEntry& entry) { for (auto it = roleNames.constBegin(); it != roleNames.constEnd(); ++it) { const auto name = QString::fromUtf8(it.value()); - const auto value = m_model->data(index, it.key()); - if (entry.context) - entry.context->setContextProperty(name, value); - if (entry.item) - entry.item->setProperty(name.toUtf8().constData(), value); + entry.item->setProperty(name.toUtf8().constData(), m_model->data(index, it.key())); if (name == QStringLiteral("modelData")) hasModelData = true; } - if (entry.context) - entry.context->setContextProperty(QStringLiteral("index"), entry.modelIndex); - if (entry.item) - entry.item->setProperty("index", entry.modelIndex); + entry.item->setProperty("index", entry.modelIndex); if (!hasModelData) { const auto role = roleNames.isEmpty() ? Qt::DisplayRole : roleNames.constBegin().key(); - const auto value = m_model->data(index, role); - if (entry.context) - entry.context->setContextProperty(QStringLiteral("modelData"), value); - if (entry.item) - entry.item->setProperty("modelData", value); + entry.item->setProperty("modelData", m_model->data(index, role)); } } @@ -824,10 +796,10 @@ void LazyListView::onRowsInserted(const QModelIndex& parent, int first, int last int newIdx = it.key() >= first ? it.key() + insertCount : it.key(); auto entry = std::move(it.value()); entry.modelIndex = newIdx; - if (entry.context) - entry.context->setContextProperty(QStringLiteral("index"), newIdx); - if (entry.item) + if (entry.item) { + entry.item->setProperty("index", newIdx); m_itemToIndex[entry.item] = newIdx; + } shifted.insert(newIdx, std::move(entry)); } m_delegates = std::move(shifted); @@ -894,10 +866,10 @@ void LazyListView::onRowsRemoved(const QModelIndex& parent, int first, int last) int newIdx = it.key() > last ? it.key() - removeCount : it.key(); auto entry = std::move(it.value()); entry.modelIndex = newIdx; - if (entry.context) - entry.context->setContextProperty(QStringLiteral("index"), newIdx); - if (entry.item) + if (entry.item) { + entry.item->setProperty("index", newIdx); m_itemToIndex[entry.item] = newIdx; + } shifted.insert(newIdx, std::move(entry)); } m_delegates = std::move(shifted); @@ -939,10 +911,10 @@ void LazyListView::onRowsMoved(const QModelIndex& parent, int start, int end, co auto entry = std::move(it.value()); entry.modelIndex = newIdx; - if (entry.context) - entry.context->setContextProperty(QStringLiteral("index"), newIdx); - if (entry.item) + if (entry.item) { + entry.item->setProperty("index", newIdx); m_itemToIndex[entry.item] = newIdx; + } remapped.insert(newIdx, std::move(entry)); } m_delegates = std::move(remapped); diff --git a/plugin/src/Caelestia/Components/lazylistview.hpp b/plugin/src/Caelestia/Components/lazylistview.hpp index 1d0e8433..395d022e 100644 --- a/plugin/src/Caelestia/Components/lazylistview.hpp +++ b/plugin/src/Caelestia/Components/lazylistview.hpp @@ -4,7 +4,6 @@ #include #include #include -#include #include #include #include @@ -164,7 +163,6 @@ private: struct DelegateEntry { int modelIndex = -1; QQuickItem* item = nullptr; - QQmlContext* context = nullptr; bool pendingRemoval = false; QMetaObject::Connection attachedConnection; }; @@ -196,7 +194,6 @@ private: void onDataChanged(const QModelIndex& topLeft, const QModelIndex& bottomRight, const QList& roles); void onModelReset(); - // Members QAbstractItemModel* m_model = nullptr; QQmlComponent* m_delegate = nullptr; From e6c1248bef68c273ceb6306db1fa0a64d440e28e Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Thu, 9 Apr 2026 23:29:12 +1000 Subject: [PATCH 28/37] fix: no need to disconnect signal Signal will auto disconnect on item destruction --- plugin/src/Caelestia/Components/lazylistview.cpp | 5 +---- plugin/src/Caelestia/Components/lazylistview.hpp | 1 - 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/plugin/src/Caelestia/Components/lazylistview.cpp b/plugin/src/Caelestia/Components/lazylistview.cpp index 94e37687..1df13123 100644 --- a/plugin/src/Caelestia/Components/lazylistview.cpp +++ b/plugin/src/Caelestia/Components/lazylistview.cpp @@ -674,8 +674,7 @@ LazyListView::DelegateEntry LazyListView::createDelegate(int modelIndex) { // Watch attached properties if the delegate uses them auto* attached = qobject_cast(qmlAttachedPropertiesObject(entry.item, false)); if (attached) { - entry.attachedConnection = - connect(attached, &LazyListViewAttached::preferredHeightChanged, this, onHeightChanged); + connect(attached, &LazyListViewAttached::preferredHeightChanged, this, onHeightChanged); connect(attached, &LazyListViewAttached::visibleHeightChanged, this, [this] { polish(); }); @@ -685,8 +684,6 @@ LazyListView::DelegateEntry LazyListView::createDelegate(int modelIndex) { } void LazyListView::destroyDelegate(DelegateEntry& entry) { - if (entry.attachedConnection) - disconnect(entry.attachedConnection); if (entry.item) { entry.item->setParentItem(nullptr); entry.item->setVisible(false); diff --git a/plugin/src/Caelestia/Components/lazylistview.hpp b/plugin/src/Caelestia/Components/lazylistview.hpp index 395d022e..0098fb03 100644 --- a/plugin/src/Caelestia/Components/lazylistview.hpp +++ b/plugin/src/Caelestia/Components/lazylistview.hpp @@ -164,7 +164,6 @@ private: int modelIndex = -1; QQuickItem* item = nullptr; bool pendingRemoval = false; - QMetaObject::Connection attachedConnection; }; // Layout From 2efe1a93a686960ded85e4b161b7418810c76fa5 Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Fri, 10 Apr 2026 00:19:16 +1000 Subject: [PATCH 29/37] fix: layout delegates after configurable delay Fixes glitches due to delegate sizes changing on creation Also use the loader state pattern to ensure size is set on same frame as active --- modules/sidebar/Notif.qml | 46 ++++++- modules/sidebar/NotifDockList.qml | 1 + modules/sidebar/NotifGroupList.qml | 1 + .../src/Caelestia/Components/lazylistview.cpp | 130 +++++++++++++----- .../src/Caelestia/Components/lazylistview.hpp | 18 ++- 5 files changed, 158 insertions(+), 38 deletions(-) diff --git a/modules/sidebar/Notif.qml b/modules/sidebar/Notif.qml index e23460f1..5ccdc97e 100644 --- a/modules/sidebar/Notif.qml +++ b/modules/sidebar/Notif.qml @@ -155,13 +155,51 @@ StyledRect { } component WrappedLoader: Loader { + id: comp + required property bool shouldBeActive - opacity: shouldBeActive ? 1 : 0 - active: opacity > 0 + active: false + opacity: 0 - Behavior on opacity { - Anim {} + // Makes the loader load on the same frame shouldBeActive becomes true, which ensures size is set + states: State { + name: "active" + when: comp.shouldBeActive + + PropertyChanges { + comp.opacity: 1 + comp.active: true + } } + + 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/sidebar/NotifDockList.qml b/modules/sidebar/NotifDockList.qml index b0e529c8..d2dc4972 100644 --- a/modules/sidebar/NotifDockList.qml +++ b/modules/sidebar/NotifDockList.qml @@ -19,6 +19,7 @@ LazyListView { implicitHeight: contentHeight spacing: Appearance.spacing.small + readyDelay: 1 cacheBuffer: 400 asynchronous: true diff --git a/modules/sidebar/NotifGroupList.qml b/modules/sidebar/NotifGroupList.qml index 78c6ce59..21692587 100644 --- a/modules/sidebar/NotifGroupList.qml +++ b/modules/sidebar/NotifGroupList.qml @@ -25,6 +25,7 @@ LazyListView { spacing: Math.round(Appearance.spacing.small / 2) asynchronous: true + readyDelay: 1 cacheBuffer: 800 removeDuration: Appearance.anim.durations.normal diff --git a/plugin/src/Caelestia/Components/lazylistview.cpp b/plugin/src/Caelestia/Components/lazylistview.cpp index 1df13123..27a8a92d 100644 --- a/plugin/src/Caelestia/Components/lazylistview.cpp +++ b/plugin/src/Caelestia/Components/lazylistview.cpp @@ -40,6 +40,17 @@ void LazyListViewAttached::setVisibleHeight(qreal 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; } @@ -268,7 +279,14 @@ qreal LazyListView::delegateVisibleHeight(QQuickItem* item) { return item->implicitHeight(); } -// --- Remove Animation --- +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; @@ -281,6 +299,17 @@ void LazyListView::setRemoveDuration(int 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 { @@ -315,12 +344,32 @@ void LazyListView::updatePolish() { if (!m_componentComplete || !m_model || !m_delegate) return; + // Flush pending inserts from the previous frame — make items visible + // and clear the adding flag so enter animations begin. + for (auto& entry : m_delegates) { + if (!entry.pendingInsert || !entry.item) + continue; + entry.pendingInsert = false; + entry.item->setVisible(true); + auto* att = qobject_cast(qmlAttachedPropertiesObject(entry.item, false)); + if (att) { + att->setAdding(false); + if (m_readyDelay > 0) { + QTimer::singleShot(m_readyDelay, att, [att] { + att->setReady(true); + }); + } else { + att->setReady(true); + } + } + } + relayout(); syncDelegates(); // Position delegates — QML Behavior on y handles the animation for (auto& entry : m_delegates) { - if (!entry.item || entry.pendingRemoval) + if (!entry.item || entry.pendingRemoval || entry.pendingInsert) continue; const int idx = entry.modelIndex; @@ -523,32 +572,15 @@ void LazyListView::syncDelegates() { // Batch create const int createBudget = m_asynchronous ? ASYNC_BATCH_CREATE : static_cast(toCreate.size()); int created = 0; - bool layoutChanged = false; for (int i : toCreate) { if (created >= createBudget) break; auto entry = createDelegate(i); if (entry.item) { - const qreal h = delegateHeight(entry.item); - if (!m_layout[i].heightKnown || !qFuzzyCompare(m_layout[i].height + 1.0, h + 1.0)) { - const qreal oldLayoutH = m_layout[i].heightKnown ? m_layout[i].height : effectiveEstimatedHeight(); - if (m_layout[i].heightKnown) - untrackHeight(m_layout[i].height); - m_layout[i].height = h; - m_layout[i].heightKnown = true; - trackHeight(h); - - // Compensate if tracked item materializes above viewport - auto* att = - qobject_cast(qmlAttachedPropertiesObject(entry.item, false)); - if (att && att->trackViewport()) { - const qreal vpTop = m_useCustomViewport ? m_viewport.y() : m_contentY; - if (m_layout[i].targetY < vpTop) - emit viewportAdjustNeeded(h - oldLayoutH); - } - layoutChanged = true; - } + // 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)); @@ -556,12 +588,10 @@ void LazyListView::syncDelegates() { } } - if (layoutChanged) - relayout(); - - // If async and there's remaining work, schedule another pass - if (m_asynchronous && - (destroyed < static_cast(toRemove.size()) || created < static_cast(toCreate.size()))) + // 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(); } @@ -616,7 +646,7 @@ LazyListView::DelegateEntry LazyListView::createDelegate(int modelIndex) { entry.item->setWidth(width()); // Set adding = true before completeCreate so bindings see it during initial evaluation. - // Cleared after creation so the transition from true→false triggers QML Behaviors. + // Cleared on the next frame in updatePolish when the item becomes visible. auto* addingAttached = qobject_cast(qmlAttachedPropertiesObject(entry.item, true)); if (addingAttached) @@ -624,11 +654,14 @@ LazyListView::DelegateEntry LazyListView::createDelegate(int modelIndex) { m_delegate->completeCreate(); - if (addingAttached) - addingAttached->setAdding(false); + // 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 + // 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; @@ -678,6 +711,33 @@ LazyListView::DelegateEntry LazyListView::createDelegate(int modelIndex) { 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; @@ -818,6 +878,12 @@ void LazyListView::onRowsAboutToBeRemoved(const QModelIndex& parent, int first, 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)); diff --git a/plugin/src/Caelestia/Components/lazylistview.hpp b/plugin/src/Caelestia/Components/lazylistview.hpp index 0098fb03..feb501c6 100644 --- a/plugin/src/Caelestia/Components/lazylistview.hpp +++ b/plugin/src/Caelestia/Components/lazylistview.hpp @@ -16,6 +16,7 @@ class LazyListViewAttached : public QObject { 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) @@ -29,6 +30,9 @@ public: [[nodiscard]] qreal visibleHeight() const; void setVisibleHeight(qreal height); + [[nodiscard]] bool ready() const; + void setReady(bool ready); + [[nodiscard]] bool adding() const; void setAdding(bool adding); @@ -41,6 +45,7 @@ public: signals: void preferredHeightChanged(); void visibleHeightChanged(); + void readyChanged(); void addingChanged(); void removingChanged(); void trackViewportChanged(); @@ -48,6 +53,7 @@ signals: 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; @@ -79,8 +85,9 @@ class LazyListView : public QQuickItem { // Async Q_PROPERTY(bool asynchronous READ asynchronous WRITE setAsynchronous NOTIFY asynchronousChanged) - // Remove Animation + // 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) @@ -126,10 +133,13 @@ public: [[nodiscard]] bool asynchronous() const; void setAsynchronous(bool async); - // Remove Animation + // Animation Durations [[nodiscard]] int removeDuration() const; void setRemoveDuration(int duration); + [[nodiscard]] int readyDelay() const; + void setReadyDelay(int delay); + // State [[nodiscard]] int count() const; signals: @@ -145,6 +155,7 @@ signals: void estimatedHeightChanged(); void asynchronousChanged(); void removeDurationChanged(); + void readyDelayChanged(); void countChanged(); void viewportAdjustNeeded(qreal delta); @@ -164,6 +175,7 @@ private: int modelIndex = -1; QQuickItem* item = nullptr; bool pendingRemoval = false; + bool pendingInsert = false; }; // Layout @@ -173,6 +185,7 @@ private: [[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); @@ -212,6 +225,7 @@ private: bool m_asynchronous = false; int m_removeDuration = 300; + int m_readyDelay = 0; QVector m_layout; QHash m_delegates; From b0aeee8b8077b596da06b06ccdfe83f02786d39d Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Fri, 10 Apr 2026 01:10:05 +1000 Subject: [PATCH 30/37] fix: allow viewport to be outside of bounds --- plugin/src/Caelestia/Components/lazylistview.cpp | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/plugin/src/Caelestia/Components/lazylistview.cpp b/plugin/src/Caelestia/Components/lazylistview.cpp index 27a8a92d..14545578 100644 --- a/plugin/src/Caelestia/Components/lazylistview.cpp +++ b/plugin/src/Caelestia/Components/lazylistview.cpp @@ -453,7 +453,9 @@ QRectF LazyListView::effectiveViewport() const { // During Flickable overshoot the viewport can extend entirely beyond content bounds, // causing all delegates to be culled. Clamp so it always overlaps [0, layoutHeight]. - if (m_layoutHeight > 0) { + // 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) @@ -470,6 +472,8 @@ QRectF LazyListView::effectiveViewport() const { 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; @@ -480,6 +484,9 @@ std::pair LazyListView::computeVisibleRange() const { 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(); @@ -533,7 +540,7 @@ void LazyListView::syncDelegates() { for (auto it = m_delegates.begin(); it != m_delegates.end(); ++it) { if (visibleIndices.contains(it.key())) continue; - if (!it->item) { + if (!it->item || vp.isEmpty()) { toRemove.append(it.key()); continue; } From ac65b78588bf48a8adfd282cc609a68e5a0d2e7f Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Fri, 10 Apr 2026 01:10:21 +1000 Subject: [PATCH 31/37] fix: reduce notif group list cache buffer --- modules/sidebar/NotifGroupList.qml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/sidebar/NotifGroupList.qml b/modules/sidebar/NotifGroupList.qml index 21692587..31aa44b5 100644 --- a/modules/sidebar/NotifGroupList.qml +++ b/modules/sidebar/NotifGroupList.qml @@ -26,7 +26,7 @@ LazyListView { asynchronous: true readyDelay: 1 - cacheBuffer: 800 + cacheBuffer: 400 removeDuration: Appearance.anim.durations.normal useCustomViewport: true From 7a82ca4765713c1d688d9fad35ccba8f5ff8c460 Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Fri, 10 Apr 2026 01:22:16 +1000 Subject: [PATCH 32/37] fix: account for closing notifs in group preview --- modules/sidebar/NotifGroupList.qml | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/modules/sidebar/NotifGroupList.qml b/modules/sidebar/NotifGroupList.qml index 31aa44b5..112db493 100644 --- a/modules/sidebar/NotifGroupList.qml +++ b/modules/sidebar/NotifGroupList.qml @@ -36,7 +36,21 @@ LazyListView { } model: ScriptModel { - values: root.expanded ? root.notifs : root.notifs.slice(0, Config.notifs.groupPreviewNum + 1) + values: { + if (root.expanded) + return root.notifs; + + let count = 0; + let i = 0; + const previewNum = Config.notifs.groupPreviewNum + 1; + while (i < root.notifs.length && count < previewNum) { + if (!(root.notifs[i]?.closed ?? true)) + count++; + i++; + } + + return root.notifs.slice(0, i); + } } delegate: Component { From e234990c52f20d31c3b038dbc1d69c94b813ba95 Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Fri, 10 Apr 2026 01:25:09 +1000 Subject: [PATCH 33/37] fix: don't play add/remove anim on old delegates --- modules/sidebar/NotifGroupList.qml | 4 ++-- .../src/Caelestia/Components/lazylistview.cpp | 20 +++++++++++++------ .../src/Caelestia/Components/lazylistview.hpp | 1 + 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/modules/sidebar/NotifGroupList.qml b/modules/sidebar/NotifGroupList.qml index 112db493..49330612 100644 --- a/modules/sidebar/NotifGroupList.qml +++ b/modules/sidebar/NotifGroupList.qml @@ -80,8 +80,8 @@ LazyListView { LazyListView.visibleHeight: modelData?.closed || previewHidden ? 0 : notifInner.implicitHeight implicitHeight: notifInner.implicitHeight - opacity: LazyListView.removing || modelData?.closed || previewHidden || LazyListView.adding ? 0 : 1 - scale: LazyListView.removing || previewHidden ? 0.7 : LazyListView.adding ? 0.7 : 1 + opacity: previewHidden || LazyListView.adding ? 0 : 1 + scale: previewHidden || LazyListView.adding ? 0.7 : 1 hoverEnabled: true cursorShape: notifInner.body?.hoveredLink ? Qt.PointingHandCursor : pressed ? Qt.ClosedHandCursor : undefined diff --git a/plugin/src/Caelestia/Components/lazylistview.cpp b/plugin/src/Caelestia/Components/lazylistview.cpp index 14545578..653fe97b 100644 --- a/plugin/src/Caelestia/Components/lazylistview.cpp +++ b/plugin/src/Caelestia/Components/lazylistview.cpp @@ -367,6 +367,12 @@ void LazyListView::updatePolish() { 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) @@ -652,12 +658,14 @@ LazyListView::DelegateEntry LazyListView::createDelegate(int modelIndex) { entry.item->setParentItem(this); entry.item->setWidth(width()); - // Set adding = true before completeCreate so bindings see it during initial evaluation. + // Only set adding = true for genuinely new model items (not viewport entries). // Cleared on the next frame in updatePolish when the item becomes visible. - auto* addingAttached = - qobject_cast(qmlAttachedPropertiesObject(entry.item, true)); - if (addingAttached) - addingAttached->setAdding(true); + 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(); @@ -852,7 +860,7 @@ void LazyListView::onRowsInserted(const QModelIndex& parent, int first, int last const int insertCount = last - first + 1; // Insert new layout records - m_layout.insert(first, insertCount, ItemRecord{ 0, 0, false }); + m_layout.insert(first, insertCount, ItemRecord{ 0, 0, false, true }); // Shift existing delegate indices QHash shifted; diff --git a/plugin/src/Caelestia/Components/lazylistview.hpp b/plugin/src/Caelestia/Components/lazylistview.hpp index feb501c6..a027d19a 100644 --- a/plugin/src/Caelestia/Components/lazylistview.hpp +++ b/plugin/src/Caelestia/Components/lazylistview.hpp @@ -169,6 +169,7 @@ private: qreal targetY = 0; qreal height = 0; bool heightKnown = false; + bool isNew = false; }; struct DelegateEntry { From c629eaaa08c9a97540f830973dfa6bd4ceb06ba2 Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Fri, 10 Apr 2026 01:38:12 +1000 Subject: [PATCH 34/37] fix: don't add delegate to layout until ready delay finish Also disable y anim if not ready --- modules/sidebar/NotifDockList.qml | 2 + modules/sidebar/NotifGroupList.qml | 2 + .../src/Caelestia/Components/lazylistview.cpp | 47 +++++++++++++++---- .../src/Caelestia/Components/lazylistview.hpp | 1 + 4 files changed, 43 insertions(+), 9 deletions(-) diff --git a/modules/sidebar/NotifDockList.qml b/modules/sidebar/NotifDockList.qml index d2dc4972..08752edc 100644 --- a/modules/sidebar/NotifDockList.qml +++ b/modules/sidebar/NotifDockList.qml @@ -120,6 +120,8 @@ LazyListView { } Behavior on y { + enabled: notif.LazyListView.ready + Anim { duration: Appearance.anim.durations.expressiveDefaultSpatial easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial diff --git a/modules/sidebar/NotifGroupList.qml b/modules/sidebar/NotifGroupList.qml index 49330612..5f081a36 100644 --- a/modules/sidebar/NotifGroupList.qml +++ b/modules/sidebar/NotifGroupList.qml @@ -140,6 +140,8 @@ LazyListView { } Behavior on y { + enabled: notif.LazyListView.ready + Anim { duration: Appearance.anim.durations.expressiveDefaultSpatial easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial diff --git a/plugin/src/Caelestia/Components/lazylistview.cpp b/plugin/src/Caelestia/Components/lazylistview.cpp index 653fe97b..c9291cfd 100644 --- a/plugin/src/Caelestia/Components/lazylistview.cpp +++ b/plugin/src/Caelestia/Components/lazylistview.cpp @@ -344,23 +344,52 @@ void LazyListView::updatePolish() { if (!m_componentComplete || !m_model || !m_delegate) return; - // Flush pending inserts from the previous frame — make items visible - // and clear the adding flag so enter animations begin. + // 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; + + // Position correctly before making visible + if (idx >= 0 && idx < static_cast(m_layout.size())) + item->setY(m_layout[idx].targetY - m_contentY); + + item->setVisible(true); + auto* att = + qobject_cast(qmlAttachedPropertiesObject(item, false)); + if (att) { + att->setAdding(false); + att->setReady(true); + } + polish(); + }); + } + continue; + } + entry.pendingInsert = false; entry.item->setVisible(true); auto* att = qobject_cast(qmlAttachedPropertiesObject(entry.item, false)); if (att) { att->setAdding(false); - if (m_readyDelay > 0) { - QTimer::singleShot(m_readyDelay, att, [att] { - att->setReady(true); - }); - } else { - att->setReady(true); - } + att->setReady(true); } } diff --git a/plugin/src/Caelestia/Components/lazylistview.hpp b/plugin/src/Caelestia/Components/lazylistview.hpp index a027d19a..e0746db2 100644 --- a/plugin/src/Caelestia/Components/lazylistview.hpp +++ b/plugin/src/Caelestia/Components/lazylistview.hpp @@ -177,6 +177,7 @@ private: QQuickItem* item = nullptr; bool pendingRemoval = false; bool pendingInsert = false; + bool readyDelayStarted = false; }; // Layout From e3d2ffd9ae931acce68c12493164710c23b5a62a Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Fri, 10 Apr 2026 02:10:52 +1000 Subject: [PATCH 35/37] fix: remove previewHidden from notif groups Not needed anymore, and was causing a visual bug where when adding a notif, some notifs would overlap --- modules/sidebar/NotifGroupList.qml | 21 +++++---------------- 1 file changed, 5 insertions(+), 16 deletions(-) diff --git a/modules/sidebar/NotifGroupList.qml b/modules/sidebar/NotifGroupList.qml index 5f081a36..fdcaf30a 100644 --- a/modules/sidebar/NotifGroupList.qml +++ b/modules/sidebar/NotifGroupList.qml @@ -42,7 +42,7 @@ LazyListView { let count = 0; let i = 0; - const previewNum = Config.notifs.groupPreviewNum + 1; + const previewNum = Config.notifs.groupPreviewNum; while (i < root.notifs.length && count < previewNum) { if (!(root.notifs[i]?.closed ?? true)) count++; @@ -60,28 +60,17 @@ LazyListView { required property int index required property NotifData modelData - 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; - } property int startY Component.onCompleted: modelData?.lock(this) Component.onDestruction: modelData?.unlock(this) - LazyListView.preferredHeight: modelData?.closed || previewHidden ? 0 : notifInner.nonAnimHeight - LazyListView.visibleHeight: modelData?.closed || previewHidden ? 0 : notifInner.implicitHeight + LazyListView.preferredHeight: modelData?.closed ? 0 : notifInner.nonAnimHeight + LazyListView.visibleHeight: modelData?.closed ? 0 : notifInner.implicitHeight implicitHeight: notifInner.implicitHeight - opacity: previewHidden || LazyListView.adding ? 0 : 1 - scale: previewHidden || LazyListView.adding ? 0.7 : 1 + opacity: LazyListView.removing || LazyListView.adding ? 0 : 1 + scale: LazyListView.removing || LazyListView.adding ? 0.7 : 1 hoverEnabled: true cursorShape: notifInner.body?.hoveredLink ? Qt.PointingHandCursor : pressed ? Qt.ClosedHandCursor : undefined From 6cd5758234729f6f5473a3536d517f15c82829fb Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Fri, 10 Apr 2026 02:34:35 +1000 Subject: [PATCH 36/37] fix: animate viewport tracking and fix condition --- modules/sidebar/NotifDockList.qml | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/modules/sidebar/NotifDockList.qml b/modules/sidebar/NotifDockList.qml index 08752edc..c40a1e01 100644 --- a/modules/sidebar/NotifDockList.qml +++ b/modules/sidebar/NotifDockList.qml @@ -23,7 +23,13 @@ LazyListView { cacheBuffer: 400 asynchronous: true - onViewportAdjustNeeded: d => container.contentY += d + onViewportAdjustNeeded: d => { + if (contentYAnim.running) + contentYAnim.complete(); + contentYAnim.to = Math.max(0, container.contentY + d); + contentYAnim.start(); + } + useCustomViewport: true viewport: Qt.rect(0, container.contentY, width, container.height) @@ -54,7 +60,7 @@ LazyListView { clearTimer.start(); } - LazyListView.trackViewport: notifInner.expanded || notifInner.nonAnimHeight < notifInner.implicitHeight + LazyListView.trackViewport: !notifInner.expanded && notifInner.nonAnimHeight < notifInner.implicitHeight LazyListView.preferredHeight: closed ? 0 : notifInner.nonAnimHeight LazyListView.visibleHeight: notifInner.implicitHeight implicitHeight: notifInner.implicitHeight @@ -147,4 +153,13 @@ LazyListView { } } } + + Anim { + id: contentYAnim + + target: root.container + property: "contentY" + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } } From 0844013ca7a4186bf27bb85a7650d9d42a5f5abe Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Fri, 10 Apr 2026 02:36:56 +1000 Subject: [PATCH 37/37] feat: animate delegates in from visual position Instead of suddenly appearing, create them at their visual position then animate them to the layout pos --- modules/sidebar/NotifGroupList.qml | 4 +-- .../src/Caelestia/Components/lazylistview.cpp | 31 +++++++++++++++++-- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/modules/sidebar/NotifGroupList.qml b/modules/sidebar/NotifGroupList.qml index fdcaf30a..aeec4c62 100644 --- a/modules/sidebar/NotifGroupList.qml +++ b/modules/sidebar/NotifGroupList.qml @@ -65,8 +65,8 @@ LazyListView { Component.onCompleted: modelData?.lock(this) Component.onDestruction: modelData?.unlock(this) - LazyListView.preferredHeight: modelData?.closed ? 0 : notifInner.nonAnimHeight - LazyListView.visibleHeight: modelData?.closed ? 0 : notifInner.implicitHeight + LazyListView.preferredHeight: modelData?.closed || LazyListView.removing ? 0 : notifInner.nonAnimHeight + LazyListView.visibleHeight: modelData?.closed || LazyListView.removing ? 0 : notifInner.implicitHeight implicitHeight: notifInner.implicitHeight opacity: LazyListView.removing || LazyListView.adding ? 0 : 1 diff --git a/plugin/src/Caelestia/Components/lazylistview.cpp b/plugin/src/Caelestia/Components/lazylistview.cpp index c9291cfd..36b49301 100644 --- a/plugin/src/Caelestia/Components/lazylistview.cpp +++ b/plugin/src/Caelestia/Components/lazylistview.cpp @@ -367,9 +367,29 @@ void LazyListView::updatePolish() { it->pendingInsert = false; it->readyDelayStarted = false; - // Position correctly before making visible - if (idx >= 0 && idx < static_cast(m_layout.size())) - item->setY(m_layout[idx].targetY - m_contentY); + // 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 = @@ -378,6 +398,11 @@ void LazyListView::updatePolish() { 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(); }); }