pragma Singleton // GNOME and GTK applications already honor these desktop sound preferences -- // whether event feedback plays, whether typing clicks, and which sound theme // those samples come from. Panama controls the same durable keys so moving // between sessions does not create two competing notions of any of it, and // plays its own notification chime out of the same theme (see Notifs.qml). // // There is deliberately no alert *volume* here: the desktop sound theme has no // volume channel of its own, and a slider that changed nothing would be worse // than no slider at all. import Quickshell import Quickshell.Io import QtQuick Singleton { id: root property bool eventSounds: true property bool inputFeedback: false // The XDG sound theme, by directory name. "freedesktop" is the one every // distribution ships and the fallback everything else is layered over. property string soundTheme: "freedesktop" property string lastError: "" readonly property bool busy: eventRead.running || inputRead.running || themeRead.running || eventWrite.running || inputWrite.running || themeWrite.running // [{ directory, name }] -- the directory is what gsettings stores, the name // is what the theme calls itself in its index.theme. property var themes: [{ directory: "freedesktop", name: "Default" }] // ── The alert sound ───────────────────────────────────────────────────── // // A theme is a directory of samples with a fallback chain, not a single // file, and a theme that overrides only a handful of sounds is normal. So // the bell is resolved as a list of candidates in preference order -- // user-installed theme, system theme, freedesktop -- and the first one that // exists is played. Resolving it in the shell rather than here keeps this // free of file probing on a hot path. readonly property string homeDir: Quickshell.env("HOME") || "" readonly property var bellCandidates: [ root.homeDir !== "" ? `${root.homeDir}/.local/share/sounds/${root.soundTheme}/stereo/bell.oga` : "", `/usr/share/sounds/${root.soundTheme}/stereo/bell.oga`, "/usr/share/sounds/freedesktop/stereo/bell.oga" ].filter((path, index, all) => path !== "" && all.indexOf(path) === index) // The argv that plays the current theme's bell once, or nothing at all if // no candidate exists. Shared with Notifs.qml, which plays the same bell // for Panama's own notification popups. readonly property var bellCommand: ["sh", "-c", 'for candidate in "$@"; do [ -f "$candidate" ] && exec pw-play "$candidate"; done; exit 0', "qs-sound-feedback"].concat(root.bellCandidates) function previewAlert(): void { if (preview.running) return; preview.command = root.bellCommand; preview.running = true; } function setSoundTheme(name: string): void { const theme = String(name ?? "").trim(); if (theme === "") return; root.soundTheme = theme; root._writeSoundTheme(); } function parsedBoolean(text: string, fallback: bool): bool { const value = text.trim(); if (value === "true") return true; if (value === "false") return false; return fallback; } function refresh(): void { if (!eventRead.running) eventRead.running = true; if (!inputRead.running) inputRead.running = true; if (!themeRead.running) themeRead.running = true; if (!themeScan.running) themeScan.running = true; } function setEventSounds(enabled: bool): void { root.eventSounds = enabled; root._writeEventSounds(); } function setInputFeedback(enabled: bool): void { root.inputFeedback = enabled; root._writeInputFeedback(); } // Assigning `running = true` to a Process that is already running is a // no-op, not a queue -- so a toggle that lands mid-write would otherwise // be dropped silently. eventWrite.onExited re-checks root.eventSounds // against what was actually written and calls this again if they still // disagree. function _writeEventSounds(): void { if (eventWrite.running) return; eventWrite.writtenValue = root.eventSounds; eventWrite.command = ["gsettings", "set", "org.gnome.desktop.sound", "event-sounds", String(root.eventSounds)]; eventWrite.running = true; } function _writeInputFeedback(): void { if (inputWrite.running) return; inputWrite.writtenValue = root.inputFeedback; inputWrite.command = ["gsettings", "set", "org.gnome.desktop.sound", "input-feedback-sounds", String(root.inputFeedback)]; inputWrite.running = true; } function _writeSoundTheme(): void { if (themeWrite.running) return; themeWrite.writtenValue = root.soundTheme; themeWrite.command = ["gsettings", "set", "org.gnome.desktop.sound", "theme-name", root.soundTheme]; themeWrite.running = true; } // gsettings prints strings quoted: 'freedesktop'. function parsedString(text: string, fallback: string): string { const value = text.trim().replace(/^'(.*)'$/, "$1"); return value === "" ? fallback : value; } Process { id: eventRead command: ["gsettings", "get", "org.gnome.desktop.sound", "event-sounds"] stdout: StdioCollector { onStreamFinished: root.eventSounds = root.parsedBoolean(this.text, root.eventSounds) } onExited: (code, status) => { if (code !== 0) root.lastError = "Event sound preferences could not be read."; } } Process { id: inputRead command: ["gsettings", "get", "org.gnome.desktop.sound", "input-feedback-sounds"] stdout: StdioCollector { onStreamFinished: root.inputFeedback = root.parsedBoolean(this.text, root.inputFeedback) } onExited: (code, status) => { if (code !== 0) root.lastError = "Input feedback preferences could not be read."; } } Process { id: eventWrite property bool writtenValue: true onExited: (code, status) => { if (code !== 0) { root.lastError = "Event sound preferences could not be changed."; root.refresh(); } else { root.lastError = ""; } if (root.eventSounds !== eventWrite.writtenValue) root._writeEventSounds(); } } Process { id: inputWrite property bool writtenValue: false onExited: (code, status) => { if (code !== 0) { root.lastError = "Input feedback preferences could not be changed."; root.refresh(); } else { root.lastError = ""; } if (root.inputFeedback !== inputWrite.writtenValue) root._writeInputFeedback(); } } Process { id: themeRead command: ["gsettings", "get", "org.gnome.desktop.sound", "theme-name"] stdout: StdioCollector { onStreamFinished: root.soundTheme = root.parsedString(this.text, root.soundTheme) } onExited: (code, status) => { if (code !== 0) root.lastError = "The alert sound theme could not be read."; } } Process { id: themeWrite property string writtenValue: "freedesktop" onExited: (code, status) => { if (code !== 0) { root.lastError = "The alert sound theme could not be changed."; root.refresh(); } else { root.lastError = ""; } if (root.soundTheme !== themeWrite.writtenValue) root._writeSoundTheme(); } } // Installed themes, from the index.theme every XDG sound theme must carry. // Emitted as directoryname so the pair cannot drift apart; a theme // whose index.theme has no Name= falls back to its directory rather than // being dropped, because it is still selectable and still works. Process { id: themeScan command: ["sh", "-c", ` for theme in "$HOME"/.local/share/sounds/*/index.theme /usr/share/sounds/*/index.theme; do [ -f "$theme" ] || continue directory=$(basename "$(dirname "$theme")") name=$(sed -n 's/^Name=//p' "$theme" | head -n1) [ -n "$name" ] || name=$directory printf '%s\\t%s\\n' "$directory" "$name" done `] stdout: StdioCollector { onStreamFinished: { const found = []; const seen = {}; for (const line of String(this.text).split("\n")) { const parts = line.split("\t"); const directory = String(parts[0] ?? "").trim(); if (directory === "" || seen[directory]) continue; seen[directory] = true; found.push({ directory, name: String(parts[1] ?? "").trim() || directory }); } // Never hand the UI an empty picker: freedesktop is the theme // every fallback chain ends at, present or not. if (!seen["freedesktop"]) found.push({ directory: "freedesktop", name: "Default" }); root.themes = found; } } } Process { id: preview } Component.onCompleted: root.refresh() }