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/