feat(hyprdrive): add beacon notification history/controls + transmitter-panel viewer

beacon gains a disk-persisted history D-Bus interface (eu.abdelbaki.beacon.History1
- List/Get/Pop/PopLatest/Remove/Clear/InvokeAction, live Added/Removed/Cleared
signals) and support for apps to embed rich controls (toggle/slider/entry, not
just fire-and-dismiss buttons) via a custom x-beacon-controls notification hint,
plus a beaconctl CLI. transmitter-panel is a new Cosmonaut Shell popup (Super+
Ctrl+N) that browses that history, with a Clear All button and a per-entry
close button.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
main
Amir Alexander Abdelbaki 2026-07-24 11:48:16 +02:00
parent ba12c31e3e
commit c86afef147
26 changed files with 1767 additions and 10 deletions

View File

@ -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))

View File

@ -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

View File

@ -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

View File

@ -46,11 +46,13 @@ def _rounded_path(cr, w: float, h: float, r: float = _CARD_RADIUS) -> None:
class NotificationCard: class NotificationCard:
def __init__(self, nid: int, summary: str, body: str, urgency: int, 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_action: Callable[[int, str], None],
on_control: Callable[[int, str, object], None],
on_dismiss: Callable[[int], None]) -> None: on_dismiss: Callable[[int], None]) -> None:
self.nid = nid self.nid = nid
self._on_action = on_action self._on_action = on_action
self._on_control = on_control
self._on_dismiss = on_dismiss self._on_dismiss = on_dismiss
self._dismissing = False self._dismissing = False
critical = urgency >= 2 critical = urgency >= 2
@ -105,6 +107,11 @@ class NotificationCard:
if row is not None: if row is not None:
textcol.append(row) 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 = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
card.add_css_class("beacon-card") card.add_css_class("beacon-card")
if critical: if critical:
@ -193,6 +200,74 @@ class NotificationCard:
shown += 1 shown += 1
return row if shown else None 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]: def _build_icon(self, icon: str, image_data) -> Optional[Gtk.Image]:
img: Optional[Gtk.Image] = None img: Optional[Gtk.Image] = None
if image_data is not None: if image_data is not None:

View File

@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import os
from pathlib import Path from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent BASE_DIR = Path(__file__).resolve().parent
@ -15,3 +16,26 @@ APP_ID = "eu.abdelbaki.beacon"
FDN_NAME = "org.freedesktop.Notifications" FDN_NAME = "org.freedesktop.Notifications"
FDN_PATH = "/org/freedesktop/Notifications" FDN_PATH = "/org/freedesktop/Notifications"
FDN_IFACE = "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)

View File

