diff --git a/modules/Bar.qml b/modules/Bar.qml index f9a4e76..8e69a2a 100644 --- a/modules/Bar.qml +++ b/modules/Bar.qml @@ -113,6 +113,7 @@ PanelWindow { visible: M.Modules.clock.enable } M.Notifications { + bar: bar visible: M.Modules.notifications.enable } } diff --git a/modules/NotifCenter.qml b/modules/NotifCenter.qml new file mode 100644 index 0000000..ccabba4 --- /dev/null +++ b/modules/NotifCenter.qml @@ -0,0 +1,253 @@ +import QtQuick +import Quickshell.Services.Notifications +import "." as M + +M.PopupPanel { + id: menuWindow + + panelWidth: 350 + + // Header: title + clear all + DND toggle + Item { + width: menuWindow.panelWidth + height: 32 + + Text { + anchors.left: parent.left + anchors.leftMargin: 12 + anchors.verticalCenter: parent.verticalCenter + text: "Notifications" + color: M.Theme.base05 + font.pixelSize: M.Theme.fontSize + 1 + font.family: M.Theme.fontFamily + font.bold: true + } + + Row { + anchors.right: parent.right + anchors.rightMargin: 8 + anchors.verticalCenter: parent.verticalCenter + spacing: 8 + + // DND toggle + Text { + text: M.NotifService.dnd ? "\uDB82\uDE93" : "\uDB80\uDC9C" + color: M.NotifService.dnd ? M.Theme.base09 : M.Theme.base04 + font.pixelSize: M.Theme.fontSize + font.family: M.Theme.iconFontFamily + anchors.verticalCenter: parent.verticalCenter + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: M.NotifService.toggleDnd() + } + } + + // Clear all + Text { + text: "\uF1F8" + color: clearArea.containsMouse ? M.Theme.base08 : M.Theme.base04 + font.pixelSize: M.Theme.fontSize + font.family: M.Theme.iconFontFamily + anchors.verticalCenter: parent.verticalCenter + visible: M.NotifService.count > 0 + + MouseArea { + id: clearArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: M.NotifService.dismissAll() + } + } + } + } + + // Separator + Rectangle { + width: menuWindow.panelWidth - 16 + height: 1 + anchors.horizontalCenter: parent.horizontalCenter + color: M.Theme.base03 + } + + // Notification list + Repeater { + model: M.NotifService.active.slice(0, 20) + + delegate: Item { + id: notifItem + required property var modelData + required property int index + + width: menuWindow.panelWidth + height: notifContent.height + 12 + + Rectangle { + anchors.fill: parent + anchors.leftMargin: 4 + anchors.rightMargin: 4 + color: notifArea.containsMouse ? M.Theme.base02 : "transparent" + radius: M.Theme.radius + } + + // Urgency accent + Rectangle { + anchors.left: parent.left + anchors.leftMargin: 4 + anchors.top: parent.top + anchors.topMargin: 4 + anchors.bottom: parent.bottom + anchors.bottomMargin: 4 + width: 2 + radius: 1 + color: { + const u = notifItem.modelData.urgency; + return u === NotificationUrgency.Critical ? M.Theme.base08 : u === NotificationUrgency.Low ? M.Theme.base04 : M.Theme.base0D; + } + } + + Column { + id: notifContent + anchors.left: parent.left + anchors.right: dismissBtn.left + anchors.top: parent.top + anchors.leftMargin: 14 + anchors.topMargin: 6 + spacing: 1 + + // App + time + Row { + width: parent.width + Text { + text: notifItem.modelData.appName || "Notification" + color: M.Theme.base04 + font.pixelSize: M.Theme.fontSize - 2 + font.family: M.Theme.fontFamily + elide: Text.ElideRight + width: parent.width - ageText.width - 4 + } + Text { + id: ageText + text: { + const diff = Math.floor((Date.now() - notifItem.modelData.time) / 60000); + if (diff < 1) + return "now"; + if (diff < 60) + return diff + "m"; + if (diff < 1440) + return Math.floor(diff / 60) + "h"; + return Math.floor(diff / 1440) + "d"; + } + color: M.Theme.base03 + font.pixelSize: M.Theme.fontSize - 2 + font.family: M.Theme.fontFamily + } + } + + Text { + width: parent.width + text: notifItem.modelData.summary || "" + color: M.Theme.base05 + font.pixelSize: M.Theme.fontSize + font.family: M.Theme.fontFamily + font.bold: true + elide: Text.ElideRight + } + + Text { + width: parent.width + text: notifItem.modelData.body || "" + color: M.Theme.base04 + font.pixelSize: M.Theme.fontSize - 1 + font.family: M.Theme.fontFamily + wrapMode: Text.WordWrap + maximumLineCount: 2 + elide: Text.ElideRight + visible: text !== "" + } + + // Actions + Row { + spacing: 4 + visible: notifItem.modelData.actions && notifItem.modelData.actions.length > 0 + + Repeater { + model: notifItem.modelData.actions || [] + delegate: Rectangle { + required property var modelData + width: actText.implicitWidth + 10 + height: actText.implicitHeight + 4 + radius: M.Theme.radius + color: actArea.containsMouse ? M.Theme.base02 : "transparent" + border.color: M.Theme.base03 + border.width: 1 + + Text { + id: actText + anchors.centerIn: parent + text: parent.modelData.text + color: M.Theme.base0D + font.pixelSize: M.Theme.fontSize - 2 + font.family: M.Theme.fontFamily + } + MouseArea { + id: actArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + parent.modelData.invoke(); + M.NotifService.dismiss(notifItem.modelData.id); + } + } + } + } + } + } + + // Dismiss button + Text { + id: dismissBtn + anchors.right: parent.right + anchors.rightMargin: 10 + anchors.top: parent.top + anchors.topMargin: 8 + text: "\uF00D" + color: dismissArea.containsMouse ? M.Theme.base08 : M.Theme.base03 + font.pixelSize: M.Theme.fontSize - 1 + font.family: M.Theme.iconFontFamily + + MouseArea { + id: dismissArea + anchors.fill: parent + anchors.margins: -4 + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: M.NotifService.dismiss(notifItem.modelData.id) + } + } + + MouseArea { + id: notifArea + anchors.fill: parent + z: -1 + hoverEnabled: true + } + } + } + + // Empty state + Text { + visible: M.NotifService.count === 0 + width: menuWindow.panelWidth + height: 48 + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + text: "No notifications" + color: M.Theme.base04 + font.pixelSize: M.Theme.fontSize + font.family: M.Theme.fontFamily + } +} diff --git a/modules/NotifPopup.qml b/modules/NotifPopup.qml new file mode 100644 index 0000000..8b346d6 --- /dev/null +++ b/modules/NotifPopup.qml @@ -0,0 +1,212 @@ +import QtQuick +import QtQuick.Effects +import Quickshell +import Quickshell.Wayland +import Quickshell.Services.Notifications +import "." as M + +PanelWindow { + id: root + + required property var screen + + visible: M.NotifService.popups.length > 0 + color: "transparent" + + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.exclusiveZone: 0 + WlrLayershell.namespace: "nova-notif-popup" + + anchors.top: true + anchors.right: true + + margins.top: 0 + margins.right: 8 + + implicitWidth: 320 + implicitHeight: popupCol.implicitHeight + + Column { + id: popupCol + width: parent.width + spacing: 6 + + Repeater { + model: M.NotifService.popups.slice(0, 4) + + delegate: Item { + id: popupItem + required property var modelData + required property int index + + width: popupCol.width + height: contentCol.height + 16 + opacity: 0 + x: 50 + + Component.onCompleted: { + slideIn.start(); + } + + ParallelAnimation { + id: slideIn + NumberAnimation { + target: popupItem + property: "opacity" + to: 1 + duration: 200 + easing.type: Easing.OutCubic + } + NumberAnimation { + target: popupItem + property: "x" + to: 0 + duration: 250 + easing.type: Easing.OutCubic + } + } + + // Background + Rectangle { + anchors.fill: parent + color: M.Theme.base01 + opacity: Math.max(M.Theme.barOpacity, 0.9) + radius: M.Theme.radius + } + + // Urgency accent bar + Rectangle { + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + width: 3 + radius: M.Theme.radius + color: { + const u = popupItem.modelData.urgency; + return u === NotificationUrgency.Critical ? M.Theme.base08 : u === NotificationUrgency.Low ? M.Theme.base04 : M.Theme.base0D; + } + } + + // Glow on critical + layer.enabled: popupItem.modelData.urgency === NotificationUrgency.Critical + layer.effect: MultiEffect { + shadowEnabled: true + shadowColor: M.Theme.base08 + shadowBlur: 0.6 + shadowVerticalOffset: 0 + shadowHorizontalOffset: 0 + } + + Column { + id: contentCol + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 8 + anchors.leftMargin: 14 + spacing: 2 + + // App name + time + Row { + width: parent.width + Text { + text: popupItem.modelData.appName || "Notification" + color: M.Theme.base04 + font.pixelSize: M.Theme.fontSize - 2 + font.family: M.Theme.fontFamily + width: parent.width - timeLabel.width + elide: Text.ElideRight + } + Text { + id: timeLabel + text: { + const diff = Math.floor((Date.now() - popupItem.modelData.time) / 1000); + if (diff < 5) + return "now"; + if (diff < 60) + return diff + "s"; + return Math.floor(diff / 60) + "m"; + } + color: M.Theme.base03 + font.pixelSize: M.Theme.fontSize - 2 + font.family: M.Theme.fontFamily + } + } + + // Summary + Text { + width: parent.width + text: popupItem.modelData.summary || "" + color: M.Theme.base05 + font.pixelSize: M.Theme.fontSize + font.family: M.Theme.fontFamily + font.bold: true + elide: Text.ElideRight + wrapMode: Text.WordWrap + maximumLineCount: 2 + } + + // Body + Text { + width: parent.width + text: popupItem.modelData.body || "" + color: M.Theme.base04 + font.pixelSize: M.Theme.fontSize - 1 + font.family: M.Theme.fontFamily + elide: Text.ElideRight + wrapMode: Text.WordWrap + maximumLineCount: 3 + visible: text !== "" + } + + // Actions + Row { + spacing: 6 + visible: popupItem.modelData.actions.length > 0 + + Repeater { + model: popupItem.modelData.actions + + delegate: Rectangle { + required property var modelData + width: actionText.implicitWidth + 12 + height: actionText.implicitHeight + 6 + radius: M.Theme.radius + color: actionArea.containsMouse ? M.Theme.base02 : M.Theme.base01 + border.color: M.Theme.base03 + border.width: 1 + + Text { + id: actionText + anchors.centerIn: parent + text: parent.modelData.text + color: M.Theme.base0D + font.pixelSize: M.Theme.fontSize - 2 + font.family: M.Theme.fontFamily + } + + MouseArea { + id: actionArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + parent.modelData.invoke(); + M.NotifService.dismiss(popupItem.modelData.id); + } + } + } + } + } + } + + // Click to dismiss + MouseArea { + anchors.fill: parent + z: -1 + onClicked: M.NotifService.dismissPopup(popupItem.modelData.id) + } + } + } + } +} diff --git a/modules/NotifService.qml b/modules/NotifService.qml new file mode 100644 index 0000000..445cec0 --- /dev/null +++ b/modules/NotifService.qml @@ -0,0 +1,142 @@ +pragma Singleton + +import QtQuick +import Quickshell +import Quickshell.Io +import Quickshell.Services.Notifications + +QtObject { + id: root + + property var list: [] + property bool dnd: false + + readonly property var active: list.filter(n => !n.closed) + readonly property var popups: list.filter(n => n.popup && !n.closed) + readonly property int count: active.length + + function dismiss(notifId) { + const n = list.find(n => n.id === notifId); + if (n) { + n.popup = false; + n.closed = true; + n.notification?.dismiss(); + _changed(); + } + } + + function dismissAll() { + for (const n of list.slice()) { + n.popup = false; + n.closed = true; + n.notification?.dismiss(); + } + _changed(); + } + + function dismissPopup(notifId) { + const n = list.find(n => n.id === notifId); + if (n) { + n.popup = false; + _changed(); + } + } + + function toggleDnd() { + dnd = !dnd; + } + + function _changed() { + list = list.slice(); + _saveTimer.restart(); + } + + property NotificationServer _server: NotificationServer { + actionsSupported: true + bodyMarkupSupported: true + imageSupported: true + persistenceSupported: true + keepOnReload: false + + onNotification: notif => { + notif.tracked = true; + + const data = { + id: notif.id, + summary: notif.summary, + body: notif.body, + appName: notif.appName, + appIcon: notif.appIcon, + image: notif.image, + urgency: notif.urgency, + actions: notif.actions ? notif.actions.map(a => ({ + identifier: a.identifier, + text: a.text, + invoke: () => a.invoke() + })) : [], + time: Date.now(), + popup: !root.dnd, + closed: false, + notification: notif + }; + + root.list = [data, ...root.list]; + + // Auto-expire popup + if (data.popup) { + const timeout = notif.expireTimeout > 0 ? notif.expireTimeout : 5000; + Qt.callLater(() => { + _expireTimer.createObject(root, { + _notifId: data.id, + interval: timeout + }); + }); + } + } + } + + property Component _expireTimer: Component { + Timer { + property string _notifId + running: true + onTriggered: { + root.dismissPopup(_notifId); + destroy(); + } + } + } + + // Persistence + property Timer _saveTimer: Timer { + interval: 1000 + onTriggered: { + const data = root.active.map(n => ({ + id: n.id, + summary: n.summary, + body: n.body, + appName: n.appName, + appIcon: n.appIcon, + image: n.image, + urgency: n.urgency, + time: n.time + })); + _storage.setText(JSON.stringify(data)); + } + } + + property FileView _storage: FileView { + path: (Quickshell.env("XDG_STATE_HOME") || (Quickshell.env("HOME") + "/.local/state")) + "/nova-shell/notifs.json" + onLoaded: { + try { + const data = JSON.parse(text()); + for (const n of data) { + n.popup = false; + n.closed = false; + n.notification = null; + n.actions = []; + } + root.list = data.concat(root.list); + } catch (e) {} + } + } +} diff --git a/modules/Notifications.qml b/modules/Notifications.qml index 45fa9c7..b160ea6 100644 --- a/modules/Notifications.qml +++ b/modules/Notifications.qml @@ -1,55 +1,31 @@ import QtQuick -import Quickshell.Io +import Quickshell import "." as M M.BarSection { id: root spacing: M.Theme.moduleSpacing tooltip: { - const parts = [root.count + " notification" + (root.count !== 1 ? "s" : "")]; - if (root.dnd) + const parts = [M.NotifService.count + " notification" + (M.NotifService.count !== 1 ? "s" : "")]; + if (M.NotifService.dnd) parts.push("Do not disturb"); - if (root.inhibited) - parts.push("Inhibited"); return parts.join("\n"); } - property int count: 0 - property bool dnd: false - property bool inhibited: false - - Process { - id: sub - running: true - command: ["swaync-client", "--subscribe-waybar"] - stdout: SplitParser { - splitMarker: "\n" - onRead: line => { - try { - const d = JSON.parse(line); - const alt = d.alt ?? ""; - root.count = parseInt(d.text) || 0; - root.dnd = alt.startsWith("dnd"); - root.inhibited = alt.includes("inhibited"); - } catch (e) {} - } - } - } + required property var bar M.BarIcon { icon: { - if (root.inhibited) - return root.count > 0 ? "\uDB80\uDC9B" : "\uDB82\uDE91"; - if (root.dnd) - return root.count > 0 ? "\uDB80\uDCA0" : "\uDB82\uDE93"; - return root.count > 0 ? "\uDB84\uDD6B" : "\uDB80\uDC9C"; + if (M.NotifService.dnd) + return M.NotifService.count > 0 ? "\uDB80\uDCA0" : "\uDB82\uDE93"; + return M.NotifService.count > 0 ? "\uDB84\uDD6B" : "\uDB80\uDC9C"; } - color: root.dnd ? M.Theme.base04 : root.accentColor + color: M.NotifService.dnd ? M.Theme.base04 : root.accentColor anchors.verticalCenter: parent.verticalCenter } M.BarLabel { id: countLabel - label: root.count > 0 ? String(root.count) : "" + label: M.NotifService.count > 0 ? String(M.NotifService.count) : "" anchors.verticalCenter: parent.verticalCenter transform: Scale { @@ -79,26 +55,35 @@ M.BarSection { } } - onCountChanged: if (count > 0) - popAnim.start() + Connections { + target: M.NotifService + function onCountChanged() { + if (M.NotifService.count > 0) + popAnim.start(); + } + } TapHandler { acceptedButtons: Qt.LeftButton cursorShape: Qt.PointingHandCursor onTapped: { - clicker.command = ["swaync-client", "--toggle-panel", "--skip-wait"]; - clicker.running = true; + centerLoader.active = !centerLoader.active; + M.FlyoutState.visible = false; } } TapHandler { acceptedButtons: Qt.RightButton cursorShape: Qt.PointingHandCursor - onTapped: { - clicker.command = ["swaync-client", "--toggle-dnd", "--skip-wait"]; - clicker.running = true; + onTapped: M.NotifService.toggleDnd() + } + + Loader { + id: centerLoader + active: false + sourceComponent: M.NotifCenter { + screen: root.bar.screen + anchorX: root.mapToGlobal(root.width / 2, 0).x - (QsWindow.window?.screen?.x ?? 0) + onDismissed: centerLoader.active = false } } - Process { - id: clicker - } } diff --git a/modules/qmldir b/modules/qmldir index 0fc1e6e..c6ecb8f 100644 --- a/modules/qmldir +++ b/modules/qmldir @@ -31,6 +31,9 @@ Weather 1.0 Weather.qml PowerProfile 1.0 PowerProfile.qml IdleInhibitor 1.0 IdleInhibitor.qml Notifications 1.0 Notifications.qml +singleton NotifService 1.0 NotifService.qml +NotifPopup 1.0 NotifPopup.qml +NotifCenter 1.0 NotifCenter.qml Power 1.0 Power.qml Privacy 1.0 Privacy.qml BackgroundOverlay 1.0 BackgroundOverlay.qml diff --git a/shell.qml b/shell.qml index d6826f1..c3072d5 100644 --- a/shell.qml +++ b/shell.qml @@ -19,6 +19,10 @@ ShellRoot { screen: scope.modelData } + NotifPopup { + screen: scope.modelData + } + BackgroundOverlay { screen: scope.modelData }