diff options
| author | Danilo M. <danix@danix.xyz> | 2026-10-19 09:52:01 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-10-19 09:52:01 +0200 |
| commit | dad46796c841ada831f40e9ef27efd91a9897616 (patch) | |
| tree | 1af72bb1ea2bbebe9e131e8d6e27e4a0f2455c77 /docs | |
| parent | 543b818b54126bc8b40302d5b12cd44b90b20ddd (diff) | |
| download | quickshell-dad46796c841ada831f40e9ef27efd91a9897616.tar.gz quickshell-dad46796c841ada831f40e9ef27efd91a9897616.zip | |
docs: plan notification sounds
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'docs')
| -rw-r--r-- | docs/superpowers/plans/2026-10-19-notification-sounds.md | 696 |
1 files changed, 696 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-10-19-notification-sounds.md b/docs/superpowers/plans/2026-10-19-notification-sounds.md new file mode 100644 index 0000000..dd949ed --- /dev/null +++ b/docs/superpowers/plans/2026-10-19-notification-sounds.md @@ -0,0 +1,696 @@ +# Notification Sounds Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** A sound per urgency for notification balloons, chosen on the notifications settings page, honouring apps' `sound-file`, `sound-name` and `suppress-sound` hints, with the sound theme mirrored to gsettings. + +**Architecture:** notifyd resolves the hints into two published fields (`sound`, `silent`), looking `sound-name` up in `config.json`'s `soundTheme`. The balloon shell plays with `pw-play` when a new notification would show a balloon, choosing via `NotifyConfig.soundFor`. The settings page edits `sounds` and `soundTheme`, writes the theme to gsettings on save, and reads gsettings back to flag outside changes. + +**Tech Stack:** Go stdlib + godbus (notifyd, `~/Programming/GIT/notifyd`), Quickshell 0.3.1 QML, `Qt.labs.platform` FileDialog, `pw-play`, `gsettings`, node for the JS check. + +Spec: `docs/superpowers/specs/2026-10-19-notification-sounds-design.md`. Builds on `docs/superpowers/specs/2026-10-19-notification-settings-design.md` (already shipped). + +Two repos. `notifyd:` paths are relative to `~/Programming/GIT/notifyd`, others to this repo. Commits GPG-signed (never disable), each ending with a blank line and `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`. No em dashes, no `/home/` paths in committed files. Never `pkill -f`, never start/kill `qs` or `notifyd`, never run `./reload.sh`. + +--- + +## File map + +notifyd: +- Create `internal/notify/sounds.go`, `internal/notify/sounds_test.go`. +- Modify `internal/notify/config.go` (+`SoundTheme`), `config_test.go`. +- Modify `internal/notify/store.go` (`Popup` gains `Sound`, `Silent`). +- Modify `internal/notify/service.go` (`Notify` fills them). +- Modify `README.md`. + +quickshell: +- Modify `shared/NotifyConfig.js` (+`sounds`, `soundTheme`, `soundFor`), `shared/test_notify_config.js`. +- Modify `notifications/Balloons.qml` (player). +- Modify `desktop/modules/notifications/NotificationsModule.qml` (themes list, gsettings read, save merges `sounds`, saver sets gsettings), `NotificationsPage.qml` (Sounds section). +- Modify `desktop/modules/notifications/README.md`, `notifications/README.md`. + +--- + +### Task 1: notifyd sound lookup + +**Files:** Create `notifyd:internal/notify/sounds.go`, `notifyd:internal/notify/sounds_test.go`. + +- [ ] **Step 1: Failing test** + +`sounds_test.go` (GPL header from `store.go` lines 1-10): + +```go +package notify + +import ( + "os" + "path/filepath" + "testing" + + "github.com/godbus/dbus/v5" +) + +// soundTree makes $XDG_DATA_HOME/sounds/<theme>/stereo/<file> for each entry +// and an index.theme per theme with the given Inherits, and points +// XDG_DATA_HOME there with XDG_DATA_DIRS empty-but-set to another temp dir. +func soundTree(t *testing.T, inherits map[string]string, files ...string) string { + t.Helper() + home := t.TempDir() + t.Setenv("XDG_DATA_HOME", home) + t.Setenv("XDG_DATA_DIRS", t.TempDir()) + for theme, inh := range inherits { + dir := filepath.Join(home, "sounds", theme) + if err := os.MkdirAll(filepath.Join(dir, "stereo"), 0o755); err != nil { + t.Fatal(err) + } + body := "[Sound Theme]\nName=" + theme + "\n" + if inh != "" { + body += "Inherits=" + inh + "\n" + } + if err := os.WriteFile(filepath.Join(dir, "index.theme"), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + for _, f := range files { + p := filepath.Join(home, "sounds", f) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + } + return filepath.Join(home, "sounds") +} + +func TestLookupSound(t *testing.T) { + root := soundTree(t, + map[string]string{"mine": "parent", "parent": "", "freedesktop": ""}, + "mine/stereo/bell.oga", + "mine/stereo/bell.wav", + "parent/stereo/message-new-instant.ogg", + "freedesktop/stereo/dialog-warning.oga", + "freedesktop/stereo/message.oga", + ) + cases := []struct{ name, want string }{ + {"bell", root + "/mine/stereo/bell.oga"}, // direct, .oga before .wav + {"message-new-instant", root + "/parent/stereo/message-new-instant.ogg"}, // inherited + {"dialog-warning", root + "/freedesktop/stereo/dialog-warning.oga"}, // freedesktop fallback + {"message-new-email", root + "/freedesktop/stereo/message.oga"}, // name fallback + {"nothing-here", ""}, + } + for _, c := range cases { + if got := LookupSound(c.name, "mine"); got != c.want { + t.Errorf("LookupSound(%q) = %q, want %q", c.name, got, c.want) + } + } +} + +func TestLookupSoundInheritsCycle(t *testing.T) { + soundTree(t, map[string]string{"a": "b", "b": "a"}) + if got := LookupSound("bell", "a"); got != "" { + t.Errorf("got %q, want empty", got) + } +} + +func TestSoundFromHints(t *testing.T) { + root := soundTree(t, map[string]string{"freedesktop": ""}, "freedesktop/stereo/bell.oga") + file := filepath.Join(t.TempDir(), "ding.wav") + if err := os.WriteFile(file, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + v := dbus.MakeVariant + cases := []struct { + name string + hints map[string]dbus.Variant + sound string + silent bool + }{ + {"none", map[string]dbus.Variant{}, "", false}, + {"name", map[string]dbus.Variant{"sound-name": v("bell")}, root + "/freedesktop/stereo/bell.oga", false}, + {"file wins", map[string]dbus.Variant{"sound-name": v("bell"), "sound-file": v(file)}, file, false}, + {"relative file rejected", map[string]dbus.Variant{"sound-file": v("ding.wav")}, "", false}, + {"missing file rejected", map[string]dbus.Variant{"sound-file": v(file + ".gone")}, "", false}, + {"bad file falls to name", map[string]dbus.Variant{"sound-file": v("x"), "sound-name": v("bell")}, root + "/freedesktop/stereo/bell.oga", false}, + {"suppress", map[string]dbus.Variant{"suppress-sound": v(true), "sound-name": v("bell")}, root + "/freedesktop/stereo/bell.oga", true}, + {"wrong types", map[string]dbus.Variant{"suppress-sound": v("yes"), "sound-name": v(3)}, "", false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + sound, silent := SoundFromHints(c.hints, "freedesktop") + if sound != c.sound || silent != c.silent { + t.Errorf("got (%q, %v), want (%q, %v)", sound, silent, c.sound, c.silent) + } + }) + } +} +``` + +- [ ] **Step 2: Run, expect failure** + +`go test ./internal/notify/ -run 'Sound'` → `undefined: LookupSound`. + +- [ ] **Step 3: Implement** + +`sounds.go` (GPL header): + +```go +package notify + +import ( + "log" + "os" + "path/filepath" + "strings" + + "github.com/godbus/dbus/v5" +) + +// XDGSoundDirs is the sound theme search path: the user's dir then each data +// dir, the same roots as XDGIconDirs. +func XDGSoundDirs() []string { + dirs := XDGIconDirs() + for i, d := range dirs { + dirs[i] = filepath.Join(filepath.Dir(d), "sounds") + } + return dirs +} + +var soundExts = []string{".oga", ".ogg", ".wav"} + +// LookupSound resolves a sound naming spec name: the theme, its Inherits +// chain, then freedesktop, each searched across every root; within that, the +// full name first and then with trailing -components dropped, as the spec's +// fallback (message-new-email, message-new, message). "" when nothing matches. +func LookupSound(name, theme string) string { + var chain []string + seen := map[string]bool{} + for t := theme; t != "" && !seen[t]; { + seen[t] = true + chain = append(chain, t) + next := "" + for _, root := range XDGSoundDirs() { + if v := inheritsOf(filepath.Join(root, t)); v != "" { + next = v + break + } + } + t = next + } + if !seen["freedesktop"] { + chain = append(chain, "freedesktop") + } + for n := name; n != ""; { + for _, t := range chain { + for _, root := range XDGSoundDirs() { + for _, ext := range soundExts { + p := filepath.Join(root, t, "stereo", n+ext) + if fileExists(p) { + return p + } + } + } + } + i := strings.LastIndexByte(n, '-') + if i < 0 { + break + } + n = n[:i] + } + return "" +} + +// SoundFromHints reads the three freedesktop sound hints. sound-file wins over +// sound-name, but only as an absolute path to an existing file: anything else +// is logged and treated as absent, so the name still gets its turn. silent is +// suppress-sound, published as is; the renderer decides what it means. +func SoundFromHints(hints map[string]dbus.Variant, theme string) (sound string, silent bool) { + if v, ok := hints["suppress-sound"]; ok { + silent, _ = v.Value().(bool) + } + if v, ok := hints["sound-file"]; ok { + if p, _ := v.Value().(string); p != "" { + if filepath.IsAbs(p) && fileExists(p) { + return p, silent + } + log.Printf("notifyd: sound-file %q is not an absolute path to a file", p) + } + } + if v, ok := hints["sound-name"]; ok { + if n, _ := v.Value().(string); n != "" { + return LookupSound(n, theme), silent + } + } + return "", silent +} +``` + +Note `XDGIconDirs` appends `/usr/local/share/icons` and `/usr/share/icons` only when `XDG_DATA_DIRS` is empty; the test sets it to a temp dir so the real system themes do not leak in. `inheritsOf` takes the first comma-separated value only, same as for icons. + +- [ ] **Step 4: Run, expect pass** + +`go test ./internal/notify/ -run 'Sound' -v`, then `go vet ./... && go test ./...`, `gofmt -l .` empty. + +- [ ] **Step 5: Commit** + +``` +feat(notify): resolve sound-file, sound-name and suppress-sound hints + +sound-name is looked up in a sound theme, its Inherits chain and +freedesktop, with the naming spec's trailing-component fallback. A +sound-file must be an absolute path to an existing file, else it is +treated as absent so a sound-name in the same notification still applies. +``` + +--- + +### Task 2: notifyd config and published fields + +**Files:** Modify `notifyd:internal/notify/config.go`, `config_test.go`, `store.go`, `service.go`, `README.md`. + +- [ ] **Step 1: Failing test** + +Append to `config_test.go`: + +```go +func TestLoadConfigSoundTheme(t *testing.T) { + if got := LoadConfig(writeConfig(t, `{"soundTheme": "mine"}`)).SoundTheme; got != "mine" { + t.Errorf("soundTheme = %q, want mine", got) + } + if got := LoadConfig(writeConfig(t, `{"soundTheme": ""}`)).SoundTheme; got != "freedesktop" { + t.Errorf("empty soundTheme = %q, want freedesktop", got) + } + if got := LoadConfig(writeConfig(t, `{"soundTheme": 4, "liveCap": 3}`)); got.SoundTheme != "freedesktop" || got.LiveCap != 3 { + t.Errorf("got %+v", got) + } +} +``` + +Run `go test ./internal/notify/ -run SoundTheme` → `got.SoundTheme undefined`. + +- [ ] **Step 2: Implement** + +`config.go`: add to `Config` after `HistoryCap`: + +```go + SoundTheme string `json:"soundTheme"` +``` + +and update its doc comment's list of what the daemon reads ("timeouts, caps and the sound theme for sound-name hints"). In `DefaultConfig()` add `SoundTheme: "freedesktop"`. In `LoadConfig`, after the timeout clamps: + +```go + if c.SoundTheme == "" { + c.SoundTheme = def.SoundTheme + } +``` + +`store.go`, `Popup`: add after `Image`: + +```go + Sound string `json:"sound"` + Silent bool `json:"silent"` +``` + +`service.go`, `Notify`: where it copies `timeouts` under `s.mu`, also copy the theme: + +```go + s.mu.Lock() + timeouts := s.cfg.Timeouts + theme := s.cfg.SoundTheme + s.mu.Unlock() +``` + +and in the `n := &Popup{...}` literal add nothing; after it: + +```go + n.Sound, n.Silent = SoundFromHints(hints, theme) +``` + +- [ ] **Step 3: Test** + +`go vet ./... && go test ./...`, `gofmt -l .` empty. `TestLoadConfigMissingFileIsDefaults` etc. compare whole structs and still pass because `DefaultConfig` carries the theme. + +- [ ] **Step 4: README** + +In `README.md`'s Configuration section, add `"soundTheme": "freedesktop"` to the JSON example and this paragraph after the caps one: + +```markdown +`soundTheme` is the freedesktop sound theme an app's `sound-name` hint is +looked up in (then its `Inherits`, then `freedesktop`). The daemon publishes +each notification's resolved `sound` (a path, or `""`) and `silent` (the +app's `suppress-sound`); a `sound-file` hint wins over `sound-name` when it +is an absolute path to an existing file. Playing is the renderer's job. +``` + +- [ ] **Step 5: Commit, push** + +``` +feat(notify): publish each notification's sound and suppress-sound + +The sound-name lookup uses config.json's soundTheme, default freedesktop. +The quickshell settings page mirrors that key to gsettings; the daemon +only reads the file. +``` + +Then `git push`. + +- [ ] **Step 6: Build for the user** + +Build only, into the session scratchpad the controller gives you; never into `~/bin`: + +```bash +go build -o <scratch>/notifyd ./cmd/notifyd && go build -o <scratch>/notifyctl ./cmd/notifyctl +``` + +--- + +### Task 3: `NotifyConfig.js` sounds + +**Files:** Modify `shared/NotifyConfig.js`, `shared/test_notify_config.js`. + +- [ ] **Step 1: Failing test** + +In `test_notify_config.js`, export `soundFor` (`return { defaults, resolve, withheld, soundFor };`) and add before `console.log("ok")`: + +```js +// Sounds: strings only, empty theme falls back. +const s = C.resolve({ sounds: { low: "/a.oga", normal: 3 }, soundTheme: "" }); +assert.deepStrictEqual(s.sounds, { low: "/a.oga", normal: "", critical: "" }); +assert.strictEqual(s.soundTheme, "freedesktop"); +assert.strictEqual(C.resolve({ soundTheme: "mine" }).soundTheme, "mine"); + +// soundFor: silent beats everything, the app's sound beats the urgency's. +const sc = C.resolve({ sounds: { low: "", normal: "/n.oga", critical: "/c.oga" } }); +const sn = (urgency, sound, silent) => ({ app: "x", urgency, sound, silent }); +assert.strictEqual(C.soundFor(sc, sn("normal", "", false)), "/n.oga"); +assert.strictEqual(C.soundFor(sc, sn("low", "", false)), ""); +assert.strictEqual(C.soundFor(sc, sn("low", "/app.oga", false)), "/app.oga"); +assert.strictEqual(C.soundFor(sc, sn("critical", "/app.oga", true)), ""); +// An old daemon publishes neither field. +assert.strictEqual(C.soundFor(sc, { app: "x", urgency: "critical" }), "/c.oga"); +``` + +Run `node shared/test_notify_config.js` → assertion failure on `s.sounds`. + +- [ ] **Step 2: Implement** + +In `defaults()` add: + +```js + sounds: { low: "", normal: "", critical: "" }, + soundTheme: "freedesktop", +``` + +In `resolve`, read `const s = raw.sounds || {};` beside `t` and `b`, and add to the returned object: + +```js + sounds: { + low: typeof s.low === "string" ? s.low : d.sounds.low, + normal: typeof s.normal === "string" ? s.normal : d.sounds.normal, + critical: typeof s.critical === "string" ? s.critical : d.sounds.critical, + }, + soundTheme: typeof raw.soundTheme === "string" && raw.soundTheme !== "" ? raw.soundTheme : d.soundTheme, +``` + +Append: + +```js +// The file a balloon plays, "" for none: the app's suppress-sound wins, then +// the app's own sound (notifyd resolved its hints), then the urgency's. +function soundFor(cfg, n) { + if (n.silent) return ""; + if (n.sound) return n.sound; + return cfg.sounds[n.urgency] || ""; +} +``` + +Update the header comment's mention of what is "this side's alone" to include `sounds`. + +- [ ] **Step 3: Pass, commit** + +`node shared/test_notify_config.js` → `ok`. + +``` +notifications: resolve the sound keys and choose a balloon's sound +``` + +--- + +### Task 4: balloon shell plays + +**Files:** Modify `notifications/Balloons.qml`. + +- [ ] **Step 1: Implement** + +Add `import "NotifyConfig.js" as NC` (the link `notifications/NotifyConfig.js` exists). Inside the root `Scope`, after `readonly property var live`: + +```qml + // A sound per new or replaced notification (replace keeps the id and + // changes created), played only when its balloon would show now: the + // same drawer, snooze, mute and DND checks as NotificationBalloon. The + // first read after a start only records what is there, so a restart is + // silent. seen is rebuilt from the queue each time, so it never grows. + property var seen: ({}) + property bool primed: false + Connections { + target: Notify + function onQueueChanged() { + const next = {}; + let file = ""; + for (const n of Notify.queue || []) { + const key = n.id + ":" + n.created; + next[key] = true; + if (!root.primed || root.seen[key] || file !== "") continue; + if (Notify.drawerOpen || Notify.snoozeUntil > Date.now()) continue; + if (NC.withheld(Notify.config, n, Notify.dnd)) continue; + file = NC.soundFor(Notify.config, n); + } + root.seen = next; + root.primed = true; + if (file !== "") { + // ponytail: one player, a new sound cuts the last; a queue of + // sounds if bursts ever need every one heard. + player.command = ["pw-play", file]; + player.running = false; + player.running = true; + } + } + } + Process { id: player } +``` + +The queue is newest first, so the first unseen, unwithheld entry with a sound is the newest. `Quickshell.Io` is already imported for `FileView`. + +- [ ] **Step 2: Check reload** + +`qs log -p notifications --log-times | tail -5`: a `Reloading configuration` after the edit, no errors. `shared/` is untouched in this task so the hot reload is enough for this file; `Notify.qml` is unchanged. + +- [ ] **Step 3: Commit** + +``` +notifications: play a sound for a new balloon + +pw-play, once per new or replaced notification, only when the balloon +would show: the drawer, snooze, mute and DND that withhold the balloon +silence it too. A restart records the queue without playing it. +``` + +--- + +### Task 5: settings page + +**Files:** Modify `desktop/modules/notifications/NotificationsModule.qml`, `NotificationsPage.qml`. + +Read both files first; they changed after the first plan (draft binding via `follow()`, nested merge in `save()`, `busy` on the SaveBar, Field rebinding, red/dim chips). + +- [ ] **Step 1: Module: themes, gsettings, save** + +In `NotificationsModule.qml`: + +1. Properties: + +```qml + // 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: "" +``` + +2. `refresh()` becomes: + +```qml + function refresh() { + if (!nm.dirty) nm.follow(); + nm.themeLister.running = false; nm.themeLister.running = true; + nm.gsGet.running = false; nm.gsGet.running = true; + } +``` + +3. Processes: + +```qml + 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 = ""; } + } +``` + +4. `save()`: add `sounds` to the one-level merge beside `timeouts` and `balloons`: + +```qml + sounds: Object.assign({}, obj(raw.sounds), nm.draft.sounds), +``` + +5. The saver: the theme goes to gsettings between the write and the reload. Replace its `command` and `onExited`: + +```qml + 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] + property string theme: "" + onExited: code => { + nm.notice = code === 0 ? "saved" + : code === 2 ? "saved; notifyd not reachable, it reads the file at its next start" + : code === 3 ? "saved; the system sound theme could not be set" + : "could not write " + Notify.configPath; + nm.gsGet.running = false; nm.gsGet.running = true; + } +``` + +and in `save()` set `nm.saver.theme = nm.draft.soundTheme;` before starting it. Update the comment above `save()` to say 3 is the gsettings write. + +- [ ] **Step 2: Page: Sounds section** + +In `NotificationsPage.qml` add `import Qt.labs.platform as P` and `import Quickshell.Io`, and a Sounds section right after the Timeouts `Repeater`: + +```qml + Heading { text: "Sounds" } + Text { + width: pg.width; wrapMode: Text.Wrap + text: "Played when a balloon pops, never for one muted, held by Do Not Disturb or snoozed. An app's own sound, or its request for silence, 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 + readonly property string file: pg.d.sounds[modelData[0]] + Text { + width: 90; anchors.verticalCenter: parent.verticalCenter + text: modelData[1]; color: Theme.text + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + } + Text { + width: 180; anchors.verticalCenter: parent.verticalCenter + elide: Text.ElideMiddle + text: parent.file ? parent.file.split("/").pop() : "none" + color: parent.file ? Theme.text : Theme.overlay + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + } + Button { + text: "Choose…" + onClicked: { picker.key = modelData[0]; picker.open(); } + } + Button { + icon: "" // nf-fa-play + enabled: parent.file !== "" + onClicked: { preview.command = ["pw-play", parent.file]; preview.running = false; preview.running = true; } + } + Button { + icon: "" + enabled: parent.file !== "" + onClicked: pg.mod.edit({ sounds: Object.assign({}, pg.d.sounds, { [modelData[0]]: "" }) }) + } + } + } + Text { + topPadding: 6 + text: "Theme for sounds apps ask for by name, also set as the system sound theme" + color: Theme.subtext + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4; bold: true } + } + Flow { + width: pg.width; spacing: 6 + Repeater { + model: pg.mod.themes + Button { + required property string modelData + text: modelData + border.color: pg.d.soundTheme === modelData ? Theme.accent : Qt.alpha(Theme.text, 0.2) + onClicked: pg.mod.edit({ soundTheme: modelData }) + } + } + } + Row { + spacing: 10 + visible: pg.mod.systemTheme !== "" && pg.mod.systemTheme !== pg.d.soundTheme + Text { + anchors.verticalCenter: parent.verticalCenter + text: "System sound theme is " + pg.mod.systemTheme + ", changed outside this page" + color: Theme.yellow + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + } + Button { + text: "Use " + pg.mod.systemTheme + onClicked: pg.mod.edit({ soundTheme: pg.mod.systemTheme }) + } + } + + Process { id: preview } + // Qt.labs.platform, not QtQuick.Dialogs: the Quick dialog opens light on + // this desktop (AGENTS.md); this one takes the widget style. + P.FileDialog { + id: picker + property string key: "" + folder: "file://" + Quickshell.env("HOME") + "/.local/share/sounds" + nameFilters: ["Sounds (*.oga *.ogg *.wav)"] + onAccepted: pg.mod.edit({ sounds: Object.assign({}, pg.d.sounds, + { [picker.key]: decodeURIComponent(file.toString().replace(/^file:\/\//, "")) }) }) + } +``` + +`Quickshell` is already imported by the page (check; add `import Quickshell` if not). Check the play glyph `` exists: `fc-list ':charset=f04b' family | grep -ci inconsolata` must be non-zero; if not, pick another play glyph that is. + +Note the yellow line hides when gsettings is unreadable (`systemTheme === ""`) and when it agrees with the draft, so "Use x" clears it immediately and Save then sets both. + +- [ ] **Step 3: Check it loads** + +Edit `desktop/shell.qml`? Not needed: no new file, both edited files reload on save. Check `qs log -p desktop --log-times | tail -20` for a reload after the edits and no `TypeError`, `ReferenceError`, `is not a type`, `Unable to assign`, `Cannot assign`. Open the page through IPC if a target exists (`qs -p desktop ipc show`) and recheck the log. + +- [ ] **Step 4: Commit, push** + +``` +desktop: notification sounds on the settings page + +A sound per urgency chosen with the widget file dialog, previewed with +pw-play, and the sound theme for apps' sound-name hints. Save writes the +theme to gsettings as well, and the page reads gsettings back on every +open so a change made elsewhere is shown, with a button to adopt it, +rather than overwritten. +``` + +--- + +### Task 6: docs + +**Files:** `desktop/modules/notifications/README.md`, `notifications/README.md`. + +- [ ] **Step 1:** Module README: add `sounds` (balloon shell) and `soundTheme` (notifyd, mirrored to gsettings by the page) rows to the key table, and a paragraph: the page reads gsettings on every open and flags a mismatch; the save exit codes (1 write, 3 gsettings, 2 reload). +- [ ] **Step 2:** Balloon README: a "Sounds" section: played with `pw-play` once per new or replaced notification, only when the balloon would show; precedence `silent` > the app's `sound` > `sounds[urgency]`; a restart is silent; one player, a new sound cuts the last. +- [ ] **Step 3:** Commit `docs: notification sounds`, push. |
