diff --git a/desktopenvs/hyprdrive/beacon/config.py b/desktopenvs/hyprdrive/beacon/config.py
new file mode 100644
index 0000000..e417260
--- /dev/null
+++ b/desktopenvs/hyprdrive/beacon/config.py
@@ -0,0 +1,45 @@
+"""Tiny user-editable config file: ~/.local/state/beacon/config.json.
+
+Read once at startup (main.py, via server.py); a change takes effect on the
+next beacon-start.sh restart, not live. Same pattern as orbit-menu/horizon-
+dock/astro-menu/station-bar's config.py.
+"""
+
+from __future__ import annotations
+
+import json
+
+from paths import CONFIG_FILE, ensure_dirs
+
+_DEFAULTS = {
+ "history_enabled": True,
+ "history_length": 100,
+ "history_persist": True,
+}
+
+
+def _load() -> dict:
+ try:
+ data = json.loads(CONFIG_FILE.read_text())
+ except (FileNotFoundError, json.JSONDecodeError):
+ data = {}
+ merged = {**_DEFAULTS, **data}
+ if not CONFIG_FILE.exists():
+ ensure_dirs()
+ CONFIG_FILE.write_text(json.dumps(merged, indent=2) + "\n")
+ return merged
+
+
+def history_enabled() -> bool:
+ return bool(_load().get("history_enabled", True))
+
+
+def history_length() -> int:
+ try:
+ return int(_load().get("history_length", 100))
+ except (TypeError, ValueError):
+ return 100
+
+
+def history_persist() -> bool:
+ return bool(_load().get("history_persist", True))
diff --git a/desktopenvs/hyprdrive/beacon/controls.py b/desktopenvs/hyprdrive/beacon/controls.py
new file mode 100644
index 0000000..87ce057
--- /dev/null
+++ b/desktopenvs/hyprdrive/beacon/controls.py
@@ -0,0 +1,81 @@
+"""Parsing/validation for the x-beacon-controls custom notification hint.
+
+Sending apps opt into rich, embedded controls (beyond the spec's plain
+fire-and-dismiss `actions` array) via a hint whose value is a JSON string —
+chosen over a nested D-Bus variant struct so any app, in any language, can
+produce it without touching GVariant construction. Advertised in
+GetCapabilities as "x-beacon-controls" so senders can detect support and
+fall back to plain `actions` against any other freedesktop-compliant daemon.
+
+Shape:
+ [
+ {"type": "button", "id": "reply", "label": "Reply"},
+ {"type": "toggle", "id": "mute", "label": "Mute", "value": false},
+ {"type": "slider", "id": "volume", "label": "Volume", "value": 40, "min": 0, "max": 100},
+ {"type": "entry", "id": "msg", "label": "Message", "placeholder": "Type a reply..."}
+ ]
+
+Unknown "type"s and unrecognised extra keys are silently dropped rather than
+erroring — forward-compatible with control types added later, and never lets
+a malformed/hostile payload (any local session-bus process can call Notify)
+crash the daemon.
+"""
+
+from __future__ import annotations
+
+import json
+from typing import Optional
+
+_TYPES = {"button", "toggle", "slider", "entry"}
+# a button auto-dismisses like a classic action by default; the stateful
+# controls default to staying on-screen so you can keep adjusting them.
+_DEFAULT_DISMISS = {"button": True, "toggle": False, "slider": False, "entry": False}
+
+
+def parse_controls(raw: Optional[str]) -> list[dict]:
+ if not raw:
+ return []
+ try:
+ data = json.loads(raw)
+ except (json.JSONDecodeError, TypeError):
+ return []
+ if not isinstance(data, list):
+ return []
+
+ out: list[dict] = []
+ for item in data:
+ if not isinstance(item, dict):
+ continue
+ ctype = item.get("type")
+ cid = item.get("id")
+ if ctype not in _TYPES or not isinstance(cid, str) or not cid:
+ continue
+ label = item.get("label")
+ label = label if isinstance(label, str) else cid
+ dismiss = item.get("dismiss")
+ dismiss = bool(dismiss) if isinstance(dismiss, bool) else _DEFAULT_DISMISS[ctype]
+
+ control = {"type": ctype, "id": cid, "label": label, "dismiss": dismiss}
+
+ if ctype == "toggle":
+ control["value"] = bool(item.get("value", False))
+ elif ctype == "slider":
+ control["value"] = _as_float(item.get("value"), 0.0)
+ control["min"] = _as_float(item.get("min"), 0.0)
+ control["max"] = _as_float(item.get("max"), 100.0)
+ control["step"] = _as_float(item.get("step"), 1.0)
+ if control["max"] <= control["min"]:
+ control["max"] = control["min"] + 1.0
+ elif ctype == "entry":
+ placeholder = item.get("placeholder")
+ control["placeholder"] = placeholder if isinstance(placeholder, str) else ""
+
+ out.append(control)
+ return out
+
+
+def _as_float(value, default: float) -> float:
+ try:
+ return float(value)
+ except (TypeError, ValueError):
+ return default
diff --git a/desktopenvs/hyprdrive/beacon/history.py b/desktopenvs/hyprdrive/beacon/history.py
new file mode 100644
index 0000000..483d57b
--- /dev/null
+++ b/desktopenvs/hyprdrive/beacon/history.py
@@ -0,0 +1,109 @@
+"""Bounded, disk-persisted notification history.
+
+Pure data structure — no D-Bus/GLib knowledge, mirrors the window/server
+separation used elsewhere in beacon (window.py knows nothing about D-Bus
+either). server.py owns turning these entries into GLib.Variant dicts and
+emitting HistoryAdded/HistoryRemoved/HistoryCleared.
+
+Entry schema (plain dict, JSON-safe, deliberately open so new keys can be
+added later without breaking old readers):
+ id (int), app_name (str), summary (str), body (str), icon (str),
+ urgency (int), timestamp (float, unix seconds), reason (int),
+ actions (list[str], flat key/label pairs), controls (str | None — the raw
+ x-beacon-controls JSON hint, re-parsed via controls.parse_controls on Pop).
+
+image_data (raw pixel bytes) is deliberately never stored here — it would
+bloat history.json and doesn't round-trip through JSON cleanly; a
+popped-from-history card just falls back to its `icon` string.
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import tempfile
+from typing import Optional
+
+from paths import CACHE_DIR, HISTORY_FILE, ensure_dirs
+
+
+class HistoryStore:
+ def __init__(self, maxlen: int, persist: bool) -> None:
+ self._maxlen = max(1, maxlen)
+ self._persist = persist
+ self._entries: list[dict] = self._load() if persist else [] # newest first
+
+ # -- mutation ---------------------------------------------------------------
+ def record(self, entry: dict) -> Optional[dict]:
+ """Insert newest-first; returns the evicted entry if capacity was hit."""
+ self._entries.insert(0, entry)
+ evicted = None
+ while len(self._entries) > self._maxlen:
+ evicted = self._entries.pop()
+ self._save()
+ return evicted
+
+ def pop(self, nid: int) -> Optional[dict]:
+ """Remove and return a specific entry (for History1.Pop)."""
+ for i, e in enumerate(self._entries):
+ if e["id"] == nid:
+ entry = self._entries.pop(i)
+ self._save()
+ return entry
+ return None
+
+ def pop_latest(self) -> Optional[dict]:
+ """Remove and return the most recently recorded entry (LIFO, like
+ dunstctl history-pop)."""
+ if not self._entries:
+ return None
+ entry = self._entries.pop(0)
+ self._save()
+ return entry
+
+ def remove(self, nid: int) -> bool:
+ for i, e in enumerate(self._entries):
+ if e["id"] == nid:
+ del self._entries[i]
+ self._save()
+ return True
+ return False
+
+ def clear(self) -> list[int]:
+ ids = [e["id"] for e in self._entries]
+ self._entries = []
+ self._save()
+ return ids
+
+ # -- read ---------------------------------------------------------------------
+ def list_all(self) -> list[dict]:
+ return list(self._entries)
+
+ def get(self, nid: int) -> Optional[dict]:
+ for e in self._entries:
+ if e["id"] == nid:
+ return e
+ return None
+
+ # -- persistence ------------------------------------------------------------
+ def _load(self) -> list[dict]:
+ try:
+ data = json.loads(HISTORY_FILE.read_text())
+ except (FileNotFoundError, json.JSONDecodeError):
+ return []
+ return data if isinstance(data, list) else []
+
+ def _save(self) -> None:
+ if not self._persist:
+ return
+ ensure_dirs()
+ fd, tmp = tempfile.mkstemp(dir=str(CACHE_DIR), prefix=".history-", suffix=".json")
+ try:
+ with os.fdopen(fd, "w") as f:
+ json.dump(self._entries, f)
+ os.replace(tmp, HISTORY_FILE)
+ except OSError:
+ try:
+ os.unlink(tmp)
+ except OSError:
+ pass
diff --git a/desktopenvs/hyprdrive/beacon/notification.py b/desktopenvs/hyprdrive/beacon/notification.py
index 3ee6ef4..7951407 100644
--- a/desktopenvs/hyprdrive/beacon/notification.py
+++ b/desktopenvs/hyprdrive/beacon/notification.py
@@ -46,11 +46,13 @@ def _rounded_path(cr, w: float, h: float, r: float = _CARD_RADIUS) -> None:
class NotificationCard:
def __init__(self, nid: int, summary: str, body: str, urgency: int,
- icon: str, image_data, actions: list[str],
+ icon: str, image_data, actions: list[str], controls: list[dict],
on_action: Callable[[int, str], None],
+ on_control: Callable[[int, str, object], None],
on_dismiss: Callable[[int], None]) -> None:
self.nid = nid
self._on_action = on_action
+ self._on_control = on_control
self._on_dismiss = on_dismiss
self._dismissing = False
critical = urgency >= 2
@@ -105,6 +107,11 @@ class NotificationCard:
if row is not None:
textcol.append(row)
+ if controls:
+ crows = self._build_controls(controls)
+ for crow in crows:
+ textcol.append(crow)
+
card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
card.add_css_class("beacon-card")
if critical:
@@ -193,6 +200,74 @@ class NotificationCard:
shown += 1
return row if shown else None
+ def _build_controls(self, controls: list[dict]) -> list[Gtk.Widget]:
+ # button-type controls share one pill row (same idiom as plain actions);
+ # each stateful control (toggle/slider/entry) gets its own full-width row,
+ # since a slider/entry can't sensibly squeeze into a compact pill.
+ rows: list[Gtk.Widget] = []
+ btn_row: Optional[Gtk.Box] = None
+ for c in controls:
+ if c["type"] == "button":
+ if btn_row is None:
+ btn_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
+ btn_row.add_css_class("beacon-actions")
+ btn_row.add_css_class("beacon-control-row")
+ btn_row.set_margin_top(6)
+ rows.append(btn_row)
+ btn = Gtk.Button(label=c["label"])
+ btn.add_css_class("beacon-action")
+ btn.connect("clicked", lambda _b, c=c: self._fire_control(c, True))
+ btn_row.append(btn)
+ continue
+
+ row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
+ row.add_css_class("beacon-control-row")
+ row.set_margin_top(6)
+ lbl = Gtk.Label(label=c["label"], xalign=0.0)
+ lbl.add_css_class("beacon-control-label")
+ row.append(lbl)
+
+ if c["type"] == "toggle":
+ sw = Gtk.Switch()
+ sw.add_css_class("beacon-toggle")
+ sw.set_active(bool(c["value"]))
+ sw.set_valign(Gtk.Align.CENTER)
+ sw.set_hexpand(True)
+ sw.set_halign(Gtk.Align.END)
+ sw.connect("state-set", lambda _s, state, c=c: self._on_toggle(c, state))
+ row.append(sw)
+ elif c["type"] == "slider":
+ adj = Gtk.Adjustment(value=c["value"], lower=c["min"], upper=c["max"],
+ step_increment=c["step"])
+ scale = Gtk.Scale(orientation=Gtk.Orientation.HORIZONTAL, adjustment=adj)
+ scale.add_css_class("beacon-slider")
+ scale.set_hexpand(True)
+ scale.set_draw_value(False)
+ scale.connect("value-changed",
+ lambda s, c=c: self._fire_control(c, s.get_value()))
+ row.append(scale)
+ elif c["type"] == "entry":
+ entry = Gtk.Entry()
+ entry.add_css_class("beacon-entry")
+ entry.set_hexpand(True)
+ if c.get("placeholder"):
+ entry.set_placeholder_text(c["placeholder"])
+ entry.connect("activate",
+ lambda e, c=c: self._fire_control(c, e.get_text()))
+ row.append(entry)
+
+ rows.append(row)
+ return rows
+
+ def _on_toggle(self, control: dict, state: bool) -> bool:
+ self._fire_control(control, state)
+ return False # False = let Gtk.Switch apply the requested visual state itself
+
+ def _fire_control(self, control: dict, value) -> None:
+ self._on_control(self.nid, control["id"], value)
+ if control.get("dismiss"):
+ self.dismiss()
+
def _build_icon(self, icon: str, image_data) -> Optional[Gtk.Image]:
img: Optional[Gtk.Image] = None
if image_data is not None:
diff --git a/desktopenvs/hyprdrive/beacon/paths.py b/desktopenvs/hyprdrive/beacon/paths.py
index ec65230..d98ad92 100644
--- a/desktopenvs/hyprdrive/beacon/paths.py
+++ b/desktopenvs/hyprdrive/beacon/paths.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+import os
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent
@@ -15,3 +16,26 @@ APP_ID = "eu.abdelbaki.beacon"
FDN_NAME = "org.freedesktop.Notifications"
FDN_PATH = "/org/freedesktop/Notifications"
FDN_IFACE = "org.freedesktop.Notifications"
+
+# Custom interfaces colocated at FDN_PATH, same well-known bus name — mirrors
+# dunst parking org.dunstproject.cmd0 alongside its own freedesktop interface
+# rather than using a bespoke object path.
+HISTORY_IFACE = "eu.abdelbaki.beacon.History1"
+CONTROLS_IFACE = "eu.abdelbaki.beacon.Controls1"
+
+# Notification history persists under XDG_CACHE_HOME, like the layouts
+# registry's ~/.cache/astro-menu/layouts.json — NOT ~/.config/beacon, which
+# config-updater wipes and re-copies on every dotfiles sync.
+CACHE_DIR = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "beacon"
+HISTORY_FILE = CACHE_DIR / "history.json"
+
+# User settings live under XDG_STATE_HOME, same rationale/convention as
+# station-bar/paths.py (config-updater's rm -rf would wipe a hand-edited
+# ~/.config/beacon/config.json otherwise).
+STATE_DIR = Path(os.environ.get("XDG_STATE_HOME", Path.home() / ".local" / "state")) / "beacon"
+CONFIG_FILE = STATE_DIR / "config.json"
+
+
+def ensure_dirs() -> None:
+ CACHE_DIR.mkdir(parents=True, exist_ok=True)
+ STATE_DIR.mkdir(parents=True, exist_ok=True)
diff --git a/desktopenvs/hyprdrive/beacon/server.py b/desktopenvs/hyprdrive/beacon/server.py
index eb62791..cd3ce54 100644
--- a/desktopenvs/hyprdrive/beacon/server.py
+++ b/desktopenvs/hyprdrive/beacon/server.py
@@ -3,6 +3,19 @@ to own. Parses each Notify call and hands it to the stack window (window.py) to
render as a holographic card; owns per-notification expiry timers and emits the
spec's NotificationClosed / ActionInvoked signals.
+Also owns two custom interfaces colocated at the same object path (mirrors
+dunst parking org.dunstproject.cmd0 alongside its own freedesktop interface):
+
+ eu.abdelbaki.beacon.History1 disk-persisted notification history —
+ List/Get/Pop/PopLatest/Remove/Clear/
+ InvokeAction + HistoryAdded/Removed/Cleared
+ signals. See history.py for the storage side.
+
+ eu.abdelbaki.beacon.Controls1 push-only: ControlChanged(id, control_id,
+ value) for the rich embedded controls apps
+ can opt into via the x-beacon-controls hint
+ (see controls.py).
+
Deliberately small: the visible/interactive spec subset (body markup, actions,
icons, urgency, replaces_id, timeouts, persistence) — no sound/markup-hint
gymnastics.
@@ -10,12 +23,17 @@ gymnastics.
from __future__ import annotations
+import time
+
import gi
gi.require_version("Gtk", "4.0")
from gi.repository import Gio, GLib # noqa: E402
-from paths import FDN_IFACE, FDN_PATH
+import config
+from controls import parse_controls
+from history import HistoryStore
+from paths import CONTROLS_IFACE, FDN_IFACE, FDN_PATH, HISTORY_IFACE
# reasons per the spec: 1 expired, 2 dismissed by user, 3 closed by call, 4 undefined
_INTROSPECTION_XML = """
@@ -56,6 +74,60 @@ _INTROSPECTION_XML = """
"""
+# a{sv} per entry (not a fixed tuple shape) so new fields can be added later
+# without breaking existing clients — the same shape dunst's own
+# NotificationListHistory uses.
+_HISTORY_INTROSPECTION_XML = """
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+"""
+
+_CONTROLS_INTROSPECTION_XML = """
+
+
+
+
+
+
+
+
+
+"""
+
# server default timeouts (ms) by urgency when the client passes -1
_DEFAULT_TIMEOUT = {0: 5000, 1: 8000, 2: 0} # low / normal / critical(never)
@@ -66,13 +138,29 @@ class NotificationServer:
self._window = window
self._next_id = 1
self._timers: dict[int, int] = {}
+ # metadata for still-visible notifications, keyed by id — snapshotted at
+ # Notify() time (before render) so emit_closed() can hand a full record
+ # to history without window.py/notification.py needing to know about it.
+ self._live_meta: dict[int, dict] = {}
+
+ self._history_enabled = config.history_enabled()
+ self._history = HistoryStore(maxlen=config.history_length(),
+ persist=config.history_persist())
node = Gio.DBusNodeInfo.new_for_xml(_INTROSPECTION_XML)
self._iface = node.interfaces[0]
connection.register_object(
FDN_PATH, self._iface, self._on_method_call, None, None)
- # -- D-Bus dispatch -------------------------------------------------------
+ hnode = Gio.DBusNodeInfo.new_for_xml(_HISTORY_INTROSPECTION_XML)
+ connection.register_object(
+ FDN_PATH, hnode.interfaces[0], self._on_history_method_call, None, None)
+
+ cnode = Gio.DBusNodeInfo.new_for_xml(_CONTROLS_INTROSPECTION_XML)
+ connection.register_object(
+ FDN_PATH, cnode.interfaces[0], self._on_controls_method_call, None, None)
+
+ # -- D-Bus dispatch: org.freedesktop.Notifications -------------------------
def _on_method_call(self, _conn, _sender, _path, _iface, method, params, invocation):
if method == "Notify":
invocation.return_value(GLib.Variant("(u)", (self._notify(params),)))
@@ -83,7 +171,8 @@ class NotificationServer:
invocation.return_value(None)
elif method == "GetCapabilities":
invocation.return_value(GLib.Variant("(as)", (
- ["body", "body-markup", "icon-static", "actions", "persistence"],)))
+ ["body", "body-markup", "icon-static", "actions", "persistence",
+ "x-beacon-controls"],)))
elif method == "GetServerInformation":
invocation.return_value(GLib.Variant("(ssss)", (
"beacon", "abdelbaki.eu", "1.0", "1.2")))
@@ -104,10 +193,19 @@ class NotificationServer:
or hints.get("icon_data"))
image_path = hints.get("image-path") or hints.get("image_path")
icon = image_path or app_icon or ""
+ controls_raw = hints.get("x-beacon-controls")
+ controls = parse_controls(controls_raw)
+
+ self._live_meta[nid] = {
+ "id": nid, "app_name": app_name or "", "summary": summary,
+ "body": body, "urgency": urgency, "icon": icon,
+ "actions": list(actions), "controls": controls_raw,
+ "timestamp": time.time(),
+ }
self._window.show_notification(
nid, summary, body, urgency, icon, image_data, list(actions),
- self._emit_action)
+ controls, self._emit_action, self._emit_control)
self._arm_timer(nid, int(expire_timeout), urgency)
return nid
@@ -133,14 +231,140 @@ class NotificationServer:
if tid is not None:
GLib.source_remove(tid)
- # -- signals (also the window's on_closed / on_action callbacks) -----------
+ # -- signals (also the window's on_closed / on_action / on_control callbacks)
def emit_closed(self, nid: int, reason: int) -> None:
self._cancel_timer(nid)
self._conn.emit_signal(None, FDN_PATH, FDN_IFACE, "NotificationClosed",
GLib.Variant("(uu)", (nid, reason)))
+ meta = self._live_meta.pop(nid, None)
+ if meta is not None and self._history_enabled:
+ entry = {**meta, "reason": reason}
+ evicted = self._history.record(entry)
+ self._emit_history_signal("HistoryAdded", GLib.Variant(
+ "(a{sv})", (_entry_dict(entry),)))
+ if evicted is not None:
+ self._emit_history_signal("HistoryRemoved", GLib.Variant(
+ "(u)", (int(evicted["id"]),)))
def _emit_action(self, nid: int, key: str) -> None:
self._conn.emit_signal(None, FDN_PATH, FDN_IFACE, "ActionInvoked",
GLib.Variant("(us)", (nid, key)))
# dismiss the card once its action fired, matching typical daemon behaviour
self._window.close_notification(nid, reason=2)
+
+ def _emit_control(self, nid: int, control_id: str, value) -> None:
+ self._conn.emit_signal(None, FDN_PATH, CONTROLS_IFACE, "ControlChanged",
+ GLib.Variant("(usv)", (nid, control_id, _variant_for(value))))
+
+ def _emit_history_signal(self, name: str, payload: GLib.Variant) -> None:
+ self._conn.emit_signal(None, FDN_PATH, HISTORY_IFACE, name, payload)
+
+ # -- D-Bus dispatch: eu.abdelbaki.beacon.History1 --------------------------
+ def _on_history_method_call(self, _conn, _sender, _path, _iface, method, params, invocation):
+ if method == "List":
+ entries = [_entry_dict(e) for e in self._history.list_all()]
+ invocation.return_value(GLib.Variant("(aa{sv})", (entries,)))
+ elif method == "Get":
+ (nid,) = params.unpack()
+ entry = self._history.get(int(nid))
+ if entry is None:
+ invocation.return_error_literal(
+ Gio.dbus_error_quark(), Gio.DBusError.FAILED,
+ f"no history entry with id {nid}")
+ else:
+ invocation.return_value(GLib.Variant("(a{sv})", (_entry_dict(entry),)))
+ elif method == "Pop":
+ (nid,) = params.unpack()
+ entry = self._history.pop(int(nid))
+ if entry is None:
+ invocation.return_error_literal(
+ Gio.dbus_error_quark(), Gio.DBusError.FAILED,
+ f"no history entry with id {nid}")
+ else:
+ self._emit_history_signal("HistoryRemoved", GLib.Variant("(u)", (int(nid),)))
+ invocation.return_value(GLib.Variant("(u)", (self._redisplay(entry),)))
+ elif method == "PopLatest":
+ entry = self._history.pop_latest()
+ if entry is None:
+ invocation.return_error_literal(
+ Gio.dbus_error_quark(), Gio.DBusError.FAILED, "history is empty")
+ else:
+ self._emit_history_signal(
+ "HistoryRemoved", GLib.Variant("(u)", (int(entry["id"]),)))
+ invocation.return_value(GLib.Variant("(u)", (self._redisplay(entry),)))
+ elif method == "Remove":
+ (nid,) = params.unpack()
+ if self._history.remove(int(nid)):
+ self._emit_history_signal("HistoryRemoved", GLib.Variant("(u)", (int(nid),)))
+ invocation.return_value(None)
+ elif method == "Clear":
+ removed = self._history.clear()
+ self._emit_history_signal("HistoryCleared", GLib.Variant("(u)", (len(removed),)))
+ invocation.return_value(None)
+ elif method == "InvokeAction":
+ (nid, key) = params.unpack()
+ entry = self._history.get(int(nid))
+ if entry is None:
+ invocation.return_error_literal(
+ Gio.dbus_error_quark(), Gio.DBusError.FAILED,
+ f"no history entry with id {nid}")
+ else:
+ # Re-emit ActionInvoked for a *historical* entry without bringing the
+ # card back on-screen — beacon (unlike dunst) doesn't void actions once
+ # a notification leaves the queue, since invoking one has always just
+ # meant "emit the signal," independent of whether a card widget exists.
+ self._conn.emit_signal(None, FDN_PATH, FDN_IFACE, "ActionInvoked",
+ GLib.Variant("(us)", (int(nid), key)))
+ invocation.return_value(None)
+ else:
+ invocation.return_error_literal(
+ Gio.dbus_error_quark(), Gio.DBusError.UNKNOWN_METHOD, method)
+
+ def _redisplay(self, entry: dict) -> int:
+ """Re-render a history entry as a live card (History1.Pop/PopLatest)."""
+ new_nid = self._next_id
+ self._next_id += 1
+ controls = parse_controls(entry.get("controls"))
+ self._window.show_notification(
+ new_nid, entry.get("summary", ""), entry.get("body", ""),
+ int(entry.get("urgency", 1)), entry.get("icon", ""), None,
+ list(entry.get("actions") or []), controls,
+ self._emit_action, self._emit_control)
+ self._live_meta[new_nid] = {
+ "id": new_nid, "app_name": entry.get("app_name", ""),
+ "summary": entry.get("summary", ""), "body": entry.get("body", ""),
+ "urgency": int(entry.get("urgency", 1)), "icon": entry.get("icon", ""),
+ "actions": list(entry.get("actions") or []),
+ "controls": entry.get("controls"), "timestamp": time.time(),
+ }
+ self._arm_timer(new_nid, -1, int(entry.get("urgency", 1)))
+ return new_nid
+
+ # -- D-Bus dispatch: eu.abdelbaki.beacon.Controls1 (signal-only, no methods)
+ def _on_controls_method_call(self, _conn, _sender, _path, _iface, method, _params, invocation):
+ invocation.return_error_literal(
+ Gio.dbus_error_quark(), Gio.DBusError.UNKNOWN_METHOD, method)
+
+
+def _entry_dict(entry: dict) -> dict:
+ """A plain dict with GLib.Variant leaf values, ready to embed in an a{sv}."""
+ return {
+ "id": GLib.Variant("u", int(entry["id"])),
+ "app_name": GLib.Variant("s", entry.get("app_name") or ""),
+ "summary": GLib.Variant("s", entry.get("summary") or ""),
+ "body": GLib.Variant("s", entry.get("body") or ""),
+ "icon": GLib.Variant("s", entry.get("icon") or ""),
+ "urgency": GLib.Variant("i", int(entry.get("urgency", 1))),
+ "timestamp": GLib.Variant("d", float(entry.get("timestamp", 0.0))),
+ "reason": GLib.Variant("i", int(entry.get("reason", 0))),
+ "actions": GLib.Variant("as", list(entry.get("actions") or [])),
+ "controls": GLib.Variant("s", entry.get("controls") or ""),
+ }
+
+
+def _variant_for(value) -> GLib.Variant:
+ if isinstance(value, bool):
+ return GLib.Variant("b", value)
+ if isinstance(value, (int, float)):
+ return GLib.Variant("d", float(value))
+ return GLib.Variant("s", str(value))
diff --git a/desktopenvs/hyprdrive/beacon/style/style.css b/desktopenvs/hyprdrive/beacon/style/style.css
index 0ca352d..749e1c3 100644
--- a/desktopenvs/hyprdrive/beacon/style/style.css
+++ b/desktopenvs/hyprdrive/beacon/style/style.css
@@ -90,4 +90,63 @@ drawingarea {
box-shadow: 0 0 12px 1px alpha(@accent, 0.55);
}
+/* embedded controls (x-beacon-controls hint) — same violet-glass/magenta/
+ * accent palette as .beacon-action, just in the shapes GTK ships (switch/
+ * scale/entry) instead of another Cairo-drawn widget. */
+.beacon-control-row { background: transparent; }
+
+.beacon-control-label {
+ color: @text;
+ font-size: 11pt;
+ opacity: 0.95;
+}
+
+.beacon-toggle {
+ background-color: alpha(@violet, 0.4);
+ border: 2px solid #8A5CFF;
+ border-radius: 20px;
+ min-width: 40px;
+ min-height: 22px;
+}
+.beacon-toggle:checked {
+ background-color: alpha(@accent, 0.5);
+ border-color: @accent;
+ box-shadow: 0 0 10px 1px alpha(@accent, 0.45);
+}
+.beacon-toggle slider {
+ background-color: @text;
+ border-radius: 50%;
+}
+
+.beacon-slider trough {
+ background-color: alpha(@violet, 0.4);
+ border: 2px solid #8A5CFF;
+ border-radius: 20px;
+ min-height: 8px;
+}
+.beacon-slider highlight {
+ background-color: @accent;
+ border-radius: 20px;
+}
+.beacon-slider slider {
+ background-color: @text;
+ border: 2px solid #8A5CFF;
+ border-radius: 50%;
+ min-width: 14px;
+ min-height: 14px;
+}
+
+.beacon-entry {
+ color: @text;
+ background-color: alpha(@violet, 0.4);
+ border: 2px solid #8A5CFF;
+ border-radius: 12px;
+ padding: 2px 10px;
+ min-height: 22px;
+}
+.beacon-entry:focus-within {
+ border-color: @accent;
+ box-shadow: 0 0 12px 1px alpha(@accent, 0.55);
+}
+
.beacon-hologram { background: transparent; }
diff --git a/desktopenvs/hyprdrive/beacon/window.py b/desktopenvs/hyprdrive/beacon/window.py
index 3b473c1..d71103a 100644
--- a/desktopenvs/hyprdrive/beacon/window.py
+++ b/desktopenvs/hyprdrive/beacon/window.py
@@ -57,12 +57,15 @@ class BeaconWindow(Gtk.ApplicationWindow):
# -- public: driven by the D-Bus server -----------------------------------
def show_notification(self, nid: int, summary: str, body: str, urgency: int,
icon: str, image_data, actions: list[str],
- on_action: Callable[[int, str], None]) -> None:
+ controls: list[dict],
+ on_action: Callable[[int, str], None],
+ on_control: Callable[[int, str, object], None]) -> None:
if nid in self._cards: # replaces_id: swap in place
self._drop_card(nid)
card = NotificationCard(nid, summary, body, urgency, icon, image_data,
- actions, on_action, self._card_dismissed)
+ actions, controls, on_action, on_control,
+ self._card_dismissed)
self._cards[nid] = card
self._order.insert(0, nid)
self._stack.prepend(card.widget)
diff --git a/desktopenvs/hyprdrive/config-updater/updater.conf b/desktopenvs/hyprdrive/config-updater/updater.conf
index b4bf963..3c00491 100644
--- a/desktopenvs/hyprdrive/config-updater/updater.conf
+++ b/desktopenvs/hyprdrive/config-updater/updater.conf
@@ -23,6 +23,7 @@ config mimeapps.list
config orbit-menu
config scripts
config station-bar
+config transmitter-panel
config vicinae
config xfce4
diff --git a/desktopenvs/hyprdrive/hypr/usr/autostart.lua b/desktopenvs/hyprdrive/hypr/usr/autostart.lua
index 6f46364..a1ff123 100644
--- a/desktopenvs/hyprdrive/hypr/usr/autostart.lua
+++ b/desktopenvs/hyprdrive/hypr/usr/autostart.lua
@@ -10,6 +10,7 @@ hl.on("hyprland.start", function()
hl.exec_cmd("hyprsunset")
hl.exec_cmd("nm-applet")
hl.exec_cmd("bash ~/Dotfiles/desktopenvs/hyprdrive/scripts/beacon-start.sh")
+ hl.exec_cmd("bash ~/Dotfiles/desktopenvs/hyprdrive/scripts/transmitter-panel-start.sh")
hl.exec_cmd("[workspace special:magic silent] kitty")
hl.exec_cmd("hyprctl setcursor Nordzy-cursors-lefthand 50")
hl.exec_cmd("hyprpaper")
diff --git a/desktopenvs/hyprdrive/hypr/usr/binds.lua b/desktopenvs/hyprdrive/hypr/usr/binds.lua
index 8b0e3aa..e2b4886 100644
--- a/desktopenvs/hyprdrive/hypr/usr/binds.lua
+++ b/desktopenvs/hyprdrive/hypr/usr/binds.lua
@@ -357,6 +357,7 @@ hl.bind(mainMod .. " + CTRL + I", hl.dsp.exec_cmd("chamel toggle"))
hl.bind(mainMod .. " + CTRL + U", hl.dsp.exec_cmd("chamel clear"))
hl.bind(mainMod .. " + CTRL + Z", hl.dsp.exec_cmd("chamel clear-and-deactivate"))
hl.bind(mainMod .. " + CTRL + C", hl.dsp.exec_cmd("pkill -USR1 -f '[b]eacon/main.py'"))
+hl.bind(mainMod .. " + CTRL + N", hl.dsp.exec_cmd("~/.config/scripts/transmitter-panel.sh"))
hl.bind(mainMod .. " + CTRL + G", hl.dsp.exec_cmd("~/.config/scripts/onscreenkb.sh"))
hl.bind(mainMod .. " + SHIFT + C", hl.dsp.exec_cmd("~/.config/scripts/caffeine.sh"))
hl.bind(mainMod .. " + SHIFT + B", hl.dsp.exec_cmd("[tag +centered-S] kitty bash ~/.config/scripts/enroll-biometrics.sh"))
diff --git a/desktopenvs/hyprdrive/hypr/usr/windowrules.lua b/desktopenvs/hyprdrive/hypr/usr/windowrules.lua
index f63aab1..400c6ae 100644
--- a/desktopenvs/hyprdrive/hypr/usr/windowrules.lua
+++ b/desktopenvs/hyprdrive/hypr/usr/windowrules.lua
@@ -136,7 +136,7 @@ hl.window_rule({
-- NOT slide in from an edge — each instead "materialises out of static" via its
-- own in-app hologram intro (start_intro), so the compositor should just map them
-- in place and let that intro be the whole opening effect.
-for _, ns in ipairs({ "astro-menu", "orbit-menu", "horizon-dock", "station-bar" }) do
+for _, ns in ipairs({ "astro-menu", "orbit-menu", "horizon-dock", "station-bar", "transmitter-panel" }) do
hl.layer_rule({ name = "cosmoshell-blur-" .. ns, match = { namespace = ns },
blur = true, ignore_alpha = 0.1, no_anim = true })
end
diff --git a/desktopenvs/hyprdrive/scripts/beaconctl b/desktopenvs/hyprdrive/scripts/beaconctl
new file mode 100755
index 0000000..0458be4
--- /dev/null
+++ b/desktopenvs/hyprdrive/scripts/beaconctl
@@ -0,0 +1,141 @@
+#!/usr/bin/env python3
+"""beaconctl — a small CLI for beacon's D-Bus surface, the "dunstctl" beacon
+never had (screenrec.sh has its own note on this gap). Talks to:
+
+ org.freedesktop.Notifications (CloseNotification)
+ eu.abdelbaki.beacon.History1 (List/Get/Pop/PopLatest/Remove/Clear)
+
+`close-all` is the one exception: it isn't a D-Bus call (beacon doesn't
+expose window.close_all() over the bus), it's the same SIGUSR1 signal the
+Super+Ctrl+C keybind already sends.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import subprocess
+import sys
+
+import gi
+
+gi.require_version("Gtk", "4.0")
+from gi.repository import Gio, GLib # noqa: E402
+
+FDN_NAME = "org.freedesktop.Notifications"
+FDN_PATH = "/org/freedesktop/Notifications"
+FDN_IFACE = "org.freedesktop.Notifications"
+HISTORY_IFACE = "eu.abdelbaki.beacon.History1"
+
+
+def _proxy(iface: str) -> Gio.DBusProxy:
+ return Gio.DBusProxy.new_for_bus_sync(
+ Gio.BusType.SESSION, Gio.DBusProxyFlags.NONE, None,
+ FDN_NAME, FDN_PATH, iface, None)
+
+
+def cmd_list(args: argparse.Namespace) -> int:
+ proxy = _proxy(HISTORY_IFACE)
+ (entries,) = proxy.call_sync(
+ "List", None, Gio.DBusCallFlags.NONE, -1, None).unpack()
+ if args.json:
+ print(json.dumps(entries, indent=2))
+ return 0
+ if not entries:
+ print("(history is empty)")
+ return 0
+ for e in entries:
+ print(f"{e['id']:>4} {(e.get('app_name') or '?'):<20} {e.get('summary', '')}")
+ return 0
+
+
+def cmd_get(args: argparse.Namespace) -> int:
+ proxy = _proxy(HISTORY_IFACE)
+ (entry,) = proxy.call_sync(
+ "Get", GLib.Variant("(u)", (args.id,)), Gio.DBusCallFlags.NONE, -1, None).unpack()
+ print(json.dumps(entry, indent=2))
+ return 0
+
+
+def cmd_pop(args: argparse.Namespace) -> int:
+ proxy = _proxy(HISTORY_IFACE)
+ if args.id is None:
+ (new_id,) = proxy.call_sync(
+ "PopLatest", None, Gio.DBusCallFlags.NONE, -1, None).unpack()
+ else:
+ (new_id,) = proxy.call_sync(
+ "Pop", GLib.Variant("(u)", (args.id,)), Gio.DBusCallFlags.NONE, -1, None).unpack()
+ print(new_id)
+ return 0
+
+
+def cmd_remove(args: argparse.Namespace) -> int:
+ proxy = _proxy(HISTORY_IFACE)
+ proxy.call_sync(
+ "Remove", GLib.Variant("(u)", (args.id,)), Gio.DBusCallFlags.NONE, -1, None)
+ return 0
+
+
+def cmd_clear(_args: argparse.Namespace) -> int:
+ proxy = _proxy(HISTORY_IFACE)
+ proxy.call_sync("Clear", None, Gio.DBusCallFlags.NONE, -1, None)
+ return 0
+
+
+def cmd_close(args: argparse.Namespace) -> int:
+ proxy = _proxy(FDN_IFACE)
+ proxy.call_sync(
+ "CloseNotification", GLib.Variant("(u)", (args.id,)),
+ Gio.DBusCallFlags.NONE, -1, None)
+ return 0
+
+
+def cmd_close_all(_args: argparse.Namespace) -> int:
+ # bracket trick so pkill's own argv doesn't self-match, same as the
+ # Super+Ctrl+C keybind in hypr/usr/binds.lua
+ subprocess.run(["pkill", "-USR1", "-f", "[b]eacon/main.py"])
+ return 0
+
+
+def main() -> int:
+ p = argparse.ArgumentParser(prog="beaconctl", description=__doc__,
+ formatter_class=argparse.RawDescriptionHelpFormatter)
+ sub = p.add_subparsers(dest="command", required=True)
+
+ sp = sub.add_parser("list", help="list notification history, newest first")
+ sp.add_argument("--json", action="store_true")
+ sp.set_defaults(func=cmd_list)
+
+ sp = sub.add_parser("get", help="show one history entry as JSON")
+ sp.add_argument("id", type=int)
+ sp.set_defaults(func=cmd_get)
+
+ sp = sub.add_parser(
+ "pop", help="re-display a history entry (most recent if id omitted)")
+ sp.add_argument("id", type=int, nargs="?", default=None)
+ sp.set_defaults(func=cmd_pop)
+
+ sp = sub.add_parser("remove", help="remove one entry from history")
+ sp.add_argument("id", type=int)
+ sp.set_defaults(func=cmd_remove)
+
+ sp = sub.add_parser("clear", help="clear all history")
+ sp.set_defaults(func=cmd_clear)
+
+ sp = sub.add_parser("close", help="dismiss a live notification by id")
+ sp.add_argument("id", type=int)
+ sp.set_defaults(func=cmd_close)
+
+ sp = sub.add_parser("close-all", help="dismiss every visible notification")
+ sp.set_defaults(func=cmd_close_all)
+
+ args = p.parse_args()
+ try:
+ return args.func(args)
+ except GLib.GError as e:
+ print(f"beaconctl: {e.message}", file=sys.stderr)
+ return 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/desktopenvs/hyprdrive/scripts/transmitter-panel-start.sh b/desktopenvs/hyprdrive/scripts/transmitter-panel-start.sh
new file mode 100755
index 0000000..067af9e
--- /dev/null
+++ b/desktopenvs/hyprdrive/scripts/transmitter-panel-start.sh
@@ -0,0 +1,13 @@
+#!/usr/bin/env bash
+# Resident launcher for transmitter-panel, beacon's notification-history
+# viewer. Same LD_PRELOAD requirement and rationale as beacon-start.sh/
+# horizon-dock-start.sh: gtk4-layer-shell must load before libwayland-client,
+# which isn't guaranteed under PyGObject.
+
+APP="${HOME}/.config/transmitter-panel/main.py"
+SO="$(ldconfig -p 2>/dev/null | awk '/libgtk4-layer-shell\.so/ {print $NF; exit}')"
+if [[ -n "${SO:-}" ]]; then
+ export LD_PRELOAD="${SO}${LD_PRELOAD:+:${LD_PRELOAD}}"
+fi
+
+exec python3 "$APP" "$@"
diff --git a/desktopenvs/hyprdrive/scripts/transmitter-panel.sh b/desktopenvs/hyprdrive/scripts/transmitter-panel.sh
new file mode 100755
index 0000000..4bc76ae
--- /dev/null
+++ b/desktopenvs/hyprdrive/scripts/transmitter-panel.sh
@@ -0,0 +1,38 @@
+#!/usr/bin/env bash
+# Toggle the transmitter-panel (beacon's notification-history viewer).
+# Forwards a verb to the resident daemon over D-Bus; if the daemon isn't
+# running yet, starts it first. Mirrors horizon-dock.sh/astro-menu.sh.
+#
+# transmitter-panel.sh -> --toggle (default)
+# transmitter-panel.sh show -> --show
+# transmitter-panel.sh hide -> --hide
+#
+# (No `set -e`: a non-zero `busctl` in the wait loop is expected and must not
+# abort the script before it forwards the verb.)
+
+BUS="eu.abdelbaki.transmitterpanel"
+OBJ="/eu/abdelbaki/transmitterpanel"
+APP="${HOME}/.config/transmitter-panel/main.py"
+
+case "${1:-toggle}" in
+ show) VERB="--show"; ACTION="show" ;;
+ hide) VERB="--hide"; ACTION="hide" ;;
+ *) VERB="--toggle"; ACTION="toggle" ;;
+esac
+
+registered() { busctl --user list 2>/dev/null | grep -q "$BUS"; }
+
+if registered; then
+ exec gdbus call --session --dest "$BUS" --object-path "$OBJ" \
+ --method org.gtk.Actions.Activate "$ACTION" "[]" "{}" >/dev/null
+fi
+
+"${HOME}/.config/scripts/transmitter-panel-start.sh" >/dev/null 2>&1 &
+for _ in $(seq 1 25); do
+ if registered; then
+ exec "$0" "$@"
+ fi
+ sleep 0.2
+done
+
+exec python3 "$APP" "$VERB"
diff --git a/desktopenvs/hyprdrive/transmitter-panel/.gitignore b/desktopenvs/hyprdrive/transmitter-panel/.gitignore
new file mode 100644
index 0000000..7a60b85
--- /dev/null
+++ b/desktopenvs/hyprdrive/transmitter-panel/.gitignore
@@ -0,0 +1,2 @@
+__pycache__/
+*.pyc
diff --git a/desktopenvs/hyprdrive/transmitter-panel/config.py b/desktopenvs/hyprdrive/transmitter-panel/config.py
new file mode 100644
index 0000000..cfa698a
--- /dev/null
+++ b/desktopenvs/hyprdrive/transmitter-panel/config.py
@@ -0,0 +1,30 @@
+"""Tiny user-editable config file: ~/.local/state/transmitter-panel/config.json.
+
+Read once at startup (main.py); a change takes effect on the next
+transmitter-panel-start.sh restart, not live. Same pattern as orbit-menu/
+horizon-dock/astro-menu/station-bar/beacon's config.py.
+"""
+
+from __future__ import annotations
+
+import json
+
+from paths import CONFIG_FILE, ensure_dirs
+
+_DEFAULTS = {"hologram": True}
+
+
+def _load() -> dict:
+ try:
+ data = json.loads(CONFIG_FILE.read_text())
+ except (FileNotFoundError, json.JSONDecodeError):
+ data = {}
+ merged = {**_DEFAULTS, **data}
+ if not CONFIG_FILE.exists():
+ ensure_dirs()
+ CONFIG_FILE.write_text(json.dumps(merged, indent=2) + "\n")
+ return merged
+
+
+def hologram_enabled() -> bool:
+ return bool(_load().get("hologram", True))
diff --git a/desktopenvs/hyprdrive/transmitter-panel/history_client.py b/desktopenvs/hyprdrive/transmitter-panel/history_client.py
new file mode 100644
index 0000000..4448f8a
--- /dev/null
+++ b/desktopenvs/hyprdrive/transmitter-panel/history_client.py
@@ -0,0 +1,79 @@
+"""Async client for beacon's eu.abdelbaki.beacon.History1 D-Bus interface.
+
+Runs on the GTK main loop: every call is async (Gio.DBusProxy.call, never
+call_sync) so a slow or hung beacon never freezes the panel. If beacon isn't
+running yet (or drops off the bus), calls fail — callers get an empty list
+or a silent no-op rather than a crash, the same defensive stance beacon's
+own controls.py/history.py took.
+"""
+
+from __future__ import annotations
+
+from typing import Callable, Optional
+
+import gi
+
+gi.require_version("Gtk", "4.0")
+from gi.repository import Gio, GLib # noqa: E402
+
+# Same well-known name/path/interface beacon's own paths.py defines —
+# duplicated here as plain strings rather than a shared import, matching how
+# screenrec.sh independently hardcodes them too (each deployed component is
+# its own ~/.config/ tree with no shared Python path).
+FDN_NAME = "org.freedesktop.Notifications"
+FDN_PATH = "/org/freedesktop/Notifications"
+HISTORY_IFACE = "eu.abdelbaki.beacon.History1"
+
+
+class HistoryClient:
+ def __init__(self,
+ on_added: Optional[Callable[[dict], None]] = None,
+ on_removed: Optional[Callable[[int], None]] = None,
+ on_cleared: Optional[Callable[[], None]] = None) -> None:
+ self._on_added = on_added
+ self._on_removed = on_removed
+ self._on_cleared = on_cleared
+ # Constructing a proxy doesn't require the peer to be present yet —
+ # only individual calls fail while beacon isn't up.
+ self._proxy = Gio.DBusProxy.new_for_bus_sync(
+ Gio.BusType.SESSION, Gio.DBusProxyFlags.NONE, None,
+ FDN_NAME, FDN_PATH, HISTORY_IFACE, None)
+ self._proxy.get_connection().signal_subscribe(
+ FDN_NAME, HISTORY_IFACE, None, FDN_PATH, None,
+ Gio.DBusSignalFlags.NONE, self._on_signal)
+
+ def _on_signal(self, _conn, _sender, _path, _iface, signal, params) -> None:
+ if signal == "HistoryAdded" and self._on_added is not None:
+ (entry,) = params.unpack()
+ self._on_added(entry)
+ elif signal == "HistoryRemoved" and self._on_removed is not None:
+ (nid,) = params.unpack()
+ self._on_removed(int(nid))
+ elif signal == "HistoryCleared" and self._on_cleared is not None:
+ self._on_cleared()
+
+ # -- calls --------------------------------------------------------------
+ def list_async(self, callback: Callable[[list[dict]], None]) -> None:
+ def done(proxy, result, _data=None) -> None:
+ try:
+ (entries,) = proxy.call_finish(result).unpack()
+ except GLib.GError:
+ entries = []
+ callback(entries)
+ self._proxy.call("List", None, Gio.DBusCallFlags.NONE, -1, None, done, None)
+
+ def remove_async(self, nid: int) -> None:
+ self._proxy.call(
+ "Remove", GLib.Variant("(u)", (nid,)), Gio.DBusCallFlags.NONE, -1, None,
+ self._ignore_result, None)
+
+ def clear_async(self) -> None:
+ self._proxy.call(
+ "Clear", None, Gio.DBusCallFlags.NONE, -1, None, self._ignore_result, None)
+
+ @staticmethod
+ def _ignore_result(proxy, result, _data=None) -> None:
+ try:
+ proxy.call_finish(result)
+ except GLib.GError:
+ pass # beacon not running / already gone — nothing to do
diff --git a/desktopenvs/hyprdrive/transmitter-panel/lib/hologram.py b/desktopenvs/hyprdrive/transmitter-panel/lib/hologram.py
new file mode 100644
index 0000000..62b1856
--- /dev/null
+++ b/desktopenvs/hyprdrive/transmitter-panel/lib/hologram.py
@@ -0,0 +1,279 @@
+"""Holographic scanline/sweep/noise overlay — the same treatment and tuning as
+the rest of the Cosmonaut Shell suite (astro-menu / station-bar / orbit-menu /
+horizon-dock / beacon lib/hologram.py), reused here so the transmitter-panel
+popup reads as one more orbit of the same look: scanline grid + a slow
+vertical sweep + drifting noise specks, and a 'materialise out of static'
+intro when the panel opens.
+
+One overlay covers the whole panel (same usage as astro-menu's window.py,
+`fade_widget=self.root`), not a per-row overlay — window.py runs a single
+frame-clock tick and feeds it via .tick(dt).
+"""
+
+from __future__ import annotations
+
+import math
+import random
+
+import cairo
+import gi
+
+gi.require_version("Gtk", "4.0")
+from gi.repository import GLib, Gtk # noqa: E402
+
+# Same CyberQueer violet/magenta/red combo as the rest of the suite's hologram.
+_VIOLET = (0x50 / 255, 0x18 / 255, 0xDD / 255)
+_MAGENTA = (0.92, 0.0, 0.65)
+_ACCENT = (0xE4 / 255, 0x00 / 255, 0x46 / 255)
+
+
+class HologramOverlay:
+ SCANLINE_GAP = 4.0
+ SCANLINE_ALPHA = 0.18
+ SWEEP_PERIOD = 3.2 # seconds for one top-to-bottom pass
+ SWEEP_HALF_HEIGHT = 34.0
+ NOISE_COUNT = 26
+ NOISE_COLORS = [_MAGENTA, _MAGENTA, _ACCENT] # magenta-biased specks
+ NOISE_LIFETIME = (0.5, 1.4)
+ NOISE_FADE_IN = 0.2
+ NOISE_FADE_OUT = 0.35
+ NOISE_ALPHA_RANGE = (0.10, 0.34)
+ EDGE_FADE_X = 40.0 # smooth horizontal fade-out of the scanline field
+ EDGE_FADE_Y = 34.0 # smooth vertical fade-out
+ INTRO_DURATION = 0.9 # brisk 'materialise out of static' when the panel opens
+ INTRO_STATIC = 900 # static specks at the very start of the intro
+ OUTRO_DURATION = 0.2 # snappy reverse dissolve back into static on close
+
+ def __init__(self, enabled: bool = True, clip_func=None, fade_widget=None,
+ intro_duration: float | None = None) -> None:
+ self.enabled = enabled
+ self._clip_func = clip_func # optional path-setter to clip the holo to a shape
+ # widget whose opacity is ramped 0->1 during the intro so the content
+ # genuinely fades in, rather than a solid haze block popping on
+ self._fade_widget = fade_widget
+ if intro_duration is not None:
+ self.INTRO_DURATION = intro_duration
+ self._sat_time = 0.0
+ self._particles: list[dict] = []
+ self._intro_t: float | None = None
+ self._outro_t: float | None = None
+ self._outro_done = None
+ # Wall-clock safety net: the frame-clock tick only advances while the
+ # compositor sends frame callbacks; if those stall the intro could freeze
+ # with content stuck at opacity 0. This timeout force-resolves it anyway.
+ self._intro_deadline_id: int | None = None
+ self._mask_cache: tuple | None = None
+
+ self.widget = Gtk.DrawingArea()
+ self.widget.set_can_target(False) # never steals clicks from the panel underneath
+ self.widget.add_css_class("tx-hologram")
+ self.widget.set_hexpand(True)
+ self.widget.set_vexpand(True)
+ self.widget.set_halign(Gtk.Align.FILL)
+ self.widget.set_valign(Gtk.Align.FILL)
+ self.widget.set_draw_func(self._draw_frame)
+
+ def tick(self, dt: float) -> None:
+ if not self.enabled:
+ return
+ self._sat_time += dt
+ if self._intro_t is not None:
+ self._intro_t += dt
+ if self._intro_t >= self.INTRO_DURATION:
+ self._finish_intro()
+ elif self._fade_widget is not None:
+ p = self._intro_t / self.INTRO_DURATION
+ self._fade_widget.set_opacity(p * p * (3 - 2 * p))
+ if self._outro_t is not None:
+ self._outro_t += dt
+ po = min(1.0, self._outro_t / self.OUTRO_DURATION)
+ if self._fade_widget is not None:
+ self._fade_widget.set_opacity(1.0 - po * po * (3 - 2 * po))
+ if self._outro_t >= self.OUTRO_DURATION:
+ done = self._outro_done
+ self._outro_t = None
+ self._outro_done = None
+ if done is not None:
+ done()
+ self.widget.queue_draw()
+
+ def _finish_intro(self) -> None:
+ self._intro_t = None
+ if self._intro_deadline_id is not None:
+ GLib.source_remove(self._intro_deadline_id)
+ self._intro_deadline_id = None
+ if self._fade_widget is not None:
+ self._fade_widget.set_opacity(1.0)
+ self.widget.queue_draw()
+
+ def start_intro(self) -> None:
+ """Kick off the 'materialising out of static' opening effect."""
+ if self.enabled:
+ self._outro_t = None
+ self._outro_done = None
+ self._intro_t = 0.0
+ if self._fade_widget is not None:
+ self._fade_widget.set_opacity(0.0)
+ if self._intro_deadline_id is not None:
+ GLib.source_remove(self._intro_deadline_id)
+ self._intro_deadline_id = GLib.timeout_add(
+ int(self.INTRO_DURATION * 1000) + 150, self._on_intro_deadline)
+
+ def _on_intro_deadline(self) -> bool:
+ self._intro_deadline_id = None
+ if self._intro_t is not None:
+ self._finish_intro()
+ return False # one-shot
+
+ def start_outro(self, on_done) -> None:
+ """Reverse of the intro (dissolving back into static), then on_done."""
+ if not self.enabled:
+ on_done()
+ return
+ self._intro_t = None
+ if self._intro_deadline_id is not None:
+ GLib.source_remove(self._intro_deadline_id)
+ self._intro_deadline_id = None
+ self._outro_t = 0.0
+ self._outro_done = on_done
+
+ # -- drawing --------------------------------------------------------------
+ def _draw_frame(self, _area, cr, width: float, height: float) -> None:
+ if not self.enabled or width <= 0 or height <= 0:
+ return
+ if self._clip_func is not None:
+ cr.save()
+ self._clip_func(cr, width, height)
+ cr.clip()
+ cr.push_group()
+ self._draw_content(cr, width, height)
+ cr.pop_group_to_source()
+ cr.mask(self._edge_fade_mask(width, height))
+ if self._intro_t is not None:
+ self._draw_intro(cr, width, height)
+ elif self._outro_t is not None:
+ self._draw_outro(cr, width, height)
+ if self._clip_func is not None:
+ cr.restore()
+
+ def _edge_fade_mask(self, width: float, height: float):
+ key = (int(width), int(height))
+ if self._mask_cache is not None and self._mask_cache[0] == key:
+ return self._mask_cache[1]
+ w, h = max(1, key[0]), max(1, key[1])
+ surf = cairo.ImageSurface(cairo.FORMAT_A8, w, h)
+ m = cairo.Context(surf)
+ m.set_source_rgba(0, 0, 0, 1)
+ m.paint()
+ m.set_operator(cairo.OPERATOR_DEST_OUT)
+ fx = min(self.EDGE_FADE_X, w / 2)
+ fy = min(self.EDGE_FADE_Y, h / 2)
+
+ def band(x0, y0, x1, y1, rx, ry, rw, rh):
+ gr = cairo.LinearGradient(x0, y0, x1, y1)
+ gr.add_color_stop_rgba(0.0, 0, 0, 0, 1)
+ gr.add_color_stop_rgba(1.0, 0, 0, 0, 0)
+ m.set_source(gr)
+ m.rectangle(rx, ry, rw, rh)
+ m.fill()
+ band(0, 0, fx, 0, 0, 0, fx, h) # left
+ band(w, 0, w - fx, 0, w - fx, 0, fx, h) # right
+ band(0, 0, 0, fy, 0, 0, w, fy) # top
+ band(0, h, 0, h - fy, 0, h - fy, w, fy) # bottom
+ pattern = cairo.SurfacePattern(surf)
+ self._mask_cache = (key, pattern)
+ return pattern
+
+ def _draw_intro(self, cr, width: float, height: float) -> None:
+ p = min(1.0, max(0.0, (self._intro_t or 0.0) / self.INTRO_DURATION))
+ strength = 1.0 - p
+ colors = [_MAGENTA, _MAGENTA, _ACCENT, (0.85, 0.85, 0.95)]
+ count = int(self.INTRO_STATIC * (strength ** 0.5))
+ for _ in range(count):
+ x = random.uniform(0, width)
+ y = random.uniform(0, height)
+ col = random.choice(colors)
+ cr.set_source_rgba(*col, random.uniform(0.25, 0.85) * (0.35 + 0.65 * strength))
+ sz = random.uniform(1.0, 2.8)
+ cr.rectangle(x, y, sz, sz)
+ cr.fill()
+ band = p * height
+ cr.set_source_rgba(*_ACCENT, 0.5 * (1.0 - abs(2 * p - 1)))
+ cr.rectangle(0, band - 2.0, width, 4.0)
+ cr.fill()
+
+ def _draw_outro(self, cr, width: float, height: float) -> None:
+ po = min(1.0, max(0.0, (self._outro_t or 0.0) / self.OUTRO_DURATION))
+ colors = [_MAGENTA, _MAGENTA, _ACCENT, (0.85, 0.85, 0.95)]
+ count = int(self.INTRO_STATIC * (po ** 0.5))
+ for _ in range(count):
+ x = random.uniform(0, width)
+ y = random.uniform(0, height)
+ col = random.choice(colors)
+ cr.set_source_rgba(*col, random.uniform(0.25, 0.85) * (0.35 + 0.65 * po))
+ sz = random.uniform(1.0, 2.8)
+ cr.rectangle(x, y, sz, sz)
+ cr.fill()
+ band = (1.0 - po) * height
+ cr.set_source_rgba(*_ACCENT, 0.5 * (1.0 - abs(2 * po - 1)))
+ cr.rectangle(0, band - 2.0, width, 4.0)
+ cr.fill()
+
+ def _draw_content(self, cr, width: float, height: float) -> None:
+ r, g, b = _VIOLET
+
+ cr.save()
+ cr.set_source_rgba(r, g, b, self.SCANLINE_ALPHA)
+ cr.set_line_width(1.0)
+ y = 0.0
+ while y < height:
+ cr.move_to(0, y)
+ cr.line_to(width, y)
+ y += self.SCANLINE_GAP
+ cr.stroke()
+ cr.restore()
+
+ phase = (self._sat_time % self.SWEEP_PERIOD) / self.SWEEP_PERIOD
+ sweep_y = phase * height
+ hh = self.SWEEP_HALF_HEIGHT
+ grad = cairo.LinearGradient(0, sweep_y - hh, 0, sweep_y + hh)
+ grad.add_color_stop_rgba(0.0, r, g, b, 0.0)
+ grad.add_color_stop_rgba(0.5, r, g, b, 0.09)
+ grad.add_color_stop_rgba(1.0, r, g, b, 0.0)
+ cr.set_source(grad)
+ cr.rectangle(0, sweep_y - hh, width, hh * 2)
+ cr.fill()
+
+ flicker = 0.012 + 0.007 * math.sin(self._sat_time * 11.0)
+ cr.set_source_rgba(r, g, b, max(0.0, flicker))
+ cr.paint()
+
+ self._draw_noise(cr, width, height)
+
+ def _draw_noise(self, cr, width: float, height: float) -> None:
+ now = self._sat_time
+ self._particles = [p for p in self._particles if now - p["birth"] < p["life"]]
+ while len(self._particles) < self.NOISE_COUNT:
+ self._particles.append({
+ "x": random.uniform(0, width),
+ "y": random.uniform(0, height),
+ "w": random.uniform(1.0, 2.6),
+ "h": random.uniform(1.0, 2.0),
+ "color": random.choice(self.NOISE_COLORS),
+ "peak_alpha": random.uniform(*self.NOISE_ALPHA_RANGE),
+ "birth": now,
+ "life": random.uniform(*self.NOISE_LIFETIME),
+ })
+
+ for p in self._particles:
+ t = (now - p["birth"]) / p["life"]
+ if t < self.NOISE_FADE_IN:
+ envelope = t / self.NOISE_FADE_IN
+ elif t > 1.0 - self.NOISE_FADE_OUT:
+ envelope = max(0.0, (1.0 - t) / self.NOISE_FADE_OUT)
+ else:
+ envelope = 1.0
+ r, g, b = p["color"]
+ cr.set_source_rgba(r, g, b, p["peak_alpha"] * envelope)
+ cr.rectangle(p["x"], p["y"], p["w"], p["h"])
+ cr.fill()
diff --git a/desktopenvs/hyprdrive/transmitter-panel/main.py b/desktopenvs/hyprdrive/transmitter-panel/main.py
new file mode 100644
index 0000000..3fd2197
--- /dev/null
+++ b/desktopenvs/hyprdrive/transmitter-panel/main.py
@@ -0,0 +1,92 @@
+#!/usr/bin/env python3
+"""transmitter-panel — beacon's notification-history viewer for hyprdrive.
+
+Single-instance, same pattern as horizon-dock/astro-menu's main.py: the first
+launch builds the (hidden) window and holds; later invocations forward their
+verb over D-Bus via scripts/transmitter-panel.sh instead of spawning a second
+python3+GTK4 process.
+
+ main.py run the resident instance (stays hidden until toggled)
+ main.py --show show
+ main.py --hide hide
+ main.py --toggle whichever of the above applies
+"""
+
+from __future__ import annotations
+
+import os
+import sys
+from pathlib import Path
+
+# transmitter-panel-start.sh LD_PRELOADs libgtk4-layer-shell (load-ordering
+# requirement ahead of libwayland-client). Drop it once resident so it isn't
+# inherited by anything this process launches — same rationale as the rest of
+# the suite's main.py.
+os.environ.pop("LD_PRELOAD", None)
+
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+
+import gi # noqa: E402
+
+gi.require_version("Gtk", "4.0")
+from gi.repository import Gio, GLib, Gtk # noqa: E402
+
+import theme # noqa: E402
+from paths import APP_ID # noqa: E402
+from window import TransmitterWindow # noqa: E402
+
+
+class TransmitterPanelApp(Gtk.Application):
+ def __init__(self) -> None:
+ super().__init__(application_id=APP_ID,
+ flags=Gio.ApplicationFlags.HANDLES_COMMAND_LINE)
+ self.window: TransmitterWindow | None = None
+
+ def do_startup(self) -> None:
+ Gtk.Application.do_startup(self)
+ theme.load_css()
+ self.window = TransmitterWindow(self)
+ self._register_actions()
+ self.hold() # stay alive with no visible window
+
+ def _register_actions(self) -> None:
+ def add(name: str, callback) -> None:
+ action = Gio.SimpleAction.new(name, None)
+ action.connect("activate", callback)
+ self.add_action(action)
+
+ def guarded(fn):
+ def wrapper(*_a) -> None:
+ assert self.window is not None
+ fn(self.window)
+ return wrapper
+
+ add("show", guarded(lambda w: w.show_panel()))
+ add("hide", guarded(lambda w: w.hide_panel()))
+ add("toggle", guarded(lambda w: w.toggle()))
+
+ def do_command_line(self, cmdline: Gio.ApplicationCommandLine) -> int:
+ args = list(cmdline.get_arguments()[1:])
+ verb = args[0] if args else "--daemon"
+ if self.window is None:
+ return 0
+ if verb == "--show":
+ self.window.show_panel()
+ elif verb == "--hide":
+ self.window.hide_panel()
+ elif verb == "--toggle":
+ self.window.toggle()
+ # --daemon and anything else: no-op (stay resident, hidden)
+ return 0
+
+ def do_activate(self) -> None:
+ pass # resident instance: nothing to do on plain activate
+
+
+def main() -> int:
+ GLib.set_prgname("transmitter-panel")
+ return TransmitterPanelApp().run(sys.argv)
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/desktopenvs/hyprdrive/transmitter-panel/paths.py b/desktopenvs/hyprdrive/transmitter-panel/paths.py
new file mode 100644
index 0000000..f5b6f67
--- /dev/null
+++ b/desktopenvs/hyprdrive/transmitter-panel/paths.py
@@ -0,0 +1,22 @@
+"""Shared filesystem locations. Works whether run from the repo or ~/.config."""
+
+from __future__ import annotations
+
+import os
+from pathlib import Path
+
+BASE_DIR = Path(__file__).resolve().parent
+STYLE_DIR = BASE_DIR / "style"
+
+# User settings live under XDG_STATE_HOME, NOT ~/.config — config-updater does
+# `rm -rf ~/.config/transmitter-panel` on every dotfiles sync (see beacon/
+# station-bar/astro-menu's own paths.py for the same rationale), which would
+# wipe a hand-edited config on the spot.
+STATE_DIR = Path(os.environ.get("XDG_STATE_HOME", Path.home() / ".local" / "state")) / "transmitter-panel"
+CONFIG_FILE = STATE_DIR / "config.json"
+
+APP_ID = "eu.abdelbaki.transmitterpanel"
+
+
+def ensure_dirs() -> None:
+ STATE_DIR.mkdir(parents=True, exist_ok=True)
diff --git a/desktopenvs/hyprdrive/transmitter-panel/style/_colors.css b/desktopenvs/hyprdrive/transmitter-panel/style/_colors.css
new file mode 100644
index 0000000..e226c9e
--- /dev/null
+++ b/desktopenvs/hyprdrive/transmitter-panel/style/_colors.css
@@ -0,0 +1,8 @@
+/* Generated from ~/Dotfiles/colors.conf by apply-theme.sh — do not hand-edit the
+ * hex values; edit colors.conf and re-run apply-theme.sh. (Kept in sync via the
+ * sed pipeline in USER_FILES; these defaults are the CyberQueer palette.) */
+@define-color text #D6ABAB;
+@define-color bg #1A1A1A;
+@define-color accent #E40046;
+@define-color violet #5018DD;
+@define-color danger #F50505;
diff --git a/desktopenvs/hyprdrive/transmitter-panel/style/style.css b/desktopenvs/hyprdrive/transmitter-panel/style/style.css
new file mode 100644
index 0000000..665bead
--- /dev/null
+++ b/desktopenvs/hyprdrive/transmitter-panel/style/style.css
@@ -0,0 +1,133 @@
+/* transmitter-panel — beacon's notification-history viewer. Matches the
+ * astro-menu/beacon idiom: emitted-magenta text, violet holo-glass fills,
+ * glow-violet/accent frames, Agave Nerd Font Mono, rounded panels. The
+ * compositor blurs behind the translucent fill (see the `transmitter-panel`
+ * layer-rule in hypr/usr/windowrules.lua); the scanline/sweep/noise depth is
+ * Cairo-drawn on top by lib/hologram.py. */
+
+@define-color text #EB00A6; /* emitted magenta, same override astro-menu/beacon use */
+
+* {
+ font-family: "Agave Nerd Font Mono", monospace;
+ font-size: 12pt;
+}
+
+/* Blank the structural nodes so the CyberQueer theme's `* { background-color:
+ * #1a1a1a }` doesn't fill the surface/gaps with an opaque slab — the panel
+ * asserts its own glass fill below (same trick as beacon/style/style.css). */
+window,
+window.background,
+.tx-window,
+.tx-list,
+box,
+overlay,
+label,
+image,
+drawingarea {
+ background: transparent;
+ background-color: transparent;
+}
+
+/* the holo-glass panel */
+.tx-panel {
+ background-color: alpha(@violet, 0.40);
+ border: 2px solid #8A5CFF;
+ border-radius: 16px;
+ padding: 14px 16px;
+ box-shadow: 0 0 16px 1px alpha(#8A5CFF, 0.35);
+}
+
+/* right margin keeps "Clear All" clear of the overlaid panel-level ✕ button
+ * (min-width 34px + its own 20px right margin, see .close-btn below — a
+ * ~54px footprint from the right edge), since both sit in the top-right
+ * corner otherwise. */
+.tx-header { margin: 0 64px 8px 0; }
+.tx-title {
+ color: @text;
+ font-weight: bold;
+ font-size: 13pt;
+ letter-spacing: 1px;
+}
+
+.tx-clear-all {
+ color: @text;
+ background-color: alpha(@violet, 0.4);
+ border: 2px solid #8A5CFF;
+ border-radius: 20px;
+ padding: 4px 14px;
+ min-height: 26px;
+ transition: border-color 180ms ease, color 180ms ease, box-shadow 220ms ease, background 180ms ease;
+}
+.tx-clear-all:hover {
+ border-color: @accent;
+ color: @accent;
+ box-shadow: 0 0 12px 1px alpha(@accent, 0.55);
+}
+
+.tx-empty {
+ color: @text;
+ opacity: 0.6;
+ padding: 18px 4px;
+}
+
+/* per-entry rows */
+.tx-row {
+ padding: 8px 10px;
+ border-radius: 12px;
+ min-height: 34px;
+ transition: background 180ms ease;
+}
+.tx-row:hover { background: alpha(@violet, 0.16); }
+.tx-row-icon { margin-right: 2px; }
+.tx-row-app {
+ color: @text;
+ opacity: 0.65;
+ font-size: 9pt;
+ letter-spacing: 0.5px;
+}
+.tx-row-summary {
+ color: @text;
+ font-weight: bold;
+ font-size: 11.5pt;
+}
+.tx-row-body {
+ color: @text;
+ font-size: 10.5pt;
+ opacity: 0.9;
+}
+
+/* small per-row dismiss ✕ — a scaled-down .close-btn */
+.tx-row-close {
+ color: @text;
+ background: alpha(@violet, 0.4);
+ border: none;
+ border-radius: 14px;
+ min-width: 24px;
+ min-height: 24px;
+ transition: background 180ms ease, color 180ms ease, box-shadow 220ms ease;
+}
+.tx-row-close:hover {
+ background: @accent;
+ color: @bg;
+ box-shadow: 0 0 10px 1px alpha(@accent, 0.5);
+}
+
+/* floating panel-level close button (top-right, closes the whole popup) —
+ * copied verbatim from astro-menu/style/style.css's .close-btn idiom. */
+.close-btn {
+ color: @text; background: alpha(@violet, 0.4);
+ border: none; border-radius: 20px;
+ min-width: 34px; min-height: 34px;
+ margin: 16px 20px;
+ transition: background 180ms ease, color 180ms ease, box-shadow 220ms ease;
+}
+.close-btn:hover {
+ background: @accent;
+ color: @bg;
+ box-shadow: 0 0 14px 2px alpha(@accent, 0.55);
+}
+
+scrollbar slider { background: @violet; border-radius: 8px; min-width: 6px; }
+scrollbar slider:hover { background: @accent; }
+
+.tx-hologram { background: transparent; }
diff --git a/desktopenvs/hyprdrive/transmitter-panel/theme.py b/desktopenvs/hyprdrive/transmitter-panel/theme.py
new file mode 100644
index 0000000..650e12f
--- /dev/null
+++ b/desktopenvs/hyprdrive/transmitter-panel/theme.py
@@ -0,0 +1,31 @@
+"""Load the two stylesheets as ordered CSS providers — same scheme as the rest
+of the Cosmonaut Shell suite (orbit-menu, horizon-dock, astro-menu, station-bar,
+beacon). _colors.css defines the CyberQueer @define-color names; style.css
+consumes them.
+
+Priority is USER+1 for the same reason as the others: the CyberQueer GTK theme
+at ~/.config/gtk-4.0/gtk.css loads at PRIORITY_USER (800), above APPLICATION
+(600), and would beat our transparent structural containers otherwise.
+"""
+
+from __future__ import annotations
+
+import gi
+
+gi.require_version("Gtk", "4.0")
+from gi.repository import Gdk, Gtk # noqa: E402
+
+from paths import STYLE_DIR
+
+
+def load_css() -> None:
+ display = Gdk.Display.get_default()
+ for name in ("_colors.css", "style.css"):
+ path = STYLE_DIR / name
+ if not path.exists():
+ continue
+ provider = Gtk.CssProvider()
+ provider.load_from_path(str(path))
+ Gtk.StyleContext.add_provider_for_display(
+ display, provider, Gtk.STYLE_PROVIDER_PRIORITY_USER + 1
+ )
diff --git a/desktopenvs/hyprdrive/transmitter-panel/window.py b/desktopenvs/hyprdrive/transmitter-panel/window.py
new file mode 100644
index 0000000..c71cead
--- /dev/null
+++ b/desktopenvs/hyprdrive/transmitter-panel/window.py
@@ -0,0 +1,266 @@
+"""The popup: a content-sized floating layer-shell panel anchored top-centre,
+listing beacon's notification history (via history_client.HistoryClient).
+
+ Gtk.Window (layer TOP, anchored TOP -> horizontally centred, height = content)
+ └ Gtk.Overlay
+ main : .tx-panel (header w/ Clear All + scrollable row list)
+ over : ✕ close button (top-right, closes the whole panel)
+
+Dismissed with the launcher toggle, Esc, or the ✕ button — no click-outside-
+to-close (would need a blocking full-screen surface), same tradeoff astro-
+menu's window.py documents. Each row has its own ✕ that removes just that
+entry from beacon's history (independent of closing the panel itself).
+"""
+
+from __future__ import annotations
+
+import gi
+
+gi.require_version("Gtk", "4.0")
+gi.require_version("Gdk", "4.0")
+gi.require_version("Gtk4LayerShell", "1.0")
+from gi.repository import Gdk, Gtk # noqa: E402
+from gi.repository import Gtk4LayerShell as LayerShell # noqa: E402
+
+import config
+from history_client import HistoryClient
+from lib.hologram import HologramOverlay
+
+PANEL_WIDTH = 380
+EDGE_MARGIN = 28
+MAX_LIST_HEIGHT = 420
+
+
+class TransmitterWindow(Gtk.ApplicationWindow):
+ def __init__(self, app: Gtk.Application) -> None:
+ super().__init__(application=app)
+ self.set_name("transmitter-window")
+ self.add_css_class("tx-window")
+ self.set_decorated(False)
+
+ self._rows: dict[int, Gtk.Widget] = {}
+ self._order: list[int] = [] # newest-first ids, mirrors History1.List order
+
+ self._init_layer_shell()
+
+ self.root = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
+ self.root.set_name("panel-root")
+ self.root.add_css_class("tx-panel")
+ self.root.set_size_request(PANEL_WIDTH, -1)
+
+ header = Gtk.CenterBox()
+ header.add_css_class("tx-header")
+ title = Gtk.Label(label="Transmissions", xalign=0.0)
+ title.add_css_class("tx-title")
+ header.set_start_widget(title)
+ clear_btn = Gtk.Button(label="Clear All")
+ clear_btn.add_css_class("tx-clear-all")
+ clear_btn.connect("clicked", lambda *_a: self._client.clear_async())
+ header.set_end_widget(clear_btn)
+ self.root.append(header)
+
+ self._list = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
+ self._list.add_css_class("tx-list")
+ self._scroller = Gtk.ScrolledWindow(hscrollbar_policy=Gtk.PolicyType.NEVER)
+ self._scroller.set_max_content_height(MAX_LIST_HEIGHT)
+ self._scroller.set_propagate_natural_height(True)
+ self._scroller.set_child(self._list)
+ self.root.append(self._scroller)
+
+ self._empty_label = Gtk.Label(label="No transmissions")
+ self._empty_label.add_css_class("tx-empty")
+
+ overlay = Gtk.Overlay()
+ overlay.set_child(self.root)
+
+ self._hologram = HologramOverlay(enabled=config.hologram_enabled(), fade_widget=self.root)
+ overlay.add_overlay(self._hologram.widget)
+
+ close = Gtk.Button(label="✕")
+ close.add_css_class("close-btn")
+ close.set_halign(Gtk.Align.END)
+ close.set_valign(Gtk.Align.START)
+ close.connect("clicked", lambda *_a: self.hide_panel())
+ overlay.add_overlay(close)
+ self.set_child(overlay)
+
+ key = Gtk.EventControllerKey()
+ key.connect("key-pressed", self._on_key)
+ self.add_controller(key)
+
+ self._last_tick: float | None = None
+ self._tick_id: int | None = None
+
+ self._client = HistoryClient(on_added=self._on_history_added,
+ on_removed=self._on_history_removed,
+ on_cleared=self._on_history_cleared)
+ self._rebuild_empty_state()
+ self.set_visible(False)
+
+ # -- layer shell ------------------------------------------------------------
+ def _init_layer_shell(self) -> None:
+ LayerShell.init_for_window(self)
+ LayerShell.set_layer(self, LayerShell.Layer.TOP)
+ LayerShell.set_namespace(self, "transmitter-panel")
+ LayerShell.set_keyboard_mode(self, LayerShell.KeyboardMode.ON_DEMAND)
+ LayerShell.set_anchor(self, LayerShell.Edge.TOP, True)
+ LayerShell.set_margin(self, LayerShell.Edge.TOP, EDGE_MARGIN)
+
+ # -- visibility ---------------------------------------------------------------
+ def show_panel(self) -> None:
+ self._client.list_async(self._on_list_result)
+ self.set_visible(True)
+ self.present()
+ if self._hologram.enabled and self._tick_id is None:
+ self._tick_id = self.add_tick_callback(self._on_tick)
+ self._hologram.start_intro()
+
+ def hide_panel(self) -> None:
+ if self._hologram.enabled and self._tick_id is not None:
+ self._hologram.start_outro(self._finish_hide)
+ else:
+ self._finish_hide()
+
+ def _finish_hide(self) -> None:
+ self.set_visible(False)
+ if self._tick_id is not None:
+ self.remove_tick_callback(self._tick_id)
+ self._tick_id = None
+ self._last_tick = None
+
+ def toggle(self) -> None:
+ if self.get_visible():
+ self.hide_panel()
+ else:
+ self.show_panel()
+
+ # -- hologram tick --------------------------------------------------------------
+ def _on_tick(self, _widget, frame_clock) -> bool:
+ now = frame_clock.get_frame_time() / 1_000_000
+ dt = 0.0 if self._last_tick is None else max(0.0, now - self._last_tick)
+ self._last_tick = now
+ self._hologram.tick(dt)
+ return True
+
+ def _on_key(self, _c, keyval, _kc, _state) -> bool:
+ if keyval == Gdk.KEY_Escape:
+ self.hide_panel()
+ return True
+ return False
+
+ # -- history model ----------------------------------------------------------
+ def _on_list_result(self, entries: list[dict]) -> None:
+ self._clear_list()
+ self._order = []
+ for entry in entries: # already newest-first from History1.List
+ self._add_entry(entry, prepend=False)
+ self._rebuild_empty_state()
+
+ def _on_history_added(self, entry: dict) -> None:
+ self._add_entry(entry, prepend=True)
+
+ def _on_history_removed(self, nid: int) -> None:
+ self._drop_entry(nid)
+
+ def _on_history_cleared(self) -> None:
+ self._clear_list()
+ self._order = []
+ self._rebuild_empty_state()
+
+ def _add_entry(self, entry: dict, prepend: bool) -> None:
+ nid = int(entry["id"])
+ if nid in self._rows: # replace in place (shouldn't normally happen)
+ self._drop_entry(nid)
+ row = self._build_row(entry)
+ self._rows[nid] = row
+ if prepend:
+ self._order.insert(0, nid)
+ self._list.prepend(row)
+ else:
+ self._order.append(nid)
+ self._list.append(row)
+ self._rebuild_empty_state()
+
+ def _drop_entry(self, nid: int) -> None:
+ row = self._rows.pop(nid, None)
+ if row is not None:
+ self._list.remove(row)
+ if nid in self._order:
+ self._order.remove(nid)
+ self._rebuild_empty_state()
+
+ def _clear_list(self) -> None:
+ child = self._list.get_first_child()
+ while child:
+ nxt = child.get_next_sibling()
+ self._list.remove(child)
+ child = nxt
+ self._rows = {}
+
+ def _rebuild_empty_state(self) -> None:
+ empty = not self._order
+ if empty and self._empty_label.get_parent() is None:
+ self._list.append(self._empty_label)
+ elif not empty and self._empty_label.get_parent() is not None:
+ self._list.remove(self._empty_label)
+
+ # -- row building -------------------------------------------------------------
+ def _build_row(self, entry: dict) -> Gtk.Widget:
+ row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
+ row.add_css_class("tx-row")
+
+ img = self._build_icon(entry.get("icon") or "")
+ if img is not None:
+ row.append(img)
+
+ textcol = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
+ textcol.set_hexpand(True)
+ textcol.set_valign(Gtk.Align.CENTER)
+
+ app_name = entry.get("app_name") or ""
+ if app_name:
+ app_lbl = Gtk.Label(label=app_name.upper(), xalign=0.0)
+ app_lbl.add_css_class("tx-row-app")
+ textcol.append(app_lbl)
+
+ summary = entry.get("summary") or ""
+ if summary:
+ sum_lbl = Gtk.Label(label=summary, xalign=0.0)
+ sum_lbl.add_css_class("tx-row-summary")
+ sum_lbl.set_wrap(True)
+ sum_lbl.set_wrap_mode(2) # WORD_CHAR, same convention as beacon/notification.py
+ sum_lbl.set_max_width_chars(28)
+ textcol.append(sum_lbl)
+
+ body = entry.get("body") or ""
+ if body:
+ body_lbl = Gtk.Label(label=body, xalign=0.0, ellipsize=3, lines=3)
+ body_lbl.add_css_class("tx-row-body")
+ body_lbl.set_wrap(True)
+ body_lbl.set_wrap_mode(2)
+ body_lbl.set_max_width_chars(32)
+ textcol.append(body_lbl)
+
+ row.append(textcol)
+
+ nid = int(entry["id"])
+ close = Gtk.Button(label="✕")
+ close.add_css_class("tx-row-close")
+ close.set_valign(Gtk.Align.START)
+ close.set_tooltip_text("Remove from history")
+ close.connect("clicked", lambda *_a, i=nid: self._client.remove_async(i))
+ row.append(close)
+
+ return row
+
+ def _build_icon(self, icon: str) -> Gtk.Image | None:
+ if not icon:
+ return None
+ if icon.startswith("file://"):
+ icon = icon[len("file://"):]
+ img = Gtk.Image.new_from_file(icon) if icon.startswith("/") \
+ else Gtk.Image.new_from_icon_name(icon)
+ img.add_css_class("tx-row-icon")
+ img.set_pixel_size(32)
+ img.set_valign(Gtk.Align.START)
+ return img
diff --git a/setup/modules/Desktop-Environments/hyprdrive.sh b/setup/modules/Desktop-Environments/hyprdrive.sh
index 3c036cf..c6f2886 100755
--- a/setup/modules/Desktop-Environments/hyprdrive.sh
+++ b/setup/modules/Desktop-Environments/hyprdrive.sh
@@ -234,7 +234,7 @@ enable_service iwd.service
# ---------------------------------------------------------------------------
log "Copying configs..."
-CONFIGS=(kitty mimeapps.list vicinae hypr xfce4 dunst alacritty orbit-menu horizon-dock astro-menu station-bar scripts btop gtk-3.0)
+CONFIGS=(kitty mimeapps.list vicinae hypr xfce4 dunst alacritty orbit-menu horizon-dock astro-menu station-bar transmitter-panel scripts btop gtk-3.0)
for cfg in "${CONFIGS[@]}"; do
rm -rf ~/.config/"$cfg"
cp -r ~/Dotfiles/desktopenvs/hyprdrive/"$cfg" ~/.config/