diff --git a/shell/plugins/README.md b/shell/plugins/README.md index 30ecf81c..ab89b02e 100644 --- a/shell/plugins/README.md +++ b/shell/plugins/README.md @@ -19,7 +19,7 @@ User-installed plugins live alongside these conceptually but on disk under | Clipboard mgr | `omarchy.clipboard` | `overlay` | `clipboard/Clipboard.qml` | | Reminders | `omarchy.reminders` | `overlay` | `reminders/ReminderFlow.qml` | | Omarchy menu | `omarchy.menu` | `menu`, `bar-widget` | `menu/Menu.qml`, `menu/BarWidget.qml` | -| Notifications | `omarchy.notifications` | `service`, `bar-widget` | `notifications/Service.qml`, `notifications/BarWidget.qml` | +| Notifications | `omarchy.notifications` | `service` | `notifications/Service.qml` | | Audio | `omarchy.audio` | `bar-widget` | `panels/audio/Panel.qml` | | Bluetooth | `omarchy.bluetooth` | `bar-widget` | `panels/bluetooth/Panel.qml` | | Monitor | `omarchy.monitor` | `bar-widget` | `panels/monitor/Panel.qml` | diff --git a/shell/plugins/bar/README.md b/shell/plugins/bar/README.md index 3606e9ab..a1d93cc6 100644 --- a/shell/plugins/bar/README.md +++ b/shell/plugins/bar/README.md @@ -59,7 +59,6 @@ Example `shell.json` (bar subtree only shown): | `omarchy.clock` | Date/time label | left = alternate format · right = timezone selector | | `omarchy.media` | MPRIS now-playing — scrolling track + artist, cover-art popup | left = play/pause · middle = next · scroll = prev/next · right = popup | | `omarchy.indicators` | Manual state indicators | left = indicator action | -| `omarchy.notifications` | Bell with badge + popup with recent notifications, DND toggle | left = popup · right = toggle DND | | `omarchy.system-update` | Available update indicator | left = update | | `omarchy.tray` | System tray | hover = reveal drawer · right on chevron = manage | | `omarchy.weather` | Weather icon + popup with forecast | left = popup · right = full notification | @@ -169,8 +168,8 @@ Widgets receive `bar` (the shell root), `moduleName` (string), and `settings` (o First-party bar widgets are manifest-backed just like third-party widgets. Simple widgets carry sibling manifests such as `widgets/Clock.manifest.json`; richer popup plugins live in feature directories such as `../panels/audio/`, -`../panels/network/`, and `../model-usage/`; and feature plugins such as `omarchy.menu`, `omarchy.media`, and -`omarchy.notifications` declare their bar-widget entry points in their own +`../panels/network/`, and `../model-usage/`; and feature plugins such as +`omarchy.menu` and `omarchy.media` declare their bar-widget entry points in their own `manifest.json`. Bar layout ids are namespaced, e.g. `omarchy.audio`, `omarchy.network`, and `omarchy.clock`. Older UpperCamelCase ids such as `AudioPanel` and `Clock` are migrated forward; new configs should use the diff --git a/shell/plugins/notifications/BarWidget.qml b/shell/plugins/notifications/BarWidget.qml deleted file mode 100644 index c517dfa6..00000000 --- a/shell/plugins/notifications/BarWidget.qml +++ /dev/null @@ -1,412 +0,0 @@ -import QtQuick -import QtQuick.Layouts -import Quickshell -import qs.Commons -import qs.Ui -import "NotificationLogic.js" as NotificationLogic - -BarWidget { - id: root - moduleName: "omarchy.notifications" - - - property bool popupOpen: false - function close() { popupOpen = false } - - // Always default to the pending tab when there's anything unseen, no - // matter how the popup was opened (click, keybind/IPC, or the close - // path). Keeps the spec from drifting based on the user's last manual - // tab selection. - onPopupOpenChanged: { - if (popupOpen) { - activeTab = pendingCount > 0 ? "pending" : "past" - } - } - - // Look up the long-running notifications service through the shell host. - readonly property var hostShell: bar && bar.shell ? bar.shell : null - readonly property var notificationService: hostShell?.firstPartyServiceFor("omarchy.notifications") - - function isChromiumDerived(app, appIcon) { - return NotificationLogic.isChromiumDerived(app, appIcon) - } - - function sanitizeBody(s, app, appIcon) { - return NotificationLogic.sanitizeBody(s, app, appIcon) - } - - function notificationIconSource(icon) { - var value = String(icon || "") - if (value.length === 0) return "" - if (value.indexOf("file://") === 0 || value.indexOf("image://") === 0) return value - if (value.charAt(0) === "/") return Util.fileUrl(value) - return Quickshell.iconPath(value, true) - } - - readonly property int pendingCount: notificationService ? notificationService.pendingModel.count : 0 - readonly property int pastCount: notificationService ? notificationService.pastModel.count : 0 - readonly property bool dnd: notificationService ? notificationService.doNotDisturb : false - - // Which tab is active in the popup. Auto-selects pending when there's - // something unseen; otherwise opens past. - property string activeTab: "pending" - - readonly property string icon: { - if (dnd) return "󰂛" - if (pendingCount > 0) return "󱅫" - return "󰂚" - } - - // Theme palette (mirrors HistoryPanel's tokens so the popup matches the - // rest of the notification stack). - readonly property color colForeground: Color.foreground - readonly property color colDim: Qt.darker(Color.foreground, 1.4) - readonly property color colBorder: Style.normalBorderFor(Color.foreground, Color.accent) - readonly property color colSurface: Style.normalFillFor(Color.foreground, Color.accent) - readonly property color colAccent: Color.accent - readonly property int cardRadius: notificationService ? notificationService.cornerRadius : 0 - - implicitWidth: button.implicitWidth - implicitHeight: button.implicitHeight - - WidgetButton { - id: button - anchors.fill: parent - bar: root.bar - text: root.icon - active: root.pendingCount > 0 && !root.dnd - tooltipText: root.dnd ? "Do Not Disturb" - : (root.pendingCount > 0 ? root.pendingCount + " pending" : "No notifications") - - onPressed: function(b) { - if (b === Qt.RightButton) { - if (root.notificationService) { - root.notificationService.setDoNotDisturb(!root.notificationService.doNotDisturb) - } - } else { - root.popupOpen = !root.popupOpen - } - } - } - - PopupCard { - id: popup - anchorItem: button - bar: root.bar - owner: root - open: root.popupOpen - contentWidth: popup.fittedContentWidth(Style.space(440)) - contentHeight: popup.cappedContentHeight(Style.space(540)) - - ColumnLayout { - anchors.fill: parent - spacing: Style.space(10) - - // ----------------------------------------- header - RowLayout { - Layout.fillWidth: true - spacing: Style.space(8) - - Text { - text: "Notifications" - font.family: root.bar ? root.bar.fontFamily : "" - color: root.colForeground - font.pixelSize: Style.font.title - font.bold: true - } - - Item { Layout.fillWidth: true } - - BorderSurface { - id: dndPill - Layout.preferredHeight: Math.max(Style.space(24), Style.font.bodySmall + Style.spacing.controlPaddingY * 2) - Layout.preferredWidth: dndLabel.implicitWidth + dndGlyph.implicitWidth + Style.space(18) - radius: Math.min(Style.space(12), root.cardRadius + Style.space(6)) - color: dndOn ? root.colAccent : root.colSurface - borderSpec: Border.flat(dndOn ? root.colAccent : root.colBorder, Style.normalBorderWidth) - - readonly property bool dndOn: !!root.notificationService && root.notificationService.doNotDisturb - - Row { - anchors.centerIn: parent - spacing: Style.space(4) - - Text { - id: dndGlyph - text: dndPill.dndOn ? "󰂛" : "󰂚" - font.family: root.bar ? root.bar.fontFamily : "" - color: dndPill.dndOn ? Color.background : root.colDim - font.pixelSize: Style.font.body - anchors.verticalCenter: parent.verticalCenter - } - - Text { - id: dndLabel - text: dndPill.dndOn ? "DND on" : "DND off" - font.family: root.bar ? root.bar.fontFamily : "" - color: dndPill.dndOn ? Color.background : root.colDim - font.pixelSize: Style.font.caption - font.bold: true - anchors.verticalCenter: parent.verticalCenter - } - } - - MouseArea { - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: if (root.notificationService) root.notificationService.setDoNotDisturb(!dndPill.dndOn) - } - } - } - - // ----------------------------------------- tabs - RowLayout { - Layout.fillWidth: true - spacing: 0 - - Repeater { - model: [ - { key: "pending", label: "Pending", count: root.pendingCount }, - { key: "past", label: "Recently", count: root.pastCount } - ] - delegate: Rectangle { - required property var modelData - readonly property bool isActive: root.activeTab === modelData.key - - Layout.fillWidth: true - Layout.preferredHeight: Math.max(Style.space(30), Style.font.body + Style.spacing.controlPaddingY * 2) - color: "transparent" - - Text { - anchors.centerIn: parent - text: modelData.label + (modelData.count > 0 ? " " + modelData.count : "") - font.family: root.bar ? root.bar.fontFamily : "" - color: parent.isActive ? root.colForeground : root.colDim - font.pixelSize: Style.font.body - font.bold: parent.isActive - } - - Rectangle { - anchors.left: parent.left - anchors.right: parent.right - anchors.bottom: parent.bottom - height: Math.max(1, Style.space(2)) - color: parent.isActive ? root.colAccent : root.colBorder - opacity: parent.isActive ? 1 : 0.4 - } - - MouseArea { - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - onClicked: root.activeTab = modelData.key - } - } - } - } - - // ----------------------------------------- action row - RowLayout { - Layout.fillWidth: true - visible: (root.activeTab === "pending" && root.pendingCount > 0) - || (root.activeTab === "past" && root.pastCount > 0) - spacing: Style.space(8) - - Item { Layout.fillWidth: true } - - BorderSurface { - Layout.preferredWidth: actionLabel.implicitWidth + Style.space(16) - Layout.preferredHeight: Math.max(Style.space(22), Style.font.bodySmall + Style.spacing.controlPaddingY * 2) - radius: Math.min(Style.space(6), root.cardRadius) - color: actionArea.containsMouse ? root.colBorder : "transparent" - borderSpec: Border.flat(root.colBorder, Style.normalBorderWidth) - - Text { - id: actionLabel - anchors.centerIn: parent - text: root.activeTab === "pending" ? "Mark all as seen" : "Clear recent" - font.family: root.bar ? root.bar.fontFamily : "" - color: root.colForeground - font.pixelSize: Style.font.caption - } - - MouseArea { - id: actionArea - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: { - if (!root.notificationService) return - if (root.activeTab === "pending") root.notificationService.markAllSeen() - else root.notificationService.clearPast() - } - } - } - } - - // ----------------------------------------- list - ListView { - id: listView - Layout.fillWidth: true - Layout.fillHeight: true - clip: true - spacing: Style.space(8) - - readonly property bool onPending: root.activeTab === "pending" - model: !root.notificationService ? null - : (onPending ? root.notificationService.pendingModel : root.notificationService.pastModel) - visible: count > 0 - - delegate: BorderSurface { - id: rowCard - required property int index - required property string app - required property string appIcon - required property string summary - required property string body - required property string image - required property int urgency - required property double timestamp - - readonly property bool hasMedia: image.length > 0 && ( - image.indexOf("image://icon//") === 0 || image.indexOf("file://") === 0) - readonly property string smallIconSource: image.length > 0 ? image : root.notificationIconSource(appIcon) - readonly property bool hasIcon: !hasMedia && smallIconSource.length > 0 - readonly property string sanitizedBody: root.sanitizeBody(body, app, appIcon) - - width: listView.width - implicitHeight: rowContent.implicitHeight + Style.spacing.panelGap - radius: root.cardRadius - color: "transparent" - borderSpec: Border.flat(root.colBorder, Style.normalBorderWidth) - - MouseArea { - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - onClicked: { /* no-op */ } - } - - RowLayout { - id: rowContent - anchors.left: parent.left - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - anchors.leftMargin: rowCard.borderLeft + Style.space(12) - anchors.rightMargin: rowCard.borderRight + Style.space(12) - spacing: Style.space(10) - - Item { - id: imageSlot - Layout.preferredWidth: Style.space(32) - Layout.preferredHeight: Style.space(32) - Layout.alignment: Qt.AlignVCenter - // Hide on icon load failure so unresolved themed-icon names - // don't render Qt's broken-image placeholder. - visible: (rowCard.hasIcon || rowCard.hasMedia) && rowIconImage.status !== Image.Error - - Image { - id: rowIconImage - anchors.fill: parent - source: rowCard.hasMedia ? rowCard.image : rowCard.smallIconSource - fillMode: rowCard.hasMedia ? Image.PreserveAspectCrop : Image.PreserveAspectFit - sourceSize.width: imageSlot.width * Screen.devicePixelRatio - sourceSize.height: imageSlot.height * Screen.devicePixelRatio - asynchronous: true - smooth: true - } - } - - ColumnLayout { - Layout.fillWidth: true - spacing: Style.space(2) - - Text { - Layout.fillWidth: true - visible: rowCard.summary.length > 0 - text: rowCard.summary - font.family: root.bar ? root.bar.fontFamily : "" - color: root.colForeground - font.pixelSize: Style.font.subtitle - font.bold: true - wrapMode: Text.WordWrap - elide: Text.ElideRight - maximumLineCount: 1 - } - - Text { - Layout.fillWidth: true - visible: rowCard.sanitizedBody.length > 0 - text: rowCard.sanitizedBody - font.family: root.bar ? root.bar.fontFamily : "" - textFormat: Text.PlainText - color: root.colDim - font.pixelSize: Style.font.bodySmall - wrapMode: Text.WordWrap - elide: Text.ElideRight - maximumLineCount: 2 - } - } - - Rectangle { - Layout.preferredWidth: Style.space(18) - Layout.preferredHeight: Style.space(18) - Layout.alignment: Qt.AlignVCenter - radius: Math.min(4, root.cardRadius) - color: rowCloseArea.containsMouse ? root.colBorder : "transparent" - - Text { - anchors.centerIn: parent - text: "✕" - font.family: root.bar ? root.bar.fontFamily : "" - color: root.colDim - font.pixelSize: Style.font.bodySmall - } - - MouseArea { - id: rowCloseArea - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: { - if (!root.notificationService) return - if (listView.onPending) root.notificationService.dismissPending(rowCard.index) - else root.notificationService.dismissPast(rowCard.index) - } - } - } - } - } - } - - // ----------------------------------------- empty state - Item { - Layout.fillWidth: true - Layout.fillHeight: true - visible: listView.count === 0 - - ColumnLayout { - anchors.centerIn: parent - spacing: Style.space(6) - - Text { - Layout.alignment: Qt.AlignHCenter - text: "󰂚" - font.family: root.bar ? root.bar.fontFamily : "" - color: root.colBorder - font.pixelSize: Style.font.displayLarge - } - - Text { - Layout.alignment: Qt.AlignHCenter - text: root.activeTab === "pending" - ? "Nothing waiting for you" - : "Nothing recent" - font.family: root.bar ? root.bar.fontFamily : "" - color: root.colDim - font.pixelSize: Style.font.body - } - } - } - } - } -} diff --git a/shell/plugins/notifications/manifest.json b/shell/plugins/notifications/manifest.json index 404f6864..be71e3c2 100644 --- a/shell/plugins/notifications/manifest.json +++ b/shell/plugins/notifications/manifest.json @@ -4,20 +4,12 @@ "name": "Notifications", "version": "1.0.0", "author": "Omarchy", - "description": "Notification daemon, popups, and history", + "description": "Notification daemon, popups, DND, and history", "kinds": [ - "service", - "bar-widget" + "service" ], "keepLoaded": true, "entryPoints": { - "service": "Service.qml", - "barWidget": "BarWidget.qml" - }, - "barWidget": { - "displayName": "Notification center", - "description": "Recent notifications + DND", - "category": "Status", - "allowMultiple": false + "service": "Service.qml" } } diff --git a/test/shell.d/fixtures/bar-widget-contract/shell.qml b/test/shell.d/fixtures/bar-widget-contract/shell.qml index f06260db..34c2885d 100644 --- a/test/shell.d/fixtures/bar-widget-contract/shell.qml +++ b/test/shell.d/fixtures/bar-widget-contract/shell.qml @@ -110,24 +110,13 @@ ShellRoot { Item { id: host } - QtObject { - id: mockNotificationService - property bool doNotDisturb: false - property ListModel pendingModel: ListModel {} - property ListModel pastModel: ListModel {} - function setDoNotDisturb(value) { doNotDisturb = !!value } - } - QtObject { id: mockShell property var bar: fakeBar property var barConfig: ({ position: "top" }) property var shellConfig: ({ version: 1, idle: {}, plugins: [], bar: { layout: { left: [], center: [], right: [] } } }) - function firstPartyServiceFor(id) { - if (id === "omarchy.notifications") return mockNotificationService - return null - } - function serviceFor(id) { return firstPartyServiceFor(id) } + function firstPartyServiceFor(id) { return null } + function serviceFor(id) { return null } function summon(id, payloadJson) { return true } function hide(id) { return true } function toggle(id, payloadJson) { return true } diff --git a/test/shell.d/fixtures/manifest-entrypoints/shell.qml b/test/shell.d/fixtures/manifest-entrypoints/shell.qml index c9a6fa34..7605eb4e 100644 --- a/test/shell.d/fixtures/manifest-entrypoints/shell.qml +++ b/test/shell.d/fixtures/manifest-entrypoints/shell.qml @@ -137,24 +137,13 @@ ShellRoot { function rescan() {} } - QtObject { - id: mockNotificationService - property bool doNotDisturb: false - property ListModel pendingModel: ListModel {} - property ListModel pastModel: ListModel {} - function setDoNotDisturb(value) { doNotDisturb = !!value } - } - QtObject { id: mockShell property var bar: fakeBar property var barConfig: ({ position: "top" }) property var shellConfig: ({ version: 1, idle: {}, plugins: [], bar: { layout: { left: [], center: [], right: [] } } }) - function firstPartyServiceFor(id) { - if (id === "omarchy.notifications") return mockNotificationService - return null - } - function serviceFor(id) { return firstPartyServiceFor(id) } + function firstPartyServiceFor(id) { return null } + function serviceFor(id) { return null } function summon(id, payloadJson) { return true } function hide(id) { return true } function toggle(id, payloadJson) { return true } diff --git a/test/shell.d/notifications-test.sh b/test/shell.d/notifications-test.sh index 6f0a852a..27651836 100644 --- a/test/shell.d/notifications-test.sh +++ b/test/shell.d/notifications-test.sh @@ -188,7 +188,6 @@ assertEqual(notifications.imageExtension('/tmp/no-extension'), 'png', 'notificat assertEqual(notifications.imageExtension('/tmp/archive.reallylong'), 'png', 'notifications reject suspicious image extensions') const serviceQml = fs.readFileSync(path.join(root, 'shell/plugins/notifications/Service.qml'), 'utf8') -const barWidgetQml = fs.readFileSync(path.join(root, 'shell/plugins/notifications/BarWidget.qml'), 'utf8') assert( /readonly property int historyReplayLimit: 5/.test(serviceQml), 'notifications service limits history replay to five rows' @@ -197,8 +196,4 @@ assert( /function showHistory\(\): string \{\s*return service\.showRecentHistory\(\)\s*\}/.test(serviceQml), 'notifications history IPC replays recent notifications' ) -assert( - !barWidgetQml.includes('historyOpenRequested'), - 'notifications history IPC does not depend on the bar widget' -) JS