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