diff options
Diffstat (limited to 'docs/superpowers/specs')
| -rw-r--r-- | docs/superpowers/specs/2026-09-15-notification-daemon-design.md | 81 |
1 files changed, 57 insertions, 24 deletions
diff --git a/docs/superpowers/specs/2026-09-15-notification-daemon-design.md b/docs/superpowers/specs/2026-09-15-notification-daemon-design.md index e0aba25..fa287e1 100644 --- a/docs/superpowers/specs/2026-09-15-notification-daemon-design.md +++ b/docs/superpowers/specs/2026-09-15-notification-daemon-design.md @@ -59,11 +59,22 @@ closed by a `CloseNotification` call. All of this is in the daemon and is independent of any renderer. -**Timeout.** `expire_timeout` from `Notify` is honoured exactly: `0` means use -the urgency default, `-1` means never expire, any positive value is -milliseconds. The urgency defaults are `10s` for low, `10s` for normal and -never for critical, matching the running dunst. `ronema` relies on `-t 0` -meaning never and `-t 1` meaning effectively immediate; both work unchanged. +**Timeout, and the two lifetimes.** `expire_timeout` from `Notify` is honoured +exactly as the notification's balloon lifetime: `0` means use the urgency +default, `-1` means never expire, any positive value is milliseconds. The +urgency defaults are `10s` for low, `10s` for normal and never for critical, +matching the running dunst. `ronema` relies on `-t 0` meaning never and `-t 1` +meaning effectively immediate; both work unchanged. + +At expiry the daemon emits `NotificationClosed(id, 1)` and the balloon goes, +which is exactly what `notify-send --wait` and `dunstify -b` clients are +blocked on. The notification is **not** removed from the live queue. It stays +in the drawer as an inert entry until it is dismissed or evicted. The two +lifetimes are deliberately separate: the D-Bus lifetime is the balloon's, so a +waiting client is freed on time, while the drawer's entry outlives it, so a +notification missed while the drawer was shut is still there when it opens. +An inert entry has no client left: its actions are gone, clicking it does +nothing, and only the X removes it, to history. **Replace.** A new notification replaces an existing one when its `replaces_id` matches, or when its stack tag matches. Both `x-dunst-stack-tag` (what @@ -73,11 +84,16 @@ honoured. A replace reuses the replaced notification's id and emits no expects. A `replaces_id` that matches nothing is a new notification with a new id, and `0` always means new. Replacing resets the timeout and moves the notification to the top of the stack, which is what "one notification per -account" has to mean when mail keeps arriving. +account" has to mean when mail keeps arriving. A replace that targets an +inert (already expired) entry re-arms its balloon and gives it a fresh expiry; +the id is reused, so the new client's actions work and no `NotificationClosed` +is emitted for the id it inherited. -**History.** A ring of 20, sticky: a notification enters it when it closes or -expires and is not removed by later arrivals beyond the cap. This is the -`history_length` and `sticky_history` of the running dunst. +**History.** A ring of 20, sticky. A notification enters it when it is +dismissed, in either form, or evicted from the live queue by the cap. Expiry +alone does not put it in history, because the drawer still lists it. This is +the `history_length` and `sticky_history` of the running dunst, with the popup +and the drawer separated. DND is deliberately absent here. Suppression is a display decision, so it lives in the balloon shell (see below), which lets the drawer list a notification that @@ -92,7 +108,7 @@ cleanup code. Every write is atomic (temporary file, then rename), so a reader never sees a half-written value. queue.json the live notifications, in stack order, newest first - history.json the last 20 closed notifications, newest first + history.json the last 20 dismissed or evicted notifications, newest first drawer "1" while the drawer is open, written by the drawer snooze an epoch second while snoozing; absent means off @@ -112,7 +128,8 @@ A live notification is one object: `app`, `summary` and `body` are markup. `icon` is an absolute path or empty. `actions` is the spec's key and label pairs. `expires` is an epoch second, or -`0` for a notification that never expires. The history objects are the same +`0` for a notification that never expires; a value in the past means the +balloon has gone and the entry is inert. The history objects are the same shape. `queue.json` is capped at 20; an arrival beyond the cap pushes the oldest into @@ -147,6 +164,11 @@ notification, bottom-right on `DP-1`, over conky. `exclusionMode` is `ExclusionMode.Ignore`: balloons are an overlay, not a reserved zone, and covering conky is intended. +A balloon is drawn only while the notification is inside its `expires`, so the +shell runs a timer per balloon and drops it at expiry. An entry whose time has +passed is not the shell's to draw; it is left to the drawer, whose copy of the +same queue outlives the balloon. + A balloon is the app name in bold, the summary, and the body, with the icon at the left when one is present, following the running dunst's `format` (`<b>%a</b>` then `%s` then `%b`). Progress bars, hovering, and body images are @@ -166,10 +188,17 @@ The reserved `Item` in `Drawer.qml`, already present and documented as above the grid. There is no tile and no module: the space is part of the grid view. -The reserved space has a header holding a **History** button, and one row per -live notification, sharing the balloon's content. The History button opens a -QML history page: the ring of 20, newest first, each row closable, with a clear -all. +The reserved space is a **scrollable** list, not a fixed strip. The grid below +it is fixed and never scrolled, so the reserved space absorbs the overflow: it +is bounded by the grid's top and scrolls when the live queue is long. That +matters now that an entry outlives its balloon, because the queue can hold up +to 20 at once. + +It has a header holding a **History** button, and one row per live notification, +sharing the balloon's content. A row whose balloon has expired is inert: it +still lists and still closes, but it carries no action, because its client is +gone. The History button opens a QML history page: the ring of 20, newest +first, each row closable, with a clear all. The drawer writes `notifyd/drawer` `1` on open and `0` on close. That is how the balloon shell knows to withhold its balloons while the drawer is open, so a @@ -185,9 +214,9 @@ indistinguishable from a lost notification. Identical in both forms. - The **X** on a balloon or row closes that notification (`notifyctl close`). -- **Clicking** a notification with actions opens a rofi menu of its labels; - choosing one invokes it (`notifyctl action`). A notification with no actions - closes on click. +- **Clicking** a notification with a live client and actions opens a rofi menu + of its labels; choosing one invokes it (`notifyctl action`). A notification + with no actions, or an inert one whose balloon has expired, closes on click. - **Right click** closes all (`notifyctl close-all`). Closing in either form removes it from the live queue, and it survives only in @@ -239,8 +268,10 @@ fresh history; it does not resurrect the previous run's notifications, because they are transient and their timeouts have passed. If a renderer dies, the daemon keeps its queue and the other renderer keeps -working. On restart the renderer reads the current queue. A notification that -expired while no renderer was up is simply gone, which is correct. +working. On restart the renderer reads the current queue. A notification whose +balloon expired while no renderer was up is still in the queue: the drawer +lists it as an inert entry, the balloon shell draws nothing for it, and +nothing is re-shown. `queue.json` is capped, so a renderer that is down cannot make the daemon grow without bound. @@ -260,10 +291,12 @@ the history ring. It fails if any of them break. The bus and the pixels need a person. The end to end check is: send a `notify-send`, confirm a balloon; open the drawer, confirm the same notification is in the reserved space and not also a balloon; close it in the drawer, confirm -the balloon goes too and the item is in the history page; toggle DND and confirm -low and normal balloons stop while critical still pops and the drawer still -lists; snooze and confirm nothing pops at all; click a mail notification's -action and confirm `qtmaildir` opens. +the balloon goes too and the item is in the history page; send one and let it +time out, confirm the balloon goes but the entry is still in the drawer and is +inert; send enough to overflow the reserved space and confirm it scrolls rather +than pushing the grid; toggle DND and confirm low and normal balloons stop while +critical still pops and the drawer still lists; snooze and confirm nothing pops +at all; click a mail notification's action and confirm `qtmaildir` opens. ## Deferred |
