From 838f954cad638f7a3f6cf065df3c49d472edab0e Mon Sep 17 00:00:00 2001 From: The_miro Date: Sun, 19 Jul 2026 16:22:01 +0200 Subject: [PATCH] feat(hyprdrive/beacon): holographic notification daemon replacing dunst beacon is a GTK4 layer-shell notification daemon in the Cosmonaut Shell idiom. It owns org.freedesktop.Notifications and renders each notification as a holographic card: scanline/sweep/noise overlay (shared lib/hologram.py) clipped to a rounded violet holo-glass card, emitted-magenta text, glow-violet frame (accent + squiggle for critical), materialise-out-of-static intro / dissolve outro, top-centre stack. Implements the interactive spec subset: body markup, urgency, replaces_id, per-urgency timeouts, action pills, icons (name/path/raw), click-to-dismiss, and the NotificationClosed / ActionInvoked signals. - scripts/beacon-start.sh: LD_PRELOAD launcher - autostart.lua: dunst -> beacon - windowrules.lua: blur layer-rule for the `beacon` namespace (no_anim; it has its own hologram intro) Co-Authored-By: Claude Opus 4.8 --- desktopenvs/hyprdrive/beacon/lib/hologram.py | 278 ++++++++++++++++++ desktopenvs/hyprdrive/beacon/main.py | 82 ++++++ desktopenvs/hyprdrive/beacon/notification.py | 188 ++++++++++++ desktopenvs/hyprdrive/beacon/paths.py | 17 ++ desktopenvs/hyprdrive/beacon/server.py | 146 +++++++++ .../hyprdrive/beacon/style/_colors.css | 8 + desktopenvs/hyprdrive/beacon/style/style.css | 90 ++++++ desktopenvs/hyprdrive/beacon/theme.py | 30 ++ desktopenvs/hyprdrive/beacon/window.py | 128 ++++++++ desktopenvs/hyprdrive/hypr/usr/autostart.lua | 2 +- .../hyprdrive/hypr/usr/windowrules.lua | 8 + desktopenvs/hyprdrive/scripts/beacon-start.sh | 13 + 12 files changed, 989 insertions(+), 1 deletion(-) create mode 100644 desktopenvs/hyprdrive/beacon/lib/hologram.py create mode 100644 desktopenvs/hyprdrive/beacon/main.py create mode 100644 desktopenvs/hyprdrive/beacon/notification.py create mode 100644 desktopenvs/hyprdrive/beacon/paths.py create mode 100644 desktopenvs/hyprdrive/beacon/server.py create mode 100644 desktopenvs/hyprdrive/beacon/style/_colors.css create mode 100644 desktopenvs/hyprdrive/beacon/style/style.css create mode 100644 desktopenvs/hyprdrive/beacon/theme.py create mode 100644 desktopenvs/hyprdrive/beacon/window.py create mode 100644 desktopenvs/hyprdrive/scripts/beacon-start.sh diff --git a/desktopenvs/hyprdrive/beacon/lib/hologram.py b/desktopenvs/hyprdrive/beacon/lib/hologram.py new file mode 100644 index 0000000..502a3e0 --- /dev/null +++ b/desktopenvs/hyprdrive/beacon/lib/hologram.py @@ -0,0 +1,278 @@ +"""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 lib/hologram.py), reused here so notification cards read 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 a card first appears. + +Each notification card owns its own overlay (see notification.py); the stack +window (window.py) runs a single frame-clock tick and feeds every visible card's +overlay 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 # a touch stronger than the bar's — cards are the focus + SWEEP_PERIOD = 3.2 # seconds for one top-to-bottom pass + SWEEP_HALF_HEIGHT = 34.0 # card-sized panel, taller than the thin bar strip + 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 a card pops in + INTRO_STATIC = 900 # static specks at the very start of the intro + OUTRO_DURATION = 0.4 # quick reverse dissolve back into static on dismiss + + 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 the card shape + # widget whose opacity is ramped 0->1 during the intro so the card 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 card underneath + self.widget.add_css_class("beacon-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 'card 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 (card 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) # clip the scanlines to the card's rounded shape + cr.clip() + cr.push_group() + self._draw_content(cr, width, height) + cr.pop_group_to_source() + cr.mask(self._edge_fade_mask(width, height)) + if self._intro_t is not None: + self._draw_intro(cr, width, height) + elif self._outro_t is not None: + self._draw_outro(cr, width, height) + if self._clip_func is not None: + cr.restore() + + def _edge_fade_mask(self, width: float, height: float): + key = (int(width), int(height)) + if self._mask_cache is not None and self._mask_cache[0] == key: + return self._mask_cache[1] + w, h = max(1, key[0]), max(1, key[1]) + surf = cairo.ImageSurface(cairo.FORMAT_A8, w, h) + m = cairo.Context(surf) + m.set_source_rgba(0, 0, 0, 1) + m.paint() + m.set_operator(cairo.OPERATOR_DEST_OUT) + fx = min(self.EDGE_FADE_X, w / 2) + fy = min(self.EDGE_FADE_Y, h / 2) + + def band(x0, y0, x1, y1, rx, ry, rw, rh): + gr = cairo.LinearGradient(x0, y0, x1, y1) + gr.add_color_stop_rgba(0.0, 0, 0, 0, 1) + gr.add_color_stop_rgba(1.0, 0, 0, 0, 0) + m.set_source(gr) + m.rectangle(rx, ry, rw, rh) + m.fill() + band(0, 0, fx, 0, 0, 0, fx, h) # left + band(w, 0, w - fx, 0, w - fx, 0, fx, h) # right + band(0, 0, 0, fy, 0, 0, w, fy) # top + band(0, h, 0, h - fy, 0, h - fy, w, fy) # bottom + pattern = cairo.SurfacePattern(surf) + self._mask_cache = (key, pattern) + return pattern + + def _draw_intro(self, cr, width: float, height: float) -> None: + p = min(1.0, max(0.0, (self._intro_t or 0.0) / self.INTRO_DURATION)) + strength = 1.0 - p + colors = [_MAGENTA, _MAGENTA, _ACCENT, (0.85, 0.85, 0.95)] + count = int(self.INTRO_STATIC * (strength ** 0.5)) + for _ in range(count): + x = random.uniform(0, width) + y = random.uniform(0, height) + col = random.choice(colors) + cr.set_source_rgba(*col, random.uniform(0.25, 0.85) * (0.35 + 0.65 * strength)) + sz = random.uniform(1.0, 2.8) + cr.rectangle(x, y, sz, sz) + cr.fill() + band = p * height + cr.set_source_rgba(*_ACCENT, 0.5 * (1.0 - abs(2 * p - 1))) + cr.rectangle(0, band - 2.0, width, 4.0) + cr.fill() + + def _draw_outro(self, cr, width: float, height: float) -> None: + po = min(1.0, max(0.0, (self._outro_t or 0.0) / self.OUTRO_DURATION)) + colors = [_MAGENTA, _MAGENTA, _ACCENT, (0.85, 0.85, 0.95)] + count = int(self.INTRO_STATIC * (po ** 0.5)) + for _ in range(count): + x = random.uniform(0, width) + y = random.uniform(0, height) + col = random.choice(colors) + cr.set_source_rgba(*col, random.uniform(0.25, 0.85) * (0.35 + 0.65 * po)) + sz = random.uniform(1.0, 2.8) + cr.rectangle(x, y, sz, sz) + cr.fill() + band = (1.0 - po) * height + cr.set_source_rgba(*_ACCENT, 0.5 * (1.0 - abs(2 * po - 1))) + cr.rectangle(0, band - 2.0, width, 4.0) + cr.fill() + + def _draw_content(self, cr, width: float, height: float) -> None: + r, g, b = _VIOLET + + cr.save() + cr.set_source_rgba(r, g, b, self.SCANLINE_ALPHA) + cr.set_line_width(1.0) + y = 0.0 + while y < height: + cr.move_to(0, y) + cr.line_to(width, y) + y += self.SCANLINE_GAP + cr.stroke() + cr.restore() + + phase = (self._sat_time % self.SWEEP_PERIOD) / self.SWEEP_PERIOD + sweep_y = phase * height + hh = self.SWEEP_HALF_HEIGHT + grad = cairo.LinearGradient(0, sweep_y - hh, 0, sweep_y + hh) + grad.add_color_stop_rgba(0.0, r, g, b, 0.0) + grad.add_color_stop_rgba(0.5, r, g, b, 0.09) + grad.add_color_stop_rgba(1.0, r, g, b, 0.0) + cr.set_source(grad) + cr.rectangle(0, sweep_y - hh, width, hh * 2) + cr.fill() + + flicker = 0.012 + 0.007 * math.sin(self._sat_time * 11.0) + cr.set_source_rgba(r, g, b, max(0.0, flicker)) + cr.paint() + + self._draw_noise(cr, width, height) + + def _draw_noise(self, cr, width: float, height: float) -> None: + now = self._sat_time + self._particles = [p for p in self._particles if now - p["birth"] < p["life"]] + while len(self._particles) < self.NOISE_COUNT: + self._particles.append({ + "x": random.uniform(0, width), + "y": random.uniform(0, height), + "w": random.uniform(1.0, 2.6), + "h": random.uniform(1.0, 2.0), + "color": random.choice(self.NOISE_COLORS), + "peak_alpha": random.uniform(*self.NOISE_ALPHA_RANGE), + "birth": now, + "life": random.uniform(*self.NOISE_LIFETIME), + }) + + for p in self._particles: + t = (now - p["birth"]) / p["life"] + if t < self.NOISE_FADE_IN: + envelope = t / self.NOISE_FADE_IN + elif t > 1.0 - self.NOISE_FADE_OUT: + envelope = max(0.0, (1.0 - t) / self.NOISE_FADE_OUT) + else: + envelope = 1.0 + r, g, b = p["color"] + cr.set_source_rgba(r, g, b, p["peak_alpha"] * envelope) + cr.rectangle(p["x"], p["y"], p["w"], p["h"]) + cr.fill() diff --git a/desktopenvs/hyprdrive/beacon/main.py b/desktopenvs/hyprdrive/beacon/main.py new file mode 100644 index 0000000..343c66c --- /dev/null +++ b/desktopenvs/hyprdrive/beacon/main.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""beacon — the Cosmonaut Shell notification daemon for hyprdrive. + +Replaces dunst: owns org.freedesktop.Notifications and renders each notification +as a holographic card (scanline/sweep/noise overlay, emitted-magenta text, violet +holo-glass, radio-squiggle divider) in a top-centre layer-shell stack, so +notifications read as one more orbit of the astro-menu / orbit-menu / station-bar +look instead of a plain popup. + + main.py run the resident daemon (owns the notification bus name, stays hidden + until a notification arrives) +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +# beacon-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, FDN_NAME # noqa: E402 +from server import NotificationServer # noqa: E402 +from window import BeaconWindow # noqa: E402 + + +class BeaconApp(Gtk.Application): + def __init__(self) -> None: + super().__init__(application_id=APP_ID, + flags=Gio.ApplicationFlags.DEFAULT_FLAGS) + self.window: BeaconWindow | None = None + self._server: NotificationServer | None = None + self._name_id = 0 + + def do_startup(self) -> None: + Gtk.Application.do_startup(self) + theme.load_css() + self.window = BeaconWindow(self, on_closed=self._on_closed) + # Own the freedesktop notification name; the server is created once the bus + # connection is in hand. + self._name_id = Gio.bus_own_name( + Gio.BusType.SESSION, FDN_NAME, Gio.BusNameOwnerFlags.NONE, + self._on_bus_acquired, None, self._on_name_lost) + self.hold() # stay alive with no visible window + + def _on_bus_acquired(self, connection: Gio.DBusConnection, _name: str) -> None: + assert self.window is not None + self._server = NotificationServer(connection, self.window) + + def _on_closed(self, nid: int, reason: int) -> None: + if self._server is not None: + self._server.emit_closed(nid, reason) + + def _on_name_lost(self, _connection, _name: str) -> None: + # Another notification daemon (e.g. a still-running dunst) already owns it. + sys.stderr.write( + "beacon: could not acquire org.freedesktop.Notifications " + "(another notification daemon is running); exiting.\n") + self.quit() + + def do_activate(self) -> None: + pass # resident daemon: nothing to do on activate + + +def main() -> int: + GLib.set_prgname("beacon") + return BeaconApp().run(sys.argv) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/desktopenvs/hyprdrive/beacon/notification.py b/desktopenvs/hyprdrive/beacon/notification.py new file mode 100644 index 0000000..4cfd7aa --- /dev/null +++ b/desktopenvs/hyprdrive/beacon/notification.py @@ -0,0 +1,188 @@ +"""A single holographic notification card. + +Layout: [app icon] [ summary (emitted-magenta, letter-spaced) / radio-squiggle +divider / body ] with an optional row of action-pill buttons, all under a +scanline/sweep/noise HologramOverlay (lib/hologram.py) clipped to the card's +rounded rectangle. The card materialises out of static (holo intro) when it +appears and dissolves back into static when dismissed. +""" + +from __future__ import annotations + +import math +from typing import Callable, Optional + +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Gdk", "4.0") +gi.require_version("GdkPixbuf", "2.0") +from gi.repository import Gdk, GdkPixbuf, GLib, Gtk # noqa: E402 + +from lib.hologram import HologramOverlay + +_SQUIGGLE = "∿" * 16 # ∿ sine-wave "tuned signal" divider +_CARD_RADIUS = 16.0 + + +def _rounded_path(cr, w: float, h: float, r: float = _CARD_RADIUS) -> None: + r = min(r, w / 2, h / 2) + cr.new_sub_path() + cr.arc(w - r, r, r, -math.pi / 2, 0) + cr.arc(w - r, h - r, r, 0, math.pi / 2) + cr.arc(r, h - r, r, math.pi / 2, math.pi) + cr.arc(r, r, r, math.pi, 3 * math.pi / 2) + cr.close_path() + + +class NotificationCard: + def __init__(self, nid: int, summary: str, body: str, urgency: int, + icon: str, image_data, actions: list[str], + on_action: Callable[[int, str], None], + on_dismiss: Callable[[int], None]) -> None: + self.nid = nid + self._on_action = on_action + self._on_dismiss = on_dismiss + self._dismissing = False + critical = urgency >= 2 + + # -- content ----------------------------------------------------------- + content = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) + content.add_css_class("beacon-content") + + img = self._build_icon(icon, image_data) + if img is not None: + content.append(img) + + textcol = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) + textcol.set_hexpand(True) + textcol.set_valign(Gtk.Align.CENTER) + + if summary: + lbl = Gtk.Label(label=summary, xalign=0.0) + lbl.add_css_class("beacon-summary") + lbl.set_wrap(True) + lbl.set_wrap_mode(2) # WORD_CHAR + lbl.set_max_width_chars(30) + textcol.append(lbl) + + squig = Gtk.Label(label=_SQUIGGLE, xalign=0.0) + squig.add_css_class("beacon-squiggle") + textcol.append(squig) + + if body: + blbl = Gtk.Label(xalign=0.0) + blbl.add_css_class("beacon-body") + blbl.set_wrap(True) + blbl.set_wrap_mode(2) + blbl.set_max_width_chars(34) + # bodies may carry a small Pango-markup subset (///…); + # fall back to plain text if the sender's markup won't parse. + try: + blbl.set_markup(body) + except GLib.GError: + blbl.set_text(body) + textcol.append(blbl) + + content.append(textcol) + + if actions: + row = self._build_actions(actions) + if row is not None: + textcol.append(row) + + card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + card.add_css_class("beacon-card") + if critical: + card.add_css_class("critical") + card.append(content) + card.set_size_request(340, -1) + + # -- hologram overlay -------------------------------------------------- + self._holo = HologramOverlay(enabled=True, clip_func=_rounded_path, + fade_widget=content) + + overlay = Gtk.Overlay() + overlay.set_child(card) + overlay.add_overlay(self._holo.widget) + overlay.set_measure_overlay(self._holo.widget, False) + + # left-click anywhere on the card = invoke default action if present, else + # dismiss; right-click always dismisses. + self._default_action = "default" if "default" in actions else None + left = Gtk.GestureClick(button=1) + left.connect("released", self._on_left_click) + overlay.add_controller(left) + right = Gtk.GestureClick(button=3) + right.connect("released", lambda *_a: self.dismiss()) + overlay.add_controller(right) + + self.widget = overlay + self._holo.start_intro() + + # -- public --------------------------------------------------------------- + def tick(self, dt: float) -> None: + self._holo.tick(dt) + + def dismiss(self) -> None: + """Play the dissolve, then hand the id back to the window for removal.""" + if self._dismissing: + return + self._dismissing = True + self._holo.start_outro(lambda: self._on_dismiss(self.nid)) + + # -- internals ------------------------------------------------------------ + def _on_left_click(self, *_a) -> None: + if self._default_action is not None: + self._on_action(self.nid, self._default_action) + else: + self.dismiss() + + def _build_actions(self, actions: list[str]) -> Optional[Gtk.Box]: + # actions is a flat [key, label, key, label, …] list; "default" is the + # implicit click action and isn't shown as a button. + row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + row.add_css_class("beacon-actions") + row.set_margin_top(6) + shown = 0 + for i in range(0, len(actions) - 1, 2): + key, label = actions[i], actions[i + 1] + if key == "default": + continue + btn = Gtk.Button(label=label or key) + btn.add_css_class("beacon-action") + btn.connect("clicked", lambda _b, k=key: self._on_action(self.nid, k)) + row.append(btn) + shown += 1 + return row if shown else None + + def _build_icon(self, icon: str, image_data) -> Optional[Gtk.Image]: + img: Optional[Gtk.Image] = None + if image_data is not None: + pb = self._pixbuf_from_hint(image_data) + if pb is not None: + img = Gtk.Image.new_from_paintable(Gdk.Texture.new_for_pixbuf(pb)) + if img is None and icon: + if icon.startswith("file://"): + icon = icon[len("file://"):] + if icon.startswith("/"): + img = Gtk.Image.new_from_file(icon) + else: + img = Gtk.Image.new_from_icon_name(icon) + if img is None: + return None + img.add_css_class("beacon-icon") + img.set_pixel_size(44) + img.set_valign(Gtk.Align.START) + return img + + @staticmethod + def _pixbuf_from_hint(data) -> Optional[GdkPixbuf.Pixbuf]: + # Spec "image-data": (width, height, rowstride, has_alpha, bits, channels, bytes) + try: + w, h, rowstride, has_alpha, bits, channels, raw = data + return GdkPixbuf.Pixbuf.new_from_bytes( + GLib.Bytes.new(bytes(raw)), GdkPixbuf.Colorspace.RGB, + has_alpha, bits, w, h, rowstride) + except (ValueError, TypeError): + return None diff --git a/desktopenvs/hyprdrive/beacon/paths.py b/desktopenvs/hyprdrive/beacon/paths.py new file mode 100644 index 0000000..ec65230 --- /dev/null +++ b/desktopenvs/hyprdrive/beacon/paths.py @@ -0,0 +1,17 @@ +"""Shared filesystem locations. Works whether run from the repo or ~/.config.""" + +from __future__ import annotations + +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parent +STYLE_DIR = BASE_DIR / "style" + +# GApplication single-instance id (our own process). NOT the freedesktop +# notification name — that well-known name (org.freedesktop.Notifications) is +# owned separately in main.py, the same one dunst used to hold. +APP_ID = "eu.abdelbaki.beacon" + +FDN_NAME = "org.freedesktop.Notifications" +FDN_PATH = "/org/freedesktop/Notifications" +FDN_IFACE = "org.freedesktop.Notifications" diff --git a/desktopenvs/hyprdrive/beacon/server.py b/desktopenvs/hyprdrive/beacon/server.py new file mode 100644 index 0000000..eb62791 --- /dev/null +++ b/desktopenvs/hyprdrive/beacon/server.py @@ -0,0 +1,146 @@ +"""org.freedesktop.Notifications D-Bus service — the well-known name dunst used +to own. Parses each Notify call and hands it to the stack window (window.py) to +render as a holographic card; owns per-notification expiry timers and emits the +spec's NotificationClosed / ActionInvoked signals. + +Deliberately small: the visible/interactive spec subset (body markup, actions, +icons, urgency, replaces_id, timeouts, persistence) — no sound/markup-hint +gymnastics. +""" + +from __future__ import annotations + +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import Gio, GLib # noqa: E402 + +from paths import FDN_IFACE, FDN_PATH + +# reasons per the spec: 1 expired, 2 dismissed by user, 3 closed by call, 4 undefined +_INTROSPECTION_XML = """ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +""" + +# server default timeouts (ms) by urgency when the client passes -1 +_DEFAULT_TIMEOUT = {0: 5000, 1: 8000, 2: 0} # low / normal / critical(never) + + +class NotificationServer: + def __init__(self, connection: Gio.DBusConnection, window) -> None: + self._conn = connection + self._window = window + self._next_id = 1 + self._timers: dict[int, int] = {} + + node = Gio.DBusNodeInfo.new_for_xml(_INTROSPECTION_XML) + self._iface = node.interfaces[0] + connection.register_object( + FDN_PATH, self._iface, self._on_method_call, None, None) + + # -- D-Bus dispatch ------------------------------------------------------- + def _on_method_call(self, _conn, _sender, _path, _iface, method, params, invocation): + if method == "Notify": + invocation.return_value(GLib.Variant("(u)", (self._notify(params),))) + elif method == "CloseNotification": + (nid,) = params.unpack() + self._window.close_notification(int(nid), reason=3) + self._cancel_timer(int(nid)) + invocation.return_value(None) + elif method == "GetCapabilities": + invocation.return_value(GLib.Variant("(as)", ( + ["body", "body-markup", "icon-static", "actions", "persistence"],))) + elif method == "GetServerInformation": + invocation.return_value(GLib.Variant("(ssss)", ( + "beacon", "abdelbaki.eu", "1.0", "1.2"))) + else: + invocation.return_error_literal( + Gio.dbus_error_quark(), Gio.DBusError.UNKNOWN_METHOD, method) + + def _notify(self, params) -> int: + (app_name, replaces_id, app_icon, summary, body, + actions, hints, expire_timeout) = params.unpack() + + nid = int(replaces_id) if replaces_id else self._next_id + if not replaces_id: + self._next_id += 1 + + urgency = int(hints.get("urgency", 1)) + image_data = (hints.get("image-data") or hints.get("image_data") + or hints.get("icon_data")) + image_path = hints.get("image-path") or hints.get("image_path") + icon = image_path or app_icon or "" + + self._window.show_notification( + nid, summary, body, urgency, icon, image_data, list(actions), + self._emit_action) + + self._arm_timer(nid, int(expire_timeout), urgency) + return nid + + # -- expiry timers -------------------------------------------------------- + def _arm_timer(self, nid: int, expire_timeout: int, urgency: int) -> None: + self._cancel_timer(nid) + if expire_timeout < 0: + ms = _DEFAULT_TIMEOUT.get(urgency, 8000) + else: + ms = expire_timeout + if ms <= 0: + return # 0 = never expire (or critical default) + self._timers[nid] = GLib.timeout_add(ms, self._on_expire, nid) + + def _on_expire(self, nid: int) -> bool: + self._timers.pop(nid, None) + self._window.close_notification(nid, reason=1) # 1 = expired + return False + + def _cancel_timer(self, nid: int) -> None: + tid = self._timers.pop(nid, None) + if tid is not None: + GLib.source_remove(tid) + + # -- signals (also the window's on_closed / on_action callbacks) ----------- + def emit_closed(self, nid: int, reason: int) -> None: + self._cancel_timer(nid) + self._conn.emit_signal(None, FDN_PATH, FDN_IFACE, "NotificationClosed", + GLib.Variant("(uu)", (nid, reason))) + + def _emit_action(self, nid: int, key: str) -> None: + self._conn.emit_signal(None, FDN_PATH, FDN_IFACE, "ActionInvoked", + GLib.Variant("(us)", (nid, key))) + # dismiss the card once its action fired, matching typical daemon behaviour + self._window.close_notification(nid, reason=2) diff --git a/desktopenvs/hyprdrive/beacon/style/_colors.css b/desktopenvs/hyprdrive/beacon/style/_colors.css new file mode 100644 index 0000000..e226c9e --- /dev/null +++ b/desktopenvs/hyprdrive/beacon/style/_colors.css @@ -0,0 +1,8 @@ +/* Generated from ~/Dotfiles/colors.conf by apply-theme.sh — do not hand-edit the + * hex values; edit colors.conf and re-run apply-theme.sh. (Kept in sync via the + * sed pipeline in USER_FILES; these defaults are the CyberQueer palette.) */ +@define-color text #D6ABAB; +@define-color bg #1A1A1A; +@define-color accent #E40046; +@define-color violet #5018DD; +@define-color danger #F50505; diff --git a/desktopenvs/hyprdrive/beacon/style/style.css b/desktopenvs/hyprdrive/beacon/style/style.css new file mode 100644 index 0000000..7d95bd9 --- /dev/null +++ b/desktopenvs/hyprdrive/beacon/style/style.css @@ -0,0 +1,90 @@ +/* beacon — holographic notification cards. Matches the astro-menu idiom + * (astro-menu/style/style.css): emitted-magenta text, violet holo-glass fills, + * glow-violet / accent frames, Agave Nerd Font Mono, rounded cards. The compositor + * blurs behind the translucent card fills (see the `beacon` layer-rule in + * hypr/usr/windowrules.lua); the scanline/sweep/noise depth is Cairo-drawn on top + * by lib/hologram.py. */ + +/* Emitted magenta reads as projected light on the blurred glass, exactly as the + * astro panel overrides its @text. */ +@define-color text #EB00A6; + +* { + font-family: "Agave Nerd Font Mono", monospace; + font-size: 12pt; +} + +/* The CyberQueer GTK theme paints `* { background-color:#1a1a1a }` on every node, + * which would fill the surface and the gaps between cards with an opaque slab. + * Blank the structural nodes; the card asserts its own glass fill below. */ +window, +window.background, +.beacon-window, +.beacon-stack, +.beacon-content, +box, +overlay, +label, +image, +drawingarea { + background: transparent; + background-color: transparent; +} + +/* the holo-glass card */ +.beacon-card { + background-color: alpha(@violet, 0.40); + border: 2px solid #8A5CFF; /* glow_violet — emitted-light frame */ + border-radius: 16px; + padding: 12px 14px; + box-shadow: 0 0 16px 1px alpha(#8A5CFF, 0.35); +} +.beacon-card.critical { + border-color: @accent; + background-color: alpha(@accent, 0.14); + box-shadow: 0 0 18px 1px alpha(@accent, 0.40); +} + +/* summary = emitted magenta, letter-spaced like the astro HUD headlines */ +.beacon-summary { + color: @text; + font-weight: bold; + font-size: 12pt; + letter-spacing: 1px; +} +.beacon-card.critical .beacon-summary { color: @accent; } + +/* radio-squiggle divider (the tuned-signal line) */ +.beacon-squiggle { + color: #8A5CFF; + font-size: 9pt; + margin: 1px 0; + opacity: 0.9; +} +.beacon-card.critical .beacon-squiggle { color: @accent; } + +.beacon-body { + color: @text; + font-size: 11pt; + opacity: 0.95; +} + +.beacon-icon { margin-right: 2px; } + +/* action pills — astro-menu's .quad-action idiom */ +.beacon-action { + color: @text; + background-color: alpha(@violet, 0.4); + border: 2px solid #8A5CFF; + border-radius: 20px; + padding: 2px 12px; + min-height: 22px; + transition: border-color 180ms ease, color 180ms ease, box-shadow 220ms ease, background 180ms ease; +} +.beacon-action:hover { + border-color: @accent; + color: @accent; + box-shadow: 0 0 12px 1px alpha(@accent, 0.55); +} + +.beacon-hologram { background: transparent; } diff --git a/desktopenvs/hyprdrive/beacon/theme.py b/desktopenvs/hyprdrive/beacon/theme.py new file mode 100644 index 0000000..82dae80 --- /dev/null +++ b/desktopenvs/hyprdrive/beacon/theme.py @@ -0,0 +1,30 @@ +"""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). +_colors.css defines the CyberQueer @define-color names; style.css consumes them. + +Priority is USER+1 for the same reason as the others: the CyberQueer GTK theme +at ~/.config/gtk-4.0/gtk.css loads at PRIORITY_USER (800), above APPLICATION +(600), and would beat our transparent structural containers otherwise. +""" + +from __future__ import annotations + +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import Gdk, Gtk # noqa: E402 + +from paths import STYLE_DIR + + +def load_css() -> None: + display = Gdk.Display.get_default() + for name in ("_colors.css", "style.css"): + path = STYLE_DIR / name + if not path.exists(): + continue + provider = Gtk.CssProvider() + provider.load_from_path(str(path)) + Gtk.StyleContext.add_provider_for_display( + display, provider, Gtk.STYLE_PROVIDER_PRIORITY_USER + 1 + ) diff --git a/desktopenvs/hyprdrive/beacon/window.py b/desktopenvs/hyprdrive/beacon/window.py new file mode 100644 index 0000000..1ccb710 --- /dev/null +++ b/desktopenvs/hyprdrive/beacon/window.py @@ -0,0 +1,128 @@ +"""The notification stack: a top-centre layer-shell surface that holds the live +notification cards and drives one frame-clock tick for all their holograms. + +Sized to its content (anchored TOP only, so it floats centred like dunst did), +on the OVERLAY layer above normal windows. Newest card on top. The surface is +hidden whenever no cards are showing, so it never eats clicks on an empty screen. +""" + +from __future__ import annotations + +from typing import Callable, Optional + +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Gtk4LayerShell", "1.0") +from gi.repository import Gtk # noqa: E402 +from gi.repository import Gtk4LayerShell as LayerShell # noqa: E402 + +from notification import NotificationCard + +MARGIN_TOP = 46 +MAX_VISIBLE = 6 + + +class BeaconWindow(Gtk.ApplicationWindow): + def __init__(self, app: Gtk.Application, + on_closed: Callable[[int, int], None]) -> None: + super().__init__(application=app) + self._on_closed = on_closed # (id, reason) -> emit NotificationClosed + self._cards: dict[int, NotificationCard] = {} + self._order: list[int] = [] # newest first + self._tick_id: Optional[int] = None + self._last_tick: Optional[float] = None + + self.set_decorated(False) + self.add_css_class("beacon-window") + + self._stack = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) + self._stack.add_css_class("beacon-stack") + self._stack.set_halign(Gtk.Align.CENTER) + self._stack.set_valign(Gtk.Align.START) + self.set_child(self._stack) + + self._init_layer_shell() + self.set_visible(False) + + def _init_layer_shell(self) -> None: + LayerShell.init_for_window(self) + LayerShell.set_layer(self, LayerShell.Layer.OVERLAY) + LayerShell.set_namespace(self, "beacon") + LayerShell.set_keyboard_mode(self, LayerShell.KeyboardMode.NONE) + LayerShell.set_anchor(self, LayerShell.Edge.TOP, True) + LayerShell.set_margin(self, LayerShell.Edge.TOP, MARGIN_TOP) + LayerShell.set_exclusive_zone(self, 0) + + # -- public: driven by the D-Bus server ----------------------------------- + def show_notification(self, nid: int, summary: str, body: str, urgency: int, + icon: str, image_data, actions: list[str], + on_action: Callable[[int, str], None]) -> None: + if nid in self._cards: # replaces_id: swap in place + self._drop_card(nid) + + card = NotificationCard(nid, summary, body, urgency, icon, image_data, + actions, on_action, self._card_dismissed) + self._cards[nid] = card + self._order.insert(0, nid) + self._stack.prepend(card.widget) + + # cap the stack: quietly expire the oldest beyond the limit + while len(self._order) > MAX_VISIBLE: + old = self._order[-1] + self._card_dismissed(old, reason=4) # 4 = undefined/expired-by-limit + + self.set_visible(True) + self._ensure_tick() + + def close_notification(self, nid: int, reason: int = 3) -> bool: + """Server-/user-requested close. reason 3 = closed by CloseNotification.""" + if nid not in self._cards: + return False + card = self._cards[nid] + # play the dissolve; removal + the NotificationClosed signal follow + card.dismiss() + self._pending_reason[nid] = reason + return True + + # -- card lifecycle ------------------------------------------------------- + _pending_reason: dict[int, int] = {} + + def _card_dismissed(self, nid: int, reason: int = 2) -> None: + """Called after a card's dissolve finishes (reason 2 = dismissed by user), + or directly for cap-expiry. Removes it and signals the closure.""" + if nid not in self._cards: + return + reason = self._pending_reason.pop(nid, reason) + self._drop_card(nid) + self._on_closed(nid, reason) + if not self._cards: + self.set_visible(False) + self._stop_tick() + + def _drop_card(self, nid: int) -> None: + card = self._cards.pop(nid, None) + if card is not None: + self._stack.remove(card.widget) + if nid in self._order: + self._order.remove(nid) + + # -- animation 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 + for card in list(self._cards.values()): + card.tick(dt) + return True + + def _ensure_tick(self) -> None: + if self._tick_id is None: + self._last_tick = None + self._tick_id = self.add_tick_callback(self._on_tick) + + def _stop_tick(self) -> None: + if self._tick_id is not None: + self.remove_tick_callback(self._tick_id) + self._tick_id = None + self._last_tick = None diff --git a/desktopenvs/hyprdrive/hypr/usr/autostart.lua b/desktopenvs/hyprdrive/hypr/usr/autostart.lua index 2774919..6f46364 100644 --- a/desktopenvs/hyprdrive/hypr/usr/autostart.lua +++ b/desktopenvs/hyprdrive/hypr/usr/autostart.lua @@ -9,7 +9,7 @@ hl.on("hyprland.start", function() hl.exec_cmd("systemctl --user start hyprpolkitagent") hl.exec_cmd("hyprsunset") hl.exec_cmd("nm-applet") - hl.exec_cmd("dunst") + hl.exec_cmd("bash ~/Dotfiles/desktopenvs/hyprdrive/scripts/beacon-start.sh") hl.exec_cmd("[workspace special:magic silent] kitty") hl.exec_cmd("hyprctl setcursor Nordzy-cursors-lefthand 50") hl.exec_cmd("hyprpaper") diff --git a/desktopenvs/hyprdrive/hypr/usr/windowrules.lua b/desktopenvs/hyprdrive/hypr/usr/windowrules.lua index 3fef059..f414897 100644 --- a/desktopenvs/hyprdrive/hypr/usr/windowrules.lua +++ b/desktopenvs/hyprdrive/hypr/usr/windowrules.lua @@ -127,6 +127,14 @@ for _, ns in ipairs({ "astro-menu", "orbit-menu", "horizon-dock", "station-bar" blur = true, ignore_alpha = 0.1, no_anim = true }) end +-- beacon (our notification daemon, namespace "beacon") renders holographic +-- notification cards. Blur what shows through their translucent violet glass so +-- they read as the same frosted holo panels as the menus. no_anim: each card +-- materialises out of static via its own in-app hologram intro, so the compositor +-- should map the surface in place rather than slide it. +hl.layer_rule({ name = "cosmoshell-blur-beacon", match = { namespace = "beacon" }, + blur = true, ignore_alpha = 0.1, no_anim = true }) + -- smart gaps hl.workspace_rule({ workspace = "w[tv1]s[false]", gaps_out = 0, gaps_in = 0 }) hl.workspace_rule({ workspace = "f[1]s[false]", gaps_out = 0, gaps_in = 0 }) diff --git a/desktopenvs/hyprdrive/scripts/beacon-start.sh b/desktopenvs/hyprdrive/scripts/beacon-start.sh new file mode 100644 index 0000000..f9540e5 --- /dev/null +++ b/desktopenvs/hyprdrive/scripts/beacon-start.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Resident launcher for beacon, the Cosmonaut Shell notification daemon (replaces +# dunst). Same LD_PRELOAD requirement and rationale as the rest of the suite's +# start scripts: gtk4-layer-shell must load before libwayland-client, which isn't +# guaranteed under PyGObject. + +APP="${HOME}/.config/beacon/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" "$@"