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 <noreply@anthropic.com>main
parent
178c5e3e91
commit
838f954cad
|
|
@ -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()
|
||||||
|
|
@ -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())
|
||||||
|
|
@ -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 (<b>/<i>/<u>/<a>…);
|
||||||
|
# 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
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -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 = """
|
||||||
|
<node>
|
||||||
|
<interface name="org.freedesktop.Notifications">
|
||||||
|
<method name="GetCapabilities">
|
||||||
|
<arg type="as" name="capabilities" direction="out"/>
|
||||||
|
</method>
|
||||||
|
<method name="Notify">
|
||||||
|
<arg type="s" name="app_name" direction="in"/>
|
||||||
|
<arg type="u" name="replaces_id" direction="in"/>
|
||||||
|
<arg type="s" name="app_icon" direction="in"/>
|
||||||
|
<arg type="s" name="summary" direction="in"/>
|
||||||
|
<arg type="s" name="body" direction="in"/>
|
||||||
|
<arg type="as" name="actions" direction="in"/>
|
||||||
|
<arg type="a{sv}" name="hints" direction="in"/>
|
||||||
|
<arg type="i" name="expire_timeout" direction="in"/>
|
||||||
|
<arg type="u" name="id" direction="out"/>
|
||||||
|
</method>
|
||||||
|
<method name="CloseNotification">
|
||||||
|
<arg type="u" name="id" direction="in"/>
|
||||||
|
</method>
|
||||||
|
<method name="GetServerInformation">
|
||||||
|
<arg type="s" name="name" direction="out"/>
|
||||||
|
<arg type="s" name="vendor" direction="out"/>
|
||||||
|
<arg type="s" name="version" direction="out"/>
|
||||||
|
<arg type="s" name="spec_version" direction="out"/>
|
||||||
|
</method>
|
||||||
|
<signal name="NotificationClosed">
|
||||||
|
<arg type="u" name="id"/>
|
||||||
|
<arg type="u" name="reason"/>
|
||||||
|
</signal>
|
||||||
|
<signal name="ActionInvoked">
|
||||||
|
<arg type="u" name="id"/>
|
||||||
|
<arg type="s" name="action_key"/>
|
||||||
|
</signal>
|
||||||
|
</interface>
|
||||||
|
</node>
|
||||||
|
"""
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
|
@ -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,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; }
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
@ -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
|
||||||
|
|
@ -9,7 +9,7 @@ hl.on("hyprland.start", function()
|
||||||
hl.exec_cmd("systemctl --user start hyprpolkitagent")
|
hl.exec_cmd("systemctl --user start hyprpolkitagent")
|
||||||
hl.exec_cmd("hyprsunset")
|
hl.exec_cmd("hyprsunset")
|
||||||
hl.exec_cmd("nm-applet")
|
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("[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")
|
||||||
|
|
|
||||||
|
|
@ -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 })
|
blur = true, ignore_alpha = 0.1, no_anim = true })
|
||||||
end
|
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
|
-- smart gaps
|
||||||
hl.workspace_rule({ workspace = "w[tv1]s[false]", gaps_out = 0, gaps_in = 0 })
|
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 })
|
hl.workspace_rule({ workspace = "f[1]s[false]", gaps_out = 0, gaps_in = 0 })
|
||||||
|
|
|
||||||
|
|
@ -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" "$@"
|
||||||
Loading…
Reference in New Issue