sidebar/notifs: better anims + misc fixes

This commit is contained in:
2 * r + 2 * t 2025-09-20 15:47:44 +10:00
parent c6b91f1299
commit 89d46c1b2f
8 changed files with 298 additions and 151 deletions

View file

@ -84,7 +84,10 @@ ColumnLayout {
clip: true clip: true
model: ScriptModel { model: ScriptModel {
values: [...new Set(Notifs.list.map(notif => notif.appName))].reverse() values: {
const list = Notifs.notClosed.map(n => [n.appName, null]);
return [...new Map(list).keys()];
}
} }
delegate: NotifGroup {} delegate: NotifGroup {}

View file

@ -16,7 +16,7 @@ StyledRect {
required property string modelData required property string modelData
readonly property list<var> notifs: Notifs.list.filter(notif => notif.appName === modelData).reverse() readonly property list<var> notifs: Notifs.list.filter(notif => notif.appName === modelData)
readonly property string image: notifs.find(n => n.image.length > 0)?.image ?? "" readonly property string image: notifs.find(n => n.image.length > 0)?.image ?? ""
readonly property string appIcon: notifs.find(n => n.appIcon.length > 0)?.appIcon ?? "" readonly property string appIcon: notifs.find(n => n.appIcon.length > 0)?.appIcon ?? ""
readonly property string urgency: notifs.some(n => n.urgency === NotificationUrgency.Critical) ? "critical" : notifs.some(n => n.urgency === NotificationUrgency.Normal) ? "normal" : "low" readonly property string urgency: notifs.some(n => n.urgency === NotificationUrgency.Critical) ? "critical" : notifs.some(n => n.urgency === NotificationUrgency.Normal) ? "normal" : "low"
@ -227,7 +227,7 @@ StyledRect {
ParallelAnimation { ParallelAnimation {
running: notif.modelData.closed running: notif.modelData.closed
onFinished: notif.modelData.lock(notif) onFinished: notif.modelData.unlock(notif)
Anim { Anim {
target: notif target: notif

View file

@ -55,7 +55,7 @@ Item {
id: list id: list
model: ScriptModel { model: ScriptModel {
values: Notifs.popups.filter(n => !n.closed).reverse() values: Notifs.popups.filter(n => !n.closed)
} }
anchors.fill: parent anchors.fill: parent

View file

@ -89,134 +89,24 @@ Item {
} }
} }
StyledListView { StyledFlickable {
id: view id: view
anchors.fill: parent anchors.fill: parent
spacing: Appearance.spacing.small flickableDirection: Flickable.VerticalFlick
contentWidth: width
model: ScriptModel { contentHeight: notifList.implicitHeight
values: {
const list = Notifs.list.filter(n => !n.closed).sort((a, b) => b.time - a.time).map(n => [n.appName, null]);
return [...new Map(list).keys()];
}
}
StyledScrollBar.vertical: StyledScrollBar { StyledScrollBar.vertical: StyledScrollBar {
flickable: view flickable: view
} }
delegate: MouseArea { NotifDockList {
id: notif id: notifList
required property int index
required property string modelData
property int startY
function closeAll(): void {
for (const n of Notifs.list.filter(n => !n.closed && n.appName === modelData))
n.close();
}
implicitWidth: root.width
implicitHeight: notifInner.implicitHeight
hoverEnabled: true
cursorShape: pressed ? Qt.ClosedHandCursor : undefined
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
preventStealing: true
drag.target: this
drag.axis: Drag.XAxis
onPressed: event => {
if (event.button === Qt.LeftButton)
startY = event.y;
else if (event.button === Qt.RightButton)
notifInner.toggleExpand();
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();
}
NotifGroup {
id: notifInner
modelData: notif.modelData
props: root.props props: root.props
} }
Behavior on x {
Anim {
duration: Appearance.anim.durations.expressiveDefaultSpatial
easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
}
}
}
add: Transition {
Anim {
property: "opacity"
from: 0
to: 1
}
Anim {
property: "scale"
from: 0
to: 1
duration: Appearance.anim.durations.expressiveDefaultSpatial
easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
}
}
remove: Transition {
Anim {
property: "opacity"
to: 0
}
Anim {
property: "scale"
to: 0.6
}
}
move: Transition {
Anim {
properties: "opacity,scale"
to: 1
}
Anim {
property: "y"
duration: Appearance.anim.durations.expressiveDefaultSpatial
easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
}
}
displaced: Transition {
Anim {
properties: "opacity,scale"
to: 1
}
Anim {
property: "y"
duration: Appearance.anim.durations.expressiveDefaultSpatial
easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
}
}
} }
} }
@ -226,8 +116,14 @@ Item {
repeat: true repeat: true
interval: 50 interval: 50
onTriggered: { onTriggered: {
if (root.notifCount > 0) let next = null;
Notifs.list.find(n => !n.closed).close(); for (let i = 0; i < notifList.repeater.count; i++) {
next = notifList.repeater.itemAt(i);
if (!next?.closed)
break;
}
if (next)
next.closeAll();
else else
stop(); stop();
} }

View file

@ -0,0 +1,150 @@
pragma ComponentBehavior: Bound
import qs.components
import qs.services
import qs.config
import Quickshell
import QtQuick
Item {
id: root
required property Props props
readonly property alias repeater: repeater
readonly property int spacing: Appearance.spacing.small
property bool flag
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 list = Notifs.list.map(n => [n.appName, null]);
return [...new Map(list).keys()];
}
onValuesChanged: root.flagChanged()
}
MouseArea {
id: notif
required property int index
required property string modelData
readonly property bool closed: notifInner.notifCount === 0
readonly property alias nonAnimHeight: notifInner.nonAnimHeight
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);
if (!item.closed)
y += item.nonAnimHeight + root.spacing;
}
return y;
}
implicitWidth: root.width
implicitHeight: notifInner.implicitHeight
hoverEnabled: true
cursorShape: pressed ? Qt.ClosedHandCursor : undefined
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
preventStealing: true
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
}
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
}
}
}
}
}

