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.Layouts
import Quickshell
import Caelestia.Components
import qs.components
import qs.services
import qs.config
Item {
LazyListView {
id: root
required property Props props
@ -16,19 +15,8 @@ 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)
readonly property real nonAnimHeight: layoutHeight
property bool showAllNotifs
property bool flag
signal requestToggleExpand(expand: bool)
@ -42,7 +30,15 @@ Item {
}
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 {
id: clearTimer
@ -51,164 +47,116 @@ Item {
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()
}
delegate: NotifDelegate {}
model: ScriptModel {
values: root.showAllNotifs ? root.notifs : root.notifs.slice(0, Config.notifs.groupPreviewNum + 1)
}
Behavior on implicitHeight {
Anim {
duration: Appearance.anim.durations.expressiveDefaultSpatial
easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
}
}
delegate: Component {
MouseArea {
id: notif
component NotifDelegate: MouseArea {
id: notif
required property int index
required property NotifData modelData
required property int index
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
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)))
readonly property bool previewHidden: {
if (root.expanded)
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
scale: previewHidden ? 0.7 : 1
Component.onCompleted: modelData?.lock(this)
Component.onDestruction: modelData?.unlock(this)
implicitWidth: root.width
implicitHeight: notifInner.implicitHeight
LazyListView.preferredHeight: modelData?.closed || previewHidden ? 0 : notifInner.nonAnimHeight
LazyListView.visibleHeight: modelData?.closed || previewHidden ? 0 : notifInner.implicitHeight
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
opacity: LazyListView.removing || modelData?.closed || previewHidden || LazyListView.adding ? 0 : 1
scale: LazyListView.removing || previewHidden ? 0.7 : LazyListView.adding ? 0.7 : 1
drag.target: this
drag.axis: Drag.XAxis
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)
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);
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();
}
}
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
onPositionChanged: event => {
if (pressed && !root.expanded) {
const diffY = event.y - startY;
if (Math.abs(diffY) > Config.notifs.expandThreshold)
root.requestToggleExpand(diffY > 0);
}
}
Anim {
target: notif
property: "scale"
from: 0.7
to: 1
onReleased: event => {
if (Math.abs(x) < width * Config.notifs.clearThreshold)
x = 0;
else
modelData?.close();
}
}
ParallelAnimation {
running: notif.modelData.closed
onFinished: notif.modelData.unlock(notif)
ParallelAnimation {
running: notif.modelData?.closed ?? false
onFinished: notif.modelData?.unlock(notif)
Anim {
target: notif
property: "opacity"
to: 0
Anim {
target: notif
property: "opacity"
to: 0
}
Anim {
target: notif
property: "x"
to: notif.x >= 0 ? notif.width : -notif.width
}
}
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
}
}
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
}
}
}
Behavior on y {
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
}
}
}
}

View file

@ -126,6 +126,10 @@ qreal LazyListView::contentHeight() const {
return m_contentHeight;
}
qreal LazyListView::layoutHeight() const {
return m_layoutHeight;
}
qreal LazyListView::contentY() const {
return m_contentY;
}
@ -413,33 +417,55 @@ void LazyListView::updatePolish() {
// --- Layout Engine ---
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;
bool hasLayoutItem = false;
for (auto& record : m_layout) {
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;
bool hasVisItem = false;
for (int i = 0; i < static_cast<int>(m_layout.size()); ++i) {
qreal h;
if (m_delegates.contains(i) && m_delegates[i].item)
h = delegateVisibleHeight(m_delegates[i].item);
else
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
for (const auto& dying : std::as_const(m_dyingDelegates)) {
if (dying.item)
maxBottom = std::max(maxBottom, dying.item->y() + delegateVisibleHeight(dying.item));
if (!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)) {
m_contentHeight = maxBottom;
if (!qFuzzyCompare(m_contentHeight, visY)) {
m_contentHeight = visY;
emit contentHeightChanged();
}
}
@ -525,7 +551,9 @@ void LazyListView::syncDelegates() {
if (entry.item) {
// Measure height (prefer attached preferredHeight, fall back to implicitHeight)
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].heightKnown = true;
trackHeight(h);
@ -625,6 +653,9 @@ LazyListView::DelegateEntry LazyListView::createDelegate(int modelIndex) {
if (wasKnown)
untrackHeight(oldH);
trackHeight(h);
// Relayout immediately so layoutHeight/contentHeight update
// synchronously for parent bindings, then polish for delegate sync.
relayout();
polish();
}
return;

View file

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