diff options
Diffstat (limited to 'docs/superpowers/plans')
| -rw-r--r-- | docs/superpowers/plans/2026-09-15-notification-renderers.md | 1380 |
1 files changed, 1380 insertions, 0 deletions
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. |
