Dotfiles/desktopenvs/hyprlua/mnotifhist/window.py

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