"""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