"""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 cairo 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 import config from lib.hologram import HologramOverlay _CARD_RADIUS = 16.0 # Divider "radio wave" colour (Cairo, not CSS): a magenta wave line on a # transparent background. Critical keeps an accent wave to match its frame. _MAGENTA = (0xEB / 255, 0x00 / 255, 0xA6 / 255) # foreground wave (normal) _ACCENT = (0xE4 / 255, 0x00 / 255, 0x46 / 255) # foreground wave (critical) _SQUIGGLE_H = 14 # divider row height _SQUIGGLE_AMP = 3.0 # wave amplitude (px) _SQUIGGLE_CYCLES = 0.045 # cycles per px of width _SQUIGGLE_SPEED = 2.4 # phase advance (rad/s) — a slowly travelling signal 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], controls: list[dict], on_action: Callable[[int, str], None], on_control: Callable[[int, str, object], None], on_dismiss: Callable[[int], None]) -> None: self.nid = nid self._on_action = on_action self._on_control = on_control self._on_dismiss = on_dismiss self._dismissing = False critical = urgency >= 2 # -- content ----------------------------------------------------------- content = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) content.add_css_class("mnotifd-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("mnotifd-summary") lbl.set_wrap(True) lbl.set_wrap_mode(2) # WORD_CHAR lbl.set_max_width_chars(30) textcol.append(lbl) self._squiggle: Optional[Gtk.DrawingArea] = None if config.squiggle_enabled(): self._squiggle_color = _ACCENT if critical else _MAGENTA self._squiggle_phase = 0.0 self._squiggle = Gtk.DrawingArea() self._squiggle.add_css_class("mnotifd-squiggle") self._squiggle.set_content_height(_SQUIGGLE_H) self._squiggle.set_hexpand(True) self._squiggle.set_draw_func(self._draw_squiggle) textcol.append(self._squiggle) if body: blbl = Gtk.Label(xalign=0.0) blbl.add_css_class("mnotifd-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) if controls: crows = self._build_controls(controls) for crow in crows: textcol.append(crow) card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) card.add_css_class("mnotifd-card") if critical: card.add_css_class("critical") card.append(content) card.set_size_request(340, -1) # -- hologram overlay -------------------------------------------------- self._holo = HologramOverlay(enabled=config.hologram_enabled(), 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) if self._squiggle is not None: self._squiggle_phase += dt * _SQUIGGLE_SPEED self._squiggle.queue_draw() # travel the wave along like a live signal def _draw_squiggle(self, _area, cr, width: int, height: int) -> None: if width <= 0: return # transparent background — just the magenta (accent for critical) radio wave mid = height / 2.0 amp = min(_SQUIGGLE_AMP, mid - 2.0) k = _SQUIGGLE_CYCLES * 2.0 * math.pi # angular freq per px steps = max(2, int(width)) cr.set_line_cap(cairo.LINE_CAP_ROUND) cr.set_line_join(cairo.LINE_JOIN_ROUND) # two passes: a soft wide glow, then a bright thin core — reads as emitted # light over the purple bar, matching the card's holographic frame. for line_w, alpha in ((3.0, 0.30), (1.4, 1.0)): cr.set_line_width(line_w) cr.set_source_rgba(*self._squiggle_color, alpha) for i in range(steps + 1): x = width * i / steps y = mid + amp * math.sin(x * k + self._squiggle_phase) cr.line_to(x, y) if i else cr.move_to(x, y) cr.stroke() 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("mnotifd-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("mnotifd-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_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("mnotifd-actions") btn_row.add_css_class("mnotifd-control-row") btn_row.set_margin_top(6) rows.append(btn_row) btn = Gtk.Button(label=c["label"]) btn.add_css_class("mnotifd-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("mnotifd-control-row") row.set_margin_top(6) lbl = Gtk.Label(label=c["label"], xalign=0.0) lbl.add_css_class("mnotifd-control-label") row.append(lbl) if c["type"] == "toggle": sw = Gtk.Switch() sw.add_css_class("mnotifd-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("mnotifd-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("mnotifd-entry") entry.set_hexpand(True) if c.get("placeholder"): entry.set_placeholder_text(c["placeholder"]) entry.connect("activate", lambda e, c=c: self._fire_control(c, e.get_text())) row.append(entry) rows.append(row) return rows def _on_toggle(self, control: dict, state: bool) -> bool: self._fire_control(control, state) return False # False = let Gtk.Switch apply the requested visual state itself def _fire_control(self, control: dict, value) -> None: self._on_control(self.nid, control["id"], value) if control.get("dismiss"): self.dismiss() def _build_icon(self, icon: str, image_data) -> Optional[Gtk.Image]: img: Optional[Gtk.Image] = None if image_data is not None: 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("mnotifd-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