integrated notification center: replace swaync with built-in NotificationServer

This commit is contained in:
Damocles 2026-04-12 23:52:49 +02:00
parent 4276434cd2
commit c973bd8163
7 changed files with 643 additions and 43 deletions

View file

@ -113,6 +113,7 @@ PanelWindow {
visible: M.Modules.clock.enable
}
M.Notifications {
bar: bar
visible: M.Modules.notifications.enable
}
}

253
modules/NotifCenter.qml Normal file
View file

@ -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
}
}

212
modules/NotifPopup.qml Normal file
View file

@ -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)
}
}
}
}
}

142
modules/NotifService.qml Normal file
View file

@ -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) {}
}
}
}

View file

@ -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
}
}

View file

@ -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