summaryrefslogtreecommitdiff
path: root/accounts/gkleen@sif
diff options
context:
space:
mode:
Diffstat (limited to 'accounts/gkleen@sif')
-rw-r--r--accounts/gkleen@sif/niri/default.nix32
-rw-r--r--accounts/gkleen@sif/niri/mako.nix2
-rw-r--r--accounts/gkleen@sif/shell/quickshell/Bar.qml6
-rw-r--r--accounts/gkleen@sif/shell/quickshell/Lockscreen.qml9
-rw-r--r--accounts/gkleen@sif/shell/quickshell/NiriIdle.qml4
-rw-r--r--accounts/gkleen@sif/shell/quickshell/NotificationDisplay.qml332
-rw-r--r--accounts/gkleen@sif/shell/quickshell/NotificationInhibitorWidget.qml266
-rw-r--r--accounts/gkleen@sif/shell/quickshell/Services/NotificationManager.qml151
-rw-r--r--accounts/gkleen@sif/shell/quickshell/UnixIPC.qml58
-rw-r--r--accounts/gkleen@sif/shell/quickshell/shell.qml2
10 files changed, 812 insertions, 50 deletions
diff --git a/accounts/gkleen@sif/niri/default.nix b/accounts/gkleen@sif/niri/default.nix
index 3c29b83c..5ae372c1 100644
--- a/accounts/gkleen@sif/niri/default.nix
+++ b/accounts/gkleen@sif/niri/default.nix
@@ -7,9 +7,6 @@ let
7 7
8 niri = cfg.package; 8 niri = cfg.package;
9 terminal = lib.getExe config.programs.kitty.package; 9 terminal = lib.getExe config.programs.kitty.package;
10 makoctl = lib.getExe' config.services.mako.package "makoctl";
11 loginctl = lib.getExe' hostConfig.systemd.package "loginctl";
12 systemctl = lib.getExe' hostConfig.systemd.package "systemctl";
13 10
14 focus_or_spawn = pkgs.writeShellApplication { 11 focus_or_spawn = pkgs.writeShellApplication {
15 name = "focus-or-spawn"; 12 name = "focus-or-spawn";
@@ -164,10 +161,6 @@ let
164 with-urgent-window-action = config.lib.niri.actions.spawn (lib.getExe (with_predicate_window ".is_urgent")); 161 with-urgent-window-action = config.lib.niri.actions.spawn (lib.getExe (with_predicate_window ".is_urgent"));
165 with-focused-window-action = config.lib.niri.actions.spawn (lib.getExe (with_predicate_window ".is_focused")); 162 with-focused-window-action = config.lib.niri.actions.spawn (lib.getExe (with_predicate_window ".is_focused"));
166in { 163in {
167 imports = [
168 ./mako.nix
169 ];
170
171 options = { 164 options = {
172 programs.niri.scratchspaces = lib.mkOption { 165 programs.niri.scratchspaces = lib.mkOption {
173 type = lib.types.listOf (lib.types.submodule ({ config, ... }: { 166 type = lib.types.listOf (lib.types.submodule ({ config, ... }: {
@@ -910,25 +903,12 @@ in {
910 action = power-off-monitors; 903 action = power-off-monitors;
911 allow-when-locked = true; 904 allow-when-locked = true;
912 }; 905 };
913 # "Mod+Shift+L".action = spawn loginctl "lock-session";
914 "Mod+Shift+E".action = quit; 906 "Mod+Shift+E".action = quit;
915 # "Mod+Shift+Minus" = { 907
916 # action = spawn systemctl "suspend"; 908 # "Mod+Semicolon".action = spawn makoctl "dismiss" "--group";
917 # allow-when-locked = true; 909 # "Mod+Shift+Semicolon".action = spawn makoctl "dismiss" "--all";
918 # }; 910 # "Mod+Period".action = spawn makoctl "menu" "--" (lib.getExe config.programs.fuzzel.package) "--dmenu";
919 # "Mod+Shift+Control+Minus" = { 911 # "Mod+Comma".action = spawn makoctl "restore";
920 # action = spawn systemctl "hibernate";
921 # allow-when-locked = true;
922 # };
923 # "Mod+Shift+P" = {
924 # action = spawn (lib.getExe pkgs.playerctl) "-a" "pause";
925 # allow-when-locked = true;
926 # };
927
928 "Mod+Semicolon".action = spawn makoctl "dismiss" "--group";
929 "Mod+Shift+Semicolon".action = spawn makoctl "dismiss" "--all";
930 "Mod+Period".action = spawn makoctl "menu" "--" (lib.getExe config.programs.fuzzel.package) "--dmenu";
931 "Mod+Comma".action = spawn makoctl "restore";
932 912
933 "Mod+Control+W".action = with-empty-unnamed-workspace-action "{\"Action\":{\"FocusWorkspace\":{\"reference\":{\"Id\": $workspace_id}}}}"; 913 "Mod+Control+W".action = with-empty-unnamed-workspace-action "{\"Action\":{\"FocusWorkspace\":{\"reference\":{\"Id\": $workspace_id}}}}";
934 "Mod+Control+Shift+W".action = with-empty-unnamed-workspace-action "{\"Action\":{\"MoveColumnToWorkspace\":{\"reference\":{\"Id\": $workspace_id}, \"focus\": true}}}"; 914 "Mod+Control+Shift+W".action = with-empty-unnamed-workspace-action "{\"Action\":{\"MoveColumnToWorkspace\":{\"reference\":{\"Id\": $workspace_id}, \"focus\": true}}}";
@@ -986,6 +966,8 @@ in {
986 action = shell { Mpris = { PauseAll = {}; }; }; 966 action = shell { Mpris = { PauseAll = {}; }; };
987 allow-when-locked = true; 967 allow-when-locked = true;
988 }; 968 };
969 "Mod+Semicolon".action = shell { Notifications = { DismissGroup = {}; }; };
970 "Mod+Shift+Semicolon".action = shell { Notifications = { DismissAll = {}; }; };
989 })) 971 }))
990 (map ({ name, selector, spawn, key, ...}: if key != null && selector != null && spawn != null then bind key { action = focus-or-spawn-action selector name spawn; } else null) cfg.scratchspaces) 972 (map ({ name, selector, spawn, key, ...}: if key != null && selector != null && spawn != null then bind key { action = focus-or-spawn-action selector name spawn; } else null) cfg.scratchspaces)
991 (map ({ name, moveKey, ...}: if moveKey != null then bind moveKey { action = kdl.magic-leaf "move-column-to-workspace" name; } else null) cfg.scratchspaces) 973 (map ({ name, moveKey, ...}: if moveKey != null then bind moveKey { action = kdl.magic-leaf "move-column-to-workspace" name; } else null) cfg.scratchspaces)
diff --git a/accounts/gkleen@sif/niri/mako.nix b/accounts/gkleen@sif/niri/mako.nix
index 703d5f7b..3d246d96 100644
--- a/accounts/gkleen@sif/niri/mako.nix
+++ b/accounts/gkleen@sif/niri/mako.nix
@@ -1,6 +1,6 @@
1{ config, lib, pkgs, ... }: 1{ config, lib, pkgs, ... }:
2{ 2{
3 config = { 3 config = lib.mkIf false {
4 services.mako = { 4 services.mako = {
5 enable = true; 5 enable = true;
6 settings = { 6 settings = {
diff --git a/accounts/gkleen@sif/shell/quickshell/Bar.qml b/accounts/gkleen@sif/shell/quickshell/Bar.qml
index 7f97bd75..426ed78c 100644
--- a/accounts/gkleen@sif/shell/quickshell/Bar.qml
+++ b/accounts/gkleen@sif/shell/quickshell/Bar.qml
@@ -66,9 +66,9 @@ PanelWindow {
66 anchors.verticalCenter: parent.verticalCenter 66 anchors.verticalCenter: parent.verticalCenter
67 spacing: 0 67 spacing: 0
68 68
69 WorktimeWidget { command: "time"; } 69 // WorktimeWidget { command: "time"; }
70 70
71 WorktimeWidget { command: "today"; } 71 // WorktimeWidget { command: "today"; }
72 72
73 KeyboardLayout {} 73 KeyboardLayout {}
74 74
@@ -100,6 +100,8 @@ PanelWindow {
100 window: bar 100 window: bar
101 } 101 }
102 102
103 NotificationInhibitorWidget {}
104
103 LidSwitchInhibitorWidget {} 105 LidSwitchInhibitorWidget {}
104 106
105 Item { 107 Item {
diff --git a/accounts/gkleen@sif/shell/quickshell/Lockscreen.qml b/accounts/gkleen@sif/shell/quickshell/Lockscreen.qml
index f983388c..996fd41b 100644
--- a/accounts/gkleen@sif/shell/quickshell/Lockscreen.qml
+++ b/accounts/gkleen@sif/shell/quickshell/Lockscreen.qml
@@ -70,6 +70,12 @@ Scope {
70 mode: Custom.SystemdInhibitorParams.Delay 70 mode: Custom.SystemdInhibitorParams.Delay
71 } 71 }
72 72
73 Binding {
74 target: NotificationManager
75 property: "lockscreenActive"
76 value: lock.locked
77 }
78
73 WlSessionLock { 79 WlSessionLock {
74 id: lock 80 id: lock
75 81
@@ -78,7 +84,7 @@ Scope {
78 pam.abort(); 84 pam.abort();
79 85
80 if (locked) { 86 if (locked) {
81 NiriService.sendCommand({ "Action": { "PowerOffMonitors": {} } }); 87 NiriService.sendCommand({ "Action": { "PowerOffMonitors": {} } }, _ => {});
82 Custom.KeePassXC.lockAllDatabases(); 88 Custom.KeePassXC.lockAllDatabases();
83 Array.from(MprisProxy.players).forEach(player => { 89 Array.from(MprisProxy.players).forEach(player => {
84 if (player.canPause && player.isPlaying) 90 if (player.canPause && player.isPlaying)
@@ -88,6 +94,7 @@ Scope {
88 GpgAgent.reloadAgent(); 94 GpgAgent.reloadAgent();
89 } 95 }
90 } 96 }
97 Component.onCompleted: { (_ => {})(MprisProxy.players); }
91 98
92 onSecureStateChanged: Custom.Systemd.lockedHint = lock.secure 99 onSecureStateChanged: Custom.Systemd.lockedHint = lock.secure
93 100
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 {
17 17
18 onIsIdleChanged: { 18 onIsIdleChanged: {
19 if (idleMonitor540.isIdle) 19 if (idleMonitor540.isIdle)
20 NiriService.sendCommand({ "Action": { "PowerOffMonitors": {} } }); 20 NiriService.sendCommand({ "Action": { "PowerOffMonitors": {} } }, _ => {});
21 } 21 }
22 } 22 }
23 Connections { 23 Connections {
24 target: Custom.Systemd 24 target: Custom.Systemd
25 function onSleep(before: bool) { 25 function onSleep(before: bool) {
26 if (!before) 26 if (!before)
27 NiriService.sendCommand({ "Action": { "PowerOnMonitors": {} } }); 27 NiriService.sendCommand({ "Action": { "PowerOnMonitors": {} } }, _ => {});
28 } 28 }
29 } 29 }
30} 30}
diff --git a/accounts/gkleen@sif/shell/quickshell/NotificationDisplay.qml b/accounts/gkleen@sif/shell/quickshell/NotificationDisplay.qml
new file mode 100644
index 00000000..03406687
--- /dev/null
+++ b/accounts/gkleen@sif/shell/quickshell/NotificationDisplay.qml
@@ -0,0 +1,332 @@
1import QtQml
2import QtQml.Models
3import QtQuick
4import Quickshell
5import Quickshell.Widgets
6import Quickshell.Wayland
7import qs.Services
8import QtQuick.Layouts
9import Quickshell.Services.Notifications
10
11Scope {
12 readonly property ShellScreen activeScreen: Array.from(Quickshell.screens).find(screen => screen.name === Array.from(NiriService.workspaces).find(ws => ws.is_focused)?.output) ?? null
13
14 Instantiator {
15 id: notifsRepeater
16
17 model: ScriptModel {
18 values: NotificationManager.active ? [] : NotificationManager.groups
19 }
20
21 delegate: PanelWindow {
22 id: notifWindow
23
24 required property var modelData
25 required property var index
26
27 property int activeIx: modelData.length - 1
28 onModelDataChanged: {
29 notifWindow.activeIx = modelData.length - 1;
30 }
31
32 property color textColor: {
33 if (notifWindow.modelData?.[notifWindow.activeIx]?.urgency == NotificationUrgency.Low)
34 return "#ff999999";
35 return "white";
36 }
37 property color backgroundColor: {
38 if (notifWindow.modelData?.[notifWindow.activeIx]?.urgency == NotificationUrgency.Critical)
39 return "#dd900000";
40 return "black";
41 }
42
43 anchors {
44 right: true
45 top: true
46 }
47
48 readonly property real spaceAbove: {
49 var res = 0;
50 for (let i = 0; i < notifWindow.index; i++) {
51 (_ => {})(notifsRepeater.objectAt(i).modelData);
52 res += notifsRepeater.objectAt(i).height + 8;
53 }
54 return res;
55 }
56
57 margins {
58 right: 26 + 8
59 top: 8 + spaceAbove
60 }
61
62 color: "transparent"
63
64 implicitHeight: Math.max(notifCount.visible ? notifCount.contentHeight : 0, notifSummary.contentHeight) + (notifBody.visible ? 8 + notifBody.contentHeight : 0) + (notifActions.visible ? 8 + notifActions.height : 0) + (notifTime.visible ? 8 + notifTime.contentHeight : 0) + 16
65 implicitWidth: 400
66
67 WrapperMouseArea {
68 enabled: true
69
70 anchors.fill: parent
71
72 cursorShape: Qt.PointingHandCursor
73
74 onClicked: {
75 for (const notif of notifWindow.modelData)
76 notif.dismiss();
77 }
78
79 property real angleRem: 0
80 property real sensitivity: 1 / 120
81 onWheel: event => {
82 angleRem += event.angleDelta.y;
83 const d = Math.round(angleRem * sensitivity);
84 angleRem -= d / sensitivity;
85 notifWindow.activeIx = ((notifWindow.modelData?.length ?? 1) + notifWindow.activeIx - d) % (notifWindow.modelData?.length ?? 1);
86 }
87
88 Rectangle {
89 color: notifWindow.backgroundColor
90 anchors.fill: parent
91 border {
92 color: Qt.hsla(195/360, 1, 0.45, 1)
93 width: 2
94 }
95
96 GridLayout {
97 id: notifLayout
98
99 width: 400 - 16
100 anchors.fill: parent
101 anchors.margins: 8
102 columnSpacing: 8
103 rowSpacing: 8
104
105 columns: notifImage.visible ? 3 : 2
106 rows: {
107 var res = 1;
108 if (notifBody.visible)
109 res += 1;
110 if (notifActions.visible)
111 res += 1;
112 if (notifTime.visible)
113 res += 1;
114 return res;
115 }
116
117 Text {
118 id: notifCount
119
120 visible: notifWindow.modelData?.length > 1 ?? false
121 text: `${notifWindow.activeIx + 1}/${notifWindow.modelData?.length ?? ""}`
122
123 font.pointSize: 10
124 font.family: "Fira Sans"
125 font.bold: true
126 font.features: { "tnum": 1 }
127 color: notifWindow.textColor
128 maximumLineCount: 1
129
130 Layout.fillWidth: false
131 Layout.row: 0
132 Layout.column: notifImage.visible ? 1 : 0
133 }
134
135 Text {
136 id: notifSummary
137
138 text: notifWindow.modelData?.[notifWindow.activeIx]?.summary ?? ""
139
140 font.pointSize: 10
141 font.family: "Fira Sans"
142 font.italic: true
143 color: notifWindow.textColor
144 maximumLineCount: 1
145 elide: Text.ElideRight
146
147 Layout.fillWidth: true
148 Layout.row: 0
149 Layout.column: (notifCount.visible ? 1 : 0) + (notifImage.visible ? 1 : 0)
150 Layout.columnSpan: notifCount.visible ? 1 : 2
151 }
152
153 Image {
154 id: notifImage
155
156 visible: (notifWindow.modelData?.[notifWindow.activeIx]?.image || notifWindow.modelData?.[notifWindow.activeIx]?.appIcon) ?? false
157
158 onStatusChanged: {
159 if (notifImage.status == Image.Error)
160 notifImage.visible = false;
161 }
162
163 source: (notifWindow.modelData?.[notifWindow.activeIx]?.image || notifWindow.modelData?.[notifWindow.activeIx]?.appIcon) ?? ""
164 fillMode: Image.PreserveAspectFit
165 asynchronous: true
166 smooth: true
167 mipmap: true
168
169 Layout.maximumWidth: 50
170 Layout.column: 0
171 Layout.row: 0
172 Layout.fillHeight: true
173 Layout.rowSpan: 1 + (notifBody.visible ? 1 : 0) + (notifTime.visible ? 1 : 0)
174 }
175
176 Text {
177 id: notifBody
178
179 visible: notifWindow.modelData?.[notifWindow.activeIx]?.body ?? false
180 text: notifWindow.modelData?.[notifWindow.activeIx]?.body ?? ""
181 textFormat: Text.RichText
182 wrapMode: Text.Wrap
183
184 font.pointSize: 10
185 font.family: "Fira Sans"
186 color: notifWindow.textColor
187
188 Layout.fillWidth: true
189 Layout.row: 1
190 Layout.column: notifImage.visible ? 1 : 0
191 Layout.columnSpan: notifCount.visible ? 2 : 1
192 }
193
194 Text {
195 id: notifTime
196
197 Connections {
198 target: NotificationManager.clock
199 function onDateChanged() {
200 notifTime.text = NotificationManager.formatTime(notifWindow.modelData?.[notifWindow.activeIx]?.receivedTime);
201 }
202 }
203
204 visible: notifTime.text && notifTime.text !== "now"
205 text: NotificationManager.formatTime(notifWindow.modelData?.[notifWindow.activeIx]?.receivedTime)
206
207 font.pointSize: 8
208 font.family: "Fira Sans"
209 font.italic: true
210 color: "#555"
211 maximumLineCount: 1
212 horizontalAlignment: Text.AlignRight
213
214 Layout.fillWidth: true
215 Layout.row: notifBody.visible ? 2 : 1
216 Layout.column: notifImage.visible ? 1 : 0
217 Layout.columnSpan: 2
218 }
219
220 RowLayout {
221 id: notifActions
222
223 visible: notifWindow.modelData?.[notifWindow.activeIx]?.actions.length > 0 ?? false
224
225 spacing: 8
226 uniformCellSizes: true
227
228 width: 400 - 16
229 Layout.row: 1 + (notifBody.visible ? 1 : 0) + (notifTime.visible ? 1 : 0)
230 Layout.column: 0
231 Layout.columnSpan: 2 + (notifImage.visible ? 1 : 0)
232
233 Repeater {
234 model: ScriptModel {
235 values: notifWindow.modelData?.[notifWindow.activeIx]?.actions
236 }
237
238 delegate: WrapperMouseArea {
239 id: actionMouseArea
240
241 required property var modelData
242
243 height: actionLabelWrapper.implicitHeight
244 Layout.fillWidth: true
245 Layout.horizontalStretchFactor: 1
246
247 hoverEnabled: true
248 cursorShape: Qt.PointingHandCursor
249
250 onClicked: actionMouseArea.modelData?.invoke()
251
252 Rectangle {
253 anchors.fill: parent
254
255 color: actionMouseArea.containsMouse ? "#20ffffff" : "transparent"
256
257 border {
258 width: 2
259 color: "#20ffffff"
260 }
261
262 WrapperItem {
263 id: actionLabelWrapper
264
265 margin: 8
266 anchors.centerIn: parent
267
268 RowLayout {
269 id: actionLabelLayout
270
271 spacing: 8
272
273 IconImage {
274 id: actionIcon
275
276 visible: notifWindow.modelData?.[notifWindow.activeIx]?.hasActionIcons
277
278 onStatusChanged: {
279 if (actionIcon.status == Image.Error)
280 actionIcon.visible = false;
281 }
282
283 implicitSize: 16
284 source: {
285 if (!actionIcon.visible)
286 return "";
287
288 let icon = actionMouseArea.modelData?.identifier ?? ""
289 if (icon.includes("?path=")) {
290 const split = icon.split("?path=")
291 if (split.length !== 2)
292 return icon
293 const name = split[0]
294 const path = split[1]
295 const fileName = name.substring(
296 name.lastIndexOf("/") + 1)
297 return `file://${path}/${fileName}`
298 }
299 return icon
300 }
301 asynchronous: true
302 smooth: true
303 mipmap: true
304
305 Layout.alignment: Qt.AlignVCenter | Qt.AlignHCenter
306 }
307
308 Text {
309 id: actionLabel
310
311 visible: actionMouseArea.modelData?.text ?? false
312
313 text: actionMouseArea.modelData?.text ?? ""
314
315 font.pointSize: 10
316 font.family: "Fira Sans"
317 color: notifWindow.textColor
318 maximumLineCount: 1
319 elide: Text.ElideRight
320 }
321 }
322 }
323 }
324 }
325 }
326 }
327 }
328 }
329 }
330 }
331 }
332}
diff --git a/accounts/gkleen@sif/shell/quickshell/NotificationInhibitorWidget.qml b/accounts/gkleen@sif/shell/quickshell/NotificationInhibitorWidget.qml
new file mode 100644
index 00000000..b58467b3
--- /dev/null
+++ b/accounts/gkleen@sif/shell/quickshell/NotificationInhibitorWidget.qml
@@ -0,0 +1,266 @@
1import Quickshell
2import QtQuick
3import Quickshell.Widgets
4import qs.Services
5import QtQuick.Controls
6import QtQuick.Layouts
7import QtQuick.Shapes
8
9Item {
10 id: root
11
12 width: icon.width + 8
13 height: parent.height
14 anchors.verticalCenter: parent.verticalCenter
15
16 WrapperMouseArea {
17 id: widgetMouseArea
18
19 anchors.fill: parent
20
21 hoverEnabled: true
22 cursorShape: Qt.PointingHandCursor
23
24 onClicked: NotificationManager.displayInhibited = !NotificationManager.displayInhibited
25
26 Rectangle {
27 anchors.fill: parent
28 color: {
29 if (widgetMouseArea.containsMouse) {
30 return "#33808080";
31 }
32 return "transparent";
33 }
34
35 Item {
36 anchors.fill: parent
37
38 MaterialDesignIcon {
39 id: icon
40
41 implicitSize: 14
42 anchors.centerIn: parent
43
44 icon: NotificationManager.active ? "message" : "message-off"
45 color: {
46 if (!NotificationManager.active && !NotificationManager.displayInhibited)
47 return "#f28a21";
48 if (NotificationManager.displayInhibited)
49 return "white";
50 return "#555";
51 }
52 }
53 }
54 }
55 }
56
57 Loader {
58 id: tooltipLoader
59
60 active: false
61
62 Connections {
63 target: widgetMouseArea
64 function onContainsMouseChanged() {
65 if (widgetMouseArea.containsMouse)
66 tooltipLoader.active = true;
67 }
68 }
69
70 sourceComponent: PopupWindow {
71 id: tooltip
72
73 property bool nextVisible: NotificationManager.active && (widgetMouseArea.containsMouse || tooltipMouseArea.containsMouse)
74
75 anchor {
76 item: widgetMouseArea
77 edges: Edges.Bottom | Edges.Left
78 }
79 visible: false
80
81 onNextVisibleChanged: hangTimer.restart()
82
83 Timer {
84 id: hangTimer
85 interval: tooltip.visible ? 100 : 500
86 onTriggered: {
87 tooltip.visible = tooltip.nextVisible;
88 if (!tooltip.visible)
89 tooltipLoader.active = false;
90 }
91 }
92
93 implicitWidth: 400
94 implicitHeight: Math.min(tooltip.screen.height * 0.66, Math.max(100, scroll.contentHeight + 16))
95 color: "black"
96
97 WrapperMouseArea {
98 id: tooltipMouseArea
99
100 hoverEnabled: true
101 enabled: true
102
103 anchors.fill: parent
104
105 WrapperItem {
106 margin: 8
107
108 ScrollView {
109 id: scroll
110
111 contentWidth: availableWidth
112 // ScrollBar.vertical.policy: ScrollBar.AlwaysOn
113
114 ColumnLayout {
115 id: historyLayout
116 anchors {
117 left: parent.left
118 right: parent.right
119 }
120
121 spacing: 8
122
123 Repeater {
124 model: ScriptModel {
125 values: [...NotificationManager.history].reverse().map(o => o.notification)
126 }
127
128 delegate: GridLayout {
129 id: notif
130
131 Layout.fillWidth: true
132 Layout.preferredHeight: notifSummary.contentHeight + (notifBody.visible ? notifBody.contentHeight + 8 : 0) + (notifSep.visible ? notifSep.height + 8 : 0) + notifTime.contentHeight + 8
133
134 required property var modelData
135 required property int index
136
137 columnSpacing: 8
138 rowSpacing: 8
139
140 columns: notifImage.visible ? 2 : 1
141 rows: {
142 var res = 2;
143 if (notifBody.visible)
144 res += 1;
145 if (notifSep.visible)
146 res += 1;
147 return res;
148 }
149
150 Shape {
151 id: notifSep
152
153 visible: notif.index != 0
154
155 height: 2
156 width: 400 - 32
157
158 ShapePath {
159 strokeWidth: 2
160 strokeColor: "#20ffffff"
161 startX: 0; startY: 0;
162 PathLine { x: 400 - 32; y: 0; }
163 }
164
165 Layout.row: 0
166 Layout.column: 0
167 Layout.columnSpan: notifImage.visible ? 2 : 1
168 Layout.alignment: Qt.AlignHCenter
169 }
170
171 Text {
172 id: notifSummary
173
174 text: notif.modelData?.summary ?? ""
175
176 font.pointSize: 10
177 font.family: "Fira Sans"
178 font.italic: true
179 color: "white"
180 maximumLineCount: 1
181 elide: Text.ElideRight
182
183 Layout.fillWidth: true
184 Layout.row: notifSep.visible ? 1 : 0
185 Layout.column: notifImage.visible ? 1 : 0
186 }
187
188 Image {
189 id: notifImage
190
191 visible: (notif.modelData?.image || notif.modelData?.appIcon) ?? false
192
193 onStatusChanged: {
194 if (notifImage.status == Image.Error)
195 notifImage.visible = false;
196 }
197
198 source: (notif.modelData?.image || notif.modelData?.appIcon) ?? ""
199 fillMode: Image.PreserveAspectFit
200 asynchronous: true
201 smooth: true
202 mipmap: true
203
204 Layout.maximumWidth: 50
205 Layout.column: 0
206 Layout.row: notifSep.visible ? 1 : 0
207 Layout.fillHeight: true
208 Layout.rowSpan: notifBody.visible ? 3 : 2
209 }
210
211 Text {
212 id: notifBody
213
214 visible: notif.modelData?.body ?? false
215 text: notif.modelData?.body ?? ""
216 textFormat: Text.RichText
217 wrapMode: Text.Wrap
218
219 font.pointSize: 10
220 font.family: "Fira Sans"
221 color: "white"
222
223 Layout.fillWidth: true
224 Layout.row: notifSep.visible ? 2 : 1
225 Layout.column: notifImage.visible ? 1 : 0
226 }
227
228 Text {
229 id: notifTime
230
231 Connections {
232 target: NotificationManager.clock
233 function onDateChanged() {
234 notifTime.text = NotificationManager.formatTime(notif.modelData?.receivedTime);
235 }
236 }
237
238 text: NotificationManager.formatTime(notif.modelData?.receivedTime)
239
240 font.pointSize: 8
241 font.family: "Fira Sans"
242 font.italic: true
243 color: "#555"
244 maximumLineCount: 1
245 horizontalAlignment: Text.AlignRight
246
247 Layout.fillWidth: true
248 Layout.row: {
249 var res = 1;
250 if (notifSep.visible)
251 res += 1;
252 if (notifBody.visible)
253 res += 1;
254 return res;
255 }
256 Layout.column: notifImage.visible ? 1 : 0
257 }
258 }
259 }
260 }
261 }
262 }
263 }
264 }
265 }
266}
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..001ffcf5
--- /dev/null
+++ b/accounts/gkleen@sif/shell/quickshell/Services/NotificationManager.qml
@@ -0,0 +1,151 @@
1pragma Singleton
2
3import QtQml
4import Quickshell
5import Quickshell.Services.Notifications
6
7Singleton {
8 id: root
9
10 readonly property bool active: !lockscreenActive && !displayInhibited
11 property bool lockscreenActive: false
12 property bool displayInhibited: false
13 property alias trackedNotifications: server.trackedNotifications
14 readonly property var groups: {
15 function matchesGroupKey(notif, groupKey) {
16 var matches = true;
17 for (const prop in groupKey.test) {
18 if (notif[prop] !== groupKey.test[prop]) {
19 matches = false;
20 break;
21 }
22 }
23 return matches;
24 }
25
26 var groups = new Map();
27 var notifs = new Array();
28 for (const [ix, notif] of server.trackedNotifications.values.entries()) {
29 var didGroup = false;
30 for (const groupKey of root.groupKeys) {
31 if (!matchesGroupKey(notif, groupKey))
32 continue;
33
34 const key = JSON.stringify({
35 "key": groupKey,
36 "values": Object.assign({}, ...(Array.from(groupKey["group-by"]).map(prop => {
37 var res = {};
38 res[prop] = notif[prop];
39 return res;
40 })))
41 });
42 if (!groups.has(key))
43 groups.set(key, new Array());
44 groups.get(key).push({ "ix": ix, "notif": notif });
45 didGroup = true;
46 break;
47 }
48
49 if (!didGroup)
50 notifs.push([{ "ix": ix, "notif": notif }]);
51 }
52 notifs.push(...groups.values());
53 notifs.sort((as, bs) => Math.min(...(as.map(o => o.ix))) - Math.min(...(bs.map(o => o.ix))));
54 return notifs.map(ns => ns.map(n => n.notif));
55 }
56
57 property var groupKeys: [
58 { "test": { "appName": "Element" }, "group-by": [ "summary" ] }
59 ];
60
61 property var history: []
62
63 Component {
64 id: expirationTimer
65
66 QtObject {
67 id: timer
68
69 required property QtObject parent
70 required property int expirationTime
71 property list<QtObject> data: [
72 Timer {
73 running: root.active
74 interval: timer.expirationTime
75 onTriggered: timer.parent.expire()
76 }
77 ]
78 }
79 }
80
81 Component {
82 id: notificationLock
83
84 RetainableLock {}
85 }
86
87 readonly property SystemClock clock: SystemClock {
88 precision: SystemClock.Minutes
89 }
90
91 function formatTime(time) {
92 const now = root.clock.date;
93 const diff = now - time;
94 const minutes = Math.ceil(diff / 60000);
95 const hours = Math.floor(minutes / 60);
96
97 if (hours < 1) {
98 if (minutes < 1)
99 return "now";
100 if (minutes == 1)
101 return "1 minute";
102 return `${minutes} minutes`;
103 }
104
105 const nowDate = new Date(now.getFullYear(), now.getMonth(), now.getDate())
106 const timeDate = new Date(time.getFullYear(), time.getMonth(), time.getDate())
107 const days = Math.floor((nowDate - timeDate) / (1000 * 86400))
108
109 const timeStr = time.toLocaleTimeString(Qt.locale(), "HH:mm");
110
111 if (days === 0)
112 return timeStr;
113 if (days === 1)
114 return `yesterday ${timeStr}`;
115
116 const dateStr = time.toLocaleTimeString(Qt.locale(), "YYYY-MM-DD");
117 return `${dateStr} ${timeStr}`;
118 }
119
120 NotificationServer {
121 id: server
122
123 bodySupported: true
124 actionsSupported: true
125 actionIconsSupported: true
126 imageSupported: true
127 bodyMarkupSupported: true
128 bodyImagesSupported: true
129
130 onNotification: notification => {
131 var timeout = notification.expireTimeout * 1000;
132 if (notification.appName == "poweralertd")
133 timeout = 2000;
134 if (timeout > 0) {
135 Object.defineProperty(notification, "expirationTimer", { configurable: true, enumerable: true, writable: true });
136 notification.expirationTimer = expirationTimer.createObject(notification, { parent: notification, expirationTime: timeout });
137 }
138 Object.defineProperty(notification, "receivedTime", { configurable: true, enumerable: true, writable: true });
139 notification.receivedTime = root.clock.date;
140 notification.closed.connect((reason) => server.onNotificationClosed(notification, reason));
141 notification.tracked = true;
142 }
143
144 function onNotificationClosed(notification, reason) {
145 root.history.push({
146 lock: notificationLock.createObject(root, { locked: true, object: notification }),
147 notification: notification
148 });
149 }
150 }
151}
diff --git a/accounts/gkleen@sif/shell/quickshell/UnixIPC.qml b/accounts/gkleen@sif/shell/quickshell/UnixIPC.qml
index e7b7b673..05a40dbc 100644
--- a/accounts/gkleen@sif/shell/quickshell/UnixIPC.qml
+++ b/accounts/gkleen@sif/shell/quickshell/UnixIPC.qml
@@ -4,6 +4,7 @@ import Quickshell.Services.Pipewire
4import Quickshell.Services.Mpris 4import Quickshell.Services.Mpris
5import qs.Services 5import qs.Services
6import Custom as Custom 6import Custom as Custom
7import QtQml
7 8
8Scope { 9Scope {
9 id: root 10 id: root
@@ -14,26 +15,32 @@ Scope {
14 handler: Socket { 15 handler: Socket {
15 parser: SplitParser { 16 parser: SplitParser {
16 onRead: line => { 17 onRead: line => {
17 try { 18 const command = (() => {
18 const command = JSON.parse(line); 19 try {
20 return JSON.parse(line);
21 } catch (e) {
22 console.warn("UnixIPC: Failed to parse command:", line, e);
23 }
24 })();
25 if (!command)
26 return;
19 27
20 if (command.Volume) 28 if (command.Volume)
21 root.onCommandVolume(command.Volume); 29 root.onCommandVolume(command.Volume);
22 else if (command.Brightness) 30 else if (command.Brightness)
23 root.onCommandBrightness(command.Brightness); 31 root.onCommandBrightness(command.Brightness);
24 else if (command.LockSession) 32 else if (command.LockSession)
25 Custom.Systemd.lockSession(); 33 Custom.Systemd.lockSession();
26 else if (command.Suspend) 34 else if (command.Suspend)
27 Custom.Systemd.suspend(); 35 Custom.Systemd.suspend();
28 else if (command.Hibernate) 36 else if (command.Hibernate)
29 Custom.Systemd.hibernate(); 37 Custom.Systemd.hibernate();
30 else if (command.Mpris) 38 else if (command.Mpris)
31 root.onCommandMpris(command.Mpris); 39 root.onCommandMpris(command.Mpris);
32 else 40 else if (command.Notifications)
33 console.warn("UnixIPC: Command not handled:", JSON.stringify(command)); 41 root.onCommandNotifications(command.Notifications);
34 } catch (e) { 42 else
35 console.warn("UnixIPC: Failed to parse command:", line, e); 43 console.warn("UnixIPC: Command not handled:", JSON.stringify(command));
36 }
37 } 44 }
38 } 45 }
39 46
@@ -74,4 +81,17 @@ Scope {
74 player.pause(); 81 player.pause();
75 }); 82 });
76 } 83 }
84 Component.onCompleted: { (_ => {})(MprisProxy.players); }
85
86 function onCommandNotifications(command) {
87 if (command.DismissGroup && NotificationManager.active) {
88 if (NotificationManager.groups.length > 0)
89 for (const notif of [...NotificationManager.groups[0]])
90 notif.dismiss();
91 }
92 if (command.DismissAll && NotificationManager.active) {
93 for (const notif of [...NotificationManager.trackedNotifications.values])
94 notif.dismiss();
95 }
96 }
77} 97}
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 {
47 VolumeOSD {} 47 VolumeOSD {}
48 BrightnessOSD {} 48 BrightnessOSD {}
49 49
50 NotificationDisplay {}
51
50 UnixIPC {} 52 UnixIPC {}
51} 53}