From 666464567055a2e4ba9f6bb310e901cdc27977f7 Mon Sep 17 00:00:00 2001 From: Gregor Kleen Date: Fri, 12 Sep 2025 22:01:51 +0200 Subject: ... --- accounts/gkleen@sif/shell/quickshell/Bar.qml | 2 + .../gkleen@sif/shell/quickshell/Lockscreen.qml | 5 +- accounts/gkleen@sif/shell/quickshell/NiriIdle.qml | 4 +- .../shell/quickshell/NotificationDisplay.qml | 302 +++++++++++++++++++++ .../quickshell/NotificationInhibitorWidget.qml | 47 ++++ .../quickshell/Services/NotificationManager.qml | 98 +++++++ accounts/gkleen@sif/shell/quickshell/UnixIPC.qml | 58 ++-- accounts/gkleen@sif/shell/quickshell/shell.qml | 2 + 8 files changed, 493 insertions(+), 25 deletions(-) create mode 100644 accounts/gkleen@sif/shell/quickshell/NotificationDisplay.qml create mode 100644 accounts/gkleen@sif/shell/quickshell/NotificationInhibitorWidget.qml create mode 100644 accounts/gkleen@sif/shell/quickshell/Services/NotificationManager.qml (limited to 'accounts/gkleen@sif/shell') diff --git a/accounts/gkleen@sif/shell/quickshell/Bar.qml b/accounts/gkleen@sif/shell/quickshell/Bar.qml index 7f97bd75..dd0feb4b 100644 --- a/accounts/gkleen@sif/shell/quickshell/Bar.qml +++ b/accounts/gkleen@sif/shell/quickshell/Bar.qml @@ -100,6 +100,8 @@ PanelWindow { window: bar } + NotificationInhibitorWidget {} + LidSwitchInhibitorWidget {} Item { diff --git a/accounts/gkleen@sif/shell/quickshell/Lockscreen.qml b/accounts/gkleen@sif/shell/quickshell/Lockscreen.qml index 124f441b..880e6614 100644 --- a/accounts/gkleen@sif/shell/quickshell/Lockscreen.qml +++ b/accounts/gkleen@sif/shell/quickshell/Lockscreen.qml @@ -78,7 +78,7 @@ Scope { pam.abort(); if (locked) { - NiriService.sendCommand({ "Action": { "PowerOffMonitors": {} } }); + NiriService.sendCommand({ "Action": { "PowerOffMonitors": {} } }, _ => {}); Custom.KeePassXC.lockAllDatabases(); Array.from(MprisProxy.players).forEach(player => { if (player.canPause && player.isPlaying) @@ -88,8 +88,7 @@ Scope { GpgAgent.reloadAgent(); } } - - Binding { target: MprisProxy; } + Component.onCompleted: { (_ => {})(MprisProxy.players); } onSecureStateChanged: Custom.Systemd.lockedHint = lock.secure diff --git a/accounts/gkleen@sif/shell/quickshell/NiriIdle.qml b/accounts/gkleen@sif/shell/quickshell/NiriIdle.qml index d65711e2..beff205c 100644 --- a/accounts/gkleen@sif/shell/quickshell/NiriIdle.qml +++ b/accounts/gkleen@sif/shell/quickshell/NiriIdle.qml @@ -17,14 +17,14 @@ Scope { onIsIdleChanged: { if (idleMonitor540.isIdle) - NiriService.sendCommand({ "Action": { "PowerOffMonitors": {} } }); + NiriService.sendCommand({ "Action": { "PowerOffMonitors": {} } }, _ => {}); } } Connections { target: Custom.Systemd function onSleep(before: bool) { if (!before) - NiriService.sendCommand({ "Action": { "PowerOnMonitors": {} } }); + NiriService.sendCommand({ "Action": { "PowerOnMonitors": {} } }, _ => {}); } } } diff --git a/accounts/gkleen@sif/shell/quickshell/NotificationDisplay.qml b/accounts/gkleen@sif/shell/quickshell/NotificationDisplay.qml new file mode 100644 index 00000000..589c36e5 --- /dev/null +++ b/accounts/gkleen@sif/shell/quickshell/NotificationDisplay.qml @@ -0,0 +1,302 @@ +import QtQml +import QtQml.Models +import QtQuick +import Quickshell +import Quickshell.Widgets +import Quickshell.Wayland +import qs.Services +import QtQuick.Layouts +import Quickshell.Services.Notifications + +Scope { + readonly property ShellScreen activeScreen: Array.from(Quickshell.screens).find(screen => screen.name === Array.from(NiriService.workspaces).find(ws => ws.is_focused)?.output) ?? null + + Instantiator { + id: notifsRepeater + + model: ScriptModel { + values: NotificationManager.displayInhibited ? [] : [...NotificationManager.groups] + } + + delegate: PanelWindow { + id: notifWindow + + required property var modelData + required property var index + + property int activeIx: modelData.length - 1 + onModelDataChanged: { + notifWindow.activeIx = modelData.length - 1; + } + + property color textColor: { + if (notifWindow.modelData?.[notifWindow.activeIx]?.urgency == NotificationUrgency.Low) + return "#ff999999"; + return "white"; + } + property color backgroundColor: { + if (notifWindow.modelData?.[notifWindow.activeIx]?.urgency == NotificationUrgency.Critical) + return "#dd900000"; + return "black"; + } + + anchors { + right: true + top: true + } + + readonly property real spaceAbove: { + var res = 0; + for (let i = 0; i < notifWindow.index; i++) { + res += notifsRepeater.objectAt(i).height + 8; + } + return res; + } + + margins { + right: 26 + 8 + top: 8 + spaceAbove + } + + color: "transparent" + + implicitHeight: Math.max(notifCount.visible ? notifCount.contentHeight : 0, notifSummary.contentHeight) + (notifBody.visible ? 8 + notifBody.contentHeight : 0) + (notifActions.visible ? 8 + notifActions.height : 0) + 16 + implicitWidth: 400 + + WrapperMouseArea { + enabled: true + + anchors.fill: parent + + cursorShape: Qt.PointingHandCursor + + onClicked: { + for (const notif of notifWindow.modelData) + notif.dismiss(); + } + + property real angleRem: 0 + property real sensitivity: 1 / 120 + onWheel: event => { + angleRem += event.angleDelta.y; + const d = Math.round(angleRem * sensitivity); + angleRem -= d / sensitivity; + notifWindow.activeIx = ((notifWindow.modelData?.length ?? 1) + notifWindow.activeIx - d) % (notifWindow.modelData?.length ?? 1); + } + + Rectangle { + color: notifWindow.backgroundColor + anchors.fill: parent + border { + color: Qt.hsla(195/360, 1, 0.45, 1) + width: 2 + } + + GridLayout { + id: notifLayout + + width: 400 - 16 + anchors.fill: parent + anchors.margins: 8 + columnSpacing: 8 + rowSpacing: 8 + + columns: notifImage.visible ? 3 : 2 + rows: { + var res = 1; + if (notifBody.visible) + res += 1; + if (notifActions.visible) + res += 1; + return res; + } + + Text { + id: notifCount + + visible: notifWindow.modelData?.length > 1 ?? false + text: `${notifWindow.activeIx + 1}/${notifWindow.modelData?.length ?? ""}` + + font.pointSize: 10 + font.family: "Fira Sans" + font.bold: true + font.features: { "tnum": 1 } + color: notifWindow.textColor + maximumLineCount: 1 + + Layout.fillWidth: false + Layout.row: 0 + Layout.column: notifImage.visible ? 1 : 0 + } + + Text { + id: notifSummary + + text: notifWindow.modelData?.[notifWindow.activeIx]?.summary ?? "" + + font.pointSize: 10 + font.family: "Fira Sans" + font.italic: true + color: notifWindow.textColor + maximumLineCount: 1 + elide: Text.ElideRight + + Layout.fillWidth: true + Layout.row: 0 + Layout.column: (notifCount.visible ? 1 : 0) + (notifImage.visible ? 1 : 0) + Layout.columnSpan: notifCount.visible ? 1 : 2 + } + + Image { + id: notifImage + + visible: (notifWindow.modelData?.[notifWindow.activeIx]?.image || notifWindow.modelData?.[notifWindow.activeIx]?.appIcon) ?? false + + onStatusChanged: { + if (notifImage.status == Image.Error) + notifImage.visible = false; + } + + source: (notifWindow.modelData?.[notifWindow.activeIx]?.image || notifWindow.modelData?.[notifWindow.activeIx]?.appIcon) ?? "" + fillMode: Image.PreserveAspectFit + asynchronous: true + smooth: true + mipmap: true + + Layout.maximumWidth: 50 + Layout.column: 0 + Layout.row: 0 + Layout.fillHeight: true + Layout.rowSpan: notifBody.visible ? 2 : 1 + } + + Text { + id: notifBody + + visible: notifWindow.modelData?.[notifWindow.activeIx]?.body ?? false + text: notifWindow.modelData?.[notifWindow.activeIx]?.body ?? "" + textFormat: Text.RichText + + font.pointSize: 10 + font.family: "Fira Sans" + color: notifWindow.textColor + + Layout.fillWidth: true + Layout.row: 1 + Layout.column: notifImage.visible ? 1 : 0 + Layout.columnSpan: notifCount.visible ? 2 : 1 + } + + RowLayout { + id: notifActions + + visible: notifWindow.modelData?.[notifWindow.activeIx]?.actions.length > 0 ?? false + + spacing: 8 + uniformCellSizes: true + + width: 400 - 16 + Layout.row: notifBody.visible ? 2 : 1 + Layout.column: 0 + Layout.columnSpan: notifImage.visible ? 3 : 2 + + Repeater { + model: ScriptModel { + values: notifWindow.modelData?.[notifWindow.activeIx]?.actions + } + + delegate: WrapperMouseArea { + id: actionMouseArea + + required property var modelData + + height: actionLabelWrapper.implicitHeight + Layout.fillWidth: true + Layout.horizontalStretchFactor: 1 + + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + + onClicked: actionMouseArea.modelData?.invoke() + + Rectangle { + anchors.fill: parent + + color: actionMouseArea.containsMouse ? "#20ffffff" : "transparent" + + border { + width: 2 + color: "#20ffffff" + } + + WrapperItem { + id: actionLabelWrapper + + margin: 8 + anchors.centerIn: parent + + RowLayout { + id: actionLabelLayout + + spacing: 8 + + IconImage { + id: actionIcon + + visible: notifWindow.modelData?.[notifWindow.activeIx]?.hasActionIcons + + onStatusChanged: { + if (actionIcon.status == Image.Error) + actionIcon.visible = false; + } + + implicitSize: 16 + source: { + if (!actionIcon.visible) + return ""; + + let icon = actionMouseArea.modelData?.identifier ?? "" + if (icon.includes("?path=")) { + const split = icon.split("?path=") + if (split.length !== 2) + return icon + const name = split[0] + const path = split[1] + const fileName = name.substring( + name.lastIndexOf("/") + 1) + return `file://${path}/${fileName}` + } + return icon + } + asynchronous: true + smooth: true + mipmap: true + + Layout.alignment: Qt.AlignVCenter | Qt.AlignHCenter + } + + Text { + id: actionLabel + + visible: actionMouseArea.modelData?.text ?? false + + text: actionMouseArea.modelData?.text ?? "" + + font.pointSize: 10 + font.family: "Fira Sans" + color: notifWindow.textColor + maximumLineCount: 1 + elide: Text.ElideRight + } + } + } + } + } + } + } + } + } + } + } + } +} diff --git a/accounts/gkleen@sif/shell/quickshell/NotificationInhibitorWidget.qml b/accounts/gkleen@sif/shell/quickshell/NotificationInhibitorWidget.qml new file mode 100644 index 00000000..3dadbc69 --- /dev/null +++ b/accounts/gkleen@sif/shell/quickshell/NotificationInhibitorWidget.qml @@ -0,0 +1,47 @@ +import Quickshell +import QtQuick +import Quickshell.Widgets +import qs.Services + +Item { + id: root + + width: icon.width + 8 + height: parent.height + anchors.verticalCenter: parent.verticalCenter + + WrapperMouseArea { + id: widgetMouseArea + + anchors.fill: parent + + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + + onClicked: NotificationManager.displayInhibited = !NotificationManager.displayInhibited + + Rectangle { + anchors.fill: parent + color: { + if (widgetMouseArea.containsMouse) { + return "#33808080"; + } + return "transparent"; + } + + Item { + anchors.fill: parent + + MaterialDesignIcon { + id: icon + + implicitSize: 14 + anchors.centerIn: parent + + icon: NotificationManager.displayInhibited ? "message-off" : "message" + color: NotificationManager.displayInhibited ? "white" : "#555" + } + } + } + } +} diff --git a/accounts/gkleen@sif/shell/quickshell/Services/NotificationManager.qml b/accounts/gkleen@sif/shell/quickshell/Services/NotificationManager.qml new file mode 100644 index 00000000..778cdc2a --- /dev/null +++ b/accounts/gkleen@sif/shell/quickshell/Services/NotificationManager.qml @@ -0,0 +1,98 @@ +pragma Singleton + +import QtQml +import Quickshell +import Quickshell.Services.Notifications + +Singleton { + id: root + + property bool displayInhibited: false + property alias trackedNotifications: server.trackedNotifications + readonly property var groups: { + function matchesGroupKey(notif, groupKey) { + var matches = true; + for (const prop in groupKey.test) { + if (notif[prop] !== groupKey.test[prop]) { + matches = false; + break; + } + } + return matches; + } + + var groups = new Map(); + var notifs = new Array(); + for (const [ix, notif] of server.trackedNotifications.values.entries()) { + var didGroup = false; + for (const groupKey of root.groupKeys) { + if (!matchesGroupKey(notif, groupKey)) + continue; + + const key = JSON.stringify({ + "key": groupKey, + "values": Object.assign({}, ...(Array.from(groupKey["group-by"]).map(prop => { + var res = {}; + res[prop] = notif[prop]; + return res; + }))) + }); + if (!groups.has(key)) + groups.set(key, new Array()); + groups.get(key).push({ "ix": ix, "notif": notif }); + didGroup = true; + break; + } + + if (!didGroup) + notifs.push([{ "ix": ix, "notif": notif }]); + } + notifs.push(...groups.values()); + notifs.sort((as, bs) => Math.min(...(as.map(o => o.ix))) - Math.min(...(bs.map(o => o.ix)))); + return notifs.map(ns => ns.map(n => n.notif)); + } + + property var groupKeys: [ + { "test": { "appName": "Element" }, "group-by": [ "summary" ] } + ]; + + Component { + id: expirationTimer + + QtObject { + id: timer + + required property QtObject parent + required property int expirationTime + property list data: [ + Timer { + running: !root.displayInhibited + interval: timer.expirationTime + onTriggered: timer.parent.expire() + } + ] + } + } + + NotificationServer { + id: server + + bodySupported: true + actionsSupported: true + actionIconsSupported: true + imageSupported: true + bodyMarkupSupported: true + bodyImagesSupported: true + + onNotification: notification => { + var timeout = notification.expireTimeout * 1000; + if (notification.appName == "poweralertd") + timeout = 2000; + if (timeout > 0) { + Object.defineProperty(notification, "expirationTimer", { configurable: true, enumerable: true, writable: true }); + notification.expirationTimer = expirationTimer.createObject(notification, { parent: notification, expirationTime: timeout }); + } + notification.tracked = true; + } + } +} diff --git a/accounts/gkleen@sif/shell/quickshell/UnixIPC.qml b/accounts/gkleen@sif/shell/quickshell/UnixIPC.qml index 4ec5186c..e19ccfb1 100644 --- a/accounts/gkleen@sif/shell/quickshell/UnixIPC.qml +++ b/accounts/gkleen@sif/shell/quickshell/UnixIPC.qml @@ -15,26 +15,32 @@ Scope { handler: Socket { parser: SplitParser { onRead: line => { - try { - const command = JSON.parse(line); + const command = (() => { + try { + return JSON.parse(line); + } catch (e) { + console.warn("UnixIPC: Failed to parse command:", line, e); + } + })(); + if (!command) + return; - if (command.Volume) - root.onCommandVolume(command.Volume); - else if (command.Brightness) - root.onCommandBrightness(command.Brightness); - else if (command.LockSession) - Custom.Systemd.lockSession(); - else if (command.Suspend) - Custom.Systemd.suspend(); - else if (command.Hibernate) - Custom.Systemd.hibernate(); - else if (command.Mpris) - root.onCommandMpris(command.Mpris); - else - console.warn("UnixIPC: Command not handled:", JSON.stringify(command)); - } catch (e) { - console.warn("UnixIPC: Failed to parse command:", line, e); - } + if (command.Volume) + root.onCommandVolume(command.Volume); + else if (command.Brightness) + root.onCommandBrightness(command.Brightness); + else if (command.LockSession) + Custom.Systemd.lockSession(); + else if (command.Suspend) + Custom.Systemd.suspend(); + else if (command.Hibernate) + Custom.Systemd.hibernate(); + else if (command.Mpris) + root.onCommandMpris(command.Mpris); + else if (command.Notifications) + root.onCommandNotifications(command.Notifications); + else + console.warn("UnixIPC: Command not handled:", JSON.stringify(command)); } } @@ -75,5 +81,17 @@ Scope { player.pause(); }); } - Binding { target: MprisProxy; } + Component.onCompleted: { (_ => {})(MprisProxy.players); } + + function onCommandNotifications(command) { + if (command.DismissGroup && !NotificationManager.displayInhibited) { + if (NotificationManager.groups.length > 0) + for (const notif of [...NotificationManager.groups[0]]) + notif.dismiss(); + } + if (command.DismissAll && !NotificationManager.displayInhibited) { + for (const notif of [...NotificationManager.trackedNotifications.values]) + notif.dismiss(); + } + } } diff --git a/accounts/gkleen@sif/shell/quickshell/shell.qml b/accounts/gkleen@sif/shell/quickshell/shell.qml index 10c2eff6..1a5875f0 100644 --- a/accounts/gkleen@sif/shell/quickshell/shell.qml +++ b/accounts/gkleen@sif/shell/quickshell/shell.qml @@ -47,5 +47,7 @@ ShellRoot { VolumeOSD {} BrightnessOSD {} + NotificationDisplay {} + UnixIPC {} } -- cgit v1.2.3