View file

@ -16,11 +16,17 @@ StyledRect {
required property string modelData required property string modelData
required property Props props required property Props props
readonly property list<var> notifs: Notifs.list.filter(notif => notif.appName === modelData).reverse() readonly property list<var> notifs: Notifs.list.filter(n => n.appName === modelData)
readonly property int notifCount: notifs.reduce((acc, n) => n.closed ? acc : acc + 1, 0)
readonly property string image: notifs.find(n => n.image.length > 0)?.image ?? "" readonly property string image: notifs.find(n => n.image.length > 0)?.image ?? ""
readonly property string appIcon: notifs.find(n => n.appIcon.length > 0)?.appIcon ?? "" readonly property string appIcon: notifs.find(n => n.appIcon.length > 0)?.appIcon ?? ""
readonly property int urgency: notifs.some(n => n.urgency === NotificationUrgency.Critical) ? NotificationUrgency.Critical : notifs.some(n => n.urgency === NotificationUrgency.Normal) ? NotificationUrgency.Normal : NotificationUrgency.Low readonly property int urgency: notifs.some(n => n.urgency === NotificationUrgency.Critical) ? NotificationUrgency.Critical : notifs.some(n => n.urgency === NotificationUrgency.Normal) ? NotificationUrgency.Normal : NotificationUrgency.Low
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;
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)
function toggleExpand(expand: bool): void { function toggleExpand(expand: bool): void {
@ -32,6 +38,11 @@ StyledRect {
} }
} }
Component.onDestruction: {
if (notifCount === 0 && expanded)
props.expandedNotifs.splice(props.expandedNotifs.indexOf(modelData), 1);
}
anchors.left: parent?.left anchors.left: parent?.left
anchors.right: parent?.right anchors.right: parent?.right
implicitHeight: content.implicitHeight + Appearance.padding.normal * 2 implicitHeight: content.implicitHeight + Appearance.padding.normal * 2
@ -133,6 +144,8 @@ StyledRect {
spacing: 0 spacing: 0
RowLayout { RowLayout {
id: header
Layout.bottomMargin: root.expanded ? Math.round(Appearance.spacing.small / 2) : 0 Layout.bottomMargin: root.expanded ? Math.round(Appearance.spacing.small / 2) : 0
Layout.fillWidth: true Layout.fillWidth: true
spacing: Appearance.spacing.smaller spacing: Appearance.spacing.smaller
@ -178,7 +191,7 @@ StyledRect {
Layout.leftMargin: Appearance.padding.small / 2 Layout.leftMargin: Appearance.padding.small / 2
animate: true animate: true
text: root.notifs.reduce((acc, n) => n.closed ? acc : acc + 1, 0) text: root.notifCount
color: root.urgency === NotificationUrgency.Critical ? Colours.palette.m3onError : Colours.palette.m3onSurface color: root.urgency === NotificationUrgency.Critical ? Colours.palette.m3onError : Colours.palette.m3onSurface
font.pointSize: Appearance.font.size.small font.pointSize: Appearance.font.size.small
} }
@ -198,6 +211,8 @@ StyledRect {
} }
NotifGroupList { NotifGroupList {
id: notifList
props: root.props props: root.props
notifs: root.notifs notifs: root.notifs
expanded: root.expanded expanded: root.expanded

View file

@ -14,16 +14,23 @@ Item {
required property list<var> notifs required property list<var> notifs
required property bool expanded required property bool expanded
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)
h += item.nonAnimHeight + root.spacing;
}
return h;
}
readonly property int spacing: Math.round(Appearance.spacing.small / 2) readonly property int spacing: Math.round(Appearance.spacing.small / 2)
property bool flag property bool flag
signal requestToggleExpand(expand: bool) signal requestToggleExpand(expand: bool)
Layout.fillWidth: true Layout.fillWidth: true
implicitHeight: { implicitHeight: nonAnimHeight
const item = repeater.itemAt(repeater.count - 1);
return item ? item.y + item.implicitHeight : 0;
}
Repeater { Repeater {
id: repeater id: repeater
@ -145,4 +152,11 @@ Item {
} }
} }
} }
Behavior on implicitHeight {
Anim {
duration: Appearance.anim.durations.expressiveDefaultSpatial
easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
}
}
} }

