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
▼config/omarchy/shell.json
+4-1
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
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
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
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
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
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
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
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