diff options
| author | 2026-05-13 13:43:29 +0100 | |
|---|---|---|
| committer | 2026-05-13 13:43:29 +0100 | |
| commit | e331fd63eaa51a51a6af06560bbe226a6d47fa16 (patch) | |
| tree | 0c6bb57823f1710b297c6e83e618715f6659ca7f /dot_config/waybar/executable_mako-history.py | |
| parent | 0af53da3eb2574ca23758e6658b7683bcee4d6da (diff) | |
| download | dotfiles-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.py | 153 |
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() |
