aboutsummaryrefslogtreecommitdiffstatshomepage
diff options
context:
space:
mode:
authorLibravatar sommerfeld <sommerfeld@sommerfeld.dev>2026-05-13 13:43:29 +0100
committerLibravatar sommerfeld <sommerfeld@sommerfeld.dev>2026-05-13 13:43:29 +0100
commite331fd63eaa51a51a6af06560bbe226a6d47fa16 (patch)
tree0c6bb57823f1710b297c6e83e618715f6659ca7f
parent0af53da3eb2574ca23758e6658b7683bcee4d6da (diff)
downloaddotfiles-e331fd63eaa51a51a6af06560bbe226a6d47fa16.tar.gz
dotfiles-e331fd63eaa51a51a6af06560bbe226a6d47fa16.tar.bz2
dotfiles-e331fd63eaa51a51a6af06560bbe226a6d47fa16.zip
feat(notifications): persistent-pending model + wofi history picker
Notifications now behave like a phone: pop briefly, auto-disappear, and remain "pending" until the user explicitly acknowledges them. The waybar count reflects pending only; idle uses a quieter glyph. State model: pending = ids in mako history/list MINUS dismissed-set state file: $XDG_RUNTIME_DIR/mako-dismissed (per-session id list) Glyph change: idle (0 pending) bell_outline U+F009C has pending bell_ring U+F009E (the previous bell_check_outline U+F11E8 "history present but nothing pending" branch is gone — there is no separate history concept now) Bindings (all now go through wrappers that maintain the dismissed-set): Super+n dismiss top visible + mark seen Super+Shift+n dismiss all visible + mark seen Super+Ctrl+n restore most recent + pop it from dismissed-set XF86Favorites history picker (rewritten on wofi) History picker (dot_config/waybar/executable_mako-history.py): - wofi --hide-search: arrow-only navigation, no fuzzy input - lines tagged [pending] / [seen] with app + summary + body - Enter re-emit via notify-send (re-shows the bubble) + mark seen - Alt-c copy "summary\nbody" to clipboard via wl-copy - Alt-d mark seen without re-showing - empty history shows a sentinel, no-op on Enter New scripts: executable_dismiss-visible.sh capture id(s) then makoctl dismiss executable_restore-pending.sh capture top-of-history id, restore, then drop that id from dismissed-set executable_mako-history.py Python rewrite (parses makoctl text output, drives wofi) Other: meta/wayland.txt add wofi (only used by this picker) dot_config/wofi/style.css minimal gruvbox style; hides input row as belt-and-suspenders even though --hide-search already does it
-rw-r--r--KEYBINDS.md8
-rw-r--r--dot_config/sway/config8
-rw-r--r--dot_config/waybar/config.jsonc2
-rw-r--r--dot_config/waybar/executable_dismiss-visible.sh33
-rw-r--r--dot_config/waybar/executable_mako-history.py153
-rwxr-xr-xdot_config/waybar/executable_mako-history.sh25
-rw-r--r--dot_config/waybar/executable_mako-status.sh55
-rw-r--r--dot_config/waybar/executable_restore-pending.sh21
-rw-r--r--dot_config/wofi/style.css46
-rw-r--r--meta/wayland.txt3
10 files changed, 305 insertions, 49 deletions
diff --git a/KEYBINDS.md b/KEYBINDS.md
index 3f203c1..892bbf4 100644
--- a/KEYBINDS.md
+++ b/KEYBINDS.md
@@ -327,10 +327,10 @@ Mod key: `Super` (Mod4). Only personal additions beyond sway defaults listed.
| `Super+i` | Dictate toggle (whisper.cpp → wtype + clipboard) |
| `Super+Shift+o` | OCR region (tesseract → clipboard) |
| `Super+Shift+s` | Lock screen + pause media |
-| `Super+n` | Dismiss notification |
-| `Super+Shift+n` | Dismiss all notifications |
-| `Super+Ctrl+n` | Restore last dismissed notification |
-| `XF86Favorites` | Notification history picker (copy to clipboard) |
+| `Super+n` | Dismiss visible notification (also marks it seen) |
+| `Super+Shift+n` | Dismiss all visible notifications (mark all seen) |
+| `Super+Ctrl+n` | Restore last dismissed; pop it back into the pending set |
+| `XF86Favorites` | Notification history picker (Enter re-shows + marks seen, Alt-c copies, Alt-d marks seen) |
| `Super+p` | Clipboard history picker (cliphist + fuzzel) |
| `Super+Shift+p` | Clipboard history delete entry |
| `Super+Tab` | Next workspace |
diff --git a/dot_config/sway/config b/dot_config/sway/config
index 93fd4be..ee22e77 100644
--- a/dot_config/sway/config
+++ b/dot_config/sway/config
@@ -171,9 +171,9 @@ bindsym $mod+Shift+o exec ~/.local/bin/ocr
bindsym $mod+Shift+s exec "playerctl -a pause; swaylock -f -e -c 282828"
# Notifications
-bindsym $mod+n exec makoctl dismiss
-bindsym $mod+Shift+n exec makoctl dismiss --all
-bindsym $mod+Ctrl+n exec makoctl restore
+bindsym $mod+n exec ~/.config/waybar/dismiss-visible.sh top
+bindsym $mod+Shift+n exec ~/.config/waybar/dismiss-visible.sh all
+bindsym $mod+Ctrl+n exec ~/.config/waybar/restore-pending.sh
# Clipboard history
bindsym $mod+p exec sh -c 'cliphist list | fuzzel --dmenu | cliphist decode | wl-copy'
@@ -185,7 +185,7 @@ bindsym --no-repeat XF86Display exec ~/.config/sway/display-toggle.sh
# Multimedia hardware keys (uncommon)
bindsym XF86Tools exec $term --class=floating -e pulsemixer
bindsym XF86Keyboard exec $term --class=floating -e glow -p ~/dotfiles/KEYBINDS.md
-bindsym XF86Favorites exec ~/.config/waybar/mako-history.sh
+bindsym XF86Favorites exec ~/.config/waybar/mako-history.py
# QR codes (Super+z then r=read or w=write)
mode "qr" {
diff --git a/dot_config/waybar/config.jsonc b/dot_config/waybar/config.jsonc
index a27f8a4..6d4f287 100644
--- a/dot_config/waybar/config.jsonc
+++ b/dot_config/waybar/config.jsonc
@@ -185,7 +185,7 @@
"exec": "~/.config/waybar/mako-status.sh",
"return-type": "json",
"interval": 2,
- "on-click": "~/.config/waybar/mako-history.sh",
+ "on-click": "~/.config/waybar/mako-history.py",
"on-click-right": "makoctl dismiss --all",
"on-click-middle": "makoctl restore",
"tooltip": true,
diff --git a/dot_config/waybar/executable_dismiss-visible.sh b/dot_config/waybar/executable_dismiss-visible.sh
new file mode 100644
index 0000000..98d240b
--- /dev/null
+++ b/dot_config/waybar/executable_dismiss-visible.sh
@@ -0,0 +1,33 @@
+#!/bin/sh
+# Dismiss currently-visible mako notifications and record their ids in the
+# shared "dismissed" set so they don't linger as pending in waybar.
+#
+# Usage: dismiss-visible.sh [top|all] (default: top)
+#
+# Coordinates with mako-status.sh and mako-history.py via
+# $XDG_RUNTIME_DIR/mako-dismissed (one id per line, per-session).
+
+set -eu
+
+mode=${1:-top}
+state=${XDG_RUNTIME_DIR:-/tmp}/mako-dismissed
+mkdir -p "$(dirname "$state")"
+: >>"$state"
+
+command -v makoctl >/dev/null 2>&1 || exit 0
+
+case "$mode" in
+ top)
+ id=$(makoctl list -f '%i' 2>/dev/null | head -n1 || true)
+ [ -n "${id:-}" ] && printf '%s\n' "$id" >>"$state"
+ makoctl dismiss
+ ;;
+ all)
+ makoctl list -f '%i' 2>/dev/null >>"$state" || true
+ makoctl dismiss --all
+ ;;
+ *)
+ echo "usage: $0 [top|all]" >&2
+ exit 2
+ ;;
+esac
diff --git a/dot_config/waybar/executable_mako-history.py b/dot_config/waybar/executable_mako-history.py
new file mode 100644
index 0000000..d12ea6f
--- /dev/null
+++ b/dot_config/waybar/executable_mako-history.py
@@ -0,0 +1,153 @@
+#!/usr/bin/env python3
+"""Notification history picker.
+
+Lists mako's history annotated with pending/seen status. Default action
+re-emits the notification (so the bubble pops again) and marks it seen.
+Alt-c copies "summary\\nbody" to the clipboard via wl-copy.
+Alt-d marks the entry seen without re-showing.
+
+State file: $XDG_RUNTIME_DIR/mako-dismissed (one id per line, per-session).
+"""
+
+from __future__ import annotations
+
+import os
+import re
+import subprocess
+from pathlib import Path
+
+STATE = Path(os.environ.get("XDG_RUNTIME_DIR", "/tmp")) / "mako-dismissed"
+RECORD_RE = re.compile(r"^Notification (\d+):\s*$")
+FIELD_RE = re.compile(r"^ ([A-Za-z][A-Za-z ]*?):\s*(.*)$")
+
+
+def parse_history() -> list[dict]:
+ try:
+ out = subprocess.run(
+ ["makoctl", "history"], capture_output=True, text=True, check=True
+ ).stdout
+ except (subprocess.CalledProcessError, FileNotFoundError):
+ return []
+ notifs: list[dict] = []
+ cur: dict | None = None
+ last_field: str | None = None
+ for line in out.splitlines():
+ m = RECORD_RE.match(line)
+ if m:
+ if cur is not None:
+ notifs.append(cur)
+ cur = {"id": int(m.group(1))}
+ last_field = None
+ continue
+ if cur is None:
+ continue
+ m = FIELD_RE.match(line)
+ if m:
+ key = m.group(1).strip().lower().replace(" ", "_")
+ cur[key] = m.group(2)
+ last_field = key
+ continue
+ # Body / continuation lines (mako indents with 8 spaces).
+ if last_field == "body" and line.startswith(" "):
+ cur["body"] = (cur.get("body", "") + " " + line.strip()).strip()
+ if cur is not None:
+ notifs.append(cur)
+ return notifs
+
+
+def load_dismissed() -> set[str]:
+ STATE.parent.mkdir(parents=True, exist_ok=True)
+ STATE.touch(exist_ok=True)
+ return {x for x in STATE.read_text().split() if x}
+
+
+def save_dismissed(ids: set[str]) -> None:
+ payload = "\n".join(sorted(ids, key=lambda s: int(s) if s.isdigit() else 0))
+ STATE.write_text(payload + ("\n" if ids else ""))
+
+
+def add_dismissed(nid: int) -> None:
+ ids = load_dismissed()
+ ids.add(str(nid))
+ save_dismissed(ids)
+
+
+def fmt_line(n: dict, dismissed: set[str]) -> str:
+ pending = str(n["id"]) not in dismissed
+ mark = "●" if pending else " "
+ app = (n.get("app_name") or "?").strip() or "?"
+ summary = (n.get("summary") or "").strip()
+ body = (n.get("body") or "").strip()
+ text = summary if not body else f"{summary} — {body}"
+ text = text.replace("\t", " ").replace("\r", " ")
+ # Trailing id sentinel for parsing on selection.
+ return f"[{mark}] [{app}] {text}\t#{n['id']}"
+
+
+def parse_selection(line: str) -> int | None:
+ m = re.search(r"\t#(\d+)\s*$", line)
+ return int(m.group(1)) if m else None
+
+
+def run_wofi(input_text: str, lines: int) -> tuple[int, str]:
+ style = Path.home() / ".config/wofi/style.css"
+ cmd = [
+ "wofi",
+ "--dmenu",
+ "--hide-search",
+ "--prompt", "Notifications",
+ "--define", "key_custom_0=Alt-c",
+ "--define", "key_custom_1=Alt-d",
+ "--lines", str(lines),
+ ]
+ if style.exists():
+ cmd += ["--style", str(style)]
+ proc = subprocess.run(
+ cmd, input=input_text, text=True, capture_output=True,
+ )
+ return proc.returncode, proc.stdout.strip()
+
+
+def main() -> None:
+ notifs = parse_history()
+ dismissed = load_dismissed()
+
+ if not notifs:
+ run_wofi("(no notifications)\n", 1)
+ return
+
+ lines_text = "\n".join(fmt_line(n, dismissed) for n in notifs) + "\n"
+ rc, sel = run_wofi(lines_text, min(len(notifs), 15))
+
+ if not sel or sel.startswith("(no "):
+ return
+
+ nid = parse_selection(sel)
+ if nid is None:
+ return
+ notif = next((n for n in notifs if n["id"] == nid), None)
+ if notif is None:
+ return
+
+ summary = (notif.get("summary") or "").strip()
+ body = (notif.get("body") or "").strip()
+ app = (notif.get("app_name") or "").strip()
+ clip_text = f"{summary}\n{body}".strip()
+
+ if rc == 10: # Alt-c → copy
+ subprocess.run(["wl-copy"], input=clip_text, text=True)
+ elif rc == 11: # Alt-d → mark seen, no re-show
+ add_dismissed(nid)
+ elif rc == 0: # Enter → re-emit + mark seen
+ cmd = ["notify-send"]
+ if app:
+ cmd += ["-a", app]
+ cmd.append(summary or "(no summary)")
+ if body:
+ cmd.append(body)
+ subprocess.run(cmd)
+ add_dismissed(nid)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/dot_config/waybar/executable_mako-history.sh b/dot_config/waybar/executable_mako-history.sh
deleted file mode 100755
index f15ec9c..0000000
--- a/dot_config/waybar/executable_mako-history.sh
+++ /dev/null
@@ -1,25 +0,0 @@
-#!/bin/sh
-# Fuzzel picker over mako's notification history. Read-only: mako has no
-# API to re-invoke an arbitrary history item, so the selected entry is
-# copied to the clipboard for reference. Use makoctl restore to bring the
-# most recent dismissed notification back to the active list.
-
-set -eu
-
-selection=$(
- makoctl history | awk '
- /^Notification / {
- sub(/^Notification [0-9]+: /, "")
- summary = $0
- next
- }
- /^ App name: / {
- sub(/^ App name: /, "")
- print "[" $0 "] " summary
- }
- ' | fuzzel --dmenu --prompt 'History: '
-)
-
-if [ -n "$selection" ]; then
- printf '%s' "$selection" | wl-copy
-fi
diff --git a/dot_config/waybar/executable_mako-status.sh b/dot_config/waybar/executable_mako-status.sh
index 791aabe..a4fdd31 100644
--- a/dot_config/waybar/executable_mako-status.sh
+++ b/dot_config/waybar/executable_mako-status.sh
@@ -1,26 +1,51 @@
#!/bin/sh
-# Emit waybar JSON with the mako notification count. Falls back to 0 when
-# mako is not running so waybar doesn't blink errors.
+# Waybar status: count of *pending* notifications, where pending = ids in
+# mako's history that have NOT been explicitly dismissed by the user via
+# Mod+n / Mod+Shift+n / the history picker.
+#
+# State file: $XDG_RUNTIME_DIR/mako-dismissed (per-session, plain id list).
+
set -eu
if ! command -v makoctl >/dev/null 2>&1; then
- printf '{"text":"","tooltip":"mako not installed","class":"off"}\n'
+ printf '{"text":"","tooltip":"mako not installed","class":"off"}
+'
exit 0
fi
-count=$(makoctl history 2>/dev/null | grep -c '^Notification ' || true)
-pending=$(makoctl list 2>/dev/null | grep -c '^Notification ' || true)
+state=${XDG_RUNTIME_DIR:-/tmp}/mako-dismissed
+: >>"$state"
-if [ "$pending" -gt 0 ]; then
- text="󰂞 $pending"
- class="pending"
-elif [ "$count" -gt 0 ]; then
- text="󱇨 $count"
- class="history"
+# Visible notifications also count as pending (they aren't in history yet).
+visible_ids=$(makoctl list -f '%i' 2>/dev/null || true)
+history_ids=$(makoctl history -f '%i' 2>/dev/null || true)
+all_ids=$(printf '%s
+%s
+' "$visible_ids" "$history_ids" \
+ | grep -E '^[0-9]+$' | sort -u || true)
+
+# Prune stale ids (no longer present in mako) from the dismissed file.
+if [ -s "$state" ] && [ -n "$all_ids" ]; then
+ tmp=$(mktemp)
+ printf '%s
+' "$all_ids" >"$tmp.all"
+ grep -Fxf "$tmp.all" "$state" >"$tmp" 2>/dev/null || :
+ mv "$tmp" "$state"
+ rm -f "$tmp.all"
+fi
+
+if [ -z "$all_ids" ]; then
+ pending=0
else
- text="󰂜"
- class="empty"
+ pending=$(printf '%s
+' "$all_ids" | grep -Fxvf "$state" | grep -c . || true)
fi
-printf '{"text":"%s","tooltip":"%s pending / %s history","class":"%s"}\n' \
- "$text" "$pending" "$count" "$class"
+if [ "$pending" -gt 0 ]; then
+ printf '{"text":"󰂞 %s","tooltip":"%s pending","class":"pending"}
+' \
+ "$pending" "$pending"
+else
+ printf '{"text":"󰂜","tooltip":"no pending notifications","class":"empty"}
+'
+fi
diff --git a/dot_config/waybar/executable_restore-pending.sh b/dot_config/waybar/executable_restore-pending.sh
new file mode 100644
index 0000000..53da3f4
--- /dev/null
+++ b/dot_config/waybar/executable_restore-pending.sh
@@ -0,0 +1,21 @@
+#!/bin/sh
+# Restore the most recently closed mako notification and remove its id
+# from the dismissed-set so it counts as pending again.
+
+set -eu
+
+state=${XDG_RUNTIME_DIR:-/tmp}/mako-dismissed
+: >>"$state"
+
+command -v makoctl >/dev/null 2>&1 || exit 0
+
+# mako's history is most-recent-first; the next restore() target is the
+# top of the list at the time of the call.
+top_id=$(makoctl history -f '%i' 2>/dev/null | head -n1 || true)
+makoctl restore || true
+
+if [ -n "${top_id:-}" ] && [ -s "$state" ]; then
+ tmp=$(mktemp)
+ grep -Fxv "$top_id" "$state" >"$tmp" || :
+ mv "$tmp" "$state"
+fi
diff --git a/dot_config/wofi/style.css b/dot_config/wofi/style.css
new file mode 100644
index 0000000..2e2a4ea
--- /dev/null
+++ b/dot_config/wofi/style.css
@@ -0,0 +1,46 @@
+/* Minimal gruvbox style for wofi.
+ * Currently used only by ~/.config/waybar/mako-history.py for its
+ * arrow-only notification picker. The search input row is always hidden
+ * via --hide-search, but we still hide it here in case wofi falls back.
+ */
+
+* {
+ font-family: monospace, "Symbols Nerd Font Mono";
+ font-size: 11pt;
+}
+
+window {
+ background-color: #282828;
+ color: #ebdbb2;
+ border: 1px solid #fabd2f;
+}
+
+#input {
+ /* belt-and-suspenders: hidden via --hide-search too */
+ min-height: 0;
+ margin: 0;
+ padding: 0;
+ border: 0;
+ opacity: 0;
+}
+
+#inner-box {
+ padding: 4px;
+}
+
+#entry {
+ padding: 2px 6px;
+ background-color: transparent;
+ color: #ebdbb2;
+}
+
+#entry:selected,
+#entry:focus,
+#entry selected {
+ background-color: #3c3836;
+ color: #fabd2f;
+}
+
+#text {
+ color: inherit;
+}
diff --git a/meta/wayland.txt b/meta/wayland.txt
index 413e47b..2550083 100644
--- a/meta/wayland.txt
+++ b/meta/wayland.txt
@@ -8,6 +8,9 @@ qt6-wayland
# Bar & launcher
waybar
fuzzel
+# wofi: secondary picker used only by mako-history.sh — needs --hide-search
+# and per-key custom bindings, neither of which fuzzel supports.
+wofi
# Terminal
ghostty