Give Sound the whole story, and keep the buttons inside the card

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
Gabriel Brown
2026-08-24 11:39:16 -04:00
parent 9bc68ba358
commit b58371bb35
38 changed files with 3884 additions and 213 deletions
@@ -1,8 +1,14 @@
pragma Singleton
// GNOME and GTK applications already honor these desktop sound preferences.
// Panama controls the same durable keys so moving between sessions does not
// create two competing notions of whether event feedback is enabled.
// 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
@@ -13,9 +19,58 @@ Singleton {
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
|| eventWrite.running || inputWrite.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();
@@ -31,6 +86,10 @@ Singleton {
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 {
@@ -64,6 +123,20 @@ Singleton {
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"]
@@ -118,5 +191,70 @@ Singleton {
}
}
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 directory<TAB>name 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()
}