diff options
Diffstat (limited to 'docs/superpowers/plans/2026-10-19-notification-settings.md')
| -rw-r--r-- | docs/superpowers/plans/2026-10-19-notification-settings.md | 1153 |
1 files changed, 1153 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-10-19-notification-settings.md b/docs/superpowers/plans/2026-10-19-notification-settings.md new file mode 100644 index 0000000..ece806e --- /dev/null +++ b/docs/superpowers/plans/2026-10-19-notification-settings.md @@ -0,0 +1,1153 @@ +# Notification Settings 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 settings window page that configures notifyd's timeouts and caps and the balloon shell's placement, DND rule and muted apps, through one file. + +**Architecture:** `~/.config/notifyd/config.json` is read by notifyd (timeouts, caps; re-read on `notifyctl reload`) and by both quickshell processes through `shared/Notify.qml` (watched `FileView`). A pure `NotifyConfig.js` library resolves defaults and decides suppression, so both shells and the node test agree. The page edits a draft held by its `Module` and saves with `cat >` then `notifyctl reload`. + +**Tech Stack:** Go 1.27 stdlib + godbus (notifyd repo, `~/Programming/GIT/notifyd`), Quickshell 0.3.1 QML, node for the JS check. + +Spec: `docs/superpowers/specs/2026-10-19-notification-settings-design.md`. + +Two repos. Paths under `notifyd:` are relative to `~/Programming/GIT/notifyd`, the rest to this repo. Every commit is GPG-signed (default config, never disable) and ends with the `Co-Authored-By` trailer the session gives. No home paths in committed files: write `~`. + +--- + +## File map + +notifyd: +- Create `internal/notify/config.go`: `Config`, `Timeouts`, `DefaultConfig`, `ConfigPath`, `LoadConfig`. +- Create `internal/notify/config_test.go`. +- Modify `internal/notify/policy.go`: timeouts come from a `Timeouts` argument. +- Modify `internal/notify/policy_test.go`: new signature. +- Modify `internal/notify/store.go`: caps become fields with `SetCaps`. +- Modify `internal/notify/store_test.go`: a `SetCaps` case. +- Modify `internal/notify/service.go`: holds a `Config`, loads at `Start`, `reload()`. +- Modify `internal/notify/control.go`: `Reload` method. +- Modify `cmd/notifyctl/main.go`: `reload` verb. +- Modify `README.md`. + +quickshell: +- Create `shared/NotifyConfig.js` + links `desktop/NotifyConfig.js`, `notifications/NotifyConfig.js`. +- Create `shared/test_notify_config.js`. +- Modify `shared/Notify.qml`: `config`, `rawConfig`, `configPath`. +- Modify `notifications/Balloons.qml`: screen and corner from config. +- Modify `notifications/NotificationBalloon.qml`: suppression through `NotifyConfig.withheld`. +- Create `desktop/modules/notifications/NotificationsModule.qml`, `NotificationsPage.qml`, `README.md`. +- Modify `desktop/shell.qml`: import + module entry. +- Modify `notifications/README.md`, `AGENTS.md`. + +--- + +### Task 1: notifyd config loader + +**Files:** +- Create: `notifyd:internal/notify/config.go` +- Test: `notifyd:internal/notify/config_test.go` + +- [ ] **Step 1: Write the failing test** + +`notifyd:internal/notify/config_test.go` (license header as in every file in the repo, copy from `store.go` lines 1-10): + +```go +package notify + +import ( + "os" + "path/filepath" + "testing" +) + +func writeConfig(t *testing.T, body string) string { + t.Helper() + p := filepath.Join(t.TempDir(), "config.json") + if err := os.WriteFile(p, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + return p +} + +func TestLoadConfigMissingFileIsDefaults(t *testing.T) { + got := LoadConfig(filepath.Join(t.TempDir(), "absent.json")) + if got != DefaultConfig() { + t.Errorf("got %+v, want defaults", got) + } +} + +func TestLoadConfigMalformedIsDefaults(t *testing.T) { + got := LoadConfig(writeConfig(t, `{"liveCap": 5,`)) + if got != DefaultConfig() { + t.Errorf("got %+v, want defaults", got) + } +} + +func TestLoadConfigPartialMergesOverDefaults(t *testing.T) { + got := LoadConfig(writeConfig(t, `{"timeouts": {"low": 3}, "historyCap": 50, "muted": ["x"]}`)) + want := DefaultConfig() + want.Timeouts.Low = 3 + want.HistoryCap = 50 + if got != want { + t.Errorf("got %+v, want %+v", got, want) + } +} + +func TestLoadConfigWrongTypeKeepsThatDefault(t *testing.T) { + got := LoadConfig(writeConfig(t, `{"liveCap": "many", "historyCap": 7}`)) + if got.LiveCap != 20 || got.HistoryCap != 7 { + t.Errorf("got live %d history %d, want 20 and 7", got.LiveCap, got.HistoryCap) + } +} + +func TestLoadConfigClamps(t *testing.T) { + got := LoadConfig(writeConfig(t, `{"liveCap": 0, "historyCap": 1000, "timeouts": {"normal": -4}}`)) + if got.LiveCap != 1 || got.HistoryCap != 100 || got.Timeouts.Normal != 10 { + t.Errorf("got %+v", got) + } +} +``` + +- [ ] **Step 2: Run, expect failure** + +Run: `cd ~/Programming/GIT/notifyd && go test ./internal/notify/ -run LoadConfig` +Expected: build failure, `undefined: LoadConfig`. + +- [ ] **Step 3: Implement** + +`notifyd:internal/notify/config.go` (license header first): + +```go +package notify + +import ( + "encoding/json" + "errors" + "log" + "os" + "path/filepath" +) + +// Timeouts are the balloon lifetimes in seconds per urgency, used only when a +// client leaves the choice to the server. 0 means never expires. +type Timeouts struct { + Low int `json:"low"` + Normal int `json:"normal"` + Critical int `json:"critical"` +} + +// Config is the daemon's share of ~/.config/notifyd/config.json. The file +// also carries the balloon shell's keys (placement, DND, mute); they are not +// declared here and the decoder ignores them. +type Config struct { + Timeouts Timeouts `json:"timeouts"` + LiveCap int `json:"liveCap"` + HistoryCap int `json:"historyCap"` +} + +// DefaultConfig is the behaviour before the file existed. +func DefaultConfig() Config { + return Config{Timeouts: Timeouts{Low: 10, Normal: 10, Critical: 0}, LiveCap: 20, HistoryCap: 20} +} + +// ConfigPath is $XDG_CONFIG_HOME/notifyd/config.json, ~/.config when unset. +func ConfigPath() string { + dir, err := os.UserConfigDir() + if err != nil { + dir = filepath.Join(homeDir(), ".config") + } + return filepath.Join(dir, "notifyd", "config.json") +} + +// LoadConfig never fails: a hand typo must not stop the daemon. A missing +// file or broken JSON is the defaults; a key of the wrong type keeps its +// default while the rest of the file applies, which is what encoding/json +// does on an UnmarshalTypeError. Out-of-range values clamp. +func LoadConfig(path string) Config { + def := DefaultConfig() + data, err := os.ReadFile(path) + if err != nil { + if !errors.Is(err, os.ErrNotExist) { + log.Printf("notifyd: config: %v", err) + } + return def + } + c := def + if err := json.Unmarshal(data, &c); err != nil { + var typeErr *json.UnmarshalTypeError + if !errors.As(err, &typeErr) { + log.Printf("notifyd: config %s: %v, using defaults", path, err) + return def + } + log.Printf("notifyd: config %s: %v", path, err) + } + c.LiveCap = clamp(c.LiveCap, 1, 100) + c.HistoryCap = clamp(c.HistoryCap, 1, 100) + if c.Timeouts.Low < 0 { + c.Timeouts.Low = def.Timeouts.Low + } + if c.Timeouts.Normal < 0 { + c.Timeouts.Normal = def.Timeouts.Normal + } + if c.Timeouts.Critical < 0 { + c.Timeouts.Critical = def.Timeouts.Critical + } + return c +} + +func clamp(v, lo, hi int) int { + return min(max(v, lo), hi) +} +``` + +`homeDir()` is in `icons.go:216`; reuse it. + +- [ ] **Step 4: Run, expect pass** + +Run: `go test ./internal/notify/ -run LoadConfig -v` +Expected: 5 PASS. If `TestLoadConfigWrongTypeKeepsThatDefault` fails because `historyCap` was not applied, the decoder stopped at the type error: decode into `map[string]json.RawMessage` and unmarshal each known key separately instead. + +- [ ] **Step 5: Commit** + +```bash +git add internal/notify/config.go internal/notify/config_test.go +git commit -m "feat(notify): read timeouts and caps from config.json + +A missing or broken file is the defaults, so a hand typo never stops the +daemon; a key of the wrong type keeps its default while the rest applies." +``` + +--- + +### Task 2: timeouts from config + +**Files:** +- Modify: `notifyd:internal/notify/policy.go:25-50` +- Modify: `notifyd:internal/notify/policy_test.go:42-63` + +- [ ] **Step 1: Update the test to the new signature** + +Replace `TestEffectiveTimeoutMS` in `policy_test.go`: + +```go +func TestEffectiveTimeoutMS(t *testing.T) { + def := DefaultConfig().Timeouts + custom := Timeouts{Low: 3, Normal: 7, Critical: 60} + cases := []struct { + name string + expire int32 + u Urgency + t Timeouts + want int64 + }{ + {"minus one uses low default", -1, Low, def, 10_000}, + {"minus one uses normal default", -1, Normal, def, 10_000}, + {"minus one critical never", -1, Critical, def, 0}, + {"minus one uses configured low", -1, Low, custom, 3_000}, + {"minus one uses configured critical", -1, Critical, custom, 60_000}, + {"zero never", 0, Normal, custom, 0}, + {"explicit wins", 3_000, Normal, custom, 3_000}, + {"sub second exact", 1, Normal, def, 1}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := EffectiveTimeoutMS(c.expire, c.u, c.t); got != c.want { + t.Errorf("EffectiveTimeoutMS(%d, %q) = %d, want %d", c.expire, c.u, got, c.want) + } + }) + } +} +``` + +- [ ] **Step 2: Run, expect failure** + +Run: `go test ./internal/notify/ -run EffectiveTimeout` +Expected: `too many arguments in call to EffectiveTimeoutMS`. + +- [ ] **Step 3: Implement** + +In `policy.go`, replace `DefaultTimeoutMS` and `EffectiveTimeoutMS`: + +```go +// DefaultTimeoutMS is the balloon lifetime for an urgency when the client +// leaves the choice to the server (expire_timeout -1), from the config's +// seconds. 0 is never. +func DefaultTimeoutMS(u Urgency, t Timeouts) int64 { + switch u { + case Low: + return int64(t.Low) * 1000 + case Critical: + return int64(t.Critical) * 1000 + default: + return int64(t.Normal) * 1000 + } +} + +// EffectiveTimeoutMS resolves the client's expire_timeout per the freedesktop +// spec: -1 means the server decides (the configured default), 0 means never, +// and anything positive is milliseconds and wins. The spec is precise about +// the direction and libnotify's default is -1, so getting it backwards would +// make every plain notify-send immortal. +func EffectiveTimeoutMS(expire int32, u Urgency, t Timeouts) int64 { + switch { + case expire == -1: + return DefaultTimeoutMS(u, t) + case expire == 0: + return 0 + default: + return int64(expire) + } +} +``` + +Note the `default` branch: an unknown urgency is normal, matching `UrgencyFromHints`. + +In `service.go`, add a config to `Service` (guarded by the existing `mu`): + +```go +type Service struct { + conn *dbus.Conn + store *Store + dir string + + mu sync.Mutex + timers map[uint32]*time.Timer + cfg Config +} +``` + +In `NewService`, set `cfg: DefaultConfig()` in the literal. In `Notify`, replace `ms := EffectiveTimeoutMS(expireTimeout, u)` with: + +```go + s.mu.Lock() + timeouts := s.cfg.Timeouts + s.mu.Unlock() + ms := EffectiveTimeoutMS(expireTimeout, u, timeouts) +``` + +- [ ] **Step 4: Run all tests** + +Run: `go test ./...` +Expected: all PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/notify/policy.go internal/notify/policy_test.go internal/notify/service.go +git commit -m "feat(notify): take the per-urgency default timeout from config" +``` + +--- + +### Task 3: caps as store fields + +**Files:** +- Modify: `notifyd:internal/notify/store.go:39-56, 226-235` +- Test: `notifyd:internal/notify/store_test.go` + +- [ ] **Step 1: Write the failing test** + +Append to `store_test.go`: + +```go +func TestSetCapsAppliesAtNextAdd(t *testing.T) { + s, _ := newTestStore() + for i := 0; i < 5; i++ { + id, _ := s.Add(popup("a"), "", 0) + s.Dismiss(id, 2) + } + for i := 0; i < 4; i++ { + s.Add(popup("a"), "", 0) + } + s.SetCaps(2, 3) + // Lowering a cap drops nothing by itself. + if got := len(s.historySnapshot()); got != 5 { + t.Fatalf("history after SetCaps = %d, want 5", got) + } + s.Add(popup("a"), "", 0) + if got := len(s.order); got != 2 { + t.Errorf("live = %d, want 2", got) + } + if got := len(s.historySnapshot()); got != 3 { + t.Errorf("history = %d, want 3", got) + } +} +``` + +- [ ] **Step 2: Run, expect failure** + +Run: `go test ./internal/notify/ -run SetCaps` +Expected: `s.SetCaps undefined`. + +- [ ] **Step 3: Implement** + +In `store.go`, delete the `liveCap`/`historyCap` const block. Add fields to `Store` after `history`: + +```go + liveCap int + historyCap int +``` + +In `NewStore`, add to the literal: + +```go + liveCap: DefaultConfig().LiveCap, + historyCap: DefaultConfig().HistoryCap, +``` + +Add after `ClearHistory`: + +```go +// SetCaps changes the queue and history bounds. Nothing is trimmed here: a +// reload must not silently drop live notifications, so a lowered cap applies +// at the next add. +func (s *Store) SetCaps(live, history int) { + s.mu.Lock() + defer s.mu.Unlock() + s.liveCap = live + s.historyCap = history +} +``` + +In `fileLocked` replace both `historyCap` with `s.historyCap`; in `evictLocked` replace `liveCap` with `s.liveCap`. + +- [ ] **Step 4: Run all tests** + +Run: `go test ./...` +Expected: all PASS, including the existing `TestQueueCapEvictsToHistory` and `TestHistoryRingCapsAtTwenty`. + +- [ ] **Step 5: Commit** + +```bash +git add internal/notify/store.go internal/notify/store_test.go +git commit -m "feat(notify): make the queue and history caps settable + +A lowered cap applies at the next add rather than at once, so reloading +the config never drops a notification the user has not seen." +``` + +--- + +### Task 4: load at start, `notifyctl reload` + +**Files:** +- Modify: `notifyd:internal/notify/service.go` (`Start`, new `reload`) +- Modify: `notifyd:internal/notify/control.go` +- Modify: `notifyd:cmd/notifyctl/main.go` + +- [ ] **Step 1: Implement** + +In `service.go`, add after `Start`: + +```go +// reload re-reads the config file and applies it. Timers already armed keep +// their old lifetime; the next notification gets the new one. +func (s *Service) reload() { + c := LoadConfig(ConfigPath()) + s.mu.Lock() + s.cfg = c + s.mu.Unlock() + s.store.SetCaps(c.LiveCap, c.HistoryCap) +} +``` + +In `Start`, call `s.reload()` immediately before `s.store.Reset()`. + +In `control.go`, append: + +```go +// Reload re-reads ~/.config/notifyd/config.json. The settings page calls it +// after a save; a bad file is logged and read as defaults, never an error. +func (c *control) Reload() *dbus.Error { + c.svc.reload() + return nil +} +``` + +In `cmd/notifyctl/main.go`, add to the usage text after `clear-history`: + +``` + reload re-read ~/.config/notifyd/config.json +``` + +and to the switch before `default:`: + +```go + case "reload": + callControl("Reload") +``` + +- [ ] **Step 2: Build and test** + +Run: `go vet ./... && go test ./...` +Expected: no vet output, all PASS. + +- [ ] **Step 3: Check against a throwaway daemon is not possible here** + +The live daemon owns the bus name, so a second one cannot start. The live check is Task 9, step 4. Confirm only that a missing daemon is reported: `go run ./cmd/notifyctl reload; echo $?` against the *installed* (old) daemon prints `notifyctl: ... Unknown method` style error and `1`. That is the "daemon not reachable" path the page reports. + +- [ ] **Step 4: Commit** + +```bash +git add internal/notify/service.go internal/notify/control.go cmd/notifyctl/main.go +git commit -m "feat(notifyctl): add reload, re-reading the config file + +The daemon also reads the file at start. Armed timers keep their lifetime; +the next notification gets the new default." +``` + +--- + +### Task 5: notifyd README + +**Files:** +- Modify: `notifyd:README.md` (new section before `## License`) + +- [ ] **Step 1: Add the section** + +```markdown +## Configuration + +`~/.config/notifyd/config.json` (`$XDG_CONFIG_HOME` is honoured). Every key +is optional and the defaults are the behaviour without the file: + + { + "timeouts": { "low": 10, "normal": 10, "critical": 0 }, + "liveCap": 20, + "historyCap": 20 + } + +Timeouts are seconds, 0 is never, and apply only when a client sends +`expire_timeout -1`. Caps clamp to 1..100; a lowered cap applies at the next +notification. A broken file is logged and read as the defaults. + +The daemon reads it at start and on `notifyctl reload`. The quickshell +settings page writes it; the same file carries the balloon shell's keys +(`balloons`, `dndPassCritical`, `muted`), which the daemon ignores. +``` + +- [ ] **Step 2: Commit and push** + +```bash +git add README.md +git commit -m "docs: document config.json and notifyctl reload" +git push +``` + +- [ ] **Step 3: Hand the install to the user** + +Do not replace the running daemon from a tool call. Tell the user to run, from their terminal: + + cd ~/Programming/GIT/notifyd + go build -o ~/bin/notifyd ./cmd/notifyd && go build -o ~/bin/notifyctl ./cmd/notifyctl + pkill -x notifyd; setsid -f notifyd + +and confirm with `notifyctl reload; echo $?` → `0`. + +--- + +### Task 6: `NotifyConfig.js`, shared resolver + +**Files:** +- Create: `shared/NotifyConfig.js` +- Create links: `desktop/NotifyConfig.js`, `notifications/NotifyConfig.js` → `../shared/NotifyConfig.js` +- Test: `shared/test_notify_config.js` + +- [ ] **Step 1: Write the failing test** + +`shared/test_notify_config.js` (license header as `desktop/modules/git/test_git.js`): + +```js +// Run: node shared/test_notify_config.js +// NotifyConfig.js is a QML library, so its .pragma line is stripped before +// node evaluates it. + +const fs = require("fs"); +const assert = require("assert"); + +const src = fs.readFileSync(__dirname + "/NotifyConfig.js", "utf8") + .replace(/^\.pragma library$/m, ""); +const C = new Function(src + "\nreturn { defaults, resolve, withheld };")(); + +// Nothing, or garbage, is the defaults. +assert.deepStrictEqual(C.resolve(undefined), C.defaults()); +assert.deepStrictEqual(C.resolve("x"), C.defaults()); + +// Partial merges; wrong types and bad values keep their defaults. +const r = C.resolve({ timeouts: { low: 3, normal: "x" }, liveCap: 500, historyCap: 0, + balloons: { corner: "middle", screen: "HDMI-A-1" }, + dndPassCritical: false, muted: ["a", 3, "b"] }); +assert.deepStrictEqual(r.timeouts, { low: 3, normal: 10, critical: 0 }); +assert.strictEqual(r.liveCap, 100); +assert.strictEqual(r.historyCap, 1); +assert.deepStrictEqual(r.balloons, { screen: "HDMI-A-1", corner: "bottom-right" }); +assert.strictEqual(r.dndPassCritical, false); +assert.deepStrictEqual(r.muted, ["a", "b"]); + +// Suppression: muted always, DND per dndPassCritical. +const n = (app, urgency) => ({ app, urgency }); +const cfg = C.resolve({ muted: ["spam"] }); +assert.strictEqual(C.withheld(cfg, n("spam", "critical"), false), true); +assert.strictEqual(C.withheld(cfg, n("ok", "normal"), false), false); +assert.strictEqual(C.withheld(cfg, n("ok", "normal"), true), true); +assert.strictEqual(C.withheld(cfg, n("ok", "critical"), true), false); +assert.strictEqual(C.withheld(C.resolve({ dndPassCritical: false }), n("ok", "critical"), true), true); + +console.log("ok"); +``` + +- [ ] **Step 2: Run, expect failure** + +Run: `node shared/test_notify_config.js` +Expected: `ENOENT ... NotifyConfig.js`. + +- [ ] **Step 3: Implement** + +`shared/NotifyConfig.js` (license header as `desktop/modules/git/Git.js`): + +```js +.pragma library + +// ~/.config/notifyd/config.json, resolved over its defaults. notifyd reads +// the same file for timeouts and caps (its config.go clamps the same way); +// the balloon keys are this side's alone. Tested by test_notify_config.js. + +const corners = ["top-left", "top-right", "bottom-left", "bottom-right"]; + +function defaults() { + return { + timeouts: { low: 10, normal: 10, critical: 0 }, + liveCap: 20, + historyCap: 20, + balloons: { screen: "DP-1", corner: "bottom-right" }, + dndPassCritical: true, + muted: [], + }; +} + +function seconds(v, d) { return Number.isInteger(v) && v >= 0 ? v : d; } +function cap(v, d) { return Number.isInteger(v) ? Math.min(Math.max(v, 1), 100) : d; } + +// Never throws: anything that is not the right shape reads as its default. +function resolve(raw) { + const d = defaults(); + if (!raw || typeof raw !== "object") return d; + const t = raw.timeouts || {}; + const b = raw.balloons || {}; + return { + timeouts: { + low: seconds(t.low, d.timeouts.low), + normal: seconds(t.normal, d.timeouts.normal), + critical: seconds(t.critical, d.timeouts.critical), + }, + liveCap: cap(raw.liveCap, d.liveCap), + historyCap: cap(raw.historyCap, d.historyCap), + balloons: { + screen: typeof b.screen === "string" && b.screen !== "" ? b.screen : d.balloons.screen, + corner: corners.indexOf(b.corner) >= 0 ? b.corner : d.balloons.corner, + }, + dndPassCritical: typeof raw.dndPassCritical === "boolean" ? raw.dndPassCritical : d.dndPassCritical, + muted: Array.isArray(raw.muted) ? raw.muted.filter(a => typeof a === "string") : d.muted, + }; +} + +// Whether a balloon is held back by mute or DND. Drawer and snooze are the +// balloon's own checks; this is only what the config decides. +function withheld(cfg, n, dnd) { + if (cfg.muted.indexOf(n.app) >= 0) return true; + return dnd && !(n.urgency === "critical" && cfg.dndPassCritical); +} +``` + +Then the links: + +```bash +ln -s ../shared/NotifyConfig.js desktop/NotifyConfig.js +ln -s ../shared/NotifyConfig.js notifications/NotifyConfig.js +``` + +- [ ] **Step 4: Run, expect pass** + +Run: `node shared/test_notify_config.js` +Expected: `ok`. + +- [ ] **Step 5: Commit** + +```bash +git add shared/NotifyConfig.js shared/test_notify_config.js desktop/NotifyConfig.js notifications/NotifyConfig.js +git commit -m "notifications: resolve notifyd's config.json in a shared library + +One resolver for both shells and the settings page, with the defaults that +reproduce today's behaviour, and the mute/DND decision beside it so the +node check covers it." +``` + +--- + +### Task 7: `Notify.qml` reads the config + +**Files:** +- Modify: `shared/Notify.qml` + +- [ ] **Step 1: Implement** + +Add after `import QtQuick`: + +```qml +import "NotifyConfig.js" as NC +``` + +`Notify.qml` is reached through the links in `desktop/` and `notifications/`, and a relative import resolves beside the link, which is why Task 6 created a link in each. If the shell logs `NotifyConfig.js: No such file`, the import resolved against `shared/` instead; then the links are unnecessary and harmless. + +Add properties after `property bool dnd: false`: + +```qml + // ~/.config/notifyd/config.json, shared with the daemon (timeouts, caps) + // and written by the settings page. rawConfig is the parsed file as is, + // so a save keeps keys this version does not know; config is it resolved + // over the defaults, which is what everything reads. + readonly property string configPath: + (Quickshell.env("XDG_CONFIG_HOME") || Quickshell.env("HOME") + "/.config") + "/notifyd/config.json" + property var rawConfig: ({}) + readonly property var config: NC.resolve(root.rawConfig) +``` + +Add a `FileView` after `snoozeFile`: + +```qml + FileView { + id: configFile + path: root.configPath + watchChanges: true + printErrors: false + onFileChanged: reload() + onLoaded: { + try { root.rawConfig = JSON.parse(configFile.text() || "{}"); } + catch (e) { root.rawConfig = {}; } + } + onLoadFailed: root.rawConfig = {} + } +``` + +Hot reload does not follow the `shared/` symlink (AGENTS.md, Reloading), so the running shells pick this up only through the edits in Task 8 (notifications) and Task 9 (desktop). Nothing to check yet. + +- [ ] **Step 2: Commit** + +```bash +git add shared/Notify.qml +git commit -m "notifications: read notifyd's config.json in the Notify singleton" +``` + +--- + +### Task 8: balloons use placement, mute and DND + +**Files:** +- Modify: `notifications/Balloons.qml:17, 71-80` +- Modify: `notifications/NotificationBalloon.qml:28-40` + +- [ ] **Step 1: Placement** + +`Balloons.qml` reads `Notify.config` and needs no new import. Replace the header comment `// The balloon stack, bottom-right of DP-1, over conky.` with: + +```qml +// The balloon stack, in the corner and on the screen notifyd's config.json +// names (bottom-right of DP-1 by default), over conky. +``` + +Inside `PanelWindow { id: win`, replace the `screen:`, `anchors`, `margins` lines with: + +```qml + readonly property var place: Notify.config.balloons + readonly property bool atTop: place.corner.startsWith("top") + readonly property bool atLeft: place.corner.endsWith("left") + screen: Quickshell.screens.find(s => s.name === place.screen) ?? Quickshell.screens[0] + anchors { top: atTop; bottom: !atTop; left: atLeft; right: !atLeft } + margins { top: 12; bottom: 12; left: 12; right: 12 } +``` + +In the `Column`, replace `anchors { bottom: parent.bottom; right: parent.right }` with nothing: the window is sized to the column, so the column's anchors do nothing. Delete that line. + +- [ ] **Step 2: Suppression** + +In `NotificationBalloon.qml`, add `import "NotifyConfig.js" as NC` after the existing imports. Replace the comment and `visible` block (lines 28-40) with: + +```qml + // Driven by the shell's tick. The balloon drops itself once its expiry + // passes, or while a suppression says so: the drawer and snooze withhold + // everything; a muted app and DND are config.json's call (DND passes + // critical unless dndPassCritical is off). The drawer still lists what + // was withheld, because a list the user opened is not an interruption. + visible: { + if (Notify.drawerOpen) return false; + if (Notify.snoozeUntil > b.now) return false; + if (NC.withheld(Notify.config, b.notification, Notify.dnd)) return false; + if (b.notification.expires !== 0 && b.now >= b.notification.expires) return false; + return true; + } +``` + +- [ ] **Step 3: Verify defaults unchanged** + +Check reload: `qs log -p notifications --log-times | tail -5` shows `Reloading configuration` after the edit and no `ReferenceError`/`TypeError`. If none followed, ask the user to run `./reload.sh notifications`. + +Run: `notify-send -u low test "default placement"`. Ask the user: balloon bottom-right of DP-1, gone after 10s? + +- [ ] **Step 4: Verify config drives it** + +```bash +mkdir -p ~/.config/notifyd +printf '{"balloons":{"corner":"top-left"},"muted":["mutetest"]}\n' > ~/.config/notifyd/config.json +notify-send -a mutetest muted "should not pop" +notify-send -a other placed "top-left" +``` + +Ask the user: no balloon for `mutetest`, `other` at top-left of DP-1, and `mutetest` listed in the drawer. If the balloon stays bottom-right, a live layer surface did not re-anchor (cf. AGENTS.md, a hot reload does not resize a layer surface): check whether it moves once the stack empties and a new notification maps it; if not, bind `visible` to also go false when `place` changes and note that in a comment. Then `rm ~/.config/notifyd/config.json`. + +- [ ] **Step 5: Commit** + +```bash +git add notifications/Balloons.qml notifications/NotificationBalloon.qml +git commit -m "notifications: place, mute and DND balloons from config.json + +Screen and corner come from the shared config. Mute and the DND rule go +through NotifyConfig.withheld, so the settings page's node check covers the +same decision the balloon makes." +``` + +--- + +### Task 9: settings page + +**Files:** +- Create: `desktop/modules/notifications/NotificationsModule.qml` +- Create: `desktop/modules/notifications/NotificationsPage.qml` +- Modify: `desktop/shell.qml` (import list, `modules:` list after `IdleModule {},`) + +- [ ] **Step 1: The module, holding the draft** + +`desktop/modules/notifications/NotificationsModule.qml` (QML license header as `desktop/modules/git/GitModule.qml`): + +```qml +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: "" + 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) + + // Not over unsaved edits. + function refresh() { if (!nm.dirty) nm.draft = Notify.config; } + function discard() { nm.draft = Notify.config; nm.notice = ""; } + function edit(patch) { nm.draft = Object.assign({}, nm.draft, patch); } + + // Unknown keys in the file survive. The text goes on stdin to cat (the + // notes pattern: the exit code is the outcome); 1 is the write, 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.saver.data = JSON.stringify(Object.assign({}, Notify.rawConfig, nm.draft), null, 2) + "\n"; + nm.saver.stdinEnabled = true; + nm.saver.running = false; + nm.saver.running = true; + } + property Process saver: Process { + property string data: "" + command: ["sh", "-c", 'mkdir -p "$(dirname "$1")" && cat > "$1" || exit 1; notifyctl reload || exit 2', + "sh", Notify.configPath] + onStarted: { + nm.saver.write(nm.saver.data); + nm.saver.stdinEnabled = false; + } + onExited: code => nm.notice = code === 0 ? "saved" + : code === 2 ? "saved; notifyd not reachable, it reads the file at its next start" + : "could not write " + Notify.configPath + } + + page: Component { + Page { + title: "Notifications" + actions: Component { + SaveBar { dirty: nm.dirty; valid: nm.valid; note: "caps are 1 to 100" + onSave: nm.save(); onDiscard: nm.discard() } + } + NotificationsPage { width: parent.width; mod: nm } + } + } +} +``` + +The save does not `refresh()` the draft: the `FileView` reloads, `Notify.config` changes, and `dirty` goes false on its own when they match. + +- [ ] **Step 2: The page** + +`desktop/modules/notifications/NotificationsPage.qml` (license header): + +```qml +import Quickshell +import QtQuick +import "../.." +import "../appearance" +import "../qtmaildir" + +// The rows of the notifications settings page. Everything edits mod.draft. +Column { + id: pg + // Not "nm": a property named like the injecting id binds to itself. + required property QtObject mod + readonly property var d: mod.draft + spacing: 8 + + function setTimeout(key, v) { + pg.mod.edit({ timeouts: Object.assign({}, pg.d.timeouts, { [key]: v }) }); + } + + Heading { text: "Timeouts" } + Text { + width: pg.width; wrapMode: Text.Wrap + text: "Seconds a balloon stays when the app leaves it to the daemon. An app's own timeout still 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 + Text { + width: 90; anchors.verticalCenter: parent.verticalCenter + text: modelData[1]; color: Theme.text + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + } + Field { + anchors.verticalCenter: parent.verticalCenter + visible: pg.d.timeouts[modelData[0]] !== 0 + value: String(pg.d.timeouts[modelData[0]]) + placeholder: "seconds" + onEdited: { const n = parseInt(value, 10); if (n > 0) pg.setTimeout(modelData[0], n); } + } + Toggle { + anchors.verticalCenter: parent.verticalCenter + label: "never" + checked: pg.d.timeouts[modelData[0]] === 0 + onToggled: pg.setTimeout(modelData[0], checked ? 0 : 10) + } + } + } + + Heading { text: "Limits" } + Repeater { + model: [["liveCap", "On screen"], ["historyCap", "History"]] + Row { + required property var modelData + spacing: 10 + Text { + width: 90; anchors.verticalCenter: parent.verticalCenter + text: modelData[1]; color: Theme.text + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + } + Field { + anchors.verticalCenter: parent.verticalCenter + value: String(pg.d[modelData[0]]) + border.color: pg.d[modelData[0]] >= 1 && pg.d[modelData[0]] <= 100 ? Qt.alpha(Theme.text, 0.12) : Theme.red + onEdited: pg.mod.edit({ [modelData[0]]: parseInt(value, 10) || 0 }) + } + } + } + + Heading { text: "Placement" } + Flow { + width: pg.width; spacing: 6 + Repeater { + model: Quickshell.screens.map(s => s.name) + Button { + required property string modelData + text: modelData + border.color: pg.d.balloons.screen === modelData ? Theme.accent : Qt.alpha(Theme.text, 0.2) + onClicked: pg.mod.edit({ balloons: Object.assign({}, pg.d.balloons, { screen: modelData }) }) + } + } + } + // The four corners as a miniature screen: click the one balloons go to. + Grid { + columns: 2; spacing: 4 + Repeater { + model: ["top-left", "top-right", "bottom-left", "bottom-right"] + Rectangle { + required property string modelData + width: 60; height: 34; radius: 5 + color: pg.d.balloons.corner === modelData ? Qt.alpha(Theme.accent, 0.6) : Qt.alpha(Theme.surface, 0.6) + border.width: 1; border.color: Qt.alpha(Theme.text, 0.15) + MouseArea { + anchors.fill: parent; cursorShape: Qt.PointingHandCursor + onClicked: pg.mod.edit({ balloons: Object.assign({}, pg.d.balloons, { corner: modelData }) }) + } + } + } + } + + Heading { text: "Suppression" } + Toggle { + label: "Critical notifications still pop up under Do Not Disturb" + checked: pg.d.dndPassCritical + onToggled: pg.mod.edit({ dndPassCritical: checked }) + } + Text { + width: pg.width; wrapMode: Text.Wrap + text: "Muted apps get no balloon. They still appear in the drawer and in history." + color: Theme.overlay + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + } + Flow { + width: pg.width; spacing: 6 + Repeater { + model: pg.d.muted + Button { + required property string modelData + text: modelData; icon: "" + onClicked: pg.mod.edit({ muted: pg.d.muted.filter(a => a !== modelData) }) + } + } + } + // Apps seen in the queue and history, not yet muted, one click to add. + Flow { + width: pg.width; spacing: 6 + Repeater { + model: [...new Set(Notify.queue.concat(Notify.history).map(n => n.app))] + .filter(a => a && pg.d.muted.indexOf(a) < 0).sort() + Button { + required property string modelData + text: modelData; icon: "" + onClicked: pg.mod.edit({ muted: pg.d.muted.concat([modelData]) }) + } + } + } + Field { + width: 200 + placeholder: "mute another app by name" + onEdited: { + const a = value.trim(); + if (a !== "" && pg.d.muted.indexOf(a) < 0) pg.mod.edit({ muted: pg.d.muted.concat([a]) }); + value = ""; + } + } + + Text { + width: pg.width; wrapMode: Text.Wrap + visible: pg.mod.notice !== "" + text: pg.mod.notice + color: pg.mod.notice === "saved" ? Theme.green : Theme.yellow + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + } +} +``` + +Glyphs: `` (times), `` (plus), `` (bell) are Font Awesome codepoints in Inconsolata Nerd Font; confirm with `fc-query` on the installed font, e.g. `python3 -c` with fontTools if present, or `fc-list ':charset=f0f3' family | grep -i inconsolata`. A missing glyph means picking another, not keeping it. + +`Field` here is `modules/appearance/Field.qml` and `Toggle` `modules/appearance/Toggle.qml`; `Heading` is `modules/qtmaildir/Heading.qml`. No file in this directory imports `QtQuick.Controls`, so `Button` is the drawer's `Button.qml`. + +- [ ] **Step 3: Register it** + +In `desktop/shell.qml`, add `import "modules/notifications"` in alphabetical place (after `import "modules/notes"`), and `NotificationsModule {},` after `IdleModule {},`. This edit is also what makes the running shell re-index the new directory and pick up the `shared/Notify.qml` change (AGENTS.md, Reloading). + +- [ ] **Step 4: Check it loads** + +Run: `qs log -p desktop --log-times | tail -20` +Expected: `Reloading configuration` after the edit time, no `TypeError`, `ReferenceError` or `is not a type`. If no reload line followed, ask the user to run `./reload.sh desktop`. + +Ask the user to open settings, Desktop → Notifications, and check: the four sections render with today's defaults, an edit marks the page unsaved, Discard reverts, Save writes `~/.config/notifyd/config.json` and shows "saved". Then: set Low to 3s, save, `notify-send -u low t "3s"`, balloon gone after 3s (needs the new daemon from Task 5). + +- [ ] **Step 5: Commit** + +```bash +git add desktop/modules/notifications desktop/shell.qml +git commit -m "desktop: notifications settings page + +Edits notifyd's config.json as a draft behind the SaveBar and runs +notifyctl reload after the write; a stopped daemon is reported as saved +but not applied, since it reads the file at its next start. Muting offers +the apps already seen in the queue and history." +``` + +--- + +### Task 10: docs + +**Files:** +- Create: `desktop/modules/notifications/README.md` +- Modify: `notifications/README.md` +- Modify: `AGENTS.md` ("What this is" block) + +- [ ] **Step 1: Module README** + +`desktop/modules/notifications/README.md`: + +```markdown +# notifications (settings page) + +Edits `~/.config/notifyd/config.json`, the one file behind both the daemon +and the balloon shell: + +| key | read by | +| --- | --- | +| `timeouts`, `liveCap`, `historyCap` | notifyd, at start and on `notifyctl reload` | +| `balloons`, `dndPassCritical`, `muted` | both shells, through `Notify.config` | + +`shared/NotifyConfig.js` resolves the file over its defaults and holds the +mute/DND decision. Check: `node shared/test_notify_config.js`. + +Save writes the file with `cat` (unknown keys kept) then runs `notifyctl +reload`. If notifyd is not running the file is still written and the page +says so; the daemon reads it when it starts. + +Snooze is not here: the status tile has it. +``` + +- [ ] **Step 2: Balloon README** + +In `notifications/README.md`, replace the "Suppression lives here" section body with: + +```markdown +The daemon does not know about DND, snooze or mute. This component withholds +balloons: `status.dnd` suppresses low and normal (and critical too when +`dndPassCritical` is off), a muted app is always withheld, and +`notifyd/snooze` suppresses everything. The drawer's reserved space lists +every live notification anyway. + +Placement, mute and the DND rule come from `~/.config/notifyd/config.json`, +edited from the settings window (Desktop → Notifications). +``` + +And in "The files are the interface" add the line: + +``` + ~/.config/notifyd/config.json settings, shared with the daemon +``` + +- [ ] **Step 3: AGENTS.md** + +In the "What this is" block, the `desktop/` description's settings list, change `sunset, idle (the former appearance shell), autostart,` to `sunset, idle (the former appearance shell), notifications, autostart,`. + +- [ ] **Step 4: Commit and push** + +```bash +git add desktop/modules/notifications/README.md notifications/README.md AGENTS.md +git commit -m "docs: the notification settings page and config.json" +git push +``` |
