diff options
| author | Danilo M. <danix@danix.xyz> | 2026-09-15 13:31:05 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-09-15 13:31:05 +0200 |
| commit | 320813f256a7d141d130fda036baa9691aa90716 (patch) | |
| tree | fbf0f906222e3a1f52089554f60bf892d93fbd80 /docs | |
| parent | ff9612c809e609e0ca1004531523083ce857ce26 (diff) | |
| download | quickshell-320813f256a7d141d130fda036baa9691aa90716.tar.gz quickshell-320813f256a7d141d130fda036baa9691aa90716.zip | |
docs: separate a notification's balloon and drawer lifetimes
The freedesktop expire_timeout is the notification's lifetime, and --wait
and -b clients block until the daemon closes it. So the daemon emits
NotificationClosed at expiry and frees the client, but keeps the entry in
the live queue: the drawer lists it until it is dismissed or evicted, inert
because its client is gone. History holds only what was dismissed or
evicted.
The reserved space is therefore scrollable, since the live queue can hold
20 at once, and a row past its expiry is read-only.
Diffstat (limited to 'docs')
| -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 |
