// 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: "" property string error: "" // The installed sound themes (directories with an index.theme), and the // system theme as gsettings has it, "" when unreadable. Both reread on // every refresh, so a change made outside the page shows. property var themes: [] property string systemTheme: "" 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) // An edit replaces the draft's binding with a value; these put the // binding back, so a change to the file shows up while nothing is // edited. The window calls refresh() on every open, which covers the // value a save leaves behind. Not over unsaved edits. function follow() { nm.draft = Qt.binding(() => Notify.config); } function refresh() { if (!nm.dirty) nm.follow(); nm.themeLister.running = false; nm.themeLister.running = true; nm.gsGet.running = false; nm.gsGet.running = true; } function discard() { nm.follow(); nm.notice = ""; nm.error = ""; } function edit(patch) { nm.draft = Object.assign({}, nm.draft, patch); } // Unknown keys in the file survive, including inside timeouts and // balloons, which are merged one level down. The text goes on stdin to // cat (the notes pattern: the exit code is the outcome); 1 is the write, // 3 the gsettings theme, 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.error = ""; const raw = Notify.rawConfig; const obj = v => v && typeof v === "object" && !Array.isArray(v) ? v : {}; const out = Object.assign({}, raw, nm.draft, { timeouts: Object.assign({}, obj(raw.timeouts), nm.draft.timeouts), balloons: Object.assign({}, obj(raw.balloons), nm.draft.balloons), sounds: Object.assign({}, obj(raw.sounds), nm.draft.sounds), }); nm.saver.theme = nm.draft.soundTheme; nm.saver.data = JSON.stringify(out, null, 2) + "\n"; nm.saver.stdinEnabled = true; nm.saver.running = false; nm.saver.running = true; } property Process saver: Process { property string data: "" property string theme: "" command: ["sh", "-c", 'mkdir -p "$(dirname "$1")" && cat > "$1" || exit 1; ' + 'gsettings set org.gnome.desktop.sound theme-name "$2" || g=3; ' + 'notifyctl reload || exit 2; exit ${g:-0}', "sh", Notify.configPath, nm.saver.theme] onStarted: { nm.saver.write(nm.saver.data); nm.saver.stdinEnabled = false; } onExited: code => { if (code === 1) nm.error = "could not write config.json"; else nm.notice = code === 0 ? "saved" : code === 2 ? "saved; notifyd not running, it reads it at start" : "saved; system sound theme not set"; nm.gsGet.running = false; nm.gsGet.running = true; } } property Process themeLister: Process { command: ["sh", "-c", 'find "$HOME/.local/share/sounds" /usr/share/sounds -mindepth 2 -maxdepth 2 -name index.theme 2>/dev/null'] stdout: StdioCollector { onStreamFinished: nm.themes = [...new Set(text.split("\n").filter(l => l) .map(l => l.split("/").slice(-2)[0]))].sort() } } // gsettings prints the string quoted: 'freedesktop'. property Process gsGet: Process { command: ["gsettings", "get", "org.gnome.desktop.sound", "theme-name"] stdout: StdioCollector { onStreamFinished: nm.systemTheme = text.trim().replace(/^'(.*)'$/, "$1") } onExited: code => { if (code !== 0) nm.systemTheme = ""; } } page: Component { Page { title: "Notifications" actions: Component { SaveBar { dirty: nm.dirty; valid: nm.valid; note: "caps are 1 to 100"; busy: nm.saver.running notice: nm.notice; noticeColor: nm.notice === "saved" ? Theme.green : Theme.yellow error: nm.error onSave: nm.save(); onDiscard: nm.discard() } } NotificationsPage { width: parent.width; mod: nm } } } }