Merge pull request #5 from Zacharias-Brohn/ListView-NC-testing

**animations**
This commit was merged in pull request #5.
This commit is contained in:
Zach
2025-11-17 15:03:39 +01:00
committed by GitHub
10 changed files with 662 additions and 259 deletions
+4
View File
@@ -17,6 +17,10 @@ Scope {
screen: modelData
property var root: Quickshell.shellDir
NotificationCenter {
bar: bar
}
Process {
id: ncProcess
command: ["sh", "-c", `qs -p ${bar.root}/shell.qml ipc call root showCenter`]
+245
View File
@@ -0,0 +1,245 @@
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<Notif> list: []
readonly property list<Notif> notClosed: list.filter( n => !n.closed )
readonly property list<Notif> 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<var> 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: notifComp
Notif {}
}
}
+16
View File
@@ -0,0 +1,16 @@
pragma Singleton
import Quickshell
import Quickshell.Io
Singleton {
id: root
property alias centerX: notifCenterSpacing.centerX
JsonAdapter {
id: notifCenterSpacing
property int centerX
}
}
+16
View File
@@ -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")
}
}
+20
View File
@@ -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
}
}
-45
View File
@@ -1,45 +0,0 @@
pragma ComponentBehavior: Bound
import Quickshell
import Quickshell.Services.Notifications
import QtQuick
import qs.Modules
Scope {
id: root
property list<int> notifIds: []
property list<TrackedNotification> 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
}
}
+52
View File
@@ -0,0 +1,52 @@
pragma ComponentBehavior: Bound
import Quickshell
import Quickshell.Services.Notifications
import QtQuick
import qs.Modules
Scope {
id: root
property list<int> notifIds: []
property list<TrackedNotification> 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
}
}
+145 -87
View File
@@ -2,6 +2,7 @@ import Quickshell
import Quickshell.Hyprland
import Quickshell.Widgets
import Quickshell.Io
import Quickshell.Wayland
import QtQuick.Layouts
import QtQuick.Controls.FluentWinUI3
import QtQuick.Effects
@@ -9,6 +10,7 @@ import QtQuick
import Quickshell.Services.Notifications
import qs.Config
import qs.Helpers
import qs.Daemons
PanelWindow {
id: root
@@ -19,22 +21,15 @@ PanelWindow {
left: true
bottom: true
}
required property list<Notification> notifications
WlrLayershell.layer: WlrLayer.Overlay
required property PanelWindow bar
property bool centerShown: false
property alias posX: backgroundRect.x
property alias doNotDisturb: dndSwitch.checked
visible: false
mask: Region { item: backgroundRect }
onNotificationsChanged: {
if ( root.notifications.length > 0 ) {
HasNotifications.hasNotifications = true;
} else {
HasNotifications.hasNotifications = false;
}
}
IpcHandler {
id: ipcHandler
target: "root"
@@ -69,10 +64,10 @@ PanelWindow {
id: showAnimation
target: backgroundRect
property: "x"
from: Screen.width
to: Screen.width - backgroundRect.implicitWidth - 10
duration: 300
easing.type: Easing.OutCubic
to: root.bar.screen.width - backgroundRect.implicitWidth - 10
from: root.bar.screen.width
duration: MaterialEasing.expressiveEffectsTime
easing.bezierCurve: MaterialEasing.expressiveEffects
onStopped: {
focusGrab.active = true;
}
@@ -82,42 +77,10 @@ PanelWindow {
id: closeAnimation
target: backgroundRect
property: "x"
from: Screen.width - backgroundRect.implicitWidth - 10
to: Screen.width
duration: 300
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();
}
from: root.bar.screen.width - backgroundRect.implicitWidth - 10
to: root.bar.screen.width
duration: MaterialEasing.expressiveEffectsTime
easing.bezierCurve: MaterialEasing.expressiveEffects
}
HyprlandFocusGrab {
@@ -129,10 +92,16 @@ PanelWindow {
}
}
TrackedNotification {
centerShown: root.centerShown
bar: root.bar
}
Rectangle {
id: backgroundRect
y: 10
x: Screen.width
z: 1
implicitWidth: 400
implicitHeight: root.height - 20
color: Config.baseBgColor
@@ -154,6 +123,9 @@ PanelWindow {
focus: false
activeFocusOnTab: false
focusPolicy: Qt.NoFocus
onCheckedChanged: {
NotifServer.dnd = dndSwitch.checked;
}
}
RowLayout {
Layout.alignment: Qt.AlignRight | Qt.AlignVCenter
@@ -180,9 +152,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 +169,7 @@ PanelWindow {
Flickable {
Layout.fillWidth: true
Layout.fillHeight: true
pixelAligned: true
contentHeight: notificationColumn.implicitHeight
clip: true
@@ -216,28 +188,52 @@ PanelWindow {
move: Transition {
NumberAnimation {
properties: "y,x";
properties: "x";
duration: 200;
easing.type: Easing.OutCubic
}
}
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<var> 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 {}
}
Behavior on y {
Anim {
duration: MaterialEasing.expressiveEffectsTime
easing.bezierCurve: MaterialEasing.expressiveEffects
}
}
add: Transition {
id: addTrans
SequentialAnimation {
@@ -259,6 +255,13 @@ PanelWindow {
duration: 100;
easing.type: Easing.OutCubic
}
NumberAnimation {
properties: "scale";
from: 0.7;
to: 1.0;
duration: 100
easing.type: Easing.InOutQuad
}
}
}
}
@@ -266,9 +269,8 @@ PanelWindow {
move: Transition {
id: moveTrans
NumberAnimation {
properties: "opacity";
properties: "y";
duration: 100;
to: 0;
easing.type: Easing.OutCubic
}
}
@@ -305,46 +307,38 @@ PanelWindow {
anchors.fill: parent
hoverEnabled: true
onClicked: {
groupColumn.isExpanded = false;
groupColumn.shouldShow = false;
}
}
}
}
Repeater {
model: groupColumn.notifications
id: groupListView
model: ScriptModel {
id: groupModel
values: groupColumn.isExpanded ? groupColumn.notifications : groupColumn.notifications.slice( 0, 1 )
}
Rectangle {
id: groupHeader
required property var modelData
required property var index
required property int index
required property NotifServer.Notif modelData
property alias notifHeight: groupHeader.height
property bool previewHidden: groupColumn.shouldShow && index > 0
width: parent.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: previewHidden ? 0 : 1.0
scale: previewHidden ? 0.7 : 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
@@ -354,11 +348,73 @@ PanelWindow {
groupHeader.modelData.actions[0].invoke();
}
} else {
groupColumn.shouldShow = true;
groupColumn.isExpanded = true;
}
}
}
ParallelAnimation {
id: collapseAnim
running: !groupColumn.shouldShow && index > 0
Anim {
target: groupHeader
property: "opacity"
duration: 100
from: 1
to: index > 0 ? 0 : 1.0
}
Anim {
target: groupHeader
property: "scale"
duration: 100
from: 1
to: index > 0 ? 0.7 : 1.0
}
onFinished: {
groupColumn.isExpanded = false;
}
}
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 +476,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 +492,7 @@ PanelWindow {
radius: 4
Text {
anchors.centerIn: parent
text: modelData.text
text: actionButton.modelData.text
color: "white"
font.pointSize: 12
}
@@ -443,7 +501,7 @@ PanelWindow {
anchors.fill: parent
hoverEnabled: true
onClicked: {
modelData.invoke();
actionButton.modelData.invoke();
}
}
}
@@ -472,7 +530,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();
}
}
}
+81 -44
View File
@@ -1,85 +1,122 @@
import Quickshell
import Quickshell.Widgets
import Quickshell.Wayland
import Quickshell.Hyprland
import QtQuick.Layouts
import QtQuick
import Quickshell.Services.Notifications
import qs.Config
import qs.Daemons
import qs.Helpers
PanelWindow {
id: root
color: "transparent"
screen: root.bar.screen
anchors {
top: true
right: true
left: true
bottom: true
}
mask: Region { item: backgroundRect }
mask: Region { regions: root.notifRegions }
exclusionMode: ExclusionMode.Ignore
WlrLayershell.layer: WlrLayer.Overlay
required property Notification notif
required property int centerX
required property list<int> notifIndex
required property list<TrackedNotification> notifList
property int index: notifIndex.indexOf(notif.id)
property alias y: backgroundRect.y
property alias notifHeight: backgroundRect.implicitHeight
signal notifDestroy()
property list<Region> notifRegions: []
required property bool centerShown
required property PanelWindow bar
visible: Hyprland.monitorFor(screen).focused
Component.onCompleted: {
openAnim.start();
console.log(root.index);
console.log(NotifServer.list.filter( n => n.popup ).length + " notification popups loaded.");
}
Timer {
id: timeout
interval: 5000
onTriggered: {
closeAnim.start();
ListView {
id: notifListView
model: ScriptModel {
values: NotifServer.list.filter( n => n.popup )
}
anchors.top: parent.top
anchors.bottom: parent.bottom
x: root.centerShown ? root.bar.width - width - 420 : root.bar.width - width - 20
z: 0
anchors.topMargin: 54
width: 400
spacing: 10
Behavior on x {
NumberAnimation {
duration: MaterialEasing.expressiveEffectsTime
easing.bezierCurve: MaterialEasing.expressiveEffects
}
}
displaced: Transition {
NumberAnimation {
property: "y"
duration: 100
easing.type: Easing.InOutQuad
}
}
remove: Transition {
id: hideTransition
ParallelAnimation {
NumberAnimation {
property: "opacity"
from: 1
to: 0
duration: 200
easing.type: Easing.InOutQuad
}
NumberAnimation {
id: openAnim
target: backgroundRect
property: "x"
from: root.centerX
to: root.centerX - backgroundRect.implicitWidth - 20
to: hideTransition.ViewTransition.destination.x + 200
duration: 200
easing.type: Easing.InOutQuad
}
}
}
add: Transition {
id: showTransition
ParallelAnimation {
NumberAnimation {
property: "opacity"
from: 0
to: 1
duration: 200
easing.type: Easing.InOutQuad
onStopped: { timeout.start(); }
}
NumberAnimation {
id: closeAnim
target: backgroundRect
property: "x"
from: root.centerX - backgroundRect.implicitWidth - 20
to: root.centerX
from: showTransition.ViewTransition.destination.x + 200
to: showTransition.ViewTransition.destination.x
duration: 200
easing.type: Easing.InOutQuad
onStopped: {
root.destroy();
root.notifDestroy();
}
}
}
Rectangle {
component NotifRegion: Region { }
Component {
id: notifRegion
NotifRegion {}
}
delegate: Rectangle {
id: backgroundRect
required property NotifServer.Notif modelData
implicitWidth: 400
implicitHeight: contentLayout.childrenRect.height + 16
x: root.centerX - implicitWidth - 20
y: !root.notifList[ root.index - 1 ] ? 34 + 20 : root.notifList[ root.index - 1 ].y + root.notifList[ root.index - 1 ].notifHeight + 10
color: Config.baseBgColor
border.color: "#555555"
radius: 8
Behavior on y {
NumberAnimation {
duration: 200
easing.type: Easing.InOutQuad
}
Component.onCompleted: {
root.notifRegions.push( notifRegion.createObject(root, { item: backgroundRect }));
}
Column {
@@ -92,11 +129,11 @@ PanelWindow {
RowLayout {
spacing: 12
IconImage {
source: root.notif.image
source: backgroundRect.modelData.image
Layout.preferredWidth: 48
Layout.preferredHeight: 48
Layout.alignment: Qt.AlignHCenter | Qt.AlignLeft
visible: root.notif.image !== ""
visible: backgroundRect.modelData.image !== ""
}
ColumnLayout {
@@ -106,7 +143,7 @@ PanelWindow {
Layout.alignment: Qt.AlignVCenter | Qt.AlignLeft
Text {
text: root.notif.appName
text: backgroundRect.modelData.appName
color: "white"
font.bold: true
font.pointSize: 14
@@ -116,7 +153,7 @@ PanelWindow {
}
Text {
text: root.notif.summary
text: backgroundRect.modelData.summary
color: "white"
font.pointSize: 12
font.bold: true
@@ -128,7 +165,7 @@ PanelWindow {
}
}
Text {
text: root.notif.body
text: backgroundRect.modelData.body
color: "#dddddd"
font.pointSize: 14
elide: Text.ElideRight
@@ -160,8 +197,8 @@ PanelWindow {
anchors.fill: parent
hoverEnabled: true
onClicked: {
root.notif.dismiss();
root.visible = false;
backgroundRect.modelData.close();
}
}
}
}
+1 -1
View File
@@ -6,6 +6,6 @@ import qs.Modules
Scope {
Bar {}
Wallpaper {}
NotifServer {}
// NotificationCenter {}
Launcher {}
}