189 lines
6.9 KiB
Python
189 lines
6.9 KiB
Python
"""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
|