Dotfiles/desktopenvs/hyprlua/mnotifd/notification.py

305 lines
12 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 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 (<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)
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