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
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);
}
state: expanded ? "expanded" : ""
states: State {
name: "expanded"
when: root.expanded
PropertyChanges {
summary.anchors.margins: Appearance.padding.normal
@ -61,8 +62,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 +76,7 @@ StyledRect {
anchors.left: parent.left
visible: false
text: root.modelData.summary
text: root.modelData?.summary ?? ""
}
WrappedLoader {
@ -88,8 +89,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: String(root.modelData?.body ?? "").replace(/\n/g, " ")
color: root.modelData?.urgency === "critical" ? Colours.palette.m3secondary : Colours.palette.m3outline
elide: Text.ElideRight
}
}
@ -103,7 +104,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 +139,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: 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
onLinkActivated: link => {
@ -154,14 +155,51 @@ StyledRect {
}
component WrappedLoader: Loader {
id: comp
required property bool shouldBeActive
asynchronous: true
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"
}
}
}
]
}
}

View file

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

View file

@ -151,18 +151,24 @@ Item {
id: clearTimer
repeat: true
interval: 50
triggeredOnStart: true
interval: Math.max(15, Math.min(80, 69.8 - 12.3 * Math.log(Notifs.notClosed.length)))
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
} else {
const first = Notifs.notClosed[0];
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;
}
}
}
}

View file

@ -2,168 +2,164 @@ 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
readyDelay: 1
cacheBuffer: 400
asynchronous: true
onViewportAdjustNeeded: d => {
if (contentYAnim.running)
contentYAnim.complete();
contentYAnim.to = Math.max(0, container.contentY + d);
contentYAnim.start();
}
Repeater {
id: repeater
useCustomViewport: true
viewport: Qt.rect(0, container.contentY, width, container.height)
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()];
}
onValuesChanged: root.flagChanged()
removeDuration: Appearance.anim.durations.normal
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()];
}
delegate: NotifGroupDelegate {}
}
component NotifGroupDelegate: MouseArea {
id: notif
delegate: Component {
MouseArea {
id: notif
required property int index
required property string modelData
required property int index
required property string modelData
readonly property bool closed: notifInner.notifCount === 0
readonly property alias nonAnimHeight: notifInner.nonAnimHeight
property int startY
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();
}
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;
function closeAll(): void {
clearTimer.start();
}
return y;
}
containmentMask: QtObject {
function contains(p: point): bool {
if (!root.container.contains(notif.mapToItem(root.container, p)))
return false;
return notifInner.contains(p);
LazyListView.trackViewport: !notifInner.expanded && notifInner.nonAnimHeight < notifInner.implicitHeight
LazyListView.preferredHeight: closed ? 0 : notifInner.nonAnimHeight
LazyListView.visibleHeight: 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
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);
}
}
onReleased: event => {
if (Math.abs(x) < width * Config.notifs.clearThreshold)
x = 0;
else
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
modelData: notif.modelData
props: root.props
container: root.container
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 {
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
easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
}
}
}
}
implicitWidth: root.width
implicitHeight: notifInner.implicitHeight
Anim {
id: contentYAnim
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);
}
}
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
}
Anim {
target: notif
property: "scale"
from: 0
to: 1
duration: Appearance.anim.durations.expressiveDefaultSpatial
easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
}
}
ParallelAnimation {
running: notif.closed
Anim {
target: notif
property: "opacity"
to: 0
}
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
}
}
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: {
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)

View file

@ -3,11 +3,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,200 +17,147 @@ 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)
property bool showAllNotifs
property bool flag
signal requestToggleExpand(expand: bool)
onExpandedChanged: {
if (expanded) {
clearTimer.stop();
showAllNotifs = true;
} else {
clearTimer.start();
}
}
Layout.fillWidth: true
implicitHeight: nonAnimHeight
implicitHeight: contentHeight
Timer {
id: clearTimer
spacing: Math.round(Appearance.spacing.small / 2)
asynchronous: true
interval: Appearance.anim.durations.normal
onTriggered: root.showAllNotifs = false
readyDelay: 1
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 {
values: root.showAllNotifs ? root.notifs : root.notifs.slice(0, Config.notifs.groupPreviewNum + 1)
onValuesChanged: root.flagChanged()
}
delegate: NotifDelegate {}
}
Behavior on implicitHeight {
Anim {
duration: Appearance.anim.durations.expressiveDefaultSpatial
easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
}
}
component NotifDelegate: MouseArea {
id: notif
required property int index
required property NotifData modelData
readonly property alias nonAnimHeight: notifInner.nonAnimHeight
readonly property bool previewHidden: {
model: ScriptModel {
values: {
if (root.expanded)
return false;
return root.notifs;
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;
let count = 0;
let i = 0;
const previewNum = Config.notifs.groupPreviewNum;
while (i < root.notifs.length && count < previewNum) {
if (!(root.notifs[i]?.closed ?? true))
count++;
i++;
}
return y;
}
containmentMask: QtObject {
function contains(p: point): bool {
if (!root.container.contains(notif.mapToItem(root.container, p)))
return false;
return notifInner.contains(p);
return root.notifs.slice(0, i);
}
}
delegate: Component {
MouseArea {
id: notif
required property int index
required property NotifData modelData
property int startY
Component.onCompleted: modelData?.lock(this)
Component.onDestruction: modelData?.unlock(this)
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
scale: LazyListView.removing || LazyListView.adding ? 0.7 : 1
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)
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();
}
onPositionChanged: event => {
if (pressed && !root.expanded) {
const diffY = event.y - startY;
if (Math.abs(diffY) > Config.notifs.expandThreshold)
root.requestToggleExpand(diffY > 0);
}
}
onReleased: event => {
if (Math.abs(x) < width * Config.notifs.clearThreshold)
x = 0;
else
modelData?.close();
}
ParallelAnimation {
running: notif.modelData?.closed ?? false
onFinished: notif.modelData?.unlock(notif)
Anim {
target: notif
property: "opacity"
to: 0
}
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
}
Behavior on y {
enabled: notif.LazyListView.ready
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
}
}
}
}
opacity: previewHidden ? 0 : 1
scale: previewHidden ? 0.7 : 1
TransformWatcher {
id: tWatcher
implicitWidth: root.width
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
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();
}
onPositionChanged: event => {
if (pressed && !root.expanded) {
const diffY = event.y - startY;
if (Math.abs(diffY) > Config.notifs.expandThreshold)
root.requestToggleExpand(diffY > 0);
}
}
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
}
Anim {
target: notif
property: "scale"
from: 0.7
to: 1
}
}
ParallelAnimation {
running: notif.modelData.closed
onFinished: notif.modelData.unlock(notif)
Anim {
target: notif
property: "opacity"
to: 0
}
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
}
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
}
}
a: root.container.contentItem
b: root
}
}

View file

@ -57,6 +57,7 @@ qml_module(caelestia
PkgConfig::Qalculate
)
add_subdirectory(Components)
add_subdirectory(Internal)
add_subdirectory(Models)
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 {
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("[]"));
}
}
}