@ -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 render as a holographic card; owns per-notification expiry timers and emits the
spec's NotificationClosed / ActionInvoked signals. 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, Deliberately small: the visible/interactive spec subset (body markup, actions,
icons, urgency, replaces_id, timeouts, persistence) no sound/markup-hint icons, urgency, replaces_id, timeouts, persistence) no sound/markup-hint
gymnastics. gymnastics.
@ -10,12 +23,17 @@ gymnastics.
from __future__ import annotations from __future__ import annotations
import time
import gi import gi
gi.require_version("Gtk", "4.0") gi.require_version("Gtk", "4.0")
from gi.repository import Gio, GLib # noqa: E402 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 # reasons per the spec: 1 expired, 2 dismissed by user, 3 closed by call, 4 undefined
_INTROSPECTION_XML = """ _INTROSPECTION_XML = """
@ -56,6 +74,60 @@ _INTROSPECTION_XML = """
</node> </node>
""" """
# 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 = """
<node>
<interface name="eu.abdelbaki.beacon.History1">
<method name="List">
<arg type="aa{sv}" name="entries" direction="out"/>
</method>
<method name="Get">
<arg type="u" name="id" direction="in"/>
<arg type="a{sv}" name="entry" direction="out"/>
</method>
<method name="Pop">
<arg type="u" name="id" direction="in"/>
<arg type="u" name="new_id" direction="out"/>
</method>
<method name="PopLatest">
<arg type="u" name="new_id" direction="out"/>
</method>
<method name="Remove">
<arg type="u" name="id" direction="in"/>
</method>
<method name="Clear">
</method>
<method name="InvokeAction">
<arg type="u" name="id" direction="in"/>
<arg type="s" name="action_key" direction="in"/>
</method>
<signal name="HistoryAdded">
<arg type="a{sv}" name="entry"/>
</signal>
<signal name="HistoryRemoved">
<arg type="u" name="id"/>
</signal>
<signal name="HistoryCleared">
<arg type="u" name="count"/>
</signal>
</interface>
</node>
"""
_CONTROLS_INTROSPECTION_XML = """
<node>
<interface name="eu.abdelbaki.beacon.Controls1">
<signal name="ControlChanged">
<arg type="u" name="id"/>
<arg type="s" name="control_id"/>
<arg type="v" name="value"/>
</signal>
</interface>
</node>
"""
# server default timeouts (ms) by urgency when the client passes -1 # server default timeouts (ms) by urgency when the client passes -1
_DEFAULT_TIMEOUT = {0: 5000, 1: 8000, 2: 0} # low / normal / critical(never) _DEFAULT_TIMEOUT = {0: 5000, 1: 8000, 2: 0} # low / normal / critical(never)
@ -66,13 +138,29 @@ class NotificationServer:
self._window = window self._window = window
self._next_id = 1 self._next_id = 1
self._timers: dict[int, int] = {} 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) node = Gio.DBusNodeInfo.new_for_xml(_INTROSPECTION_XML)
self._iface = node.interfaces[0] self._iface = node.interfaces[0]
connection.register_object( connection.register_object(
FDN_PATH, self._iface, self._on_method_call, None, None) 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): def _on_method_call(self, _conn, _sender, _path, _iface, method, params, invocation):
if method == "Notify": if method == "Notify":
invocation.return_value(GLib.Variant("(u)", (self._notify(params),))) invocation.return_value(GLib.Variant("(u)", (self._notify(params),)))
@ -83,7 +171,8 @@ class NotificationServer:
invocation.return_value(None) invocation.return_value(None)
elif method == "GetCapabilities": elif method == "GetCapabilities":
invocation.return_value(GLib.Variant("(as)", ( 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": elif method == "GetServerInformation":
invocation.return_value(GLib.Variant("(ssss)", ( invocation.return_value(GLib.Variant("(ssss)", (
"beacon", "abdelbaki.eu", "1.0", "1.2"))) "beacon", "abdelbaki.eu", "1.0", "1.2")))
@ -104,10 +193,19 @@ class NotificationServer:
or hints.get("icon_data")) or hints.get("icon_data"))
image_path = hints.get("image-path") or hints.get("image_path") image_path = hints.get("image-path") or hints.get("image_path")
icon = image_path or app_icon or "" 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( self._window.show_notification(
nid, summary, body, urgency, icon, image_data, list(actions), 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) self._arm_timer(nid, int(expire_timeout), urgency)
return nid return nid
@ -133,14 +231,140 @@ class NotificationServer:
if tid is not None: if tid is not None:
GLib.source_remove(tid) 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: def emit_closed(self, nid: int, reason: int) -> None:
self._cancel_timer(nid) self._cancel_timer(nid)
self._conn.emit_signal(None, FDN_PATH, FDN_IFACE, "NotificationClosed", self._conn.emit_signal(None, FDN_PATH, FDN_IFACE, "NotificationClosed",
GLib.Variant("(uu)", (nid, reason))) 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: def _emit_action(self, nid: int, key: str) -> None:
self._conn.emit_signal(None, FDN_PATH, FDN_IFACE, "ActionInvoked", self._conn.emit_signal(None, FDN_PATH, FDN_IFACE, "ActionInvoked",
GLib.Variant("(us)", (nid, key))) GLib.Variant("(us)", (nid, key)))
# dismiss the card once its action fired, matching typical daemon behaviour # dismiss the card once its action fired, matching typical daemon behaviour
self._window.close_notification(nid, reason=2) 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))

View File

@ -90,4 +90,63 @@ drawingarea {
box-shadow: 0 0 12px 1px alpha(@accent, 0.55); 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; } .beacon-hologram { background: transparent; }

View File

@ -57,12 +57,15 @@ class BeaconWindow(Gtk.ApplicationWindow):
# -- public: driven by the D-Bus server ----------------------------------- # -- public: driven by the D-Bus server -----------------------------------
def show_notification(self, nid: int, summary: str, body: str, urgency: int, def show_notification(self, nid: int, summary: str, body: str, urgency: int,
icon: str, image_data, actions: list[str], 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 if nid in self._cards: # replaces_id: swap in place
self._drop_card(nid) self._drop_card(nid)
card = NotificationCard(nid, summary, body, urgency, icon, image_data, 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._cards[nid] = card
self._order.insert(0, nid) self._order.insert(0, nid)
self._stack.prepend(card.widget) self._stack.prepend(card.widget)

View File

@ -23,6 +23,7 @@ config mimeapps.list
config orbit-menu config orbit-menu
config scripts config scripts
config station-bar config station-bar
config transmitter-panel
config vicinae config vicinae
config xfce4 config xfce4

View File

@ -10,6 +10,7 @@ hl.on("hyprland.start", function()
hl.exec_cmd("hyprsunset") hl.exec_cmd("hyprsunset")
hl.exec_cmd("nm-applet") hl.exec_cmd("nm-applet")
hl.exec_cmd("bash ~/Dotfiles/desktopenvs/hyprdrive/scripts/beacon-start.sh") 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("[workspace special:magic silent] kitty")
hl.exec_cmd("hyprctl setcursor Nordzy-cursors-lefthand 50") hl.exec_cmd("hyprctl setcursor Nordzy-cursors-lefthand 50")
hl.exec_cmd("hyprpaper") hl.exec_cmd("hyprpaper")

View File

@ -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 + U", hl.dsp.exec_cmd("chamel clear"))
hl.bind(mainMod .. " + CTRL + Z", hl.dsp.exec_cmd("chamel clear-and-deactivate")) 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 + 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 .. " + 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 + 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")) hl.bind(mainMod .. " + SHIFT + B", hl.dsp.exec_cmd("[tag +centered-S] kitty bash ~/.config/scripts/enroll-biometrics.sh"))

View File

@ -136,7 +136,7 @@ hl.window_rule({
-- NOT slide in from an edge — each instead "materialises out of static" via its -- 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 -- 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. -- 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 }, hl.layer_rule({ name = "cosmoshell-blur-" .. ns, match = { namespace = ns },
blur = true, ignore_alpha = 0.1, no_anim = true }) blur = true, ignore_alpha = 0.1, no_anim = true })
end end

View File

@ -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())

View File

@ -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" "$@"

View File

@ -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"

View File

@ -0,0 +1,2 @@
__pycache__/
*.pyc

View File

@ -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))

View File

@ -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/<name> 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

View File

@ -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()

View File

@ -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())

View File

@ -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)

View File

@ -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;

View File

@ -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; }

View File

@ -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
)

View File

@ -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

View File

@ -234,7 +234,7 @@ enable_service iwd.service
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
log "Copying configs..." 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 for cfg in "${CONFIGS[@]}"; do
rm -rf ~/.config/"$cfg" rm -rf ~/.config/"$cfg"
cp -r ~/Dotfiles/desktopenvs/hyprdrive/"$cfg" ~/.config/ cp -r ~/Dotfiles/desktopenvs/hyprdrive/"$cfg" ~/.config/