diff options
Diffstat (limited to 'docs/superpowers/plans')
4 files changed, 4348 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-09-15-appearance-sunset-idle-icons.md b/docs/superpowers/plans/2026-09-15-appearance-sunset-idle-icons.md new file mode 100644 index 0000000..4e77136 --- /dev/null +++ b/docs/superpowers/plans/2026-09-15-appearance-sunset-idle-icons.md @@ -0,0 +1,1750 @@ +# appearance Sunset / Idle / Icons 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:** Add Sunset, Idle and Icons tabs to the `appearance/` quickshell drawer. + +**Architecture:** Each tab is backed by a `pragma Singleton` model (`Hyprsunset.qml`, `Hypridle.qml`, `Icons.qml`) that parses, serializes and applies a config file, mirroring `Udt.qml` / `Wallpapers.qml`. Tab UI lives in `SunsetTab.qml`, `IdleTab.qml`, `IconsTab.qml`, loaded by the existing `Loader`. Models are built and self-tested first, UI after, so every commit loads. + +**Tech Stack:** Quickshell 0.3.1 / Qt6 QML, `Quickshell.Io` (Process, FileView, IpcHandler), `Quickshell.Widgets.IconImage`. Shell tools already installed: `python3` + `gi`/`Gtk`, `curl`, `unzip`, `xcur2png`, `gsettings`, `hyprctl`, `pkill`, `setsid`. + +**Spec:** `docs/superpowers/specs/2026-09-15-appearance-sunset-idle-icons-design.md` + +## Global Constraints + +- GPLv2-only header comment at the top of every new source file, verbatim from `appearance/Udt.qml` lines 1-10. +- No home paths in committed files: use `Quickshell.env("HOME")` or `~` in documentation. +- No new runtime dependencies. No `String.matchAll` (QML's JS engine lacks it): use `exec` loops or `[\s\S]`. +- Reusing a `Process` for a second command requires `running = false` immediately before `running = true`. +- Writes to config files go through `FileView.setText()` (atomic). Files we write are never read with `watchChanges`. Reads go through `Process { command: [...] }` + `StdioCollector`. +- Never `pkill -f`; the process name is `qs` and `-f` matches the agent's own shell. Use `pkill -x`. +- Detached daemon starts use `Quickshell.execDetached(["sh", "-c", "..."])`. +- The 1x1 keepalive `PanelWindow` in `AppearancePanel.qml` stays untouched. +- Selftests are pure functions returning a string starting with `SELFTEST ... PASS` or `SELFTEST ... FAIL`. They must not touch real files. + +--- + +## File structure + + appearance/Hyprsunset.qml sunset model: parse/serialize/validate/location/apply + appearance/Hypridle.qml idle model: parse/serialize/apply + appearance/Icons.qml theme lists, previews, apply + appearance/Field.qml styled single-line TextInput + appearance/Toggle.qml styled checkbox + appearance/SunsetTab.qml Sunset tab UI + appearance/IdleTab.qml Idle tab UI + appearance/IconsTab.qml Icons tab UI + appearance/AppearancePanel.qml tab bar, key cycle, Loader, show() refresh + appearance/shell.qml IPC verbs + selftest + appearance/README.md document the three tabs + +--- + +### Task 1: Hyprsunset model + +**Files:** +- Create: `appearance/Hyprsunset.qml` +- Modify: `appearance/shell.qml` + +**Interfaces:** +- Produces: + - `Hyprsunset.profiles` : `var[]`, each `{ time: string, identity: bool, temperature: int|null, gamma: real|null }` + - `Hyprsunset.parseProfiles(text): var[]` + - `Hyprsunset.serializeProfiles(list): string` + - `Hyprsunset.dayIndex(list): int|null`, `Hyprsunset.nightIndex(list): int|null` + - `Hyprsunset.validTime(s): bool`, `validTemperature(t): bool`, `validGamma(g): bool` + - `Hyprsunset.refresh()`, `.save()`, `.preview()`, `.detect()`, `.fetchSun()` + - `Hyprsunset.lat`, `.lon`, `.autoDetect`, `.daemonCommand`, `.notice`, `.busy`, `.sunSummary` + - `Hyprsunset.selftest(): string` +- Consumes: existing `shell.qml` IpcHandler. + +- [ ] **Step 1: Ensure the appearance shell is running** + +Run: `pgrep -cx qs` +Expected: a number ≥ 1 (the autostarted session). If `0`, ask the user to start it (`qs -p appearance`) before continuing; the agent cannot keep a detached `qs` alive. + +- [ ] **Step 2: Write the model with a failing selftest** + +Create `appearance/Hyprsunset.qml`. The parser and serializer are stubbed (`return []` / `return ""`) so the selftest fails; everything else is final. + +```qml +// Copyright (C) 2026 Danilo M. <danix@danix.xyz> +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 2 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +pragma Singleton + +import Quickshell +import Quickshell.Io +import QtQuick + +// The hyprsunset side: profiles in ~/.config/hypr/hyprsunset.conf, the +// location in hyprsunset-qt's own config, and the daemon. The file format is +// byte-for-byte what ~/Programming/GIT/sunset-qt writes, so both tools edit +// the same file. +Singleton { + id: root + + readonly property string home: Quickshell.env("HOME") + readonly property string confPath: `${home}/.config/hypr/hyprsunset.conf` + readonly property string appConfPath: `${home}/.config/hyprsunset-qt/config` + + property var profiles: [] + property string lat: "" + property string lon: "" + property bool autoDetect: true + property string daemonCommand: "hyprsunset" + property string cachePath: `${home}/.config/hyprsunset-qt/sun.json` + property string notice: "" + property string sunSummary: "" + property bool busy: false + + readonly property string header: + "# Managed by hyprsunset-qt. Edits here are overwritten on save.\n" + + // QML has no String.matchAll; everything here is exec loops. + function getField(body, key) { + const m = body.match(new RegExp(`^\\s*${key}\\s*=\\s*(.+?)\\s*$`, "m")); + return m ? m[1] : null; + } + + function parseProfiles(text) { + return []; // STEP 4 fills this in + } + + function serializeProfiles(list) { + return ""; // STEP 4 fills this in + } + + function dayIndex(list) { + for (let i = 0; i < list.length; i++) if (list[i].identity) return i; + return null; + } + + function nightIndex(list) { + for (let i = 0; i < list.length; i++) + if (list[i].temperature !== null && list[i].temperature !== undefined) return i; + return null; + } + + function validTime(s) { return /^([01]?\d|2[0-3]):([0-5]\d)$/.test(s); } + function validTemperature(t) { return t >= 1000 && t <= 20000; } + function validGamma(g) { return g >= 0.0 && g <= 2.0; } + + function localHM(iso) { + const d = new Date(iso); + if (isNaN(d.getTime())) return ""; + return ("0" + d.getHours()).slice(-2) + ":" + ("0" + d.getMinutes()).slice(-2); + } + + function selftest(): string { + // A file exactly as hyprsunset-qt writes it. String.raw keeps the + // backslashes, none here, but keeps this fixture readable. + // Two blank lines after the header: sunset-qt's serializer joins parts + // with a newline and each part starts with one, so the file really has + // them. + const fixture = String.raw`# Managed by hyprsunset-qt. Edits here are overwritten on save. + + +# day profile -- sunrise +profile { + time = 05:42 + identity = true +} + +# night profile -- sunset +profile { + time = 21:02 + temperature = 5500 + gamma = 0.8 +} +`; + const parsed = root.parseProfiles(fixture); + if (parsed.length !== 2) return `SELFTEST Hyprsunset FAIL: parsed ${parsed.length} profiles`; + if (parsed[0].time !== "05:42" || parsed[0].identity !== true) + return "SELFTEST Hyprsunset FAIL: day profile wrong"; + if (parsed[1].temperature !== 5500 || parsed[1].gamma !== 0.8) + return "SELFTEST Hyprsunset FAIL: night profile wrong"; + if (root.dayIndex(parsed) !== 0 || root.nightIndex(parsed) !== 1) + return "SELFTEST Hyprsunset FAIL: day/night index wrong"; + if (root.serializeProfiles(parsed) !== fixture) + return "SELFTEST Hyprsunset FAIL: round-trip not byte-identical"; + if (!root.validTime("05:42") || root.validTime("24:00") || root.validTime("5:6")) + return "SELFTEST Hyprsunset FAIL: validTime"; + if (!root.validTemperature(1000) || root.validTemperature(20001)) + return "SELFTEST Hyprsunset FAIL: validTemperature"; + if (!root.validGamma(0.8) || root.validGamma(2.1)) + return "SELFTEST Hyprsunset FAIL: validGamma"; + return "SELFTEST Hyprsunset PASS"; + } + + function refresh() { + confProc.running = false; + confProc.running = true; + appProc.running = false; + appProc.running = true; + } + + Process { + id: confProc + command: ["cat", root.confPath] + stdout: StdioCollector { onStreamFinished: root.profiles = root.parseProfiles(text) } + } + + // hyprsunset-qt's own settings: location + daemon command. + function parseAppConf(text) { + const out = {}; + let section = ""; + for (const raw of text.split("\n")) { + const line = raw.trim(); + if (!line || line.startsWith("#") || line.startsWith(";")) continue; + const sec = line.match(/^\[(.+)\]$/); + if (sec) { section = sec[1]; continue; } + const kv = line.match(/^([^=]+)=\s*(.*)$/); + if (kv) out[`${section}.${kv[1].trim()}`] = kv[2].trim(); + } + return out; + } + + function expandTilde(p) { + return p.startsWith("~/") ? root.home + p.slice(1) : p; + } + + Process { + id: appProc + command: ["cat", root.appConfPath] + stdout: StdioCollector { + onStreamFinished: { + const c = root.parseAppConf(text); + root.lat = c["location.lat"] ?? ""; + root.lon = c["location.lon"] ?? ""; + root.autoDetect = (c["location.auto_detect"] ?? "true") === "true"; + root.cachePath = root.expandTilde(c["cache.path"] ?? "~/.config/hyprsunset-qt/sun.json"); + root.daemonCommand = c["daemon.command"] ?? "hyprsunset"; + } + } + } + + FileView { id: confFile; path: root.confPath } + FileView { id: appFile; path: root.appConfPath } + FileView { id: cacheFile; path: root.cachePath } + + function appConfText() { + return `[location]\nlat = ${root.lat}\nlon = ${root.lon}\n` + + `auto_detect = ${root.autoDetect}\n\n` + + `[cache]\npath = ~/.config/hyprsunset-qt/sun.json\n\n` + + `[daemon]\ncommand = ${root.daemonCommand}\n\n`; + } + function saveSettings() { appFile.setText(root.appConfText()); } + + function restart() { + Quickshell.execDetached(["sh", "-c", + `pkill -x hyprsunset; setsid -f ${root.daemonCommand}`]); + } + + function save() { + for (const p of root.profiles) { + if (!root.validTime(p.time)) { root.notice = `invalid time: ${p.time}`; return; } + if (p.temperature !== null && p.temperature !== undefined && + !root.validTemperature(p.temperature)) { + root.notice = `invalid temperature: ${p.temperature}`; return; + } + if (p.gamma !== null && p.gamma !== undefined && !root.validGamma(p.gamma)) { + root.notice = `invalid gamma: ${p.gamma}`; return; + } + } + confFile.setText(root.serializeProfiles(root.profiles)); + root.saveSettings(); + root.restart(); + root.notice = "saved + restarted"; + } + + // Live preview via the daemon's IPC. Identity wins, then temperature, then + // gamma, matching hyprsunset-qt. Nothing is written. + function preview() { + const i = root.nightIndex(root.profiles); + const target = i !== null ? root.profiles[i] + : (root.profiles.length ? root.profiles[0] : null); + if (!target) return; + if (target.identity) { + previewProc.command = ["hyprctl", "hyprsunset", "identity"]; + } else { + const parts = []; + if (target.temperature !== null && target.temperature !== undefined) + parts.push(`hyprctl hyprsunset temperature ${target.temperature}`); + if (target.gamma !== null && target.gamma !== undefined) + parts.push(`hyprctl hyprsunset gamma ${Math.round(target.gamma * 100)}`); + if (!parts.length) return; + previewProc.command = ["sh", "-c", parts.join("; ")]; + } + previewProc.running = true; + } + Process { id: previewProc } + + function detect() { + detectProc.running = false; + detectProc.running = true; + } + Process { + id: detectProc + command: ["curl", "-fsS", "http://ip-api.com/json"] + stdout: StdioCollector { + onStreamFinished: { + try { + const d = JSON.parse(text); + root.lat = String(d.lat); + root.lon = String(d.lon); + root.saveSettings(); + root.notice = `located ${root.lat}, ${root.lon}`; + } catch (e) { root.notice = `detect failed: ${e}`; } + } + } + } + + // sunrise-sunset.org is behind Cloudflare and 403s curl's default UA. + function fetchSun() { + const url = "https://api.sunrise-sunset.org/json?lat=" + + encodeURIComponent(root.lat) + "&lng=" + encodeURIComponent(root.lon) + + "&formatted=0"; + fetchProc.command = ["curl", "-fsS", "-A", + "Mozilla/5.0 (X11; Linux x86_64) hyprsunset-qt", url]; + fetchProc.running = false; + fetchProc.running = true; + root.busy = true; + } + Process { + id: fetchProc + stdout: StdioCollector { + onStreamFinished: { + root.busy = false; + let data; + try { data = JSON.parse(text); } + catch (e) { root.notice = `fetch failed: ${e}`; return; } + cacheFile.setText(JSON.stringify(data, null, 2)); + const r = data.results ?? {}; + const rise = r.sunrise ? root.localHM(r.sunrise) : ""; + const set = r.sunset ? root.localHM(r.sunset) : ""; + root.sunSummary = `sunrise ${rise || "—"} sunset ${set || "—"}`; + const di = root.dayIndex(root.profiles); + const ni = root.nightIndex(root.profiles); + const next = root.profiles.slice(); + if (di !== null && rise) next[di] = Object.assign({}, next[di], { time: rise }); + if (ni !== null && set) next[ni] = Object.assign({}, next[ni], { time: set }); + root.profiles = next; + } + } + } +} +``` + +- [ ] **Step 3: Add the selftest IPC and run it to verify it fails** + +In `appearance/shell.qml`, add to the `IpcHandler`: + +```qml + function selftest(): string { return Hyprsunset.selftest(); } +``` + +Run: `qs -p appearance ipc call appearance selftest` +Expected: `SELFTEST Hyprsunset FAIL: parsed 0 profiles` + +(If the new singleton is not visible after save, the qmldir did not rescan; restart the appearance shell. Hot reload does not pick up a new component file by itself. The edit to `shell.qml` should force it.) + +- [ ] **Step 4: Implement the parser and serializer** + +Replace the two stub bodies: + +```qml + function parseProfiles(text) { + const list = []; + const re = /profile\s*\{([\s\S]*?)\}/g; + let m; + while ((m = re.exec(text)) !== null) { + const body = m[1]; + const t = root.getField(body, "temperature"); + const g = root.getField(body, "gamma"); + list.push({ + time: root.getField(body, "time") ?? "", + identity: (root.getField(body, "identity") ?? "").toLowerCase() === "true", + temperature: t === null ? null : parseInt(t, 10), + gamma: g === null ? null : parseFloat(g), + }); + } + return list; + } + + // Byte-for-byte the same construction as sunset-qt's config.serialize(): + // a list of parts joined with newlines, so a no-op save writes the file + // back exactly as it was. + function serializeProfiles(list) { + const di = root.dayIndex(list); + const ni = root.nightIndex(list); + const parts = [root.header]; + for (let i = 0; i < list.length; i++) { + const p = list[i]; + if (i === di) parts.push("\n# day profile -- sunrise"); + else if (i === ni) parts.push("\n# night profile -- sunset"); + else parts.push("\n# profile"); + const lines = ["profile {", ` time = ${p.time}`]; + if (p.identity) lines.push(" identity = true"); + if (p.temperature !== null && p.temperature !== undefined) + lines.push(` temperature = ${p.temperature}`); + if (p.gamma !== null && p.gamma !== undefined) + lines.push(` gamma = ${p.gamma}`); + lines.push("}"); + parts.push(lines.join("\n")); + } + return parts.join("\n") + "\n"; + } +``` + +- [ ] **Step 5: Run the selftest to verify it passes** + +Run: `qs -p appearance ipc call appearance selftest` +Expected: `SELFTEST Hyprsunset PASS` + +- [ ] **Step 6: Verify against the real file (no write)** + +Run: `cp ~/.config/hypr/hyprsunset.conf /tmp/hs.conf && cat ~/.config/hypr/hyprsunset.conf` +Expected: two profile blocks, matching the fixture shape. If it differs, note it and continue; the no-op check happens in Task 7. + +- [ ] **Step 7: Commit** + +```bash +git add appearance/Hyprsunset.qml appearance/shell.qml +git commit -m "feat(appearance): add the hyprsunset profile model + +Parses and serializes ~/.config/hypr/hyprsunset.conf in the exact format +hyprsunset-qt writes, so both editors share the file. Location lives in +hyprsunset-qt's own INI; fetching mirrors its curl calls and browser +User-Agent. Selftest round-trips a fixture byte-for-byte." +``` + +--- + +### Task 2: Hypridle model + +**Files:** +- Create: `appearance/Hypridle.qml` +- Modify: `appearance/shell.qml` + +**Interfaces:** +- Produces: + - `Hypridle.listeners` : `var[]`, each `{ leading: string, timeout: int, onTimeout: string, onResume: string, enabled: bool }` + - `Hypridle.prefix: string`, `Hypridle.tail: string` + - `Hypridle.parse(text): { prefix: string, listeners: var[], tail: string }` + - `Hypridle.serialize(prefix, listeners, tail): string` + - `Hypridle.refresh()`, `.save()`, `.describe(listener): string` + - `Hypridle.selftest(): string` +- Consumes: nothing from Task 1. + +**Trap:** the `on-timeout` of the dpms listener contains `{ state = ... }`, so a naive `listener\s*\{(.*?)\}` regex truncates the block. The block ends at the first line that is only whitespace then `}`, which the command's brace never is. + +- [ ] **Step 1: Write the model with a failing selftest** + +Create `appearance/Hypridle.qml` (GPLv2 header as in Task 1): + +```qml +pragma Singleton + +import Quickshell +import Quickshell.Io +import QtQuick + +// The hypridle side. Commands are fixed: only each listener's timeout and +// whether it is active are editable. Everything before the first listener is +// kept verbatim so the general block and the rationale comments survive. +Singleton { + id: root + + readonly property string home: Quickshell.env("HOME") + readonly property string confPath: `${home}/.config/hypr/hypridle.conf` + + property string prefix: "" + property var listeners: [] + property string tail: "" + + function getField(body, key) { + const m = body.match(new RegExp(`^\\s*${key}\\s*=\\s*(.+?)\\s*$`, "m")); + return m ? m[1] : null; + } + + // A disabled listener is written every line prefixed with "# ". Strip it + // before reading fields. + function uncomment(body) { + return body.split("\n").map(l => l.replace(/^([ \t]*)#[ \t]?/, "$1")).join("\n"); + } + + function parse(text) { + return { prefix: text, listeners: [], tail: "" }; // STEP 3 fills this in + } + + function serialize(prefix, list, tail) { + return prefix; // STEP 3 fills this in + } + + function describe(l) { + const cmd = l.onTimeout; + if (cmd.indexOf("notify-send") === 0) return "notify before lock"; + if (cmd.indexOf("loginctl lock-session") >= 0) return "lock session"; + if (cmd.indexOf("dpms") >= 0) return "monitors off"; + if (cmd.indexOf("loginctl suspend") >= 0) return "suspend"; + return cmd; + } + + function selftest(): string { + const fixture = String.raw`general { + lock_cmd = pidof hyprlock || hyprlock +} + +# 10:00s - screen lock +listener { + timeout = 600 + on-timeout = loginctl lock-session +} + +# 10:30s - monitor off +listener { + timeout = 630 + on-timeout = hyprctl dispatch "hl.dsp.dpms({ state = \"off\" })" + on-resume = hyprctl dispatch "hl.dsp.dpms({ state = \"on\" })" +} +`; + const p = root.parse(fixture); + if (p.listeners.length !== 2) return `SELFTEST Hypridle FAIL: ${p.listeners.length} listeners`; + if (p.listeners[0].timeout !== 600 || p.listeners[0].enabled !== true) + return "SELFTEST Hypridle FAIL: first listener"; + if (p.listeners[1].onTimeout.indexOf(String.raw`state = \"off\"`) < 0) + return "SELFTEST Hypridle FAIL: dpms command truncated"; + if (p.listeners[1].onResume.indexOf(String.raw`state = \"on\"`) < 0) + return "SELFTEST Hypridle FAIL: dpms on-resume lost"; + if (root.serialize(p.prefix, p.listeners, p.tail) !== fixture) + return "SELFTEST Hypridle FAIL: round-trip not byte-identical"; + const off = p.listeners.slice(); + off[1] = Object.assign({}, off[1], { enabled: false }); + const p2 = root.parse(root.serialize(p.prefix, off, p.tail)); + if (p2.listeners.length !== 2 || p2.listeners[1].enabled !== false || + p2.listeners[1].timeout !== 630) + return "SELFTEST Hypridle FAIL: disabled listener not round-tripped"; + return "SELFTEST Hypridle PASS"; + } + + function refresh() { + confProc.running = false; + confProc.running = true; + } + + Process { + id: confProc + command: ["cat", root.confPath] + stdout: StdioCollector { + onStreamFinished: { + const p = root.parse(text); + root.prefix = p.prefix; + root.listeners = p.listeners; + root.tail = p.tail; + } + } + } + + FileView { id: confFile; path: root.confPath } + + function save() { + confFile.setText(root.serialize(root.prefix, root.listeners, root.tail)); + Quickshell.execDetached(["sh", "-c", "pkill -x hypridle; setsid -f hypridle"]); + } +} +``` + +- [ ] **Step 2: Extend the selftest IPC and run it to verify it fails** + +In `appearance/shell.qml`: + +```qml + function selftest(): string { return Hyprsunset.selftest() + "\n" + Hypridle.selftest(); } +``` + +Run: `qs -p appearance ipc call appearance selftest` +Expected: the Hyprsunset line passes, then `SELFTEST Hypridle FAIL: 0 listeners` + +- [ ] **Step 3: Implement parse and serialize** + +```qml + // The closing brace is matched at the start of a line, because the dpms + // command contains a brace mid-line and a non-greedy match would stop + // inside it. A disabled listener has both its opening line and its closing + // brace commented, so the closing pattern allows a leading "#". The first + // listener's leading trivia is empty because its text is the prefix. + function parse(text) { + const re = /^([ \t]*)(#?)[ \t]*listener\s*\{([\s\S]*?)^[ \t]*#?[ \t]*\}/gm; + const found = []; + let firstStart = -1; + let prevEnd = 0; + let m; + while ((m = re.exec(text)) !== null) { + const start = m.index; + if (firstStart < 0) { firstStart = start; prevEnd = start; } + const leading = text.slice(prevEnd, start); + const enabled = m[2] !== "#"; + const body = enabled ? m[3] : root.uncomment(m[3]); + const t = root.getField(body, "timeout"); + found.push({ + leading: leading, + timeout: t === null ? 0 : parseInt(t, 10), + onTimeout: root.getField(body, "on-timeout") ?? "", + onResume: root.getField(body, "on-resume") ?? "", + enabled: enabled, + }); + prevEnd = start + m[0].length; + } + if (firstStart < 0) return { prefix: text, listeners: [], tail: "" }; + return { prefix: text.slice(0, firstStart), listeners: found, tail: text.slice(prevEnd) }; + } + + function serialize(prefix, list, tail) { + let out = prefix; + for (const l of list) { + const lines = ["listener {", ` timeout = ${l.timeout}`, + ` on-timeout = ${l.onTimeout}`]; + if (l.onResume) lines.push(` on-resume = ${l.onResume}`); + lines.push("}"); + const body = l.enabled ? lines.join("\n") : lines.map(x => "# " + x).join("\n"); + out += l.leading + body; + } + return out + tail; + } +``` + +- [ ] **Step 4: Run the selftest to verify it passes** + +Run: `qs -p appearance ipc call appearance selftest` +Expected: Hyprsunset PASS then `SELFTEST Hypridle PASS` + +- [ ] **Step 5: Commit** + +```bash +git add appearance/Hypridle.qml appearance/shell.qml +git commit -m "feat(appearance): add the hypridle listener model + +Parses the general block and comments before the first listener and keeps +them verbatim; only timeouts and the enabled flag are editable, commands +are copied. Disabled listeners are written commented out, and the parser +matches the closing brace at line start so the dpms command's inline brace +does not truncate the block." +``` + +--- + +### Task 3: Icons model + +**Files:** +- Create: `appearance/Icons.qml` +- Modify: `appearance/shell.qml` + +**Interfaces:** +- Produces: + - `Icons.iconThemes: string[]`, `Icons.cursorThemes: string[]` + - `Icons.currentIcon: string`, `Icons.currentCursor: string` + - `Icons.iconPreview: var` (`name -> { iconName -> path }`), `Icons.cursorPreview: var` (`name -> path`) + - `Icons.classify(entries): { icons: string[], cursors: string[] }` + - `Icons.refresh()`, `.previewIcons()`, `.previewCursor(name)`, `.applyIcon(name)`, `.applyCursor(name)` + - `Icons.selftest(): string` +- Consumes: nothing. + +- [ ] **Step 1: Write the model with a failing selftest** + +Create `appearance/Icons.qml` (GPLv2 header as in Task 1): + +```qml +pragma Singleton + +import Quickshell +import Quickshell.Io +import QtQuick + +// The icon and cursor themes installed on this machine. Previews are resolved +// from each theme, not just the active one: Quickshell.iconPath can only read +// the platform theme or QS_ICON_THEME, both fixed at load. +Singleton { + id: root + + readonly property string home: Quickshell.env("HOME") + readonly property string iconScript: ` +import sys +import gi +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk +SAMPLES = ["folder","text-x-generic","image-x-generic","network-wireless", + "audio-x-generic","video-x-generic","battery-full","printer"] +t = Gtk.IconTheme.new() +for name in sys.argv[1:]: + t.set_custom_theme(name) + for s in SAMPLES: + info = t.lookup_icon(s, 32, 0) + if info: + print("%s\\t%s\\t%s" % (name, s, info.get_filename())) + print("%s\\tEND\\t" % name) +` + + property var iconThemes: [] + property var cursorThemes: [] + property string currentIcon: "" + property string currentCursor: "" + property var iconPreview: ({}) + property var cursorPreview: ({}) + property bool scanning: true + property string notice: "" + + // Pure: classify works on entries shaped { name, index, cursors, manifest } + // so it can be tested without touching the filesystem. + function classify(entries) { + return { icons: [], cursors: [] }; // STEP 3 fills this in + } + + function selftest(): string { + const entries = [ + { name: "Material-Black-Plum-Suru", index: "Directories=32x32/apps\n", cursors: false, manifest: false }, + { name: "hypr_bibata-modern-amber", index: "", cursors: false, manifest: true }, + { name: "default", index: "Inherits=Bibata-Modern-Amber\n", cursors: false, manifest: false }, + { name: "breeze_cursors", index: "", cursors: true, manifest: false }, + ]; + const r = root.classify(entries); + if (r.icons.length !== 1 || r.icons[0] !== "Material-Black-Plum-Suru") + return `SELFTEST Icons FAIL: icons ${JSON.stringify(r.icons)}`; + if (r.cursors.length !== 2 || r.cursors.indexOf("hypr_bibata-modern-amber") < 0 || + r.cursors.indexOf("breeze_cursors") < 0) + return `SELFTEST Icons FAIL: cursors ${JSON.stringify(r.cursors)}`; + return "SELFTEST Icons PASS"; + } + + function refresh() { + scanProc.running = false; + scanProc.running = true; + curProc.running = false; + curProc.running = true; + } + + // One shell pass over every theme root. Format per line: + // name|hasIndex|cursors|manifest|Directories= + Process { + id: scanProc + command: ["sh", "-c", + `for d in ${root.home}/.icons/* ${root.home}/.local/share/icons/* ` + + `/usr/share/icons/*; do [ -d "$d" ] || continue; ` + + `i=0; c=0; m=0; dirs=""; ` + + `[ -d "$d/cursors" ] && c=1; ` + + `[ -f "$d/manifest.hl" ] && m=1; ` + + `if [ -f "$d/index.theme" ]; then i=1; dirs=$(sed -n 's/^Directories=//p' "$d/index.theme"); fi; ` + + `echo "$(basename "$d")|$i|$c|$m|$dirs"; done`] + stdout: StdioCollector { + onStreamFinished: { + const entries = []; + for (const line of text.trim().split("\n")) { + const p = line.split("|"); + if (p.length < 5) continue; + entries.push({ name: p[0], index: p[1] === "1" ? (p[4] || "x") : "", + cursors: p[2] === "1", manifest: p[3] === "1" }); + } + const r = root.classify(entries); + root.iconThemes = r.icons; + root.cursorThemes = r.cursors; + root.scanning = false; + if (root.iconThemes.length) root.previewIcons(); + } + } + } + + // One python process for every icon theme, so opening the tab costs one + // spawn, not one per theme. + function previewIcons() { + if (!root.iconThemes.length) return; + iconProc.command = ["python3", "-c", root.iconScript].concat(root.iconThemes); + iconProc.running = false; + iconProc.running = true; + } + Process { + id: iconProc + stdout: StdioCollector { + onStreamFinished: { + const next = {}; + for (const line of text.split("\n")) { + const p = line.split("\t"); + if (p.length < 2 || p[1] === "END" || !p[2]) continue; + if (!next[p[0]]) next[p[0]] = {}; + next[p[0]][p[1]] = p[2]; + } + root.iconPreview = next; + } + } + } + + // gsettings for the current values. + Process { + id: curProc + command: ["sh", "-c", + `gsettings get org.gnome.desktop.interface icon-theme; ` + + `gsettings get org.gnome.desktop.interface cursor-theme`] + stdout: StdioCollector { + onStreamFinished: { + const lines = text.trim().split("\n"); + root.currentIcon = (lines[0] ?? "").replace(/'/g, ""); + root.currentCursor = (lines[1] ?? "").replace(/'/g, ""); + } + } + } + + // A cursor theme is a manifest + .hlc shapes, a shape directory with SVGs, + // or a legacy Xcursor cursors/ directory. The extracted image goes to the + // per-shell cache; on failure the tab shows nothing rather than a broken + // image. + function previewCursor(name) { + const dir = Quickshell.cachePath("cursors"); + const out = `${dir}/${name}.png`; + curPreviewProc.themeName = name; + curPreviewProc.command = ["sh", "-c", + `set -e; ` + + `d=""; for r in ${root.home}/.icons ${root.home}/.local/share/icons /usr/share/icons; do ` + + `[ -d "$r/${name}" ] && d="$r/${name}" && break; done; [ -n "$d" ] || exit 1; ` + + `mkdir -p ${dir}; ` + + `c=""; for n in left_ptr default pointer hand2; do ` + + `if [ -f "$d/$n.hlc" ]; then c="$d/$n.hlc"; break; fi; ` + + `if [ -f "$d/$n/$n.svg" ]; then c="$d/$n/$n.svg"; break; fi; ` + + `if [ -f "$d/cursors/$n" ]; then c="$d/cursors/$n"; break; fi; done; ` + + `[ -n "$c" ] || exit 1; ` + + `case "$c" in ` + + `*.hlc) unzip -p "$c" '*.svg' > ${out} 2>/dev/null; ` + + `[ -s ${out} ] || unzip -p "$c" '*.png' > ${out} 2>/dev/null; ` + + `[ -s ${out} ] || exit 1;; ` + + `*.svg) cp "$c" ${out};; ` + + `*) rm -rf ${dir}/raw-${name}; mkdir -p ${dir}/raw-${name}; ` + + `xcur2png -d ${dir}/raw-${name} "$c" >/dev/null 2>&1; ` + + `cp "$(ls ${dir}/raw-${name}/$(basename "$c")_*.png | tail -1)" ${out};; esac`] + curPreviewProc.running = false; + curPreviewProc.running = true; + } + Process { + id: curPreviewProc + property string themeName: "" + onExited: code => { + if (code === 0) { + const next = Object.assign({}, root.cursorPreview); + next[themeName] = `${Quickshell.cachePath("cursors")}/${themeName}.png`; + root.cursorPreview = next; + } + } + } + + // Writes go through FileView so no shell quoting is involved. They are + // preloaded because setText on an unloaded FileView would write empty. + FileView { id: qt6File; path: `${root.home}/.config/qt6ct/qt6ct.conf`; blockLoading: true } + FileView { id: qt5File; path: `${root.home}/.config/qt5ct/qt5ct.conf`; blockLoading: true } + FileView { id: envFile; path: `${root.home}/.config/hypr/sections/environment.lua`; blockLoading: true } + + function gsettingsSet(key, value) { + gsetProc.command = ["gsettings", "set", "org.gnome.desktop.interface", key, value]; + gsetProc.running = false; + gsetProc.running = true; + } + Process { id: gsetProc } + + function applyIcon(name) { + root.gsettingsSet("icon-theme", name); + const files = [qt6File, qt5File]; + for (let i = 0; i < files.length; i++) { + const text = files[i].text(); + if (text !== "") files[i].setText(text.replace(/^icon_theme=.*/m, `icon_theme=${name}`)); + } + root.currentIcon = name; + root.notice = `${name} set. Restart apps to see it.`; + } + + function applyCursor(name) { + // Live switch first, then persistence. + Quickshell.execDetached(["hyprctl", "setcursor", name, "24"]); + root.gsettingsSet("cursor-theme", name); + const text = envFile.text(); + // Matches both XCURSOR_THEME and HYPRCURSOR_THEME: both end in + // CURSOR_THEME", ". + if (text !== "") + envFile.setText(text.replace(/(CURSOR_THEME", ")[^"]*/g, (m, p1) => p1 + name)); + root.currentCursor = name; + root.notice = `${name} set live. environment.lua updated for next login.`; + } +} +``` + +- [ ] **Step 2: Extend the selftest IPC and run it to verify it fails** + +In `appearance/shell.qml`: + +```qml + function selftest(): string { + return Hyprsunset.selftest() + "\n" + Hypridle.selftest() + "\n" + Icons.selftest(); + } +``` + +Run: `qs -p appearance ipc call appearance selftest` +Expected: three lines; the `Icons` line reports `SELFTEST Icons FAIL: icons []`. + +- [ ] **Step 3: Implement classify and verify the selftest passes** + +Replace the stub body with: + +```qml + const icons = [], cursors = []; + for (const e of entries) { + if (e.cursors || e.manifest) cursors.push(e.name); + else if (e.index && /Directories=\S/.test(e.index)) icons.push(e.name); + } + const uniq = a => a.filter((v, i) => a.indexOf(v) === i).sort(); + return { icons: uniq(icons), cursors: uniq(cursors) }; +``` + +Run: `qs -p appearance ipc call appearance selftest` +Expected: `SELFTEST Icons PASS` + +- [ ] **Step 4: Verify the icon lookup standalone** + +Run: +`python3 -c 'import gi; gi.require_version("Gtk","3.0"); from gi.repository import Gtk; t=Gtk.IconTheme.new(); t.set_custom_theme("Material-Black-Plum-Suru"); print(t.lookup_icon("folder",32,0).get_filename())'` +Expected: an absolute path ending `folder.svg`. If `gi` is not importable, the icon section will be empty; report that and stop. + +- [ ] **Step 5: Commit** + +```bash +git add appearance/Icons.qml appearance/shell.qml +git commit -m "feat(appearance): add the icon and cursor theme model + +Lists themes from ~/.icons, ~/.local/share/icons and /usr/share/icons, +distinguishing cursor themes by cursors/ or manifest.hl. Icon previews +come from one GTK lookup process for every theme; cursor previews extract +left_ptr from a hyprcursor .hlc via unzip or from an Xcursor theme via +xcur2png. Application sets gsettings and the Qt configs, and rewrites the +cursor env in environment.lua." +``` + +--- + +### Task 4: Tab bar, Loader, and the Sunset tab + +**Files:** +- Create: `appearance/Field.qml`, `appearance/Toggle.qml`, `appearance/SunsetTab.qml` +- Modify: `appearance/AppearancePanel.qml`, `appearance/shell.qml` + +**Interfaces:** +- Consumes: `Hyprsunset` (Task 1). +- Produces: `Field` (`value`, `placeholder`, `onEdited`), `Toggle` (`checked`, `label`), `SunsetTab` (a `Flickable`). + +- [ ] **Step 1: Create the two shared controls** + +`appearance/Field.qml`: + +```qml +// Copyright (C) 2026 Danilo M. <danix@danix.xyz> +// GPLv2-only. See LICENSE. + +import QtQuick + +Rectangle { + id: field + property alias value: input.text + property string placeholder: "" + signal edited + + implicitWidth: 70 + height: 24 + radius: 5 + color: Qt.alpha(Theme.surface, 0.5) + border.width: 1 + border.color: input.activeFocus ? Qt.alpha(Theme.accent, 0.6) : Qt.alpha(Theme.text, 0.12) + + Text { + anchors.fill: parent + anchors.leftMargin: 6 + anchors.rightMargin: 6 + verticalAlignment: Text.AlignVCenter + visible: input.text === "" + text: field.placeholder + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + color: Theme.overlay + } + + TextInput { + id: input + anchors.fill: parent + anchors.leftMargin: 6 + anchors.rightMargin: 6 + verticalAlignment: TextInput.AlignVCenter + clip: true + selectByMouse: true + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + color: Theme.text + onEditingFinished: field.edited() + } +} +``` + +`appearance/Toggle.qml`: + +```qml +// Copyright (C) 2026 Danilo M. <danix@danix.xyz> +// GPLv2-only. See LICENSE. + +import QtQuick + +Rectangle { + id: toggle + property bool checked: false + property string label: "" + signal toggled + + implicitWidth: row.implicitWidth + implicitHeight: 22 + color: "transparent" + + Row { + id: row + anchors.verticalCenter: parent.verticalCenter + spacing: 6 + Rectangle { + anchors.verticalCenter: parent.verticalCenter + width: 16; height: 16; radius: 4 + color: toggle.checked ? Qt.alpha(Theme.accent, 0.7) : Qt.alpha(Theme.surface, 0.5) + border.width: 1 + border.color: toggle.checked ? Qt.alpha(Theme.accent, 0.9) : Qt.alpha(Theme.text, 0.2) + Text { + anchors.centerIn: parent + visible: toggle.checked + text: "✓" + font { family: Theme.iconFamily; pixelSize: Theme.fontSize - 6; bold: true } + color: Theme.base + } + } + Text { + anchors.verticalCenter: parent.verticalCenter + visible: toggle.label !== "" + text: toggle.label + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + color: Theme.subtext + } + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { toggle.checked = !toggle.checked; toggle.toggled(); } + } +} +``` + +- [ ] **Step 2: Generalize the tab bar and key cycle** + +In `appearance/AppearancePanel.qml`, add to the `Scope` (near `property string tab`): + +```qml + readonly property var tabs: ["wallpaper", "theme", "sunset", "idle", "icons"] + readonly property var tabLabels: ({ + wallpaper: "Wallpaper", theme: "Theme", sunset: "Sunset", + idle: "Idle", icons: "Icons", + }) +``` + +Replace the `Keys.onPressed` body (the `Qt.Key_Tab` branch): + +```qml + if (event.key === Qt.Key_Tab) { + const i = root.tabs.indexOf(root.tab); + root.tab = root.tabs[(i + 1) % root.tabs.length]; + event.accepted = true; + } +``` + +Replace the two hardcoded `Tab` buttons in the header `Row` with: + +```qml + Repeater { + model: root.tabs + Tab { + required property var modelData + text: root.tabLabels[modelData] + selected: root.tab === modelData + onClicked: root.tab = modelData + } + } +``` + +Replace the tab `Loader` with: + +```qml + Loader { + width: parent.width + height: parent.height - y + // Existing tabs are inline Components; the new ones are + // files. sourceComponent wins when it is non-null. + sourceComponent: root.tab === "theme" ? themeTab + : root.tab === "wallpaper" ? wallpaperTab : null + source: root.tab === "sunset" ? Qt.resolvedUrl("SunsetTab.qml") + : root.tab === "idle" ? Qt.resolvedUrl("IdleTab.qml") + : root.tab === "icons" ? Qt.resolvedUrl("IconsTab.qml") : "" + } +``` + +In `show()`, add refreshes: + +```qml + Hyprsunset.refresh(); +``` + +- [ ] **Step 3: Add the sunset IPC verbs** + +In `appearance/shell.qml` `IpcHandler`: + +```qml + function sunset() { panel.toggle("sunset"); } + function idle() { panel.toggle("idle"); } + function icons() { panel.toggle("icons"); } +``` + +- [ ] **Step 4: Create the Sunset tab** + +`appearance/SunsetTab.qml`: + +```qml +// Copyright (C) 2026 Danilo M. <danix@danix.xyz> +// GPLv2-only. See LICENSE. + +import QtQuick + +Flickable { + contentHeight: col.implicitHeight + clip: true + + Column { + id: col + width: parent.width + spacing: 12 + + Row { + spacing: 8 + Text { + anchors.verticalCenter: parent.verticalCenter + text: "Lat" + color: Theme.subtext + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + } + Field { width: 90; value: Hyprsunset.lat; onEdited: Hyprsunset.lat = value; placeholder: "lat" } + Text { + anchors.verticalCenter: parent.verticalCenter + text: "Lon" + color: Theme.subtext + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + } + Field { width: 90; value: Hyprsunset.lon; onEdited: Hyprsunset.lon = value; placeholder: "lon" } + Toggle { + anchors.verticalCenter: parent.verticalCenter + checked: Hyprsunset.autoDetect + label: "auto" + onToggled: Hyprsunset.autoDetect = checked + } + Tab { text: "Detect"; onClicked: Hyprsunset.detect() } + Tab { text: Hyprsunset.busy ? "fetching…" : "Fetch sun times"; onClicked: Hyprsunset.fetchSun() } + Text { + anchors.verticalCenter: parent.verticalCenter + text: Hyprsunset.sunSummary + color: Theme.overlay + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + } + } + + Repeater { + model: Hyprsunset.profiles + Rectangle { + required property var modelData + required property int index + width: col.width + implicitHeight: 40 + radius: 8 + color: Qt.alpha(Theme.surface, 0.35) + + Row { + anchors.fill: parent + anchors.margins: 8 + spacing: 8 + + Text { + anchors.verticalCenter: parent.verticalCenter + text: index === Hyprsunset.dayIndex(Hyprsunset.profiles) ? "day" + : index === Hyprsunset.nightIndex(Hyprsunset.profiles) ? "night" : "profile" + color: Theme.subtext + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + } + Field { + anchors.verticalCenter: parent.verticalCenter + width: 60 + value: modelData.time + placeholder: "HH:MM" + onEdited: Hyprsunset.profiles = Hyprsunset.profiles.map( + (p, i) => i === index ? Object.assign({}, p, { time: value }) : p) + } + Toggle { + anchors.verticalCenter: parent.verticalCenter + checked: modelData.identity + label: "identity" + onToggled: Hyprsunset.profiles = Hyprsunset.profiles.map( + (p, i) => i === index ? Object.assign({}, p, { identity: checked }) : p) + } + Toggle { + anchors.verticalCenter: parent.verticalCenter + checked: modelData.temperature !== null && modelData.temperature !== undefined + label: "temp" + onToggled: Hyprsunset.profiles = Hyprsunset.profiles.map( + (p, i) => i === index ? Object.assign({}, p, + { temperature: checked ? (p.temperature ?? 5500) : null }) : p) + } + Field { + anchors.verticalCenter: parent.verticalCenter + width: 70 + value: modelData.temperature === null || modelData.temperature === undefined + ? "" : String(modelData.temperature) + placeholder: "5500" + onEdited: Hyprsunset.profiles = Hyprsunset.profiles.map( + (p, i) => i === index ? Object.assign({}, p, + { temperature: value === "" ? null : parseInt(value, 10) }) : p) + } + Toggle { + anchors.verticalCenter: parent.verticalCenter + checked: modelData.gamma !== null && modelData.gamma !== undefined + label: "gamma" + onToggled: Hyprsunset.profiles = Hyprsunset.profiles.map( + (p, i) => i === index ? Object.assign({}, p, + { gamma: checked ? (p.gamma ?? 1.0) : null }) : p) + } + Field { + anchors.verticalCenter: parent.verticalCenter + width: 60 + value: modelData.gamma === null || modelData.gamma === undefined + ? "" : String(modelData.gamma) + placeholder: "0.8" + onEdited: Hyprsunset.profiles = Hyprsunset.profiles.map( + (p, i) => i === index ? Object.assign({}, p, + { gamma: value === "" ? null : parseFloat(value) }) : p) + } + Item { width: 8; height: 1 } + Tab { + anchors.verticalCenter: parent.verticalCenter + text: "✕" + onClicked: Hyprsunset.profiles = + Hyprsunset.profiles.filter((p, i) => i !== index) + } + Item { width: parent.width - 640; height: 1 } + } + } + } + + Row { + spacing: 8 + Tab { + text: "+ Add profile" + onClicked: Hyprsunset.profiles = + Hyprsunset.profiles.concat([{ time: "0:00", identity: false, + temperature: null, gamma: null }]) + } + Tab { text: "Live preview"; onClicked: Hyprsunset.preview() } + Tab { + text: "Save + Restart" + selected: true + onClicked: Hyprsunset.save() + } + } + + Text { + width: parent.width + wrapMode: Text.Wrap + visible: Hyprsunset.notice !== "" + text: Hyprsunset.notice + color: Hyprsunset.notice.indexOf("invalid") === 0 ? Theme.red : Theme.green + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + } + } +} +``` + +- [ ] **Step 5: Verify the tab opens and the selftest still passes** + +Run: `qs -p appearance ipc call appearance sunset` +Expected: the drawer opens on the Sunset tab, showing two profile rows from the real file. Confirm the profiles match `~/.config/hypr/hyprsunset.conf`. + +Run: `qs -p appearance ipc call appearance selftest` +Expected: `SELFTEST Hyprsunset PASS` (and Hypridle PASS). + +- [ ] **Step 6: Commit** + +```bash +git add appearance/Field.qml appearance/Toggle.qml appearance/SunsetTab.qml \ + appearance/AppearancePanel.qml appearance/shell.qml +git commit -m "feat(appearance): add the Sunset tab + +Tab bar and Tab-key cycle become five entries, the Loader picks inline or +file tabs, and show() refreshes the model. The tab edits the profiles +in place, fetches sun times and previews through the daemon." +``` + +--- + +### Task 5: Idle tab + +**Files:** +- Create: `appearance/IdleTab.qml` +- Modify: `appearance/AppearancePanel.qml` (`show()` refresh) + +**Interfaces:** +- Consumes: `Hypridle` (Task 2), `Toggle`, `Field`, `Tab`. +- Produces: `IdleTab` (a `Flickable`). + +- [ ] **Step 1: Create the tab** + +`appearance/IdleTab.qml`: + +```qml +// Copyright (C) 2026 Danilo M. <danix@danix.xyz> +// GPLv2-only. See LICENSE. + +import QtQuick + +Flickable { + contentHeight: col.implicitHeight + clip: true + + Column { + id: col + width: parent.width + spacing: 10 + + Text { + text: "Commands are fixed. Only the timeout and whether a listener runs are editable." + color: Theme.overlay + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + } + + Repeater { + model: Hypridle.listeners + Rectangle { + required property var modelData + required property int index + width: col.width + implicitHeight: 40 + radius: 8 + color: Qt.alpha(Theme.surface, modelData.enabled ? 0.4 : 0.2) + opacity: modelData.enabled ? 1 : 0.6 + + Row { + anchors.fill: parent + anchors.margins: 8 + spacing: 10 + + Toggle { + anchors.verticalCenter: parent.verticalCenter + checked: modelData.enabled + onToggled: Hypridle.listeners = Hypridle.listeners.map( + (l, i) => i === index ? Object.assign({}, l, { enabled: checked }) : l) + } + Text { + anchors.verticalCenter: parent.verticalCenter + width: 150 + text: Hypridle.describe(modelData) + color: Theme.text + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + } + Field { + anchors.verticalCenter: parent.verticalCenter + width: 70 + value: String(modelData.timeout) + placeholder: "seconds" + onEdited: Hypridle.listeners = Hypridle.listeners.map( + (l, i) => i === index + ? Object.assign({}, l, { timeout: parseInt(value, 10) || 0 }) : l) + } + Text { + anchors.verticalCenter: parent.verticalCenter + text: `${Math.floor(modelData.timeout / 60)}m ${modelData.timeout % 60}s` + color: Theme.subtext + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + } + Text { + anchors.verticalCenter: parent.verticalCenter + width: parent.width - 380 + elide: Text.ElideRight + text: modelData.onTimeout + color: Theme.overlay + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 5 } + } + } + } + } + + Tab { + text: "Save + Restart hypridle" + selected: true + onClicked: Hypridle.save() + } + } +} +``` + +- [ ] **Step 2: Refresh the model when the panel opens** + +In `appearance/AppearancePanel.qml` `show()`, beside `Hyprsunset.refresh();` add: + +```qml + Hypridle.refresh(); +``` + +- [ ] **Step 3: Verify** + +Run: `qs -p appearance ipc call appearance idle` +Expected: the drawer opens on Idle with four rows (notify before lock, lock session, monitors off, suspend) and their timeouts 570/600/630/660. + +Toggle one off, run: +`qs -p appearance ipc call appearance selftest` +Expected: `SELFTEST Hypridle PASS` still. + +- [ ] **Step 4: Commit** + +```bash +git add appearance/IdleTab.qml appearance/AppearancePanel.qml +git commit -m "feat(appearance): add the Idle tab + +Rows for each listener with an enable toggle and a timeout field, the +command shown read-only. Save rewrites the file and restarts hypridle." +``` + +--- + +### Task 6: Icons tab + +**Files:** +- Create: `appearance/IconsTab.qml` +- Modify: `appearance/AppearancePanel.qml` (`show()` refresh) + +**Interfaces:** +- Consumes: `Icons` (Task 3), `Toggle`, `Tab`, `Quickshell.Widgets.IconImage`. +- Produces: `IconsTab` (a `Flickable`). + +- [ ] **Step 1: Create the tab** + +`appearance/IconsTab.qml`: + +```qml +// Copyright (C) 2026 Danilo M. <danix@danix.xyz> +// GPLv2-only. See LICENSE. + +import Quickshell +import Quickshell.Widgets +import QtQuick + +Column { + spacing: 12 + + Text { + text: Icons.scanning ? "scanning…" : `${Icons.iconThemes.length} icon themes · ${Icons.cursorThemes.length} cursor themes` + color: Theme.overlay + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + } + + Text { + text: "Icon theme" + color: Theme.subtext + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: true } + } + + Flickable { + id: iconList + width: parent.width + height: 150 + contentWidth: iconRow.implicitWidth + contentHeight: height + clip: true + flickableDirection: Flickable.HorizontalFlick + + Row { + id: iconRow + spacing: 10 + Repeater { + model: Icons.iconThemes + Rectangle { + id: iconCard + required property string modelData + readonly property bool current: Icons.currentIcon === modelData + width: 150; height: 120; radius: 10 + color: current ? Qt.alpha(Theme.accent, 0.2) : Qt.alpha(Theme.surface, 0.35) + border.width: current ? 2 : 1 + border.color: current ? Theme.accent : "transparent" + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: Icons.applyIcon(modelData) + } + + Column { + anchors.centerIn: parent + spacing: 6 + Row { + spacing: 6 + Repeater { + model: ["folder", "text-x-generic", "image-x-generic", "network-wireless"] + IconImage { + required property string modelData + implicitSize: 26 + source: (Icons.iconPreview[iconCard.modelData] ?? {})[modelData] ?? "" + } + } + } + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: iconCard.current ? modelData + " current" : modelData + color: Theme.text + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + } + } + } + } + } + } + + Text { + text: "Cursor theme" + color: Theme.subtext + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: true } + } + + Flickable { + width: parent.width + height: 150 + contentWidth: cursorRow.implicitWidth + contentHeight: height + clip: true + flickableDirection: Flickable.HorizontalFlick + + Row { + id: cursorRow + spacing: 10 + Repeater { + model: Icons.cursorThemes + Rectangle { + required property string modelData + readonly property bool current: Icons.currentCursor === modelData + width: 120; height: 120; radius: 10 + color: current ? Qt.alpha(Theme.accent, 0.2) : Qt.alpha(Theme.surface, 0.35) + border.width: current ? 2 : 1 + border.color: current ? Theme.accent : "transparent" + + HoverHandler { + onHoveredChanged: { + if (hovered) { + Icons.previewCursor(modelData); + // Live preview: the real cursor, reverted on leave. + liveCursorCursor = modelData; + Quickshell.execDetached(["hyprctl", "setcursor", modelData, "24"]); + } else if (liveCursorCursor === modelData) { + liveCursorCursor = ""; + Quickshell.execDetached(["hyprctl", "setcursor", Icons.currentCursor, "24"]); + } + } + } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: Icons.applyCursor(modelData) + } + + Column { + anchors.centerIn: parent + spacing: 6 + Image { + anchors.horizontalCenter: parent.horizontalCenter + width: 40; height: 40 + asynchronous: true + source: Icons.cursorPreview[modelData] ? "file://" + Icons.cursorPreview[modelData] : "" + fillMode: Image.PreserveAspectFit + } + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: parent.parent.current ? modelData + " current" : modelData + color: Theme.text + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + width: 110 + elide: Text.ElideMiddle + horizontalAlignment: Text.AlignHCenter + } + } + } + } + } + } + + Text { + width: parent.width + wrapMode: Text.Wrap + visible: Icons.notice !== "" + text: Icons.notice + color: Theme.green + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + } + + property string liveCursorCursor: "" + onVisibleChanged: if (!visible && liveCursorCursor !== "") { + Quickshell.execDetached(["hyprctl", "setcursor", Icons.currentCursor, "24"]); + liveCursorCursor = ""; + } +} +``` + +- [ ] **Step 2: Refresh the model when the panel opens** + +In `appearance/AppearancePanel.qml` `show()`, add: + +```qml + Icons.refresh(); +``` + +- [ ] **Step 3: Verify** + +Run: `qs -p appearance ipc call appearance icons` +Expected: two scrollable rows. Icon themes show four glyphs each and the current theme is marked. Cursor themes show an extracted arrow and hovering changes the real cursor, reverting on leave. + +Run: `qs -p appearance ipc call appearance selftest` +Expected: all three `PASS`. + +- [ ] **Step 4: Commit** + +```bash +git add appearance/IconsTab.qml appearance/AppearancePanel.qml +git commit -m "feat(appearance): add the Icons tab + +Icon themes preview four glyphs each from the GTK lookup; cursor themes +preview an extracted left_ptr and change the real cursor on hover, reverted +on leave and on close. Selection applies both." +``` + +--- + +### Task 7: Docs, follow-up TODOs, final verification + +**Files:** +- Modify: `appearance/README.md` +- Create: `~/Programming/GIT/unified-desktop-theme/TODO-icons.md` (or append to an existing TODO if one exists) +- Create: `~/Programming/GIT/waybar-theme-udt/TODO-icons.md` (same) +- Modify: no code. + +**Interfaces:** +- Consumes: the finished tabs. +- Produces: documentation and tracked follow-ups. + +- [ ] **Step 1: Update the README** + +In `appearance/README.md`, extend the tab diagram line and add a section: + +````markdown + ┌─[ Wallpaper ]─[ Theme ]─[ Sunset ]─[ Idle ]─[ Icons ]──┐ +```` + +Add under `## Running it`: + +```markdown +IPC verbs: `wallpaper`, `theme`, `sunset`, `idle`, `icons`. + +## Sunset + +A port of `hyprsunset-qt` (`~/Programming/GIT/sunset-qt`): profiles in +`~/.config/hypr/hyprsunset.conf`, location in +`~/.config/hyprsunset-qt/config`, sunrise/sunset from the same API and cache. +Both apps read and write the identical file format, so either can edit it. + +## Idle + +Timeout and enabled only; the commands in `~/.config/hypr/hypridle.conf` are +fixed. Everything before the first `listener` (the `general` block and the +comments explaining the design) is preserved verbatim. A disabled listener is +written commented out. Save restarts `hypridle`, which resets its timers. + +## Icons + +Switches the icon and cursor theme. Icon previews come from a GTK lookup per +theme; cursor previews extract `left_ptr` from a hyprcursor `.hlc` with +`unzip` or from an Xcursor theme with `xcur2png`, and hovering changes the real +cursor. Applying writes `gsettings`, the Qt configs, and for cursors +`environment.lua`; apps and a relogin are needed to see the rest. The theme +name is still hardcoded in `unified-desktop-theme`, `waybar-theme-udt` and +rofi, which is tracked as a follow-up in those repos. +``` + +- [ ] **Step 2: Write the follow-up TODOs** + +Check each repo for an existing TODO file (`ls ~/Programming/GIT/unified-desktop-theme ~/Programming/GIT/waybar-theme-udt`). If one exists, append; otherwise create `TODO-icons.md` in each with: + +```markdown +# Unhardcode the icon theme + +The active icon theme name `Material-Black-Plum-Suru` is hardcoded here. The +appearance drawer's Icons tab now switches it through gsettings and the Qt +configs, but these files do not follow, so a switch is silently reverted. +Replace the hardcoded name with a read of +`gsettings get org.gnome.desktop.interface icon-theme`. + +Known sites: +- unified-desktop-theme: `templates/qt-gtk/qt5ct.conf`, `qt6ct.conf`, + `gtk3-settings.ini` +- waybar-theme-udt: `bin/wb-icon` (the `THEME` constant), + `modules/extras/taskbar.jsonc`, `install.sh` +- rofi: `~/.config/rofi/config.rasi` +- `~/.config/hypr/hyprqt6engine.conf` +- `~/.local/share/applications/firefox-clean.desktop` +``` + +- [ ] **Step 3: Final verification, real saves** + +```bash +cp ~/.config/hypr/hyprsunset.conf /tmp/hs.before +cp ~/.config/hypr/hypridle.conf /tmp/hi.before +cp ~/.config/hypr/sections/environment.lua /tmp/env.before +``` + +Open the Sunset tab, press Save + Restart with no edits, then: +Run: `diff /tmp/hs.before ~/.config/hypr/hyprsunset.conf` +Expected: no output (byte-identical). Repeat for Idle. For the cursor, applying any theme is expected to change `environment.lua`; confirm with `diff /tmp/env.before ~/.config/hypr/sections/environment.lua` that only the two cursor lines changed, then set it back. + +- [ ] **Step 4: Confirm the daemons still run** + +Run: `pgrep -x hyprsunset; pgrep -x hypridle` +Expected: one PID each. + +- [ ] **Step 5: Commit** + +```bash +git add appearance/README.md +git commit -m "docs(appearance): document the Sunset, Idle and Icons tabs + +Records the IPC verbs, the shared hyprsunset.conf format, the preserved +hypridle comments, and the preview mechanisms. The hardcoded icon theme in +udt, waybar and rofi is tracked as a TODO in those repos." +``` + +Note: the `TODO-icons.md` files live in other repos and are committed there separately, not in this plan's commits. + +--- + +## Self-review + +- **Spec coverage:** tab bar/IPC (Task 4), Sunset full mirror (Tasks 1, 4), Idle timeouts+toggle+comment preservation (Tasks 2, 5), Icons lists/preview/apply (Tasks 3, 6), README + cross-repo TODOs (Task 7), selftests (Tasks 1-3), verified traps (Global Constraints, Task 2). All spec sections map to a task. +- **Placeholder scan:** no TBD/TODO steps; every code step has full code. +- **Type consistency:** `parseProfiles`/`serializeProfiles` names are used consistently; `Hypridle.parse`/`serialize` signatures match between the selftest and Task 5's use; `Icons.classify` field names (`index`, `cursors`, `manifest`) match the scan and the selftest. `Field.value`/`Toggle.checked` match the tab code. +- **Fixed during self-review:** the sunset fixture gained the two blank lines after the header that sunset-qt's serializer actually emits; the idle parser's first-listener leading trivia no longer duplicates the prefix, and its closing-brace pattern tolerates a commented brace; `Icons` writes configs through `FileView` instead of shell `sed`, so no quoting can corrupt them; `previewCursor` assigns `themeName` before running and no longer concatenates raw zip bytes on an `.hlc` without an SVG; `IconsTab` references the delegate by `id`. +- **Open risk:** the mixed `Loader` (inline `sourceComponent` for theme/wallpaper, `source` for the new tabs) relies on `sourceComponent` taking precedence when non-null. If a tab switch between an inline and a file tab misbehaves during Task 4, fall back to `source`-only by moving `themeTab` and `wallpaperTab` into `ThemeTab.qml` and `WallpaperTab.qml`; the change is mechanical. diff --git a/docs/superpowers/plans/2026-09-15-notification-image-renderers.md b/docs/superpowers/plans/2026-09-15-notification-image-renderers.md new file mode 100644 index 0000000..660949e --- /dev/null +++ b/docs/superpowers/plans/2026-09-15-notification-image-renderers.md @@ -0,0 +1,218 @@ +# Notification Image Renderers 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:** The balloon draws a notification's content image as a large preview below the text, and the RichText body never fetches a remote image. + +**Architecture:** `NotificationBalloon` gains one `Image` bound to the new `notifyd` contract field `image`, sized to the balloon width and capped in height. Inline `<img>` already renders through the existing RichText body; a small sanitizer in the `Notify` singleton strips remote sources before display, so the shell cannot be made to fetch a URL. + +**Tech Stack:** Quickshell 0.3.1, Qt6 QML, `Quickshell.Io.FileView`, `Quickshell.Io.Process`. + +**Spec:** `docs/superpowers/specs/2026-09-15-notification-images-design.md` (read it before starting). The daemon half is a separate plan and must ship for the image path to be exercised; this plan tolerates a daemon without the field. + +## Global Constraints + +- Quickshell 0.3.1, Qt6 QML. Run a config with `qs -p <dir>`. The running process is `qs`: `pkill -x qs`, `pgrep -cx qs`, never `pkill -f`. +- GPLv2 only. Existing headers stay; no new source file in this plan needs one. +- `image` may be absent on an older daemon, so every read guards `!== undefined`. +- Inline images are local only: a `<img>` whose `src` is `http:` or `https:` is stripped before display. Local paths and `file://` are left alone. +- No em dashes. No home paths in committed files. +- Smoke check, harness owns the process: + +```bash +timeout 8 qs -p <dir> 2>&1 | grep -E 'ERROR|TypeError|ReferenceError|is not defined|Cannot assign|Unable to assign' && echo "ERRORS ABOVE" || echo "clean" +``` + +--- + +## File Structure + + notifications/NotificationBalloon.qml the large preview image (modify) + shared/Notify.qml the sanitizer for inline sources (modify) + desktop/NotificationRow.qml use the sanitizer for the row body (modify) + +--- + +### Task 1: The balloon image preview + +**Files:** +- Modify: `notifications/NotificationBalloon.qml` + +**Interfaces:** +- Consumes: the daemon's `image` field (`notification.image`, a path string or undefined). +- Produces: nothing consumed by later tasks. + +- [ ] **Step 1: Make the balloon height account for the image** + +In `notifications/NotificationBalloon.qml`, change: + +```qml + implicitHeight: texts.implicitHeight + 20 +``` + +to: + +```qml + implicitHeight: texts.implicitHeight + 20 + (preview.visible ? preview.height + 8 : 0) +``` + +- [ ] **Step 2: Add the preview image** + +Insert this block immediately after the closing `}` of the `Column { id: texts ... }` +and before the `Text { id: close ... }`: + +```qml + // The content image (a screenshot or an app-provided image), below the + // text. The daemon writes the path; an older daemon without the field + // leaves this hidden. The height matches the scaled width so + // PreserveAspectFit does not letterbox, and a tall screenshot is capped at + // 240px. Asynchronous so a large screenshot does not stall the shell. + Image { + id: preview + visible: b.notification.image !== "" && b.notification.image !== undefined + anchors { + left: parent.left + right: parent.right + top: texts.bottom + leftMargin: 10 + rightMargin: 10 + topMargin: 8 + } + height: visible && implicitWidth > 0 + ? Math.min(width * implicitHeight / implicitWidth, 240) + : 0 + source: visible ? "file://" + b.notification.image : "" + fillMode: Image.PreserveAspectFit + asynchronous: true + } +``` + +- [ ] **Step 3: Smoke check** + +```bash +timeout 8 qs -p ./notifications 2>&1 | grep -E 'ERROR|TypeError|ReferenceError|is not defined|Cannot assign|Unable to assign' && echo "ERRORS ABOVE" || echo "clean" +``` + +Expected: `clean`. The running daemon currently publishes no `image` field, so +this also proves the `undefined` guard holds: nothing new is drawn. + +- [ ] **Step 4: Confirm by hand, once the daemon plan has shipped** + +Ask the user to send: + +```bash +notify-send -u critical -t 30000 -i ~/.cache/opencode/packages/@mohak34/opencode-notifier@latest/node_modules/@mohak34/opencode-notifier/logos/opencode-logo-dark.png "preview" "the logo should fill the balloon width" +``` + +Expected: a balloon with the logo as a large image below the text, undistorted and capped in height. A grimblast screenshot (`notify-send -i <screenshot>`) behaves the same. + +- [ ] **Step 5: Commit** + +```bash +git add notifications/NotificationBalloon.qml +git commit -m "feat(notifications): draw the content image in the balloon + +The daemon now publishes an image path; the balloon shows it below the +text, scaled to the balloon width with a 240px cap. A daemon without the +field leaves it hidden, so the renderer and the daemon can ship in +either order." +``` + +--- + +### Task 2: Strip remote inline image sources + +**Files:** +- Modify: `shared/Notify.qml` +- Modify: `notifications/NotificationBalloon.qml` +- Modify: `desktop/NotificationRow.qml` + +**Interfaces:** +- Consumes: nothing. +- Produces: `Notify.sanitize(body)` returning the body with remote `<img>` tags removed. + +- [ ] **Step 1: Add the sanitizer** + +In `shared/Notify.qml`, add this function beside `run`/`close`: + +```qml + // Inline images are local only. A notification is untrusted input, and a + // remote <img src> would otherwise make the shell fetch a URL, which leaks + // that the notification was shown. This removes such tags before the + // RichText body renders; a local path or file:// source is left alone. + function sanitize(body) { + return (body || "").replace(/<img\b[^>]*\bsrc\s*=\s*["']?\s*https?:\/\/[^>]*>/gi, ""); + } +``` + +- [ ] **Step 2: Use it in both renderers** + +In `notifications/NotificationBalloon.qml`, change the body text: + +```qml + text: b.notification.body || "" +``` + +to: + +```qml + text: Notify.sanitize(b.notification.body) +``` + +In `desktop/NotificationRow.qml`, change: + +```qml + text: row.notification.body || "" +``` + +to: + +```qml + text: Notify.sanitize(row.notification.body) +``` + +The balloon already resolves `Notify`; the row does too, through the +`desktop/Notify.qml` symlink. + +- [ ] **Step 3: Smoke check both configs** + +```bash +timeout 8 qs -p ./notifications 2>&1 | grep -E 'ERROR|TypeError|ReferenceError|is not defined|Cannot assign|Unable to assign' && echo "ERRORS ABOVE" || echo "clean" +timeout 8 qs -p ./desktop 2>&1 | grep -E 'ERROR|TypeError|ReferenceError|is not defined|Cannot assign|Unable to assign' && echo "ERRORS ABOVE" || echo "clean" +``` + +Expected: both `clean`. + +- [ ] **Step 4: Confirm by hand** + +Ask the user to send both, with the logo path from Task 1: + +```bash +notify-send -u critical -t 30000 "inline local" "above<br><img src='file://<logo-path>' width='200'><br>below" +notify-send -u critical -t 30000 "inline remote" "above<br><img src='https://example.org/does-not-exist.png' width='200'><br>below" +``` + +Expected: the first shows the image inline between the lines of text. The +second shows only the text, with no image and no network request. + +- [ ] **Step 5: Commit** + +```bash +git add shared/Notify.qml notifications/NotificationBalloon.qml desktop/NotificationRow.qml +git commit -m "feat(notifications): strip remote inline image sources + +A notification is untrusted input. Inline <img> now renders only for +local sources; an http(s) source is removed before the RichText body is +shown, so a remote sender cannot make the shell fetch a URL. The row and +the balloon share the one sanitizer in the Notify singleton." +``` + +--- + +## Self-Review + +**Spec coverage:** the balloon large preview (Task 1) and the local-only inline policy (Task 2) are the renderer half of the spec. The drawer row correctly gets no image. App-icon theme names are resolved daemon-side, so the renderer is unchanged there. + +**Placeholder scan:** none; every step carries its code. + +**Type consistency:** `Notify.sanitize(body)` is defined in Task 2 and used by both renderers; `notification.image` is read as a string path in Task 1. diff --git a/docs/superpowers/plans/2026-09-15-notification-renderers.md b/docs/superpowers/plans/2026-09-15-notification-renderers.md new file mode 100644 index 0000000..fdcbe7a --- /dev/null +++ b/docs/superpowers/plans/2026-09-15-notification-renderers.md @@ -0,0 +1,1380 @@ +# Notification Renderers 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:** The quickshell side of the notification daemon: balloon popups, the drawer's reserved notification space and history page, and the Status page snooze row. + +**Architecture:** `shared/Notify.qml` reads the daemon's published files (`queue.json`, `history.json`, `drawer`, `snooze`) and is the one place both renderers share; mutations go through `notifyctl` over a `Process`. A new `notifications/` component draws balloons bottom-right of `DP-1` over conky. The desktop drawer's existing reserved `Item` is filled with a scrollable live list plus a History button, and the Status page gains a snooze row. Nothing here talks D-Bus; the files are the interface, the same convention the status registry set. + +**Tech Stack:** Quickshell 0.3.1, Qt6 QML, `Quickshell.Io.FileView`, `Quickshell.Io.Process`, `notifyctl` and `notify-snooze.sh` in `~/bin`. + +**Spec:** `docs/superpowers/specs/2026-09-15-notification-daemon-design.md`. Plan 1 (the daemon, in the `notifyd` repo) is a prerequisite and is shipped. + +## Global Constraints + +- Quickshell 0.3.1, Qt6 QML. Run a config with `qs -p <dir>`. The running process is `qs`: `pkill -x qs`, `pgrep -cx qs`, never `pkill -f`. +- GPLv2 only. Every new `.qml` file begins with this exact header: + +```qml +// Copyright (C) 2026 Danilo M. <danix@danix.xyz> +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 2 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +``` + +Shell scripts use the same notice with `#` markers after the shebang. + +- A component with no always-visible window exits. Every new component (`notifications/`) holds itself open with a 1x1 transparent `PanelWindow` with `mask: Region {}`. See AGENTS.md. +- The daemon's published JSON is the contract. `created` and `expires` are epoch milliseconds, `0` meaning never; `actions` is an array of `[key, label]` pairs; `urgency` is `"low"`, `"normal"` or `"critical"`. +- `$XDG_RUNTIME_DIR/notifyd/` holds `queue.json`, `history.json`, (written by the daemon), `drawer` (written by the drawer) and `snooze` (written by `notify-snooze.sh`). +- Suppression lives here, not in the daemon: balloons are withheld by `dnd` (low and normal only) and by snooze (all). The drawer's reserved space lists everything. +- Reusing a `Process` needs `running = false` immediately before `running = true`. +- No em dashes. No home paths in committed files; `~` in documentation only. Nerd Font glyphs are written as `\uXXXX` and their bytes verified with `git diff`. +- Smoke check, harness owns the process and reads the log, never a later `pgrep`: + +```bash +timeout 8 qs -p <dir> 2>&1 | grep -E 'ERROR|TypeError|ReferenceError|is not defined|Cannot assign|Unable to assign' && echo "ERRORS ABOVE" || echo "clean" +``` + +Expected: `clean`. + +## Verified Facts (from Plan 1 and this repo) + +- The daemon is installed as `~/bin/notifyd` and is running; `notifyctl` and `notify-snooze.sh` are in `~/bin`. `notifyctl list` prints the live queue as JSON. +- `expire_timeout` direction is the freedesktop one: `-1` takes the urgency default, `0` means never. +- `Theme` carries `base surface text subtext red green yellow surfaceAlt overlay accent`, plus `fontFamily`, `fontSize` (16) and `iconFamily`. +- `Drawer.qml:131` already has an empty `Item { id: notifications }` documented as "Reserved for the notification engine", anchored above the grid inside the grid view. +- `Status.qml` (the registry singleton) is symlinked into `desktop/`; `Status.dnd` and `Status.presentation` are booleans. +- The Hyprland blur rules live in `~/.config/hypr/sections/decorations.lua`; each quickshell layer needs its own rule matched on its namespace. +- `custom/notification.jsonc` and `waybar/scripts/notifications.py` reference `dunstctl` but are not in the live waybar config; they are dead and not part of this plan. + +--- + +## File Structure + + shared/Notify.qml the shared singleton (create) + desktop/Notify.qml symlink to it (create) + notifications/ the balloon component (create) + shell.qml ShellRoot, keepalive, Balloons + Balloons.qml the stack and the suppression filter + NotificationBalloon.qml one balloon + notify-actions.sh the rofi action picker + Theme.qml, Status.qml, Notify.qml symlinks into ../shared (create) + README.md component notes (create) + desktop/Drawer.qml reserved space, history view, drawer flag (modify) + desktop/NotificationList.qml the reserved space's list (create) + desktop/NotificationRow.qml one row, live or history (create) + desktop/NotificationHistory.qml the history page body (create) + desktop/modules/status/SnoozeRow.qml the snooze control (create) + desktop/modules/status/StatusPage.qml add the snooze row (modify) + AGENTS.md traps (modify) + +--- + +### Task 1: The shared Notify singleton + +**Files:** +- Create: `shared/Notify.qml` +- Create: `desktop/Notify.qml` (symlink) + +**Interfaces:** +- Consumes: `Quickshell.Io.FileView`, `Quickshell.Io.Process`, `notifyctl` on PATH. +- Produces: singleton `Notify` with `readonly property var queue`, `readonly property var history`, `readonly property bool drawerOpen`, `readonly property double snoozeUntil`, and the functions `close(id)`, `closeAll()`, `action(id, key)`, `actions(id, pairs)`, `clearHistory()`. Every later task uses these. + +- [ ] **Step 1: Write `shared/Notify.qml`** + +```qml +// Copyright (C) 2026 Danilo M. <danix@danix.xyz> +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 2 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +pragma Singleton + +import Quickshell +import Quickshell.Io +import QtQuick + +// The notification daemon's state, read from the files it publishes under +// $XDG_RUNTIME_DIR/notifyd/. The files are the interface, the same convention +// the status registry set: the daemon writes them, this reads them, and +// notifyctl is the one path back for a mutation. Nothing here speaks D-Bus. +// +// A shell restart loses nothing: the daemon keeps running, the files stay, and +// this singleton reads them back. +Singleton { + id: root + + readonly property string dir: (Quickshell.env("XDG_RUNTIME_DIR") || "/tmp") + "/notifyd" + + // Parsed whole on every change. Malformed or missing JSON is treated as + // empty rather than propagated: a renderer with a bad array is worse than + // a renderer that briefly shows nothing. + property var queue: [] + property var history: [] + + // Written by the drawer, read here so the balloons know to stand down. + property bool drawerOpen: false + + // Epoch milliseconds; 0 means not snoozing. + property double snoozeUntil: 0 + + function parseQueue() { + try { root.queue = JSON.parse(queueFile.text() || "[]"); } + catch (e) { root.queue = []; } + } + + function parseHistory() { + try { root.history = JSON.parse(historyFile.text() || "[]"); } + catch (e) { root.history = []; } + } + + // Mutations go through notifyctl, the only thing that talks to the daemon. + function run(args) { + ctl.command = ["notifyctl"].concat(args); + ctl.running = false; + ctl.running = true; + } + function close(id) { root.run(["close", String(id)]); } + function closeAll() { root.run(["close-all"]); } + function action(id, key) { root.run(["action", String(id), key]); } + function clearHistory() { root.run(["clear-history"]); } + + // The rofi picker lives in the notifications component; a caller passes + // the notification id and its [key, label] pairs. Only that component + // uses this, but the singleton owns the Process so there is one place a + // notifyctl-adjacent command is built. + function actions(id, pairs) { + const cmd = ["notify-actions.sh", String(id)]; + for (const pair of pairs) { + cmd.push(pair[1]); + cmd.push(pair[0]); + } + actProc.command = cmd; + actProc.running = false; + actProc.running = true; + } + + Process { id: ctl; printErrors: false } + Process { id: actProc; printErrors: false } + + FileView { + id: queueFile + path: root.dir + "/queue.json" + watchChanges: true + printErrors: false + onFileChanged: reload() + onLoaded: root.parseQueue() + onLoadFailed: root.queue = [] + } + + FileView { + id: historyFile + path: root.dir + "/history.json" + watchChanges: true + printErrors: false + onFileChanged: reload() + onLoaded: root.parseHistory() + onLoadFailed: root.history = [] + } + + FileView { + id: drawerFile + path: root.dir + "/drawer" + watchChanges: true + printErrors: false + onFileChanged: reload() + onLoaded: root.drawerOpen = drawerFile.text().trim() === "1" + onLoadFailed: root.drawerOpen = false + } + + FileView { + id: snoozeFile + path: root.dir + "/snooze" + watchChanges: true + printErrors: false + onFileChanged: reload() + onLoaded: { + const v = parseInt(snoozeFile.text().trim(), 10); + root.snoozeUntil = isNaN(v) ? 0 : v * 1000; + } + onLoadFailed: root.snoozeUntil = 0 + } +} +``` + +- [ ] **Step 2: Symlink it into the drawer** + +```bash +ln -s ../shared/Notify.qml desktop/Notify.qml +ls -l desktop/Notify.qml +``` + +Expected: `desktop/Notify.qml -> ../shared/Notify.qml`. + +- [ ] **Step 3: Smoke check that the singleton parses** + +```bash +timeout 8 qs -p ./desktop 2>&1 | grep -E 'ERROR|TypeError|ReferenceError|is not defined|Cannot assign|Unable to assign' && echo "ERRORS ABOVE" || echo "clean" +``` + +Expected: `clean`. The daemon is running, so the files exist; nothing references the singleton yet. + +- [ ] **Step 4: Commit** + +```bash +git add shared/Notify.qml desktop/Notify.qml +git commit -m "feat(desktop): add the Notify singleton + +The daemon publishes its queue, history, drawer flag and snooze as files; +this reads them for both renderers, the same files-are-the-interface +convention the status registry set. Mutations and the rofi action picker run +notifyctl through a Process, which is the one path back to the daemon." +``` + +--- + +### Task 2: The balloon shell + +**Files:** +- Create: `notifications/shell.qml` +- Create: `notifications/Balloons.qml` +- Create: `notifications/NotificationBalloon.qml` +- Create: `notifications/Theme.qml`, `notifications/Status.qml`, `notifications/Notify.qml` (symlinks) +- Create: `notifications/README.md` + +**Interfaces:** +- Consumes: `Notify`, `Status`, `Theme`. +- Produces: a running component that draws one balloon per unsuppressed live notification, bottom-right of `DP-1`. Task 3 adds interaction; this task draws. + +- [ ] **Step 1: Create the component directory and its symlinks** + +```bash +mkdir -p notifications +ln -s ../shared/Theme.qml notifications/Theme.qml +ln -s ../shared/Status.qml notifications/Status.qml +ln -s ../shared/Notify.qml notifications/Notify.qml +ls -l notifications/ +``` + +Expected: three symlinks into `../shared`. + +- [ ] **Step 2: Write `notifications/shell.qml`** + +```qml +// Copyright (C) 2026 Danilo M. <danix@danix.xyz> +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 2 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +import Quickshell +import Quickshell.Wayland + +ShellRoot { + // Quickshell exits once no window is visible, and the balloons are + // hidden whenever the queue is empty, so this keeps the shell alive. See + // AGENTS.md. + PanelWindow { + visible: true + implicitWidth: 1 + implicitHeight: 1 + color: "transparent" + exclusionMode: ExclusionMode.Ignore + mask: Region {} + WlrLayershell.keyboardFocus: WlrKeyboardFocus.None + } + + Balloons {} +} +``` + +- [ ] **Step 3: Write `notifications/Balloons.qml`** + +```qml +// Copyright (C) 2026 Danilo M. <danix@danix.xyz> +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 2 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +import Quickshell +import Quickshell.Wayland +import QtQuick + +// The balloon stack, bottom-right of DP-1, over conky. +Scope { + id: root + + // A tick drives both expiry and the end of a snooze without waiting for a + // file change. 250ms keeps ronema's -t 1 near-instant. + property double now: Date.now() + Timer { + interval: 250 + running: true + repeat: true + onTriggered: root.now = Date.now() + } + + // Which live notifications draw here. Suppression is deliberately here + // and not in the daemon: the drawer lists a notification DND chose not to + // pop, because a list the user opened is not an interruption. + readonly property var visible: (Notify.queue || []).filter(p => { + if (Notify.drawerOpen) return false; + if (p.expires !== 0 && root.now >= p.expires) return false; + if (Notify.snoozeUntil > root.now) return false; + if (Status.dnd && p.urgency !== "critical") return false; + return true; + }) + + PanelWindow { + id: win + + visible: root.visible.length > 0 + screen: Quickshell.screens.find(s => s.name === "DP-1") ?? Quickshell.screens[0] + anchors { bottom: true; right: true } + margins { bottom: 12; right: 12 } + implicitWidth: 340 + implicitHeight: column.implicitHeight + color: "transparent" + exclusionMode: ExclusionMode.Ignore + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.namespace: "quickshell-notifications" + WlrLayershell.keyboardFocus: WlrKeyboardFocus.None + + Column { + id: column + width: parent.width + anchors { bottom: parent.bottom; right: parent.right } + spacing: 8 + + Repeater { + model: root.visible + NotificationBalloon { notification: modelData } + } + } + } +} +``` + +- [ ] **Step 4: Write `notifications/NotificationBalloon.qml`** + +```qml +// Copyright (C) 2026 Danilo M. <danix@danix.xyz> +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 2 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +import QtQuick + +// One balloon: icon at the left, app, summary and body, an X, and the click +// targets. The body is markup, which is why the daemon advertises body-markup. +Rectangle { + id: b + + required property var notification + + width: parent ? parent.width : 340 + implicitHeight: texts.implicitHeight + 20 + radius: 10 + color: Qt.alpha(Theme.base, 0.82) + border.width: 1 + border.color: Qt.alpha(Theme.text, 0.12) + + // The background click target is declared first so the X, declared later, + // sits above it and wins its corner. + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.LeftButton | Qt.RightButton + cursorShape: Qt.PointingHandCursor + onClicked: mouse => { + if (mouse.button === Qt.RightButton) { + Notify.closeAll(); + return; + } + const acts = b.notification.actions || []; + const inert = b.notification.expires !== 0 && Date.now() >= b.notification.expires; + if (acts.length > 0 && !inert) Notify.actions(b.notification.id, acts); + else Notify.close(b.notification.id); + } + } + + Image { + id: icon + visible: b.notification.icon !== "" && b.notification.icon !== undefined + anchors { left: parent.left; top: parent.top; margins: 10 } + width: 32 + height: 32 + source: visible ? "file://" + b.notification.icon : "" + sourceSize { width: 64; height: 64 } + } + + Text { + id: close + anchors { right: parent.right; top: parent.top; margins: 6 } + width: 20 + height: 20 + text: "\uf00d" + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + font { family: Theme.iconFamily; pixelSize: 11 } + color: closeArea.containsMouse ? Theme.red : Theme.subtext + + MouseArea { + id: closeArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: Notify.close(b.notification.id) + } + } + + Column { + id: texts + anchors { + left: icon.visible ? icon.right : parent.left + leftMargin: 10 + right: parent.right + rightMargin: 10 + top: parent.top + topMargin: 10 + } + spacing: 2 + + Text { + width: parent.width + text: b.notification.app || "" + elide: Text.ElideRight + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4; bold: true } + color: Theme.subtext + } + + Text { + width: parent.width + text: b.notification.summary || "" + elide: Text.ElideRight + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } + color: Theme.text + } + + Text { + width: parent.width + visible: text !== "" + text: b.notification.body || "" + textFormat: Text.RichText + wrapMode: Text.WordWrap + maximumLineCount: 3 + elide: Text.ElideRight + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + color: Theme.subtext + } + } +} +``` + +- [ ] **Step 5: Write `notifications/README.md`** + +```markdown +# notifications + +The balloon renderer for the notification daemon (`notifyd`, a separate repo). +It reads the daemon's published files through the `Notify` singleton and draws +one balloon per live notification, bottom-right of `DP-1`. + +## Suppression lives here + +The daemon does not know about DND or snooze. This component withholds +balloons: `status.dnd` suppresses low and normal, `notifyd/snooze` suppresses +everything. The drawer's reserved space lists every live notification anyway. + +## The files are the interface + + $XDG_RUNTIME_DIR/notifyd/queue.json the live queue + $XDG_RUNTIME_DIR/notifyd/history.json the ring of 20 + $XDG_RUNTIME_DIR/notifyd/drawer "1" while the drawer holds the space + $XDG_RUNTIME_DIR/notifyd/snooze an epoch second while snoozing + +`notifyctl` and `notify-snooze.sh` are in `~/bin`; without them the balloons +draw but close and actions do nothing. + +## Blur + +Hyprland blurs a layer surface only when a rule names its namespace. This +component sets `quickshell-notifications`; the rule is in +`~/.config/hypr/sections/decorations.lua`. +``` + +- [ ] **Step 6: Smoke check** + +```bash +timeout 8 qs -p ./notifications 2>&1 | grep -E 'ERROR|TypeError|ReferenceError|is not defined|Cannot assign|Unable to assign' && echo "ERRORS ABOVE" || echo "clean" +``` + +Expected: `clean`. (This briefly starts a second copy; it dies with the timeout.) + +- [ ] **Step 7: Commit** + +```bash +git add notifications/ +git commit -m "feat(notifications): add the balloon shell + +Reads the daemon's queue through the Notify singleton and draws a balloon +per live notification, bottom-right of DP-1 over conky. Suppression is here, +not in the daemon: dnd withholds low and normal, snooze withholds all, and +the drawer still lists them." +``` + +--- + +### Task 3: Balloon interactions + +**Files:** +- Create: `notifications/notify-actions.sh` + +**Interfaces:** +- Consumes: `Notify.actions`, `Notify.close`, `Notify.closeAll`. +- Produces: `notify-actions.sh <id> <label> <key> [label key ...]`, which reads a selection from rofi and runs `notifyctl action`. + +The click handlers were written in Task 2; this task supplies the picker they call. + +- [ ] **Step 1: Write `notifications/notify-actions.sh`** + +```bash +#!/bin/bash +# +# Copyright (C) 2026 Danilo M. <danix@danix.xyz> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# Pick one of a notification's actions with rofi and invoke it. The renderer +# passes the labels and keys as arguments, so nothing here parses JSON. +# +# notify-actions.sh <id> <label> <key> [label key ...] + +set -u + +[[ $# -ge 3 ]] || { echo "usage: ${0##*/} <id> <label> <key> ..." >&2; exit 2; } + +id="$1"; shift +labels=() +keys=() +while [[ $# -ge 2 ]]; do + labels+=("$1") + keys+=("$2") + shift 2 +done + +choice="$(printf '%s\n' "${labels[@]}" | rofi -dmenu -i -p "Notification")" +[[ -n "$choice" ]] || exit 0 + +for i in "${!labels[@]}"; do + if [[ "${labels[$i]}" == "$choice" ]]; then + notifyctl action "$id" "${keys[$i]}" + exit $? + fi +done +``` + +- [ ] **Step 2: Make it runnable, install it, and smoke check** + +The `Notify.actions` helper runs it from PATH, so it is installed beside `notifyctl`: + +```bash +chmod +x notifications/notify-actions.sh +install -m 755 notifications/notify-actions.sh ~/bin/notify-actions.sh +command -v notify-actions.sh +timeout 8 qs -p ./notifications 2>&1 | grep -E 'ERROR|TypeError|ReferenceError|is not defined|Cannot assign|Unable to assign' && echo "ERRORS ABOVE" || echo "clean" +``` + +Expected: the resolved path, and `clean`. + +- [ ] **Step 3: Confirm by hand** + +Ask the user to run the component (`qs -p notifications`) and send a mail-shaped notification with an action, then click its balloon body and confirm rofi lists the action and choosing it opens the target: + +```bash +notify-send -a test -u normal -A default=open "Action test" "click the body" +``` + +Expected: rofi appears with `open`; choosing it invokes the action. An X closes the notification, a right click closes all. + +- [ ] **Step 4: Commit** + +```bash +git add notifications/notify-actions.sh +git commit -m "feat(notifications): add the rofi action picker + +The renderer passes the action labels and keys as arguments, so the picker +parses no JSON; a chosen label maps to its key and runs notifyctl action." +``` + +--- + +### Task 4: The drawer's reserved space + +**Files:** +- Modify: `desktop/Drawer.qml` (the reserved `Item`, and the drawer flag write) +- Create: `desktop/NotificationList.qml` +- Create: `desktop/NotificationRow.qml` + +**Interfaces:** +- Consumes: `Notify`, `Theme`. +- Produces: `NotificationList` with a `history` signal; `NotificationRow` with a `required property var notification` and a `live` boolean. Task 5 uses the signal. + +- [ ] **Step 1: Write `desktop/NotificationRow.qml`** + +```qml +// Copyright (C) 2026 Danilo M. <danix@danix.xyz> +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 2 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +import QtQuick + +// One notification in the drawer: app, summary and body. Live rows close with +// the X and click through to their actions; a history row is inert. +Item { + id: row + + required property var notification + // A history row has no live client: no X, and a click does nothing. + property bool live: true + + readonly property bool inert: row.notification.expires !== 0 && Date.now() >= row.notification.expires + + implicitHeight: texts.implicitHeight + 16 + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { + if (!row.live) return; + const acts = row.notification.actions || []; + if (acts.length > 0 && !row.inert) Notify.actions(row.notification.id, acts); + else Notify.close(row.notification.id); + } + } + + Column { + id: texts + anchors { + left: parent.left + right: closeBtn.visible ? closeBtn.left : parent.right + rightMargin: 10 + verticalCenter: parent.verticalCenter + } + spacing: 2 + + Text { + width: parent.width + text: (row.notification.app || "") + (row.notification.summary ? " " + row.notification.summary : "") + elide: Text.ElideRight + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + color: Theme.text + } + + Text { + width: parent.width + visible: text !== "" + text: row.notification.body || "" + textFormat: Text.RichText + wrapMode: Text.WordWrap + maximumLineCount: 2 + elide: Text.ElideRight + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 5 } + color: Theme.subtext + } + } + + Text { + id: closeBtn + visible: row.live + anchors { right: parent.right; verticalCenter: parent.verticalCenter } + width: 20 + height: 20 + text: "\uf00d" + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + font { family: Theme.iconFamily; pixelSize: 11 } + color: closeArea.containsMouse ? Theme.red : Theme.subtext + + MouseArea { + id: closeArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: Notify.close(row.notification.id) + } + } +} +``` + +- [ ] **Step 2: Write `desktop/NotificationList.qml`** + +```qml +// Copyright (C) 2026 Danilo M. <danix@danix.xyz> +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 2 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +import QtQuick + +// The drawer's reserved notification space: a header with a History button, +// then the live queue, scrollable because the queue can hold 20 and the grid +// below is fixed. The grid owns its own position, so this space only fills +// what the grid leaves. +Item { + id: list + + signal history + + Column { + anchors.fill: parent + spacing: 6 + + Item { + width: parent.width + height: 28 + + Text { + anchors { left: parent.left; verticalCenter: parent.verticalCenter } + text: "Notifications" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4; bold: true } + color: Theme.subtext + } + + Text { + id: historyBtn + anchors { right: parent.right; verticalCenter: parent.verticalCenter } + text: "History" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + color: historyArea.containsMouse ? Theme.accent : Theme.subtext + + MouseArea { + id: historyArea + anchors.fill: parent + anchors.margins: -6 + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: list.history() + } + } + } + + Flickable { + id: flick + width: parent.width + height: parent.height - 34 + clip: true + contentWidth: width + contentHeight: col.implicitHeight + boundsBehavior: Flickable.StopAtBounds + + Column { + id: col + width: flick.width + spacing: 2 + + Repeater { + model: Notify.queue + NotificationRow { + required property var modelData + width: col.width + notification: modelData + live: true + } + } + + Text { + width: col.width + visible: Notify.queue.length === 0 + text: "No notifications" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + color: Theme.overlay + } + } + } + } +} +``` + +- [ ] **Step 3: Fill the reserved space and write the drawer flag in `desktop/Drawer.qml`** + +Replace the reserved `Item` block: + +```qml + // Reserved for the notification engine. An empty Item that + // claims the space rather than a placeholder graphic: the + // grid has to sit where it will sit once notifications + // arrive, or the layout is tuned against a position that + // does not survive. + Item { + id: notifications + anchors { top: parent.top; left: parent.left; right: parent.right } + anchors.bottom: grid.top + anchors.bottomMargin: 16 + } +``` + +with: + +```qml + // The notification engine's space. The live queue lists + // here while the drawer is open; the balloons stand down + // because the drawer flag below is set. + NotificationList { + id: notifications + anchors { top: parent.top; left: parent.left; right: parent.right } + anchors.bottom: grid.top + anchors.bottomMargin: 16 + onHistory: root.history = true + } +``` + +Add the imports at the top (the file imports `Quickshell`, `Quickshell.Wayland`, `QtQuick`): add `import Quickshell.Io`. + +Add the drawer flag and the open/close writes. After the `Scope { id: root ... }` properties, add: + +```qml + // The balloons read this to stand down while the drawer holds the space. + FileView { + id: drawerFlag + path: (Quickshell.env("XDG_RUNTIME_DIR") || "/tmp") + "/notifyd/drawer" + atomicWrites: true + printErrors: false + } + + onOpenChanged: drawerFlag.setText(root.open ? "1\n" : "0\n") +``` + +- [ ] **Step 4: Smoke check** + +```bash +timeout 8 qs -p ./desktop 2>&1 | grep -E 'ERROR|TypeError|ReferenceError|is not defined|Cannot assign|Unable to assign' && echo "ERRORS ABOVE" || echo "clean" +``` + +Expected: `clean`. + +- [ ] **Step 5: Confirm by hand** + +Ask the user to restart the drawer, open it, and confirm: the reserved space above the grid shows the live queue, its History button is present (inert until Task 5), a notification sent while the drawer is open appears there and **not** as a balloon, and closing it with the X removes it to history. Then with the drawer closed, confirm a balloon appears again. + +- [ ] **Step 6: Commit** + +```bash +git add desktop/Drawer.qml desktop/NotificationList.qml desktop/NotificationRow.qml +git commit -m "feat(desktop): fill the drawer's notification space + +The reserved Item becomes the live queue with a History button, scrollable +because the queue can hold 20. The drawer writes notifyd/drawer so the +balloon shell stands down while this space is showing, which is what stops a +notification appearing twice." +``` + +--- + +### Task 5: The history page + +**Files:** +- Create: `desktop/NotificationHistory.qml` +- Modify: `desktop/Drawer.qml` (a history view and the `history` property) + +**Interfaces:** +- Consumes: `Notify.history`, `Notify.clearHistory`, `NotificationRow`. +- Produces: a drawer view reachable from the reserved space's History button. This plan ships a clear-all only; see the deviation note in Task 6's README. + +- [ ] **Step 1: Write `desktop/NotificationHistory.qml`** + +```qml +// Copyright (C) 2026 Danilo M. <danix@danix.xyz> +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 2 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +import QtQuick + +// The history ring: the last 20 dismissed or evicted notifications, newest +// first, with a clear all. Rows are inert; only clear-all removes them, since +// the daemon has no per-id history deletion. +Column { + id: page + + spacing: 4 + + Item { + width: parent.width + height: 28 + + Text { + anchors { left: parent.left; verticalCenter: parent.verticalCenter } + text: Notify.history.length + " in history" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + color: Theme.subtext + } + + Text { + id: clearBtn + anchors { right: parent.right; verticalCenter: parent.verticalCenter } + visible: Notify.history.length > 0 + text: "Clear all" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + color: clearArea.containsMouse ? Theme.red : Theme.subtext + + MouseArea { + id: clearArea + anchors.fill: parent + anchors.margins: -6 + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: Notify.clearHistory() + } + } + } + + Repeater { + model: Notify.history + + NotificationRow { + required property var modelData + width: page.width + notification: modelData + live: false + } + } + + Text { + width: parent.width + visible: Notify.history.length === 0 + text: "Nothing in history" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + color: Theme.overlay + } +} +``` + +- [ ] **Step 2: Add the history view to `desktop/Drawer.qml`** + +Add to the root, beside `property string page`: + +```qml + // The history page is reachable only from the reserved space, so it is not + // a module and has no tile: a boolean, reset when the drawer closes. + property bool history: false +``` + +In `close()`, add `root.history = false;` after `root.page = "";`. + +In the grid view's `visible:` condition, add the history guard so the grid does not show through the history page (the page has no opaque background): + +```qml + visible: root.page === "" && !root.history +``` + +Add the history loader after the page view Loader: + +```qml + // --- history view --- + + Loader { + id: historyLoader + anchors.fill: parent + anchors.margins: 16 + active: root.history + sourceComponent: Component { + Page { + title: "History" + NotificationHistory { width: parent.width } + } + } + onLoaded: if (item && item.back) item.back.connect(() => root.history = false) + } +``` + +The `Page` and `Theme` names resolve through the root directory's own types, the same way the module page loader uses `Page`. + +- [ ] **Step 3: Also let Escape leave history** + +In the panel's `Keys.onEscapePressed`, change: + +```qml + Keys.onEscapePressed: { + if (root.page) root.page = ""; + else root.close(); + } +``` + +to: + +```qml + Keys.onEscapePressed: { + if (root.history) root.history = false; + else if (root.page) root.page = ""; + else root.close(); + } +``` + +- [ ] **Step 4: Smoke check** + +```bash +timeout 8 qs -p ./desktop 2>&1 | grep -E 'ERROR|TypeError|ReferenceError|is not defined|Cannot assign|Unable to assign' && echo "ERRORS ABOVE" || echo "clean" +``` + +Expected: `clean`. + +- [ ] **Step 5: Confirm by hand** + +Ask the user to open the drawer, click History, and confirm the ring lists closed notifications newest-first with a clear all that empties it, that the back arrow and Escape return to the grid, and that a notification closed in the reserved space then appears in history. + +- [ ] **Step 6: Commit** + +```bash +git add desktop/Drawer.qml desktop/NotificationHistory.qml +git commit -m "feat(desktop): add the notification history page + +Reachable only from the reserved space's History button, so it is a drawer +view, not a module and not a tile. Rows are inert; clear-all empties the +ring, since the daemon has no per-id history deletion." +``` + +--- + +### Task 6: The Status page snooze row + +**Files:** +- Create: `desktop/modules/status/SnoozeRow.qml` +- Modify: `desktop/modules/status/StatusPage.qml` + +**Interfaces:** +- Consumes: `Notify.snoozeUntil`, `notify-snooze.sh`, the shared `Switch`, `Theme`, and the last-used file `~/.local/state/notify-snooze.minutes`. +- Produces: a snooze control on the Status page. The balloon shell already reads `Notify.snoozeUntil`, so this only drives the file. + +- [ ] **Step 1: Write `desktop/modules/status/SnoozeRow.qml`** + +```qml +// Copyright (C) 2026 Danilo M. <danix@danix.xyz> +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 2 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +import Quickshell +import Quickshell.Io +import QtQuick +import QtQuick.Controls +import "../.." + +// A switch plus a minutes field. On snoozes for the typed minutes through +// notify-snooze.sh; off clears. The last used value is persisted by the +// script, so the field prefills from it. +Item { + id: row + + // A tick so the switch reflects the snooze ending on its own. + property double now: Date.now() + Timer { + interval: 1000 + running: true + repeat: true + onTriggered: row.now = Date.now() + } + + readonly property bool active: Notify.snoozeUntil > row.now + + implicitHeight: Math.max(texts.implicitHeight, sw.implicitHeight) + 16 + + function apply(on) { + if (on) { + const n = parseInt(minutes.text, 10); + snooze.command = ["notify-snooze.sh", String(isNaN(n) || n < 1 ? 30 : n)]; + } else { + snooze.command = ["notify-snooze.sh", "off"]; + } + snooze.running = false; + snooze.running = true; + } + + Process { id: snooze; printErrors: false } + + FileView { + id: lastUsed + path: `${Quickshell.env("HOME")}/.local/state/notify-snooze.minutes` + printErrors: false + onLoaded: minutes.text = text().trim() + onLoadFailed: minutes.text = "30" + } + + Column { + id: texts + anchors { + left: parent.left + right: controls.left + rightMargin: 12 + verticalCenter: parent.verticalCenter + } + spacing: 2 + + Text { + text: "Snooze" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: true } + color: Theme.text + } + + Text { + width: parent.width + wrapMode: Text.WordWrap + text: row.active ? "All notification balloons are held until snooze ends." + : "Hold every notification balloon for a number of minutes." + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + color: Theme.subtext + } + } + + Row { + id: controls + anchors { right: parent.right; verticalCenter: parent.verticalCenter } + spacing: 8 + + TextField { + id: minutes + width: 48 + height: 28 + text: "30" + horizontalAlignment: TextInput.AlignHCenter + color: Theme.text + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + background: Rectangle { + radius: 6 + color: Qt.alpha(Theme.surface, 0.9) + border.width: 1 + border.color: Qt.alpha(Theme.text, 0.15) + } + validator: IntValidator { bottom: 1; top: 1440 } + } + + Switch { + id: sw + checked: row.active + onToggled: row.apply(!row.active) + } + } + + onNowChanged: sw.checked = row.active +} +``` + +- [ ] **Step 2: Add the row to `desktop/modules/status/StatusPage.qml`** + +Append after the presentation row: + +```qml + Rectangle { + width: page.width + height: 1 + color: Qt.alpha(Theme.text, 0.08) + } + + SnoozeRow { + width: page.width + } +``` + +- [ ] **Step 3: Smoke check** + +```bash +timeout 8 qs -p ./desktop 2>&1 | grep -E 'ERROR|TypeError|ReferenceError|is not defined|Cannot assign|Unable to assign' && echo "ERRORS ABOVE" || echo "clean" +``` + +Expected: `clean`. + +- [ ] **Step 4: Confirm by hand** + +Ask the user to open Status. Confirm: the Snooze row has a minutes field and a switch; typing `2` and toggling on sets the switch and writes `notifyd/snooze` about two minutes out; sending a notification draws no balloon while snoozed but still appears in the reserved space; `notify-snooze.sh off` or the switch off ends it; reopening the page after a shell restart shows the last used minutes. + +- [ ] **Step 5: Commit** + +```bash +git add desktop/modules/status/SnoozeRow.qml desktop/modules/status/StatusPage.qml +git commit -m "feat(status): add the snooze row + +A switch and a free-text minutes field driving notify-snooze.sh. Snooze is a +file the balloon shell reads, so the row only writes it; the switch follows +the file, including a snooze that ends while the page is open." +``` + +--- + +### Task 7: Wire it up and record the traps + +**Files:** +- Modify: `notifications/README.md` +- Modify: `AGENTS.md` +- Modify outside the repo: `~/.config/hypr/sections/decorations.lua`, `~/.config/hypr/sections/autostart.lua` (user steps) + +**Interfaces:** +- Consumes: the finished component. +- Produces: nothing executable. + +- [ ] **Step 1: Ask the user to add the blur rule** + +Hyprland blurs a layer surface only when a rule names its namespace. Ask the user to append to `~/.config/hypr/sections/decorations.lua`: + +```lua +-- Frosted glass for the quickshell notification balloons. +hl.layer_rule({ + name = "blur-notifications", + match = { namespace = "^(quickshell-notifications)$" }, + blur = true, + xray = false, + ignore_alpha = 0.1, +}) +``` + +Then `hyprctl reload`. + +- [ ] **Step 2: Ask the user to add the component to autostart** + +In `~/.config/hypr/sections/autostart.lua`, beside the other quickshell lines, using the same absolute prefix those lines use: + +```lua + hl.exec_cmd("qs -p ~/Programming/GIT/quickshell/notifications") +``` + +- [ ] **Step 3: Add the deviation note to `notifications/README.md`** + +Append: + +```markdown +## History rows + +The spec calls history rows closable individually. `notifyctl` has no per-id +history delete, only `clear-history`, so the history page ships a clear all +and inert rows. The daemon verb and the renderer row it needs are tracked in +the `notifyd` repo's `TODO.md`; when that ships, the row's X is wired to +`notifyctl history-remove`. +``` + +- [ ] **Step 4: Add the traps to `AGENTS.md`** + +Append to the per-component notes list: + +```markdown +- **The notification daemon is a separate process that owns the D-Bus name.** + The quickshell side only reads its published files under + `$XDG_RUNTIME_DIR/notifyd/`; the files are the interface, and `notifyctl` is + the one path back. `notifications/` is inert without it: balloons still + draw, but close and actions do nothing. +- **Suppression is in the balloon shell, not the daemon.** `status.dnd` + withholds low and normal balloons and `notifyd/snooze` withholds all, but + the drawer's reserved space lists everything, because a list the user opened + is not an interruption. +- **A notification appears as a balloon or in the drawer's reserved space, + never both.** The drawer writes `notifyd/drawer` and the balloon shell reads + it; `Drawer.qml`'s reserved `Item` is the space. +- **The dead `waybar/modules/custom/notification.jsonc` and + `waybar/scripts/notifications.py` still shell out to `dunstctl`.** They are + not in the live waybar config; do not resurrect them. +``` + +- [ ] **Step 5: Run every check** + +```bash +timeout 8 qs -p ./desktop 2>&1 | grep -E 'ERROR|TypeError|ReferenceError|is not defined|Cannot assign|Unable to assign' && echo "ERRORS ABOVE" || echo "clean" +timeout 8 qs -p ./notifications 2>&1 | grep -E 'ERROR|TypeError|ReferenceError|is not defined|Cannot assign|Unable to assign' && echo "ERRORS ABOVE" || echo "clean" +bash desktop/modules/status/test-statusctl.sh +``` + +Expected: both `clean`; statusctl `9 passed, 0 failed`. + +- [ ] **Step 6: Confirm the process count** + +```bash +pgrep -cx qs +``` + +Expected: the shells the user runs, one more than before if `notifications/` is running (normally four: desktop, appearance, window-switcher, notifications). + +- [ ] **Step 7: Final visual pass** + +Ask the user to confirm the whole loop: a balloon bottom-right on DP-1 with an icon and markup body; opening the drawer replaces it with a row in the reserved space; the History button opens the ring; DND still pops critical only while listing both; snooze holds every balloon; and a mail notification's action opens through rofi. + +- [ ] **Step 8: Commit** + +```bash +git add notifications/README.md AGENTS.md +git commit -m "docs(notifications): document the renderers and record the traps + +The daemon is a separate process and the files are the interface; suppression +lives in the balloon shell so the drawer can list what DND held back; and the +drawer flag is what stops a notification appearing as both a balloon and a +row." +``` + +--- + +## Notes for the implementer + +**Suppression is a filter, not daemon state.** DND and snooze withhold a balloon; they never stop the notification reaching the queue or history. If you move the filter into the daemon, the drawer loses the items DND held back, which is the behaviour the spec rejected. + +**The drawer flag is the whole no-double-show mechanism.** `Drawer.qml` writes `notifyd/drawer`; `Balloons.qml` reads `Notify.drawerOpen`. Do not add a second source of truth. + +**`Notify.actions` passes pairs as flattened arguments.** A pair from the JSON is `[key, label]`; the picker takes `<label> <key>`. Getting that order backwards makes rofi display the key and invoke the label. + +**A missing published file is normal at first start.** Every `FileView` here has an `onLoadFailed` that means empty or off, never an error: the daemon writes `[]` on its own start, but a renderer may win the race. diff --git a/docs/superpowers/plans/2026-09-15-notifyd-images.md b/docs/superpowers/plans/2026-09-15-notifyd-images.md new file mode 100644 index 0000000..104e566 --- /dev/null +++ b/docs/superpowers/plans/2026-09-15-notifyd-images.md @@ -0,0 +1,1000 @@ +# notifyd Image Support 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:** The daemon accepts the freedesktop image hints, publishes a content image path for every notification, resolves app_icon / image-path theme names to files, and cleans up what it owns. + +**Architecture:** Image hints are parsed as pure functions in `internal/notify`, decoded and encoded to PNG with the standard library, and written under `$XDG_RUNTIME_DIR/notifyd/img/`. `Popup` gains an `image` field that the renderers read. A theme-name resolver reads qt6ct (then GTK3, then hicolor) and searches the XDG icon directories. The store gains an injected cleanup callback so a daemon-owned image is unlinked when its notification leaves the live queue. + +**Tech Stack:** Go, `github.com/godbus/dbus/v5`, the standard library (`image`, `image/png`), `dbus-run-session` for the integration test. + +**Spec:** `docs/superpowers/specs/2026-09-15-notification-images-design.md` (in the quickshell repo; read it before starting). The renderer half is a separate plan. + +## Global Constraints + +- Go module path `danix.xyz/notifyd`. The only third-party dependency is `github.com/godbus/dbus/v5`; everything else is the standard library. +- GPLv2 only. Every new `.go` file begins with the standard per-file header notice (copy it from `policy.go`). +- The published contract is exact: `Popup` gains `"image"` (a path, empty when none); `created` and `expires` stay epoch milliseconds. +- `GetCapabilities` becomes `actions`, `body-markup`, `body-images`, `icon-static`, `persistence`. +- The spec's image priority is `image-data`, then `image-path`, then the deprecated `icon_data`; `app_icon` stays the icon, not a fallback image. +- `image-data` / `icon_data` are a D-Bus `(iiibiiay)` struct: width, height, rowstride, has_alpha, bits_per_sample, channels, data (RGB byte order). +- Theme source is qt6ct `icon_theme`, then GTK3 `gtk-icon-theme-name`, then `hicolor`. Search `$XDG_DATA_HOME/icons` then `$XDG_DATA_DIRS/icons`. +- No home paths in committed files. `gofmt` clean. `go vet ./...` clean. +- Test commands: `go test ./...` for pure logic; `dbus-run-session -- go test ./internal/notify` for the bus test; `bash test-notifyctl.sh` for the end to end check. +- Work in the `notifyd` repo (`~/Programming/GIT/notifyd`), not the quickshell repo. + +--- + +## File Structure + + internal/notify/image.go image hint parsing and PNG encoding (create) + internal/notify/image_test.go + internal/notify/icons.go theme-name resolution to an icon file (create) + internal/notify/icons_test.go + internal/notify/policy.go add image hint entry points (modify) + internal/notify/store.go Popup.Image and the removal callback (modify) + internal/notify/store_test.go + internal/notify/files.go image directory and PNG write (modify) + internal/notify/files_test.go + internal/notify/service.go capabilities, materialisation, wiring (modify) + internal/notify/service_test.go + test-notifyctl.sh assert the image field survives publish (modify) + +--- + +### Task 1: Image hint parsing and PNG encoding + +**Files:** +- Create: `internal/notify/image.go` +- Test: `internal/notify/image_test.go` + +**Interfaces:** +- Consumes: `github.com/godbus/dbus/v5`. +- Produces: `type RawImage struct { Width, Height, RowStride int; HasAlpha bool; BitsPerSample, Channels int; Data []byte }`; `ImageDataFromHints(hints map[string]dbus.Variant) (*RawImage, bool)`; `ImagePathFromHints(hints map[string]dbus.Variant) (string, bool)`; `(*RawImage) PNG() ([]byte, error)`. Tasks 3 and 4 use these. + +- [ ] **Step 1: Write the failing test** + +Create `internal/notify/image_test.go`: + +```go +// Copyright (C) 2026 Danilo M. <danix@danix.xyz> +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 2 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +package notify + +import ( + "bytes" + "image/png" + "testing" + + "github.com/godbus/dbus/v5" +) + +// rawVariant builds the (iiibiiay) struct the bus delivers for image-data. +func rawVariant(w, h, stride int, alpha bool, ch int, data []byte) dbus.Variant { + return dbus.MakeVariant([]interface{}{ + int32(w), int32(h), int32(stride), alpha, int32(8), int32(ch), data, + }) +} + +func TestImageDataFromHints(t *testing.T) { + // 2x1 RGBA: red, green. + rgba := []byte{255, 0, 0, 255, 0, 255, 0, 255} + cases := []struct { + name string + hints map[string]dbus.Variant + wantW int + want bool + }{ + {"image-data wins", map[string]dbus.Variant{ + "image-data": rawVariant(2, 1, 8, true, 4, rgba), + "image-path": dbus.MakeVariant("/tmp/x.png"), + }, 2, true}, + {"icon_data fallback", map[string]dbus.Variant{ + "icon_data": rawVariant(2, 1, 8, true, 4, rgba), + }, 2, true}, + {"image_data alias", map[string]dbus.Variant{ + "image_data": rawVariant(2, 1, 8, true, 4, rgba), + }, 2, true}, + {"absent", map[string]dbus.Variant{}, 0, false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got, ok := ImageDataFromHints(c.hints) + if ok != c.want { + t.Fatalf("ok = %v, want %v", ok, c.want) + } + if ok && got.Width != c.wantW { + t.Fatalf("width = %d, want %d", got.Width, c.wantW) + } + }) + } +} + +func TestImagePathFromHints(t *testing.T) { + got, ok := ImagePathFromHints(map[string]dbus.Variant{"image-path": dbus.MakeVariant("/tmp/shot.png")}) + if !ok || got != "/tmp/shot.png" { + t.Fatalf("got %q ok=%v", got, ok) + } + if _, ok := ImagePathFromHints(map[string]dbus.Variant{}); ok { + t.Fatal("empty hints must not report a path") + } +} + +func TestRawImagePNG(t *testing.T) { + // 2x1 RGBA on a rowstride wider than the data, to prove stride is honoured. + r := &RawImage{Width: 2, Height: 1, RowStride: 12, HasAlpha: true, BitsPerSample: 8, Channels: 4, + Data: []byte{255, 0, 0, 255, 0, 255, 0, 255, 9, 9, 9, 9}} + data, err := r.PNG() + if err != nil { + t.Fatalf("PNG: %v", err) + } + img, err := png.Decode(bytes.NewReader(data)) + if err != nil { + t.Fatalf("decode: %v", err) + } + if img.Bounds().Dx() != 2 || img.Bounds().Dy() != 1 { + t.Fatalf("bounds = %v", img.Bounds()) + } + r0, g0, b0, a0 := img.At(0, 0).RGBA() + if r0>>8 != 255 || g0>>8 != 0 || b0>>8 != 0 || a0>>8 != 255 { + t.Fatalf("pixel 0 = %d %d %d %d", r0>>8, g0>>8, b0>>8, a0>>8) + } +} + +func TestRawImagePNGRejectsBadData(t *testing.T) { + if _, err := (&RawImage{Width: 0, Height: 1, Channels: 4, BitsPerSample: 8}).PNG(); err == nil { + t.Fatal("zero width must error") + } + if _, err := (&RawImage{Width: 1, Height: 1, Channels: 2, BitsPerSample: 8}).PNG(); err == nil { + t.Fatal("channels 2 must error") + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./internal/notify -run 'TestImage|TestRaw' -v` +Expected: FAIL with `undefined: RawImage` and the hint functions. + +- [ ] **Step 3: Write the implementation** + +Create `internal/notify/image.go`: + +```go +// Copyright (C) 2026 Danilo M. <danix@danix.xyz> +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 2 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +package notify + +import ( + "bytes" + "fmt" + "image" + "image/color" + "image/png" + + "github.com/godbus/dbus/v5" +) + +// RawImage is the spec's image-data structure (iiibiiay). Data is RGB byte +// order: 4 bytes per pixel with alpha, 3 without, and rows are RowStride +// bytes apart, which may exceed Width*Channels. +type RawImage struct { + Width int + Height int + RowStride int + HasAlpha bool + BitsPerSample int + Channels int + Data []byte +} + +// ImageDataFromHints reads the raw image struct, preferring the spec key then +// the deprecated icon_data, then the underscore alias older libnotify sent. +func ImageDataFromHints(hints map[string]dbus.Variant) (*RawImage, bool) { + for _, key := range []string{"image-data", "icon_data", "image_data"} { + v, ok := hints[key] + if !ok { + continue + } + if r, ok := rawImageFromVariant(v); ok { + return r, true + } + } + return nil, false +} + +// ImagePathFromHints reads image-path, a URI, a path, or a theme icon name. +func ImagePathFromHints(hints map[string]dbus.Variant) (string, bool) { + v, ok := hints["image-path"] + if !ok { + return "", false + } + s, ok := v.Value().(string) + if !ok || s == "" { + return "", false + } + return s, true +} + +// rawImageFromVariant accepts the []interface{} godbus yields for a struct. +// Each numeric field may arrive as int32 or int depending on the encoder. +func rawImageFromVariant(v dbus.Variant) (*RawImage, bool) { + f, ok := v.Value().([]interface{}) + if !ok || len(f) != 7 { + return nil, false + } + r := &RawImage{} + var okW, okH, okS, okC, okD bool + r.Width, okW = asInt(f[0]) + r.Height, okH = asInt(f[1]) + r.RowStride, okS = asInt(f[2]) + r.HasAlpha, _ = f[3].(bool) + r.BitsPerSample, _ = asInt(f[4]) + r.Channels, okC = asInt(f[5]) + r.Data, okD = f[6].([]byte) + if !okW || !okH || !okS || !okC || !okD { + return nil, false + } + return r, true +} + +func asInt(v any) (int, bool) { + switch n := v.(type) { + case int: + return n, true + case int32: + return int(n), true + case int64: + return int(n), true + case uint32: + return int(n), true + } + return 0, false +} + +// PNG encodes the raw pixels as a PNG the renderer can load. +func (r *RawImage) PNG() ([]byte, error) { + if r.Width <= 0 || r.Height <= 0 { + return nil, fmt.Errorf("notifyd: image %dx%d", r.Width, r.Height) + } + if r.BitsPerSample != 8 || (r.Channels != 3 && r.Channels != 4) { + return nil, fmt.Errorf("notifyd: image bits=%d channels=%d", r.BitsPerSample, r.Channels) + } + stride := r.RowStride + if stride < r.Width*r.Channels { + stride = r.Width * r.Channels + } + if len(r.Data) < stride*(r.Height-1)+r.Width*r.Channels { + return nil, fmt.Errorf("notifyd: image data short: %d bytes", len(r.Data)) + } + img := image.NewRGBA(image.Rect(0, 0, r.Width, r.Height)) + for y := 0; y < r.Height; y++ { + row := r.Data[y*stride:] + for x := 0; x < r.Width; x++ { + if r.Channels == 4 { + i := x * 4 + img.SetRGBA(x, y, color.RGBA{row[i], row[i+1], row[i+2], row[i+3]}) + } else { + i := x * 3 + img.SetRGBA(x, y, color.RGBA{row[i], row[i+1], row[i+2], 255}) + } + } + } + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + return nil, err + } + return buf.Bytes(), nil +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `go test ./internal/notify -run 'TestImage|TestRaw' -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/notify/image.go internal/notify/image_test.go +git commit -m "feat(notify): parse the image hints and encode them to PNG + +image-data (and the deprecated icon_data) is the (iiibiiay) struct; the +PNG encoder honours rowstride and both 3- and 4-channel data. The path +and data readers are pure, so the service can apply the spec's priority +and the store stays free of image handling." +``` + +--- + +### Task 2: Theme-name resolution + +**Files:** +- Create: `internal/notify/icons.go` +- Test: `internal/notify/icons_test.go` + +**Interfaces:** +- Consumes: nothing but the standard library and `os`. +- Produces: `IconThemeName() string`; `ResolveIcon(value string) string`; `XDGIconDirs() []string`. Task 4 uses both entry points. + +- [ ] **Step 1: Write the failing test** + +Create `internal/notify/icons_test.go`: + +```go +// Copyright (C) 2026 Danilo M. <danix@danix.xyz> +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 2 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +package notify + +import ( + "os" + "path/filepath" + "testing" +) + +func TestResolveIconPathAndURI(t *testing.T) { + if got := ResolveIcon("/usr/share/icons/x/apps/48/firefox.png"); got != "/usr/share/icons/x/apps/48/firefox.png" { + t.Fatalf("path passthrough: %q", got) + } + if got := ResolveIcon("file:///tmp/shot.png"); got != "/tmp/shot.png" { + t.Fatalf("uri: %q", got) + } + if got := ResolveIcon(""); got != "" { + t.Fatalf("empty: %q", got) + } +} + +func TestResolveIconThemeName(t *testing.T) { + root := t.TempDir() + // Theme "Plum" inherits "Base"; the icon is only in Base. + theme := filepath.Join(root, "icons", "Plum") + base := filepath.Join(root, "icons", "Base") + plumIndex := filepath.Join(theme, "index.theme") + baseApp := filepath.Join(base, "apps", "48") + if err := os.MkdirAll(filepath.Join(theme, "apps", "scalable"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(baseApp, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(plumIndex, []byte("[Icon Theme]\nInherits=Base\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(baseApp, "firefox.svg"), []byte("<svg/>"), 0o644); err != nil { + t.Fatal(err) + } + + t.Setenv("XDG_DATA_HOME", root) + t.Setenv("XDG_DATA_DIRS", "") + t.Setenv("HOME", filepath.Join(root, "home")) + if err := os.MkdirAll(filepath.Join(root, "home", ".config", "qt6ct"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "home", ".config", "qt6ct", "qt6ct.conf"), []byte("icon_theme=Plum\n"), 0o644); err != nil { + t.Fatal(err) + } + + got := ResolveIcon("firefox") + want := filepath.Join(root, "icons", "Base", "apps", "48", "firefox.svg") + if got != want { + t.Fatalf("resolved %q, want %q", got, want) + } + if got := ResolveIcon("no-such-icon-xyz"); got != "" { + t.Fatalf("missing name must be empty, got %q", got) + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./internal/notify -run TestResolveIcon -v` +Expected: FAIL with `undefined: ResolveIcon`. + +- [ ] **Step 3: Write the implementation** + +Create `internal/notify/icons.go`: + +```go +// Copyright (C) 2026 Danilo M. <danix@danix.xyz> +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 2 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +package notify + +import ( + "os" + "path/filepath" + "sort" + "strconv" + "strings" +) + +// IconThemeName reads the desktop's current icon theme: qt6ct is the truth on +// this desktop, then the GTK3 setting, then hicolor. A missing or empty value +// falls through. +func IconThemeName() string { + if v := iniValue(filepath.Join(homeDir(), ".config", "qt6ct", "qt6ct.conf"), "icon_theme"); v != "" { + return v + } + if v := iniValue(filepath.Join(homeDir(), ".config", "gtk-3.0", "settings.ini"), "gtk-icon-theme-name"); v != "" { + return v + } + return "hicolor" +} + +// ResolveIcon turns an app_icon or image-path value into a file path. A URI is +// trimmed, a path is returned unchanged, and a bare name is looked up in the +// icon theme. Nothing found is an empty string, which renders no image. +func ResolveIcon(value string) string { + if value == "" { + return "" + } + if strings.HasPrefix(value, "file://") { + return strings.TrimPrefix(value, "file://") + } + if strings.Contains(value, "/") { + return value + } + return lookupThemeIcon(value, IconThemeName()) +} + +// XDGIconDirs is the icon search path: the user's dir then each data dir. +func XDGIconDirs() []string { + home := os.Getenv("XDG_DATA_HOME") + if home == "" { + home = filepath.Join(homeDir(), ".local", "share") + } + dirs := []string{filepath.Join(home, "icons")} + for _, d := range filepath.SplitList(os.Getenv("XDG_DATA_DIRS")) { + if d != "" { + dirs = append(dirs, filepath.Join(d, "icons")) + } + } + if len(dirs) == 1 { + dirs = append(dirs, "/usr/local/share/icons", "/usr/share/icons") + } + return dirs +} + +// lookupThemeIcon searches the theme, then its Inherits chain, then hicolor. +func lookupThemeIcon(name, theme string) string { + seen := map[string]bool{} + for theme != "" && !seen[theme] { + seen[theme] = true + found, next := "", "" + for _, root := range XDGIconDirs() { + base := filepath.Join(root, theme) + if p := findInTheme(base, name); p != "" { + found = p + break + } + if next == "" { + next = inheritsOf(base) + } + } + if found != "" { + return found + } + theme = next + } + for _, root := range XDGIconDirs() { + if p := findInTheme(filepath.Join(root, "hicolor"), name); p != "" { + return p + } + } + return "" +} + +// findInTheme prefers scalable then the largest raster under apps/. +func findInTheme(base, name string) string { + sizes := []string{"scalable"} + matches, _ := filepath.Glob(filepath.Join(base, "apps", "[0-9]*")) + for _, m := range matches { + sizes = append(sizes, filepath.Base(m)) + } + numeric := sizes[1:] + sort.Slice(numeric, func(i, j int) bool { + a, _ := strconv.Atoi(strings.TrimSuffix(numeric[i], "@2x")) + b, _ := strconv.Atoi(strings.TrimSuffix(numeric[j], "@2x")) + return a > b + }) + for _, size := range sizes { + for _, ext := range []string{"svg", "png", "xpm"} { + p := filepath.Join(base, "apps", size, name+"."+ext) + if fileExists(p) { + return p + } + } + } + return "" +} + +func inheritsOf(base string) string { + v := iniValue(filepath.Join(base, "index.theme"), "Inherits") + if i := strings.IndexByte(v, ','); i >= 0 { + v = v[:i] + } + return strings.TrimSpace(v) +} + +func iniValue(path, key string) string { + data, err := os.ReadFile(path) + if err != nil { + return "" + } + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "#") || !strings.Contains(line, "=") { + continue + } + k, v, _ := strings.Cut(line, "=") + if strings.TrimSpace(k) == key { + return strings.TrimSpace(v) + } + } + return "" +} + +func homeDir() string { + if h := os.Getenv("HOME"); h != "" { + return h + } + return os.TempDir() +} + +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `go test ./internal/notify -run TestResolveIcon -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/notify/icons.go internal/notify/icons_test.go +git commit -m "feat(notify): resolve theme icon names to files + +qt6ct's icon_theme is authoritative on this desktop, with the GTK3 +setting and hicolor as fallbacks. The lookup prefers scalable, then the +largest raster, and follows the theme's Inherits chain, so an app that +passes a name instead of a path gets an icon." +``` + +--- + +### Task 3: The image field and its lifecycle + +**Files:** +- Modify: `internal/notify/store.go` +- Modify: `internal/notify/files.go` +- Test: `internal/notify/store_test.go` +- Test: `internal/notify/files_test.go` + +**Interfaces:** +- Consumes: `Popup` from `store.go`. +- Produces: `Popup.Image string`; `NewStore(emit, publish, removeImage)` with a third parameter `removeImage func(string)`; `(*Store) SetImage(id uint32, path string)`; `WriteImage(dir string, id uint32, data []byte) (string, error)`; `ImagesDir(dir string) string`. Task 4 wires them. + +- [ ] **Step 1: Write the failing test** + +Append to `internal/notify/store_test.go`: + +```go +func TestStoreRemovesImageOnDismissAndExpire(t *testing.T) { + var removed []string + s := NewStore(func(uint32, uint32) {}, func(_, _ []Popup) {}, func(p string) { removed = append(removed, p) }) + id, _ := s.Add(&Popup{Image: "/run/img/1.png"}, "", 0) + s.SetImage(id, "/run/img/other.png") + s.Dismiss(id, 2) + if len(removed) != 1 || removed[0] != "/run/img/other.png" { + t.Fatalf("dismiss removed %v", removed) + } +} + +func TestStoreRemovesImageOnReplace(t *testing.T) { + var removed []string + s := NewStore(func(uint32, uint32) {}, func(_, _ []Popup) {}, func(p string) { removed = append(removed, p) }) + s.Add(&Popup{Image: "/run/img/old.png"}, "tag", 0) + id, _ := s.Add(&Popup{Image: "/run/img/new.png"}, "tag", 0) + _ = id + if len(removed) != 1 || removed[0] != "/run/img/old.png" { + t.Fatalf("replace removed %v", removed) + } +} +``` + +Create `internal/notify/files_test.go` if it does not exist, else append: + +```go +func TestWriteImage(t *testing.T) { + dir := t.TempDir() + p, err := WriteImage(dir, 7, []byte("png-bytes")) + if err != nil { + t.Fatal(err) + } + if filepath.Base(p) != "7.png" { + t.Fatalf("path %q", p) + } + if b, _ := os.ReadFile(p); string(b) != "png-bytes" { + t.Fatalf("contents %q", b) + } + if got := ImagesDir(dir); got != filepath.Join(dir, "img") { + t.Fatalf("ImagesDir %q", got) + } +} +``` + +Add the needed imports (`os`, `path/filepath`) to the test files. + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/notify -run 'TestStoreRemovesImage|TestWriteImage' -v` +Expected: FAIL with `undefined: SetImage` / `WriteImage` and a NewStore arity error. + +- [ ] **Step 3: Write the implementation** + +In `internal/notify/store.go`, add the field to `Popup`: + +```go + Image string `json:"image"` +``` + +Add the callback to `Store` and `NewStore`: + +```go +type Store struct { + // ...existing fields... + removeImage func(string) +} + +func NewStore(emit func(id, reason uint32), publish func(live, history []Popup), removeImage func(string)) *Store { + return &Store{ + nextID: 1, + entries: map[uint32]*live{}, + emit: emit, + publish: publish, + removeImage: removeImage, + } +} +``` + +In `Add`, before overwriting a replaced entry, remove its daemon-owned image: + +```go + add := &live{Popup: *n, Stack: stack} + if old != nil { + if s.removeImage != nil { + s.removeImage(old.Image) + } + add.ID = old.ID + // ...unchanged... +``` + +Add `SetImage` and clean up in `removeLocked` and `Expire`: + +```go +// SetImage attaches a materialised image path to a live entry and republishes. +func (s *Store) SetImage(id uint32, path string) { + s.mu.Lock() + defer s.mu.Unlock() + n, ok := s.entries[id] + if !ok { + return + } + n.Image = path + s.publishLocked() +} +``` + +In `Expire`, after `n.Closed = true`, remove the image (the balloon is gone): + +```go + if s.removeImage != nil { + s.removeImage(n.Image) + } +``` + +In `removeLocked`, remove the image before deleting: + +```go +func (s *Store) removeLocked(id uint32) { + if n, ok := s.entries[id]; ok && s.removeImage != nil { + s.removeImage(n.Image) + } + delete(s.entries, id) + // ...unchanged... +``` + +Update the two existing `NewStore(...)` call sites in `store_test.go` to pass `nil` or a no-op as the third argument. + +In `internal/notify/files.go`, add: + +```go +// ImagesDir is where the daemon writes decoded image-data. +func ImagesDir(dir string) string { + return filepath.Join(dir, "img") +} + +// WriteImage writes a decoded image as <id>.png under the image directory. +func WriteImage(dir string, id uint32, data []byte) (string, error) { + imgDir := ImagesDir(dir) + if err := os.MkdirAll(imgDir, 0o700); err != nil { + return "", err + } + path := filepath.Join(imgDir, strconv.FormatUint(uint64(id), 10)+".png") + tmp, err := os.CreateTemp(imgDir, ".img-*") + if err != nil { + return "", err + } + tmpName := tmp.Name() + if _, err := tmp.Write(data); err != nil { + tmp.Close() + os.Remove(tmpName) + return "", err + } + if err := tmp.Close(); err != nil { + os.Remove(tmpName) + return "", err + } + if err := os.Rename(tmpName, path); err != nil { + os.Remove(tmpName) + return "", err + } + return path, nil +} +``` + +Add `strconv` to the `files.go` imports. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/notify -run 'TestStoreRemovesImage|TestWriteImage' -v` +Expected: PASS. Then run `go test ./...` and `go vet ./...`. + +- [ ] **Step 5: Commit** + +```bash +git add internal/notify/store.go internal/notify/store_test.go internal/notify/files.go internal/notify/files_test.go +git commit -m "feat(notify): add the image field and its cleanup + +Popup gains image, and the store calls an injected removeImage when an +entry is dismissed, evicted, replaced or expired, so a daemon-written +PNG does not outlive its balloon. The service decides what is +daemon-owned; the store only names the path." +``` + +--- + +### Task 4: Service materialisation and capabilities + +**Files:** +- Modify: `internal/notify/service.go` +- Test: `internal/notify/service_test.go` +- Modify: `test-notifyctl.sh` + +**Interfaces:** +- Consumes: `ImageDataFromHints`, `ImagePathFromHints`, `(*RawImage).PNG`, `ResolveIcon`, `WriteImage`, `ImagesDir`, `Store.SetImage`. +- Produces: a `Popup.Image` populated for every notification and `body-images` advertised. + +- [ ] **Step 1: Write the failing test** + +In `internal/notify/service_test.go`, add a capability assertion and an image-path assertion. The existing bus test builds a `NewService(...)`; keep its shape and add: + +```go +func TestCapabilitiesIncludeBodyImages(t *testing.T) { + caps, err := NewService(nil, t.TempDir()).GetCapabilities() + if err != nil { + t.Fatal(err) + } + found := false + for _, c := range caps { + if c == "body-images" { + found = true + } + } + if !found { + t.Fatalf("body-images missing from %v", caps) + } +} +``` + +Add a pure test that an image-path hint becomes `Popup.Image`. The test file is +`package notify`, so it builds a Service directly and captures the publish: + +```go +func TestNotifyPublishesImagePath(t *testing.T) { + var live []Popup + s := &Service{dir: t.TempDir()} + s.store = NewStore(s.emitClosed, func(l, _ []Popup) { live = l }, s.removeImage) + hints := map[string]dbus.Variant{"image-path": dbus.MakeVariant("/tmp/shot.png")} + if _, err := s.Notify("t", 0, "", "s", "b", nil, hints, -1); err != nil { + t.Fatal(err) + } + if len(live) != 1 || live[0].Image != "/tmp/shot.png" { + t.Fatalf("published %+v", live) + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./...` +Expected: FAIL with the missing capability and the missing `newServiceWithDir` if used. + +- [ ] **Step 3: Write the implementation** + +In `internal/notify/service.go`: + +```go +func (s *Service) GetCapabilities() ([]string, *dbus.Error) { + return []string{"actions", "body-markup", "body-images", "icon-static", "persistence"}, nil +} +``` + +Add the image removal hook and wire it in `NewService`: + +```go +// removeImage unlinks only what the daemon wrote, so a client's own image-path +// is never touched. +func (s *Service) removeImage(path string) { + if path == "" { + return + } + if !strings.HasPrefix(path, ImagesDir(s.dir)+string(os.PathSeparator)) { + return + } + os.Remove(path) +} +``` + +Update `NewService` to pass it: + +```go + s.store = NewStore(s.emitClosed, func(live, history []Popup) { + if err := Publish(s.dir, live, history); err != nil { + log.Printf("notifyd: publish: %v", err) + } + }, s.removeImage) +``` + +In `Start`, clear leftovers from a previous run: + +```go + if err := os.RemoveAll(ImagesDir(s.dir)); err != nil { + log.Printf("notifyd: clear images: %v", err) + } +``` + +In `Notify`, resolve the icon and the image: + +```go + n := &Popup{ + App: appName, + Summary: summary, + Body: body, + Urgency: u, + Icon: ResolveIcon(appIcon), + Actions: ParseActions(actions), + Created: now, + } + if raw, ok := ImageDataFromHints(hints); ok { + if data, err := raw.PNG(); err == nil { + // The id is assigned by Add; remember the blob and write it after. + pendingImage = data + } + } else if path, ok := ImagePathFromHints(hints); ok { + n.Image = ResolveIcon(path) + } +``` + +Then after `Add`: + +```go + id, _ := s.store.Add(n, tag, replacesID) + if pendingImage != nil { + if path, err := WriteImage(s.dir, id, pendingImage); err == nil { + s.store.SetImage(id, path) + } else { + log.Printf("notifyd: write image: %v", err) + } + } + s.arm(id, ms) + return id, nil +``` + +Declare `var pendingImage []byte` before the `Popup` literal. Add `os` and `strings` to the imports. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./... && go vet ./...` +Expected: PASS and clean. Then `dbus-run-session -- go test ./internal/notify` and `bash test-notifyctl.sh`. + +Extend `test-notifyctl.sh`. The file is structured around a `dbus-run-session` +that prints one line per observation into `$tmp/out`, then a `check label want +got` per line. Add the image-path notification and its emitted line inside the +session, after the first `notifyctl list` echo, then add the matching check and +shift the existing "list cleared" check to the third line: + +Inside the `dbus-run-session` script, change the block to: + +```bash + notify-send -a test -u normal "t1" "b1" || exit 3 + sleep 0.3 + echo "$(notifyctl list | grep -c "\"summary\": \"t1\"")" + notify-send -a test -u normal --hint=string:image-path:/tmp/x.png "t2" "b2" || exit 3 + sleep 0.3 + echo "$(notifyctl list | grep -c "\"image\": \"/tmp/x.png\"")" + notifyctl close-all + sleep 0.3 + echo "$(notifyctl list | grep -c "\"summary\": \"t1\"")" + kill $daemon +``` + +Then add and adjust the checks at the bottom of the script: + +```bash +check "list shows the notification" "1" "$(sed -n 1p "$tmp/out")" +check "image-path is published" "1" "$(sed -n 2p "$tmp/out")" +check "list clears" "0" "$(sed -n 3p "$tmp/out")" +``` + +- [ ] **Step 5: Commit** + +```bash +git add internal/notify/service.go internal/notify/service_test.go test-notifyctl.sh +git commit -m "feat(notify): materialise notification images + +Notify resolves app_icon and image-path theme names, decodes image-data to +a PNG under the image directory, and re-publishes the entry with its +image path. The daemon advertises body-images, and removeImage refuses to +touch a path outside its own directory so a client's screenshot file is +never deleted." +``` + +--- + +## Self-Review + +**Spec coverage:** hints and priority (Task 1), theme resolution including qt6ct authority and Inherits (Task 2), the `image` contract field and cleanup lifecycle (Task 3), capabilities and materialisation (Task 4). The inline-image and renderer sections belong to the renderer plan. + +**Placeholder scan:** none; every code step carries the code. + +**Type consistency:** `RawImage`, `ImageDataFromHints`, `ImagePathFromHints`, `PNG`, `ResolveIcon`, `WriteImage`, `ImagesDir`, `SetImage`, and the `NewStore` third parameter are used with the same signatures across tasks. |
