Merge pull request #1375 from caelestia-dots/cxx-listview

Replace notif list inefficient fake list view with C++ lazy loaded list view
This commit is contained in:
2 * r + 2 * t 2026-04-10 16:18:09 +10:00 committed by GitHub
commit 0e07176ff1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 1695 additions and 347 deletions

View file

@ -22,13 +22,14 @@ StyledRect {
radius: Appearance.rounding.small radius: Appearance.rounding.small
color: { 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); return expanded ? c : Qt.alpha(c, 0);
} }
state: expanded ? "expanded" : ""
states: State { states: State {
name: "expanded" name: "expanded"
when: root.expanded
PropertyChanges { PropertyChanges {
summary.anchors.margins: Appearance.padding.normal summary.anchors.margins: Appearance.padding.normal
@ -61,8 +62,8 @@ StyledRect {
anchors.left: parent.left anchors.left: parent.left
width: parent.width width: parent.width
text: root.modelData.summary text: root.modelData?.summary ?? ""
color: root.modelData.urgency === "critical" ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurface color: root.modelData?.urgency === "critical" ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurface
elide: Text.ElideRight elide: Text.ElideRight
wrapMode: Text.WordWrap wrapMode: Text.WordWrap
maximumLineCount: 1 maximumLineCount: 1
@ -75,7 +76,7 @@ StyledRect {
anchors.left: parent.left anchors.left: parent.left
visible: false visible: false
text: root.modelData.summary text: root.modelData?.summary ?? ""
} }
WrappedLoader { WrappedLoader {
@ -88,8 +89,8 @@ StyledRect {
anchors.leftMargin: Appearance.spacing.small anchors.leftMargin: Appearance.spacing.small
sourceComponent: StyledText { 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 color: root.modelData?.urgency === "critical" ? Colours.palette.m3secondary : Colours.palette.m3outline
elide: Text.ElideRight elide: Text.ElideRight
} }
} }
@ -103,7 +104,7 @@ StyledRect {
sourceComponent: StyledText { sourceComponent: StyledText {
animate: true animate: true
text: root.modelData.timeStr text: root.modelData?.timeStr ?? ""
color: Colours.palette.m3outline color: Colours.palette.m3outline
font.pointSize: Appearance.font.size.small font.pointSize: Appearance.font.size.small
} }
@ -138,8 +139,8 @@ StyledRect {
Layout.fillWidth: true Layout.fillWidth: true
textFormat: Text.MarkdownText 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 color: root.modelData?.urgency === "critical" ? Colours.palette.m3secondary : Colours.palette.m3outline
wrapMode: Text.WordWrap wrapMode: Text.WordWrap
onLinkActivated: link => { onLinkActivated: link => {
@ -154,14 +155,51 @@ StyledRect {
} }
component WrappedLoader: Loader { component WrappedLoader: Loader {
id: comp
required property bool shouldBeActive required property bool shouldBeActive
asynchronous: true active: false
opacity: shouldBeActive ? 1 : 0 opacity: 0
active: opacity > 0
Behavior on opacity { // Makes the loader load on the same frame shouldBeActive becomes true, which ensures size is set
Anim {} 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"
}
}
}
]
}
} }

View file

@ -101,7 +101,7 @@ Item {
{ {
isClose: true isClose: true
}, },
...root.notif.actions, ...(root.notif?.actions ?? []),
{ {
isCopy: true isCopy: true
} }
@ -150,7 +150,7 @@ Item {
id: actionInner id: actionInner
anchors.centerIn: parent 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 { Component {

View file

@ -151,18 +151,24 @@ Item {
id: clearTimer id: clearTimer
repeat: true repeat: true
interval: 50 triggeredOnStart: true
interval: Math.max(15, Math.min(80, 69.8 - 12.3 * Math.log(Notifs.notClosed.length)))
onTriggered: { onTriggered: {
let next = null; const first = Notifs.notClosed[0];
for (let i = 0; i < notifList.repeater.count; i++) { if (!first) {
next = notifList.repeater.itemAt(i);
if (!next?.closed) // qmllint disable missing-property
break;
}
if (next) {
next.closeAll(); // qmllint disable missing-property
} else {
stop(); 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;
}
} }
} }
} }

View file

@ -2,30 +2,38 @@ pragma ComponentBehavior: Bound
import QtQuick import QtQuick
import Quickshell import Quickshell
import Caelestia.Components
import qs.components import qs.components
import qs.services import qs.services
import qs.config import qs.config
Item { LazyListView {
id: root id: root
required property Props props required property Props props
required property Flickable container required property Flickable container
required property DrawerVisibilities visibilities required property DrawerVisibilities visibilities
readonly property alias repeater: repeater anchors.left: parent?.left
readonly property int spacing: Appearance.spacing.small anchors.right: parent?.right
property bool flag implicitHeight: contentHeight
anchors.left: parent.left spacing: Appearance.spacing.small
anchors.right: parent.right readyDelay: 1
implicitHeight: { cacheBuffer: 400
const item = repeater.itemAt(repeater.count - 1); asynchronous: true
return item ? item.y + item.implicitHeight : 0;
onViewportAdjustNeeded: d => {
if (contentYAnim.running)
contentYAnim.complete();
contentYAnim.to = Math.max(0, container.contentY + d);
contentYAnim.start();
} }
Repeater { useCustomViewport: true
id: repeater viewport: Qt.rect(0, container.contentY, width, container.height)
removeDuration: Appearance.anim.durations.normal
model: ScriptModel { model: ScriptModel {
values: { values: {
@ -36,49 +44,30 @@ Item {
map.set(n.appName, null); map.set(n.appName, null);
return [...map.keys()]; return [...map.keys()];
} }
onValuesChanged: root.flagChanged()
} }
delegate: NotifGroupDelegate {} delegate: Component {
} MouseArea {
component NotifGroupDelegate: MouseArea {
id: notif id: notif
required property int index required property int index
required property string modelData required property string modelData
readonly property bool closed: notifInner.notifCount === 0 readonly property bool closed: notifInner.notifCount === 0
readonly property alias nonAnimHeight: notifInner.nonAnimHeight
property int startY property int startY
function closeAll(): void { function closeAll(): void {
for (const n of Notifs.notClosed.filter(n => n.appName === modelData)) clearTimer.start();
n.close();
} }
y: { LazyListView.trackViewport: !notifInner.expanded && notifInner.nonAnimHeight < notifInner.implicitHeight
root.flag; // Force update LazyListView.preferredHeight: closed ? 0 : notifInner.nonAnimHeight
let y = 0; LazyListView.visibleHeight: notifInner.implicitHeight
for (let i = 0; i < index; i++) {
const item = repeater.itemAt(i) as NotifGroupDelegate;
if (item && !item.closed)
y += item.nonAnimHeight + root.spacing;
}
return y;
}
containmentMask: QtObject {
function contains(p: point): bool {
if (!root.container.contains(notif.mapToItem(root.container, p)))
return false;
return notifInner.contains(p);
}
}
implicitWidth: root.width
implicitHeight: notifInner.implicitHeight implicitHeight: notifInner.implicitHeight
opacity: LazyListView.removing || closed || LazyListView.adding ? 0 : 1
scale: LazyListView.removing || closed ? 0.6 : LazyListView.adding ? 0 : 1
hoverEnabled: true hoverEnabled: true
cursorShape: pressed ? Qt.ClosedHandCursor : undefined cursorShape: pressed ? Qt.ClosedHandCursor : undefined
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
@ -109,37 +98,21 @@ Item {
closeAll(); closeAll();
} }
ParallelAnimation { Timer {
running: true id: clearTimer
Anim { interval: 15
target: notif repeat: true
property: "opacity" triggeredOnStart: true
from: 0 onTriggered: {
to: 1 const notifs = Notifs.notClosed.filter(n => n.appName === notif.modelData);
} if (notifs.length === 0) {
Anim { stop();
target: notif return;
property: "scale"
from: 0
to: 1
duration: Appearance.anim.durations.expressiveDefaultSpatial
easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
}
} }
ParallelAnimation { for (const n of notifs.slice(0, 30))
running: notif.closed n.close();
Anim {
target: notif
property: "opacity"
to: 0
}
Anim {
target: notif
property: "scale"
to: 0.6
} }
} }
@ -152,18 +125,41 @@ Item {
visibilities: root.visibilities visibilities: root.visibilities
} }
Behavior on x { Behavior on y {
enabled: notif.LazyListView.ready
Anim { Anim {
duration: Appearance.anim.durations.expressiveDefaultSpatial duration: Appearance.anim.durations.expressiveDefaultSpatial
easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
} }
} }
Behavior on y { Behavior on opacity {
Anim {}
}
Behavior on scale {
Anim {
duration: Appearance.anim.durations.expressiveDefaultSpatial
easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
}
}
Behavior on x {
Anim { Anim {
duration: Appearance.anim.durations.expressiveDefaultSpatial duration: Appearance.anim.durations.expressiveDefaultSpatial
easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
} }
} }
} }
}
Anim {
id: contentYAnim
target: root.container
property: "contentY"
duration: Appearance.anim.durations.expressiveDefaultSpatial
easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
}
} }

View file

@ -52,7 +52,7 @@ StyledRect {
readonly property int nonAnimHeight: { readonly property int nonAnimHeight: {
const headerHeight = header.implicitHeight + (root.expanded ? Math.round(Appearance.spacing.small / 2) : 0); 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); return Math.round(Math.max(Config.notifs.sizes.image, columnHeight) + Appearance.padding.normal * 2);
} }
readonly property bool expanded: props.expandedNotifs.includes(modelData) readonly property bool expanded: props.expandedNotifs.includes(modelData)

View file

@ -3,11 +3,12 @@ pragma ComponentBehavior: Bound
import QtQuick import QtQuick
import QtQuick.Layouts import QtQuick.Layouts
import Quickshell import Quickshell
import Caelestia.Components
import qs.components import qs.components
import qs.services import qs.services
import qs.config import qs.config
Item { LazyListView {
id: root id: root
required property Props props required property Props props
@ -16,109 +17,66 @@ Item {
required property Flickable container required property Flickable container
required property DrawerVisibilities visibilities 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)
property bool showAllNotifs
property bool flag
signal requestToggleExpand(expand: bool) signal requestToggleExpand(expand: bool)
onExpandedChanged: {
if (expanded) {
clearTimer.stop();
showAllNotifs = true;
} else {
clearTimer.start();
}
}
Layout.fillWidth: true Layout.fillWidth: true
implicitHeight: nonAnimHeight implicitHeight: contentHeight
Timer { spacing: Math.round(Appearance.spacing.small / 2)
id: clearTimer asynchronous: true
interval: Appearance.anim.durations.normal readyDelay: 1
onTriggered: root.showAllNotifs = false cacheBuffer: 400
removeDuration: Appearance.anim.durations.normal
useCustomViewport: true
viewport: {
tWatcher.transform; // mapToItem is not reactive so use this to trigger updates
return Qt.rect(0, container.contentY - mapToItem(container.contentItem, 0, 0).y, width, container.height);
} }
Repeater {
id: repeater
model: ScriptModel { model: ScriptModel {
values: root.showAllNotifs ? root.notifs : root.notifs.slice(0, Config.notifs.groupPreviewNum + 1) values: {
onValuesChanged: root.flagChanged() if (root.expanded)
return root.notifs;
let count = 0;
let i = 0;
const previewNum = Config.notifs.groupPreviewNum;
while (i < root.notifs.length && count < previewNum) {
if (!(root.notifs[i]?.closed ?? true))
count++;
i++;
} }
delegate: NotifDelegate {} return root.notifs.slice(0, i);
}
Behavior on implicitHeight {
Anim {
duration: Appearance.anim.durations.expressiveDefaultSpatial
easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
} }
} }
component NotifDelegate: MouseArea { delegate: Component {
MouseArea {
id: notif id: notif
required property int index required property int index
required property NotifData modelData 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 property int startY
y: { Component.onCompleted: modelData?.lock(this)
root.flag; // Force update Component.onDestruction: modelData?.unlock(this)
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 { LazyListView.preferredHeight: modelData?.closed || LazyListView.removing ? 0 : notifInner.nonAnimHeight
function contains(p: point): bool { LazyListView.visibleHeight: modelData?.closed || LazyListView.removing ? 0 : notifInner.implicitHeight
if (!root.container.contains(notif.mapToItem(root.container, p)))
return false;
return notifInner.contains(p);
}
}
opacity: previewHidden ? 0 : 1
scale: previewHidden ? 0.7 : 1
implicitWidth: root.width
implicitHeight: notifInner.implicitHeight implicitHeight: notifInner.implicitHeight
opacity: LazyListView.removing || LazyListView.adding ? 0 : 1
scale: LazyListView.removing || LazyListView.adding ? 0.7 : 1
hoverEnabled: true hoverEnabled: true
cursorShape: notifInner.body?.hoveredLink ? Qt.PointingHandCursor : pressed ? Qt.ClosedHandCursor : undefined cursorShape: notifInner.body?.hoveredLink ? Qt.PointingHandCursor : pressed ? Qt.ClosedHandCursor : undefined
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
preventStealing: !root.expanded preventStealing: !root.expanded
enabled: !modelData.closed enabled: !(modelData?.closed ?? true)
drag.target: this drag.target: this
drag.axis: Drag.XAxis drag.axis: Drag.XAxis
@ -128,7 +86,7 @@ Item {
if (event.button === Qt.RightButton) if (event.button === Qt.RightButton)
root.requestToggleExpand(!root.expanded); root.requestToggleExpand(!root.expanded);
else if (event.button === Qt.MiddleButton) else if (event.button === Qt.MiddleButton)
modelData.close(); modelData?.close();
} }
onPositionChanged: event => { onPositionChanged: event => {
if (pressed && !root.expanded) { if (pressed && !root.expanded) {
@ -141,32 +99,12 @@ Item {
if (Math.abs(x) < width * Config.notifs.clearThreshold) if (Math.abs(x) < width * Config.notifs.clearThreshold)
x = 0; x = 0;
else else
modelData.close(); 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
}
Anim {
target: notif
property: "scale"
from: 0.7
to: 1
}
} }
ParallelAnimation { ParallelAnimation {
running: notif.modelData.closed running: notif.modelData?.closed ?? false
onFinished: notif.modelData.unlock(notif) onFinished: notif.modelData?.unlock(notif)
Anim { Anim {
target: notif target: notif
@ -190,6 +128,15 @@ Item {
visibilities: root.visibilities visibilities: root.visibilities
} }
Behavior on y {
enabled: notif.LazyListView.ready
Anim {
duration: Appearance.anim.durations.expressiveDefaultSpatial
easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
}
}
Behavior on opacity { Behavior on opacity {
Anim {} Anim {}
} }
@ -204,12 +151,13 @@ Item {
easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
} }
} }
}
}
Behavior on y { TransformWatcher {
Anim { id: tWatcher
duration: Appearance.anim.durations.expressiveDefaultSpatial
easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial a: root.container.contentItem
} b: root
}
} }
} }

View file

@ -57,6 +57,7 @@ qml_module(caelestia
PkgConfig::Qalculate PkgConfig::Qalculate
) )
add_subdirectory(Components)
add_subdirectory(Internal) add_subdirectory(Internal)
add_subdirectory(Models) add_subdirectory(Models)
add_subdirectory(Services) add_subdirectory(Services)

View file

@ -0,0 +1,7 @@
qml_module(caelestia-components
URI Caelestia.Components
SOURCES
lazylistview.hpp lazylistview.cpp
LIBRARIES
Qt::Quick
)

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,243 @@
#pragma once
#include <qabstractitemmodel.h>
#include <qhash.h>
#include <qobject.h>
#include <qqmlcomponent.h>
#include <qqmlintegration.h>
#include <qquickitem.h>
#include <qrect.h>
#include <qvector.h>
namespace caelestia::components {
class LazyListViewAttached : public QObject {
Q_OBJECT
Q_PROPERTY(qreal preferredHeight READ preferredHeight WRITE setPreferredHeight NOTIFY preferredHeightChanged)
Q_PROPERTY(qreal visibleHeight READ visibleHeight WRITE setVisibleHeight NOTIFY visibleHeightChanged)
Q_PROPERTY(bool ready READ ready NOTIFY readyChanged)
Q_PROPERTY(bool adding READ adding NOTIFY addingChanged)
Q_PROPERTY(bool removing READ removing NOTIFY removingChanged)
Q_PROPERTY(bool trackViewport READ trackViewport WRITE setTrackViewport NOTIFY trackViewportChanged)
public:
explicit LazyListViewAttached(QObject* parent = nullptr);
[[nodiscard]] qreal preferredHeight() const;
void setPreferredHeight(qreal height);
[[nodiscard]] qreal visibleHeight() const;
void setVisibleHeight(qreal height);
[[nodiscard]] bool ready() const;
void setReady(bool ready);
[[nodiscard]] bool adding() const;
void setAdding(bool adding);
[[nodiscard]] bool removing() const;
void setRemoving(bool removing);
[[nodiscard]] bool trackViewport() const;
void setTrackViewport(bool track);
signals:
void preferredHeightChanged();
void visibleHeightChanged();
void readyChanged();
void addingChanged();
void removingChanged();
void trackViewportChanged();
private:
qreal m_preferredHeight = -1;
qreal m_visibleHeight = -1;
bool m_ready = false;
bool m_adding = false;
bool m_removing = false;
bool m_trackViewport = false;
};
class LazyListView : public QQuickItem {
Q_OBJECT
QML_ELEMENT
QML_ATTACHED(LazyListViewAttached)
// Model & Delegate
Q_PROPERTY(QAbstractItemModel* model READ model WRITE setModel NOTIFY modelChanged)
Q_PROPERTY(QQmlComponent* delegate READ delegate WRITE setDelegate NOTIFY delegateChanged)
// Layout
Q_PROPERTY(qreal spacing READ spacing WRITE setSpacing NOTIFY spacingChanged)
Q_PROPERTY(qreal contentHeight READ contentHeight NOTIFY contentHeightChanged)
Q_PROPERTY(qreal layoutHeight READ layoutHeight NOTIFY layoutHeightChanged)
Q_PROPERTY(qreal contentY READ contentY WRITE setContentY NOTIFY contentYChanged)
// Viewport & Lazy Loading
Q_PROPERTY(QRectF viewport READ viewport WRITE setViewport NOTIFY viewportChanged)
Q_PROPERTY(bool useCustomViewport READ useCustomViewport WRITE setUseCustomViewport NOTIFY useCustomViewportChanged)
Q_PROPERTY(qreal cacheBuffer READ cacheBuffer WRITE setCacheBuffer NOTIFY cacheBufferChanged)
// Sizing
Q_PROPERTY(qreal estimatedHeight READ estimatedHeight WRITE setEstimatedHeight NOTIFY estimatedHeightChanged)
// Async
Q_PROPERTY(bool asynchronous READ asynchronous WRITE setAsynchronous NOTIFY asynchronousChanged)
// Animation Durations
Q_PROPERTY(int removeDuration READ removeDuration WRITE setRemoveDuration NOTIFY removeDurationChanged)
Q_PROPERTY(int readyDelay READ readyDelay WRITE setReadyDelay NOTIFY readyDelayChanged)
// State
Q_PROPERTY(int count READ count NOTIFY countChanged)
public:
explicit LazyListView(QQuickItem* parent = nullptr);
~LazyListView() override;
static LazyListViewAttached* qmlAttachedProperties(QObject* object);
// Model & Delegate
[[nodiscard]] QAbstractItemModel* model() const;
void setModel(QAbstractItemModel* model);
[[nodiscard]] QQmlComponent* delegate() const;
void setDelegate(QQmlComponent* delegate);
// Layout
[[nodiscard]] qreal spacing() const;
void setSpacing(qreal spacing);
[[nodiscard]] qreal contentHeight() const;
[[nodiscard]] qreal layoutHeight() const;
[[nodiscard]] qreal contentY() const;
void setContentY(qreal contentY);
// Viewport
[[nodiscard]] QRectF viewport() const;
void setViewport(const QRectF& viewport);
[[nodiscard]] bool useCustomViewport() const;
void setUseCustomViewport(bool use);
[[nodiscard]] qreal cacheBuffer() const;
void setCacheBuffer(qreal buffer);
// Sizing
[[nodiscard]] qreal estimatedHeight() const;
void setEstimatedHeight(qreal height);
// Async
[[nodiscard]] bool asynchronous() const;
void setAsynchronous(bool async);
// Animation Durations
[[nodiscard]] int removeDuration() const;
void setRemoveDuration(int duration);
[[nodiscard]] int readyDelay() const;
void setReadyDelay(int delay);
// State
[[nodiscard]] int count() const;
signals:
void modelChanged();
void delegateChanged();
void spacingChanged();
void contentHeightChanged();
void layoutHeightChanged();
void contentYChanged();
void viewportChanged();
void useCustomViewportChanged();
void cacheBufferChanged();
void estimatedHeightChanged();
void asynchronousChanged();
void removeDurationChanged();
void readyDelayChanged();
void countChanged();
void viewportAdjustNeeded(qreal delta);
protected:
void componentComplete() override;
void geometryChange(const QRectF& newGeometry, const QRectF& oldGeometry) override;
void updatePolish() override;
private:
struct ItemRecord {
qreal targetY = 0;
qreal height = 0;
bool heightKnown = false;
bool isNew = false;
};
struct DelegateEntry {
int modelIndex = -1;
QQuickItem* item = nullptr;
bool pendingRemoval = false;
bool pendingInsert = false;
bool readyDelayStarted = false;
};
// Layout
void relayout();
[[nodiscard]] std::pair<int, int> computeVisibleRange() const;
[[nodiscard]] QRectF effectiveViewport() const;
[[nodiscard]] qreal effectiveEstimatedHeight() const;
[[nodiscard]] static qreal delegateHeight(QQuickItem* item);
[[nodiscard]] static qreal delegateVisibleHeight(QQuickItem* item);
[[nodiscard]] static bool isDelegateReady(QQuickItem* item);
void trackHeight(qreal height);
void untrackHeight(qreal height);
// Delegate lifecycle
void syncDelegates();
DelegateEntry createDelegate(int modelIndex);
void destroyDelegate(DelegateEntry& entry);
void updateDelegateData(DelegateEntry& entry);
// Model connection
void connectModel();
void disconnectModel();
void resetContent();
void onRowsInserted(const QModelIndex& parent, int first, int last);
void onRowsAboutToBeRemoved(const QModelIndex& parent, int first, int last);
void onRowsRemoved(const QModelIndex& parent, int first, int last);
void onRowsMoved(const QModelIndex& parent, int start, int end, const QModelIndex& destination, int row);
void onDataChanged(const QModelIndex& topLeft, const QModelIndex& bottomRight, const QList<int>& roles);
void onModelReset();
// Members
QAbstractItemModel* m_model = nullptr;
QQmlComponent* m_delegate = nullptr;
qreal m_spacing = 0;
qreal m_contentHeight = 0;
qreal m_layoutHeight = 0;
qreal m_contentY = 0;
QRectF m_viewport;
bool m_useCustomViewport = false;
qreal m_cacheBuffer = 0;
qreal m_estimatedHeight = -1;
qreal m_knownHeightSum = 0;
int m_knownHeightCount = 0;
bool m_asynchronous = false;
int m_removeDuration = 300;
int m_readyDelay = 0;
QVector<ItemRecord> m_layout;
QHash<int, DelegateEntry> m_delegates;
QHash<QQuickItem*, int> m_itemToIndex;
QVector<DelegateEntry> m_dyingDelegates;
bool m_componentComplete = false;
bool m_relayoutPending = false;
QList<QMetaObject::Connection> m_modelConnections;
};
} // namespace caelestia::components

View file

@ -105,6 +105,7 @@ Singleton {
FileView { FileView {
id: storage id: storage
printErrors: false
path: `${Paths.state}/notifs.json` path: `${Paths.state}/notifs.json`
onLoaded: { onLoaded: {
const data = JSON.parse(text()); const data = JSON.parse(text());
@ -116,7 +117,7 @@ Singleton {
onLoadFailed: err => { onLoadFailed: err => {
if (err === FileViewError.FileNotFound) { if (err === FileViewError.FileNotFound) {
root.loaded = true; root.loaded = true;
setText("[]"); Qt.callLater(() => setText("[]"));
} }
} }
} }