feat: replace notif group list with lazy list view

This commit is contained in:
2 * r + 2 * t 2026-04-03 17:59:27 +11:00
parent cf8b68be3b
commit 20983ffa6e
3 changed files with 146 additions and 163 deletions

View file

@ -1,13 +1,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,19 +15,8 @@ Item {
required property Flickable container required property Flickable container
required property DrawerVisibilities visibilities required property DrawerVisibilities visibilities
readonly property real nonAnimHeight: { readonly property real nonAnimHeight: layoutHeight
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 showAllNotifs
property bool flag
signal requestToggleExpand(expand: bool) signal requestToggleExpand(expand: bool)
@ -42,7 +30,15 @@ Item {
} }
Layout.fillWidth: true Layout.fillWidth: true
implicitHeight: nonAnimHeight implicitHeight: contentHeight
spacing: Math.round(Appearance.spacing.small / 2)
removeDuration: Appearance.anim.durations.normal
useCustomViewport: true
viewport: Qt.rect(0, container.contentY - mapToItem(container.contentItem, 0, 0).y,
width, container.height)
Timer { Timer {
id: clearTimer id: clearTimer
@ -51,164 +47,116 @@ Item {
onTriggered: root.showAllNotifs = false onTriggered: root.showAllNotifs = false
} }
Repeater { model: ScriptModel {
id: repeater values: root.showAllNotifs ? root.notifs : root.notifs.slice(0, Config.notifs.groupPreviewNum + 1)
model: ScriptModel {
values: root.showAllNotifs ? root.notifs : root.notifs.slice(0, Config.notifs.groupPreviewNum + 1)
onValuesChanged: root.flagChanged()
}
delegate: NotifDelegate {}
} }
Behavior on implicitHeight { delegate: Component {
Anim { MouseArea {
duration: Appearance.anim.durations.expressiveDefaultSpatial id: notif
easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
}
}
component NotifDelegate: MouseArea { required property int index
id: notif required property NotifData modelData
required property int index readonly property bool previewHidden: {
required property NotifData modelData if (root.expanded)
readonly property alias nonAnimHeight: notifInner.nonAnimHeight
readonly property bool previewHidden: {
if (root.expanded)
return false;
let extraHidden = 0;
for (let i = 0; i < index; i++)
if (root.notifs[i].closed)
extraHidden++;
return index >= Config.notifs.groupPreviewNum + extraHidden;
}
property int startY
y: {
root.flag; // Force update
let y = 0;
for (let i = 0; i < index; i++) {
const item = repeater.itemAt(i) as NotifDelegate;
if (item && !item.modelData.closed && !item.previewHidden)
y += item.nonAnimHeight + root.spacing;
}
return y;
}
containmentMask: QtObject {
function contains(p: point): bool {
if (!root.container.contains(notif.mapToItem(root.container, p)))
return false; return false;
return notifInner.contains(p);
let extraHidden = 0;
for (let i = 0; i < index; i++)
if (root.notifs[i]?.closed)
extraHidden++;
return index >= Config.notifs.groupPreviewNum + extraHidden;
} }
} property int startY
opacity: previewHidden ? 0 : 1 Component.onCompleted: modelData?.lock(this)
scale: previewHidden ? 0.7 : 1 Component.onDestruction: modelData?.unlock(this)
implicitWidth: root.width LazyListView.preferredHeight: modelData?.closed || previewHidden ? 0 : notifInner.nonAnimHeight
implicitHeight: notifInner.implicitHeight LazyListView.visibleHeight: modelData?.closed || previewHidden ? 0 : notifInner.implicitHeight
implicitHeight: notifInner.implicitHeight
hoverEnabled: true opacity: LazyListView.removing || modelData?.closed || previewHidden || LazyListView.adding ? 0 : 1
cursorShape: notifInner.body?.hoveredLink ? Qt.PointingHandCursor : pressed ? Qt.ClosedHandCursor : undefined scale: LazyListView.removing || previewHidden ? 0.7 : LazyListView.adding ? 0.7 : 1
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
preventStealing: !root.expanded
enabled: !modelData.closed
drag.target: this hoverEnabled: true
drag.axis: Drag.XAxis cursorShape: notifInner.body?.hoveredLink ? Qt.PointingHandCursor : pressed ? Qt.ClosedHandCursor : undefined
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
preventStealing: !root.expanded
enabled: !(modelData?.closed ?? true)
onPressed: event => { drag.target: this
startY = event.y; drag.axis: Drag.XAxis
if (event.button === Qt.RightButton)
root.requestToggleExpand(!root.expanded); onPressed: event => {
else if (event.button === Qt.MiddleButton) startY = event.y;
modelData.close(); if (event.button === Qt.RightButton)
} root.requestToggleExpand(!root.expanded);
onPositionChanged: event => { else if (event.button === Qt.MiddleButton)
if (pressed && !root.expanded) { modelData?.close();
const diffY = event.y - startY;
if (Math.abs(diffY) > Config.notifs.expandThreshold)
root.requestToggleExpand(diffY > 0);
} }
} onPositionChanged: event => {
onReleased: event => { if (pressed && !root.expanded) {
if (Math.abs(x) < width * Config.notifs.clearThreshold) const diffY = event.y - startY;
x = 0; if (Math.abs(diffY) > Config.notifs.expandThreshold)
else root.requestToggleExpand(diffY > 0);
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 { onReleased: event => {
target: notif if (Math.abs(x) < width * Config.notifs.clearThreshold)
property: "scale" x = 0;
from: 0.7 else
to: 1 modelData?.close();
} }
}
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
property: "opacity" property: "opacity"
to: 0 to: 0
}
Anim {
target: notif
property: "x"
to: notif.x >= 0 ? notif.width : -notif.width
}
} }
Anim {
target: notif Notif {
property: "x" id: notifInner
to: notif.x >= 0 ? notif.width : -notif.width
anchors.fill: parent
modelData: notif.modelData
props: root.props
expanded: root.expanded
visibilities: root.visibilities
} }
}
Notif { Behavior on y {
id: notifInner Anim {
duration: Appearance.anim.durations.expressiveDefaultSpatial
anchors.fill: parent easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
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 { Behavior on opacity {
Anim { Anim {}
duration: Appearance.anim.durations.expressiveDefaultSpatial }
easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
Behavior on scale {
Anim {}
}
Behavior on x {
Anim {
duration: Appearance.anim.durations.expressiveDefaultSpatial
easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
}
} }
} }
} }

View file

@ -126,6 +126,10 @@ qreal LazyListView::contentHeight() const {
return m_contentHeight; return m_contentHeight;
} }
qreal LazyListView::layoutHeight() const {
return m_layoutHeight;
}
qreal LazyListView::contentY() const { qreal LazyListView::contentY() const {
return m_contentY; return m_contentY;
} }
@ -413,33 +417,55 @@ void LazyListView::updatePolish() {
// --- Layout Engine --- // --- Layout Engine ---
void LazyListView::relayout() { void LazyListView::relayout() {
// Layout positioning uses preferredHeight (final/non-animated) // Layout positioning uses preferredHeight (final/non-animated).
// Only add spacing between items with non-zero height.
qreal y = 0; qreal y = 0;
bool hasLayoutItem = false;
for (auto& record : m_layout) { for (auto& record : m_layout) {
record.targetY = y; record.targetY = y;
y += (record.heightKnown ? record.height : effectiveEstimatedHeight()) + m_spacing; const qreal layoutH = record.heightKnown ? record.height : effectiveEstimatedHeight();
if (layoutH > 0) {
if (hasLayoutItem)
y += m_spacing;
hasLayoutItem = true;
y += layoutH;
}
} }
// Content height tracks actual visible heights so scrolling follows animations if (!qFuzzyCompare(m_layoutHeight, y)) {
m_layoutHeight = y;
emit layoutHeightChanged();
}
// Content height tracks actual visible heights so scrolling follows animations.
// Only add spacing between items with non-zero visible height.
qreal visY = 0; qreal visY = 0;
bool hasVisItem = false;
for (int i = 0; i < static_cast<int>(m_layout.size()); ++i) { for (int i = 0; i < static_cast<int>(m_layout.size()); ++i) {
qreal h; qreal h;
if (m_delegates.contains(i) && m_delegates[i].item) if (m_delegates.contains(i) && m_delegates[i].item)
h = delegateVisibleHeight(m_delegates[i].item); h = delegateVisibleHeight(m_delegates[i].item);
else else
h = m_layout[i].heightKnown ? m_layout[i].height : effectiveEstimatedHeight(); h = m_layout[i].heightKnown ? m_layout[i].height : effectiveEstimatedHeight();
visY += h + m_spacing; if (h > 0) {
if (hasVisItem)
visY += m_spacing;
hasVisItem = true;
visY += h;
}
} }
qreal maxBottom = m_layout.isEmpty() ? 0 : visY - m_spacing;
// Account for dying delegates still visually present // Account for dying delegates still visually present
for (const auto& dying : std::as_const(m_dyingDelegates)) { for (const auto& dying : std::as_const(m_dyingDelegates)) {
if (dying.item) if (!dying.item)
maxBottom = std::max(maxBottom, dying.item->y() + delegateVisibleHeight(dying.item)); continue;
const qreal dyingH = delegateVisibleHeight(dying.item);
if (dyingH > 0)
visY = std::max(visY, dying.item->y() + dyingH);
} }
if (!qFuzzyCompare(m_contentHeight, maxBottom)) { if (!qFuzzyCompare(m_contentHeight, visY)) {
m_contentHeight = maxBottom; m_contentHeight = visY;
emit contentHeightChanged(); emit contentHeightChanged();
} }
} }
@ -525,7 +551,9 @@ void LazyListView::syncDelegates() {
if (entry.item) { if (entry.item) {
// Measure height (prefer attached preferredHeight, fall back to implicitHeight) // Measure height (prefer attached preferredHeight, fall back to implicitHeight)
const qreal h = delegateHeight(entry.item); const qreal h = delegateHeight(entry.item);
if (h > 0 && !m_layout[i].heightKnown) { if (!m_layout[i].heightKnown || !qFuzzyCompare(m_layout[i].height, h)) {
if (m_layout[i].heightKnown)
untrackHeight(m_layout[i].height);
m_layout[i].height = h; m_layout[i].height = h;
m_layout[i].heightKnown = true; m_layout[i].heightKnown = true;
trackHeight(h); trackHeight(h);
@ -625,6 +653,9 @@ LazyListView::DelegateEntry LazyListView::createDelegate(int modelIndex) {
if (wasKnown) if (wasKnown)
untrackHeight(oldH); untrackHeight(oldH);
trackHeight(h); trackHeight(h);
// Relayout immediately so layoutHeight/contentHeight update
// synchronously for parent bindings, then polish for delegate sync.
relayout();
polish(); polish();
} }
return; return;

View file

@ -62,6 +62,7 @@ class LazyListView : public QQuickItem {
// Layout // Layout
Q_PROPERTY(qreal spacing READ spacing WRITE setSpacing NOTIFY spacingChanged) Q_PROPERTY(qreal spacing READ spacing WRITE setSpacing NOTIFY spacingChanged)
Q_PROPERTY(qreal contentHeight READ contentHeight NOTIFY contentHeightChanged) 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) Q_PROPERTY(qreal contentY READ contentY WRITE setContentY NOTIFY contentYChanged)
// Viewport & Lazy Loading // Viewport & Lazy Loading
@ -110,6 +111,7 @@ public:
void setSpacing(qreal spacing); void setSpacing(qreal spacing);
[[nodiscard]] qreal contentHeight() const; [[nodiscard]] qreal contentHeight() const;
[[nodiscard]] qreal layoutHeight() const;
[[nodiscard]] qreal contentY() const; [[nodiscard]] qreal contentY() const;
void setContentY(qreal contentY); void setContentY(qreal contentY);
@ -170,6 +172,7 @@ signals:
void delegateChanged(); void delegateChanged();
void spacingChanged(); void spacingChanged();
void contentHeightChanged(); void contentHeightChanged();
void layoutHeightChanged();
void contentYChanged(); void contentYChanged();
void viewportChanged(); void viewportChanged();
void useCustomViewportChanged(); void useCustomViewportChanged();
@ -248,6 +251,7 @@ private:
qreal m_spacing = 0; qreal m_spacing = 0;
qreal m_contentHeight = 0; qreal m_contentHeight = 0;
qreal m_layoutHeight = 0;
qreal m_contentY = 0; qreal m_contentY = 0;
QRectF m_viewport; QRectF m_viewport;