267 lines
9.7 KiB
Python
267 lines
9.7 KiB
Python
"""The popup: a content-sized floating layer-shell panel anchored top-centre,
|
|
listing mnotifd'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 mnotifd'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 MnotifhistWindow(Gtk.ApplicationWindow):
|
|
def __init__(self, app: Gtk.Application) -> None:
|
|
super().__init__(application=app)
|
|
self.set_name("mnotifhist-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="Notifications", 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, "mnotifhist")
|
|
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 mnotifd/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
|