From e331fd63eaa51a51a6af06560bbe226a6d47fa16 Mon Sep 17 00:00:00 2001 From: sommerfeld Date: Wed, 13 May 2026 13:43:29 +0100 Subject: feat(notifications): persistent-pending model + wofi history picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- KEYBINDS.md | 8 +- dot_config/sway/config | 8 +- dot_config/waybar/config.jsonc | 2 +- dot_config/waybar/executable_dismiss-visible.sh | 33 +++++ dot_config/waybar/executable_mako-history.py | 153 ++++++++++++++++++++++++ dot_config/waybar/executable_mako-history.sh | 25 ---- dot_config/waybar/executable_mako-status.sh | 55 ++++++--- dot_config/waybar/executable_restore-pending.sh | 21 ++++ dot_config/wofi/style.css | 46 +++++++ meta/wayland.txt | 3 + 10 files changed, 305 insertions(+), 49 deletions(-) create mode 100644 dot_config/waybar/executable_dismiss-visible.sh create mode 100644 dot_config/waybar/executable_mako-history.py delete mode 100755 dot_config/waybar/executable_mako-history.sh create mode 100644 dot_config/waybar/executable_restore-pending.sh create mode 100644 dot_config/wofi/style.css 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 -- cgit v1.3.1