129 lines
5.0 KiB
Python
129 lines
5.0 KiB
Python
"""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
|