View file

@ -12,17 +12,23 @@ import QtQuick
Singleton { Singleton {
id: root id: root
readonly property list<Notif> list: [] property list<Notif> list: []
readonly property list<Notif> notClosed: list.filter(n => !n.closed)
readonly property list<Notif> popups: list.filter(n => n.popup) readonly property list<Notif> popups: list.filter(n => n.popup)
property alias dnd: props.dnd property alias dnd: props.dnd
property bool loaded property bool loaded
onListChanged: { onListChanged: {
if (!loaded) if (loaded)
return; saveTimer.restart();
}
storage.setText(JSON.stringify(list.filter(n => !n.closed).map(n => ({ Timer {
id: saveTimer
interval: 1000
onTriggered: storage.setText(JSON.stringify(root.notClosed.map(n => ({
time: n.time, time: n.time,
id: n.id, id: n.id,
summary: n.summary, summary: n.summary,
@ -35,7 +41,7 @@ Singleton {
resident: n.resident, resident: n.resident,
hasActionIcons: n.hasActionIcons, hasActionIcons: n.hasActionIcons,
actions: n.actions actions: n.actions
})))); }))))
} }
PersistentProperties { PersistentProperties {
@ -60,10 +66,11 @@ Singleton {
onNotification: notif => { onNotification: notif => {
notif.tracked = true; notif.tracked = true;
root.list.push(notifComp.createObject(root, { const comp = notifComp.createObject(root, {
popup: !props.dnd && ![...Visibilities.screens.values()].some(v => v.sidebar), popup: !props.dnd && ![...Visibilities.screens.values()].some(v => v.sidebar),
notification: notif notification: notif
})); });
root.list = [comp, ...root.list];
} }
} }
@ -75,6 +82,7 @@ Singleton {
const data = JSON.parse(text()); const data = JSON.parse(text());
for (const notif of data) for (const notif of data)
root.list.push(notifComp.createObject(root, notif)); root.list.push(notifComp.createObject(root, notif));
root.list.sort((a, b) => a.time - b.time);
root.loaded = true; root.loaded = true;
} }
onLoadFailed: err => { onLoadFailed: err => {
@ -140,21 +148,17 @@ Singleton {
} }
property Notification notification property Notification notification
property string id: notification?.id ?? "" property string id
property string summary: notification?.summary ?? "" property string summary
property string body: notification?.body ?? "" property string body
property string appIcon: notification?.appIcon ?? "" property string appIcon
property string appName: notification?.appName ?? "" property string appName
property string image: notification?.image ?? "" property string image
property real expireTimeout: notification?.expireTimeout ?? Config.notifs.defaultExpireTimeout property real expireTimeout: Config.notifs.defaultExpireTimeout
property int urgency: notification?.urgency ?? NotificationUrgency.Normal property int urgency: NotificationUrgency.Normal
property bool resident: notification?.resident ?? false property bool resident
property bool hasActionIcons: notification?.hasActionIcons ?? false property bool hasActionIcons
property list<var> actions: notification?.actions.map(a => ({ property list<var> actions
identifier: a.identifier,
text: a.text,
invoke: () => a.invoke()
})) ?? []
readonly property Timer timer: Timer { readonly property Timer timer: Timer {
running: true running: true
@ -171,6 +175,50 @@ Singleton {
function onClosed(): void { function onClosed(): void {
notif.close(); notif.close();
} }
function onSummaryChanged(): void {
notif.summary = notif.notification.summary;
}
function onBodyChanged(): void {
notif.body = notif.notification.body;
}
function onAppIconChanged(): void {
notif.appIcon = notif.notification.appIcon;
}
function onAppNameChanged(): void {
notif.appName = notif.notification.appName;
}
function onImageChanged(): void {
notif.image = notif.notification.image;
}
function onExpireTimeoutChanged(): void {
notif.expireTimeout = notif.notification.expireTimeout;
}
function onUrgencyChanged(): void {
notif.urgency = notif.notification.urgency;
}
function onResidentChanged(): void {
notif.resident = notif.notification.resident;
}
function onHasActionIconsChanged(): void {
notif.hasActionIcons = notif.notification.hasActionIcons;
}
function onActionsChanged(): void {
notif.actions = notif.notification.actions.map(a => ({
identifier: a.identifier,
text: a.text,
invoke: () => a.invoke()
}));
}
} }
function lock(item: Item): void { function lock(item: Item): void {
@ -195,6 +243,27 @@ Singleton {
destroy(); destroy();
} }
} }
Component.onCompleted: {
if (!notification)
return;
id = notification.id;
summary = notification.summary;
body = notification.body;
appIcon = notification.appIcon;
appName = notification.appName;
image = notification.image;
expireTimeout = notification.expireTimeout;
urgency = notification.urgency;
resident = notification.resident;
hasActionIcons = notification.hasActionIcons;
actions = notification.actions.map(a => ({
identifier: a.identifier,
text: a.text,
invoke: () => a.invoke()
}));
}
} }
Component { Component {