test notif plugin #51

Merged
zach merged 2 commits from notif-plugin into main 2026-04-12 22:04:23 +02:00
12 changed files with 1567 additions and 223 deletions
+2 -1
View File
@@ -113,11 +113,12 @@ Singleton {
id: storage
path: `${Paths.state}/notifs.json`
printErrors: false
onLoadFailed: err => {
if (err === FileViewError.FileNotFound) {
root.loaded = true;
setText("[]");
Qt.callLater(() => setText("[]"));
}
}
onLoaded: {
+50 -13
View File
@@ -19,11 +19,12 @@ CustomRect {
required property var visibilities
color: {
const c = root.modelData.urgency === "critical" ? DynamicColors.palette.m3secondaryContainer : DynamicColors.layer(DynamicColors.palette.m3surfaceContainerHigh, 2);
const c = root.modelData?.urgency === "critical" ? DynamicColors.palette.m3secondaryContainer : DynamicColors.layer(DynamicColors.palette.m3surfaceContainerHigh, 2);
return expanded ? c : Qt.alpha(c, 0);
}
implicitHeight: nonAnimHeight
radius: 6
state: expanded ? "expanded" : ""
Behavior on implicitHeight {
Anim {
@@ -33,7 +34,6 @@ CustomRect {
}
states: State {
name: "expanded"
when: root.expanded
PropertyChanges {
compactBody.anchors.margins: 10
@@ -63,10 +63,10 @@ CustomRect {
anchors.left: parent.left
anchors.top: parent.top
color: root.modelData.urgency === "critical" ? DynamicColors.palette.m3onSecondaryContainer : DynamicColors.palette.m3onSurface
color: root.modelData?.urgency === "critical" ? DynamicColors.palette.m3onSecondaryContainer : DynamicColors.palette.m3onSurface
elide: Text.ElideRight
maximumLineCount: 1
text: root.modelData.summary
text: root.modelData?.summary ?? ""
width: parent.width
wrapMode: Text.WordWrap
}
@@ -76,7 +76,7 @@ CustomRect {
anchors.left: parent.left
anchors.top: parent.top
text: root.modelData.summary
text: root.modelData?.summary ?? ""
visible: false
}
@@ -90,9 +90,9 @@ CustomRect {
shouldBeActive: !root.expanded
sourceComponent: CustomText {
color: root.modelData.urgency === "critical" ? DynamicColors.palette.m3secondary : DynamicColors.palette.m3outline
color: root.modelData?.urgency === "critical" ? DynamicColors.palette.m3secondary : DynamicColors.palette.m3outline
elide: Text.ElideRight
text: root.modelData.body.replace(/\n/g, " ")
text: String(root.modelData?.body ?? "").replace(/\n/g, " ")
textFormat: Text.StyledText
}
}
@@ -108,7 +108,7 @@ CustomRect {
animate: true
color: DynamicColors.palette.m3outline
font.pointSize: 11
text: root.modelData.timeStr
text: root.modelData?.timeStr ?? ""
}
}
@@ -130,8 +130,8 @@ CustomRect {
id: body
Layout.fillWidth: true
color: root.modelData.urgency === "critical" ? DynamicColors.palette.m3secondary : DynamicColors.palette.m3onSurface
text: root.modelData.body.replace(/(.)\n(?!\n)/g, "$1\n\n") || qsTr("No body given")
color: root.modelData?.urgency === "critical" ? DynamicColors.palette.m3secondary : DynamicColors.palette.m3onSurface
text: String(root.modelData?.body ?? "").replace(/(.)\n(?!\n)/g, "$1\n\n") || qsTr("No body given")
textFormat: Text.MarkdownText
wrapMode: Text.WordWrap
@@ -148,14 +148,51 @@ CustomRect {
}
component WrappedLoader: Loader {
id: comp
required property bool shouldBeActive
active: opacity > 0
opacity: shouldBeActive ? 1 : 0
active: false
opacity: 0
states: State {
name: "active"
when: comp.shouldBeActive
PropertyChanges {
comp.active: true
comp.opacity: 1
}
}
transitions: [
Transition {
from: ""
to: "active"
SequentialAnimation {
PropertyAction {
property: "active"
}
Behavior on opacity {
Anim {
property: "opacity"
}
}
},
Transition {
from: "active"
to: ""
SequentialAnimation {
Anim {
property: "opacity"
}
PropertyAction {
property: "active"
}
}
}
]
}
}
@@ -35,7 +35,7 @@ Item {
{
isClose: true
},
...root.notif.actions,
...(root.notif?.actions ?? ""),
{
isCopy: true
}
@@ -97,7 +97,7 @@ Item {
id: actionInner
anchors.centerIn: parent
sourceComponent: action.modelData.isClose || action.modelData.isCopy ? iconBtn : root.notif.hasActionIcons ? iconComp : textComp
sourceComponent: action.modelData.isClose || action.modelData.isCopy ? iconBtn : root.notif?.hasActionIcons ? iconComp : textComp
}
Component {
+17 -10
View File
@@ -129,20 +129,27 @@ Item {
Timer {
id: clearTimer
interval: 50
interval: Math.max(15, Math.min(80, 69.8 - 12.3 * Math.log(NotifServer.notClosed.length)))
repeat: true
triggeredOnStart: true
onTriggered: {
let next = null;
for (let i = 0; i < notifList.repeater.count; i++) {
next = notifList.repeater.itemAt(i);
if (!next?.closed)
break;
}
if (next)
next.closeAll();
else
const first = NotifServer.notClosed[0];
if (!first) {
stop();
return;
}
const appName = first.appName;
let cleared = 0;
for (const n of NotifServer.notClosed.filter(n => n.appName === appName)) {
n.close();
cleared++;
if (cleared > 30) {
interval = 5;
return;
}
}
}
}
+75 -83
View File
@@ -1,60 +1,47 @@
pragma ComponentBehavior: Bound
import Quickshell
import QtQuick
import ZShell.Components
import qs.Components
import qs.Config
import qs.Modules
import qs.Daemons
import Quickshell
import QtQuick
Item {
LazyListView {
id: root
required property Flickable container
property bool flag
required property Props props
readonly property alias repeater: repeater
readonly property int spacing: 8
required property var visibilities
required property PersistentProperties visibilities
anchors.left: parent.left
anchors.right: parent.right
implicitHeight: {
const item = repeater.itemAt(repeater.count - 1);
return item ? item.y + item.implicitHeight : 0;
}
Repeater {
id: repeater
model: ScriptModel {
values: {
const map = new Map();
for (const n of NotifServer.notClosed)
map.set(n.appName, null);
for (const n of NotifServer.list)
map.set(n.appName, null);
return [...map.keys()];
}
onValuesChanged: root.flagChanged()
}
anchors.left: parent?.left
anchors.right: parent?.right
asynchronous: true
cacheBuffer: 400
implicitHeight: contentHeight
readyDelay: 1
removeDuration: Appearance.anim.durations.normal
spacing: Appearance.spacing.small
useCustomViewport: true
viewport: Qt.rect(0, container.contentY, width, container.height)
delegate: Component {
MouseArea {
id: notif
readonly property bool closed: notifInner.notifCount === 0
required property int index
required property string modelData
readonly property alias nonAnimHeight: notifInner.nonAnimHeight
property int startY
function closeAll(): void {
for (const n of NotifServer.notClosed.filter(n => n.appName === modelData)) {
n.close();
}
clearTimer.start();
}
LazyListView.preferredHeight: closed ? 0 : notifInner.nonAnimHeight
LazyListView.trackViewport: !notifInner.expanded && notifInner.nonAnimHeight < notifInner.implicitHeight
LazyListView.visibleHeight: notifInner.implicitHeight
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
cursorShape: pressed ? Qt.ClosedHandCursor : undefined
drag.axis: Drag.XAxis
@@ -62,36 +49,32 @@ Item {
enabled: !closed
hoverEnabled: true
implicitHeight: notifInner.implicitHeight
implicitWidth: root.width
opacity: LazyListView.removing || closed || LazyListView.adding ? 0 : 1
preventStealing: true
y: {
root.flag; // Force update
let y = 0;
for (let i = 0; i < index; i++) {
const item = repeater.itemAt(i);
if (!item.closed)
y += item.nonAnimHeight + root.spacing;
}
return y;
}
scale: LazyListView.removing || closed ? 0.6 : LazyListView.adding ? 0 : 1
containmentMask: QtObject {
function contains(p: point): bool {
if (!root.container.contains(notif.mapToItem(root.container, p)))
return false;
return notifInner.contains(p);
Behavior on opacity {
Anim {
}
}
Behavior on scale {
Anim {
duration: Appearance.anim.durations.expressiveDefaultSpatial
easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
}
}
Behavior on x {
Anim {
duration: MaterialEasing.expressiveEffectsTime
easing.bezierCurve: MaterialEasing.expressiveEffects
duration: Appearance.anim.durations.expressiveDefaultSpatial
easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
}
}
Behavior on y {
enabled: notif.LazyListView.ready
Anim {
duration: MaterialEasing.expressiveEffectsTime
easing.bezierCurve: MaterialEasing.expressiveEffects
duration: Appearance.anim.durations.expressiveDefaultSpatial
easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
}
}
@@ -116,39 +99,22 @@ Item {
closeAll();
}
ParallelAnimation {
running: true
Timer {
id: clearTimer
Anim {
from: 0
property: "opacity"
target: notif
to: 1
interval: 15
repeat: true
triggeredOnStart: true
onTriggered: {
const notifs = NotifServer.notClosed.filter(n => n.appName === notif.modelData);
if (notifs.length === 0) {
stop();
return;
}
Anim {
duration: MaterialEasing.expressiveEffectsTime
easing.bezierCurve: MaterialEasing.expressiveEffects
from: 0
property: "scale"
target: notif
to: 1
}
}
ParallelAnimation {
running: notif.closed
Anim {
property: "opacity"
target: notif
to: 0
}
Anim {
property: "scale"
target: notif
to: 0.6
for (const n of notifs.slice(0, 30))
n.close();
}
}
@@ -162,4 +128,30 @@ Item {
}
}
}
model: ScriptModel {
values: {
const map = new Map();
for (const n of NotifServer.notClosed)
map.set(n.appName, null);
for (const n of NotifServer.list)
map.set(n.appName, null);
return [...map.keys()];
}
}
onViewportAdjustNeeded: d => {
if (contentYAnim.running)
contentYAnim.complete();
contentYAnim.to = Math.max(0, container.contentY + d);
contentYAnim.start();
}
Anim {
id: contentYAnim
duration: Appearance.anim.durations.expressiveDefaultSpatial
easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
property: "contentY"
target: root.container
}
}
+1 -1
View File
@@ -20,7 +20,7 @@ CustomRect {
required property string modelData
readonly property int nonAnimHeight: {
const headerHeight = header.implicitHeight + (root.expanded ? Math.round(7 / 2) : 0);
const columnHeight = headerHeight + notifList.nonAnimHeight + column.Layout.topMargin + column.Layout.bottomMargin;
const columnHeight = headerHeight + notifList.layoutHeight + column.Layout.topMargin + column.Layout.bottomMargin;
return Math.round(Math.max(Config.notifs.sizes.image, columnHeight) + 10 * 2);
}
readonly property int notifCount: notifs.reduce((acc, n) => n.closed ? acc : acc + 1, 0)
+58 -111
View File
@@ -1,120 +1,59 @@
pragma ComponentBehavior: Bound
import Quickshell
import QtQuick
import QtQuick.Layouts
import ZShell.Components
import qs.Components
import qs.Config
import qs.Modules
import qs.Daemons
import Quickshell
import QtQuick
import QtQuick.Layouts
Item {
LazyListView {
id: root
required property Flickable container
required property bool expanded
property bool flag
readonly property real nonAnimHeight: {
let h = -root.spacing;
for (let i = 0; i < repeater.count; i++) {
const item = repeater.itemAt(i);
if (!item.modelData.closed && !item.previewHidden)
h += item.nonAnimHeight + root.spacing;
}
return h;
}
required property list<var> notifs
required property Props props
property bool showAllNotifs
readonly property int spacing: Math.round(7 / 2)
required property var visibilities
required property PersistentProperties visibilities
signal requestToggleExpand(expand: bool)
Layout.fillWidth: true
implicitHeight: nonAnimHeight
Behavior on implicitHeight {
Anim {
duration: MaterialEasing.expressiveEffectsTime
easing.bezierCurve: MaterialEasing.expressiveEffects
}
}
onExpandedChanged: {
if (expanded) {
clearTimer.stop();
showAllNotifs = true;
} else {
clearTimer.start();
}
}
Timer {
id: clearTimer
interval: MaterialEasing.standardTime
onTriggered: root.showAllNotifs = false
}
Repeater {
id: repeater
model: ScriptModel {
values: root.showAllNotifs ? root.notifs : root.notifs.slice(0, Config.notifs.groupPreviewNum + 1)
onValuesChanged: root.flagChanged()
asynchronous: true
cacheBuffer: 400
implicitHeight: contentHeight
readyDelay: 1
removeDuration: Appearance.anim.durations.normal
spacing: Math.round(Appearance.spacing.small / 2)
useCustomViewport: true
viewport: {
tWatcher.transform; // mapToItem is not reactive so use this to trigger updates
return Qt.rect(0, container.contentY - mapToItem(container.contentItem, 0, 0).y, width, container.height);
}
delegate: Component {
MouseArea {
id: notif
required property int index
required property NotifServer.Notif modelData
readonly property alias nonAnimHeight: notifInner.nonAnimHeight
readonly property bool previewHidden: {
if (root.expanded)
return false;
let extraHidden = 0;
for (let i = 0; i < index; i++)
if (root.notifs[i].closed)
extraHidden++;
return index >= Config.notifs.groupPreviewNum + extraHidden;
}
property int startY
LazyListView.preferredHeight: modelData?.closed || LazyListView.removing ? 0 : notifInner.nonAnimHeight
LazyListView.visibleHeight: modelData?.closed || LazyListView.removing ? 0 : notifInner.implicitHeight
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
cursorShape: notifInner.body?.hoveredLink ? Qt.PointingHandCursor : pressed ? Qt.ClosedHandCursor : undefined
drag.axis: Drag.XAxis
drag.target: this
enabled: !modelData.closed
enabled: !(modelData?.closed ?? true)
hoverEnabled: true
implicitHeight: notifInner.implicitHeight
implicitWidth: root.width
opacity: previewHidden ? 0 : 1
opacity: LazyListView.removing || LazyListView.adding ? 0 : 1
preventStealing: !root.expanded
scale: previewHidden ? 0.7 : 1
y: {
root.flag; // Force update
let y = 0;
for (let i = 0; i < index; i++) {
const item = repeater.itemAt(i);
if (!item.modelData.closed && !item.previewHidden)
y += item.nonAnimHeight + root.spacing;
}
return y;
}
scale: LazyListView.removing || LazyListView.adding ? 0.7 : 1
containmentMask: QtObject {
function contains(p: point): bool {
if (!root.container.contains(notif.mapToItem(root.container, p)))
return false;
return notifInner.contains(p);
}
}
Behavior on opacity {
Anim {
}
@@ -125,19 +64,21 @@ Item {
}
Behavior on x {
Anim {
duration: MaterialEasing.expressiveEffectsTime
easing.bezierCurve: MaterialEasing.expressiveEffects
duration: Appearance.anim.durations.expressiveDefaultSpatial
easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
}
}
Behavior on y {
enabled: notif.LazyListView.ready
Anim {
duration: MaterialEasing.expressiveEffectsTime
easing.bezierCurve: MaterialEasing.expressiveEffects
duration: Appearance.anim.durations.expressiveDefaultSpatial
easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
}
}
Component.onCompleted: modelData.lock(this)
Component.onDestruction: modelData.unlock(this)
Component.onCompleted: modelData?.lock(this)
Component.onDestruction: modelData?.unlock(this)
onPositionChanged: event => {
if (pressed && !root.expanded) {
const diffY = event.y - startY;
@@ -150,37 +91,19 @@ Item {
if (event.button === Qt.RightButton)
root.requestToggleExpand(!root.expanded);
else if (event.button === Qt.MiddleButton)
modelData.close();
modelData?.close();
}
onReleased: event => {
if (Math.abs(x) < width * Config.notifs.clearThreshold)
x = 0;
else
modelData.close();
modelData?.close();
}
ParallelAnimation {
Component.onCompleted: running = !notif.previewHidden
running: notif.modelData?.closed ?? false
Anim {
from: 0
property: "opacity"
target: notif
to: 1
}
Anim {
from: 0.7
property: "scale"
target: notif
to: 1
}
}
ParallelAnimation {
running: notif.modelData.closed
onFinished: notif.modelData.unlock(notif)
onFinished: notif.modelData?.unlock(notif)
Anim {
property: "opacity"
@@ -206,4 +129,28 @@ Item {
}
}
}
model: ScriptModel {
values: {
if (root.expanded)
return root.notifs;
let count = 0;
let i = 0;
const previewNum = Config.notifs.groupPreviewNum;
while (i < root.notifs.length && count < previewNum) {
if (!(root.notifs[i]?.closed ?? true))
count++;
i++;
}
return root.notifs.slice(0, i);
}
}
TransformWatcher {
id: tWatcher
a: root.container.contentItem
b: root
}
}
+1 -1
View File
@@ -13,7 +13,7 @@ CustomRect {
color: DynamicColors.tPalette.m3surfaceContainer
implicitHeight: Config.barConfig.height + Appearance.padding.smallest * 2
implicitWidth: contentRow.implicitWidth + Appearance.spacing.smaller
implicitWidth: contentRow.implicitWidth + Appearance.spacing.small * 2
radius: height / 2
RowLayout {
+1
View File
@@ -56,3 +56,4 @@ qml_module(ZShell
add_subdirectory(Models)
add_subdirectory(Internal)
add_subdirectory(Services)
add_subdirectory(Components)
+7
View File
@@ -0,0 +1,7 @@
qml_module(ZShell-components
URI ZShell.Components
SOURCES
lazylistview.hpp lazylistview.cpp
LIBRARIES
Qt::Quick
)
File diff suppressed because it is too large Load Diff
+243
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 ZShell::components {
class LazyListViewAttached : public QObject {
Q_OBJECT
Q_PROPERTY(qreal preferredHeight READ preferredHeight WRITE setPreferredHeight NOTIFY preferredHeightChanged)
Q_PROPERTY(qreal visibleHeight READ visibleHeight WRITE setVisibleHeight NOTIFY visibleHeightChanged)
Q_PROPERTY(bool ready READ ready NOTIFY readyChanged)
Q_PROPERTY(bool adding READ adding NOTIFY addingChanged)
Q_PROPERTY(bool removing READ removing NOTIFY removingChanged)
Q_PROPERTY(bool trackViewport READ trackViewport WRITE setTrackViewport NOTIFY trackViewportChanged)
public:
explicit LazyListViewAttached(QObject* parent = nullptr);
[[nodiscard]] qreal preferredHeight() const;
void setPreferredHeight(qreal height);
[[nodiscard]] qreal visibleHeight() const;
void setVisibleHeight(qreal height);
[[nodiscard]] bool ready() const;
void setReady(bool ready);
[[nodiscard]] bool adding() const;
void setAdding(bool adding);
[[nodiscard]] bool removing() const;
void setRemoving(bool removing);
[[nodiscard]] bool trackViewport() const;
void setTrackViewport(bool track);
signals:
void preferredHeightChanged();
void visibleHeightChanged();
void readyChanged();
void addingChanged();
void removingChanged();
void trackViewportChanged();
private:
qreal m_preferredHeight = -1;
qreal m_visibleHeight = -1;
bool m_ready = false;
bool m_adding = false;
bool m_removing = false;
bool m_trackViewport = false;
};
class LazyListView : public QQuickItem {
Q_OBJECT
QML_ELEMENT
QML_ATTACHED(LazyListViewAttached)
// Model & Delegate
Q_PROPERTY(QAbstractItemModel* model READ model WRITE setModel NOTIFY modelChanged)
Q_PROPERTY(QQmlComponent* delegate READ delegate WRITE setDelegate NOTIFY delegateChanged)
// Layout
Q_PROPERTY(qreal spacing READ spacing WRITE setSpacing NOTIFY spacingChanged)
Q_PROPERTY(qreal contentHeight READ contentHeight NOTIFY contentHeightChanged)
Q_PROPERTY(qreal layoutHeight READ layoutHeight NOTIFY layoutHeightChanged)
Q_PROPERTY(qreal contentY READ contentY WRITE setContentY NOTIFY contentYChanged)
// Viewport & Lazy Loading
Q_PROPERTY(QRectF viewport READ viewport WRITE setViewport NOTIFY viewportChanged)
Q_PROPERTY(bool useCustomViewport READ useCustomViewport WRITE setUseCustomViewport NOTIFY useCustomViewportChanged)
Q_PROPERTY(qreal cacheBuffer READ cacheBuffer WRITE setCacheBuffer NOTIFY cacheBufferChanged)
// Sizing
Q_PROPERTY(qreal estimatedHeight READ estimatedHeight WRITE setEstimatedHeight NOTIFY estimatedHeightChanged)
// Async
Q_PROPERTY(bool asynchronous READ asynchronous WRITE setAsynchronous NOTIFY asynchronousChanged)
// Animation Durations
Q_PROPERTY(int removeDuration READ removeDuration WRITE setRemoveDuration NOTIFY removeDurationChanged)
Q_PROPERTY(int readyDelay READ readyDelay WRITE setReadyDelay NOTIFY readyDelayChanged)
// State
Q_PROPERTY(int count READ count NOTIFY countChanged)
public:
explicit LazyListView(QQuickItem* parent = nullptr);
~LazyListView() override;
static LazyListViewAttached* qmlAttachedProperties(QObject* object);
// Model & Delegate
[[nodiscard]] QAbstractItemModel* model() const;
void setModel(QAbstractItemModel* model);
[[nodiscard]] QQmlComponent* delegate() const;
void setDelegate(QQmlComponent* delegate);
// Layout
[[nodiscard]] qreal spacing() const;
void setSpacing(qreal spacing);
[[nodiscard]] qreal contentHeight() const;
[[nodiscard]] qreal layoutHeight() const;
[[nodiscard]] qreal contentY() const;
void setContentY(qreal contentY);
// Viewport
[[nodiscard]] QRectF viewport() const;
void setViewport(const QRectF& viewport);
[[nodiscard]] bool useCustomViewport() const;
void setUseCustomViewport(bool use);
[[nodiscard]] qreal cacheBuffer() const;
void setCacheBuffer(qreal buffer);
// Sizing
[[nodiscard]] qreal estimatedHeight() const;
void setEstimatedHeight(qreal height);
// Async
[[nodiscard]] bool asynchronous() const;
void setAsynchronous(bool async);
// Animation Durations
[[nodiscard]] int removeDuration() const;
void setRemoveDuration(int duration);
[[nodiscard]] int readyDelay() const;
void setReadyDelay(int delay);
// State
[[nodiscard]] int count() const;
signals:
void modelChanged();
void delegateChanged();
void spacingChanged();
void contentHeightChanged();
void layoutHeightChanged();
void contentYChanged();
void viewportChanged();
void useCustomViewportChanged();
void cacheBufferChanged();
void estimatedHeightChanged();
void asynchronousChanged();
void removeDurationChanged();
void readyDelayChanged();
void countChanged();
void viewportAdjustNeeded(qreal delta);
protected:
void componentComplete() override;
void geometryChange(const QRectF& newGeometry, const QRectF& oldGeometry) override;
void updatePolish() override;
private:
struct ItemRecord {
qreal targetY = 0;
qreal height = 0;
bool heightKnown = false;
bool isNew = false;
};
struct DelegateEntry {
int modelIndex = -1;
QQuickItem* item = nullptr;
bool pendingRemoval = false;
bool pendingInsert = false;
bool readyDelayStarted = false;
};
// Layout
void relayout();
[[nodiscard]] std::pair<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 ZShell::components