move NotifService and NotifItem to services/, keep notification UI in modules/

This commit is contained in:
Damocles 2026-04-18 00:26:14 +02:00
commit d20fdf8fa0
8 changed files with 33 additions and 36 deletions

View file

@ -6,7 +6,6 @@ import Quickshell.Services.Mpris
import Quickshell.Services.Pipewire import Quickshell.Services.Pipewire
import "../services" as S import "../services" as S
import "../applets" as C import "../applets" as C
import "../modules" as M
WlSessionLockSurface { WlSessionLockSurface {
id: root id: root
@ -149,7 +148,7 @@ WlSessionLockSurface {
visible: (S.Modules.lock.notifications ?? true) && _notifGroups.length > 0 visible: (S.Modules.lock.notifications ?? true) && _notifGroups.length > 0
readonly property var _notifGroups: { readonly property var _notifGroups: {
const notifs = M.NotifService.list.filter(n => n.state !== "dismissed"); const notifs = S.NotifService.list.filter(n => n.state !== "dismissed");
const groups = {}; const groups = {};
for (const n of notifs) { for (const n of notifs) {
const key = n.appIcon || n.appName || "unknown"; const key = n.appIcon || n.appName || "unknown";

View file

@ -33,8 +33,8 @@ M.HoverPanel {
// DND toggle // DND toggle
Text { Text {
text: M.NotifService.dnd ? "\uDB82\uDE93" : "\uDB80\uDC9C" text: S.NotifService.dnd ? "\uDB82\uDE93" : "\uDB80\uDC9C"
color: M.NotifService.dnd ? S.Theme.base09 : S.Theme.base04 color: S.NotifService.dnd ? S.Theme.base09 : S.Theme.base04
font.pixelSize: S.Theme.fontSize font.pixelSize: S.Theme.fontSize
font.family: S.Theme.iconFontFamily font.family: S.Theme.iconFontFamily
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
@ -42,7 +42,7 @@ M.HoverPanel {
MouseArea { MouseArea {
anchors.fill: parent anchors.fill: parent
cursorShape: Qt.PointingHandCursor cursorShape: Qt.PointingHandCursor
onClicked: M.NotifService.toggleDnd() onClicked: S.NotifService.toggleDnd()
} }
} }
@ -53,7 +53,7 @@ M.HoverPanel {
font.pixelSize: S.Theme.fontSize font.pixelSize: S.Theme.fontSize
font.family: S.Theme.iconFontFamily font.family: S.Theme.iconFontFamily
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
visible: M.NotifService.count > 0 visible: S.NotifService.count > 0
MouseArea { MouseArea {
id: clearArea id: clearArea
@ -88,7 +88,7 @@ M.HoverPanel {
// Group notifications by appName, sorted by max urgency desc then most recent time desc // Group notifications by appName, sorted by max urgency desc then most recent time desc
readonly property var _groups: { readonly property var _groups: {
const map = {}; const map = {};
for (const n of M.NotifService.list) { for (const n of S.NotifService.list) {
const key = n.appName || ""; const key = n.appName || "";
if (!map[key]) if (!map[key])
map[key] = { map[key] = {
@ -167,14 +167,14 @@ M.HoverPanel {
} }
function _cascadeDismiss() { function _cascadeDismiss() {
if (M.NotifService.list.length === 0) if (S.NotifService.list.length === 0)
return; return;
const ids = M.NotifService.list.map(n => n.id); const ids = S.NotifService.list.map(n => n.id);
_startCascade(_getVisibleNotifDelegates(), ids); _startCascade(_getVisibleNotifDelegates(), ids);
} }
function _cascadeGroupDismiss(appName) { function _cascadeGroupDismiss(appName) {
const ids = M.NotifService.list.filter(n => n.appName === appName).map(n => n.id); const ids = S.NotifService.list.filter(n => n.appName === appName).map(n => n.id);
if (ids.length === 0) if (ids.length === 0)
return; return;
_startCascade(_getVisibleNotifDelegates(appName), ids); _startCascade(_getVisibleNotifDelegates(appName), ids);
@ -184,7 +184,7 @@ M.HoverPanel {
const ids = _pendingDismissIds; const ids = _pendingDismissIds;
_pendingDismissIds = []; _pendingDismissIds = [];
for (const id of ids) for (const id of ids)
M.NotifService.dismiss(id); S.NotifService.dismiss(id);
} }
property Component _cascadeTimer: Component { property Component _cascadeTimer: Component {
@ -467,7 +467,7 @@ M.HoverPanel {
ScriptAction { ScriptAction {
script: { script: {
if (notifDelegate._notif && !notifDelegate._skipDismiss) if (notifDelegate._notif && !notifDelegate._skipDismiss)
M.NotifService.dismiss(notifDelegate._notif.id); S.NotifService.dismiss(notifDelegate._notif.id);
} }
} }
} }
@ -476,7 +476,7 @@ M.HoverPanel {
// Empty state // Empty state
Text { Text {
visible: M.NotifService.count === 0 visible: S.NotifService.count === 0
width: menuWindow.contentWidth width: menuWindow.contentWidth
height: 48 height: 48
horizontalAlignment: Text.AlignHCenter horizontalAlignment: Text.AlignHCenter

View file

@ -10,7 +10,7 @@ PanelWindow {
required property var screen required property var screen
visible: M.NotifService.popups.length > 0 && !S.NiriIpc.overviewOpen visible: S.NotifService.popups.length > 0 && !S.NiriIpc.overviewOpen
color: "transparent" color: "transparent"
WlrLayershell.layer: WlrLayer.Overlay WlrLayershell.layer: WlrLayer.Overlay
@ -34,7 +34,7 @@ PanelWindow {
property var _knownIds: ({}) property var _knownIds: ({})
Repeater { Repeater {
model: M.NotifService.popups.slice(0, S.Modules.notifications.maxPopups || 4) model: S.NotifService.popups.slice(0, S.Modules.notifications.maxPopups || 4)
delegate: Item { delegate: Item {
id: popupItem id: popupItem
@ -74,7 +74,7 @@ PanelWindow {
} }
Connections { Connections {
target: M.NotifService target: S.NotifService
function onPopupExpiring(notifId) { function onPopupExpiring(notifId) {
if (notifId === popupItem.modelData.id) if (notifId === popupItem.modelData.id)
popupItem.animateDismiss(false); popupItem.animateDismiss(false);
@ -152,7 +152,7 @@ PanelWindow {
easing.type: Easing.OutCubic easing.type: Easing.OutCubic
} }
ScriptAction { ScriptAction {
script: popupItem._fullDismiss ? M.NotifService.dismiss(popupItem.modelData.id) : M.NotifService.dismissPopup(popupItem.modelData.id) script: popupItem._fullDismiss ? S.NotifService.dismiss(popupItem.modelData.id) : S.NotifService.dismissPopup(popupItem.modelData.id)
} }
} }

View file

@ -8,28 +8,28 @@ M.BarSection {
id: root id: root
spacing: S.Theme.moduleSpacing spacing: S.Theme.moduleSpacing
tooltip: { tooltip: {
const parts = [M.NotifService.count + " notification" + (M.NotifService.count !== 1 ? "s" : "")]; const parts = [S.NotifService.count + " notification" + (S.NotifService.count !== 1 ? "s" : "")];
if (M.NotifService.dnd) if (S.NotifService.dnd)
parts.push("Do not disturb"); parts.push("Do not disturb");
return parts.join("\n"); return parts.join("\n");
} }
required property var bar required property var bar
readonly property bool hasUrgent: M.NotifService.list.some(n => n.urgency === NotificationUrgency.Critical && n.state !== "dismissed") readonly property bool hasUrgent: S.NotifService.list.some(n => n.urgency === NotificationUrgency.Critical && n.state !== "dismissed")
M.BarIcon { M.BarIcon {
icon: { icon: {
if (M.NotifService.dnd) if (S.NotifService.dnd)
return M.NotifService.count > 0 ? "\uDB80\uDCA0" : "\uDB82\uDE93"; return S.NotifService.count > 0 ? "\uDB80\uDCA0" : "\uDB82\uDE93";
return M.NotifService.count > 0 ? "\uDB84\uDD6B" : "\uDB80\uDC9C"; return S.NotifService.count > 0 ? "\uDB84\uDD6B" : "\uDB80\uDC9C";
} }
color: M.NotifService.dnd ? S.Theme.base04 : root.accentColor color: S.NotifService.dnd ? S.Theme.base04 : root.accentColor
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
} }
M.BarLabel { M.BarLabel {
id: countLabel id: countLabel
label: M.NotifService.count > 0 ? String(M.NotifService.count) + (root.hasUrgent ? "!" : "") : "" label: S.NotifService.count > 0 ? String(S.NotifService.count) + (root.hasUrgent ? "!" : "") : ""
color: root.hasUrgent ? S.Theme.base08 : root.accentColor color: root.hasUrgent ? S.Theme.base08 : root.accentColor
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
@ -61,9 +61,9 @@ M.BarSection {
} }
Connections { Connections {
target: M.NotifService target: S.NotifService
function onCountChanged() { function onCountChanged() {
if (M.NotifService.count > 0) if (S.NotifService.count > 0)
popAnim.start(); popAnim.start();
} }
} }
@ -77,7 +77,7 @@ M.BarSection {
} }
TapHandler { TapHandler {
acceptedButtons: Qt.RightButton acceptedButtons: Qt.RightButton
onTapped: M.NotifService.toggleDnd() onTapped: S.NotifService.toggleDnd()
} }
LazyLoader { LazyLoader {

View file

@ -31,8 +31,6 @@ PowerProfileModule 1.0 PowerProfileModule.qml
IdleInhibitorModule 1.0 IdleInhibitorModule.qml IdleInhibitorModule 1.0 IdleInhibitorModule.qml
NotificationsModule 1.0 NotificationsModule.qml NotificationsModule 1.0 NotificationsModule.qml
ProcessList 1.0 ProcessList.qml ProcessList 1.0 ProcessList.qml
singleton NotifService 1.0 NotifService.qml
NotifItem 1.0 NotifItem.qml
NotifPopup 1.0 NotifPopup.qml NotifPopup 1.0 NotifPopup.qml
NotifCenter 1.0 NotifCenter.qml NotifCenter 1.0 NotifCenter.qml
NotifCard 1.0 NotifCard.qml NotifCard 1.0 NotifCard.qml

View file

@ -0,0 +1,64 @@
import QtQuick
import Quickshell.Services.Notifications
import "." as S
QtObject {
id: root
property bool popup: false
property string state: "visible" // "visible" | "dismissing" | "dismissed"
property var notification: null
property var id
property string summary
property string body
property string appName
property string appIcon
property string image
property var hints
property int urgency: NotificationUrgency.Normal
property var actions: []
property real time: Date.now()
// Expire timer — owned by this item, not dynamically created
readonly property Timer _expireTimer: Timer {
running: false
onTriggered: {
if (root.state === "visible")
root.popup = false;
}
}
// Relative time string — recomputed whenever NotifService._now ticks (single global 5s timer)
readonly property string timeStr: {
const diff = S.NotifService._now - time;
const m = Math.floor(diff / 60000);
if (m < 1)
return "now";
const h = Math.floor(m / 60);
if (h < 1)
return m + "m";
const d = Math.floor(h / 24);
return d > 0 ? d + "d" : h + "h";
}
// App closed the notification from its side — remove from our list while the object is still alive
readonly property Connections _notifConn: Connections {
target: root.notification
function onClosed() {
if (root.state !== "dismissed")
S.NotifService.dismiss(root.id);
}
}
function beginDismiss() {
if (state === "visible")
state = "dismissing";
}
function finishDismiss() {
state = "dismissed";
_expireTimer.running = false;
notification?.dismiss();
}
}

View file

@ -0,0 +1,137 @@
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Services.Notifications
import "." as S
QtObject {
id: root
property var list: []
property bool dnd: false
readonly property var popups: list.filter(n => n.popup && n.state !== "dismissed")
readonly property int count: list.filter(n => n.state !== "dismissed").length
// O(1) lookup
property var _byId: ({})
function dismiss(notifId) {
const item = _byId[notifId];
if (!item)
return;
item.finishDismiss();
list = list.filter(n => n !== item);
delete _byId[notifId];
item.destroy();
}
function dismissAll() {
for (const item of list.slice()) {
item.finishDismiss();
delete _byId[item.id];
item.destroy();
}
list = [];
}
function dismissPopup(notifId) {
const item = _byId[notifId];
if (item) {
item.popup = false;
_changed();
}
}
function toggleDnd() {
dnd = !dnd;
}
function _changed() {
list = list.slice();
}
// Signal popups to animate out before removal
signal popupExpiring(var notifId)
property NotificationServer _server: NotificationServer {
actionsSupported: true
bodyMarkupSupported: true
imageSupported: true
persistenceSupported: true
keepOnReload: false
onNotification: notif => {
notif.tracked = true;
const isCritical = notif.urgency === NotificationUrgency.Critical;
const item = _itemComp.createObject(root, {
notification: notif,
id: notif.id,
summary: notif.summary,
body: notif.body,
appName: notif.appName,
appIcon: notif.appIcon,
image: notif.image,
hints: notif.hints,
urgency: notif.urgency,
actions: notif.actions ? notif.actions.map(a => ({
identifier: a.identifier,
text: a.text,
invoke: () => a.invoke()
})) : [],
time: Date.now(),
popup: isCritical || !root.dnd
});
root._byId[item.id] = item;
root.list = [item, ...root.list].sort((a, b) => {
const aU = a.urgency === NotificationUrgency.Critical ? 1 : 0;
const bU = b.urgency === NotificationUrgency.Critical ? 1 : 0;
if (aU !== bU)
return bU - aU;
return b.time - a.time;
});
// Trim excess popups
const max = S.Modules.notifications.maxPopups || 4;
const currentPopups = root.list.filter(n => n.popup);
if (currentPopups.length > max) {
for (let i = max; i < currentPopups.length; i++)
currentPopups[i].popup = false;
root._changed();
}
// Auto-expire popup (skip for critical)
if (item.popup && !isCritical) {
const timeout = notif.expireTimeout > 0 ? notif.expireTimeout : (S.Modules.notifications.timeout || 3000);
item._expireTimer.interval = timeout;
item._expireTimer.running = true;
}
// Trim history (-1 = unlimited)
const maxHistory = S.Modules.notifications.maxHistory ?? -1;
while (maxHistory > 0 && root.list.length > maxHistory) {
const old = root.list.pop();
old.finishDismiss();
delete root._byId[old.id];
old.destroy();
}
}
}
property Component _itemComp: Component {
NotifItem {}
}
// Single global tick for all NotifItem.timeStr bindings — replaces per-item 5s timers
property real _now: Date.now()
property Timer _nowTimer: Timer {
running: root.count > 0
repeat: true
interval: 5000
onTriggered: root._now = Date.now()
}
}

View file

@ -4,3 +4,5 @@ singleton SystemStats 1.0 SystemStats.qml
singleton Modules 1.0 Modules.qml singleton Modules 1.0 Modules.qml
singleton NiriIpc 1.0 NiriIpc.qml singleton NiriIpc 1.0 NiriIpc.qml
singleton PowerProfileService 1.0 PowerProfileService.qml singleton PowerProfileService 1.0 PowerProfileService.qml
singleton NotifService 1.0 NotifService.qml
NotifItem 1.0 NotifItem.qml