From f6d518ba241f4d410f1ee6efd210a94a6e6585ae Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Mon, 19 Oct 2026 09:20:57 +0200 Subject: desktop: notifications settings page Edits notifyd's config.json as a draft behind the SaveBar and runs notifyctl reload after the write; a stopped daemon is reported as saved but not applied, since it reads the file at its next start. Muting offers the apps already seen in the queue and history. Co-Authored-By: Claude Opus 5.5 --- .../modules/notifications/NotificationsModule.qml | 73 +++++++++ .../modules/notifications/NotificationsPage.qml | 176 +++++++++++++++++++++ 2 files changed, 249 insertions(+) create mode 100644 desktop/modules/notifications/NotificationsModule.qml create mode 100644 desktop/modules/notifications/NotificationsPage.qml (limited to 'desktop/modules/notifications') diff --git a/desktop/modules/notifications/NotificationsModule.qml b/desktop/modules/notifications/NotificationsModule.qml new file mode 100644 index 0000000..6dea790 --- /dev/null +++ b/desktop/modules/notifications/NotificationsModule.qml @@ -0,0 +1,73 @@ +// Copyright (C) 2026 Danilo M. +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 2 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +import Quickshell.Io +import QtQuick +import "../.." + +// notifyd's config.json: the daemon's timeouts and caps, and the balloon +// shell's placement, DND rule and muted apps. The draft lives here, not in +// the page, so the sidebar's unsaved mark survives the page unloading. +Module { + id: nm + name: "notifications" + label: "Notifications" + // nf-fa-bell, present in Inconsolata Nerd Font. + icon: "" + settings: true + group: "Desktop" + keywords: ["notifyd", "timeout", "history", "queue", "mute", "dnd", "corner", "monitor", "balloon"] + + property var draft: Notify.config + property string notice: "" + dirty: JSON.stringify(nm.draft) !== JSON.stringify(Notify.config) + readonly property bool valid: [nm.draft.liveCap, nm.draft.historyCap].every(v => v >= 1 && v <= 100) + + // Not over unsaved edits. + function refresh() { if (!nm.dirty) nm.draft = Notify.config; } + function discard() { nm.draft = Notify.config; nm.notice = ""; } + function edit(patch) { nm.draft = Object.assign({}, nm.draft, patch); } + + // Unknown keys in the file survive. The text goes on stdin to cat (the + // notes pattern: the exit code is the outcome); 1 is the write, 2 the + // reload, so a stopped daemon reads as saved-but-not-applied. + // Written in onStarted, then stdinEnabled cleared to give cat its EOF: + // the sequence notes' bodyWriter uses (NotesModule.qml). + function save() { + nm.notice = ""; + nm.saver.data = JSON.stringify(Object.assign({}, Notify.rawConfig, nm.draft), null, 2) + "\n"; + nm.saver.stdinEnabled = true; + nm.saver.running = false; + nm.saver.running = true; + } + property Process saver: Process { + property string data: "" + command: ["sh", "-c", 'mkdir -p "$(dirname "$1")" && cat > "$1" || exit 1; notifyctl reload || exit 2', + "sh", Notify.configPath] + onStarted: { + nm.saver.write(nm.saver.data); + nm.saver.stdinEnabled = false; + } + onExited: code => nm.notice = code === 0 ? "saved" + : code === 2 ? "saved; notifyd not reachable, it reads the file at its next start" + : "could not write " + Notify.configPath + } + + page: Component { + Page { + title: "Notifications" + actions: Component { + SaveBar { dirty: nm.dirty; valid: nm.valid; note: "caps are 1 to 100" + onSave: nm.save(); onDiscard: nm.discard() } + } + NotificationsPage { width: parent.width; mod: nm } + } + } +} diff --git a/desktop/modules/notifications/NotificationsPage.qml b/desktop/modules/notifications/NotificationsPage.qml new file mode 100644 index 0000000..b85a6d7 --- /dev/null +++ b/desktop/modules/notifications/NotificationsPage.qml @@ -0,0 +1,176 @@ +// Copyright (C) 2026 Danilo M. +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 2 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +import Quickshell +import QtQuick +import "../.." +import "../appearance" +import "../qtmaildir" + +// The rows of the notifications settings page. Everything edits mod.draft. +Column { + id: pg + // Not "nm": a property named like the injecting id binds to itself. + required property QtObject mod + readonly property var d: mod.draft + spacing: 8 + + function setTimeout(key, v) { + pg.mod.edit({ timeouts: Object.assign({}, pg.d.timeouts, { [key]: v }) }); + } + + Heading { text: "Timeouts" } + Text { + width: pg.width; wrapMode: Text.Wrap + text: "Seconds a balloon stays when the app leaves it to the daemon. An app's own timeout still wins." + color: Theme.overlay + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + } + Repeater { + model: [["low", "Low"], ["normal", "Normal"], ["critical", "Critical"]] + Row { + required property var modelData + spacing: 10 + Text { + width: 90; anchors.verticalCenter: parent.verticalCenter + text: modelData[1]; color: Theme.text + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + } + Field { + anchors.verticalCenter: parent.verticalCenter + visible: pg.d.timeouts[modelData[0]] !== 0 + value: String(pg.d.timeouts[modelData[0]]) + placeholder: "seconds" + onEdited: { const n = parseInt(value, 10); if (n > 0 && n <= 86400) pg.setTimeout(modelData[0], n); } + } + Toggle { + anchors.verticalCenter: parent.verticalCenter + label: "never" + checked: pg.d.timeouts[modelData[0]] === 0 + // Toggle flips itself on click, which drops the binding; put + // it back so Discard shows the draft again. + onToggled: { + const on = checked; + checked = Qt.binding(() => pg.d.timeouts[modelData[0]] === 0); + pg.setTimeout(modelData[0], on ? 0 : 10); + } + } + } + } + + Heading { text: "Limits" } + Repeater { + model: [["liveCap", "On screen"], ["historyCap", "History"]] + Row { + required property var modelData + spacing: 10 + Text { + width: 90; anchors.verticalCenter: parent.verticalCenter + text: modelData[1]; color: Theme.text + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + } + Field { + anchors.verticalCenter: parent.verticalCenter + value: String(pg.d[modelData[0]]) + border.color: pg.d[modelData[0]] >= 1 && pg.d[modelData[0]] <= 100 ? Qt.alpha(Theme.text, 0.12) : Theme.red + onEdited: pg.mod.edit({ [modelData[0]]: parseInt(value, 10) || 0 }) + } + } + } + + Heading { text: "Placement" } + Flow { + width: pg.width; spacing: 6 + Repeater { + model: Quickshell.screens.map(s => s.name) + Button { + required property string modelData + text: modelData + border.color: pg.d.balloons.screen === modelData ? Theme.accent : Qt.alpha(Theme.text, 0.2) + onClicked: pg.mod.edit({ balloons: Object.assign({}, pg.d.balloons, { screen: modelData }) }) + } + } + } + // The four corners as a miniature screen: click the one balloons go to. + Grid { + columns: 2; spacing: 4 + Repeater { + model: ["top-left", "top-right", "bottom-left", "bottom-right"] + Rectangle { + required property string modelData + width: 60; height: 34; radius: 5 + color: pg.d.balloons.corner === modelData ? Qt.alpha(Theme.accent, 0.6) : Qt.alpha(Theme.surface, 0.6) + border.width: 1; border.color: Qt.alpha(Theme.text, 0.15) + MouseArea { + anchors.fill: parent; cursorShape: Qt.PointingHandCursor + onClicked: pg.mod.edit({ balloons: Object.assign({}, pg.d.balloons, { corner: modelData }) }) + } + } + } + } + + Heading { text: "Suppression" } + Toggle { + label: "Critical notifications still pop up under Do Not Disturb" + checked: pg.d.dndPassCritical + onToggled: { + const on = checked; + checked = Qt.binding(() => pg.d.dndPassCritical); + pg.mod.edit({ dndPassCritical: on }); + } + } + Text { + width: pg.width; wrapMode: Text.Wrap + text: "Muted apps get no balloon. They still appear in the drawer and in history." + color: Theme.overlay + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + } + Flow { + width: pg.width; spacing: 6 + Repeater { + model: pg.d.muted + Button { + required property string modelData + text: modelData; icon: "" + onClicked: pg.mod.edit({ muted: pg.d.muted.filter(a => a !== modelData) }) + } + } + } + // Apps seen in the queue and history, not yet muted, one click to add. + Flow { + width: pg.width; spacing: 6 + Repeater { + model: [...new Set(Notify.queue.concat(Notify.history).map(n => n.app))] + .filter(a => a && pg.d.muted.indexOf(a) < 0).sort() + Button { + required property string modelData + text: modelData; icon: "" + onClicked: pg.mod.edit({ muted: pg.d.muted.concat([modelData]) }) + } + } + } + Field { + width: 200 + placeholder: "mute another app by name" + onEdited: { + const a = value.trim(); + if (a !== "" && pg.d.muted.indexOf(a) < 0) pg.mod.edit({ muted: pg.d.muted.concat([a]) }); + value = ""; + } + } + + Text { + width: pg.width; wrapMode: Text.Wrap + visible: pg.mod.notice !== "" + text: pg.mod.notice + color: pg.mod.notice === "saved" ? Theme.green : Theme.yellow + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + } +} -- cgit v1.2.3