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
parent
ba12c31e3e
commit
c86afef147
|
|
@ -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))
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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"))
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
|
@ -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" "$@"
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|
@ -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))
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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())
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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; }
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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/
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue