aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--AGENTS.md50
-rw-r--r--README.md9
-rw-r--r--docs/superpowers/plans/2026-09-13-mail-arrival-notifications.md924
-rw-r--r--docs/superpowers/specs/2026-09-13-mail-arrival-notifications-design.md194
-rw-r--r--mail-overview/Accounts.qml24
-rw-r--r--mail-overview/MailPanel.qml78
-rw-r--r--mail-overview/README.md93
-rwxr-xr-xmail-overview/mail-notify.sh295
-rwxr-xr-xmail-overview/test-mail-notify.sh148
-rw-r--r--vm-manager/README.md12
-rw-r--r--vm-manager/Virsh.qml45
-rw-r--r--vm-manager/VmPanel.qml14
-rw-r--r--window-switcher/README.md151
13 files changed, 1991 insertions, 46 deletions
diff --git a/AGENTS.md b/AGENTS.md
index 3a2e504..ee37cf7 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -8,10 +8,11 @@ Quickshell components for a Hyprland desktop, one per directory, each a
complete shell in its own right. They are not modules of a single bar: any of
them runs alone, and running one does not require the others.
- volume-osd/ volume for output and input, plus what is playing
- vm-manager/ libvirt drawer: state, live stats, snapshots
- appearance/ wallpaper picker and colour scheme switcher
- mail-overview/ notmuch unread counts per account, waybar icon and drawer
+ volume-osd/ volume for output and input, plus what is playing
+ vm-manager/ libvirt drawer: state, live stats, snapshots
+ appearance/ wallpaper picker and colour scheme switcher
+ mail-overview/ notmuch unread counts per account, waybar icon and drawer
+ window-switcher/ open windows as live previews in a grid, on ALT+TAB
They are started from `~/.config/hypr/sections/autostart.lua` and keep running
for the whole session.
@@ -102,6 +103,40 @@ changing that component. The ones that generalise:
`block.allocation` is qcow2 growth on the host, not usage inside the guest.
The real numbers come from qemu-guest-agent, and the panel shows a dash
rather than substituting the host-side ones.
+- **A managed save makes `virsh start` a restore, and nothing in `domstats`
+ says so.** A shut-off VM carrying a saved memory image fails to start with
+ `unable to execute QEMU command 'migrate-incoming'` every time, and the
+ state reads a plain `shut off`. The shut-off *reason* is no help either: it
+ reads `failed`, from the failed start, not from the save. `virsh list
+ --all --managed-save` prints `saved` in the state column but not with
+ `--name`, which is the form the panel lists with, so detection is `virsh
+ dominfo <vm>` grepped for `Managed save: yes`, polled per VM the way the
+ agent rows are. It measured 6ms.
+- **Hyprland 0.56.2 evaluates dispatch arguments as Lua.** `dispatch
+ focuswindow address:0x...` is a syntax error rather than a command, and it
+ fails silently unless stderr is read. The working form is `dispatch
+ hl.dsp.focus({ window = "address:0x..." })`. This is why
+ `~/bin/hypr-windows.sh` is written the way it is, and its syntax was misread
+ as legacy oddity worth modernising.
+- **A focus dispatched while an overlay holds the keyboard is accepted and then
+ ignored.** A layer surface with `keyboardFocus: Exclusive` grabs the
+ keyboard, and the compositor will not move window focus out from under that
+ grab. The dispatch reports `ok`, nothing moves, and no log line says
+ otherwise. Closing in the same turn does not help either, because a `close()`
+ that clears a property leaves the surface alive until the frame after. Focus
+ after the overlay is really gone, which `window-switcher` does with a 60ms
+ timer.
+- **`HyprlandToplevel` has no `focusHistoryID` property.** It reads
+ `undefined`, so a sort on it compares `NaN` and silently does nothing,
+ leaving a plausible looking list in arbitrary order. The value is on
+ `lastIpcObject`.
+- **`HyprlandToplevel.address` omits the `0x`** that `hyprctl clients` prints
+ and that every dispatch requires.
+- **`Hyprland.toplevels` reads 0 until `refreshToplevels()` is called.**
+- **An uncaptured `ScreencopyView` reports `sourceSize` of `QSize(-1, -1)`**,
+ not `(0, 0)`. An aspect ratio guard has to test for a positive height: the
+ obvious rewrites, `!== 0` or a truthiness check, all pass on `-1` and produce
+ a negative ratio.
## Theme
@@ -128,7 +163,7 @@ they never had. A symlink rather than a shared import path because a singleton
outside the config directory needs a `qmldir`, which is the same friction that
keeps the palette parsed rather than imported; quickshell follows the link and
resolves the singleton with no qmldir and no consumer change. Editing any
-component's `Theme.qml` edits all four. Do not replace a link with a copy.
+component's `Theme.qml` edits all five. Do not replace a link with a copy.
## Blur
@@ -144,9 +179,12 @@ A panel that should sit below waybar rather than over it wants
exclusive zone without the component knowing the bar's height. Measured with
`hyprctl layers`: waybar at `y=-540 h=42`, a `Normal` overlay on the same
screen at `y=-498 h=1038`, starting exactly where the bar ends, so the
-backdrop never dims it. `mail-overview` does this; the other three use
+backdrop never dims it. `mail-overview` does this; the other four use
`ExclusionMode.Ignore` and cover the whole screen.
+`window-switcher` is the fifth, with namespace `quickshell-window-switcher` and
+a `blur-window-switcher` rule of its own.
+
## Reloading
| | How |
diff --git a/README.md b/README.md
index 1dffab2..a74de8a 100644
--- a/README.md
+++ b/README.md
@@ -13,10 +13,11 @@ repos stay independent, this one has no build-time dependency on that one.
## Implementations
- volume-osd/ on-screen display for output and input volume
- vm-manager/ libvirt VM drawer: state, live stats, snapshots
- appearance/ wallpaper picker and colour scheme switcher
- mail-overview/ unread mail per account, as a waybar count and a drawer
+ volume-osd/ on-screen display for output and input volume
+ vm-manager/ libvirt VM drawer: state, live stats, snapshots
+ appearance/ wallpaper picker and colour scheme switcher
+ mail-overview/ unread mail per account, as a waybar count and a drawer
+ window-switcher/ open windows as live previews in a grid, on ALT+TAB
Each directory has its own README covering what it does and how to run it.
diff --git a/docs/superpowers/plans/2026-09-13-mail-arrival-notifications.md b/docs/superpowers/plans/2026-09-13-mail-arrival-notifications.md
new file mode 100644
index 0000000..02f35f8
--- /dev/null
+++ b/docs/superpowers/plans/2026-09-13-mail-arrival-notifications.md
@@ -0,0 +1,924 @@
+# Mail Arrival Notifications 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:** One desktop notification per mail account per arriving batch, naming the senders and subjects, triggered by notmuch database commits.
+
+**Architecture:** A standalone bash script, `mail-overview/mail-notify.sh`, watches the notmuch Xapian directory with `inotifywait` and on each commit asks notmuch what changed since the last revision it saw. It notifies per account via `dunstify`, storing its position in a small state file. It runs as its own process from `autostart.lua`, not inside quickshell and not inside the waybar module.
+
+**Tech Stack:** bash, notmuch 0.39 (`--lastmod`, `lastmod:` queries), `inotifywait` (inotify-tools), `dunstify` (dunst 1.12.2), `jq`.
+
+**Design spec:** `docs/superpowers/specs/2026-09-13-mail-arrival-notifications-design.md`
+
+---
+
+## Testing approach, and why it is not a framework
+
+This repo has no test framework and should not gain one. Per the project and
+global conventions, non-trivial logic leaves behind **one runnable check**: a
+single bash script, `mail-overview/test-mail-notify.sh`, asserting against
+fixture data. No pytest, no fixtures directory, no harness.
+
+The check is real TDD: each task writes its assertion first, watches it fail,
+then implements. The assertions run against **pure functions** that take text
+on stdin or as arguments and print to stdout. That is the single most
+important structural decision in this plan: **no function that this test
+touches may call notmuch, dunstify, or inotify.** Those are named in one
+place each, at the edges, so everything with logic in it stays testable
+without mail arriving.
+
+`set -u` everywhere, matching `waybar-mail.sh`. Not `set -e`: this script must
+survive a failing notmuch call and carry on to the next account, which is the
+same reasoning `Accounts.qml` documents for its per-account loop.
+
+---
+
+## File Structure
+
+| File | Responsibility |
+| --- | --- |
+| `mail-overview/mail-notify.sh` (create) | Everything: parse accounts, read/write state, query notmuch, build bodies, send notifications, watch loop. One file, ~180 lines. |
+| `mail-overview/test-mail-notify.sh` (create) | The one runnable check. Sources the script with `MAIL_NOTIFY_LIB=1` and asserts on the pure functions. |
+| `mail-overview/README.md` (modify) | A section documenting the notifier, matching the existing prose style. |
+| `~/.config/hypr/sections/autostart.lua` (modify, outside repo) | One `hl.exec_cmd` line. Not committed; this repo holds no home paths. |
+
+**Why one script rather than several.** It is a single sequential job with no
+reusable parts: an abstraction between "parse config" and "send notification"
+would have exactly one caller each. The project convention is single-file
+scripts where practical, and `waybar-mail.sh` sets the precedent at 69 lines.
+
+**The library guard.** The script ends with a `main` dispatch that only runs
+when `MAIL_NOTIFY_LIB` is unset, so the test can source it for its functions
+without starting a watch loop. This is the standard bash equivalent of
+Python's `if __name__ == "__main__"`.
+
+---
+
+## Task 1: Skeleton, library guard, and the test harness
+
+Creates both files, establishes the sourcing contract, and proves the test
+runs. No mail logic yet.
+
+**Files:**
+- Create: `mail-overview/mail-notify.sh`
+- Create: `mail-overview/test-mail-notify.sh`
+
+- [ ] **Step 1: Write the failing test**
+
+Create `mail-overview/test-mail-notify.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.
+#
+# The one runnable check for mail-notify.sh. No framework: it sources the
+# script as a library and asserts on the pure functions, the ones that take
+# text and print text. Nothing here calls notmuch, dunstify or inotify, which
+# is why it runs in milliseconds and needs no mail to arrive.
+#
+# Usage: ./test-mail-notify.sh (exit 0 = all passed)
+
+set -u
+
+MAIL_NOTIFY_LIB=1 . "$(dirname "$0")/mail-notify.sh"
+
+pass=0
+fail=0
+
+# Compares two strings and reports. Multi-line values are printed with their
+# newlines intact, because half the assertions here are about line structure.
+check() {
+ local name="$1" want="$2" got="$3"
+ if [[ "$want" == "$got" ]]; then
+ pass=$((pass + 1))
+ else
+ fail=$((fail + 1))
+ printf 'FAIL: %s\n want: %s\n got: %s\n' "$name" "$want" "$got"
+ fi
+}
+
+check "library guard does not run main" "loaded" "loaded"
+
+printf '\n%d passed, %d failed\n' "$pass" "$fail"
+[[ "$fail" -eq 0 ]]
+```
+
+- [ ] **Step 2: Run it to verify it fails**
+
+```bash
+chmod +x mail-overview/test-mail-notify.sh
+./mail-overview/test-mail-notify.sh
+```
+
+Expected: FAIL, `mail-notify.sh: No such file or directory`.
+
+- [ ] **Step 3: Write the minimal implementation**
+
+Create `mail-overview/mail-notify.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.
+#
+# Notifies when mail arrives: one notification per account per batch, naming
+# the newest senders and subjects.
+#
+# Watches the notmuch Xapian directory and, on each commit, asks notmuch what
+# changed since the revision it last saw. A commit is NOT the same as new
+# mail, since reading and tagging also commit, which is why the revision
+# counter does the work rather than a count delta.
+#
+# This is its own process rather than part of waybar-mail.sh, which already
+# has the same arrival edge: waybar owns that process, so a bar restart would
+# stop notifications with nothing reporting it.
+#
+# Usage:
+# mail-notify.sh watch forever (what autostart runs)
+# mail-notify.sh --once process one tick and exit (what the test drives)
+
+set -u
+
+STATE="${MAIL_NOTIFY_STATE:-$HOME/.local/state/mail-notify.lastmod}"
+CONFIG="${MAIL_NOTIFY_CONFIG:-$HOME/.config/qtmaildir/qtmaildir.conf}"
+SCOPE='tag:unread and tag:inbox'
+
+# How many threads a notification body lists before eliding into "+N more".
+# Three matches the drawer's own --limit=3.
+ROWS=3
+
+main() {
+ echo "not implemented"
+}
+
+# Sourced by the test with MAIL_NOTIFY_LIB set, which must not start a watch
+# loop. The bash equivalent of Python's __name__ == "__main__".
+[[ -n "${MAIL_NOTIFY_LIB:-}" ]] || main "$@"
+```
+
+- [ ] **Step 4: Run it to verify it passes**
+
+```bash
+chmod +x mail-overview/mail-notify.sh
+./mail-overview/test-mail-notify.sh
+```
+
+Expected: PASS, `1 passed, 0 failed`, exit 0. Nothing prints `not implemented`,
+which is the proof the guard works.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add mail-overview/mail-notify.sh mail-overview/test-mail-notify.sh
+git commit -m "feat(mail-overview): skeleton for the arrival notifier
+
+A library guard so the test can source the script for its pure
+functions without starting a watch loop, and the check itself,
+which is one bash script rather than a framework."
+```
+
+---
+
+## Task 2: Parse accounts from qtmaildir.conf
+
+The key-to-bracket and walk-lines traps both live here. They are the two
+failure modes that already cost this component a debugging session each, per
+`Accounts.qml` and the README.
+
+**Files:**
+- Modify: `mail-overview/mail-notify.sh`
+- Modify: `mail-overview/test-mail-notify.sh`
+
+- [ ] **Step 1: Write the failing test**
+
+Add to `test-mail-notify.sh`, immediately before the `printf '\n%d passed'`
+summary line:
+
+```bash
+# A config with the two traps in it: a key containing dots, and a folder
+# value containing a bracket. Both are real shapes from qtmaildir.conf, with
+# placeholder names.
+read -r -d '' fixture <<'EOF'
+[general]
+theme = dark
+
+[account.simple]
+label = Simple
+color = #112233
+
+[account.provider-first.last]
+folder = [Gmail]/Bozze
+label = Dotted
+color = #445566
+
+[account.nolabel]
+color = #778899
+
+[ui]
+label = NotAnAccount
+EOF
+
+check "keys run to the bracket, not the first dot" \
+ "simple provider-first.last nolabel" \
+ "$(parse_accounts <<<"$fixture" | cut -f1 | tr '\n' ' ' | sed 's/ $//')"
+
+check "a bracket in a value does not end the section" \
+ "Dotted" \
+ "$(parse_accounts <<<"$fixture" | awk -F'\t' '$1=="provider-first.last"{print $2}')"
+
+check "a missing label falls back to the key" \
+ "nolabel" \
+ "$(parse_accounts <<<"$fixture" | awk -F'\t' '$1=="nolabel"{print $2}')"
+
+check "a non-account section is not an account" \
+ "" \
+ "$(parse_accounts <<<"$fixture" | awk -F'\t' '$1=="ui"{print $2}')"
+```
+
+- [ ] **Step 2: Run it to verify it fails**
+
+```bash
+./mail-overview/test-mail-notify.sh
+```
+
+Expected: FAIL on all four, `parse_accounts: command not found`.
+
+- [ ] **Step 3: Write the minimal implementation**
+
+Add to `mail-notify.sh`, after the `ROWS=3` line:
+
+```bash
+# Accounts in file order, one "key<TAB>label" line each, read from stdin.
+#
+# Two things here are load-bearing, and both have already broken this
+# component once:
+#
+# The key runs to the closing bracket, NOT to the first dot. Real keys
+# contain dots, so splitting on the first one yields a notmuch tag matching
+# nothing and an account that silently never notifies.
+#
+# And this walks lines rather than matching a section body as "everything up
+# to the next [". Accounts have folders named like [Gmail]/Bozze, which ends
+# the body before its label and makes the account display its raw key.
+parse_accounts() {
+ local line key label
+ key=""
+ label=""
+
+ while IFS= read -r line || [[ -n "$line" ]]; do
+ if [[ "$line" =~ ^\[account\.([^]]+)\] ]]; then
+ [[ -n "$key" ]] && printf '%s\t%s\n' "$key" "${label:-$key}"
+ key="${BASH_REMATCH[1]}"
+ label=""
+ continue
+ fi
+ # Any other section ends the current account.
+ if [[ "$line" =~ ^\[ ]]; then
+ [[ -n "$key" ]] && printf '%s\t%s\n' "$key" "${label:-$key}"
+ key=""
+ label=""
+ continue
+ fi
+ [[ -n "$key" ]] || continue
+ if [[ "$line" =~ ^[[:space:]]*label[[:space:]]*=[[:space:]]*(.*)$ ]]; then
+ label="${BASH_REMATCH[1]}"
+ # Trailing whitespace only; a label may contain spaces.
+ label="${label%"${label##*[![:space:]]}"}"
+ fi
+ done
+
+ [[ -n "$key" ]] && printf '%s\t%s\n' "$key" "${label:-$key}"
+ return 0
+}
+```
+
+- [ ] **Step 4: Run it to verify it passes**
+
+```bash
+./mail-overview/test-mail-notify.sh
+```
+
+Expected: PASS, `5 passed, 0 failed`.
+
+- [ ] **Step 5: Verify against the real config**
+
+```bash
+MAIL_NOTIFY_LIB=1 . ./mail-overview/mail-notify.sh
+parse_accounts < ~/.config/qtmaildir/qtmaildir.conf
+```
+
+Expected: five lines, each `key<TAB>label`, every label a real label and none
+falling back to a raw key. Confirms the fixture matches reality.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add mail-overview/mail-notify.sh mail-overview/test-mail-notify.sh
+git commit -m "feat(mail-overview): parse accounts for the notifier
+
+Same source the drawer parses, so adding an account in qtmaildir
+notifies with no edit here. Keys run to the closing bracket because
+real ones contain dots, and parsing walks lines because a folder
+named [Gmail]/Bozze ends a section body early."
+```
+
+---
+
+## Task 3: Build the notification body
+
+Pure text transformation, the part most worth testing and the part that never
+touches notmuch.
+
+**Files:**
+- Modify: `mail-overview/mail-notify.sh`
+- Modify: `mail-overview/test-mail-notify.sh`
+
+- [ ] **Step 1: Write the failing test**
+
+Add to `test-mail-notify.sh` before the summary:
+
+```bash
+# The shape notmuch search --format=json actually returns, trimmed to the two
+# fields the body uses.
+rows_json='[
+ {"authors":"Alice Example","subject":"First subject"},
+ {"authors":"Bob Example","subject":"Second subject"},
+ {"authors":"Carol Example","subject":"Third subject"}
+]'
+
+check "a body lists author and subject per row" \
+ "Alice Example — First subject
+Bob Example — Second subject
+Carol Example — Third subject" \
+ "$(build_body "$rows_json" 3)"
+
+check "a batch bigger than the rows shown is elided" \
+ "Alice Example — First subject
+Bob Example — Second subject
+Carol Example — Third subject
++7 more" \
+ "$(build_body "$rows_json" 10)"
+
+check "markup characters are escaped, not rendered" \
+ "A &amp; B — &lt;script&gt;" \
+ "$(build_body '[{"authors":"A & B","subject":"<script>"}]' 1)"
+
+check "a missing subject says so rather than printing nothing" \
+ "Alice Example — (no subject)" \
+ "$(build_body '[{"authors":"Alice Example","subject":null}]' 1)"
+
+check "malformed json yields an empty body rather than an error" \
+ "" \
+ "$(build_body 'not json at all' 1 2>/dev/null)"
+```
+
+- [ ] **Step 2: Run it to verify it fails**
+
+```bash
+./mail-overview/test-mail-notify.sh
+```
+
+Expected: FAIL on all five, `build_body: command not found`.
+
+- [ ] **Step 3: Write the minimal implementation**
+
+Add to `mail-notify.sh` after `parse_accounts`:
+
+```bash
+# Renders notmuch search JSON into notification body text.
+# $1 the JSON array from `notmuch search --format=json`
+# $2 the true total for this batch, which may exceed the rows present
+#
+# dunst has body-markup in its capabilities, so a subject containing < or &
+# would be parsed as markup and could vanish from the notification. Subjects
+# are attacker-controlled text arriving from the internet, so the three XML
+# characters are escaped here. This is the one place in this script where
+# untrusted text reaches a renderer.
+#
+# Malformed JSON prints nothing and succeeds. A notification with no body is
+# still worth sending: the summary already carries the account and the count.
+build_body() {
+ local json="$1" total="$2" shown
+
+ local body
+ body="$(printf '%s' "$json" | jq -r '
+ .[] | ((.authors // "(unknown)") + " — " + (.subject // "(no subject)"))
+ | gsub("&"; "&amp;") | gsub("<"; "&lt;") | gsub(">"; "&gt;")
+ ' 2>/dev/null)" || return 0
+ [[ -n "$body" ]] || return 0
+
+ shown="$(printf '%s\n' "$body" | wc -l)"
+ printf '%s' "$body"
+ if [[ "$total" -gt "$shown" ]]; then
+ printf '\n+%d more' "$((total - shown))"
+ fi
+ printf '\n'
+}
+```
+
+Note the `&` escapes: writing `&amp;` directly inside a `jq` replacement
+string is fine, but `&` in some `gsub` implementations is a back-reference, so
+the escape keeps it literal regardless.
+
+- [ ] **Step 4: Run it to verify it passes**
+
+```bash
+./mail-overview/test-mail-notify.sh
+```
+
+Expected: PASS, `10 passed, 0 failed`.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add mail-overview/mail-notify.sh mail-overview/test-mail-notify.sh
+git commit -m "feat(mail-overview): build notification bodies
+
+Three rows of author and subject, then +N more, which bounds the
+height so a mailing list burst is not a wall. Markup characters are
+escaped because dunst renders body markup and a subject is untrusted
+text off the internet."
+```
+
+---
+
+## Task 4: Read and write the state file
+
+The UUID rule and the silent-seed rule both live here. Both exist to prevent a
+notification storm.
+
+**Files:**
+- Modify: `mail-overview/mail-notify.sh`
+- Modify: `mail-overview/test-mail-notify.sh`
+
+- [ ] **Step 1: Write the failing test**
+
+Add to `test-mail-notify.sh` before the summary:
+
+```bash
+# A scratch state file, removed at exit. Never the real one: that is the
+# user's live notification position, and a test must not move it.
+tmpstate="$(mktemp)"
+trap 'rm -f "$tmpstate"' EXIT
+STATE="$tmpstate"
+
+rm -f "$tmpstate"
+check "a missing state file reports no previous revision" \
+ "" "$(read_prev_rev "uuid-a")"
+
+write_state "uuid-a" 500
+check "a revision written is a revision read back" \
+ "500" "$(read_prev_rev "uuid-a")"
+
+check "a different database UUID discards the revision" \
+ "" "$(read_prev_rev "uuid-b")"
+
+printf 'garbage not a state file\n' > "$tmpstate"
+check "an unparseable state file reports no previous revision" \
+ "" "$(read_prev_rev "uuid-a")"
+
+write_state "uuid-a" 600
+check "a rewrite replaces rather than appends" \
+ "1" "$(wc -l < "$tmpstate")"
+```
+
+- [ ] **Step 2: Run it to verify it fails**
+
+```bash
+./mail-overview/test-mail-notify.sh
+```
+
+Expected: FAIL, `read_prev_rev: command not found`.
+
+- [ ] **Step 3: Write the minimal implementation**
+
+Add to `mail-notify.sh` after `build_body`:
+
+```bash
+# The last revision this script notified up to, or empty when there is none
+# to trust. Empty means "seed silently": record where we are now and notify
+# nothing.
+#
+# The stored UUID is checked because notmuch revisions are only comparable
+# within one database. A rebuilt database restarts the counter, so an old
+# revision would be meaningless, and treating it as a floor would either
+# notify nothing forever or notify everything at once.
+read_prev_rev() {
+ local want_uuid="$1" got_uuid rev
+
+ [[ -f "$STATE" ]] || return 0
+ read -r got_uuid rev < "$STATE" 2>/dev/null || return 0
+
+ [[ "$got_uuid" == "$want_uuid" ]] || return 0
+ [[ "$rev" =~ ^[0-9]+$ ]] || return 0
+
+ printf '%s' "$rev"
+}
+
+# Written by atomic replace, the same idiom mail-watcher uses for its
+# heartbeat: a reader must never see a half-written file, and mv within a
+# directory is atomic where a redirect into the final path is not.
+#
+# Failure to write is deliberately not fatal. The notifications have already
+# been sent; taking the watcher down over a failure to record that would turn
+# a bookkeeping problem into a no-mail-notifications problem.
+write_state() {
+ local uuid="$1" rev="$2" tmp
+
+ mkdir -p "$(dirname "$STATE")" 2>/dev/null || return 0
+ tmp="$(mktemp "${STATE}.XXXXXX")" || return 0
+ printf '%s %s\n' "$uuid" "$rev" > "$tmp" || { rm -f "$tmp"; return 0; }
+ mv -f "$tmp" "$STATE" 2>/dev/null || rm -f "$tmp"
+ return 0
+}
+```
+
+- [ ] **Step 4: Run it to verify it passes**
+
+```bash
+./mail-overview/test-mail-notify.sh
+```
+
+Expected: PASS, `15 passed, 0 failed`.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add mail-overview/mail-notify.sh mail-overview/test-mail-notify.sh
+git commit -m "feat(mail-overview): track the notified revision
+
+Stores the database UUID beside the revision, because notmuch
+revisions only compare within one database and a rebuild restarts
+the counter. Missing, corrupt or mismatched state reports nothing,
+which the caller treats as seed-silently: with no floor, lastmod:0..
+matches every unread message and startup becomes a wall of popups."
+```
+
+---
+
+## Task 5: The tick, the notification, and the watch loop
+
+Wires the pure functions to notmuch, dunstify and inotify. These are the
+edges, each named in exactly one place.
+
+**Files:**
+- Modify: `mail-overview/mail-notify.sh`
+
+- [ ] **Step 1: Write the implementation**
+
+Replace the `main() { echo "not implemented"; }` stub in `mail-notify.sh`
+with everything below:
+
+```bash
+# Sends one notification for one account.
+#
+# dunstify rather than notify-send because actions need it. A stack tag per
+# account means a second batch for the same account replaces the first rather
+# than stacking, which is what "one notification per account" has to mean when
+# mail keeps arriving.
+#
+# Normal urgency and an explicit 10s timeout, deliberately not -u critical:
+# on most dunst configurations critical notifications never expire, which
+# would leave mail popups on screen until clicked.
+#
+# The click cannot open the account it belongs to. qtmaildir accepts no
+# command line arguments and startup_account is a static config setting, not
+# a flag, which is the same limitation the drawer's thread rows already have.
+notify_account() {
+ local label="$1" key="$2" count="$3" body="$4"
+
+ if ! command -v dunstify >/dev/null 2>&1; then
+ # No actions available, but a notification without a click is still
+ # worth having.
+ notify-send -a mail-overview -u normal -t 10000 \
+ "$label · $count new" "$body"
+ return 0
+ fi
+
+ # Backgrounded because -b blocks until the notification is dismissed or
+ # clicked. Without this the loop would stall for the full timeout on
+ # every account, and a five-account batch would take most of a minute.
+ (
+ if [[ "$(dunstify -a mail-overview -u normal -t 10000 -b \
+ -h "string:x-dunst-stack-tag:mail-$key" \
+ -A "default,open" \
+ "$label · $count new" "$body")" == "default" ]]; then
+ "$HOME/bin/qtmaildir" &
+ fi
+ ) >/dev/null 2>&1 &
+}
+
+# One pass: what has arrived since the revision we last saw.
+tick() {
+ local lastmod uuid cur prev
+
+ # Three tab-separated fields: count, database UUID, revision. Verified on
+ # notmuch 0.39.
+ lastmod="$(notmuch count --lastmod "$SCOPE" 2>/dev/null)" || return 0
+ uuid="$(printf '%s' "$lastmod" | cut -f2)"
+ cur="$(printf '%s' "$lastmod" | cut -f3)"
+
+ # The output is the test, not the exit status. notmuch fails two ways and
+ # only one is detectable: a rejected query prints nothing and exits 1,
+ # while a query Xapian merely misparses returns a plausible wrong number
+ # and exits 0. The defence against the second is that SCOPE is a fixed
+ # string and is never built from anything.
+ [[ "$cur" =~ ^[0-9]+$ ]] || return 0
+ [[ -n "$uuid" ]] || return 0
+
+ prev="$(read_prev_rev "$uuid")"
+
+ # No trustworthy floor: record where we are and say nothing. This is the
+ # first run, a rebuilt database, or a corrupt state file.
+ if [[ -z "$prev" ]]; then
+ write_state "$uuid" "$cur"
+ return 0
+ fi
+
+ # Nothing committed since last time, or the counter went backwards.
+ if [[ "$cur" -le "$prev" ]]; then
+ return 0
+ fi
+
+ local key label query count rows
+ while IFS=$'\t' read -r key label; do
+ [[ -n "$key" ]] || continue
+
+ query="$SCOPE and tag:account-$key and lastmod:$prev..$cur"
+
+ count="$(notmuch count "$query" 2>/dev/null)"
+ # Same validation, same reason: an empty string must not become a
+ # zero, and a failure here skips this account rather than the batch.
+ [[ "$count" =~ ^[0-9]+$ ]] || continue
+ [[ "$count" -gt 0 ]] || continue
+
+ rows="$(notmuch search --format=json --limit="$ROWS" \
+ --sort=newest-first "$query" 2>/dev/null)" || rows="[]"
+
+ notify_account "$label" "$key" "$count" "$(build_body "$rows" "$count")"
+ done < <(parse_accounts < "$CONFIG")
+
+ # Written only after every account is done, so a failure part way through
+ # leaves prev unchanged and the next tick retries rather than dropping a
+ # batch silently.
+ write_state "$uuid" "$cur"
+}
+
+main() {
+ local db
+ db="$(notmuch config get database.path 2>/dev/null)/xapian"
+
+ if [[ ! -d "$db" ]]; then
+ echo "mail-notify: no notmuch database at $db" >&2
+ exit 1
+ fi
+
+ if [[ ! -r "$CONFIG" ]]; then
+ echo "mail-notify: cannot read $CONFIG" >&2
+ exit 1
+ fi
+
+ if [[ "${1:-}" == "--once" ]]; then
+ tick
+ return 0
+ fi
+
+ # Seed before watching, so a first run never notifies the backlog.
+ tick
+
+ # The watch is on the xapian DIRECTORY, not a file inside it: a commit
+ # replaces files, and a watch held on a filename dies with the file.
+ while inotifywait -qq -e close_write,moved_to "$db" 2>/dev/null; do
+ # One commit touches several files. Without this, a single sync fires
+ # three or four ticks.
+ sleep 0.3
+ tick
+ done
+
+ # Falling out means inotifywait itself failed. Say so rather than exiting
+ # silently, which is indistinguishable from no mail arriving.
+ echo "mail-notify: inotify watch stopped" >&2
+ exit 1
+}
+```
+
+- [ ] **Step 2: Verify the test still passes**
+
+```bash
+./mail-overview/test-mail-notify.sh
+```
+
+Expected: PASS, `15 passed, 0 failed`. The pure functions are unchanged, and
+this is the check that Task 5 did not break them.
+
+- [ ] **Step 3: Verify a seed run is silent**
+
+```bash
+export MAIL_NOTIFY_STATE=/tmp/mail-notify-test.lastmod
+rm -f "$MAIL_NOTIFY_STATE"
+./mail-overview/mail-notify.sh --once
+cat "$MAIL_NOTIFY_STATE"
+```
+
+Expected: **no notifications appear**, and the state file holds one line of
+`<uuid> <revision>`. This is the anti-storm rule working: 101 unread messages
+and not one popup.
+
+- [ ] **Step 4: Verify a backdated run notifies**
+
+This is the verification the spec calls for, and it does not need mail to
+arrive:
+
+```bash
+uuid="$(notmuch count --lastmod 'tag:unread and tag:inbox' | cut -f2)"
+cur="$(notmuch count --lastmod 'tag:unread and tag:inbox' | cut -f3)"
+printf '%s %s\n' "$uuid" "$((cur - 2000))" > "$MAIL_NOTIFY_STATE"
+./mail-overview/mail-notify.sh --once
+```
+
+Expected: one notification per account that had mail in that window, each
+summarised `<label> · N new` with up to three author/subject rows and a
+`+N more` line when the batch is bigger. Confirm the counts look plausible
+against the drawer, that stacking replaced rather than piled up, and that
+clicking one opens qtmaildir.
+
+- [ ] **Step 5: Clean up the scratch state**
+
+```bash
+rm -f /tmp/mail-notify-test.lastmod
+unset MAIL_NOTIFY_STATE
+```
+
+The real state file was never touched: every step above overrode it.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add mail-overview/mail-notify.sh
+git commit -m "feat(mail-overview): notify per account when mail arrives
+
+Asks notmuch what changed since the revision last seen, then sends
+one notification per account with mail in that window. Counts are
+validated as integers because a rejected notmuch query prints nothing
+and exits 1, and an empty string must not read as zero.
+
+dunstify is backgrounded because -b blocks until the notification is
+dismissed, which would otherwise stall the loop for the full timeout
+on every account. The watch is on the xapian directory rather than a
+file inside it, because a commit replaces files and a watch held on a
+filename dies with it."
+```
+
+---
+
+## Task 6: Documentation and autostart
+
+**Files:**
+- Modify: `mail-overview/README.md`
+- Modify: `~/.config/hypr/sections/autostart.lua` (outside the repo, not committed)
+
+- [ ] **Step 1: Add the README section**
+
+Insert into `mail-overview/README.md`, between the `## The waybar module`
+section and `## Geometry, and the one property that differs from the other
+panels`:
+
+```markdown
+## Notifications on arrival
+
+`mail-notify.sh` sends one notification per account when mail lands, with the
+newest three senders and subjects and a `+N more` line when the batch is
+bigger. Started from `autostart.lua`, it runs for the whole session.
+
+ /home/you/Programming/GIT/quickshell/mail-overview/mail-notify.sh
+
+It watches the same xapian directory the waybar module does, for the same
+reason and with the same 0.3s debounce. It is a **separate process rather
+than part of `waybar-mail.sh`**, which already has that edge: waybar owns
+that script's process, so a bar restart would stop notifications with nothing
+reporting it.
+
+**A commit is not the same as new mail.** Reading a message in qtmaildir drops
+its `unread` tag and commits; so does tagging. Arrival is found with notmuch's
+revision counter instead:
+
+ notmuch count --lastmod 'tag:unread and tag:inbox'
+
+which prints count, database UUID and revision, tab separated. Each account is
+then asked what it gained in `lastmod:<prev>..<cur>`. A count delta was
+rejected because it cannot name senders, and a `date:` watermark because
+`date:` is the message's own Date header: backdated mail would never notify
+and future-dated mail would notify forever.
+
+Position is kept in `~/.local/state/mail-notify.lastmod`, written by atomic
+replace, holding the **UUID as well as the revision**. Revisions only compare
+within one database, so a rebuild restarts the counter and a stored revision
+from the old one means nothing.
+
+**Missing, corrupt or mismatched state seeds silently**, recording the current
+revision and notifying nothing. Without that floor, `lastmod:0..` matches every
+unread message ever: 101 of them here, which is a wall of popups at every
+login. The same applies to a restart, so mail that arrived while it was down is
+never notified. The waybar count is still right and the drawer still shows it,
+so nothing is lost but a stale popup.
+
+Clicking a notification opens qtmaildir, but not the account it belongs to:
+`qtmaildir` takes no arguments and `startup_account` is a static setting rather
+than a flag, the same limitation the thread rows have.
+
+`test-mail-notify.sh` is the check. It sources the script as a library and
+asserts on the functions that only move text around, so it needs no mail and no
+notmuch:
+
+ ./test-mail-notify.sh
+```
+
+- [ ] **Step 2: Verify the README claims are true**
+
+```bash
+./mail-overview/test-mail-notify.sh
+```
+
+Expected: PASS. The README now promises this command works, so it must.
+
+- [ ] **Step 3: Commit the README**
+
+```bash
+git add mail-overview/README.md
+git commit -m "docs(mail-overview): document the arrival notifier
+
+Why a revision counter rather than a count delta or a date
+watermark, why the state file carries a UUID, and why an unseeded
+run stays quiet."
+```
+
+- [ ] **Step 4: Add the autostart line**
+
+Edit `~/.config/hypr/sections/autostart.lua`, after the mail-watcher line
+(currently line 28, `hl.exec_cmd("/home/you/bin/mail-watcher.sh --ensure")`),
+substituting the real home directory for `/home/you`:
+
+```lua
+ -- one notification per account when mail lands
+ hl.exec_cmd("/home/you/Programming/GIT/quickshell/mail-overview/mail-notify.sh")
+```
+
+The absolute path is required: Hyprland's Lua strings do not expand `~`, the
+same reason the `qs -p` lines above are spelled out. This file is outside the
+repo and is not committed; no home path enters git.
+
+- [ ] **Step 5: Reload and confirm it is running**
+
+```bash
+hyprctl reload
+pgrep -cf mail-notify.sh
+```
+
+Expected: `1`. Note this is one of the few safe uses of `pgrep -f` here, since
+the script name is distinctive; `pkill -f` remains forbidden in this repo
+because it matches the agent's own shell.
+
+---
+
+## Self-Review
+
+**Spec coverage.** Shape and placement: Task 1 and Task 5's `main`. Arrival
+detection via `lastmod`: Task 5's `tick`. State file with UUID and silent
+seeding: Task 4. Account parsing with both traps: Task 2. Notification content,
+stack tag, urgency, timeout, click action, `notify-send` fallback: Tasks 3 and
+5. Failure handling and integer validation: Task 5, tested in Task 3's
+malformed-JSON case. Verification by backdated revision: Task 5 Step 4.
+Documentation: Task 6. No spec section is unimplemented.
+
+**Placeholder scan.** No TBDs, no "add error handling", no "similar to Task N".
+Every code step carries its code.
+
+**Type consistency.** `parse_accounts` emits `key<TAB>label` and is consumed
+that way in Task 5. `build_body "$json" "$total"` is defined in Task 3 and
+called with that signature in Task 5. `read_prev_rev "$uuid"` and
+`write_state "$uuid" "$rev"` match between Tasks 4 and 5. `STATE`, `CONFIG`,
+`SCOPE` and `ROWS` are declared in Task 1 and used unchanged after.
+
+**One correction made during review:** Task 4's test needed `STATE` to be
+overridable, so Task 1 declares it as `${MAIL_NOTIFY_STATE:-...}` rather than
+a bare path. `CONFIG` gained the same treatment for symmetry. Without it the
+check would have moved the user's real notification position.
diff --git a/docs/superpowers/specs/2026-09-13-mail-arrival-notifications-design.md b/docs/superpowers/specs/2026-09-13-mail-arrival-notifications-design.md
new file mode 100644
index 0000000..1423fe9
--- /dev/null
+++ b/docs/superpowers/specs/2026-09-13-mail-arrival-notifications-design.md
@@ -0,0 +1,194 @@
+# mail-overview: notify on mail arrival
+
+New mail should announce itself. Today nothing does: `mailsync.sh` syncs and
+writes a status file, `waybar-mail.sh` updates a number in the bar, and the
+drawer shows detail only when opened. A message that lands while the user is
+looking at something else is silent.
+
+This adds one notification per account per arriving batch, from a new script
+in this component.
+
+## What it is not
+
+It is not the quickshell notification daemon that would replace dunst. That
+remains deferred and is independent of this: notifications here are sent over
+the freedesktop DBus spec, so they work with dunst today and keep working
+unchanged if the daemon is ever swapped in. Nothing here should wait for it.
+
+## Shape
+
+`mail-overview/mail-notify.sh`, bash, sibling to `waybar-mail.sh`, GPLv2
+header like every other source file here. Started from `autostart.lua`, runs
+for the whole session.
+
+ resolve db path from `notmuch config get database.path`
+ guard: db dir missing -> complain on stderr, exit 1
+ seed state silently (no startup notification)
+ while inotifywait -qq -e close_write,moved_to "$db"; do
+ sleep 0.3
+ notify_new
+ done
+ complain, exit 1
+
+The watch idiom is copied from `waybar-mail.sh` rather than shared. It is
+about six lines, and two copies of six lines beat an abstraction spanning a
+bar module and a notifier, which have different owners and different
+lifetimes.
+
+**Why a separate process rather than extending `waybar-mail.sh`.** That
+script already has the arrival edge, and reusing it would be the shortest
+diff. It was rejected because waybar owns that process: a waybar restart or a
+`hyprctl reload` would stop mail notifications with nothing reporting it. A
+notifier that silently stops is worse than one that costs a second inotify
+watch. Running it inside quickshell was also rejected: `FileView` watches
+files, not directories, and a watch on a file inside the Xapian directory
+dies on commit (the trap already recorded in AGENTS.md), so it would need a
+`Process` running `inotifywait` anyway.
+
+## What counts as new
+
+A notmuch commit is not the same as new mail. Reading a message in qtmaildir
+drops its `unread` tag and commits; so does tagging. Arrival is detected with
+notmuch's own revision counter.
+
+ notmuch count --lastmod 'tag:unread and tag:inbox'
+
+prints three tab-separated fields: count, database UUID, revision. The
+revision is field 3. Verified on notmuch 0.39.
+
+Per tick, if the revision has not advanced, there is nothing to do. Otherwise,
+per account:
+
+ notmuch count "tag:unread and tag:inbox and tag:account-<key> and lastmod:<prev+1>..<cur>"
+ notmuch search --format=json --limit=3 --sort=newest-first "<same query>"
+
+Two calls, the same count-plus-preview pair `Accounts.qml` already makes, and
+only for accounts whose query is non-empty. The count gives the true N for
+"+N more"; the search gives the rows. The lower bound is exclusive (`prev+1`)
+so the revision just written, which is inclusive at the top end, is not
+re-matched on the next tick.
+
+`search --format=json` returns `authors` and `subject` directly, which is all
+the body needs.
+
+**Alternatives rejected.** A per-account count delta is simpler but cannot
+name senders, which was the point of the feature. A timestamp watermark using
+`date:@<ts>..` is quietly broken: `date:` is the message's Date header, so
+backdated mail never notifies and future-dated mail notifies forever.
+
+## State
+
+`~/.local/state/mail-notify.lastmod`, alongside `mail-watcher.heartbeat` and
+`mailsync.log`. Written by atomic replace (tmpfile then rename), the same
+idiom the heartbeat uses.
+
+It stores the database UUID as well as the revision. notmuch revisions are
+only comparable within one database: a rebuilt database restarts the counter,
+and a stored revision from the old one would then be meaningless. A UUID
+mismatch is treated exactly like a missing file.
+
+**Missing, unparseable, or UUID-mismatched state seeds silently:** record the
+current revision, notify nothing. Without this, a first run has no floor,
+`lastmod:0..` matches every unread inbox message ever, and startup is a wall
+of popups. Measured here: 101 unread messages across five accounts.
+
+The same applies to a restart mid-session. Mail that arrived while the script
+was down is never notified. This is the right trade: the waybar count is still
+correct and the drawer still shows the mail, so nothing is lost except a
+popup that would have been stale anyway.
+
+The new revision is written **after** every account has been processed. A
+single account whose count does not validate is skipped for that tick, and the
+revision still advances: holding it back would make every later tick re-notify
+the successful accounts' whole range. Only a failure to write the state file
+itself is non-fatal and leaves the old revision, so the next tick re-notifies
+rather than dropping mail.
+
+## Accounts
+
+Parsed from `qtmaildir.conf`, the same file the drawer parses, so adding an
+account in qtmaildir makes it notify with no edit here.
+
+Two details carry over from `Accounts.qml` and are not optional:
+
+- **The key runs to the closing bracket, not to the first dot.** Real keys
+ contain dots: a section like `[account.provider-first.last]` maps to the
+ notmuch tag `account-provider-first.last`. Splitting on the first dot yields
+ a tag that matches nothing, and an account that never notifies.
+- **Walk lines; never match "everything up to the next `[`".** Several
+ accounts have folders named like `[Gmail]/Bozze`, which ends a section body
+ before its `label` and makes the account display its raw key.
+
+The `label` is what the notification summary shows.
+
+## The notification
+
+One per account with new mail:
+
+ dunstify -a "New Mail" -u normal -t 10000 -b \
+ -i "$MAIL_ICON" \
+ -h string:x-dunst-stack-tag:mail-<key> \
+ -A default,open \
+ "<label> (<count>)" "<body>"
+
+The heading is split to match the running dunst `format`, which renders `%a`
+(the app name) bold on the top line and `%s` (the summary) italic below it:
+the app name is `New Mail`, the summary is `<label> (<count>)`.
+
+Body: up to 3 bullet rows of author and subject, each on its own line with a
+blank line between, then `+N more` when N > 3. Three matches the drawer's own
+`--limit=3`, and bounds the height so a 20-message mailing list burst does not
+become a wall.
+
+`MAIL_ICON` is an absolute path, not an icon name. The running dunst resolves
+a themed icon name only through the `icon_path` in `dunstrc`, which holds no
+mail icon, so a name renders nothing; an absolute path under the active theme
+resolves, as the machine's other notifiers already do. It points at
+`${XDG_DATA_HOME:-$HOME/.local/share}/icons/MB-Blueberry-Suru-GLOW/actions/24/mail-unread-multiple.svg`.
+
+Because the notification carries `-A`, dunst prepends an `(A)` action
+indicator when `show_indicators` is on, so `dunstrc` sets
+`show_indicators = no`.
+
+Normal urgency and an explicit 10s timeout. Deliberately not `-u critical`:
+on most dunst configurations critical notifications never expire, which
+would leave mail popups stuck on screen.
+
+**Stack tag per account** means a second batch for the same account replaces
+the first rather than stacking, which is what "one notification per account"
+has to mean when mail keeps arriving.
+
+**Click opens qtmaildir.** `-A default,open` plus `-b` makes dunstify block
+until the notification is dismissed or actioned and print the action key, so
+each notification is launched in a backgrounded subshell that waits and runs
+`~/bin/qtmaildir` on `default`. Without backgrounding, the loop would stall
+for the full timeout on every account. With the running dunst's
+`mouse_middle_click = do_action`, it is a middle click that invokes it.
+
+## Failure handling
+
+The existing rule for this component applies unchanged: **the output is the
+test, not the exit status.** notmuch fails two ways and only one is
+detectable. A rejected query prints nothing and exits 1; a query Xapian merely
+misparses returns a plausible wrong number and exits 0.
+
+So every count is validated as `^[0-9]+$`. An account whose count does not
+validate is skipped for that tick with no notification. The defence against
+the second failure mode is that the queries are fixed strings with only the
+account key and the two revisions interpolated, never built from anything
+else.
+
+Falling out of the `inotifywait` loop means the watch itself died. The script
+says so on stderr and exits non-zero rather than exiting silently, which would
+look indistinguishable from no mail arriving.
+
+## Verification
+
+The script is runnable standalone, which is what makes this testable without
+waiting for real mail: write a `prev` revision a few hundred revisions behind
+current into the state file, run one tick, and confirm the per-account
+notifications appear with the right counts, that no storm occurs, and that the
+state file advances to the current revision.
+
+That single check is enough. It exercises the parse, the query, the body
+construction and the state write together, and it fails if any of them break.
diff --git a/mail-overview/Accounts.qml b/mail-overview/Accounts.qml
index 27cdcf9..0ae6886 100644
--- a/mail-overview/Accounts.qml
+++ b/mail-overview/Accounts.qml
@@ -184,32 +184,12 @@ Singleton {
accounts = next;
}
- // Launching qtmaildir, and syncing. qtmaildir takes no arguments, so
- // there is nothing to tell it about the account or thread clicked.
+ // Launching qtmaildir. It takes no arguments, so there is nothing to tell
+ // it about the account or thread clicked.
Process { id: openProc; command: [`${Quickshell.env("HOME")}/bin/qtmaildir`] }
function openClient() {
openProc.running = false;
openProc.running = true;
}
-
- property bool syncing: false
-
- // mailsync.sh is already lock-protected against a concurrent cron run, so
- // this does not need its own guard beyond not stacking clicks.
- Process {
- id: syncProc
- command: [`${Quickshell.env("HOME")}/bin/mailsync.sh`]
- onExited: {
- root.syncing = false;
- root.refresh();
- }
- }
-
- function sync() {
- if (syncing) return;
- syncing = true;
- syncProc.running = false;
- syncProc.running = true;
- }
}
diff --git a/mail-overview/MailPanel.qml b/mail-overview/MailPanel.qml
index 7457fb2..0a7f36a 100644
--- a/mail-overview/MailPanel.qml
+++ b/mail-overview/MailPanel.qml
@@ -10,6 +10,7 @@
// GNU General Public License for more details.
import Quickshell
+import Quickshell.Io
import Quickshell.Wayland
import QtQuick
@@ -20,6 +21,45 @@ Scope {
property string monitor: "DP-1"
property bool open: false
+ // mail-watcher's heartbeat, written every 60s. Read once per open: the
+ // drawer is a LazyLoader, so closing and reopening rebuilds the FileView
+ // and reads the current file. Watch is deliberately not used, because the
+ // heartbeat is written by atomic replace (tmpfile + rename) and an inotify
+ // watch held on the old inode dies with it.
+ property bool watcherAlive: false
+ property int watcherDead: 0
+ property int watcherExpected: 0
+
+ // The same staleness rule as mail-watcher's heartbeat_is_healthy: dead
+ // threads or a heartbeat older than 300s mean the watcher needs a look.
+ // Backoff is healthy, so it never turns the dot.
+ function readHeartbeat(payload) {
+ root.watcherAlive = false;
+ root.watcherDead = 0;
+ root.watcherExpected = 0;
+
+ let data = null;
+ try { data = JSON.parse(payload); } catch (e) { return; }
+ if (!data || typeof data.ts !== "string") return;
+
+ const ts = Date.parse(data.ts);
+ if (isNaN(ts) || (Date.now() - ts) / 1000 > 300) return;
+
+ root.watcherAlive = true;
+ root.watcherDead = Number(data.dead) || 0;
+ root.watcherExpected = Number(data.expected) || 0;
+ }
+
+ readonly property color watcherColor:
+ !watcherAlive ? Theme.red
+ : watcherDead > 0 ? Theme.yellow
+ : Theme.green
+
+ readonly property string watcherText:
+ !watcherAlive ? "watcher not running"
+ : watcherDead > 0 ? `${watcherDead} folder(s) dead, check the log`
+ : `watcher ok · ${watcherExpected} folders`
+
readonly property var screenObj:
Quickshell.screens.find(s => s.name === root.monitor) ?? Quickshell.screens[0]
@@ -95,6 +135,13 @@ Scope {
// the drawer.
MouseArea { anchors.fill: parent }
+ FileView {
+ id: heartbeat
+ path: `${Quickshell.env("HOME")}/.local/state/mail-watcher.heartbeat`
+ onLoaded: root.readHeartbeat(text())
+ onLoadFailed: root.readHeartbeat("")
+ }
+
Column {
id: content
anchors { left: parent.left; right: parent.right; top: parent.top; margins: 16 }
@@ -230,17 +277,36 @@ Scope {
Rectangle { width: parent.width; height: 1; color: Qt.alpha(Theme.text, 0.12) }
+ // Watcher health, above the button. Green when idling,
+ // yellow when a folder gave up, red when there is no fresh
+ // heartbeat at all.
+ Item {
+ width: parent.width
+ implicitHeight: 22
+
+ Rectangle {
+ id: watcherDot
+ anchors.verticalCenter: parent.verticalCenter
+ width: 8; height: 8; radius: 4
+ color: root.watcherColor
+ }
+
+ Text {
+ anchors { left: watcherDot.right; leftMargin: 10; verticalCenter: parent.verticalCenter }
+ text: root.watcherText
+ color: Theme.subtext
+ font.family: Theme.fontFamily
+ font.pixelSize: Theme.fontSize - 2
+ }
+ }
+
+ Rectangle { width: parent.width; height: 1; color: Qt.alpha(Theme.text, 0.12) }
+
Row {
anchors.right: parent.right
spacing: 10
Button {
- text: Accounts.syncing ? "Syncing..." : "Sync now"
- enabled: !Accounts.syncing
- onClicked: Accounts.sync()
- }
-
- Button {
text: "Open qtmaildir"
onClicked: { Accounts.openClient(); root.close(); }
}
diff --git a/mail-overview/README.md b/mail-overview/README.md
index daf2bf9..34eaa53 100644
--- a/mail-overview/README.md
+++ b/mail-overview/README.md
@@ -16,7 +16,9 @@ Clicking the icon opens the drawer; Escape or a click outside closes it.
│ ● Account D 0 │
│ ● Account E 0 │
├──────────────────────────────────────────────────┤
- │ [Sync now] [Open qtmaildir] │
+ │ ● watcher ok · 25 folders │
+ ├──────────────────────────────────────────────────┤
+ │ [Open qtmaildir] │
└──────────────────────────────────────────────────┘
## Running it
@@ -118,6 +120,63 @@ Gmail API with stored credentials, covered three of the five accounts, and
opened Thunderbird. Everything it fetched over the network was already in the
local index.
+## Notifications on arrival
+
+`mail-notify.sh` sends one notification per account when mail lands, with the
+newest three threads' sender and subject and a `+N more` line when the batch is
+bigger. Each message is a bullet on its own line with a blank line between,
+under a bold `New Mail` heading with the account and count as the second line.
+Started from `autostart.lua`, it runs for the whole session.
+
+ /home/you/Programming/GIT/quickshell/mail-overview/mail-notify.sh
+
+It watches the same xapian directory the waybar module does, for the same
+reason and with the same 0.3s debounce. It is a **separate process rather
+than part of `waybar-mail.sh`**, which already has that edge: waybar owns
+that script's process, so a bar restart would stop notifications with nothing
+reporting it.
+
+**A commit is not the same as new mail.** Reading a message in qtmaildir drops
+its `unread` tag and commits; so does tagging. Arrival is found with notmuch's
+revision counter instead:
+
+ notmuch count --lastmod 'tag:unread and tag:inbox'
+
+which prints count, database UUID and revision, tab separated. Each account is
+then asked what it gained in `lastmod:<prev+1>..<cur>`, the lower bound one
+revision after the stored one so it is exclusive. A count delta was
+rejected because it cannot name senders, and a `date:` watermark because
+`date:` is the message's own Date header: backdated mail would never notify
+and future-dated mail would notify forever.
+
+Position is kept in `~/.local/state/mail-notify.lastmod`, written by atomic
+replace, holding the **UUID as well as the revision**. Revisions only compare
+within one database, so a rebuild restarts the counter and a stored revision
+from the old one means nothing.
+
+**Missing, corrupt or mismatched state seeds silently**, recording the current
+revision and notifying nothing. Without that floor, `lastmod:0..` matches every
+unread message ever: 101 of them here, which is a wall of popups at every
+login. The same applies to a restart, so mail that arrived while it was down is
+never notified. The waybar count is still right and the drawer still shows it,
+so nothing is lost but a stale popup.
+
+The notification is sent with dunstify. The icon is passed as an **absolute
+path** to the active icon theme's `mail-unread-multiple`, because this dunst
+resolves an icon *name* only through the `icon_path` in `dunstrc`, and that
+path holds no mail icon, so a name renders no icon at all. The click action
+(`-A default,open`) makes dunst prepend an `(A)` action indicator when
+`show_indicators` is on, so `dunstrc` sets `show_indicators = no`. A middle
+click (dunst's `do_action`) opens qtmaildir, but not the account it belongs
+to: `qtmaildir` takes no arguments and `startup_account` is a static setting
+rather than a flag, the same limitation the thread rows have.
+
+`test-mail-notify.sh` is the check. It sources the script as a library and
+asserts on the functions that only move text around, so it needs no mail and no
+notmuch:
+
+ ./test-mail-notify.sh
+
## Geometry, and the one property that differs from the other panels
The window is a fullscreen overlay with a dimmed backdrop and the drawer itself
@@ -145,8 +204,36 @@ open. `startup_account` in its config is a static setting, not a flag, so
here was rejected rather than deferred: it would write the database behind a
possibly running client.
-"Sync now" runs `~/bin/mailsync.sh`, which is already lock-protected against a
-concurrent cron run, and the watcher picks up whatever it commits.
+"Open qtmaildir" launches the client. There is no "Sync now" button: the
+watcher triggers a sync the moment mail lands, the cron tick is the backstop,
+and `on-click-right` on the waybar module runs `~/bin/mailsync.sh` for a manual
+pull.
+
+## The watcher status dot
+
+Below the accounts, above the button, a coloured dot reports whether
+`mail-watcher` is alive and sane, read from its heartbeat at
+`~/.local/state/mail-watcher.heartbeat`:
+
+- **green** (`watcher ok · N folders`) heartbeat fresh, no dead threads.
+- **yellow** (`N folder(s) dead, check the log`) heartbeat fresh, but a folder
+ gave up permanently.
+- **red** (`watcher not running`) heartbeat missing, unparseable, or older than
+ 300s.
+
+Backoff never turns the dot: it is normal recovery from a dropped IDLE
+connection, and the watcher's own health check treats it as healthy. The
+staleness rule is the same one `mail-watcher` uses (`heartbeat_is_healthy`),
+reimplemented here in a few lines rather than shelling out to
+`mail-watcher.py --status` on every open.
+
+The heartbeat is read once per open. The drawer is a `LazyLoader`, so closing
+and reopening rebuilds the `FileView` and reads the file current. A file watch
+is deliberately not used: the heartbeat is written by atomic replace (tmpfile
+then rename), so an inotify watch held on the old inode dies with it, which is
+the same trap as watching a file inside the Xapian directory. The cost is that
+a drawer left open does not update until reopened, which for 60s heartbeat data
+is not worth a timer.
## Theme and blur
diff --git a/mail-overview/mail-notify.sh b/mail-overview/mail-notify.sh
new file mode 100755
index 0000000..b132775
--- /dev/null
+++ b/mail-overview/mail-notify.sh
@@ -0,0 +1,295 @@
+#!/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.
+#
+# Notifies when mail arrives: one notification per account per batch, naming
+# the newest senders and subjects.
+#
+# Watches the notmuch Xapian directory and, on each commit, asks notmuch what
+# changed since the revision it last saw. A commit is NOT the same as new
+# mail, since reading and tagging also commit, which is why the revision
+# counter does the work rather than a count delta.
+#
+# This is its own process rather than part of waybar-mail.sh, which already
+# has the same arrival edge: waybar owns that process, so a bar restart would
+# stop notifications with nothing reporting it.
+#
+# Usage:
+# mail-notify.sh watch forever (what autostart runs)
+# mail-notify.sh --once process one tick and exit (for manual verification, one tick)
+
+set -u
+
+STATE="${MAIL_NOTIFY_STATE:-$HOME/.local/state/mail-notify.lastmod}"
+CONFIG="${MAIL_NOTIFY_CONFIG:-$HOME/.config/qtmaildir/qtmaildir.conf}"
+SCOPE='tag:unread and tag:inbox'
+
+# How many threads a notification body lists before eliding into "+N more".
+# Three matches the drawer's own --limit=3.
+ROWS=3
+
+# dunst here resolves a themed icon NAME only through its icon_path, which
+# holds no mail icon, so a name renders nothing (dunst stores an empty
+# icon_path for it). Pass an absolute path, as this machine's other
+# notifiers do. This is the icon the user picked; ${XDG_DATA_HOME} keeps a
+# home path out of the committed file.
+MAIL_ICON="${XDG_DATA_HOME:-$HOME/.local/share}/icons/MB-Blueberry-Suru-GLOW/actions/24/mail-unread-multiple.svg"
+
+# Accounts in file order, one "key<TAB>label" line each, read from stdin.
+#
+# Two things here are load-bearing, and both have already broken this
+# component once:
+#
+# The key runs to the closing bracket, NOT to the first dot. Real keys
+# contain dots, so splitting on the first one yields a notmuch tag matching
+# nothing and an account that silently never notifies.
+#
+# And this walks lines rather than matching a section body as "everything up
+# to the next [". Accounts have folders named like [Gmail]/Bozze, which ends
+# the body before its label and makes the account display its raw key.
+parse_accounts() {
+ local line key label
+ key=""
+ label=""
+
+ while IFS= read -r line || [[ -n "$line" ]]; do
+ if [[ "$line" =~ ^\[account\.([^]]+)\] ]]; then
+ [[ -n "$key" ]] && printf '%s\t%s\n' "$key" "${label:-$key}"
+ key="${BASH_REMATCH[1]}"
+ label=""
+ continue
+ fi
+ # Any other section ends the current account.
+ if [[ "$line" =~ ^\[ ]]; then
+ [[ -n "$key" ]] && printf '%s\t%s\n' "$key" "${label:-$key}"
+ key=""
+ label=""
+ continue
+ fi
+ [[ -n "$key" ]] || continue
+ if [[ "$line" =~ ^[[:space:]]*label[[:space:]]*=[[:space:]]*(.*)$ ]]; then
+ label="${BASH_REMATCH[1]}"
+ # Trailing whitespace only; a label may contain spaces.
+ label="${label%"${label##*[![:space:]]}"}"
+ fi
+ done
+
+ [[ -n "$key" ]] && printf '%s\t%s\n' "$key" "${label:-$key}"
+ return 0
+}
+
+# Renders notmuch search JSON into notification body text.
+# $1 the JSON array from `notmuch search --format=json`
+# $2 the true total for this batch, which may exceed the rows present
+#
+# dunst has body-markup in its capabilities, so a subject containing < or &
+# would be parsed as markup and could vanish from the notification. Subjects
+# are attacker-controlled text arriving from the internet, so the three XML
+# characters are escaped here. This is the one place in this script where
+# untrusted text reaches a renderer.
+#
+# Malformed JSON prints nothing and succeeds. A notification with no body is
+# still worth sending: the summary already carries the account and the count.
+build_body() {
+ local json="$1" total="$2" shown rowtext body
+
+ rowtext="$(printf '%s' "$json" | jq -r '
+ .[] | "• " + ((.authors // "(unknown)") + " — " + (.subject // "(no subject)"))
+ | gsub("[\r\n]+"; " ")
+ | gsub("&"; "&amp;") | gsub("<"; "&lt;") | gsub(">"; "&gt;")
+ ' 2>/dev/null)" || return 0
+ [[ -n "$rowtext" ]] || return 0
+
+ shown="$(printf '%s\n' "$rowtext" | wc -l)"
+ body="$(printf '%s\n' "$rowtext" | awk 'NR>1{print ""} 1')"
+
+ printf '%s' "$body"
+ if [[ "$total" -gt "$shown" ]]; then
+ printf '\n+%d more' "$((total - shown))"
+ fi
+ printf '\n'
+}
+
+# The last revision this script notified up to, or empty when there is none
+# to trust. Empty means "seed silently": record where we are now and notify
+# nothing.
+#
+# The stored UUID is checked because notmuch revisions are only comparable
+# within one database. A rebuilt database restarts the counter, so an old
+# revision would be meaningless, and treating it as a floor would either
+# notify nothing forever or notify everything at once.
+read_prev_rev() {
+ local want_uuid="$1" got_uuid rev
+
+ [[ -f "$STATE" ]] || return 0
+ read -r got_uuid rev < "$STATE" 2>/dev/null || return 0
+
+ [[ "$got_uuid" == "$want_uuid" ]] || return 0
+ [[ "$rev" =~ ^[0-9]+$ ]] || return 0
+
+ printf '%s' "$rev"
+}
+
+# Written by atomic replace, the same idiom mail-watcher uses for its
+# heartbeat: a reader must never see a half-written file, and mv within a
+# directory is atomic where a redirect into the final path is not.
+#
+# Failure to write is deliberately not fatal. The notifications have already
+# been sent; taking the watcher down over a failure to record that would turn
+# a bookkeeping problem into a no-mail-notifications problem.
+write_state() {
+ local uuid="$1" rev="$2" tmp
+
+ mkdir -p "$(dirname "$STATE")" 2>/dev/null || return 0
+ tmp="$(mktemp "${STATE}.XXXXXX")" || return 0
+ printf '%s %s\n' "$uuid" "$rev" > "$tmp" || { rm -f "$tmp"; return 0; }
+ mv -f "$tmp" "$STATE" 2>/dev/null || rm -f "$tmp"
+ return 0
+}
+
+# Sends one notification for one account.
+#
+# dunstify rather than notify-send because actions need it. A stack tag per
+# account means a second batch for the same account replaces the first rather
+# than stacking, which is what "one notification per account" has to mean when
+# mail keeps arriving.
+#
+# Normal urgency and an explicit 10s timeout, deliberately not -u critical:
+# on most dunst configurations critical notifications never expire, which
+# would leave mail popups on screen until clicked.
+#
+# The click cannot open the account it belongs to. qtmaildir accepts no
+# command line arguments and startup_account is a static config setting, not
+# a flag, which is the same limitation the drawer's thread rows already have.
+notify_account() {
+ local label="$1" key="$2" count="$3" body="$4"
+
+ # -a carries "New Mail" because the user's dunst format renders %a as the
+ # bold heading line, with %s italic below it.
+ if ! command -v dunstify >/dev/null 2>&1; then
+ # No actions available, but a notification without a click is still
+ # worth having.
+ notify-send -a "New Mail" -u normal -t 10000 -i "$MAIL_ICON" \
+ "$label ($count)" "$body"
+ return 0
+ fi
+
+ # Backgrounded because -b blocks until the notification is dismissed or
+ # clicked. Without this the loop would stall for the full timeout on
+ # every account, and a five-account batch would take most of a minute.
+ (
+ if [[ "$(dunstify -a "New Mail" -i "$MAIL_ICON" -u normal -t 10000 -b \
+ -h "string:x-dunst-stack-tag:mail-$key" \
+ -A "default,open" \
+ "$label ($count)" "$body")" == "default" ]]; then
+ "$HOME/bin/qtmaildir" &
+ fi
+ ) >/dev/null 2>&1 &
+}
+
+# One pass: what has arrived since the revision we last saw.
+tick() {
+ local lastmod uuid cur prev
+
+ # Three tab-separated fields: count, database UUID, revision. Verified on
+ # notmuch 0.39.
+ lastmod="$(notmuch count --lastmod "$SCOPE" 2>/dev/null)" || return 0
+ uuid="$(printf '%s' "$lastmod" | cut -f2)"
+ cur="$(printf '%s' "$lastmod" | cut -f3)"
+
+ # The output is the test, not the exit status. notmuch fails two ways and
+ # only one is detectable: a rejected query prints nothing and exits 1,
+ # while a query Xapian merely misparses returns a plausible wrong number
+ # and exits 0. The defence against the second is that SCOPE is a fixed
+ # string and is never built from anything.
+ [[ "$cur" =~ ^[0-9]+$ ]] || return 0
+ [[ -n "$uuid" ]] || return 0
+
+ prev="$(read_prev_rev "$uuid")"
+
+ # No trustworthy floor: record where we are and say nothing. This is the
+ # first run, a rebuilt database, or a corrupt state file.
+ if [[ -z "$prev" ]]; then
+ write_state "$uuid" "$cur"
+ return 0
+ fi
+
+ # Nothing committed since last time, or the counter went backwards.
+ if [[ "$cur" -le "$prev" ]]; then
+ return 0
+ fi
+
+ local key label query count rows
+ while IFS=$'\t' read -r key label; do
+ [[ -n "$key" ]] || continue
+
+ query="$SCOPE and tag:account-$key and lastmod:$((prev + 1))..$cur"
+
+ count="$(notmuch count "$query" 2>/dev/null)"
+ # Same validation, same reason: an empty string must not become a
+ # zero, and a failure here skips this account rather than the batch.
+ [[ "$count" =~ ^[0-9]+$ ]] || continue
+ [[ "$count" -gt 0 ]] || continue
+
+ rows="$(notmuch search --format=json --limit="$ROWS" \
+ --sort=newest-first "$query" 2>/dev/null)" || rows="[]"
+
+ notify_account "$label" "$key" "$count" "$(build_body "$rows" "$count")"
+ done < <(parse_accounts < "$CONFIG")
+
+ # Written only after every account is done. A single account whose count
+ # fails to validate is skipped above but does not hold the revision back;
+ # leaving it behind would re-notify every successful account's range on
+ # each later tick. Only a failed state write itself leaves prev unchanged.
+ write_state "$uuid" "$cur"
+}
+
+main() {
+ local db
+ db="$(notmuch config get database.path 2>/dev/null)/xapian"
+
+ if [[ ! -d "$db" ]]; then
+ echo "mail-notify: no notmuch database at $db" >&2
+ exit 1
+ fi
+
+ if [[ ! -r "$CONFIG" ]]; then
+ echo "mail-notify: cannot read $CONFIG" >&2
+ exit 1
+ fi
+
+ if [[ "${1:-}" == "--once" ]]; then
+ tick
+ return 0
+ fi
+
+ # Seed before watching, so a first run never notifies the backlog.
+ tick
+
+ # The watch is on the xapian DIRECTORY, not a file inside it: a commit
+ # replaces files, and a watch held on a filename dies with the file.
+ while inotifywait -qq -e close_write,moved_to "$db" 2>/dev/null; do
+ # One commit touches several files. Without this, a single sync fires
+ # three or four ticks.
+ sleep 0.3
+ tick
+ done
+
+ # Falling out means inotifywait itself failed. Say so rather than exiting
+ # silently, which is indistinguishable from no mail arriving.
+ echo "mail-notify: inotify watch stopped" >&2
+ exit 1
+}
+
+# Sourced by the test with MAIL_NOTIFY_LIB set, which must not start a watch
+# loop. The bash equivalent of Python's __name__ == "__main__".
+[[ -n "${MAIL_NOTIFY_LIB:-}" ]] || main "$@"
diff --git a/mail-overview/test-mail-notify.sh b/mail-overview/test-mail-notify.sh
new file mode 100755
index 0000000..1c3e01a
--- /dev/null
+++ b/mail-overview/test-mail-notify.sh
@@ -0,0 +1,148 @@
+#!/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.
+#
+# The one runnable check for mail-notify.sh. No framework: it sources the
+# script as a library and asserts on the pure functions, the ones that take
+# text and print text. Nothing here calls notmuch, dunstify or inotify, which
+# is why it runs in milliseconds and needs no mail to arrive.
+#
+# Usage: ./test-mail-notify.sh (exit 0 = all passed)
+
+set -u
+
+MAIL_NOTIFY_LIB=1 . "$(dirname "$0")/mail-notify.sh"
+
+pass=0
+fail=0
+
+# Compares two strings and reports. Multi-line values are printed with their
+# newlines intact, because half the assertions here are about line structure.
+check() {
+ local name="$1" want="$2" got="$3"
+ if [[ "$want" == "$got" ]]; then
+ pass=$((pass + 1))
+ else
+ fail=$((fail + 1))
+ printf 'FAIL: %s\n want: %s\n got: %s\n' "$name" "$want" "$got"
+ fi
+}
+
+check "library guard does not run main" "loaded" "loaded"
+
+# A config with the two traps in it: a key containing dots, and a folder
+# value containing a bracket. Both are real shapes from qtmaildir.conf, with
+# placeholder names.
+read -r -d '' fixture <<'EOF'
+[general]
+theme = dark
+
+[account.simple]
+label = Simple
+color = #112233
+
+[account.provider-first.last]
+folder = [Gmail]/Bozze
+label = Dotted
+color = #445566
+
+[account.nolabel]
+color = #778899
+
+[ui]
+label = NotAnAccount
+EOF
+
+check "keys run to the bracket, not the first dot" \
+ "simple provider-first.last nolabel" \
+ "$(parse_accounts <<<"$fixture" | cut -f1 | tr '\n' ' ' | sed 's/ $//')"
+
+check "a bracket in a value does not end the section" \
+ "Dotted" \
+ "$(parse_accounts <<<"$fixture" | awk -F'\t' '$1=="provider-first.last"{print $2}')"
+
+check "a missing label falls back to the key" \
+ "nolabel" \
+ "$(parse_accounts <<<"$fixture" | awk -F'\t' '$1=="nolabel"{print $2}')"
+
+check "a non-account section is not an account" \
+ "" \
+ "$(parse_accounts <<<"$fixture" | awk -F'\t' '$1=="ui"{print $2}')"
+
+# The shape notmuch search --format=json actually returns, trimmed to the two
+# fields the body uses.
+rows_json='[
+ {"authors":"Alice Example","subject":"First subject"},
+ {"authors":"Bob Example","subject":"Second subject"},
+ {"authors":"Carol Example","subject":"Third subject"}
+]'
+
+check "a body lists author and subject per row" \
+ "• Alice Example — First subject
+
+• Bob Example — Second subject
+
+• Carol Example — Third subject" \
+ "$(build_body "$rows_json" 3)"
+
+check "a batch bigger than the rows shown is elided" \
+ "• Alice Example — First subject
+
+• Bob Example — Second subject
+
+• Carol Example — Third subject
++7 more" \
+ "$(build_body "$rows_json" 10)"
+
+check "markup characters are escaped, not rendered" \
+ "• A &amp; B — &lt;script&gt;" \
+ "$(build_body '[{"authors":"A & B","subject":"<script>"}]' 1)"
+
+check "a missing subject says so rather than printing nothing" \
+ "• Alice Example — (no subject)" \
+ "$(build_body '[{"authors":"Alice Example","subject":null}]' 1)"
+
+check "malformed json yields an empty body rather than an error" \
+ "" \
+ "$(build_body 'not json at all' 1 2>/dev/null)"
+
+check "a newline in a subject stays on one row" \
+ "• Alice Example — line one line two" \
+ "$(build_body '[{"authors":"Alice Example","subject":"line one\nline two"}]' 1)"
+
+# A scratch state file, removed at exit. Never the real one: that is the
+# user's live notification position, and a test must not move it.
+tmpstate="$(mktemp)"
+trap 'rm -f "$tmpstate"' EXIT
+STATE="$tmpstate"
+
+rm -f "$tmpstate"
+check "a missing state file reports no previous revision" \
+ "" "$(read_prev_rev "uuid-a")"
+
+write_state "uuid-a" 500
+check "a revision written is a revision read back" \
+ "500" "$(read_prev_rev "uuid-a")"
+
+check "a different database UUID discards the revision" \
+ "" "$(read_prev_rev "uuid-b")"
+
+printf 'uuid-a garbage\n' > "$tmpstate"
+check "an unparseable state file reports no previous revision" \
+ "" "$(read_prev_rev "uuid-a")"
+
+write_state "uuid-a" 600
+check "a rewrite replaces rather than appends" \
+ "1" "$(wc -l < "$tmpstate")"
+
+printf '\n%d passed, %d failed\n' "$pass" "$fail"
+[[ "$fail" -eq 0 ]]
diff --git a/vm-manager/README.md b/vm-manager/README.md
index 8d817f9..bcf12ac 100644
--- a/vm-manager/README.md
+++ b/vm-manager/README.md
@@ -85,6 +85,18 @@ in a delete confirmation gives it up.
## Destructive actions
+`Discard saved state` appears on a shut-off VM only when one actually exists,
+and runs `virsh managedsave-remove`. A VM that was saved rather than shut down
+restores that memory image on the next `start`, and when the image cannot be
+restored the start fails every time with a QEMU `migrate-incoming` error while
+the panel shows an ordinary `shut off`. Discarding it deletes the memory image
+and nothing else, so the next start is a cold boot and the disk is untouched.
+That is why it takes a confirmation but not a typed name.
+
+Detection is `virsh dominfo`, grepped for `Managed save: yes`, once per VM on
+every list refresh. `domstats` does not carry it and `virsh list --name` drops
+the column that would.
+
`Reset`, `Force stop`, snapshot `Revert` and snapshot `Delete` each take one
confirmation click. `Delete VM` requires the machine's name to be typed,
because it runs `virsh undefine --remove-all-storage`, which erases the disk
diff --git a/vm-manager/Virsh.qml b/vm-manager/Virsh.qml
index 309781c..14bd368 100644
--- a/vm-manager/Virsh.qml
+++ b/vm-manager/Virsh.qml
@@ -21,7 +21,7 @@ import QtQuick
Singleton {
id: root
- // name -> { state, cpu, memUsed, memTotal, fsUsed, fsTotal, ip, vcpus, agent }
+ // name -> { state, cpu, memUsed, memTotal, fsUsed, fsTotal, ip, vcpus, agent, saved }
property var vms: ({})
property var names: []
property var snapshots: ({}) // name -> [{ name, created, state, current }]
@@ -33,7 +33,8 @@ Singleton {
function _vm(name) {
return vms[name] ?? { state: "unknown", cpu: -1, memUsed: -1, memTotal: -1,
- fsUsed: -1, fsTotal: -1, ip: "", vcpus: 0, agent: false };
+ fsUsed: -1, fsTotal: -1, ip: "", vcpus: 0, agent: false,
+ saved: false };
}
function _set(name, fields) {
@@ -52,6 +53,7 @@ Singleton {
const found = text.trim().split("\n").map(s => s.trim()).filter(s => s.length);
root.names = found;
for (const n of found) if (!(n in root.vms)) root._set(n, {});
+ root._pollSaved();
root.refresh();
}
}
@@ -191,6 +193,44 @@ Singleton {
onExited: code => { if (code !== 0) { root._set(vm, { ip: "" }); root._nextAgent(); } }
}
+ // --- managed save ----------------------------------------------------
+ //
+ // A shut-off VM can still carry a saved memory image, and `virsh start`
+ // then restores it rather than booting. When that image cannot be
+ // restored the start fails every time with a QEMU migrate-incoming
+ // error, and nothing in the panel said why. `domstats` does not report
+ // it: the shut-off reason reads "failed", from the failed start, not
+ // from the save. `dominfo` is the only cheap source, so it is polled per
+ // VM the way the agent rows are.
+
+ property var _savedQueue: []
+
+ function _pollSaved() {
+ _savedQueue = names.slice();
+ _nextSaved();
+ }
+
+ function _nextSaved() {
+ if (_savedQueue.length === 0) return;
+ const vm = _savedQueue[0];
+ _savedQueue = _savedQueue.slice(1);
+ savedProc.vm = vm;
+ savedProc.command = ["virsh", "dominfo", vm];
+ savedProc.running = true;
+ }
+
+ Process {
+ id: savedProc
+ property string vm: ""
+ stdout: StdioCollector {
+ onStreamFinished: {
+ root._set(savedProc.vm, { saved: /^Managed save:\s+yes$/m.test(text) });
+ root._nextSaved();
+ }
+ }
+ onExited: code => { if (code !== 0) { root._set(vm, { saved: false }); root._nextSaved(); } }
+ }
+
// --- snapshots -------------------------------------------------------
Process {
@@ -227,6 +267,7 @@ Singleton {
destroy: v => ["virsh", "destroy", v],
suspend: v => ["virsh", "suspend", v],
resume: v => ["virsh", "resume", v],
+ discardsave: v => ["virsh", "managedsave-remove", v],
})
function act(vm, action) {
diff --git a/vm-manager/VmPanel.qml b/vm-manager/VmPanel.qml
index cf224d6..5960a1a 100644
--- a/vm-manager/VmPanel.qml
+++ b/vm-manager/VmPanel.qml
@@ -62,16 +62,21 @@ Scope {
// Which verbs make sense in the current state, mirroring the states the
// old rofi script switched on.
- function actionsFor(s) {
+ function actionsFor(s, saved) {
if (s === "running")
return [["shutdown", "Shutdown"], ["reboot", "Reboot"], ["suspend", "Suspend"],
["reset", "Reset"], ["destroy", "Force stop"]];
if (s === "paused" || s === "suspended")
return [["resume", "Resume"], ["shutdown", "Shutdown"], ["destroy", "Force stop"]];
+ // Only worth offering when a saved image actually exists: without one
+ // managedsave-remove fails, and the button would be noise on every
+ // other VM.
+ if (saved)
+ return [["start", "Start"], ["discardsave", "Discard saved state"]];
return [["start", "Start"]];
}
- function isDestructive(a) { return a === "reset" || a === "destroy"; }
+ function isDestructive(a) { return a === "reset" || a === "destroy" || a === "discardsave"; }
function run(vm, action) {
if (isDestructive(action)) root.confirming = { kind: action, vm: vm, snap: "" };
@@ -265,6 +270,7 @@ Scope {
sourceComponent: detail
property string vmName: modelData
property string vmState: vm.state ?? ""
+ property bool vmSaved: vm.saved ?? false
}
}
}
@@ -282,6 +288,7 @@ Scope {
readonly property string vmName: parent.vmName
readonly property string vmState: parent.vmState
+ readonly property bool vmSaved: parent.vmSaved
Rectangle { width: parent.width; height: 1; color: Qt.alpha(Theme.text, 0.08) }
@@ -299,7 +306,7 @@ Scope {
spacing: 8
Repeater {
- model: root.actionsFor(vmState)
+ model: root.actionsFor(vmState, vmSaved)
Button {
required property var modelData
text: modelData[1]
@@ -391,6 +398,7 @@ Scope {
if (c.kind === "revert") return `Revert ${c.vm} to "${c.snap}"? Changes since that snapshot are lost.`;
if (c.kind === "snapdelete") return `Delete snapshot "${c.snap}"?`;
if (c.kind === "destroy") return `Force stop ${c.vm}? This is a power cut, not a shutdown.`;
+ if (c.kind === "discardsave") return `Discard the saved state of ${c.vm}? Its memory image is deleted and the next start boots cold. The disk is untouched.`;
if (c.kind === "reset") return `Reset ${c.vm}? This is a hard reset, not a reboot.`;
return "";
}
diff --git a/window-switcher/README.md b/window-switcher/README.md
new file mode 100644
index 0000000..3cfb28b
--- /dev/null
+++ b/window-switcher/README.md
@@ -0,0 +1,151 @@
+# window-switcher
+
+Every open window as a live preview, in one centred grid over a dimmed screen.
+ALT+TAB opens it, a click or Enter picks a window, Escape drops it. It replaces
+a rofi list that showed the same windows as text.
+
+ ┌──────────────────────────────────────────────────────────────┐
+ │ │
+ │ ┌────────────────────┐ ┌────────────────────┐ │
+ │ │ [icon] [x] │ │ [icon] [x] │ │
+ │ │ │ │ │ │
+ │ │ live preview │ │ live preview │ │
+ │ │ fitted in a │ │ │ │
+ │ │ 16:10 box │ │ │ │
+ │ └────────────────────┘ └────────────────────┘ │
+ │ firefox kitty │
+ │ a page title, elided... ~/Programming/GIT/... │
+ │ [1] [4] │
+ │ │
+ └──────────────────────────────────────────────────────────────┘
+
+The cards are ordered most recently used first, so the window you just left is
+the first one, the same order ALT+TAB implies. Windows on a `special:`
+workspace are the scratchpad, which has its own bind, and are left out.
+
+With nothing to switch to it says so, because a full-screen dim with nothing in
+it reads as a hang.
+
+## Running it
+
+ qs -p .
+
+It is started from `autostart.lua` and reached over IPC, so the shell has to be
+running for the keybind to do anything:
+
+ qs -p ~/Programming/GIT/quickshell/window-switcher ipc call switcher toggle
+
+Write that path out in full in the real config: Hyprland's Lua strings have no
+shell to expand `~`. The bind lives in `keybindings.lua` on ALT+TAB. `show` and
+`close` exist on the same IPC target for anything that wants one direction only.
+
+## Interaction
+
+| | |
+| --- | --- |
+| ALT+TAB | open, or close if already open |
+| click a card, or Enter | focus that window and dismiss |
+| arrows | move the selection, up and down by a row |
+| Tab / Shift+Tab | move the selection by one |
+| the `x` on a card | close that window, overlay stays open |
+| Escape, or a click on the backdrop | dismiss, focus unchanged |
+
+Closing leaves the overlay up on purpose. Tidying several windows would
+otherwise mean reopening between each one, and the card goes when Hyprland says
+the window went, not when the button is pressed.
+
+## Hyprland dispatches from QML
+
+**Dispatch arguments are evaluated as Lua on Hyprland 0.56.2.** The form that
+reads like the documented one, `dispatch focuswindow address:0x...`, is a
+syntax error rather than a command, and it fails silently unless stderr is
+read. What works:
+
+ hl.dsp.focus({ window = "address:0x..." })
+ hl.dsp.window.close({ window = "address:0x..." })
+
+`~/bin/hypr-windows.sh`, the script behind the rofi list this replaces, is
+written that way for this reason. While planning, its syntax was read as
+legacy oddity worth modernising, which would have broken it.
+
+**A focus dispatched while the overlay is up is accepted and then ignored.**
+The layer surface holds keyboard focus exclusively and the compositor will not
+move window focus out from under that grab. The dispatch reports `ok`, nothing
+happens, and no log line says otherwise. Closing in the same turn does not help
+either: `close()` only clears a property, and the surface survives until the
+frame after. So the target is remembered, the overlay closes, and a 60ms timer
+does the focus once the grab is really gone. The symptom was picking a window
+on the other monitor and seeing nothing happen.
+
+**Focus alone raises a stacked window.** Workspaces 1 and 8 here are `monocle`
+and 4 and 5 are scrolling, so stacking is normal rather than an edge case, and
+it was tested with two stacked windows on workspace 1 in both directions. No
+`alterzorder` and no batch sequence are needed, which is why the fullscreen
+batch in `hypr-windows.sh` was not carried over.
+
+## Reading the toplevel list
+
+**`Hyprland.toplevels` reads 0 until `refreshToplevels()` is called.** The
+model is refreshed on every opening, which also picks up windows opened since
+the last one.
+
+**`HyprlandToplevel` has no `focusHistoryID` property.** It reads `undefined`,
+so a sort on it compares `NaN` and does nothing at all, leaving a plausible
+looking list in arbitrary order. The value is on `lastIpcObject` instead, which
+is what `focusOrder()` reads, and a missing one sorts last rather than as 0.
+
+**`HyprlandToplevel.address` omits the `0x`** that `hyprctl clients` prints and
+that a dispatch requires, so `addressOf()` puts it back.
+
+**`toplevel.wayland` can be null.** The Hyprland toplevel and its wlr handle
+are separate objects created at different moments, so a window that is
+appearing or being destroyed has one and not the other. The card guards every
+use of it.
+
+## Card geometry
+
+**A card is a fixed 16:10 box with the preview fitted inside**, not a box
+shaped to its preview. The previews are not one shape: DP-1 windows are near
+21:9 (2556x1034) and DP-3 reports `transform=1`, so its windows are portrait
+9:16 (1076x1916). Sizing each card to its own window gives ragged rows and
+breaks the alignment of the three text lines under them.
+
+**Card width is clamped by the height as well as the width.** A centred `Grid`
+has no way to scroll, so without the height term a busy desktop pushes rows off
+the top and bottom where they cannot be reached. The cards shrink instead.
+There is a ceiling on the width too, or a single window becomes a full-screen
+mirror of itself.
+
+**An uncaptured `ScreencopyView` reports `sourceSize` of `QSize(-1, -1)`**, not
+`(0, 0)`. The aspect ratio guard therefore tests for a positive height. The
+obvious defensive rewrites, `!== 0` or a plain truthiness check, all pass on
+`-1` and produce a negative ratio.
+
+**A `Repeater` delegate with required properties needs both `required property
+int index` and `required property var modelData`.** Declaring `modelData`
+required makes the implicit `index` unavailable.
+
+**An app with no themed icon renders a blank scrim square.**
+`Quickshell.iconPath("", true)` returns `""`, and an `IconImage` with an empty
+source draws nothing. There is no fallback glyph. Both corner overlays carry
+their own scrim regardless, because a themed icon on an arbitrary window
+preview can otherwise land on a same-coloured region and vanish.
+
+## Theme and blur
+
+`Theme.qml` is a symlink to `shared/Theme.qml`: the palette comes from
+`~/.cache/wal/udt-palette.qml` and is watched.
+
+Frosting is Hyprland's, matched on this window's namespace:
+
+ hl.layer_rule({
+ name = "blur-window-switcher",
+ match = { namespace = "^(quickshell-window-switcher)$" },
+ blur = true,
+ xray = false,
+ ignore_alpha = 0.1,
+ })
+
+Without the rule it still works, rendering flat translucent. The overlay covers
+the whole screen, waybar included, so unlike `mail-overview` it uses
+`ExclusionMode.Ignore`.