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:
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user