diff --git a/Daemons/NotifServer.qml b/Daemons/NotifServer.qml new file mode 100644 index 0000000..de9679a --- /dev/null +++ b/Daemons/NotifServer.qml @@ -0,0 +1,252 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import Quickshell +import Quickshell.Io +import Quickshell.Services.Notifications +import QtQuick +import qs.Modules +import qs.Helpers + +Singleton { + id: root + + property list list: [] + readonly property list notClosed: list.filter( n => !n.closed ) + readonly property list popups: list.filter( n => n.popup ) + property alias dnd: props.dnd + property alias server: server + + property bool loaded + + onListChanged: { + if ( loaded ) { + saveTimer.restart(); + } + if ( root.list.length > 0 ) { + HasNotifications.hasNotifications = true; + } else { + HasNotifications.hasNotifications = false; + } + } + + Timer { + id: saveTimer + interval: 1000 + onTriggered: storage.setText( JSON.stringify( root.notClosed.map( n => ({ + time: n.time, + id: n.id, + summary: n.summary, + body: n.body, + appIcon: n.appIcon, + appName: n.appName, + image: n.image, + expireTimeout: n.expireTimeout, + urgency: n.urgency, + resident: n.resident, + hasActionIcons: n.hasActionIcons, + actions: n.actions + })))); + } + + PersistentProperties { + id: props + + property bool dnd + + reloadableId: "notifs" + } + + NotificationServer { + id: server + + keepOnReload: false + actionsSupported: true + bodyHyperlinksSupported: true + bodyImagesSupported: true + bodyMarkupSupported: true + imageSupported: true + persistenceSupported: true + + onNotification: notif => { + notif.tracked = true; + + const comp = notifComp.createObject(root, { + popup: !props.dnd, + notification: notif + }); + root.list = [comp, ...root.list]; + } + } + + FileView { + id: storage + path: NotifPath.notifPath + + onLoaded: { + const data = JSON.parse(text()); + for (const notif of data) + root.list.push(notifComp.createObject(root, notif)); + root.list.sort((a, b) => b.time - a.time); + root.loaded = true; + } + onLoadFailed: err => { + if (err === FileViewError.FileNotFound) { + root.loaded = true; + setText("[]"); + } + } + } + + component Notif: QtObject { + id: notif + + property bool popup + property bool closed + property var locks: new Set() + + property date time: new Date() + readonly property string timeStr: { + const diff = Time.date.getTime() - time.getTime(); + const m = Math.floor(diff / 60000); + + if (m < 1) + return qsTr("now"); + + const h = Math.floor(m / 60); + const d = Math.floor(h / 24); + + if (d > 0) + return `${d}d`; + if (h > 0) + return `${h}h`; + return `${m}m`; + } + + property Notification notification + property string id + property string summary + property string body + property string appIcon + property string appName + property string image + property real expireTimeout: 5 + property int urgency: NotificationUrgency.Normal + property bool resident + property bool hasActionIcons + property list actions + + readonly property Timer timer: Timer { + running: true + interval: 5000 + onTriggered: { + notif.popup = false; + } + } + + readonly property Connections conn: Connections { + target: notif.notification + + function onClosed(): void { + 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 { + locks.add(item); + } + + function unlock(item: Item): void { + locks.delete(item); + if (closed) + close(); + } + + function close(): void { + closed = true; + if (locks.size === 0 && root.list.includes(this)) { + root.list = root.list.filter(n => n !== this); + notification?.dismiss(); + 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 { + id: notificationPopup + TrackedNotification { + centerX: NotificationCenter.posX + } + } + + Component { + id: notifComp + + Notif {} + } +} diff --git a/Helpers/NotifPath.qml b/Helpers/NotifPath.qml new file mode 100644 index 0000000..3d4b3cd --- /dev/null +++ b/Helpers/NotifPath.qml @@ -0,0 +1,16 @@ +pragma Singleton + +import Quickshell.Io +import Quickshell + +Singleton { + id: root + + property alias notifPath: storage.notifPath + + JsonAdapter { + id: storage + + property string notifPath: Quickshell.statePath("notifications.json") + } +} diff --git a/Helpers/Time.qml b/Helpers/Time.qml new file mode 100644 index 0000000..c4b3913 --- /dev/null +++ b/Helpers/Time.qml @@ -0,0 +1,20 @@ +pragma Singleton + +import Quickshell + +Singleton { + property alias enabled: clock.enabled + readonly property date date: clock.date + readonly property int hours: clock.hours + readonly property int minutes: clock.minutes + readonly property int seconds: clock.seconds + + function format(fmt: string): string { + return Qt.formatDateTime(clock.date, fmt); + } + + SystemClock { + id: clock + precision: SystemClock.Seconds + } +} diff --git a/Modules/NotifServer.qml b/Modules/NotifServer.qml deleted file mode 100644 index 5babbf2..0000000 --- a/Modules/NotifServer.qml +++ /dev/null @@ -1,45 +0,0 @@ -pragma ComponentBehavior: Bound - -import Quickshell -import Quickshell.Services.Notifications -import QtQuick -import qs.Modules - -Scope { - id: root - property list notifIds: [] - property list notifications; - NotificationServer { - id: notificationServer - imageSupported: true - actionsSupported: true - persistenceSupported: true - bodyImagesSupported: true - bodySupported: true - onNotification: notification => { - notification.tracked = true; - notification.receivedTime = Date.now(); - root.notifIds.push(notification.id); - const notif = notificationComponent.createObject(root, { notif: notification, visible: !notificationCenter.doNotDisturb }); - root.notifications.push(notif); - } - } - - Component { - id: notificationComponent - TrackedNotification { - centerX: notificationCenter.posX - notifIndex: root.notifIds - notifList: root.notifications - onNotifDestroy: { - root.notifications.shift(); - root.notifIds.shift(); - } - } - } - - NotificationCenter { - id: notificationCenter - notifications: notificationServer.trackedNotifications.values - } -} diff --git a/Modules/NotifServerOld.qml b/Modules/NotifServerOld.qml new file mode 100644 index 0000000..381fcf8 --- /dev/null +++ b/Modules/NotifServerOld.qml @@ -0,0 +1,52 @@ +pragma ComponentBehavior: Bound + +import Quickshell +import Quickshell.Services.Notifications +import QtQuick +import qs.Modules + +Scope { + id: root + property list notifIds: [] + property list notifications; + // NotificationServer { + // id: notificationServer + // imageSupported: true + // actionsSupported: true + // persistenceSupported: true + // bodyImagesSupported: true + // bodySupported: true + // onNotification: notification => { + // notification.tracked = true; + // notification.receivedTime = Date.now(); + // root.notifIds.push(notification.id); + // const notif = notificationComponent.createObject(root, { notif: notification, visible: !notificationCenter.doNotDisturb }); + // root.notifications.push(notif); + // } + // } + + Connections { + target: NotifServer.server + function onNotification() { + notificationComponent.createObject( root, { notif: NotifServer.list[0] }); + } + } + + Component { + id: notificationComponent + TrackedNotification { + centerX: notificationCenter.posX + notifIndex: root.notifIds + notifList: root.notifications + onNotifDestroy: { + root.notifications.shift(); + root.notifIds.shift(); + } + } + } + + NotificationCenter { + id: notificationCenter + notifications: notificationServer.trackedNotifications.values + } +} diff --git a/Modules/NotificationCenter.qml b/Modules/NotificationCenter.qml index 328a52a..0398ca3 100644 --- a/Modules/NotificationCenter.qml +++ b/Modules/NotificationCenter.qml @@ -9,6 +9,7 @@ import QtQuick import Quickshell.Services.Notifications import qs.Config import qs.Helpers +import qs.Daemons PanelWindow { id: root @@ -19,7 +20,6 @@ PanelWindow { left: true bottom: true } - required property list notifications property bool centerShown: false property alias posX: backgroundRect.x property alias doNotDisturb: dndSwitch.checked @@ -27,14 +27,6 @@ PanelWindow { mask: Region { item: backgroundRect } - onNotificationsChanged: { - if ( root.notifications.length > 0 ) { - HasNotifications.hasNotifications = true; - } else { - HasNotifications.hasNotifications = false; - } - } - IpcHandler { id: ipcHandler target: "root" @@ -88,38 +80,6 @@ PanelWindow { easing.type: Easing.OutCubic } - QtObject { - id: groupedData - property var groups: ({}) - - function updateGroups() { - var newGroups = {}; - for ( var i = 0; i < root.notifications.length; i++ ) { - var notif = root.notifications[ i ]; - var appName = notif.appName || "Unknown"; - if ( !newGroups[ appName ]) { - newGroups[ appName ] = []; - } - newGroups[ appName ].push( notif ); - } - // Sort notifications within each group (latest first) - for ( var app in newGroups ) { - newGroups[ app ].sort(( a, b ) => b.receivedTime - a.receivedTime ); - } - groups = newGroups; - groupsChanged(); - } - - Component.onCompleted: updateGroups() - } - - Connections { - target: root - function onNotificationsChanged() { - groupedData.updateGroups(); - } - } - HyprlandFocusGrab { id: focusGrab active: false @@ -180,9 +140,8 @@ PanelWindow { anchors.fill: parent hoverEnabled: true onClicked: { - for ( var app in groupedData.groups ) { - groupedData.groups[ app ].forEach( function( n ) { n.dismiss(); }); - } + for ( const n of NotifServer.notClosed ) + n.close(); } } } @@ -198,6 +157,7 @@ PanelWindow { Flickable { Layout.fillWidth: true Layout.fillHeight: true + pixelAligned: true contentHeight: notificationColumn.implicitHeight clip: true @@ -223,52 +183,68 @@ PanelWindow { } Repeater { - model: Object.keys( groupedData.groups ) + 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(); + } + } Column { id: groupColumn required property string modelData - property var notifications: groupedData.groups[ modelData ] + property list notifications: NotifServer.list.filter( n => n.appName === modelData ) width: parent.width spacing: 10 property bool shouldShow: false property bool isExpanded: false + function closeAll(): void { + for ( const n of NotifServer.notClosed.filter( n => n.appName === modelData )) + n.close(); + } + Behavior on height { Anim {} } - add: Transition { - id: addTrans - SequentialAnimation { - PauseAnimation { - duration: ( addTrans.ViewTransition.index - addTrans.ViewTransition.targetIndexes[ 0 ]) * 50 - } - ParallelAnimation { - NumberAnimation { - properties: "y"; - from: addTrans.ViewTransition.destination.y - (height / 2); - to: addTrans.ViewTransition.destination.y; - duration: 100; - easing.type: Easing.OutCubic - } - NumberAnimation { - properties: "opacity"; - from: 0; - to: 1; - duration: 100; - easing.type: Easing.OutCubic - } - } - } - } - + // add: Transition { + // id: addTrans + // SequentialAnimation { + // PauseAnimation { + // duration: ( addTrans.ViewTransition.index - addTrans.ViewTransition.targetIndexes[ 0 ]) * 50 + // } + // ParallelAnimation { + // NumberAnimation { + // properties: "y"; + // from: addTrans.ViewTransition.destination.y - (height / 2); + // to: addTrans.ViewTransition.destination.y; + // duration: 100; + // easing.type: Easing.OutCubic + // } + // NumberAnimation { + // properties: "opacity"; + // from: 0; + // to: 1; + // duration: 100; + // easing.type: Easing.OutCubic + // } + // } + // } + // } + // move: Transition { id: moveTrans NumberAnimation { - properties: "opacity"; + properties: "y"; duration: 100; - to: 0; easing.type: Easing.OutCubic } } @@ -311,40 +287,76 @@ PanelWindow { } } - Repeater { - model: groupColumn.notifications - Rectangle { + ListView { + id: groupListView + model: ScriptModel { + id: groupModel + values: groupColumn.isExpanded ? groupColumn.notifications : groupColumn.notifications.slice( 0, 1 ) + } + + width: parent.width + spacing: 10 + height: contentHeight + contentHeight: childrenRect.height + clip: false + + pixelAligned: true + boundsBehavior: Flickable.StopAtBounds + displayMarginBeginning: 0 + displayMarginEnd: 5000 + + Behavior on height { + Anim { + duration: 20; + } + } + + add: Transition { + id: add + NumberAnimation { + properties: "y,opacity"; + duration: 100 * ( add.ViewTransition.targetIndexes.length / ( add.ViewTransition.targetIndexes.length < 3 ? 1 : 3 )); + easing.bezierCurve: MaterialEasing.expressiveDefaultSpatial + } + } + + remove: Transition { + NumberAnimation { + properties: "opacity"; + from: 1; + to: 0; + duration: 300; + easing.bezierCurve: MaterialEasing.expressiveDefaultSpatial + } + } + + displaced: Transition { + NumberAnimation { + properties: "y"; + duration: 200; + easing.bezierCurve: MaterialEasing.expressiveDefaultSpatial + } + } + + delegate: Rectangle { id: groupHeader - required property var modelData - required property var index - width: parent.width + required property int index + required property NotifServer.Notif modelData + property alias notifHeight: groupHeader.height + + property bool previewHidden: !groupColumn.isExpanded && index > 0 + + width: groupListView.width height: contentColumn.height + 20 color: Config.baseBgColor border.color: "#555555" border.width: 1 radius: 8 - visible: groupColumn.notifications[0].id === modelData.id || groupColumn.isExpanded - opacity: groupColumn.notifications[0].id === modelData.id ? 1 : 0 + opacity: 1 + scale: 1.0 - Connections { - target: groupColumn - function onShouldShowChanged() { - if ( !shouldShow ) { - // collapseAnim.start(); - } - } - } - - onVisibleChanged: { - if ( visible ) { - // expandAnim.start(); - } else { - if ( groupColumn.notifications[0].id !== modelData.id ) { - groupHeader.opacity = 0; - } - groupColumn.isExpanded = false; - } - } + Component.onCompleted: modelData.lock(this); + Component.onDestruction: modelData.unlock(this); MouseArea { anchors.fill: parent @@ -359,6 +371,44 @@ PanelWindow { } } + ParallelAnimation { + running: groupHeader.modelData.closed + onFinished: groupHeader.modelData.unlock(groupHeader) + + Anim { + target: groupHeader + property: "opacity" + to: 0 + } + Anim { + target: groupHeader + property: "x" + to: groupHeader.x >= 0 ? groupHeader.width : -groupHeader.width + } + } + + Behavior on opacity { + Anim {} + } + + Behavior on scale { + Anim {} + } + + Behavior on x { + Anim { + duration: MaterialEasing.expressiveDefaultSpatialTime + easing.bezierCurve: MaterialEasing.expressiveDefaultSpatial + } + } + + Behavior on y { + Anim { + duration: MaterialEasing.expressiveDefaultSpatialTime + easing.bezierCurve: MaterialEasing.expressiveDefaultSpatial + } + } + Column { id: contentColumn anchors.centerIn: parent @@ -420,13 +470,15 @@ PanelWindow { RowLayout { spacing: 2 - visible: groupColumn.isExpanded ? ( groupHeader.modelData.actions.length > 1 ? true : false ) : ( groupColumn.notifications.length === 1 ? ( groupHeader.modelData.actions.length > 1 ? true : false ) : false ) + // visible: groupColumn.isExpanded ? ( groupHeader.modelData.actions.length > 1 ? true : false ) : ( groupColumn.notifications.length === 1 ? ( groupHeader.modelData.actions.length > 1 ? true : false ) : false ) + visible: true height: 30 width: parent.width Repeater { model: groupHeader.modelData.actions Rectangle { + id: actionButton Layout.fillWidth: true Layout.preferredHeight: 30 required property var modelData @@ -434,7 +486,7 @@ PanelWindow { radius: 4 Text { anchors.centerIn: parent - text: modelData.text + text: actionButton.modelData.text color: "white" font.pointSize: 12 } @@ -443,7 +495,7 @@ PanelWindow { anchors.fill: parent hoverEnabled: true onClicked: { - modelData.invoke(); + actionButton.modelData.invoke(); } } } @@ -472,7 +524,7 @@ PanelWindow { anchors.fill: parent hoverEnabled: true onClicked: { - groupColumn.isExpanded ? groupColumn.notifications[0].dismiss() : groupColumn.notifications.forEach( function( n ) { n.dismiss(); }); + groupColumn.isExpanded ? groupHeader.modelData.close() : groupColumn.closeAll(); } } } diff --git a/Modules/TrackedNotification.qml b/Modules/TrackedNotification.qml index 6dee817..64a6b81 100644 --- a/Modules/TrackedNotification.qml +++ b/Modules/TrackedNotification.qml @@ -5,6 +5,7 @@ import QtQuick.Layouts import QtQuick import Quickshell.Services.Notifications import qs.Config +import qs.Daemons PanelWindow { id: root @@ -21,7 +22,6 @@ PanelWindow { required property Notification notif required property int centerX required property list notifIndex - required property list notifList property int index: notifIndex.indexOf(notif.id) property alias y: backgroundRect.y property alias notifHeight: backgroundRect.implicitHeight diff --git a/shell.qml b/shell.qml index d9fae93..6db3a87 100644 --- a/shell.qml +++ b/shell.qml @@ -6,6 +6,6 @@ import qs.Modules Scope { Bar {} Wallpaper {} - NotifServer {} + NotificationCenter {} Launcher {} }