aboutsummaryrefslogtreecommitdiffstatshomepage
path: root/dot_config/waybar/executable_mako-history.py
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 /dot_config/waybar/executable_mako-history.py
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
Diffstat (limited to 'dot_config/waybar/executable_mako-history.py')
-rw-r--r--dot_config/waybar/executable_mako-history.py153
1 files changed, 153 insertions, 0 deletions
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()