Add notification-center bar widget + history panel; toasts render on focused monitor with finite expiries
swadhinbiswas committed 2 months ago
eb36c6c
1 parent fe09911
commit eb36c6c
5 files changed
+614 -13
Showing 5 changed files+614-13
▼config/omarchy/shell.json
+4-1
| @@ -61,9 +61,12 @@ | ||
61 | 61 | }, |
62 | 62 | { |
63 | 63 | "id": "omarchy.power" |
64 | + }, | |
65 | + { | |
66 | + "id": "deomarchy.notification-center" | |
64 | 67 | } |
65 | 68 | ] |
66 | 69 | } |
67 | 70 | }, |
68 | 71 | "plugins": [] |
69 | -} | |
72 | +} | |
70 | 73 | |
▼shell/plugins/notification-center/BarWidget.qml
+117-0
| @@ -0,0 +1,117 @@ | ||
1 | +import QtQuick | |
2 | +import Quickshell | |
3 | +import Quickshell.Io | |
4 | +import qs.Commons | |
5 | +import qs.Ui | |
6 | + | |
7 | +BarWidget { | |
8 | + id: root | |
9 | + moduleName: "deomarchy.notification-center" | |
10 | + | |
11 | + // The notification service is a separate first-party plugin; reach it | |
12 | + // through the shell's service map so the bell reacts to live toasts. | |
13 | + readonly property var notifService: root.bar && root.bar.shell | |
14 | + ? (root.bar.shell.firstPartyServiceFor("omarchy.notifications") | |
15 | + || root.bar.shell.serviceFor("omarchy.notifications")) | |
16 | + : null | |
17 | + readonly property int liveCount: notifService ? notifService.popupModel.count : 0 | |
18 | + readonly property bool dndOn: notifService ? notifService.doNotDisturb : false | |
19 | + | |
20 | + readonly property bool opened: panelItem ? panelItem.opened === true : false | |
21 | + | |
22 | + function open() { if (panelItem) panelItem.open() } | |
23 | + function close() { if (panelItem) panelItem.close() } | |
24 | + function togglePanel() { if (panelItem) panelItem.toggle() } | |
25 | + | |
26 | + readonly property bool popoutSwitchClosing: panelItem ? panelItem.popoutSwitchClosing === true : false | |
27 | + function closeForPopoutSwitch() { if (panelItem) panelItem.closeForPopoutSwitch() } | |
28 | + | |
29 | + property var panelItem: null | |
30 | + | |
31 | + function injectPanel() { | |
32 | + var target = panelLoader.item | |
33 | + if (!target) return | |
34 | + panelItem = target | |
35 | + if ("bar" in target) target.bar = root.bar | |
36 | + if ("settings" in target) target.settings = root.settings | |
37 | + if ("anchorItem" in target) target.anchorItem = button | |
38 | + if ("hostWidget" in target) target.hostWidget = root | |
39 | + } | |
40 | + | |
41 | + implicitWidth: button.implicitWidth + (badge.visible ? badge.width : 0) | |
42 | + implicitHeight: button.implicitHeight | |
43 | + | |
44 | + onBarChanged: injectPanel() | |
45 | + onSettingsChanged: injectPanel() | |
46 | + | |
47 | + Loader { | |
48 | + id: panelLoader | |
49 | + active: true | |
50 | + source: Qt.resolvedUrl("Panel.qml") | |
51 | + visible: false | |
52 | + onLoaded: { | |
53 | + root.injectPanel() | |
54 | + Qt.callLater(root.injectPanel) | |
55 | + } | |
56 | + } | |
57 | + | |
58 | + IpcHandler { | |
59 | + target: "deomarchy.notification-center" | |
60 | + | |
61 | + function open(): void { root.open() } | |
62 | + function close(): void { root.close() } | |
63 | + function toggle(): void { root.togglePanel() } | |
64 | + } | |
65 | + | |
66 | + BarIconButton { | |
67 | + id: button | |
68 | + anchors.left: parent.left | |
69 | + anchors.verticalCenter: parent.verticalCenter | |
70 | + bar: root.bar | |
71 | + // md-bell / md-bell-ring / md-bell-off from JetBrainsMono Nerd Font — the | |
72 | + // family the bar already renders with, matching the other widgets. | |
73 | + text: String.fromCodePoint( | |
74 | + root.dndOn ? 0xF009C | |
75 | + : (root.liveCount > 0 ? 0xF009D : 0xF009A)) | |
76 | + tooltipText: { | |
77 | + if (root.dndOn) return "Notifications muted (DND)" | |
78 | + if (root.liveCount > 0) return root.liveCount + " notification" + (root.liveCount === 1 ? "" : "s") | |
79 | + return "Notification Center" | |
80 | + } | |
81 | + | |
82 | + onPressed: function(b) { | |
83 | + if (b === Qt.LeftButton) root.togglePanel() | |
84 | + else if (b === Qt.RightButton && root.notifService) | |
85 | + root.notifService.setDoNotDisturb(!root.notifService.doNotDisturb) | |
86 | + } | |
87 | + } | |
88 | + | |
89 | + // Unread pill beside the bell while live toasts are up. | |
90 | + Rectangle { | |
91 | + id: badge | |
92 | + visible: root.liveCount > 0 && !root.dndOn | |
93 | + anchors.left: button.right | |
94 | + anchors.leftMargin: -2 | |
95 | + anchors.top: parent.top | |
96 | + anchors.topMargin: 2 | |
97 | + implicitWidth: Math.max(14, countLabel.implicitWidth + 6) | |
98 | + implicitHeight: 14 | |
99 | + radius: height / 2 | |
100 | + color: Color.accent | |
101 | + | |
102 | + TextMetrics { | |
103 | + id: countText | |
104 | + text: root.liveCount > 9 ? "9+" : String(root.liveCount) | |
105 | + } | |
106 | + | |
107 | + Text { | |
108 | + id: countLabel | |
109 | + anchors.centerIn: parent | |
110 | + text: countText.text | |
111 | + color: Color.background | |
112 | + font.family: root.bar && root.bar.fontFamily ? root.bar.fontFamily : "JetBrainsMono Nerd Font" | |
113 | + font.pixelSize: 8 | |
114 | + font.bold: true | |
115 | + } | |
116 | + } | |
117 | +} | |
0 | 118 | |
▼shell/plugins/notification-center/Panel.qml
+440-0
| @@ -0,0 +1,440 @@ | ||
1 | +import QtQuick | |
2 | +import QtQuick.Controls | |
3 | +import QtQuick.Layouts | |
4 | +import Quickshell | |
5 | +import Quickshell.Io | |
6 | +import Quickshell.Services.Notifications | |
7 | +import qs.Commons | |
8 | +import qs.Ui | |
9 | + | |
10 | +Panel { | |
11 | + id: root | |
12 | + moduleName: "deomarchy.notification-center" | |
13 | + ipcTarget: "deomarchy.notification-center" | |
14 | + | |
15 | + implicitWidth: 440 | |
16 | + implicitHeight: 620 | |
17 | + | |
18 | + property var anchorItem: null | |
19 | + property var hostWidget: null | |
20 | + | |
21 | + // Bar-widget mounting injects the shell through `bar`; the shell's own | |
22 | + // panel loader injects it directly as `shell`. Accept either so the panel | |
23 | + // works even when this plugin has no bar widget placed. | |
24 | + property var shell: null | |
25 | + readonly property var shellRef: root.bar && root.bar.shell ? root.bar.shell : root.shell | |
26 | + | |
27 | + // The notification service lives in another first-party plugin; everything | |
28 | + // this panel shows flows through it: popupModel for live toasts, the | |
29 | + // history directory for what has already left the screen. | |
30 | + readonly property var notifService: { | |
31 | + var shellObj = root.shellRef | |
32 | + if (!shellObj) return null | |
33 | + return shellObj.firstPartyServiceFor("omarchy.notifications") | |
34 | + || shellObj.serviceFor("omarchy.notifications") | |
35 | + } | |
36 | + readonly property int liveCount: notifService ? notifService.popupModel.count : 0 | |
37 | + readonly property bool dndOn: notifService ? notifService.doNotDisturb : false | |
38 | + | |
39 | + // ---------------------------------------------------------------- history | |
40 | + | |
41 | + // Newest-first parsed entries straight from the service's history dir. | |
42 | + // The service owns the files; this panel only reads them. | |
43 | + property var historyEntries: [] | |
44 | + | |
45 | + Process { | |
46 | + id: historyReader | |
47 | + running: false | |
48 | + stdout: StdioCollector { | |
49 | + waitForEnd: true | |
50 | + onStreamFinished: { | |
51 | + var rows = [] | |
52 | + var lines = String(text || "").split("\n") | |
53 | + for (var i = 0; i < lines.length; i++) { | |
54 | + var line = lines[i].trim() | |
55 | + if (!line) continue | |
56 | + try { | |
57 | + var value = JSON.parse(line) | |
58 | + if (value && typeof value === "object") rows.push(value) | |
59 | + } catch (e) { | |
60 | + // Torn line — skip it, keep the rest. | |
61 | + } | |
62 | + } | |
63 | + rows.sort(function(a, b) { return (b.timestamp || 0) - (a.timestamp || 0) }) | |
64 | + root.historyEntries = rows | |
65 | + } | |
66 | + } | |
67 | + } | |
68 | + | |
69 | + function loadHistory() { | |
70 | + if (!notifService) return | |
71 | + historyReader.command = ["bash", "-c", | |
72 | + "awk 1 \"$1\"/*.json 2>/dev/null || true", "--", notifService.historyDir] | |
73 | + historyReader.running = true | |
74 | + } | |
75 | + | |
76 | + onOpenedChanged: if (opened) loadHistory() | |
77 | + Component.onCompleted: loadHistory() | |
78 | + | |
79 | + // A toast leaving the screen lands in history moments later; poll lightly | |
80 | + // while open instead of wiring into the service's file queue. | |
81 | + Timer { | |
82 | + interval: 5000 | |
83 | + repeat: true | |
84 | + running: root.opened | |
85 | + onTriggered: root.loadHistory() | |
86 | + } | |
87 | + | |
88 | + Connections { | |
89 | + target: root.notifService ? root.notifService.popupModel : null | |
90 | + ignoreUnknownSignals: true | |
91 | + function onCountChanged() { Qt.callLater(root.loadHistory) } | |
92 | + } | |
93 | + | |
94 | + // ---------------------------------------------------------------- helpers | |
95 | + | |
96 | + readonly property string nfFontFamily: "JetBrainsMono Nerd Font" | |
97 | + function nf(code) { return String.fromCodePoint(code) } | |
98 | + readonly property var ic: ({ | |
99 | + bell: 0xF009A, // md-bell-outline | |
100 | + bellRing: 0xF009D, // md-bell-ring | |
101 | + bellOff: 0xF009C, // md-bell-off | |
102 | + close: 0xF0156, // md-close | |
103 | + deleteAll: 0xF01B4, // md-delete-sweep | |
104 | + clock: 0xF0954 // md-clock-time-four-outline | |
105 | + }) | |
106 | + | |
107 | + function relativeTime(ts) { | |
108 | + var delta = Math.max(0, Date.now() - (ts || 0)) | |
109 | + var minutes = Math.floor(delta / 60000) | |
110 | + if (minutes < 1) return "now" | |
111 | + if (minutes < 60) return minutes + "m" | |
112 | + var hours = Math.floor(minutes / 60) | |
113 | + if (hours < 24) return hours + "h" | |
114 | + return Math.floor(hours / 24) + "d" | |
115 | + } | |
116 | + | |
117 | + function urgencyColor(urgency) { | |
118 | + return urgency === NotificationUrgency.Critical ? Color.urgent : "transparent" | |
119 | + } | |
120 | + | |
121 | + function rowFont() { | |
122 | + return root.bar && root.bar.fontFamily ? root.bar.fontFamily : nfFontFamily | |
123 | + } | |
124 | + | |
125 | + // ---------------------------------------------------------------- layout | |
126 | + | |
127 | + ColumnLayout { | |
128 | + anchors.fill: parent | |
129 | + spacing: Style.space(8) | |
130 | + | |
131 | + // Header: title, DND toggle, clear-history. | |
132 | + RowLayout { | |
133 | + Layout.fillWidth: true | |
134 | + Layout.margins: Style.space(10) | |
135 | + spacing: Style.space(8) | |
136 | + | |
137 | + Text { | |
138 | + text: nf(root.dndOn ? ic.bellOff : (root.liveCount > 0 ? ic.bellRing : ic.bell)) | |
139 | + font.family: nfFontFamily | |
140 | + font.pixelSize: 18 | |
141 | + color: root.dndOn ? Color.muted : Color.accent | |
142 | + } | |
143 | + | |
144 | + Text { | |
145 | + Layout.fillWidth: true | |
146 | + text: root.dndOn ? "Notifications (muted)" : "Notifications" | |
147 | + color: root.barForeground | |
148 | + font.family: rowFont() | |
149 | + font.pixelSize: 14 | |
150 | + font.bold: true | |
151 | + elide: Text.ElideRight | |
152 | + } | |
153 | + | |
154 | + Button { | |
155 | + id: dndButton | |
156 | + checkable: true | |
157 | + checked: root.dndOn | |
158 | + text: checked ? "DND on" : "DND off" | |
159 | + font.family: rowFont() | |
160 | + font.pixelSize: 11 | |
161 | + | |
162 | + onClicked: if (root.notifService) root.notifService.setDoNotDisturb(checked) | |
163 | + } | |
164 | + | |
165 | + Button { | |
166 | + id: clearButton | |
167 | + enabled: root.historyEntries.length > 0 | |
168 | + font.family: nfFontFamily | |
169 | + font.pixelSize: 13 | |
170 | + text: nf(ic.deleteAll) | |
171 | + ToolTip.visible: hovered | |
172 | + ToolTip.text: "Clear history" | |
173 | + | |
174 | + onClicked: { | |
175 | + if (!root.notifService) return | |
176 | + root.notifService.clearHistory() | |
177 | + Qt.callLater(root.loadHistory) | |
178 | + } | |
179 | + } | |
180 | + } | |
181 | + | |
182 | + // Body: live toasts first, then history. | |
183 | + Flickable { | |
184 | + id: scroll | |
185 | + Layout.fillWidth: true | |
186 | + Layout.fillHeight: true | |
187 | + contentWidth: width | |
188 | + contentHeight: bodyColumn.implicitHeight + Style.space(20) | |
189 | + clip: true | |
190 | + ScrollBar.vertical: ScrollBar {} | |
191 | + | |
192 | + ColumnLayout { | |
193 | + id: bodyColumn | |
194 | + width: scroll.width | |
195 | + spacing: Style.space(4) | |
196 | + | |
197 | + Text { | |
198 | + visible: root.liveCount > 0 | |
199 | + leftPadding: Style.space(10) | |
200 | + text: "ON SCREEN" | |
201 | + color: Color.muted | |
202 | + font.family: rowFont() | |
203 | + font.pixelSize: 9 | |
204 | + font.bold: true | |
205 | + font.letterSpacing: 1 | |
206 | + } | |
207 | + | |
208 | + Repeater { | |
209 | + model: root.notifService ? root.notifService.popupModel : null | |
210 | + | |
211 | + delegate: RowLayout { | |
212 | + id: liveRow | |
213 | + required property int index | |
214 | + required property string app | |
215 | + required property string summary | |
216 | + required property string body | |
217 | + required property string image | |
218 | + required property string glyph | |
219 | + required property int urgency | |
220 | + required property double timestamp | |
221 | + | |
222 | + Layout.fillWidth: true | |
223 | + Layout.leftMargin: Style.space(6) | |
224 | + Layout.rightMargin: Style.space(6) | |
225 | + spacing: Style.space(8) | |
226 | + | |
227 | + Rectangle { | |
228 | + width: 3 | |
229 | + implicitHeight: liveRowCard.implicitHeight | |
230 | + radius: 1 | |
231 | + color: root.urgencyColor(liveRow.urgency) | |
232 | + } | |
233 | + | |
234 | + Rectangle { | |
235 | + id: liveRowCard | |
236 | + Layout.fillWidth: true | |
237 | + implicitHeight: liveRowTexts.implicitHeight + Style.space(12) | |
238 | + radius: 8 | |
239 | + color: liveRowMa.containsMouse ? Color.selection : "transparent" | |
240 | + | |
241 | + MouseArea { | |
242 | + id: liveRowMa | |
243 | + anchors.fill: parent | |
244 | + hoverEnabled: true | |
245 | + cursorShape: Qt.PointingHandCursor | |
246 | + onClicked: if (root.notifService) root.notifService.invokePopupDefault(liveRow.index) | |
247 | + } | |
248 | + | |
249 | + ColumnLayout { | |
250 | + id: liveRowTexts | |
251 | + anchors.left: parent.left | |
252 | + anchors.right: dismissButton.left | |
253 | + anchors.rightMargin: Style.space(4) | |
254 | + anchors.verticalCenter: parent.verticalCenter | |
255 | + spacing: 2 | |
256 | + | |
257 | + Text { | |
258 | + Layout.fillWidth: true | |
259 | + text: liveRow.summary || "(no title)" | |
260 | + color: root.barForeground | |
261 | + font.family: rowFont() | |
262 | + font.pixelSize: 12 | |
263 | + font.bold: true | |
264 | + elide: Text.ElideRight | |
265 | + } | |
266 | + Text { | |
267 | + Layout.fillWidth: true | |
268 | + visible: liveRow.body !== "" | |
269 | + text: liveRow.body | |
270 | + color: root.barForeground | |
271 | + opacity: 0.75 | |
272 | + font.family: rowFont() | |
273 | + font.pixelSize: 11 | |
274 | + wrapMode: Text.WordWrap | |
275 | + maximumLineCount: 3 | |
276 | + elide: Text.ElideRight | |
277 | + } | |
278 | + Text { | |
279 | + Layout.fillWidth: true | |
280 | + text: (liveRow.app || "") + " · " + root.relativeTime(liveRow.timestamp) | |
281 | + color: Color.muted | |
282 | + font.family: rowFont() | |
283 | + font.pixelSize: 9 | |
284 | + elide: Text.ElideRight | |
285 | + } | |
286 | + } | |
287 | + | |
288 | + Text { | |
289 | + id: dismissButton | |
290 | + anchors.right: parent.right | |
291 | + anchors.rightMargin: Style.space(8) | |
292 | + anchors.verticalCenter: parent.verticalCenter | |
293 | + text: nf(ic.close) | |
294 | + font.family: nfFontFamily | |
295 | + font.pixelSize: 13 | |
296 | + color: dismissMa.containsMouse ? root.barForeground : Color.muted | |
297 | + | |
298 | + MouseArea { | |
299 | + id: dismissMa | |
300 | + anchors.fill: parent | |
301 | + hoverEnabled: true | |
302 | + cursorShape: Qt.PointingHandCursor | |
303 | + onClicked: if (root.notifService) root.notifService.dismissPopup(liveRow.index) | |
304 | + } | |
305 | + } | |
306 | + } | |
307 | + } | |
308 | + } | |
309 | + | |
310 | + Item { Layout.preferredHeight: root.liveCount > 0 ? Style.space(8) : 0 } | |
311 | + | |
312 | + Text { | |
313 | + visible: root.historyEntries.length > 0 | |
314 | + leftPadding: Style.space(10) | |
315 | + text: "HISTORY" | |
316 | + color: Color.muted | |
317 | + font.family: rowFont() | |
318 | + font.pixelSize: 9 | |
319 | + font.bold: true | |
320 | + font.letterSpacing: 1 | |
321 | + } | |
322 | + | |
323 | + Repeater { | |
324 | + model: root.historyEntries | |
325 | + | |
326 | + delegate: RowLayout { | |
327 | + id: histRow | |
328 | + required property int index | |
329 | + required property var modelData | |
330 | + | |
331 | + Layout.fillWidth: true | |
332 | + Layout.leftMargin: Style.space(6) | |
333 | + Layout.rightMargin: Style.space(6) | |
334 | + spacing: Style.space(8) | |
335 | + | |
336 | + Rectangle { | |
337 | + width: 3 | |
338 | + implicitHeight: histCard.implicitHeight | |
339 | + radius: 1 | |
340 | + color: root.urgencyColor(histRow.modelData.urgency) | |
341 | + } | |
342 | + | |
343 | + Rectangle { | |
344 | + id: histCard | |
345 | + Layout.fillWidth: true | |
346 | + implicitHeight: histTexts.implicitHeight + Style.space(12) | |
347 | + radius: 8 | |
348 | + color: histMa.containsMouse ? Color.selection : "transparent" | |
349 | + | |
350 | + MouseArea { | |
351 | + id: histMa | |
352 | + anchors.fill: parent | |
353 | + hoverEnabled: true | |
354 | + cursorShape: histRow.modelData.exec ? Qt.PointingHandCursor : Qt.ArrowCursor | |
355 | + onClicked: { | |
356 | + // Persisted toasts carry their omarchy-action command; fire it | |
357 | + // detached exactly like the live-popup path does. | |
358 | + var cmd = String(histRow.modelData.exec || "") | |
359 | + if (cmd) Util.execDetached(cmd) | |
360 | + } | |
361 | + } | |
362 | + | |
363 | + ColumnLayout { | |
364 | + id: histTexts | |
365 | + anchors.left: parent.left | |
366 | + anchors.right: parent.right | |
367 | + anchors.rightMargin: Style.space(8) | |
368 | + anchors.verticalCenter: parent.verticalCenter | |
369 | + spacing: 2 | |
370 | + | |
371 | + Text { | |
372 | + Layout.fillWidth: true | |
373 | + text: histRow.modelData.summary || "(no title)" | |
374 | + color: root.barForeground | |
375 | + opacity: 0.9 | |
376 | + font.family: rowFont() | |
377 | + font.pixelSize: 12 | |
378 | + elide: Text.ElideRight | |
379 | + } | |
380 | + Text { | |
381 | + Layout.fillWidth: true | |
382 | + visible: !!histRow.modelData.body | |
383 | + text: histRow.modelData.body | |
384 | + color: root.barForeground | |
385 | + opacity: 0.6 | |
386 | + font.family: rowFont() | |
387 | + font.pixelSize: 11 | |
388 | + maximumLineCount: 2 | |
389 | + wrapMode: Text.WordWrap | |
390 | + elide: Text.ElideRight | |
391 | + } | |
392 | + Text { | |
393 | + Layout.fillWidth: true | |
394 | + text: (histRow.modelData.app || "") + " · " + root.relativeTime(histRow.modelData.timestamp) | |
395 | + + (histRow.modelData.exec ? " · click to run action" : "") | |
396 | + color: Color.muted | |
397 | + font.family: rowFont() | |
398 | + font.pixelSize: 9 | |
399 | + elide: Text.ElideRight | |
400 | + } | |
401 | + } | |
402 | + } | |
403 | + } | |
404 | + } | |
405 | + | |
406 | + // Empty state — shown only when there is truly nothing anywhere. | |
407 | + ColumnLayout { | |
408 | + visible: root.liveCount === 0 && root.historyEntries.length === 0 | |
409 | + Layout.alignment: Qt.AlignHCenter | |
410 | + Layout.topMargin: Style.space(48) | |
411 | + spacing: Style.space(10) | |
412 | + | |
413 | + Text { | |
414 | + Layout.alignment: Qt.AlignHCenter | |
415 | + text: nf(ic.bell) | |
416 | + font.family: nfFontFamily | |
417 | + font.pixelSize: 32 | |
418 | + color: Color.muted | |
419 | + opacity: 0.5 | |
420 | + } | |
421 | + Text { | |
422 | + Layout.alignment: Qt.AlignHCenter | |
423 | + text: "No notifications" | |
424 | + color: Color.muted | |
425 | + font.family: rowFont() | |
426 | + font.pixelSize: 12 | |
427 | + } | |
428 | + Text { | |
429 | + Layout.alignment: Qt.AlignHCenter | |
430 | + text: "SUPER + ALT + N opens this panel" | |
431 | + color: Color.muted | |
432 | + opacity: 0.7 | |
433 | + font.family: rowFont() | |
434 | + font.pixelSize: 10 | |
435 | + } | |
436 | + } | |
437 | + } | |
438 | + } | |
439 | + } | |
440 | +} | |
0 | 441 | |
▼shell/plugins/notification-center/manifest.json
+23-0
| @@ -0,0 +1,23 @@ | ||
1 | +{ | |
2 | + "schemaVersion": 1, | |
3 | + "id": "deomarchy.notification-center", | |
4 | + "name": "Notification Center", | |
5 | + "version": "1.0.0", | |
6 | + "author": "de-omarchy", | |
7 | + "description": "Browse live toasts and notification history, clear, and toggle DND from the bar", | |
8 | + "kinds": [ | |
9 | + "bar-widget", | |
10 | + "panel" | |
11 | + ], | |
12 | + "entryPoints": { | |
13 | + "barWidget": "BarWidget.qml", | |
14 | + "panel": "Panel.qml" | |
15 | + }, | |
16 | + "barWidget": { | |
17 | + "displayName": "Notification Center", | |
18 | + "description": "Live notifications and history, DND toggle", | |
19 | + "category": "System", | |
20 | + "allowMultiple": false, | |
21 | + "defaultSection": "right" | |
22 | + } | |
23 | +} | |
0 | 24 | |
▼shell/plugins/notifications/Service.qml
+30-12
| @@ -5,6 +5,7 @@ import QtQuick.Layouts | ||
5 | 5 | import Quickshell |
6 | 6 | import Quickshell.Io |
7 | 7 | import Quickshell.Wayland |
8 | +import Quickshell.Hyprland | |
8 | 9 | import Quickshell.Services.Notifications |
9 | 10 | import qs.Commons |
10 | 11 | |
| @@ -95,26 +96,27 @@ Item { | ||
95 | 96 | // many `showHistory` can replay. |
96 | 97 | readonly property int historyLimit: 10 |
97 | 98 | |
98 | - readonly property int lowPopupDuration: 5000 | |
99 | - readonly property int normalPopupDuration: 8000 | |
100 | - readonly property int maxPopupDuration: 30000 | |
99 | + // Every toast leaves the screen on its own. Low/normal get 3s (a hovered | |
100 | + // card pauses the countdown, so longer reading is still possible); | |
101 | + // critical used to never expire — it now gets a longer but finite window, | |
102 | + // and lands in history either way. | |
103 | + readonly property int popupDuration: 3000 | |
104 | + readonly property int criticalPopupDuration: 6000 | |
101 | 105 | |
102 | 106 | function durationFor(urgency, expireTimeout) { |
103 | 107 | switch (urgency) { |
104 | 108 | case NotificationUrgency.Critical: |
105 | - return 0 | |
106 | - case NotificationUrgency.Low: | |
107 | - return Math.min(maxPopupDuration, Math.max(lowPopupDuration, requestedDuration(expireTimeout))) | |
109 | + return Math.min(criticalPopupDuration, requestedDurationOr(expireTimeout, criticalPopupDuration)) | |
108 | 110 | default: |
109 | - return Math.min(maxPopupDuration, Math.max(normalPopupDuration, requestedDuration(expireTimeout))) | |
111 | + return Math.min(popupDuration, requestedDurationOr(expireTimeout, popupDuration)) | |
110 | 112 | } |
111 | 113 | } |
112 | 114 | |
113 | - function requestedDuration(expireTimeout) { | |
114 | - // FreeDesktop notification spec (and Quickshell) report expireTimeout in | |
115 | - // milliseconds, so pass it through directly. | |
115 | + // A sender may ask for less than the cap; anything at or above it is | |
116 | + // clamped to the cap so no toast outstays its welcome. | |
117 | + function requestedDurationOr(expireTimeout, fallback) { | |
116 | 118 | var ms = Number(expireTimeout || 0) |
117 | - if (!isFinite(ms) || ms <= 0) return 0 | |
119 | + if (!isFinite(ms) || ms <= 0) return fallback | |
118 | 120 | return Math.round(ms) |
119 | 121 | } |
120 | 122 | |
| @@ -947,6 +949,22 @@ Item { | ||
947 | 949 | // stacked toast cards. Layer is Overlay, exclusionMode Ignore, no |
948 | 950 | // keyboard focus — popups are passive surfaces and must never steal input |
949 | 951 | // from the focused application. |
952 | + // | |
953 | + // With more than one display, only the monitor holding Hyprland's focused | |
954 | + // workspace shows toasts; every other surface is parked invisible so a | |
955 | + // focus change is a visibility flip, not surface teardown (the same trick | |
956 | + // the bar uses for hide). Without a readable focus, fall back to the first | |
957 | + // screen so notifications always land somewhere. | |
958 | + | |
959 | + readonly property string focusedScreenName: { | |
960 | + try { | |
961 | + var monitor = Hyprland.focusedMonitor | |
962 | + if (monitor && monitor.name) return String(monitor.name) | |
963 | + } catch (e) { | |
964 | + // No Hyprland integration in this process — use the fallback below. | |
965 | + } | |
966 | + return Quickshell.screens.length > 0 ? String(Quickshell.screens[0].name) : "" | |
967 | + } | |
950 | 968 | |
951 | 969 | Variants { |
952 | 970 | model: Quickshell.screens |
| @@ -955,7 +973,7 @@ Item { | ||
955 | 973 | id: popupWindow |
956 | 974 | required property var modelData |
957 | 975 | screen: modelData |
958 | - visible: popupModel.count > 0 | |
976 | + visible: popupModel.count > 0 && modelData.name === service.focusedScreenName | |
959 | 977 | |
960 | 978 | WlrLayershell.namespace: "omarchy-notifications" |
961 | 979 | WlrLayershell.layer: WlrLayer.Overlay |
962 | 980 | |