From 9640e09677b3f94891c1c0f3c4403e2aa661984b Mon Sep 17 00:00:00 2001 From: The_miro Date: Fri, 24 Jul 2026 12:34:19 +0200 Subject: [PATCH] feat(hyprlua): migrate to mnotifd/mnotifhist, generated from hyprdrive's beacon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds regen-beacon.sh, a by-hand generator that produces hyprlua's plain-themed notification daemon (mnotifd) and history viewer (mnotifhist) from hyprdrive's beacon/transmitter-panel sources — stripping the Cosmonaut Shell hologram/ squiggle sci-fi treatment (now config-toggleable on beacon itself) and renaming every beacon/transmitter-panel reference throughout, so future edits to the hyprdrive originals propagate with one script run instead of two hand-diverging copies. Wires mnotifd/mnotifhist into hyprlua in place of dunst (autostart, keybinds, config-updater, install script incl. the D-Bus activation shadow file). Also adds a notification-history launcher button (verified bell glyph) to station-bar and all three hyprlua EWW bar variants. Co-Authored-By: Claude Sonnet 5 --- desktopenvs/hyprdrive/beacon/config.py | 10 + desktopenvs/hyprdrive/beacon/notification.py | 28 +- desktopenvs/hyprdrive/station-bar/bar.py | 7 + .../hyprdrive/station-bar/style/style.css | 10 +- .../hyprlua/config-updater/updater.conf | 15 +- desktopenvs/hyprlua/eww-nobattery/eww.scss | 10 + desktopenvs/hyprlua/eww-nobattery/eww.yuck | 2 + desktopenvs/hyprlua/eww-touch/eww.scss | 10 + desktopenvs/hyprlua/eww-touch/eww.yuck | 2 + desktopenvs/hyprlua/eww/eww.scss | 10 + desktopenvs/hyprlua/eww/eww.yuck | 2 + desktopenvs/hyprlua/hypr/usr/autostart.lua | 3 +- desktopenvs/hyprlua/hypr/usr/binds.lua | 3 +- desktopenvs/hyprlua/mnotifd-theme/_colors.css | 8 + desktopenvs/hyprlua/mnotifd-theme/style.css | 140 +++++++ desktopenvs/hyprlua/mnotifd/config.py | 55 +++ desktopenvs/hyprlua/mnotifd/controls.py | 81 ++++ .../org.freedesktop.Notifications.service | 12 + desktopenvs/hyprlua/mnotifd/history.py | 109 ++++++ desktopenvs/hyprlua/mnotifd/lib/hologram.py | 278 +++++++++++++ desktopenvs/hyprlua/mnotifd/main.py | 92 +++++ desktopenvs/hyprlua/mnotifd/notification.py | 304 ++++++++++++++ desktopenvs/hyprlua/mnotifd/paths.py | 41 ++ desktopenvs/hyprlua/mnotifd/server.py | 370 ++++++++++++++++++ desktopenvs/hyprlua/mnotifd/style/_colors.css | 8 + desktopenvs/hyprlua/mnotifd/style/style.css | 140 +++++++ desktopenvs/hyprlua/mnotifd/theme.py | 30 ++ desktopenvs/hyprlua/mnotifd/window.py | 138 +++++++ .../hyprlua/mnotifhist-theme/_colors.css | 8 + .../hyprlua/mnotifhist-theme/style.css | 121 ++++++ desktopenvs/hyprlua/mnotifhist/.gitignore | 2 + desktopenvs/hyprlua/mnotifhist/config.py | 30 ++ .../hyprlua/mnotifhist/history_client.py | 79 ++++ .../hyprlua/mnotifhist/lib/hologram.py | 279 +++++++++++++ desktopenvs/hyprlua/mnotifhist/main.py | 92 +++++ desktopenvs/hyprlua/mnotifhist/paths.py | 22 ++ .../hyprlua/mnotifhist/style/_colors.css | 8 + .../hyprlua/mnotifhist/style/style.css | 121 ++++++ desktopenvs/hyprlua/mnotifhist/theme.py | 31 ++ desktopenvs/hyprlua/mnotifhist/window.py | 266 +++++++++++++ desktopenvs/hyprlua/scripts/mnotifd-start.sh | 13 + desktopenvs/hyprlua/scripts/mnotifdctl | 141 +++++++ .../hyprlua/scripts/mnotifhist-start.sh | 13 + desktopenvs/hyprlua/scripts/mnotifhist.sh | 38 ++ desktopenvs/hyprlua/scripts/regen-beacon.sh | 133 +++++++ setup/modules/Desktop-Environments/hyprlua.sh | 18 +- 46 files changed, 3310 insertions(+), 23 deletions(-) create mode 100644 desktopenvs/hyprlua/mnotifd-theme/_colors.css create mode 100644 desktopenvs/hyprlua/mnotifd-theme/style.css create mode 100644 desktopenvs/hyprlua/mnotifd/config.py create mode 100644 desktopenvs/hyprlua/mnotifd/controls.py create mode 100644 desktopenvs/hyprlua/mnotifd/dbus/org.freedesktop.Notifications.service create mode 100644 desktopenvs/hyprlua/mnotifd/history.py create mode 100644 desktopenvs/hyprlua/mnotifd/lib/hologram.py create mode 100644 desktopenvs/hyprlua/mnotifd/main.py create mode 100644 desktopenvs/hyprlua/mnotifd/notification.py create mode 100644 desktopenvs/hyprlua/mnotifd/paths.py create mode 100644 desktopenvs/hyprlua/mnotifd/server.py create mode 100644 desktopenvs/hyprlua/mnotifd/style/_colors.css create mode 100644 desktopenvs/hyprlua/mnotifd/style/style.css create mode 100644 desktopenvs/hyprlua/mnotifd/theme.py create mode 100644 desktopenvs/hyprlua/mnotifd/window.py create mode 100644 desktopenvs/hyprlua/mnotifhist-theme/_colors.css create mode 100644 desktopenvs/hyprlua/mnotifhist-theme/style.css create mode 100644 desktopenvs/hyprlua/mnotifhist/.gitignore create mode 100644 desktopenvs/hyprlua/mnotifhist/config.py create mode 100644 desktopenvs/hyprlua/mnotifhist/history_client.py create mode 100644 desktopenvs/hyprlua/mnotifhist/lib/hologram.py create mode 100644 desktopenvs/hyprlua/mnotifhist/main.py create mode 100644 desktopenvs/hyprlua/mnotifhist/paths.py create mode 100644 desktopenvs/hyprlua/mnotifhist/style/_colors.css create mode 100644 desktopenvs/hyprlua/mnotifhist/style/style.css create mode 100644 desktopenvs/hyprlua/mnotifhist/theme.py create mode 100644 desktopenvs/hyprlua/mnotifhist/window.py create mode 100755 desktopenvs/hyprlua/scripts/mnotifd-start.sh create mode 100755 desktopenvs/hyprlua/scripts/mnotifdctl create mode 100755 desktopenvs/hyprlua/scripts/mnotifhist-start.sh create mode 100755 desktopenvs/hyprlua/scripts/mnotifhist.sh create mode 100755 desktopenvs/hyprlua/scripts/regen-beacon.sh diff --git a/desktopenvs/hyprdrive/beacon/config.py b/desktopenvs/hyprdrive/beacon/config.py index e417260..e2af71e 100644 --- a/desktopenvs/hyprdrive/beacon/config.py +++ b/desktopenvs/hyprdrive/beacon/config.py @@ -15,6 +15,8 @@ _DEFAULTS = { "history_enabled": True, "history_length": 100, "history_persist": True, + "hologram": True, + "squiggle": True, } @@ -43,3 +45,11 @@ def history_length() -> int: def history_persist() -> bool: return bool(_load().get("history_persist", True)) + + +def hologram_enabled() -> bool: + return bool(_load().get("hologram", True)) + + +def squiggle_enabled() -> bool: + return bool(_load().get("squiggle", True)) diff --git a/desktopenvs/hyprdrive/beacon/notification.py b/desktopenvs/hyprdrive/beacon/notification.py index 7951407..3bdd27b 100644 --- a/desktopenvs/hyprdrive/beacon/notification.py +++ b/desktopenvs/hyprdrive/beacon/notification.py @@ -20,6 +20,7 @@ gi.require_version("Gdk", "4.0") gi.require_version("GdkPixbuf", "2.0") from gi.repository import Gdk, GdkPixbuf, GLib, Gtk # noqa: E402 +import config from lib.hologram import HologramOverlay _CARD_RADIUS = 16.0 @@ -77,14 +78,16 @@ class NotificationCard: lbl.set_max_width_chars(30) textcol.append(lbl) - self._squiggle_color = _ACCENT if critical else _MAGENTA - self._squiggle_phase = 0.0 - self._squiggle = Gtk.DrawingArea() - self._squiggle.add_css_class("beacon-squiggle") - self._squiggle.set_content_height(_SQUIGGLE_H) - self._squiggle.set_hexpand(True) - self._squiggle.set_draw_func(self._draw_squiggle) - textcol.append(self._squiggle) + self._squiggle: Optional[Gtk.DrawingArea] = None + if config.squiggle_enabled(): + self._squiggle_color = _ACCENT if critical else _MAGENTA + self._squiggle_phase = 0.0 + self._squiggle = Gtk.DrawingArea() + self._squiggle.add_css_class("beacon-squiggle") + self._squiggle.set_content_height(_SQUIGGLE_H) + self._squiggle.set_hexpand(True) + self._squiggle.set_draw_func(self._draw_squiggle) + textcol.append(self._squiggle) if body: blbl = Gtk.Label(xalign=0.0) @@ -120,8 +123,8 @@ class NotificationCard: card.set_size_request(340, -1) # -- hologram overlay -------------------------------------------------- - self._holo = HologramOverlay(enabled=True, clip_func=_rounded_path, - fade_widget=content) + self._holo = HologramOverlay(enabled=config.hologram_enabled(), + clip_func=_rounded_path, fade_widget=content) overlay = Gtk.Overlay() overlay.set_child(card) @@ -144,8 +147,9 @@ class NotificationCard: # -- public --------------------------------------------------------------- def tick(self, dt: float) -> None: self._holo.tick(dt) - self._squiggle_phase += dt * _SQUIGGLE_SPEED - self._squiggle.queue_draw() # travel the wave along like a live signal + if self._squiggle is not None: + self._squiggle_phase += dt * _SQUIGGLE_SPEED + self._squiggle.queue_draw() # travel the wave along like a live signal def _draw_squiggle(self, _area, cr, width: int, height: int) -> None: if width <= 0: diff --git a/desktopenvs/hyprdrive/station-bar/bar.py b/desktopenvs/hyprdrive/station-bar/bar.py index 4376e93..4a0b017 100644 --- a/desktopenvs/hyprdrive/station-bar/bar.py +++ b/desktopenvs/hyprdrive/station-bar/bar.py @@ -57,6 +57,10 @@ _VIOLET = (0x50 / 255, 0x18 / 255, 0xDD / 255) # md-speaker 0xf04c3 — volume, same glyph eww.yuck's volume metric used ICON_ORBIT = chr(0xf0018) ICON_ASTRO = chr(0xf0471) +# md-bell 0xf009a — transmitter-panel launcher (notification history); +# verified against the actually-installed Agave Nerd Font Mono's cmap, +# same technique/rationale as the rest of these codepoints. +ICON_HISTORY = chr(0xf009a) ICON_STATION = chr(0xf1383) ICON_SPACESHIP = chr(0xf135) ICON_WINDOW = chr(0xf10ac) @@ -275,8 +279,11 @@ class StationBar(Gtk.Window): ["bash", "-c", "$HOME/.config/scripts/orbit-menu.sh menu"]) astro_btn = self._make_launcher(ICON_ASTRO, "Astro Menu", "station-astro", ["bash", "-c", "$HOME/.config/scripts/astro-menu.sh toggle top"]) + history_btn = self._make_launcher(ICON_HISTORY, "Notification History", "station-history", + ["bash", "-c", "$HOME/.config/scripts/transmitter-panel.sh"]) self._left.append(orbit_btn) self._left.append(astro_btn) + self._left.append(history_btn) self._ws_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) self._ws_row.add_css_class("station-ws-row") diff --git a/desktopenvs/hyprdrive/station-bar/style/style.css b/desktopenvs/hyprdrive/station-bar/style/style.css index 28717c6..0c331ca 100644 --- a/desktopenvs/hyprdrive/station-bar/style/style.css +++ b/desktopenvs/hyprdrive/station-bar/style/style.css @@ -88,10 +88,12 @@ button.station-badge, padding: 0 7px; transition: text-shadow 160ms ease, color 160ms ease; } -.station-orbit { color: @glow_violet; text-shadow: 0 0 9px alpha(@glow_violet, 0.9); } -.station-astro { color: @accent; text-shadow: 0 0 9px alpha(@accent, 0.9); } -.station-orbit:hover { text-shadow: 0 0 16px @glow_violet, 0 0 5px @glow_violet; } -.station-astro:hover { text-shadow: 0 0 16px @accent, 0 0 5px @accent; } +.station-orbit { color: @glow_violet; text-shadow: 0 0 9px alpha(@glow_violet, 0.9); } +.station-astro { color: @accent; text-shadow: 0 0 9px alpha(@accent, 0.9); } +.station-history { color: @glow_magenta; text-shadow: 0 0 9px alpha(@glow_magenta, 0.9); } +.station-orbit:hover { text-shadow: 0 0 16px @glow_violet, 0 0 5px @glow_violet; } +.station-astro:hover { text-shadow: 0 0 16px @accent, 0 0 5px @accent; } +.station-history:hover { text-shadow: 0 0 16px @glow_magenta, 0 0 5px @glow_magenta; } /* workspace "stations" row */ .station-ws-row { padding: 0 4px; } diff --git a/desktopenvs/hyprlua/config-updater/updater.conf b/desktopenvs/hyprlua/config-updater/updater.conf index 09cfae6..fad0048 100644 --- a/desktopenvs/hyprlua/config-updater/updater.conf +++ b/desktopenvs/hyprlua/config-updater/updater.conf @@ -13,11 +13,12 @@ SOURCE_BASE = ~/Dotfiles/desktopenvs/hyprlua config alacritty config astal-menu config btop -config dunst config gtk-3.0 config hypr except usr config kitty config mimeapps.list +config mnotifd +config mnotifhist config nwg-panel config scripts config ulauncher @@ -29,6 +30,15 @@ config xfce4 # ── intentionally not managed here ─────────────────────────────────────────── ignore config-updater # the updater itself ignore CRT # referenced from dotfiles path directly in binds.conf +ignore dunst # superseded by mnotifd (regen-beacon.sh's plain-themed + # port of hyprdrive's beacon notification daemon) + # NOTE: the dunst package still ships a D-Bus activation file + # (org.knopwob.dunst.service, Name=org.freedesktop.Notifications). + # Shadow it so D-Bus activates mnotifd, not dunst, by deploying + # mnotifd/dbus/ at install time: + # mkdir -p ~/.local/share/dbus-1/services + # cp ~/Dotfiles/desktopenvs/hyprlua/mnotifd/dbus/*.service \ + # ~/.local/share/dbus-1/services/ ignore eww # eww bar variant selected and installed separately ignore eww-nobattery ignore eww-touch @@ -36,6 +46,9 @@ ignore greetd-tuigreet # deployed to /etc/greetd/ at install time ignore spicetify # managed separately (spicetify handles its own config) ignore Vencord # managed separately ignore waybar # present but inactive; eww bar is used instead +ignore mnotifd-theme # hand-maintained plain-theme seed for regen-beacon.sh, + # not deployed to ~/.config itself +ignore mnotifhist-theme # hypr/usr/ contains device-specific lua files (monitors, binds, input, etc.). # They are excluded from automated syncs to preserve per-device customisations. # Deploy manually on a new device: diff --git a/desktopenvs/hyprlua/eww-nobattery/eww.scss b/desktopenvs/hyprlua/eww-nobattery/eww.scss index dc6ce50..8f4f68a 100644 --- a/desktopenvs/hyprlua/eww-nobattery/eww.scss +++ b/desktopenvs/hyprlua/eww-nobattery/eww.scss @@ -117,3 +117,13 @@ menuitem:hover { .menu-launcher:hover { color: #5018dd; } + +// notification-history launcher button (mnotifhist) — magenta, matches +// beacon/transmitter-panel's own notification-card accent colour +.notif-launcher { + color: #EB00A6; + font-size: 15pt; +} +.notif-launcher:hover { + color: #E40046; +} diff --git a/desktopenvs/hyprlua/eww-nobattery/eww.yuck b/desktopenvs/hyprlua/eww-nobattery/eww.yuck index a4dd111..3789842 100644 --- a/desktopenvs/hyprlua/eww-nobattery/eww.yuck +++ b/desktopenvs/hyprlua/eww-nobattery/eww.yuck @@ -22,6 +22,8 @@ (box :orientation "h" :space-evenly false :halign "start" ; astal-menu launcher — opens the popup control centre / app drawer (button :class "music menu-launcher" :onclick "~/.config/scripts/menu-toggle.sh toggle top" {""}) + ; notification-history launcher + (button :class "music notif-launcher" :onclick "~/.config/scripts/mnotifhist.sh" {"󰂚"}) (workspaceWidget :monitor monitor) (button :onclick "~/.config/scripts/menu-toggle.sh toggle top" :class "music" {" ${activewindow}"}) ) diff --git a/desktopenvs/hyprlua/eww-touch/eww.scss b/desktopenvs/hyprlua/eww-touch/eww.scss index 88fc01f..b98032a 100644 --- a/desktopenvs/hyprlua/eww-touch/eww.scss +++ b/desktopenvs/hyprlua/eww-touch/eww.scss @@ -98,6 +98,16 @@ tooltip { color: #5018dd; } +// notification-history launcher button (mnotifhist) — magenta, matches +// beacon/transmitter-panel's own notification-card accent colour +.notif-launcher { + color: #EB00A6; + font-size: 15pt; +} +.notif-launcher:hover { + color: #E40046; +} + menuitem { border: solid; border-width: 3px; diff --git a/desktopenvs/hyprlua/eww-touch/eww.yuck b/desktopenvs/hyprlua/eww-touch/eww.yuck index 792f99e..776b261 100644 --- a/desktopenvs/hyprlua/eww-touch/eww.yuck +++ b/desktopenvs/hyprlua/eww-touch/eww.yuck @@ -27,6 +27,8 @@ (osk) (box :class "music" {"${battery}"}) (button :class "music menu-launcher" :onclick "~/.config/scripts/menu-toggle.sh toggle top" {""}) + ; notification-history launcher + (button :class "music notif-launcher" :onclick "~/.config/scripts/mnotifhist.sh" {"󰂚"}) (metric :label "󰓃 " :value volume :onchange "pactl set-sink-volume @DEFAULT_SINK@ {}%" diff --git a/desktopenvs/hyprlua/eww/eww.scss b/desktopenvs/hyprlua/eww/eww.scss index 5d381da..99a3210 100644 --- a/desktopenvs/hyprlua/eww/eww.scss +++ b/desktopenvs/hyprlua/eww/eww.scss @@ -117,3 +117,13 @@ menuitem:hover { .menu-launcher:hover { color: #5018dd; } + +// notification-history launcher button (mnotifhist) — magenta, matches +// beacon/transmitter-panel's own notification-card accent colour +.notif-launcher { + color: #EB00A6; + font-size: 15pt; +} +.notif-launcher:hover { + color: #E40046; +} diff --git a/desktopenvs/hyprlua/eww/eww.yuck b/desktopenvs/hyprlua/eww/eww.yuck index 00323d7..97bf4db 100644 --- a/desktopenvs/hyprlua/eww/eww.yuck +++ b/desktopenvs/hyprlua/eww/eww.yuck @@ -56,6 +56,8 @@ (box :orientation "h" :space-evenly false :halign "start" ; astal-menu launcher — opens the popup control centre / app drawer (button :class "music menu-launcher" :onclick "~/.config/scripts/menu-toggle.sh toggle top" {""}) + ; notification-history launcher — opens mnotifhist + (button :class "music notif-launcher" :onclick "~/.config/scripts/mnotifhist.sh" {"󰂚"}) ; Battery percentage badge — styled as a pill with class "music" (box :class "music" {"${battery}"}) ; Workspace dots — one button per active workspace on this monitor diff --git a/desktopenvs/hyprlua/hypr/usr/autostart.lua b/desktopenvs/hyprlua/hypr/usr/autostart.lua index bee738b..c614454 100644 --- a/desktopenvs/hyprlua/hypr/usr/autostart.lua +++ b/desktopenvs/hyprlua/hypr/usr/autostart.lua @@ -11,7 +11,8 @@ hl.on("hyprland.start", function() hl.exec_cmd("systemctl --user start hyprpolkitagent") hl.exec_cmd("hyprsunset") hl.exec_cmd("nm-applet") - hl.exec_cmd("dunst") + hl.exec_cmd("bash ~/Dotfiles/desktopenvs/hyprlua/scripts/mnotifd-start.sh") + hl.exec_cmd("bash ~/Dotfiles/desktopenvs/hyprlua/scripts/mnotifhist-start.sh") hl.exec_cmd("[workspace special:magic silent] kitty") hl.exec_cmd("hyprctl setcursor Nordzy-cursors-lefthand 50") hl.exec_cmd("hyprpaper") diff --git a/desktopenvs/hyprlua/hypr/usr/binds.lua b/desktopenvs/hyprlua/hypr/usr/binds.lua index c8ac0ff..896bdfb 100644 --- a/desktopenvs/hyprlua/hypr/usr/binds.lua +++ b/desktopenvs/hyprlua/hypr/usr/binds.lua @@ -336,7 +336,8 @@ hl.bind(mainMod .. " + CTRL + X", hl.dsp.exec_cmd("hyprctl hyprsunset identity") hl.bind(mainMod .. " + CTRL + I", hl.dsp.exec_cmd("chamel toggle")) hl.bind(mainMod .. " + CTRL + U", hl.dsp.exec_cmd("chamel clear")) hl.bind(mainMod .. " + CTRL + Z", hl.dsp.exec_cmd("chamel clear-and-deactivate")) -hl.bind(mainMod .. " + CTRL + C", hl.dsp.exec_cmd("dunstctl close-all")) +hl.bind(mainMod .. " + CTRL + C", hl.dsp.exec_cmd("pkill -USR1 -f '[m]notifd/main.py'")) +hl.bind(mainMod .. " + CTRL + N", hl.dsp.exec_cmd("~/.config/scripts/mnotifhist.sh")) hl.bind(mainMod .. " + CTRL + G", hl.dsp.exec_cmd("~/.config/scripts/onscreenkb.sh")) hl.bind(mainMod .. " + SHIFT + C", hl.dsp.exec_cmd("~/.config/scripts/caffeine.sh")) hl.bind(mainMod .. " + SHIFT + B", hl.dsp.exec_cmd("[tag +centered-S] kitty bash ~/.config/scripts/enroll-biometrics.sh")) diff --git a/desktopenvs/hyprlua/mnotifd-theme/_colors.css b/desktopenvs/hyprlua/mnotifd-theme/_colors.css new file mode 100644 index 0000000..e226c9e --- /dev/null +++ b/desktopenvs/hyprlua/mnotifd-theme/_colors.css @@ -0,0 +1,8 @@ +/* Generated from ~/Dotfiles/colors.conf by apply-theme.sh — do not hand-edit the + * hex values; edit colors.conf and re-run apply-theme.sh. (Kept in sync via the + * sed pipeline in USER_FILES; these defaults are the CyberQueer palette.) */ +@define-color text #D6ABAB; +@define-color bg #1A1A1A; +@define-color accent #E40046; +@define-color violet #5018DD; +@define-color danger #F50505; diff --git a/desktopenvs/hyprlua/mnotifd-theme/style.css b/desktopenvs/hyprlua/mnotifd-theme/style.css new file mode 100644 index 0000000..b50fc9b --- /dev/null +++ b/desktopenvs/hyprlua/mnotifd-theme/style.css @@ -0,0 +1,140 @@ +/* mnotifd — plain CyberQueer theme (hyprlua's standard look, no hologram/glow + * treatment). Hand-maintained here, not generated: regen-beacon.sh copies this + * file into desktopenvs/hyprlua/mnotifd/style/ as-is, replacing whatever + * style.css hyprdrive's beacon shipped. Mirrors astal-menu's own plainness + * relative to astro-menu: flat @violet/@bg fills instead of alpha glass, no + * box-shadow glow, no hover transitions, no @text colour override, no + * hardcoded "glow_violet" hex — just the shared palette variables. + * + * Targets the SAME class names the (renamed) Python source emits — beacon's + * `.beacon-*` classes become `.mnotifd-*` after regen-beacon.sh's rename + * pass, so this file has to speak that post-rename vocabulary already. */ + +* { + font-family: "Agave Nerd Font Mono", monospace; + font-size: 12pt; +} + +/* The CyberQueer GTK theme paints `* { background-color:#1a1a1a }` on every node, + * which would fill the surface and the gaps between cards with an opaque slab. + * Blank the structural nodes; the card asserts its own fill below. */ +window, +window.background, +.mnotifd-window, +.mnotifd-stack, +.mnotifd-content, +box, +overlay, +label, +image, +drawingarea { + background: transparent; + background-color: transparent; +} + +/* the card */ +.mnotifd-card { + background-color: @violet; + border: 2px solid @violet; + border-radius: 12px; + padding: 12px 14px; +} +.mnotifd-card.critical { + border-color: @accent; +} + +.mnotifd-summary { + color: @text; + font-weight: bold; + font-size: 12pt; +} +.mnotifd-card.critical .mnotifd-summary { color: @accent; } + +/* divider — kept minimal even when re-enabled locally (squiggle defaults off + * for this variant); the wave colour itself is Cairo-drawn in notification.py. */ +.mnotifd-squiggle { + margin: 2px 0; + background: none; + background-color: transparent; + border: none; + box-shadow: none; +} + +.mnotifd-body { + color: @text; + font-size: 11pt; + opacity: 0.95; +} + +.mnotifd-icon { margin-right: 2px; } + +/* action pills */ +.mnotifd-action { + color: @text; + background-color: @violet; + border: 2px solid @violet; + border-radius: 20px; + padding: 2px 12px; + min-height: 22px; +} +.mnotifd-action:hover { + border-color: @accent; + color: @accent; +} + +/* embedded controls (x-mnotifd-controls hint after renaming) */ +.mnotifd-control-row { background: transparent; } + +.mnotifd-control-label { + color: @text; + font-size: 11pt; + opacity: 0.95; +} + +.mnotifd-toggle { + background-color: @violet; + border: 2px solid @violet; + border-radius: 20px; + min-width: 40px; + min-height: 22px; +} +.mnotifd-toggle:checked { + background-color: @accent; + border-color: @accent; +} +.mnotifd-toggle slider { + background-color: @text; + border-radius: 50%; +} + +.mnotifd-slider trough { + background-color: @violet; + border: 2px solid @violet; + border-radius: 20px; + min-height: 8px; +} +.mnotifd-slider highlight { + background-color: @accent; + border-radius: 20px; +} +.mnotifd-slider slider { + background-color: @text; + border: 2px solid @violet; + border-radius: 50%; + min-width: 14px; + min-height: 14px; +} + +.mnotifd-entry { + color: @text; + background-color: @violet; + border: 2px solid @violet; + border-radius: 12px; + padding: 2px 10px; + min-height: 22px; +} +.mnotifd-entry:focus-within { + border-color: @accent; +} + +.mnotifd-hologram { background: transparent; } diff --git a/desktopenvs/hyprlua/mnotifd/config.py b/desktopenvs/hyprlua/mnotifd/config.py new file mode 100644 index 0000000..f2bbf74 --- /dev/null +++ b/desktopenvs/hyprlua/mnotifd/config.py @@ -0,0 +1,55 @@ +"""Tiny user-editable config file: ~/.local/state/mnotifd/config.json. + +Read once at startup (main.py, via server.py); a change takes effect on the +next mnotifd-start.sh restart, not live. Same pattern as orbit-menu/horizon- +dock/astro-menu/station-bar's config.py. +""" + +from __future__ import annotations + +import json + +from paths import CONFIG_FILE, ensure_dirs + +_DEFAULTS = { + "history_enabled": True, + "history_length": 100, + "history_persist": True, + "hologram": False, + "squiggle": False, +} + + +def _load() -> dict: + try: + data = json.loads(CONFIG_FILE.read_text()) + except (FileNotFoundError, json.JSONDecodeError): + data = {} + merged = {**_DEFAULTS, **data} + if not CONFIG_FILE.exists(): + ensure_dirs() + CONFIG_FILE.write_text(json.dumps(merged, indent=2) + "\n") + return merged + + +def history_enabled() -> bool: + return bool(_load().get("history_enabled", True)) + + +def history_length() -> int: + try: + return int(_load().get("history_length", 100)) + except (TypeError, ValueError): + return 100 + + +def history_persist() -> bool: + return bool(_load().get("history_persist", True)) + + +def hologram_enabled() -> bool: + return bool(_load().get("hologram", True)) + + +def squiggle_enabled() -> bool: + return bool(_load().get("squiggle", True)) diff --git a/desktopenvs/hyprlua/mnotifd/controls.py b/desktopenvs/hyprlua/mnotifd/controls.py new file mode 100644 index 0000000..dc01d4c --- /dev/null +++ b/desktopenvs/hyprlua/mnotifd/controls.py @@ -0,0 +1,81 @@ +"""Parsing/validation for the x-mnotifd-controls custom notification hint. + +Sending apps opt into rich, embedded controls (beyond the spec's plain +fire-and-dismiss `actions` array) via a hint whose value is a JSON string — +chosen over a nested D-Bus variant struct so any app, in any language, can +produce it without touching GVariant construction. Advertised in +GetCapabilities as "x-mnotifd-controls" so senders can detect support and +fall back to plain `actions` against any other freedesktop-compliant daemon. + +Shape: + [ + {"type": "button", "id": "reply", "label": "Reply"}, + {"type": "toggle", "id": "mute", "label": "Mute", "value": false}, + {"type": "slider", "id": "volume", "label": "Volume", "value": 40, "min": 0, "max": 100}, + {"type": "entry", "id": "msg", "label": "Message", "placeholder": "Type a reply..."} + ] + +Unknown "type"s and unrecognised extra keys are silently dropped rather than +erroring — forward-compatible with control types added later, and never lets +a malformed/hostile payload (any local session-bus process can call Notify) +crash the daemon. +""" + +from __future__ import annotations + +import json +from typing import Optional + +_TYPES = {"button", "toggle", "slider", "entry"} +# a button auto-dismisses like a classic action by default; the stateful +# controls default to staying on-screen so you can keep adjusting them. +_DEFAULT_DISMISS = {"button": True, "toggle": False, "slider": False, "entry": False} + + +def parse_controls(raw: Optional[str]) -> list[dict]: + if not raw: + return [] + try: + data = json.loads(raw) + except (json.JSONDecodeError, TypeError): + return [] + if not isinstance(data, list): + return [] + + out: list[dict] = [] + for item in data: + if not isinstance(item, dict): + continue + ctype = item.get("type") + cid = item.get("id") + if ctype not in _TYPES or not isinstance(cid, str) or not cid: + continue + label = item.get("label") + label = label if isinstance(label, str) else cid + dismiss = item.get("dismiss") + dismiss = bool(dismiss) if isinstance(dismiss, bool) else _DEFAULT_DISMISS[ctype] + + control = {"type": ctype, "id": cid, "label": label, "dismiss": dismiss} + + if ctype == "toggle": + control["value"] = bool(item.get("value", False)) + elif ctype == "slider": + control["value"] = _as_float(item.get("value"), 0.0) + control["min"] = _as_float(item.get("min"), 0.0) + control["max"] = _as_float(item.get("max"), 100.0) + control["step"] = _as_float(item.get("step"), 1.0) + if control["max"] <= control["min"]: + control["max"] = control["min"] + 1.0 + elif ctype == "entry": + placeholder = item.get("placeholder") + control["placeholder"] = placeholder if isinstance(placeholder, str) else "" + + out.append(control) + return out + + +def _as_float(value, default: float) -> float: + try: + return float(value) + except (TypeError, ValueError): + return default diff --git a/desktopenvs/hyprlua/mnotifd/dbus/org.freedesktop.Notifications.service b/desktopenvs/hyprlua/mnotifd/dbus/org.freedesktop.Notifications.service new file mode 100644 index 0000000..48b9caf --- /dev/null +++ b/desktopenvs/hyprlua/mnotifd/dbus/org.freedesktop.Notifications.service @@ -0,0 +1,12 @@ +[D-BUS Service] +# Route D-Bus activation of the freedesktop notification name to mnotifd instead +# of dunst. The dunst package ships /usr/share/dbus-1/services/org.knopwob.dunst.service +# which also claims Name=org.freedesktop.Notifications, so without this file D-Bus +# auto-activates dunst the moment any app posts a notification and dunst grabs the +# name before mnotifd can (mnotifd then exits on name-lost — see mnotifd/main.py). +# A service file in ~/.local/share/dbus-1/services/ takes precedence over /usr/share, +# so this wins the name resolution for org.freedesktop.Notifications. +# +# Deployed to ~/.local/share/dbus-1/services/ at install time (see updater.conf). +Name=org.freedesktop.Notifications +Exec=/home/themiro/Dotfiles/desktopenvs/hyprlua/scripts/mnotifd-start.sh diff --git a/desktopenvs/hyprlua/mnotifd/history.py b/desktopenvs/hyprlua/mnotifd/history.py new file mode 100644 index 0000000..ed4902c --- /dev/null +++ b/desktopenvs/hyprlua/mnotifd/history.py @@ -0,0 +1,109 @@ +"""Bounded, disk-persisted notification history. + +Pure data structure — no D-Bus/GLib knowledge, mirrors the window/server +separation used elsewhere in mnotifd (window.py knows nothing about D-Bus +either). server.py owns turning these entries into GLib.Variant dicts and +emitting HistoryAdded/HistoryRemoved/HistoryCleared. + +Entry schema (plain dict, JSON-safe, deliberately open so new keys can be +added later without breaking old readers): + id (int), app_name (str), summary (str), body (str), icon (str), + urgency (int), timestamp (float, unix seconds), reason (int), + actions (list[str], flat key/label pairs), controls (str | None — the raw + x-mnotifd-controls JSON hint, re-parsed via controls.parse_controls on Pop). + +image_data (raw pixel bytes) is deliberately never stored here — it would +bloat history.json and doesn't round-trip through JSON cleanly; a +popped-from-history card just falls back to its `icon` string. +""" + +from __future__ import annotations + +import json +import os +import tempfile +from typing import Optional + +from paths import CACHE_DIR, HISTORY_FILE, ensure_dirs + + +class HistoryStore: + def __init__(self, maxlen: int, persist: bool) -> None: + self._maxlen = max(1, maxlen) + self._persist = persist + self._entries: list[dict] = self._load() if persist else [] # newest first + + # -- mutation --------------------------------------------------------------- + def record(self, entry: dict) -> Optional[dict]: + """Insert newest-first; returns the evicted entry if capacity was hit.""" + self._entries.insert(0, entry) + evicted = None + while len(self._entries) > self._maxlen: + evicted = self._entries.pop() + self._save() + return evicted + + def pop(self, nid: int) -> Optional[dict]: + """Remove and return a specific entry (for History1.Pop).""" + for i, e in enumerate(self._entries): + if e["id"] == nid: + entry = self._entries.pop(i) + self._save() + return entry + return None + + def pop_latest(self) -> Optional[dict]: + """Remove and return the most recently recorded entry (LIFO, like + dunstctl history-pop).""" + if not self._entries: + return None + entry = self._entries.pop(0) + self._save() + return entry + + def remove(self, nid: int) -> bool: + for i, e in enumerate(self._entries): + if e["id"] == nid: + del self._entries[i] + self._save() + return True + return False + + def clear(self) -> list[int]: + ids = [e["id"] for e in self._entries] + self._entries = [] + self._save() + return ids + + # -- read --------------------------------------------------------------------- + def list_all(self) -> list[dict]: + return list(self._entries) + + def get(self, nid: int) -> Optional[dict]: + for e in self._entries: + if e["id"] == nid: + return e + return None + + # -- persistence ------------------------------------------------------------ + def _load(self) -> list[dict]: + try: + data = json.loads(HISTORY_FILE.read_text()) + except (FileNotFoundError, json.JSONDecodeError): + return [] + return data if isinstance(data, list) else [] + + def _save(self) -> None: + if not self._persist: + return + ensure_dirs() + fd, tmp = tempfile.mkstemp(dir=str(CACHE_DIR), prefix=".history-", suffix=".json") + try: + with os.fdopen(fd, "w") as f: + json.dump(self._entries, f) + os.replace(tmp, HISTORY_FILE) + except OSError: + try: + os.unlink(tmp) + except OSError: + pass diff --git a/desktopenvs/hyprlua/mnotifd/lib/hologram.py b/desktopenvs/hyprlua/mnotifd/lib/hologram.py new file mode 100644 index 0000000..ccbbf68 --- /dev/null +++ b/desktopenvs/hyprlua/mnotifd/lib/hologram.py @@ -0,0 +1,278 @@ +"""Holographic scanline/sweep/noise overlay — the same treatment and tuning as +the rest of the Cosmonaut Shell suite (astro-menu / station-bar / orbit-menu / +horizon-dock lib/hologram.py), reused here so notification cards read as one more +orbit of the same look: scanline grid + a slow vertical sweep + drifting noise +specks, and a 'materialise out of static' intro when a card first appears. + +Each notification card owns its own overlay (see notification.py); the stack +window (window.py) runs a single frame-clock tick and feeds every visible card's +overlay via .tick(dt). +""" + +from __future__ import annotations + +import math +import random + +import cairo +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import GLib, Gtk # noqa: E402 + +# Same CyberQueer violet/magenta/red combo as the rest of the suite's hologram. +_VIOLET = (0x50 / 255, 0x18 / 255, 0xDD / 255) +_MAGENTA = (0.92, 0.0, 0.65) +_ACCENT = (0xE4 / 255, 0x00 / 255, 0x46 / 255) + + +class HologramOverlay: + SCANLINE_GAP = 4.0 + SCANLINE_ALPHA = 0.18 # a touch stronger than the bar's — cards are the focus + SWEEP_PERIOD = 3.2 # seconds for one top-to-bottom pass + SWEEP_HALF_HEIGHT = 34.0 # card-sized panel, taller than the thin bar strip + NOISE_COUNT = 26 + NOISE_COLORS = [_MAGENTA, _MAGENTA, _ACCENT] # magenta-biased specks + NOISE_LIFETIME = (0.5, 1.4) + NOISE_FADE_IN = 0.2 + NOISE_FADE_OUT = 0.35 + NOISE_ALPHA_RANGE = (0.10, 0.34) + EDGE_FADE_X = 40.0 # smooth horizontal fade-out of the scanline field + EDGE_FADE_Y = 34.0 # smooth vertical fade-out + INTRO_DURATION = 0.9 # brisk 'materialise out of static' when a card pops in + INTRO_STATIC = 900 # static specks at the very start of the intro + OUTRO_DURATION = 0.2 # snappy reverse dissolve back into static on dismiss + + def __init__(self, enabled: bool = True, clip_func=None, fade_widget=None, + intro_duration: float | None = None) -> None: + self.enabled = enabled + self._clip_func = clip_func # optional path-setter to clip the holo to the card shape + # widget whose opacity is ramped 0->1 during the intro so the card content + # genuinely fades in, rather than a solid haze block popping on + self._fade_widget = fade_widget + if intro_duration is not None: + self.INTRO_DURATION = intro_duration + self._sat_time = 0.0 + self._particles: list[dict] = [] + self._intro_t: float | None = None + self._outro_t: float | None = None + self._outro_done = None + # Wall-clock safety net: the frame-clock tick only advances while the + # compositor sends frame callbacks; if those stall the intro could freeze + # with content stuck at opacity 0. This timeout force-resolves it anyway. + self._intro_deadline_id: int | None = None + self._mask_cache: tuple | None = None + + self.widget = Gtk.DrawingArea() + self.widget.set_can_target(False) # never steals clicks from the card underneath + self.widget.add_css_class("mnotifd-hologram") + self.widget.set_hexpand(True) + self.widget.set_vexpand(True) + self.widget.set_halign(Gtk.Align.FILL) + self.widget.set_valign(Gtk.Align.FILL) + self.widget.set_draw_func(self._draw_frame) + + def tick(self, dt: float) -> None: + if not self.enabled: + return + self._sat_time += dt + if self._intro_t is not None: + self._intro_t += dt + if self._intro_t >= self.INTRO_DURATION: + self._finish_intro() + elif self._fade_widget is not None: + p = self._intro_t / self.INTRO_DURATION + self._fade_widget.set_opacity(p * p * (3 - 2 * p)) + if self._outro_t is not None: + self._outro_t += dt + po = min(1.0, self._outro_t / self.OUTRO_DURATION) + if self._fade_widget is not None: + self._fade_widget.set_opacity(1.0 - po * po * (3 - 2 * po)) + if self._outro_t >= self.OUTRO_DURATION: + done = self._outro_done + self._outro_t = None + self._outro_done = None + if done is not None: + done() + self.widget.queue_draw() + + def _finish_intro(self) -> None: + self._intro_t = None + if self._intro_deadline_id is not None: + GLib.source_remove(self._intro_deadline_id) + self._intro_deadline_id = None + if self._fade_widget is not None: + self._fade_widget.set_opacity(1.0) + self.widget.queue_draw() + + def start_intro(self) -> None: + """Kick off the 'card materialising out of static' opening effect.""" + if self.enabled: + self._outro_t = None + self._outro_done = None + self._intro_t = 0.0 + if self._fade_widget is not None: + self._fade_widget.set_opacity(0.0) + if self._intro_deadline_id is not None: + GLib.source_remove(self._intro_deadline_id) + self._intro_deadline_id = GLib.timeout_add( + int(self.INTRO_DURATION * 1000) + 150, self._on_intro_deadline) + + def _on_intro_deadline(self) -> bool: + self._intro_deadline_id = None + if self._intro_t is not None: + self._finish_intro() + return False # one-shot + + def start_outro(self, on_done) -> None: + """Reverse of the intro (card dissolving back into static), then on_done.""" + if not self.enabled: + on_done() + return + self._intro_t = None + if self._intro_deadline_id is not None: + GLib.source_remove(self._intro_deadline_id) + self._intro_deadline_id = None + self._outro_t = 0.0 + self._outro_done = on_done + + # -- drawing -------------------------------------------------------------- + def _draw_frame(self, _area, cr, width: float, height: float) -> None: + if not self.enabled or width <= 0 or height <= 0: + return + if self._clip_func is not None: + cr.save() + self._clip_func(cr, width, height) # clip the scanlines to the card's rounded shape + cr.clip() + cr.push_group() + self._draw_content(cr, width, height) + cr.pop_group_to_source() + cr.mask(self._edge_fade_mask(width, height)) + if self._intro_t is not None: + self._draw_intro(cr, width, height) + elif self._outro_t is not None: + self._draw_outro(cr, width, height) + if self._clip_func is not None: + cr.restore() + + def _edge_fade_mask(self, width: float, height: float): + key = (int(width), int(height)) + if self._mask_cache is not None and self._mask_cache[0] == key: + return self._mask_cache[1] + w, h = max(1, key[0]), max(1, key[1]) + surf = cairo.ImageSurface(cairo.FORMAT_A8, w, h) + m = cairo.Context(surf) + m.set_source_rgba(0, 0, 0, 1) + m.paint() + m.set_operator(cairo.OPERATOR_DEST_OUT) + fx = min(self.EDGE_FADE_X, w / 2) + fy = min(self.EDGE_FADE_Y, h / 2) + + def band(x0, y0, x1, y1, rx, ry, rw, rh): + gr = cairo.LinearGradient(x0, y0, x1, y1) + gr.add_color_stop_rgba(0.0, 0, 0, 0, 1) + gr.add_color_stop_rgba(1.0, 0, 0, 0, 0) + m.set_source(gr) + m.rectangle(rx, ry, rw, rh) + m.fill() + band(0, 0, fx, 0, 0, 0, fx, h) # left + band(w, 0, w - fx, 0, w - fx, 0, fx, h) # right + band(0, 0, 0, fy, 0, 0, w, fy) # top + band(0, h, 0, h - fy, 0, h - fy, w, fy) # bottom + pattern = cairo.SurfacePattern(surf) + self._mask_cache = (key, pattern) + return pattern + + def _draw_intro(self, cr, width: float, height: float) -> None: + p = min(1.0, max(0.0, (self._intro_t or 0.0) / self.INTRO_DURATION)) + strength = 1.0 - p + colors = [_MAGENTA, _MAGENTA, _ACCENT, (0.85, 0.85, 0.95)] + count = int(self.INTRO_STATIC * (strength ** 0.5)) + for _ in range(count): + x = random.uniform(0, width) + y = random.uniform(0, height) + col = random.choice(colors) + cr.set_source_rgba(*col, random.uniform(0.25, 0.85) * (0.35 + 0.65 * strength)) + sz = random.uniform(1.0, 2.8) + cr.rectangle(x, y, sz, sz) + cr.fill() + band = p * height + cr.set_source_rgba(*_ACCENT, 0.5 * (1.0 - abs(2 * p - 1))) + cr.rectangle(0, band - 2.0, width, 4.0) + cr.fill() + + def _draw_outro(self, cr, width: float, height: float) -> None: + po = min(1.0, max(0.0, (self._outro_t or 0.0) / self.OUTRO_DURATION)) + colors = [_MAGENTA, _MAGENTA, _ACCENT, (0.85, 0.85, 0.95)] + count = int(self.INTRO_STATIC * (po ** 0.5)) + for _ in range(count): + x = random.uniform(0, width) + y = random.uniform(0, height) + col = random.choice(colors) + cr.set_source_rgba(*col, random.uniform(0.25, 0.85) * (0.35 + 0.65 * po)) + sz = random.uniform(1.0, 2.8) + cr.rectangle(x, y, sz, sz) + cr.fill() + band = (1.0 - po) * height + cr.set_source_rgba(*_ACCENT, 0.5 * (1.0 - abs(2 * po - 1))) + cr.rectangle(0, band - 2.0, width, 4.0) + cr.fill() + + def _draw_content(self, cr, width: float, height: float) -> None: + r, g, b = _VIOLET + + cr.save() + cr.set_source_rgba(r, g, b, self.SCANLINE_ALPHA) + cr.set_line_width(1.0) + y = 0.0 + while y < height: + cr.move_to(0, y) + cr.line_to(width, y) + y += self.SCANLINE_GAP + cr.stroke() + cr.restore() + + phase = (self._sat_time % self.SWEEP_PERIOD) / self.SWEEP_PERIOD + sweep_y = phase * height + hh = self.SWEEP_HALF_HEIGHT + grad = cairo.LinearGradient(0, sweep_y - hh, 0, sweep_y + hh) + grad.add_color_stop_rgba(0.0, r, g, b, 0.0) + grad.add_color_stop_rgba(0.5, r, g, b, 0.09) + grad.add_color_stop_rgba(1.0, r, g, b, 0.0) + cr.set_source(grad) + cr.rectangle(0, sweep_y - hh, width, hh * 2) + cr.fill() + + flicker = 0.012 + 0.007 * math.sin(self._sat_time * 11.0) + cr.set_source_rgba(r, g, b, max(0.0, flicker)) + cr.paint() + + self._draw_noise(cr, width, height) + + def _draw_noise(self, cr, width: float, height: float) -> None: + now = self._sat_time + self._particles = [p for p in self._particles if now - p["birth"] < p["life"]] + while len(self._particles) < self.NOISE_COUNT: + self._particles.append({ + "x": random.uniform(0, width), + "y": random.uniform(0, height), + "w": random.uniform(1.0, 2.6), + "h": random.uniform(1.0, 2.0), + "color": random.choice(self.NOISE_COLORS), + "peak_alpha": random.uniform(*self.NOISE_ALPHA_RANGE), + "birth": now, + "life": random.uniform(*self.NOISE_LIFETIME), + }) + + for p in self._particles: + t = (now - p["birth"]) / p["life"] + if t < self.NOISE_FADE_IN: + envelope = t / self.NOISE_FADE_IN + elif t > 1.0 - self.NOISE_FADE_OUT: + envelope = max(0.0, (1.0 - t) / self.NOISE_FADE_OUT) + else: + envelope = 1.0 + r, g, b = p["color"] + cr.set_source_rgba(r, g, b, p["peak_alpha"] * envelope) + cr.rectangle(p["x"], p["y"], p["w"], p["h"]) + cr.fill() diff --git a/desktopenvs/hyprlua/mnotifd/main.py b/desktopenvs/hyprlua/mnotifd/main.py new file mode 100644 index 0000000..6984280 --- /dev/null +++ b/desktopenvs/hyprlua/mnotifd/main.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""mnotifd — the Cosmonaut Shell notification daemon for hyprlua. + +Replaces dunst: owns org.freedesktop.Notifications and renders each notification +as a holographic card (scanline/sweep/noise overlay, emitted-magenta text, violet +holo-glass, radio-squiggle divider) in a top-centre layer-shell stack, so +notifications read as one more orbit of the astro-menu / orbit-menu / station-bar +look instead of a plain popup. + + main.py run the resident daemon (owns the notification bus name, stays hidden + until a notification arrives) +""" + +from __future__ import annotations + +import os +import signal +import sys +from pathlib import Path + +# mnotifd-start.sh LD_PRELOADs libgtk4-layer-shell (load-ordering requirement ahead +# of libwayland-client). Drop it once resident so it isn't inherited by anything +# this process launches (same rationale as the rest of the suite's main.py). +os.environ.pop("LD_PRELOAD", None) + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import gi # noqa: E402 + +gi.require_version("Gtk", "4.0") +from gi.repository import Gio, GLib, Gtk # noqa: E402 + +import theme # noqa: E402 +from paths import APP_ID, FDN_NAME # noqa: E402 +from server import NotificationServer # noqa: E402 +from window import MnotifdWindow # noqa: E402 + + +class MnotifdApp(Gtk.Application): + def __init__(self) -> None: + super().__init__(application_id=APP_ID, + flags=Gio.ApplicationFlags.DEFAULT_FLAGS) + self.window: MnotifdWindow | None = None + self._server: NotificationServer | None = None + self._name_id = 0 + + def do_startup(self) -> None: + Gtk.Application.do_startup(self) + theme.load_css() + self.window = MnotifdWindow(self, on_closed=self._on_closed) + # Own the freedesktop notification name; the server is created once the bus + # connection is in hand. + self._name_id = Gio.bus_own_name( + Gio.BusType.SESSION, FDN_NAME, Gio.BusNameOwnerFlags.NONE, + self._on_bus_acquired, None, self._on_name_lost) + # SIGUSR1 = close every visible card (the `Super+Ctrl+C` keybind, which + # used to run `dunstctl close-all`). + GLib.unix_signal_add(GLib.PRIORITY_DEFAULT, signal.SIGUSR1, + self._on_sigusr1) + self.hold() # stay alive with no visible window + + def _on_sigusr1(self) -> bool: + if self.window is not None: + self.window.close_all() + return GLib.SOURCE_CONTINUE + + def _on_bus_acquired(self, connection: Gio.DBusConnection, _name: str) -> None: + assert self.window is not None + self._server = NotificationServer(connection, self.window) + + def _on_closed(self, nid: int, reason: int) -> None: + if self._server is not None: + self._server.emit_closed(nid, reason) + + def _on_name_lost(self, _connection, _name: str) -> None: + # Another notification daemon (e.g. a still-running dunst) already owns it. + sys.stderr.write( + "mnotifd: could not acquire org.freedesktop.Notifications " + "(another notification daemon is running); exiting.\n") + self.quit() + + def do_activate(self) -> None: + pass # resident daemon: nothing to do on activate + + +def main() -> int: + GLib.set_prgname("mnotifd") + return MnotifdApp().run(sys.argv) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/desktopenvs/hyprlua/mnotifd/notification.py b/desktopenvs/hyprlua/mnotifd/notification.py new file mode 100644 index 0000000..5794221 --- /dev/null +++ b/desktopenvs/hyprlua/mnotifd/notification.py @@ -0,0 +1,304 @@ +"""A single holographic notification card. + +Layout: [app icon] [ summary (emitted-magenta, letter-spaced) / radio-squiggle +divider / body ] with an optional row of action-pill buttons, all under a +scanline/sweep/noise HologramOverlay (lib/hologram.py) clipped to the card's +rounded rectangle. The card materialises out of static (holo intro) when it +appears and dissolves back into static when dismissed. +""" + +from __future__ import annotations + +import math +from typing import Callable, Optional + +import cairo +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Gdk", "4.0") +gi.require_version("GdkPixbuf", "2.0") +from gi.repository import Gdk, GdkPixbuf, GLib, Gtk # noqa: E402 + +import config +from lib.hologram import HologramOverlay + +_CARD_RADIUS = 16.0 + +# Divider "radio wave" colour (Cairo, not CSS): a magenta wave line on a +# transparent background. Critical keeps an accent wave to match its frame. +_MAGENTA = (0xEB / 255, 0x00 / 255, 0xA6 / 255) # foreground wave (normal) +_ACCENT = (0xE4 / 255, 0x00 / 255, 0x46 / 255) # foreground wave (critical) +_SQUIGGLE_H = 14 # divider row height +_SQUIGGLE_AMP = 3.0 # wave amplitude (px) +_SQUIGGLE_CYCLES = 0.045 # cycles per px of width +_SQUIGGLE_SPEED = 2.4 # phase advance (rad/s) — a slowly travelling signal + + +def _rounded_path(cr, w: float, h: float, r: float = _CARD_RADIUS) -> None: + r = min(r, w / 2, h / 2) + cr.new_sub_path() + cr.arc(w - r, r, r, -math.pi / 2, 0) + cr.arc(w - r, h - r, r, 0, math.pi / 2) + cr.arc(r, h - r, r, math.pi / 2, math.pi) + cr.arc(r, r, r, math.pi, 3 * math.pi / 2) + cr.close_path() + + +class NotificationCard: + def __init__(self, nid: int, summary: str, body: str, urgency: int, + icon: str, image_data, actions: list[str], controls: list[dict], + on_action: Callable[[int, str], None], + on_control: Callable[[int, str, object], None], + on_dismiss: Callable[[int], None]) -> None: + self.nid = nid + self._on_action = on_action + self._on_control = on_control + self._on_dismiss = on_dismiss + self._dismissing = False + critical = urgency >= 2 + + # -- content ----------------------------------------------------------- + content = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) + content.add_css_class("mnotifd-content") + + img = self._build_icon(icon, image_data) + if img is not None: + content.append(img) + + textcol = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) + textcol.set_hexpand(True) + textcol.set_valign(Gtk.Align.CENTER) + + if summary: + lbl = Gtk.Label(label=summary, xalign=0.0) + lbl.add_css_class("mnotifd-summary") + lbl.set_wrap(True) + lbl.set_wrap_mode(2) # WORD_CHAR + lbl.set_max_width_chars(30) + textcol.append(lbl) + + self._squiggle: Optional[Gtk.DrawingArea] = None + if config.squiggle_enabled(): + self._squiggle_color = _ACCENT if critical else _MAGENTA + self._squiggle_phase = 0.0 + self._squiggle = Gtk.DrawingArea() + self._squiggle.add_css_class("mnotifd-squiggle") + self._squiggle.set_content_height(_SQUIGGLE_H) + self._squiggle.set_hexpand(True) + self._squiggle.set_draw_func(self._draw_squiggle) + textcol.append(self._squiggle) + + if body: + blbl = Gtk.Label(xalign=0.0) + blbl.add_css_class("mnotifd-body") + blbl.set_wrap(True) + blbl.set_wrap_mode(2) + blbl.set_max_width_chars(34) + # bodies may carry a small Pango-markup subset (///…); + # fall back to plain text if the sender's markup won't parse. + try: + blbl.set_markup(body) + except GLib.GError: + blbl.set_text(body) + textcol.append(blbl) + + content.append(textcol) + + if actions: + row = self._build_actions(actions) + if row is not None: + textcol.append(row) + + if controls: + crows = self._build_controls(controls) + for crow in crows: + textcol.append(crow) + + card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + card.add_css_class("mnotifd-card") + if critical: + card.add_css_class("critical") + card.append(content) + card.set_size_request(340, -1) + + # -- hologram overlay -------------------------------------------------- + self._holo = HologramOverlay(enabled=config.hologram_enabled(), + clip_func=_rounded_path, fade_widget=content) + + overlay = Gtk.Overlay() + overlay.set_child(card) + overlay.add_overlay(self._holo.widget) + overlay.set_measure_overlay(self._holo.widget, False) + + # left-click anywhere on the card = invoke default action if present, else + # dismiss; right-click always dismisses. + self._default_action = "default" if "default" in actions else None + left = Gtk.GestureClick(button=1) + left.connect("released", self._on_left_click) + overlay.add_controller(left) + right = Gtk.GestureClick(button=3) + right.connect("released", lambda *_a: self.dismiss()) + overlay.add_controller(right) + + self.widget = overlay + self._holo.start_intro() + + # -- public --------------------------------------------------------------- + def tick(self, dt: float) -> None: + self._holo.tick(dt) + if self._squiggle is not None: + self._squiggle_phase += dt * _SQUIGGLE_SPEED + self._squiggle.queue_draw() # travel the wave along like a live signal + + def _draw_squiggle(self, _area, cr, width: int, height: int) -> None: + if width <= 0: + return + # transparent background — just the magenta (accent for critical) radio wave + mid = height / 2.0 + amp = min(_SQUIGGLE_AMP, mid - 2.0) + k = _SQUIGGLE_CYCLES * 2.0 * math.pi # angular freq per px + steps = max(2, int(width)) + cr.set_line_cap(cairo.LINE_CAP_ROUND) + cr.set_line_join(cairo.LINE_JOIN_ROUND) + # two passes: a soft wide glow, then a bright thin core — reads as emitted + # light over the purple bar, matching the card's holographic frame. + for line_w, alpha in ((3.0, 0.30), (1.4, 1.0)): + cr.set_line_width(line_w) + cr.set_source_rgba(*self._squiggle_color, alpha) + for i in range(steps + 1): + x = width * i / steps + y = mid + amp * math.sin(x * k + self._squiggle_phase) + cr.line_to(x, y) if i else cr.move_to(x, y) + cr.stroke() + + def dismiss(self) -> None: + """Play the dissolve, then hand the id back to the window for removal.""" + if self._dismissing: + return + self._dismissing = True + self._holo.start_outro(lambda: self._on_dismiss(self.nid)) + + # -- internals ------------------------------------------------------------ + def _on_left_click(self, *_a) -> None: + if self._default_action is not None: + self._on_action(self.nid, self._default_action) + else: + self.dismiss() + + def _build_actions(self, actions: list[str]) -> Optional[Gtk.Box]: + # actions is a flat [key, label, key, label, …] list; "default" is the + # implicit click action and isn't shown as a button. + row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + row.add_css_class("mnotifd-actions") + row.set_margin_top(6) + shown = 0 + for i in range(0, len(actions) - 1, 2): + key, label = actions[i], actions[i + 1] + if key == "default": + continue + btn = Gtk.Button(label=label or key) + btn.add_css_class("mnotifd-action") + btn.connect("clicked", lambda _b, k=key: self._on_action(self.nid, k)) + row.append(btn) + shown += 1 + return row if shown else None + + def _build_controls(self, controls: list[dict]) -> list[Gtk.Widget]: + # button-type controls share one pill row (same idiom as plain actions); + # each stateful control (toggle/slider/entry) gets its own full-width row, + # since a slider/entry can't sensibly squeeze into a compact pill. + rows: list[Gtk.Widget] = [] + btn_row: Optional[Gtk.Box] = None + for c in controls: + if c["type"] == "button": + if btn_row is None: + btn_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + btn_row.add_css_class("mnotifd-actions") + btn_row.add_css_class("mnotifd-control-row") + btn_row.set_margin_top(6) + rows.append(btn_row) + btn = Gtk.Button(label=c["label"]) + btn.add_css_class("mnotifd-action") + btn.connect("clicked", lambda _b, c=c: self._fire_control(c, True)) + btn_row.append(btn) + continue + + row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + row.add_css_class("mnotifd-control-row") + row.set_margin_top(6) + lbl = Gtk.Label(label=c["label"], xalign=0.0) + lbl.add_css_class("mnotifd-control-label") + row.append(lbl) + + if c["type"] == "toggle": + sw = Gtk.Switch() + sw.add_css_class("mnotifd-toggle") + sw.set_active(bool(c["value"])) + sw.set_valign(Gtk.Align.CENTER) + sw.set_hexpand(True) + sw.set_halign(Gtk.Align.END) + sw.connect("state-set", lambda _s, state, c=c: self._on_toggle(c, state)) + row.append(sw) + elif c["type"] == "slider": + adj = Gtk.Adjustment(value=c["value"], lower=c["min"], upper=c["max"], + step_increment=c["step"]) + scale = Gtk.Scale(orientation=Gtk.Orientation.HORIZONTAL, adjustment=adj) + scale.add_css_class("mnotifd-slider") + scale.set_hexpand(True) + scale.set_draw_value(False) + scale.connect("value-changed", + lambda s, c=c: self._fire_control(c, s.get_value())) + row.append(scale) + elif c["type"] == "entry": + entry = Gtk.Entry() + entry.add_css_class("mnotifd-entry") + entry.set_hexpand(True) + if c.get("placeholder"): + entry.set_placeholder_text(c["placeholder"]) + entry.connect("activate", + lambda e, c=c: self._fire_control(c, e.get_text())) + row.append(entry) + + rows.append(row) + return rows + + def _on_toggle(self, control: dict, state: bool) -> bool: + self._fire_control(control, state) + return False # False = let Gtk.Switch apply the requested visual state itself + + def _fire_control(self, control: dict, value) -> None: + self._on_control(self.nid, control["id"], value) + if control.get("dismiss"): + self.dismiss() + + def _build_icon(self, icon: str, image_data) -> Optional[Gtk.Image]: + img: Optional[Gtk.Image] = None + if image_data is not None: + pb = self._pixbuf_from_hint(image_data) + if pb is not None: + img = Gtk.Image.new_from_paintable(Gdk.Texture.new_for_pixbuf(pb)) + if img is None and icon: + if icon.startswith("file://"): + icon = icon[len("file://"):] + if icon.startswith("/"): + img = Gtk.Image.new_from_file(icon) + else: + img = Gtk.Image.new_from_icon_name(icon) + if img is None: + return None + img.add_css_class("mnotifd-icon") + img.set_pixel_size(44) + img.set_valign(Gtk.Align.START) + return img + + @staticmethod + def _pixbuf_from_hint(data) -> Optional[GdkPixbuf.Pixbuf]: + # Spec "image-data": (width, height, rowstride, has_alpha, bits, channels, bytes) + try: + w, h, rowstride, has_alpha, bits, channels, raw = data + return GdkPixbuf.Pixbuf.new_from_bytes( + GLib.Bytes.new(bytes(raw)), GdkPixbuf.Colorspace.RGB, + has_alpha, bits, w, h, rowstride) + except (ValueError, TypeError): + return None diff --git a/desktopenvs/hyprlua/mnotifd/paths.py b/desktopenvs/hyprlua/mnotifd/paths.py new file mode 100644 index 0000000..061f387 --- /dev/null +++ b/desktopenvs/hyprlua/mnotifd/paths.py @@ -0,0 +1,41 @@ +"""Shared filesystem locations. Works whether run from the repo or ~/.config.""" + +from __future__ import annotations + +import os +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parent +STYLE_DIR = BASE_DIR / "style" + +# GApplication single-instance id (our own process). NOT the freedesktop +# notification name — that well-known name (org.freedesktop.Notifications) is +# owned separately in main.py, the same one dunst used to hold. +APP_ID = "eu.abdelbaki.mnotifd" + +FDN_NAME = "org.freedesktop.Notifications" +FDN_PATH = "/org/freedesktop/Notifications" +FDN_IFACE = "org.freedesktop.Notifications" + +# Custom interfaces colocated at FDN_PATH, same well-known bus name — mirrors +# dunst parking org.dunstproject.cmd0 alongside its own freedesktop interface +# rather than using a bespoke object path. +HISTORY_IFACE = "eu.abdelbaki.mnotifd.History1" +CONTROLS_IFACE = "eu.abdelbaki.mnotifd.Controls1" + +# Notification history persists under XDG_CACHE_HOME, like the layouts +# registry's ~/.cache/astro-menu/layouts.json — NOT ~/.config/mnotifd, which +# config-updater wipes and re-copies on every dotfiles sync. +CACHE_DIR = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "mnotifd" +HISTORY_FILE = CACHE_DIR / "history.json" + +# User settings live under XDG_STATE_HOME, same rationale/convention as +# station-bar/paths.py (config-updater's rm -rf would wipe a hand-edited +# ~/.config/mnotifd/config.json otherwise). +STATE_DIR = Path(os.environ.get("XDG_STATE_HOME", Path.home() / ".local" / "state")) / "mnotifd" +CONFIG_FILE = STATE_DIR / "config.json" + + +def ensure_dirs() -> None: + CACHE_DIR.mkdir(parents=True, exist_ok=True) + STATE_DIR.mkdir(parents=True, exist_ok=True) diff --git a/desktopenvs/hyprlua/mnotifd/server.py b/desktopenvs/hyprlua/mnotifd/server.py new file mode 100644 index 0000000..1b69add --- /dev/null +++ b/desktopenvs/hyprlua/mnotifd/server.py @@ -0,0 +1,370 @@ +"""org.freedesktop.Notifications D-Bus service — the well-known name dunst used +to own. Parses each Notify call and hands it to the stack window (window.py) to +render as a holographic card; owns per-notification expiry timers and emits the +spec's NotificationClosed / ActionInvoked signals. + +Also owns two custom interfaces colocated at the same object path (mirrors +dunst parking org.dunstproject.cmd0 alongside its own freedesktop interface): + + eu.abdelbaki.mnotifd.History1 disk-persisted notification history — + List/Get/Pop/PopLatest/Remove/Clear/ + InvokeAction + HistoryAdded/Removed/Cleared + signals. See history.py for the storage side. + + eu.abdelbaki.mnotifd.Controls1 push-only: ControlChanged(id, control_id, + value) for the rich embedded controls apps + can opt into via the x-mnotifd-controls hint + (see controls.py). + +Deliberately small: the visible/interactive spec subset (body markup, actions, +icons, urgency, replaces_id, timeouts, persistence) — no sound/markup-hint +gymnastics. +""" + +from __future__ import annotations + +import time + +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import Gio, GLib # noqa: E402 + +import config +from controls import parse_controls +from history import HistoryStore +from paths import CONTROLS_IFACE, FDN_IFACE, FDN_PATH, HISTORY_IFACE + +# reasons per the spec: 1 expired, 2 dismissed by user, 3 closed by call, 4 undefined +_INTROSPECTION_XML = """ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +""" + +# a{sv} per entry (not a fixed tuple shape) so new fields can be added later +# without breaking existing clients — the same shape dunst's own +# NotificationListHistory uses. +_HISTORY_INTROSPECTION_XML = """ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +""" + +_CONTROLS_INTROSPECTION_XML = """ + + + + + + + + + +""" + +# server default timeouts (ms) by urgency when the client passes -1 +_DEFAULT_TIMEOUT = {0: 5000, 1: 8000, 2: 0} # low / normal / critical(never) + + +class NotificationServer: + def __init__(self, connection: Gio.DBusConnection, window) -> None: + self._conn = connection + self._window = window + self._next_id = 1 + self._timers: dict[int, int] = {} + # metadata for still-visible notifications, keyed by id — snapshotted at + # Notify() time (before render) so emit_closed() can hand a full record + # to history without window.py/notification.py needing to know about it. + self._live_meta: dict[int, dict] = {} + + self._history_enabled = config.history_enabled() + self._history = HistoryStore(maxlen=config.history_length(), + persist=config.history_persist()) + + node = Gio.DBusNodeInfo.new_for_xml(_INTROSPECTION_XML) + self._iface = node.interfaces[0] + connection.register_object( + FDN_PATH, self._iface, self._on_method_call, None, None) + + hnode = Gio.DBusNodeInfo.new_for_xml(_HISTORY_INTROSPECTION_XML) + connection.register_object( + FDN_PATH, hnode.interfaces[0], self._on_history_method_call, None, None) + + cnode = Gio.DBusNodeInfo.new_for_xml(_CONTROLS_INTROSPECTION_XML) + connection.register_object( + FDN_PATH, cnode.interfaces[0], self._on_controls_method_call, None, None) + + # -- D-Bus dispatch: org.freedesktop.Notifications ------------------------- + def _on_method_call(self, _conn, _sender, _path, _iface, method, params, invocation): + if method == "Notify": + invocation.return_value(GLib.Variant("(u)", (self._notify(params),))) + elif method == "CloseNotification": + (nid,) = params.unpack() + self._window.close_notification(int(nid), reason=3) + self._cancel_timer(int(nid)) + invocation.return_value(None) + elif method == "GetCapabilities": + invocation.return_value(GLib.Variant("(as)", ( + ["body", "body-markup", "icon-static", "actions", "persistence", + "x-mnotifd-controls"],))) + elif method == "GetServerInformation": + invocation.return_value(GLib.Variant("(ssss)", ( + "mnotifd", "abdelbaki.eu", "1.0", "1.2"))) + else: + invocation.return_error_literal( + Gio.dbus_error_quark(), Gio.DBusError.UNKNOWN_METHOD, method) + + def _notify(self, params) -> int: + (app_name, replaces_id, app_icon, summary, body, + actions, hints, expire_timeout) = params.unpack() + + nid = int(replaces_id) if replaces_id else self._next_id + if not replaces_id: + self._next_id += 1 + + urgency = int(hints.get("urgency", 1)) + image_data = (hints.get("image-data") or hints.get("image_data") + or hints.get("icon_data")) + image_path = hints.get("image-path") or hints.get("image_path") + icon = image_path or app_icon or "" + controls_raw = hints.get("x-mnotifd-controls") + controls = parse_controls(controls_raw) + + self._live_meta[nid] = { + "id": nid, "app_name": app_name or "", "summary": summary, + "body": body, "urgency": urgency, "icon": icon, + "actions": list(actions), "controls": controls_raw, + "timestamp": time.time(), + } + + self._window.show_notification( + nid, summary, body, urgency, icon, image_data, list(actions), + controls, self._emit_action, self._emit_control) + + self._arm_timer(nid, int(expire_timeout), urgency) + return nid + + # -- expiry timers -------------------------------------------------------- + def _arm_timer(self, nid: int, expire_timeout: int, urgency: int) -> None: + self._cancel_timer(nid) + if expire_timeout < 0: + ms = _DEFAULT_TIMEOUT.get(urgency, 8000) + else: + ms = expire_timeout + if ms <= 0: + return # 0 = never expire (or critical default) + self._timers[nid] = GLib.timeout_add(ms, self._on_expire, nid) + + def _on_expire(self, nid: int) -> bool: + self._timers.pop(nid, None) + self._window.close_notification(nid, reason=1) # 1 = expired + return False + + def _cancel_timer(self, nid: int) -> None: + tid = self._timers.pop(nid, None) + if tid is not None: + GLib.source_remove(tid) + + # -- signals (also the window's on_closed / on_action / on_control callbacks) + def emit_closed(self, nid: int, reason: int) -> None: + self._cancel_timer(nid) + self._conn.emit_signal(None, FDN_PATH, FDN_IFACE, "NotificationClosed", + GLib.Variant("(uu)", (nid, reason))) + meta = self._live_meta.pop(nid, None) + if meta is not None and self._history_enabled: + entry = {**meta, "reason": reason} + evicted = self._history.record(entry) + self._emit_history_signal("HistoryAdded", GLib.Variant( + "(a{sv})", (_entry_dict(entry),))) + if evicted is not None: + self._emit_history_signal("HistoryRemoved", GLib.Variant( + "(u)", (int(evicted["id"]),))) + + def _emit_action(self, nid: int, key: str) -> None: + self._conn.emit_signal(None, FDN_PATH, FDN_IFACE, "ActionInvoked", + GLib.Variant("(us)", (nid, key))) + # dismiss the card once its action fired, matching typical daemon behaviour + self._window.close_notification(nid, reason=2) + + def _emit_control(self, nid: int, control_id: str, value) -> None: + self._conn.emit_signal(None, FDN_PATH, CONTROLS_IFACE, "ControlChanged", + GLib.Variant("(usv)", (nid, control_id, _variant_for(value)))) + + def _emit_history_signal(self, name: str, payload: GLib.Variant) -> None: + self._conn.emit_signal(None, FDN_PATH, HISTORY_IFACE, name, payload) + + # -- D-Bus dispatch: eu.abdelbaki.mnotifd.History1 -------------------------- + def _on_history_method_call(self, _conn, _sender, _path, _iface, method, params, invocation): + if method == "List": + entries = [_entry_dict(e) for e in self._history.list_all()] + invocation.return_value(GLib.Variant("(aa{sv})", (entries,))) + elif method == "Get": + (nid,) = params.unpack() + entry = self._history.get(int(nid)) + if entry is None: + invocation.return_error_literal( + Gio.dbus_error_quark(), Gio.DBusError.FAILED, + f"no history entry with id {nid}") + else: + invocation.return_value(GLib.Variant("(a{sv})", (_entry_dict(entry),))) + elif method == "Pop": + (nid,) = params.unpack() + entry = self._history.pop(int(nid)) + if entry is None: + invocation.return_error_literal( + Gio.dbus_error_quark(), Gio.DBusError.FAILED, + f"no history entry with id {nid}") + else: + self._emit_history_signal("HistoryRemoved", GLib.Variant("(u)", (int(nid),))) + invocation.return_value(GLib.Variant("(u)", (self._redisplay(entry),))) + elif method == "PopLatest": + entry = self._history.pop_latest() + if entry is None: + invocation.return_error_literal( + Gio.dbus_error_quark(), Gio.DBusError.FAILED, "history is empty") + else: + self._emit_history_signal( + "HistoryRemoved", GLib.Variant("(u)", (int(entry["id"]),))) + invocation.return_value(GLib.Variant("(u)", (self._redisplay(entry),))) + elif method == "Remove": + (nid,) = params.unpack() + if self._history.remove(int(nid)): + self._emit_history_signal("HistoryRemoved", GLib.Variant("(u)", (int(nid),))) + invocation.return_value(None) + elif method == "Clear": + removed = self._history.clear() + self._emit_history_signal("HistoryCleared", GLib.Variant("(u)", (len(removed),))) + invocation.return_value(None) + elif method == "InvokeAction": + (nid, key) = params.unpack() + entry = self._history.get(int(nid)) + if entry is None: + invocation.return_error_literal( + Gio.dbus_error_quark(), Gio.DBusError.FAILED, + f"no history entry with id {nid}") + else: + # Re-emit ActionInvoked for a *historical* entry without bringing the + # card back on-screen — mnotifd (unlike dunst) doesn't void actions once + # a notification leaves the queue, since invoking one has always just + # meant "emit the signal," independent of whether a card widget exists. + self._conn.emit_signal(None, FDN_PATH, FDN_IFACE, "ActionInvoked", + GLib.Variant("(us)", (int(nid), key))) + invocation.return_value(None) + else: + invocation.return_error_literal( + Gio.dbus_error_quark(), Gio.DBusError.UNKNOWN_METHOD, method) + + def _redisplay(self, entry: dict) -> int: + """Re-render a history entry as a live card (History1.Pop/PopLatest).""" + new_nid = self._next_id + self._next_id += 1 + controls = parse_controls(entry.get("controls")) + self._window.show_notification( + new_nid, entry.get("summary", ""), entry.get("body", ""), + int(entry.get("urgency", 1)), entry.get("icon", ""), None, + list(entry.get("actions") or []), controls, + self._emit_action, self._emit_control) + self._live_meta[new_nid] = { + "id": new_nid, "app_name": entry.get("app_name", ""), + "summary": entry.get("summary", ""), "body": entry.get("body", ""), + "urgency": int(entry.get("urgency", 1)), "icon": entry.get("icon", ""), + "actions": list(entry.get("actions") or []), + "controls": entry.get("controls"), "timestamp": time.time(), + } + self._arm_timer(new_nid, -1, int(entry.get("urgency", 1))) + return new_nid + + # -- D-Bus dispatch: eu.abdelbaki.mnotifd.Controls1 (signal-only, no methods) + def _on_controls_method_call(self, _conn, _sender, _path, _iface, method, _params, invocation): + invocation.return_error_literal( + Gio.dbus_error_quark(), Gio.DBusError.UNKNOWN_METHOD, method) + + +def _entry_dict(entry: dict) -> dict: + """A plain dict with GLib.Variant leaf values, ready to embed in an a{sv}.""" + return { + "id": GLib.Variant("u", int(entry["id"])), + "app_name": GLib.Variant("s", entry.get("app_name") or ""), + "summary": GLib.Variant("s", entry.get("summary") or ""), + "body": GLib.Variant("s", entry.get("body") or ""), + "icon": GLib.Variant("s", entry.get("icon") or ""), + "urgency": GLib.Variant("i", int(entry.get("urgency", 1))), + "timestamp": GLib.Variant("d", float(entry.get("timestamp", 0.0))), + "reason": GLib.Variant("i", int(entry.get("reason", 0))), + "actions": GLib.Variant("as", list(entry.get("actions") or [])), + "controls": GLib.Variant("s", entry.get("controls") or ""), + } + + +def _variant_for(value) -> GLib.Variant: + if isinstance(value, bool): + return GLib.Variant("b", value) + if isinstance(value, (int, float)): + return GLib.Variant("d", float(value)) + return GLib.Variant("s", str(value)) diff --git a/desktopenvs/hyprlua/mnotifd/style/_colors.css b/desktopenvs/hyprlua/mnotifd/style/_colors.css new file mode 100644 index 0000000..e226c9e --- /dev/null +++ b/desktopenvs/hyprlua/mnotifd/style/_colors.css @@ -0,0 +1,8 @@ +/* Generated from ~/Dotfiles/colors.conf by apply-theme.sh — do not hand-edit the + * hex values; edit colors.conf and re-run apply-theme.sh. (Kept in sync via the + * sed pipeline in USER_FILES; these defaults are the CyberQueer palette.) */ +@define-color text #D6ABAB; +@define-color bg #1A1A1A; +@define-color accent #E40046; +@define-color violet #5018DD; +@define-color danger #F50505; diff --git a/desktopenvs/hyprlua/mnotifd/style/style.css b/desktopenvs/hyprlua/mnotifd/style/style.css new file mode 100644 index 0000000..78d2264 --- /dev/null +++ b/desktopenvs/hyprlua/mnotifd/style/style.css @@ -0,0 +1,140 @@ +/* mnotifd — plain CyberQueer theme (hyprlua's standard look, no hologram/glow + * treatment). Hand-maintained here, not generated: regen-mnotifd.sh copies this + * file into desktopenvs/hyprlua/mnotifd/style/ as-is, replacing whatever + * style.css hyprlua's mnotifd shipped. Mirrors astal-menu's own plainness + * relative to astro-menu: flat @violet/@bg fills instead of alpha glass, no + * box-shadow glow, no hover transitions, no @text colour override, no + * hardcoded "glow_violet" hex — just the shared palette variables. + * + * Targets the SAME class names the (renamed) Python source emits — mnotifd's + * `.mnotifd-*` classes become `.mnotifd-*` after regen-mnotifd.sh's rename + * pass, so this file has to speak that post-rename vocabulary already. */ + +* { + font-family: "Agave Nerd Font Mono", monospace; + font-size: 12pt; +} + +/* The CyberQueer GTK theme paints `* { background-color:#1a1a1a }` on every node, + * which would fill the surface and the gaps between cards with an opaque slab. + * Blank the structural nodes; the card asserts its own fill below. */ +window, +window.background, +.mnotifd-window, +.mnotifd-stack, +.mnotifd-content, +box, +overlay, +label, +image, +drawingarea { + background: transparent; + background-color: transparent; +} + +/* the card */ +.mnotifd-card { + background-color: @violet; + border: 2px solid @violet; + border-radius: 12px; + padding: 12px 14px; +} +.mnotifd-card.critical { + border-color: @accent; +} + +.mnotifd-summary { + color: @text; + font-weight: bold; + font-size: 12pt; +} +.mnotifd-card.critical .mnotifd-summary { color: @accent; } + +/* divider — kept minimal even when re-enabled locally (squiggle defaults off + * for this variant); the wave colour itself is Cairo-drawn in notification.py. */ +.mnotifd-squiggle { + margin: 2px 0; + background: none; + background-color: transparent; + border: none; + box-shadow: none; +} + +.mnotifd-body { + color: @text; + font-size: 11pt; + opacity: 0.95; +} + +.mnotifd-icon { margin-right: 2px; } + +/* action pills */ +.mnotifd-action { + color: @text; + background-color: @violet; + border: 2px solid @violet; + border-radius: 20px; + padding: 2px 12px; + min-height: 22px; +} +.mnotifd-action:hover { + border-color: @accent; + color: @accent; +} + +/* embedded controls (x-mnotifd-controls hint after renaming) */ +.mnotifd-control-row { background: transparent; } + +.mnotifd-control-label { + color: @text; + font-size: 11pt; + opacity: 0.95; +} + +.mnotifd-toggle { + background-color: @violet; + border: 2px solid @violet; + border-radius: 20px; + min-width: 40px; + min-height: 22px; +} +.mnotifd-toggle:checked { + background-color: @accent; + border-color: @accent; +} +.mnotifd-toggle slider { + background-color: @text; + border-radius: 50%; +} + +.mnotifd-slider trough { + background-color: @violet; + border: 2px solid @violet; + border-radius: 20px; + min-height: 8px; +} +.mnotifd-slider highlight { + background-color: @accent; + border-radius: 20px; +} +.mnotifd-slider slider { + background-color: @text; + border: 2px solid @violet; + border-radius: 50%; + min-width: 14px; + min-height: 14px; +} + +.mnotifd-entry { + color: @text; + background-color: @violet; + border: 2px solid @violet; + border-radius: 12px; + padding: 2px 10px; + min-height: 22px; +} +.mnotifd-entry:focus-within { + border-color: @accent; +} + +.mnotifd-hologram { background: transparent; } diff --git a/desktopenvs/hyprlua/mnotifd/theme.py b/desktopenvs/hyprlua/mnotifd/theme.py new file mode 100644 index 0000000..82dae80 --- /dev/null +++ b/desktopenvs/hyprlua/mnotifd/theme.py @@ -0,0 +1,30 @@ +"""Load the two stylesheets as ordered CSS providers — same scheme as the rest +of the Cosmonaut Shell suite (orbit-menu, horizon-dock, astro-menu, station-bar). +_colors.css defines the CyberQueer @define-color names; style.css consumes them. + +Priority is USER+1 for the same reason as the others: the CyberQueer GTK theme +at ~/.config/gtk-4.0/gtk.css loads at PRIORITY_USER (800), above APPLICATION +(600), and would beat our transparent structural containers otherwise. +""" + +from __future__ import annotations + +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import Gdk, Gtk # noqa: E402 + +from paths import STYLE_DIR + + +def load_css() -> None: + display = Gdk.Display.get_default() + for name in ("_colors.css", "style.css"): + path = STYLE_DIR / name + if not path.exists(): + continue + provider = Gtk.CssProvider() + provider.load_from_path(str(path)) + Gtk.StyleContext.add_provider_for_display( + display, provider, Gtk.STYLE_PROVIDER_PRIORITY_USER + 1 + ) diff --git a/desktopenvs/hyprlua/mnotifd/window.py b/desktopenvs/hyprlua/mnotifd/window.py new file mode 100644 index 0000000..686d596 --- /dev/null +++ b/desktopenvs/hyprlua/mnotifd/window.py @@ -0,0 +1,138 @@ +"""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 MnotifdWindow(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("mnotifd-window") + + self._stack = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) + self._stack.add_css_class("mnotifd-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, "mnotifd") + 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], + controls: list[dict], + on_action: Callable[[int, str], None], + on_control: Callable[[int, str, object], 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, controls, on_action, on_control, + 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 + + def close_all(self, reason: int = 2) -> None: + """Dismiss every visible card (the old `dunstctl close-all` keybind). + reason 2 = dismissed by user. Each card plays its dissolve; removal and + the NotificationClosed signals follow as the outros finish.""" + for nid in list(self._order): + self.close_notification(nid, reason=reason) + + # -- 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 diff --git a/desktopenvs/hyprlua/mnotifhist-theme/_colors.css b/desktopenvs/hyprlua/mnotifhist-theme/_colors.css new file mode 100644 index 0000000..e226c9e --- /dev/null +++ b/desktopenvs/hyprlua/mnotifhist-theme/_colors.css @@ -0,0 +1,8 @@ +/* Generated from ~/Dotfiles/colors.conf by apply-theme.sh — do not hand-edit the + * hex values; edit colors.conf and re-run apply-theme.sh. (Kept in sync via the + * sed pipeline in USER_FILES; these defaults are the CyberQueer palette.) */ +@define-color text #D6ABAB; +@define-color bg #1A1A1A; +@define-color accent #E40046; +@define-color violet #5018DD; +@define-color danger #F50505; diff --git a/desktopenvs/hyprlua/mnotifhist-theme/style.css b/desktopenvs/hyprlua/mnotifhist-theme/style.css new file mode 100644 index 0000000..d25523f --- /dev/null +++ b/desktopenvs/hyprlua/mnotifhist-theme/style.css @@ -0,0 +1,121 @@ +/* mnotifhist — plain CyberQueer theme (hyprlua's standard look, no hologram/ + * glow treatment). Hand-maintained here, not generated: regen-beacon.sh + * copies this file into desktopenvs/hyprlua/mnotifhist/style/ as-is, + * replacing whatever style.css hyprdrive's transmitter-panel shipped. + * Mirrors astal-menu's own plainness relative to astro-menu: flat + * @violet/@bg fills instead of alpha glass, no box-shadow glow, no hover + * transitions, no @text colour override, no hardcoded "glow_violet" hex. + * + * Targets the SAME `.tx-*` class names as the original — that prefix isn't + * touched by regen-beacon.sh's rename pass (it doesn't contain "beacon" or + * "transmitter-panel" as a substring), so nothing here needs renaming. */ + +* { + font-family: "Agave Nerd Font Mono", monospace; + font-size: 12pt; +} + +window, +window.background, +.tx-window, +.tx-list, +box, +overlay, +label, +image, +drawingarea { + background: transparent; + background-color: transparent; +} + +.tx-panel { + background-color: @violet; + border: 2px solid @violet; + border-radius: 12px; + padding: 14px 16px; +} + +/* right margin keeps "Clear All" clear of the overlaid panel-level ✕ button + * (min-width 34px + its own 20px right margin, see .close-btn below). */ +.tx-header { margin: 0 64px 8px 0; } +.tx-title { + color: @text; + font-weight: bold; + font-size: 13pt; +} + +.tx-clear-all { + color: @text; + background-color: @violet; + border: 2px solid @violet; + border-radius: 20px; + padding: 4px 14px; + min-height: 26px; +} +.tx-clear-all:hover { + border-color: @accent; + color: @accent; +} + +.tx-empty { + color: @text; + opacity: 0.6; + padding: 18px 4px; +} + +.tx-row { + padding: 8px 10px; + border-radius: 10px; + min-height: 34px; +} +.tx-row:hover { background: alpha(@violet, 0.3); } +.tx-row-icon { margin-right: 2px; } +.tx-row-app { + color: @text; + opacity: 0.65; + font-size: 9pt; +} +.tx-row-summary { + color: @text; + font-weight: bold; + font-size: 11.5pt; +} +.tx-row-body { + color: @text; + font-size: 10.5pt; + opacity: 0.9; +} + +.tx-row-close { + color: @text; + background: @violet; + border: none; + border-radius: 14px; + min-width: 24px; + min-height: 24px; +} +.tx-row-close:hover { + background: @accent; + color: @bg; +} + +/* floating panel-level close button (top-right, closes the whole popup) — + * same idiom astal-menu uses for its own menu-window close button. */ +.close-btn { + color: @text; + background: @violet; + border: none; + border-radius: 20px; + min-width: 34px; + min-height: 34px; + margin: 16px 20px; +} +.close-btn:hover { + background: @accent; + color: @bg; +} + +scrollbar slider { background: @violet; border-radius: 8px; min-width: 6px; } +scrollbar slider:hover { background: @accent; } + +.tx-hologram { background: transparent; } diff --git a/desktopenvs/hyprlua/mnotifhist/.gitignore b/desktopenvs/hyprlua/mnotifhist/.gitignore new file mode 100644 index 0000000..7a60b85 --- /dev/null +++ b/desktopenvs/hyprlua/mnotifhist/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.pyc diff --git a/desktopenvs/hyprlua/mnotifhist/config.py b/desktopenvs/hyprlua/mnotifhist/config.py new file mode 100644 index 0000000..49f9383 --- /dev/null +++ b/desktopenvs/hyprlua/mnotifhist/config.py @@ -0,0 +1,30 @@ +"""Tiny user-editable config file: ~/.local/state/mnotifhist/config.json. + +Read once at startup (main.py); a change takes effect on the next +mnotifhist-start.sh restart, not live. Same pattern as orbit-menu/ +horizon-dock/astro-menu/station-bar/mnotifd's config.py. +""" + +from __future__ import annotations + +import json + +from paths import CONFIG_FILE, ensure_dirs + +_DEFAULTS = {"hologram": False} + + +def _load() -> dict: + try: + data = json.loads(CONFIG_FILE.read_text()) + except (FileNotFoundError, json.JSONDecodeError): + data = {} + merged = {**_DEFAULTS, **data} + if not CONFIG_FILE.exists(): + ensure_dirs() + CONFIG_FILE.write_text(json.dumps(merged, indent=2) + "\n") + return merged + + +def hologram_enabled() -> bool: + return bool(_load().get("hologram", True)) diff --git a/desktopenvs/hyprlua/mnotifhist/history_client.py b/desktopenvs/hyprlua/mnotifhist/history_client.py new file mode 100644 index 0000000..1c46241 --- /dev/null +++ b/desktopenvs/hyprlua/mnotifhist/history_client.py @@ -0,0 +1,79 @@ +"""Async client for mnotifd's eu.abdelbaki.mnotifd.History1 D-Bus interface. + +Runs on the GTK main loop: every call is async (Gio.DBusProxy.call, never +call_sync) so a slow or hung mnotifd never freezes the panel. If mnotifd isn't +running yet (or drops off the bus), calls fail — callers get an empty list +or a silent no-op rather than a crash, the same defensive stance mnotifd's +own controls.py/history.py took. +""" + +from __future__ import annotations + +from typing import Callable, Optional + +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import Gio, GLib # noqa: E402 + +# Same well-known name/path/interface mnotifd's own paths.py defines — +# duplicated here as plain strings rather than a shared import, matching how +# screenrec.sh independently hardcodes them too (each deployed component is +# its own ~/.config/ tree with no shared Python path). +FDN_NAME = "org.freedesktop.Notifications" +FDN_PATH = "/org/freedesktop/Notifications" +HISTORY_IFACE = "eu.abdelbaki.mnotifd.History1" + + +class HistoryClient: + def __init__(self, + on_added: Optional[Callable[[dict], None]] = None, + on_removed: Optional[Callable[[int], None]] = None, + on_cleared: Optional[Callable[[], None]] = None) -> None: + self._on_added = on_added + self._on_removed = on_removed + self._on_cleared = on_cleared + # Constructing a proxy doesn't require the peer to be present yet — + # only individual calls fail while mnotifd isn't up. + self._proxy = Gio.DBusProxy.new_for_bus_sync( + Gio.BusType.SESSION, Gio.DBusProxyFlags.NONE, None, + FDN_NAME, FDN_PATH, HISTORY_IFACE, None) + self._proxy.get_connection().signal_subscribe( + FDN_NAME, HISTORY_IFACE, None, FDN_PATH, None, + Gio.DBusSignalFlags.NONE, self._on_signal) + + def _on_signal(self, _conn, _sender, _path, _iface, signal, params) -> None: + if signal == "HistoryAdded" and self._on_added is not None: + (entry,) = params.unpack() + self._on_added(entry) + elif signal == "HistoryRemoved" and self._on_removed is not None: + (nid,) = params.unpack() + self._on_removed(int(nid)) + elif signal == "HistoryCleared" and self._on_cleared is not None: + self._on_cleared() + + # -- calls -------------------------------------------------------------- + def list_async(self, callback: Callable[[list[dict]], None]) -> None: + def done(proxy, result, _data=None) -> None: + try: + (entries,) = proxy.call_finish(result).unpack() + except GLib.GError: + entries = [] + callback(entries) + self._proxy.call("List", None, Gio.DBusCallFlags.NONE, -1, None, done, None) + + def remove_async(self, nid: int) -> None: + self._proxy.call( + "Remove", GLib.Variant("(u)", (nid,)), Gio.DBusCallFlags.NONE, -1, None, + self._ignore_result, None) + + def clear_async(self) -> None: + self._proxy.call( + "Clear", None, Gio.DBusCallFlags.NONE, -1, None, self._ignore_result, None) + + @staticmethod + def _ignore_result(proxy, result, _data=None) -> None: + try: + proxy.call_finish(result) + except GLib.GError: + pass # mnotifd not running / already gone — nothing to do diff --git a/desktopenvs/hyprlua/mnotifhist/lib/hologram.py b/desktopenvs/hyprlua/mnotifhist/lib/hologram.py new file mode 100644 index 0000000..8ab04e2 --- /dev/null +++ b/desktopenvs/hyprlua/mnotifhist/lib/hologram.py @@ -0,0 +1,279 @@ +"""Holographic scanline/sweep/noise overlay — the same treatment and tuning as +the rest of the Cosmonaut Shell suite (astro-menu / station-bar / orbit-menu / +horizon-dock / mnotifd lib/hologram.py), reused here so the mnotifhist +popup reads as one more orbit of the same look: scanline grid + a slow +vertical sweep + drifting noise specks, and a 'materialise out of static' +intro when the panel opens. + +One overlay covers the whole panel (same usage as astro-menu's window.py, +`fade_widget=self.root`), not a per-row overlay — window.py runs a single +frame-clock tick and feeds it via .tick(dt). +""" + +from __future__ import annotations + +import math +import random + +import cairo +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import GLib, Gtk # noqa: E402 + +# Same CyberQueer violet/magenta/red combo as the rest of the suite's hologram. +_VIOLET = (0x50 / 255, 0x18 / 255, 0xDD / 255) +_MAGENTA = (0.92, 0.0, 0.65) +_ACCENT = (0xE4 / 255, 0x00 / 255, 0x46 / 255) + + +class HologramOverlay: + SCANLINE_GAP = 4.0 + SCANLINE_ALPHA = 0.18 + SWEEP_PERIOD = 3.2 # seconds for one top-to-bottom pass + SWEEP_HALF_HEIGHT = 34.0 + NOISE_COUNT = 26 + NOISE_COLORS = [_MAGENTA, _MAGENTA, _ACCENT] # magenta-biased specks + NOISE_LIFETIME = (0.5, 1.4) + NOISE_FADE_IN = 0.2 + NOISE_FADE_OUT = 0.35 + NOISE_ALPHA_RANGE = (0.10, 0.34) + EDGE_FADE_X = 40.0 # smooth horizontal fade-out of the scanline field + EDGE_FADE_Y = 34.0 # smooth vertical fade-out + INTRO_DURATION = 0.9 # brisk 'materialise out of static' when the panel opens + INTRO_STATIC = 900 # static specks at the very start of the intro + OUTRO_DURATION = 0.2 # snappy reverse dissolve back into static on close + + def __init__(self, enabled: bool = True, clip_func=None, fade_widget=None, + intro_duration: float | None = None) -> None: + self.enabled = enabled + self._clip_func = clip_func # optional path-setter to clip the holo to a shape + # widget whose opacity is ramped 0->1 during the intro so the content + # genuinely fades in, rather than a solid haze block popping on + self._fade_widget = fade_widget + if intro_duration is not None: + self.INTRO_DURATION = intro_duration + self._sat_time = 0.0 + self._particles: list[dict] = [] + self._intro_t: float | None = None + self._outro_t: float | None = None + self._outro_done = None + # Wall-clock safety net: the frame-clock tick only advances while the + # compositor sends frame callbacks; if those stall the intro could freeze + # with content stuck at opacity 0. This timeout force-resolves it anyway. + self._intro_deadline_id: int | None = None + self._mask_cache: tuple | None = None + + self.widget = Gtk.DrawingArea() + self.widget.set_can_target(False) # never steals clicks from the panel underneath + self.widget.add_css_class("tx-hologram") + self.widget.set_hexpand(True) + self.widget.set_vexpand(True) + self.widget.set_halign(Gtk.Align.FILL) + self.widget.set_valign(Gtk.Align.FILL) + self.widget.set_draw_func(self._draw_frame) + + def tick(self, dt: float) -> None: + if not self.enabled: + return + self._sat_time += dt + if self._intro_t is not None: + self._intro_t += dt + if self._intro_t >= self.INTRO_DURATION: + self._finish_intro() + elif self._fade_widget is not None: + p = self._intro_t / self.INTRO_DURATION + self._fade_widget.set_opacity(p * p * (3 - 2 * p)) + if self._outro_t is not None: + self._outro_t += dt + po = min(1.0, self._outro_t / self.OUTRO_DURATION) + if self._fade_widget is not None: + self._fade_widget.set_opacity(1.0 - po * po * (3 - 2 * po)) + if self._outro_t >= self.OUTRO_DURATION: + done = self._outro_done + self._outro_t = None + self._outro_done = None + if done is not None: + done() + self.widget.queue_draw() + + def _finish_intro(self) -> None: + self._intro_t = None + if self._intro_deadline_id is not None: + GLib.source_remove(self._intro_deadline_id) + self._intro_deadline_id = None + if self._fade_widget is not None: + self._fade_widget.set_opacity(1.0) + self.widget.queue_draw() + + def start_intro(self) -> None: + """Kick off the 'materialising out of static' opening effect.""" + if self.enabled: + self._outro_t = None + self._outro_done = None + self._intro_t = 0.0 + if self._fade_widget is not None: + self._fade_widget.set_opacity(0.0) + if self._intro_deadline_id is not None: + GLib.source_remove(self._intro_deadline_id) + self._intro_deadline_id = GLib.timeout_add( + int(self.INTRO_DURATION * 1000) + 150, self._on_intro_deadline) + + def _on_intro_deadline(self) -> bool: + self._intro_deadline_id = None + if self._intro_t is not None: + self._finish_intro() + return False # one-shot + + def start_outro(self, on_done) -> None: + """Reverse of the intro (dissolving back into static), then on_done.""" + if not self.enabled: + on_done() + return + self._intro_t = None + if self._intro_deadline_id is not None: + GLib.source_remove(self._intro_deadline_id) + self._intro_deadline_id = None + self._outro_t = 0.0 + self._outro_done = on_done + + # -- drawing -------------------------------------------------------------- + def _draw_frame(self, _area, cr, width: float, height: float) -> None: + if not self.enabled or width <= 0 or height <= 0: + return + if self._clip_func is not None: + cr.save() + self._clip_func(cr, width, height) + cr.clip() + cr.push_group() + self._draw_content(cr, width, height) + cr.pop_group_to_source() + cr.mask(self._edge_fade_mask(width, height)) + if self._intro_t is not None: + self._draw_intro(cr, width, height) + elif self._outro_t is not None: + self._draw_outro(cr, width, height) + if self._clip_func is not None: + cr.restore() + + def _edge_fade_mask(self, width: float, height: float): + key = (int(width), int(height)) + if self._mask_cache is not None and self._mask_cache[0] == key: + return self._mask_cache[1] + w, h = max(1, key[0]), max(1, key[1]) + surf = cairo.ImageSurface(cairo.FORMAT_A8, w, h) + m = cairo.Context(surf) + m.set_source_rgba(0, 0, 0, 1) + m.paint() + m.set_operator(cairo.OPERATOR_DEST_OUT) + fx = min(self.EDGE_FADE_X, w / 2) + fy = min(self.EDGE_FADE_Y, h / 2) + + def band(x0, y0, x1, y1, rx, ry, rw, rh): + gr = cairo.LinearGradient(x0, y0, x1, y1) + gr.add_color_stop_rgba(0.0, 0, 0, 0, 1) + gr.add_color_stop_rgba(1.0, 0, 0, 0, 0) + m.set_source(gr) + m.rectangle(rx, ry, rw, rh) + m.fill() + band(0, 0, fx, 0, 0, 0, fx, h) # left + band(w, 0, w - fx, 0, w - fx, 0, fx, h) # right + band(0, 0, 0, fy, 0, 0, w, fy) # top + band(0, h, 0, h - fy, 0, h - fy, w, fy) # bottom + pattern = cairo.SurfacePattern(surf) + self._mask_cache = (key, pattern) + return pattern + + def _draw_intro(self, cr, width: float, height: float) -> None: + p = min(1.0, max(0.0, (self._intro_t or 0.0) / self.INTRO_DURATION)) + strength = 1.0 - p + colors = [_MAGENTA, _MAGENTA, _ACCENT, (0.85, 0.85, 0.95)] + count = int(self.INTRO_STATIC * (strength ** 0.5)) + for _ in range(count): + x = random.uniform(0, width) + y = random.uniform(0, height) + col = random.choice(colors) + cr.set_source_rgba(*col, random.uniform(0.25, 0.85) * (0.35 + 0.65 * strength)) + sz = random.uniform(1.0, 2.8) + cr.rectangle(x, y, sz, sz) + cr.fill() + band = p * height + cr.set_source_rgba(*_ACCENT, 0.5 * (1.0 - abs(2 * p - 1))) + cr.rectangle(0, band - 2.0, width, 4.0) + cr.fill() + + def _draw_outro(self, cr, width: float, height: float) -> None: + po = min(1.0, max(0.0, (self._outro_t or 0.0) / self.OUTRO_DURATION)) + colors = [_MAGENTA, _MAGENTA, _ACCENT, (0.85, 0.85, 0.95)] + count = int(self.INTRO_STATIC * (po ** 0.5)) + for _ in range(count): + x = random.uniform(0, width) + y = random.uniform(0, height) + col = random.choice(colors) + cr.set_source_rgba(*col, random.uniform(0.25, 0.85) * (0.35 + 0.65 * po)) + sz = random.uniform(1.0, 2.8) + cr.rectangle(x, y, sz, sz) + cr.fill() + band = (1.0 - po) * height + cr.set_source_rgba(*_ACCENT, 0.5 * (1.0 - abs(2 * po - 1))) + cr.rectangle(0, band - 2.0, width, 4.0) + cr.fill() + + def _draw_content(self, cr, width: float, height: float) -> None: + r, g, b = _VIOLET + + cr.save() + cr.set_source_rgba(r, g, b, self.SCANLINE_ALPHA) + cr.set_line_width(1.0) + y = 0.0 + while y < height: + cr.move_to(0, y) + cr.line_to(width, y) + y += self.SCANLINE_GAP + cr.stroke() + cr.restore() + + phase = (self._sat_time % self.SWEEP_PERIOD) / self.SWEEP_PERIOD + sweep_y = phase * height + hh = self.SWEEP_HALF_HEIGHT + grad = cairo.LinearGradient(0, sweep_y - hh, 0, sweep_y + hh) + grad.add_color_stop_rgba(0.0, r, g, b, 0.0) + grad.add_color_stop_rgba(0.5, r, g, b, 0.09) + grad.add_color_stop_rgba(1.0, r, g, b, 0.0) + cr.set_source(grad) + cr.rectangle(0, sweep_y - hh, width, hh * 2) + cr.fill() + + flicker = 0.012 + 0.007 * math.sin(self._sat_time * 11.0) + cr.set_source_rgba(r, g, b, max(0.0, flicker)) + cr.paint() + + self._draw_noise(cr, width, height) + + def _draw_noise(self, cr, width: float, height: float) -> None: + now = self._sat_time + self._particles = [p for p in self._particles if now - p["birth"] < p["life"]] + while len(self._particles) < self.NOISE_COUNT: + self._particles.append({ + "x": random.uniform(0, width), + "y": random.uniform(0, height), + "w": random.uniform(1.0, 2.6), + "h": random.uniform(1.0, 2.0), + "color": random.choice(self.NOISE_COLORS), + "peak_alpha": random.uniform(*self.NOISE_ALPHA_RANGE), + "birth": now, + "life": random.uniform(*self.NOISE_LIFETIME), + }) + + for p in self._particles: + t = (now - p["birth"]) / p["life"] + if t < self.NOISE_FADE_IN: + envelope = t / self.NOISE_FADE_IN + elif t > 1.0 - self.NOISE_FADE_OUT: + envelope = max(0.0, (1.0 - t) / self.NOISE_FADE_OUT) + else: + envelope = 1.0 + r, g, b = p["color"] + cr.set_source_rgba(r, g, b, p["peak_alpha"] * envelope) + cr.rectangle(p["x"], p["y"], p["w"], p["h"]) + cr.fill() diff --git a/desktopenvs/hyprlua/mnotifhist/main.py b/desktopenvs/hyprlua/mnotifhist/main.py new file mode 100644 index 0000000..96d6277 --- /dev/null +++ b/desktopenvs/hyprlua/mnotifhist/main.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""mnotifhist — mnotifd's notification-history viewer for hyprlua. + +Single-instance, same pattern as horizon-dock/astro-menu's main.py: the first +launch builds the (hidden) window and holds; later invocations forward their +verb over D-Bus via scripts/mnotifhist.sh instead of spawning a second +python3+GTK4 process. + + main.py run the resident instance (stays hidden until toggled) + main.py --show show + main.py --hide hide + main.py --toggle whichever of the above applies +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +# mnotifhist-start.sh LD_PRELOADs libgtk4-layer-shell (load-ordering +# requirement ahead of libwayland-client). Drop it once resident so it isn't +# inherited by anything this process launches — same rationale as the rest of +# the suite's main.py. +os.environ.pop("LD_PRELOAD", None) + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import gi # noqa: E402 + +gi.require_version("Gtk", "4.0") +from gi.repository import Gio, GLib, Gtk # noqa: E402 + +import theme # noqa: E402 +from paths import APP_ID # noqa: E402 +from window import MnotifhistWindow # noqa: E402 + + +class MnotifhistApp(Gtk.Application): + def __init__(self) -> None: + super().__init__(application_id=APP_ID, + flags=Gio.ApplicationFlags.HANDLES_COMMAND_LINE) + self.window: MnotifhistWindow | None = None + + def do_startup(self) -> None: + Gtk.Application.do_startup(self) + theme.load_css() + self.window = MnotifhistWindow(self) + self._register_actions() + self.hold() # stay alive with no visible window + + def _register_actions(self) -> None: + def add(name: str, callback) -> None: + action = Gio.SimpleAction.new(name, None) + action.connect("activate", callback) + self.add_action(action) + + def guarded(fn): + def wrapper(*_a) -> None: + assert self.window is not None + fn(self.window) + return wrapper + + add("show", guarded(lambda w: w.show_panel())) + add("hide", guarded(lambda w: w.hide_panel())) + add("toggle", guarded(lambda w: w.toggle())) + + def do_command_line(self, cmdline: Gio.ApplicationCommandLine) -> int: + args = list(cmdline.get_arguments()[1:]) + verb = args[0] if args else "--daemon" + if self.window is None: + return 0 + if verb == "--show": + self.window.show_panel() + elif verb == "--hide": + self.window.hide_panel() + elif verb == "--toggle": + self.window.toggle() + # --daemon and anything else: no-op (stay resident, hidden) + return 0 + + def do_activate(self) -> None: + pass # resident instance: nothing to do on plain activate + + +def main() -> int: + GLib.set_prgname("mnotifhist") + return MnotifhistApp().run(sys.argv) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/desktopenvs/hyprlua/mnotifhist/paths.py b/desktopenvs/hyprlua/mnotifhist/paths.py new file mode 100644 index 0000000..6b91501 --- /dev/null +++ b/desktopenvs/hyprlua/mnotifhist/paths.py @@ -0,0 +1,22 @@ +"""Shared filesystem locations. Works whether run from the repo or ~/.config.""" + +from __future__ import annotations + +import os +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parent +STYLE_DIR = BASE_DIR / "style" + +# User settings live under XDG_STATE_HOME, NOT ~/.config — config-updater does +# `rm -rf ~/.config/mnotifhist` on every dotfiles sync (see mnotifd/ +# station-bar/astro-menu's own paths.py for the same rationale), which would +# wipe a hand-edited config on the spot. +STATE_DIR = Path(os.environ.get("XDG_STATE_HOME", Path.home() / ".local" / "state")) / "mnotifhist" +CONFIG_FILE = STATE_DIR / "config.json" + +APP_ID = "eu.abdelbaki.mnotifhist" + + +def ensure_dirs() -> None: + STATE_DIR.mkdir(parents=True, exist_ok=True) diff --git a/desktopenvs/hyprlua/mnotifhist/style/_colors.css b/desktopenvs/hyprlua/mnotifhist/style/_colors.css new file mode 100644 index 0000000..e226c9e --- /dev/null +++ b/desktopenvs/hyprlua/mnotifhist/style/_colors.css @@ -0,0 +1,8 @@ +/* Generated from ~/Dotfiles/colors.conf by apply-theme.sh — do not hand-edit the + * hex values; edit colors.conf and re-run apply-theme.sh. (Kept in sync via the + * sed pipeline in USER_FILES; these defaults are the CyberQueer palette.) */ +@define-color text #D6ABAB; +@define-color bg #1A1A1A; +@define-color accent #E40046; +@define-color violet #5018DD; +@define-color danger #F50505; diff --git a/desktopenvs/hyprlua/mnotifhist/style/style.css b/desktopenvs/hyprlua/mnotifhist/style/style.css new file mode 100644 index 0000000..1655d53 --- /dev/null +++ b/desktopenvs/hyprlua/mnotifhist/style/style.css @@ -0,0 +1,121 @@ +/* mnotifhist — plain CyberQueer theme (hyprlua's standard look, no hologram/ + * glow treatment). Hand-maintained here, not generated: regen-mnotifd.sh + * copies this file into desktopenvs/hyprlua/mnotifhist/style/ as-is, + * replacing whatever style.css hyprlua's mnotifhist shipped. + * Mirrors astal-menu's own plainness relative to astro-menu: flat + * @violet/@bg fills instead of alpha glass, no box-shadow glow, no hover + * transitions, no @text colour override, no hardcoded "glow_violet" hex. + * + * Targets the SAME `.tx-*` class names as the original — that prefix isn't + * touched by regen-mnotifd.sh's rename pass (it doesn't contain "mnotifd" or + * "mnotifhist" as a substring), so nothing here needs renaming. */ + +* { + font-family: "Agave Nerd Font Mono", monospace; + font-size: 12pt; +} + +window, +window.background, +.tx-window, +.tx-list, +box, +overlay, +label, +image, +drawingarea { + background: transparent; + background-color: transparent; +} + +.tx-panel { + background-color: @violet; + border: 2px solid @violet; + border-radius: 12px; + padding: 14px 16px; +} + +/* right margin keeps "Clear All" clear of the overlaid panel-level ✕ button + * (min-width 34px + its own 20px right margin, see .close-btn below). */ +.tx-header { margin: 0 64px 8px 0; } +.tx-title { + color: @text; + font-weight: bold; + font-size: 13pt; +} + +.tx-clear-all { + color: @text; + background-color: @violet; + border: 2px solid @violet; + border-radius: 20px; + padding: 4px 14px; + min-height: 26px; +} +.tx-clear-all:hover { + border-color: @accent; + color: @accent; +} + +.tx-empty { + color: @text; + opacity: 0.6; + padding: 18px 4px; +} + +.tx-row { + padding: 8px 10px; + border-radius: 10px; + min-height: 34px; +} +.tx-row:hover { background: alpha(@violet, 0.3); } +.tx-row-icon { margin-right: 2px; } +.tx-row-app { + color: @text; + opacity: 0.65; + font-size: 9pt; +} +.tx-row-summary { + color: @text; + font-weight: bold; + font-size: 11.5pt; +} +.tx-row-body { + color: @text; + font-size: 10.5pt; + opacity: 0.9; +} + +.tx-row-close { + color: @text; + background: @violet; + border: none; + border-radius: 14px; + min-width: 24px; + min-height: 24px; +} +.tx-row-close:hover { + background: @accent; + color: @bg; +} + +/* floating panel-level close button (top-right, closes the whole popup) — + * same idiom astal-menu uses for its own menu-window close button. */ +.close-btn { + color: @text; + background: @violet; + border: none; + border-radius: 20px; + min-width: 34px; + min-height: 34px; + margin: 16px 20px; +} +.close-btn:hover { + background: @accent; + color: @bg; +} + +scrollbar slider { background: @violet; border-radius: 8px; min-width: 6px; } +scrollbar slider:hover { background: @accent; } + +.tx-hologram { background: transparent; } diff --git a/desktopenvs/hyprlua/mnotifhist/theme.py b/desktopenvs/hyprlua/mnotifhist/theme.py new file mode 100644 index 0000000..280086f --- /dev/null +++ b/desktopenvs/hyprlua/mnotifhist/theme.py @@ -0,0 +1,31 @@ +"""Load the two stylesheets as ordered CSS providers — same scheme as the rest +of the Cosmonaut Shell suite (orbit-menu, horizon-dock, astro-menu, station-bar, +mnotifd). _colors.css defines the CyberQueer @define-color names; style.css +consumes them. + +Priority is USER+1 for the same reason as the others: the CyberQueer GTK theme +at ~/.config/gtk-4.0/gtk.css loads at PRIORITY_USER (800), above APPLICATION +(600), and would beat our transparent structural containers otherwise. +""" + +from __future__ import annotations + +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import Gdk, Gtk # noqa: E402 + +from paths import STYLE_DIR + + +def load_css() -> None: + display = Gdk.Display.get_default() + for name in ("_colors.css", "style.css"): + path = STYLE_DIR / name + if not path.exists(): + continue + provider = Gtk.CssProvider() + provider.load_from_path(str(path)) + Gtk.StyleContext.add_provider_for_display( + display, provider, Gtk.STYLE_PROVIDER_PRIORITY_USER + 1 + ) diff --git a/desktopenvs/hyprlua/mnotifhist/window.py b/desktopenvs/hyprlua/mnotifhist/window.py new file mode 100644 index 0000000..88457e8 --- /dev/null +++ b/desktopenvs/hyprlua/mnotifhist/window.py @@ -0,0 +1,266 @@ +"""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 diff --git a/desktopenvs/hyprlua/scripts/mnotifd-start.sh b/desktopenvs/hyprlua/scripts/mnotifd-start.sh new file mode 100755 index 0000000..45d83c1 --- /dev/null +++ b/desktopenvs/hyprlua/scripts/mnotifd-start.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Resident launcher for mnotifd, the Cosmonaut Shell notification daemon (replaces +# dunst). Same LD_PRELOAD requirement and rationale as the rest of the suite's +# start scripts: gtk4-layer-shell must load before libwayland-client, which isn't +# guaranteed under PyGObject. + +APP="${HOME}/.config/mnotifd/main.py" +SO="$(ldconfig -p 2>/dev/null | awk '/libgtk4-layer-shell\.so/ {print $NF; exit}')" +if [[ -n "${SO:-}" ]]; then + export LD_PRELOAD="${SO}${LD_PRELOAD:+:${LD_PRELOAD}}" +fi + +exec python3 "$APP" "$@" diff --git a/desktopenvs/hyprlua/scripts/mnotifdctl b/desktopenvs/hyprlua/scripts/mnotifdctl new file mode 100755 index 0000000..e558b62 --- /dev/null +++ b/desktopenvs/hyprlua/scripts/mnotifdctl @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +"""mnotifdctl — a small CLI for mnotifd's D-Bus surface, the "dunstctl" mnotifd +never had (screenrec.sh has its own note on this gap). Talks to: + + org.freedesktop.Notifications (CloseNotification) + eu.abdelbaki.mnotifd.History1 (List/Get/Pop/PopLatest/Remove/Clear) + +`close-all` is the one exception: it isn't a D-Bus call (mnotifd doesn't +expose window.close_all() over the bus), it's the same SIGUSR1 signal the +Super+Ctrl+C keybind already sends. +""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import sys + +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import Gio, GLib # noqa: E402 + +FDN_NAME = "org.freedesktop.Notifications" +FDN_PATH = "/org/freedesktop/Notifications" +FDN_IFACE = "org.freedesktop.Notifications" +HISTORY_IFACE = "eu.abdelbaki.mnotifd.History1" + + +def _proxy(iface: str) -> Gio.DBusProxy: + return Gio.DBusProxy.new_for_bus_sync( + Gio.BusType.SESSION, Gio.DBusProxyFlags.NONE, None, + FDN_NAME, FDN_PATH, iface, None) + + +def cmd_list(args: argparse.Namespace) -> int: + proxy = _proxy(HISTORY_IFACE) + (entries,) = proxy.call_sync( + "List", None, Gio.DBusCallFlags.NONE, -1, None).unpack() + if args.json: + print(json.dumps(entries, indent=2)) + return 0 + if not entries: + print("(history is empty)") + return 0 + for e in entries: + print(f"{e['id']:>4} {(e.get('app_name') or '?'):<20} {e.get('summary', '')}") + return 0 + + +def cmd_get(args: argparse.Namespace) -> int: + proxy = _proxy(HISTORY_IFACE) + (entry,) = proxy.call_sync( + "Get", GLib.Variant("(u)", (args.id,)), Gio.DBusCallFlags.NONE, -1, None).unpack() + print(json.dumps(entry, indent=2)) + return 0 + + +def cmd_pop(args: argparse.Namespace) -> int: + proxy = _proxy(HISTORY_IFACE) + if args.id is None: + (new_id,) = proxy.call_sync( + "PopLatest", None, Gio.DBusCallFlags.NONE, -1, None).unpack() + else: + (new_id,) = proxy.call_sync( + "Pop", GLib.Variant("(u)", (args.id,)), Gio.DBusCallFlags.NONE, -1, None).unpack() + print(new_id) + return 0 + + +def cmd_remove(args: argparse.Namespace) -> int: + proxy = _proxy(HISTORY_IFACE) + proxy.call_sync( + "Remove", GLib.Variant("(u)", (args.id,)), Gio.DBusCallFlags.NONE, -1, None) + return 0 + + +def cmd_clear(_args: argparse.Namespace) -> int: + proxy = _proxy(HISTORY_IFACE) + proxy.call_sync("Clear", None, Gio.DBusCallFlags.NONE, -1, None) + return 0 + + +def cmd_close(args: argparse.Namespace) -> int: + proxy = _proxy(FDN_IFACE) + proxy.call_sync( + "CloseNotification", GLib.Variant("(u)", (args.id,)), + Gio.DBusCallFlags.NONE, -1, None) + return 0 + + +def cmd_close_all(_args: argparse.Namespace) -> int: + # bracket trick so pkill's own argv doesn't self-match, same as the + # Super+Ctrl+C keybind in hypr/usr/binds.lua + subprocess.run(["pkill", "-USR1", "-f", "[b]eacon/main.py"]) + return 0 + + +def main() -> int: + p = argparse.ArgumentParser(prog="mnotifdctl", description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + sub = p.add_subparsers(dest="command", required=True) + + sp = sub.add_parser("list", help="list notification history, newest first") + sp.add_argument("--json", action="store_true") + sp.set_defaults(func=cmd_list) + + sp = sub.add_parser("get", help="show one history entry as JSON") + sp.add_argument("id", type=int) + sp.set_defaults(func=cmd_get) + + sp = sub.add_parser( + "pop", help="re-display a history entry (most recent if id omitted)") + sp.add_argument("id", type=int, nargs="?", default=None) + sp.set_defaults(func=cmd_pop) + + sp = sub.add_parser("remove", help="remove one entry from history") + sp.add_argument("id", type=int) + sp.set_defaults(func=cmd_remove) + + sp = sub.add_parser("clear", help="clear all history") + sp.set_defaults(func=cmd_clear) + + sp = sub.add_parser("close", help="dismiss a live notification by id") + sp.add_argument("id", type=int) + sp.set_defaults(func=cmd_close) + + sp = sub.add_parser("close-all", help="dismiss every visible notification") + sp.set_defaults(func=cmd_close_all) + + args = p.parse_args() + try: + return args.func(args) + except GLib.GError as e: + print(f"mnotifdctl: {e.message}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/desktopenvs/hyprlua/scripts/mnotifhist-start.sh b/desktopenvs/hyprlua/scripts/mnotifhist-start.sh new file mode 100755 index 0000000..de852b2 --- /dev/null +++ b/desktopenvs/hyprlua/scripts/mnotifhist-start.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Resident launcher for mnotifhist, mnotifd's notification-history +# viewer. Same LD_PRELOAD requirement and rationale as mnotifd-start.sh/ +# horizon-dock-start.sh: gtk4-layer-shell must load before libwayland-client, +# which isn't guaranteed under PyGObject. + +APP="${HOME}/.config/mnotifhist/main.py" +SO="$(ldconfig -p 2>/dev/null | awk '/libgtk4-layer-shell\.so/ {print $NF; exit}')" +if [[ -n "${SO:-}" ]]; then + export LD_PRELOAD="${SO}${LD_PRELOAD:+:${LD_PRELOAD}}" +fi + +exec python3 "$APP" "$@" diff --git a/desktopenvs/hyprlua/scripts/mnotifhist.sh b/desktopenvs/hyprlua/scripts/mnotifhist.sh new file mode 100755 index 0000000..014284c --- /dev/null +++ b/desktopenvs/hyprlua/scripts/mnotifhist.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Toggle the mnotifhist (mnotifd's notification-history viewer). +# Forwards a verb to the resident daemon over D-Bus; if the daemon isn't +# running yet, starts it first. Mirrors horizon-dock.sh/astro-menu.sh. +# +# mnotifhist.sh -> --toggle (default) +# mnotifhist.sh show -> --show +# mnotifhist.sh hide -> --hide +# +# (No `set -e`: a non-zero `busctl` in the wait loop is expected and must not +# abort the script before it forwards the verb.) + +BUS="eu.abdelbaki.mnotifhist" +OBJ="/eu/abdelbaki/mnotifhist" +APP="${HOME}/.config/mnotifhist/main.py" + +case "${1:-toggle}" in + show) VERB="--show"; ACTION="show" ;; + hide) VERB="--hide"; ACTION="hide" ;; + *) VERB="--toggle"; ACTION="toggle" ;; +esac + +registered() { busctl --user list 2>/dev/null | grep -q "$BUS"; } + +if registered; then + exec gdbus call --session --dest "$BUS" --object-path "$OBJ" \ + --method org.gtk.Actions.Activate "$ACTION" "[]" "{}" >/dev/null +fi + +"${HOME}/.config/scripts/mnotifhist-start.sh" >/dev/null 2>&1 & +for _ in $(seq 1 25); do + if registered; then + exec "$0" "$@" + fi + sleep 0.2 +done + +exec python3 "$APP" "$VERB" diff --git a/desktopenvs/hyprlua/scripts/regen-beacon.sh b/desktopenvs/hyprlua/scripts/regen-beacon.sh new file mode 100755 index 0000000..f17e182 --- /dev/null +++ b/desktopenvs/hyprlua/scripts/regen-beacon.sh @@ -0,0 +1,133 @@ +#!/usr/bin/env bash +# Regenerates hyprlua's mnotifd (from hyprdrive's beacon) and mnotifhist +# (from hyprdrive's transmitter-panel) — hyprlua's plain-themed notification +# daemon + history viewer, replacing dunst. +# +# Run BY HAND whenever hyprdrive's beacon/transmitter-panel change and you +# want those changes reflected here. Never wired into sysupdate.sh. +# +# What this does, per component: +# 1. rm -rf + cp -r the hyprdrive source tree in fresh (not an incremental +# patch — regenerating from scratch avoids drift bugs), dropping style/ +# and any __pycache__. +# 2. Copies in the hand-maintained plain stylesheet from the matching +# desktopenvs/hyprlua/-theme/ seed directory (never written to by +# this script — edit those by hand for a different plain look). +# 3. Flips the copied config.py's hologram/squiggle defaults to False (the +# real hyprdrive beacon/transmitter-panel default to True and are never +# touched by this script). +# 4. Runs one ordered rename table over every file's content AND over +# filenames: beacon -> mnotifd, transmitter-panel -> mnotifhist, plus +# the CamelCase class-name and UI-label variants actually present in +# the source (checked empirically, not guessed). This is what turns +# eu.abdelbaki.beacon.History1 into eu.abdelbaki.mnotifd.History1, +# beaconctl into mnotifdctl, ~/.config/beacon/... into +# ~/.config/mnotifd/..., etc. — all from the one table. Also rewrites +# the literal "hyprdrive" -> "hyprlua" (docstrings, and critically the +# D-Bus .service file's Exec= path, which otherwise still points at +# desktopenvs/hyprdrive/scripts/ after the beacon->mnotifd rename). +# +# The rename table intentionally does NOT touch the .tx-* / .close-btn CSS +# class names transmitter-panel/mnotifhist use — those prefixes don't +# contain "beacon" or "transmitter-panel" as substrings, so the mnotifhist +# plain stylesheet targets them unchanged. beacon's `.beacon-*` classes DO +# get renamed to `.mnotifd-*` (because "beacon" is a substring of each), so +# mnotifd's plain stylesheet is written against that post-rename vocabulary. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +HL="$(cd "$SCRIPT_DIR/.." && pwd)" +HD="$(cd "$HL/../hyprdrive" && pwd)" + +# -- the rename table --------------------------------------------------------- +# Order matters: longest/most-specific compounds first, so e.g. +# "TransmitterPanelApp" resolves via its own rule before the bare +# "transmitter-panel" rule would otherwise mangle it mid-word. +rename_stream() { + sed \ + -e 's/TransmitterPanelApp/MnotifhistApp/g' \ + -e 's/TransmitterWindow/MnotifhistWindow/g' \ + -e 's/BeaconWindow/MnotifdWindow/g' \ + -e 's/BeaconApp/MnotifdApp/g' \ + -e 's/transmitter-window/mnotifhist-window/g' \ + -e 's/transmitter-panel/mnotifhist/g' \ + -e 's/transmitterpanel/mnotifhist/g' \ + -e 's/beacon/mnotifd/g' \ + -e 's/Transmissions/Notifications/g' \ + -e 's/hyprdrive/hyprlua/g' +} + +rename_file_in_place() { + local f="$1" tmp + tmp="$(mktemp)" + rename_stream < "$f" > "$tmp" + mv "$tmp" "$f" +} + +rename_tree_contents() { + local dir="$1" f + while IFS= read -r -d '' f; do + rename_file_in_place "$f" + done < <(find "$dir" -type f -print0) +} + +flip_default() { + # Flip `"": True` -> `"": False` in a copied config.py's + # _DEFAULTS dict. No-op (silently) if the key isn't present. + local config_py="$1" key="$2" + sed -i "s/\"${key}\": *True/\"${key}\": False/" "$config_py" +} + +# -- per-component regeneration ------------------------------------------------ +regen_component() { + local src="$1" dst="$2" theme_dir="$3" + shift 3 + local flip_keys=("$@") + + rm -rf "$dst" + cp -r "$src" "$dst" + rm -rf "$dst/style" "$dst/__pycache__" "$dst/lib/__pycache__" + mkdir -p "$dst/style" + cp "$theme_dir/_colors.css" "$theme_dir/style.css" "$dst/style/" + + local key + for key in "${flip_keys[@]}"; do + flip_default "$dst/config.py" "$key" + done + + rename_tree_contents "$dst" + + # rename any files/dirs whose *name* itself needs the substitution + # (deepest paths first, so renaming a parent dir doesn't orphan a path + # to a file inside it that find already queued) + local p renamed + while IFS= read -r -d '' p; do + renamed="$(dirname "$p")/$(basename "$p" | rename_stream)" + if [[ "$p" != "$renamed" ]]; then + mv "$p" "$renamed" + fi + done < <(find "$dst" -depth -print0) + return 0 +} + +regen_component "$HD/beacon" "$HL/mnotifd" "$HL/mnotifd-theme" hologram squiggle +regen_component "$HD/transmitter-panel" "$HL/mnotifhist" "$HL/mnotifhist-theme" hologram + +# -- launcher / toggle scripts + CLI ------------------------------------------ +regen_script() { + local src="$1" dst + dst="$SCRIPT_DIR/$(basename "$src" | rename_stream)" + rename_stream < "$src" > "$dst" + chmod +x "$dst" +} + +regen_script "$HD/scripts/beacon-start.sh" +regen_script "$HD/scripts/beaconctl" +regen_script "$HD/scripts/transmitter-panel.sh" +regen_script "$HD/scripts/transmitter-panel-start.sh" + +echo "Regenerated:" +echo " $HL/mnotifd/" +echo " $HL/mnotifhist/" +echo " $SCRIPT_DIR/mnotifd-start.sh, mnotifdctl, mnotifhist.sh, mnotifhist-start.sh" diff --git a/setup/modules/Desktop-Environments/hyprlua.sh b/setup/modules/Desktop-Environments/hyprlua.sh index d09f216..d15c099 100755 --- a/setup/modules/Desktop-Environments/hyprlua.sh +++ b/setup/modules/Desktop-Environments/hyprlua.sh @@ -69,7 +69,6 @@ HYPRLUA_PACKAGES=( hyprlock # GPU-accelerated screen locker (Hyprland-native) wofi # Wayland application launcher (rofi alternative) kitty # GPU-accelerated terminal (default in this setup) - dunst # lightweight, scriptable notification daemon nwg-look # GTK/cursor/icon theme picker for wlroots sessions @@ -356,12 +355,23 @@ log "Copying configs..." # Deploy each config directory from the hyprlua Dotfiles source. # The wipe-then-copy pattern ensures no stale files from older installs remain. -CONFIGS=(kitty mimeapps.list vicinae walker ulauncher hypr xfce4 wofi dunst alacritty astal-menu nwg-panel scripts btop gtk-3.0) +CONFIGS=(kitty mimeapps.list vicinae walker ulauncher hypr xfce4 wofi alacritty astal-menu mnotifd mnotifhist nwg-panel scripts btop gtk-3.0) for cfg in "${CONFIGS[@]}"; do rm -rf ~/.config/"$cfg" cp -r ~/Dotfiles/desktopenvs/hyprlua/"$cfg" ~/.config/ done +# mnotifd replaces dunst as the notification daemon. The dunst package (if +# still installed) ships its own D-Bus activation file +# (org.knopwob.dunst.service, Name=org.freedesktop.Notifications); without a +# shadow file of our own, D-Bus would auto-activate dunst instead of mnotifd +# the moment any app posts a notification before mnotifd is already running. +# A service file under ~/.local/share/dbus-1/services/ takes precedence over +# the one under /usr/share, so this wins the name resolution. +mkdir -p ~/.local/share/dbus-1/services +cp ~/Dotfiles/desktopenvs/hyprlua/mnotifd/dbus/*.service \ + ~/.local/share/dbus-1/services/ + # Vicinae loads NAMED custom themes from ~/.local/share/vicinae/themes/*.toml — # NOT from ~/.config/vicinae/. The cyberqueer.toml copied above (into # ~/.config/vicinae/) is therefore never found, so settings.json's @@ -377,7 +387,7 @@ cp -f ~/Dotfiles/desktopenvs/hyprlua/vicinae/cyberqueer.toml \ # directory copied above, so no separate step is needed here. # After install, customise ~/.config/hypr/usr/ per device as needed. -# Shared colour palette used by EWW bar, dunst, and scripts. +# Shared colour palette used by EWW bar, mnotifd/mnotifhist, and scripts. cp ~/Dotfiles/colors.conf ~/.config/colors.conf # --------------------------------------------------------------------------- @@ -452,7 +462,7 @@ ln -sf ~/Dotfiles/desktopenvs/hyprlua/config-updater/updater.conf ~/.config/conf ln -sf ~/Dotfiles/desktopenvs/hyprlua/config-updater/update-configs.sh ~/update-configs.sh # apply-theme.sh applies the cyberqueer palette across all running apps -# (reloads dunst, refreshes EWW variables, sets GTK/Qt themes). +# (refreshes EWW variables, sets GTK/Qt themes). # Copied rather than symlinked so it is available even when ~/Dotfiles is absent. cp ~/Dotfiles/apply-theme.sh ~/apply-theme.sh chmod +x ~/apply-theme.sh