diff --git a/assets/icons/notification.svg b/assets/icons/notification.svg new file mode 100644 index 0000000..2b3a208 --- /dev/null +++ b/assets/icons/notification.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/modules/osd/NotificationCard.qml b/modules/osd/NotificationCard.qml new file mode 100644 index 0000000..74f07c0 --- /dev/null +++ b/modules/osd/NotificationCard.qml @@ -0,0 +1,117 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Services.Notifications +import qs.config + +Rectangle { + id: root + + property Notification notif + readonly property bool critical: notif?.urgency === NotificationUrgency.Critical + readonly property int timeoutDuration: { + const seconds = Math.round((notif?.expireTimeout ?? 0) / 1000); + return seconds > 0 ? seconds : 5; + } + readonly property string iconName: { + if (!notif) + return ""; + + const hints = notif.hints ?? {}; + const hinted = String(hints["image-path"] ?? hints["image_path"] ?? hints["icon_path"] ?? ""); + return notif.appIcon || notif.desktopEntry || hinted; + } + readonly property url themedIconUrl: iconName !== "" ? Quickshell.iconPath(iconName, false) : "" + readonly property url iconUrl: `${Quickshell.shellPath("assets")}/icons/notification.svg` + + implicitWidth: 360 + implicitHeight: content.implicitHeight + Appearance.padding * 2 + radius: Appearance.radius + color: Appearance.colors.background + border.width: critical ? 1 : 0 + border.color: Appearance.colors.accent + + Timer { + id: countdown + + interval: root.timeoutDuration * 1000 + repeat: false + running: !root.critical && !hovered.hovered && visible + onTriggered: root.notif.expire() + } + + HoverHandler { + id: hovered + } + + RowLayout { + id: content + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: Appearance.padding + spacing: Appearance.spacing + + Item { + width: 40 + height: 40 + Layout.alignment: Qt.AlignTop + + Image { + anchors.fill: parent + source: root.iconUrl + fillMode: Image.PreserveAspectFit + smooth: true + } + + Image { + anchors.fill: parent + source: root.themedIconUrl + visible: root.themedIconUrl !== "" && status === Image.Ready + fillMode: Image.PreserveAspectFit + smooth: true + } + } + + ColumnLayout { + spacing: 2 + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter + + Text { + text: root.notif?.appName ?? "" + color: Appearance.colors.accent + font.family: Appearance.font.family + font.pointSize: Appearance.font.pointSize - 2 + elide: Text.ElideRight + Layout.fillWidth: true + } + Text { + text: root.notif?.summary ?? "" + color: Appearance.colors.foreground + font.family: Appearance.font.family + font.pointSize: Appearance.font.pointSize + font.weight: Font.DemiBold + elide: Text.ElideRight + Layout.fillWidth: true + } + Text { + text: root.notif?.body ?? "" + visible: text !== "" + color: Appearance.colors.foreground + opacity: 0.8 + font.family: Appearance.font.family + font.pointSize: Appearance.font.pointSize - 1 + wrapMode: Text.Wrap + maximumLineCount: 3 + elide: Text.ElideRight + textFormat: Text.PlainText + Layout.fillWidth: true + } + } + } +} + diff --git a/services/Notifications.qml b/services/Notifications.qml new file mode 100644 index 0000000..f66dac6 --- /dev/null +++ b/services/Notifications.qml @@ -0,0 +1,74 @@ +pragma Singleton + +import QtQuick +import Quickshell +import Quickshell.Services.Notifications + +Singleton { + id: root + + // Notifications currently shown in the OSD, tracked by the server. + readonly property alias popups: server.trackedNotifications + readonly property int popupCount: popups.values.length + + // Do-not-disturb: notifications are recorded but not displayed. + property bool dnd: false + + // Maximum amount of simultaneously displayed popups. + property int maxPopups: 5 + + // History of received notifications (newest first), for future dashboards. + property var history: [] + property int historyLimit: 100 + + NotificationServer { + id: server + + keepOnReload: false + + bodySupported: true + bodyMarkupSupported: true + actionsSupported: true + + onNotification: notification => { + root.addHistory(notification); + + if (root.dnd || notification.transient) + return; + + notification.tracked = true; + + const tracked = server.trackedNotifications.values; + if (tracked.length > root.maxPopups) + tracked[0].expire(); + } + } + + function clearHistory(): void { + root.history = []; + } + + function addHistory(notification: Notification): void { + const entry = { + id: notification.id, + appName: notification.appName, + appIcon: notification.appIcon, + desktopEntry: notification.desktopEntry, + summary: notification.summary, + body: notification.body, + image: notification.image, + urgency: notification.urgency, + transient: notification.transient, + time: Date.now(), + actions: notification.actions.map(action => ({ + identifier: action.identifier, + text: action.text + })) + }; + + let next = [entry, ...root.history]; + if (next.length > root.historyLimit) + next = next.slice(0, root.historyLimit); + root.history = next; + } +} diff --git a/shell.qml b/shell.qml index e389b05..00ef23a 100644 --- a/shell.qml +++ b/shell.qml @@ -3,4 +3,6 @@ import qs.windows Scope { Bar {} + Notification {} } + diff --git a/windows/Notification.qml b/windows/Notification.qml new file mode 100644 index 0000000..789c9b2 --- /dev/null +++ b/windows/Notification.qml @@ -0,0 +1,55 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import Quickshell +import qs.config +import qs.services +import "../modules/osd" + +PanelWindow { + id: root + + readonly property int cardWidth: 420 + + color: "transparent" + exclusionMode: ExclusionMode.Ignore + visible: Notifications.popupCount > 0 + implicitWidth: root.cardWidth + implicitHeight: stack.implicitHeight + + anchors { + top: true + right: true + } + margins { + top: 50 + Appearance.margin + right: 10 + Appearance.margin + } + + mask: Region { + item: stack + } + + ColumnLayout { + id: stack + + anchors.top: parent.top + anchors.right: parent.right + anchors.left: parent.left + spacing: Appearance.spacing + + Repeater { + model: [...Notifications.popups.values].reverse() + + NotificationCard { + id: card + + required property var modelData + + notif: modelData + Layout.fillWidth: true + } + } + } +}