feat(hyprlua): migrate to mnotifd/mnotifhist, generated from hyprdrive's beacon

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 <noreply@anthropic.com>
main
Amir Alexander Abdelbaki 2026-07-24 12:34:19 +02:00
parent c86afef147
commit 9640e09677
46 changed files with 3310 additions and 23 deletions

View File

@ -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))

View File

@ -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,6 +78,8 @@ class NotificationCard:
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()
@ -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,6 +147,7 @@ class NotificationCard:
# -- 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

View File

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

View File

@ -90,8 +90,10 @@ button.station-badge,
}
.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; }

View File

@ -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:

View File

@ -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;
}

View File

@ -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}"})
)

View File

@ -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;

View File

@ -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@ {}%"

View File

@ -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;
}

View File

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

View File

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

View File

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

View File

@ -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;

View File

@ -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; }

View File

@ -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))

View File

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

View File

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

View File

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

View File

@ -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()

View File

@ -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())

View File

@ -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 (<b>/<i>/<u>/<a>…);
# 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

View File

@ -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)

View File

@ -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 = """
<node>
<interface name="org.freedesktop.Notifications">
<method name="GetCapabilities">
<arg type="as" name="capabilities" direction="out"/>
</method>
<method name="Notify">
<arg type="s" name="app_name" direction="in"/>
<arg type="u" name="replaces_id" direction="in"/>
<arg type="s" name="app_icon" direction="in"/>
<arg type="s" name="summary" direction="in"/>
<arg type="s" name="body" direction="in"/>
<arg type="as" name="actions" direction="in"/>
<arg type="a{sv}" name="hints" direction="in"/>
<arg type="i" name="expire_timeout" direction="in"/>
<arg type="u" name="id" direction="out"/>
</method>
<method name="CloseNotification">
<arg type="u" name="id" direction="in"/>
</method>
<method name="GetServerInformation">
<arg type="s" name="name" direction="out"/>
<arg type="s" name="vendor" direction="out"/>
<arg type="s" name="version" direction="out"/>
<arg type="s" name="spec_version" direction="out"/>
</method>
<signal name="NotificationClosed">
<arg type="u" name="id"/>
<arg type="u" name="reason"/>
</signal>
<signal name="ActionInvoked">
<arg type="u" name="id"/>
<arg type="s" name="action_key"/>
</signal>
</interface>
</node>
"""
# 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 = """
<node>
<interface name="eu.abdelbaki.mnotifd.History1">
<method name="List">
<arg type="aa{sv}" name="entries" direction="out"/>
</method>
<method name="Get">
<arg type="u" name="id" direction="in"/>
<arg type="a{sv}" name="entry" direction="out"/>
</method>
<method name="Pop">
<arg type="u" name="id" direction="in"/>
<arg type="u" name="new_id" direction="out"/>
</method>
<method name="PopLatest">
<arg type="u" name="new_id" direction="out"/>
</method>
<method name="Remove">
<arg type="u" name="id" direction="in"/>
</method>
<method name="Clear">
</method>
<method name="InvokeAction">
<arg type="u" name="id" direction="in"/>
<arg type="s" name="action_key" direction="in"/>
</method>
<signal name="HistoryAdded">
<arg type="a{sv}" name="entry"/>
</signal>
<signal name="HistoryRemoved">
<arg type="u" name="id"/>
</signal>
<signal name="HistoryCleared">
<arg type="u" name="count"/>
</signal>
</interface>
</node>
"""
_CONTROLS_INTROSPECTION_XML = """
<node>
<interface name="eu.abdelbaki.mnotifd.Controls1">
<signal name="ControlChanged">
<arg type="u" name="id"/>
<arg type="s" name="control_id"/>
<arg type="v" name="value"/>
</signal>
</interface>
</node>
"""
# 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))

View File

@ -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;

View File

@ -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; }

View File

@ -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
)

View File

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

View File

@ -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;

View File

@ -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; }

View File

@ -0,0 +1,2 @@
__pycache__/
*.pyc

View File

@ -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))

View File

@ -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/<name> 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

View File

@ -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()

View File

@ -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())

View File

@ -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)

View File

@ -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;

View File

@ -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; }

View File

@ -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
)

View File

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

View File

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

View File

@ -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())

View File

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

View File

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

View File

@ -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/<name>-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 `"<key>": True` -> `"<key>": 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"

View File

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