"""The popup: a content-sized floating layer-shell panel anchored top-centre, listing beacon's notification history (via history_client.HistoryClient). Gtk.Window (layer TOP, anchored TOP -> horizontally centred, height = content) └ Gtk.Overlay main : .tx-panel (header w/ Clear All + scrollable row list) over : ✕ close button (top-right, closes the whole panel) Dismissed with the launcher toggle, Esc, or the ✕ button — no click-outside- to-close (would need a blocking full-screen surface), same tradeoff astro- menu's window.py documents. Each row has its own ✕ that removes just that entry from beacon's history (independent of closing the panel itself). """ from __future__ import annotations import gi gi.require_version("Gtk", "4.0") gi.require_version("Gdk", "4.0") gi.require_version("Gtk4LayerShell", "1.0") from gi.repository import Gdk, Gtk # noqa: E402 from gi.repository import Gtk4LayerShell as LayerShell # noqa: E402 import config from history_client import HistoryClient from lib.hologram import HologramOverlay PANEL_WIDTH = 380 EDGE_MARGIN = 28 MAX_LIST_HEIGHT = 420 class TransmitterWindow(Gtk.ApplicationWindow): def __init__(self, app: Gtk.Application) -> None: super().__init__(application=app) self.set_name("transmitter-window") self.add_css_class("tx-window") self.set_decorated(False) self._rows: dict[int, Gtk.Widget] = {} self._order: list[int] = [] # newest-first ids, mirrors History1.List order self._init_layer_shell() self.root = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) self.root.set_name("panel-root") self.root.add_css_class("tx-panel") self.root.set_size_request(PANEL_WIDTH, -1) header = Gtk.CenterBox() header.add_css_class("tx-header") title = Gtk.Label(label="Transmissions", xalign=0.0) title.add_css_class("tx-title") header.set_start_widget(title) clear_btn = Gtk.Button(label="Clear All") clear_btn.add_css_class("tx-clear-all") clear_btn.connect("clicked", lambda *_a: self._client.clear_async()) header.set_end_widget(clear_btn) self.root.append(header) self._list = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) self._list.add_css_class("tx-list") self._scroller = Gtk.ScrolledWindow(hscrollbar_policy=Gtk.PolicyType.NEVER) self._scroller.set_max_content_height(MAX_LIST_HEIGHT) self._scroller.set_propagate_natural_height(True) self._scroller.set_child(self._list) self.root.append(self._scroller) self._empty_label = Gtk.Label(label="No transmissions") self._empty_label.add_css_class("tx-empty") overlay = Gtk.Overlay() overlay.set_child(self.root) self._hologram = HologramOverlay(enabled=config.hologram_enabled(), fade_widget=self.root) overlay.add_overlay(self._hologram.widget) close = Gtk.Button(label="✕") close.add_css_class("close-btn") close.set_halign(Gtk.Align.END) close.set_valign(Gtk.Align.START) close.connect("clicked", lambda *_a: self.hide_panel()) overlay.add_overlay(close) self.set_child(overlay) key = Gtk.EventControllerKey() key.connect("key-pressed", self._on_key) self.add_controller(key) self._last_tick: float | None = None self._tick_id: int | None = None self._client = HistoryClient(on_added=self._on_history_added, on_removed=self._on_history_removed, on_cleared=self._on_history_cleared) self._rebuild_empty_state() self.set_visible(False) # -- layer shell ------------------------------------------------------------ def _init_layer_shell(self) -> None: LayerShell.init_for_window(self) LayerShell.set_layer(self, LayerShell.Layer.TOP) LayerShell.set_namespace(self, "transmitter-panel") LayerShell.set_keyboard_mode(self, LayerShell.KeyboardMode.ON_DEMAND) LayerShell.set_anchor(self, LayerShell.Edge.TOP, True) LayerShell.set_margin(self, LayerShell.Edge.TOP, EDGE_MARGIN) # -- visibility --------------------------------------------------------------- def show_panel(self) -> None: self._client.list_async(self._on_list_result) self.set_visible(True) self.present() if self._hologram.enabled and self._tick_id is None: self._tick_id = self.add_tick_callback(self._on_tick) self._hologram.start_intro() def hide_panel(self) -> None: if self._hologram.enabled and self._tick_id is not None: self._hologram.start_outro(self._finish_hide) else: self._finish_hide() def _finish_hide(self) -> None: self.set_visible(False) if self._tick_id is not None: self.remove_tick_callback(self._tick_id) self._tick_id = None self._last_tick = None def toggle(self) -> None: if self.get_visible(): self.hide_panel() else: self.show_panel() # -- hologram 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 self._hologram.tick(dt) return True def _on_key(self, _c, keyval, _kc, _state) -> bool: if keyval == Gdk.KEY_Escape: self.hide_panel() return True return False # -- history model ---------------------------------------------------------- def _on_list_result(self, entries: list[dict]) -> None: self._clear_list() self._order = [] for entry in entries: # already newest-first from History1.List self._add_entry(entry, prepend=False) self._rebuild_empty_state() def _on_history_added(self, entry: dict) -> None: self._add_entry(entry, prepend=True) def _on_history_removed(self, nid: int) -> None: self._drop_entry(nid) def _on_history_cleared(self) -> None: self._clear_list() self._order = [] self._rebuild_empty_state() def _add_entry(self, entry: dict, prepend: bool) -> None: nid = int(entry["id"]) if nid in self._rows: # replace in place (shouldn't normally happen) self._drop_entry(nid) row = self._build_row(entry) self._rows[nid] = row if prepend: self._order.insert(0, nid) self._list.prepend(row) else: self._order.append(nid) self._list.append(row) self._rebuild_empty_state() def _drop_entry(self, nid: int) -> None: row = self._rows.pop(nid, None) if row is not None: self._list.remove(row) if nid in self._order: self._order.remove(nid) self._rebuild_empty_state() def _clear_list(self) -> None: child = self._list.get_first_child() while child: nxt = child.get_next_sibling() self._list.remove(child) child = nxt self._rows = {} def _rebuild_empty_state(self) -> None: empty = not self._order if empty and self._empty_label.get_parent() is None: self._list.append(self._empty_label) elif not empty and self._empty_label.get_parent() is not None: self._list.remove(self._empty_label) # -- row building ------------------------------------------------------------- def _build_row(self, entry: dict) -> Gtk.Widget: row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) row.add_css_class("tx-row") img = self._build_icon(entry.get("icon") or "") if img is not None: row.append(img) textcol = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) textcol.set_hexpand(True) textcol.set_valign(Gtk.Align.CENTER) app_name = entry.get("app_name") or "" if app_name: app_lbl = Gtk.Label(label=app_name.upper(), xalign=0.0) app_lbl.add_css_class("tx-row-app") textcol.append(app_lbl) summary = entry.get("summary") or "" if summary: sum_lbl = Gtk.Label(label=summary, xalign=0.0) sum_lbl.add_css_class("tx-row-summary") sum_lbl.set_wrap(True) sum_lbl.set_wrap_mode(2) # WORD_CHAR, same convention as beacon/notification.py sum_lbl.set_max_width_chars(28) textcol.append(sum_lbl) body = entry.get("body") or "" if body: body_lbl = Gtk.Label(label=body, xalign=0.0, ellipsize=3, lines=3) body_lbl.add_css_class("tx-row-body") body_lbl.set_wrap(True) body_lbl.set_wrap_mode(2) body_lbl.set_max_width_chars(32) textcol.append(body_lbl) row.append(textcol) nid = int(entry["id"]) close = Gtk.Button(label="✕") close.add_css_class("tx-row-close") close.set_valign(Gtk.Align.START) close.set_tooltip_text("Remove from history") close.connect("clicked", lambda *_a, i=nid: self._client.remove_async(i)) row.append(close) return row def _build_icon(self, icon: str) -> Gtk.Image | None: if not icon: return None if icon.startswith("file://"): icon = icon[len("file://"):] img = Gtk.Image.new_from_file(icon) if icon.startswith("/") \ else Gtk.Image.new_from_icon_name(icon) img.add_css_class("tx-row-icon") img.set_pixel_size(32) img.set_valign(Gtk.Align.START) return img