There are two ways to do great mathematics. The first is to be smarter than everybody else. The second way is to be stupider than everybody else — but persistent, Raoul Bott.
This entry introduces expand-daemon.py, a background Python daemon that transforms your clipboard into an intelligent, automated text expander for Wayland. Unlike a simple clipboard manager, this script actively monitors the clipboard for specific triggers (prefixes like : and ;).
When a trigger is detected, the daemon instantly replaces the clipboard content with its expansion —whether that is a static snippet (like an address) or a dynamic value (like the current date or a UUID)— and auto-pastes it into your focused window. This setup removes the friction of opening a menu or searching for a snippet; you simply type the trigger, copy it, and it expands.
This script runs in the background, polling wl-paste for changes. It differentiates between static snippets stored in a JSON file and dynamic functions executed by a separate Python module.
#!/usr/bin/env python3
"""
expand-daemon.py — Clipboard-based text expander for Wayland / Niri / Arch
────────────────────────────────────────────────────────────────────────────
OVERVIEW
A background daemon that watches the Wayland clipboard. When you copy
text that starts with a trigger prefix, it replaces the clipboard with
the expansion and auto-pastes it (Ctrl+V) into the focused window.
Two trigger types with separate prefixes:
TRIGGER_PREFIX ":" static snippets from mysnippets.json
:mywebsite → https://justtothepoint.com
TRIGGER_FUNCTION ";" dynamic functions from myfunctions.json
;date → 2026-04-19
;uuid → 550e8400-e29b-41d4-a716-...
WORKFLOW
1. In any app, type: :mywebsite (or ;date for a function)
2. Select that text: double-click, Shift+Home, or drag+mouse
3. Press: Ctrl+C
4. Daemon fires in ~0.2s, replaces clipboard, sends Ctrl+V
5. Result: expansion appears in place of the trigger
OR use myrofi_type.py (rofi input box) — no select+copy needed.
FILES
mysnippets.json ~/dotfiles/myArch/config/snippets/mysnippets.json
myfunctions.json ~/dotfiles/myArch/config/snippets/myfunctions.json
myfunctions.py ~/dotfiles/myArch/config/snippets/myfunctions.py
AUTO-START (add ONE of these — not both)
niri config.kdl:
spawn-at-startup "python3" "/home/nmaximo7/dotfiles/myArch/admin/expand-daemon.py"
systemd user service — see template at bottom of this file.
RELOAD (no restart needed):
kill -HUP $(pgrep -f expand-daemon.py)
WHY POLL INSTEAD OF CLIPBOARD EVENTS
Wayland does not expose clipboard ownership/change events to
arbitrary clients. The only portable way to monitor the clipboard
is to poll wl-paste periodically. 200ms is a balance between
responsiveness and resource usage. The cooldown prevents accidental
re‑triggering after the daemon itself writes to the clipboard.
WHY NOT xdotool / xclip
xdotool requires XWayland and often misses keys on native Wayland
compositors (like Niri). wtype and wl‑copy/wl‑paste are pure
Wayland tools and work consistently without X11 fallback.
"""
import json, os, sys, time, signal, subprocess, shutil, logging, importlib.util
# ── Configuration ──────────────────────────────────────────────────────────────
# The trigger characters are chosen to be unlikely in normal text, reducing
# false positives. Use a single character for ergonomic typing.
TRIGGER_PREFIX = ":" # static snippets
TRIGGER_FUNCTION = ";" # dynamic functions
# JSON files that map trigger words to static text or function names.
# Both files are re‑read on SIGHUP, allowing live editing without restart.
SNIPPET_FILE = os.path.expanduser("~/dotfiles/myArch/config/snippets/mysnippets.json")
FUNCTION_FILE = os.path.expanduser("~/dotfiles/myArch/config/snippets/myfunctions.json")
# Python file containing the actual function implementations.
# This file is dynamically imported; it must define all functions referenced
# in myfunctions.json. On SIGHUP the module is re‑imported from disk.
FUNCTIONS_PY = os.path.expanduser("~/dotfiles/myArch/config/snippets/myfunctions.py")
# How often the clipboard is read. 0.2 seconds (5 times per second) is
# fast enough to feel instant but light enough to avoid battery drain.
POLL_INTERVAL = 0.2
# After an expansion is pasted, the daemon ignores the clipboard for
# EXPAND_COOLDOWN seconds. This prevents the pasted text (which may itself
# start with a trigger character) from being re‑expanded in a loop.
EXPAND_COOLDOWN = 1.0
# Delay between placing the expansion on the clipboard (wl-copy) and
# simulating Ctrl+V (wtype -k ctrl+v). 150ms gives the Wayland compositor
# time to register the new clipboard content so that the paste actually
# sees the expansion.
PASTE_DELAY = 0.15
# Log file path. Writing to a file is useful for long‑running daemons;
# combined with stdout logging (see below) it gives both immediate
# feedback when run in a terminal and a persistent record.
LOG_FILE = os.path.expanduser("~/.local/share/expand-daemon.log")
# ── Logging ────────────────────────────────────────────────────────────────────
# Logging is configured at the module level because this script is a daemon.
# It logs at DEBUG level to capture every trigger, expansion, and error.
# The dual handler setup (stdout + file) means:
# - stdout: visible when running manually from a terminal
# - file: always available, rotated or reviewed later
logging.basicConfig(
level = logging.DEBUG,
format = "%(asctime)s %(levelname)-5s %(message)s",
handlers = [
logging.StreamHandler(sys.stdout),
# The file handler is conditional. If LOG_FILE is not set (empty),
# it is omitted, but here we always provide a path.
*([logging.FileHandler(LOG_FILE)] if LOG_FILE else []),
],
)
log = logging.getLogger("expand")
# ── Load snippets ──────────────────────────────────────────────────────────────
def load_snippets(path: str) -> dict[str, str]:
"""
Load mysnippets.json → {trigger: text}. Called once at startup and on SIGHUP.
The JSON structure is expected to be:
{
"snippets": [
{"trigger": "mywebsite", "text": "https://..."},
...
]
}
The function is lenient:
- Missing file returns an empty dict (no snippets, but the daemon stays alive).
- Malformed JSON logs an error and returns {}.
- Entries without a trigger or with a non‑string text are silently skipped.
Returns:
A dict mapping the trigger word (without the ':' prefix) to the expansion text.
"""
# Missing file returns an empty dict (no snippets, but the daemon stays alive).
if not os.path.exists(path):
log.warning("Snippet file not found: %s", path)
return {}
try:
with open(path, encoding="utf-8") as f:
data = json.load(f)
# Build a clean dict. Only entries with both a trigger and a text string
# are included. The trigger is whitespace‑stripped for safety.
result = {}
# If "snippets" is missing, data.get("snippets", []) ensures the comprehension iterates over an empty list instead of crashing.
for s in data.get("snippets", []):
# It keeps only entries where: (i) "trigger" exists and is truthy (s.get("trigger"))
# "text" exists and is a string (isinstance(s.get("text"), str))
if s.get("trigger") and isinstance(s.get("text"), str):
result[s["trigger"].strip()] = s["text"]
log.info("Loaded %d snippets from %s", len(result), path)
return result
except Exception as e:
log.error("Could not load snippets: %s", e)
return {}
def load_function_map(json_path: str) -> dict[str, str]:
"""
Load myfunctions.json → {trigger: function_name}.
Structure: {"functions": [ {"trigger": "date", "function": "current_date"}, ... ]}
The function names must correspond to callable symbols in myfunctions.py.
Returns:
A dict mapping trigger (e.g., "date") to the Python function name
(e.g., "current_date"). On error returns an empty dict.
"""
if not os.path.exists(json_path):
log.warning("Functions JSON not found: %s", json_path)
return {}
try:
with open(json_path, encoding="utf-8") as f:
data = json.load(f)
result = {}
for e in data.get("functions", [])
if e.get("trigger") and e.get("function")
result[e["trigger"].strip()]= e["function"].strip()
log.info("Loaded %d function triggers from %s", len(result), json_path)
return result
except Exception as e:
log.error("Could not load functions JSON: %s", e)
return {}
def load_functions_module(py_path: str):
"""
Dynamically import myfunctions.py without adding it to sys.path.
Standard import (import myfunctions) would require the file to be in a
directory on sys.path. Instead, we use importlib to load the file from an
absolute path, keeping the user's configuration directory clean.
Recall: sys.path contains a list of directories that the interpreter will search in for the required module.
On SIGHUP, this function is called again, and importlib.util will re‑read
the file from disk (no caching issues because we use a fresh spec each time).
Returns:
The module object, or None if the file is missing or contains errors.
"""
if not os.path.exists(py_path):
log.warning("myfunctions.py not found: %s", py_path)
return None
try:
# The import_module function from Python's importlib module basically works like an import statement,
# except it allows us to dynamically specify the module_name as a string.
# spec_from_file_location creates a module spec from the file path.
# The module name is set to "myfunctions" for consistent error reporting.
spec = importlib.util.spec_from_file_location("myfunctions", py_path)
# module_from_spec creates an empty module object.
module = importlib.util.module_from_spec(spec)
# exec_module runs the Python code (importing a module runs code), populating the module's namespace.
spec.loader.exec_module(module)
log.info("Loaded functions module from %s", py_path)
return module
except Exception as e:
log.error("Could not load myfunctions.py: %s", e)
return None
def call_function(module, func_name: str, trigger: str) -> str | None:
"""
Call module.func_name(trigger) and return the string result.
The trigger string (without prefix) is passed as the argument.
This allows future parameterised triggers like `;fmt:DD/MM/YYYY` —
the function receives the full string after the prefix and can parse it.
Args:
module: The module object returned by load_functions_module(). It is expected
to contain the callable named by `func_name`.
func_name: The name of the function to call (string). Must correspond to an attribute of
`module`.
trigger: The trigger word (e.g., "date", "fmt:DD/MM").
May be used by the function to vary its output.
Returns:
The string produced by the function, or None on any error.
None is the signal to the daemon that expansion failed and nothing
should be typed.
"""
# Try to fetch the function named func_name from the module.
# If it’s not there, set fn to None.
fn = getattr(module, func_name, None)
if fn is None:
log.error("Function %r not found in myfunctions.py", func_name)
return None
try:
result = fn(trigger)
# If the function returns None explicitly, treat it as "no expansion".
return str(result) if result is not None else None
except Exception as e:
log.error("Function %r raised: %s", func_name, e)
return None
# ── Clipboard ──────────────────────────────────────────────────────────────────
def clipboard_read() -> str | None:
"""
Read the current Wayland clipboard content using `wl-paste`.
Uses --no-newline to strip any trailing newline that wl-paste might add.
This is crucial because the trigger text we want to match (e.g., `:date`)
does not normally end with a newline. If we left a newline on, the
prefix‑check (startswith) would fail, and the expansion would never fire.
If the clipboard is empty or wl-paste fails for any reason (e.g., no
compositor support), we return None. This tells the daemon there's nothing
to process.
Returns:
The clipboard text string, or None if empty/failed.
"""
result = subprocess.run(
["wl-paste", "--no-newline"], capture_output=True, text=True
)
# Return stdout only if the process succeeded AND the output is non‑empty.
return result.stdout if result.returncode == 0 and result.stdout else None
def clipboard_write(text: str) -> None:
"""
Write to Wayland clipboard via wl-copy (non‑blocking Popen).
WHY Popen + stdin.close() instead of subprocess.run():
wl-copy stays alive in the background to serve paste requests.
It only exits after another app reads the clipboard.
subprocess.run() would block forever waiting for wl-copy to finish.
Popen() starts it and returns immediately; we write our text and
close stdin to signal end‑of‑input, then walk away.
WHY start_new_session=True:
Detaches wl-copy from our process group so it survives after
this script exits. Without it, wl-copy would receive SIGHUP when
the daemon exits (or when its parent process group terminates),
causing the clipboard to be cleared immediately — which would
defeat the purpose of a persistent clipboard daemon.
"""
proc = subprocess.Popen(
["wl-copy"],
stdin=subprocess.PIPE, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, start_new_session=True,
)
# Encode text to bytes for stdin; wl-copy expects UTF‑8 by default.
proc.stdin.write(text.encode("utf-8"))
proc.stdin.close()
# IMPORTANT: Do NOT call proc.wait() or proc.communicate() — they block.
# wl-copy is now a background daemon that will persist after this function returns.
# ── Auto-paste ─────────────────────────────────────────────────────────────────
def autopaste() -> None:
"""
Simulate Ctrl+V using wtype (Wayland virtual keyboard protocol).
The selection that contained the trigger is still active, so Ctrl+V
replaces it with the expansion — trigger disappears, expansion appears.
This works even if the original selection was by mouse or keyboard,
because the application still considers that text as selected.
WHY wtype instead of ydotool or xdotool:
wtype speaks the Wayland virtual‑keyboard protocol directly.
It works on Niri without XWayland and does not need root or a daemon.
xdotool cannot inject keystrokes into native Wayland windows.
The key sequence:
-M ctrl → press modifier Ctrl
-k v → press and release the v key
-m ctrl → release modifier Ctrl
This simulates a normal Ctrl+V chord. The capture_output=True silences
any diagnostic messages, as the daemon doesn't need them.
"""
if not shutil.which("wtype"):
log.warning("wtype not found — sudo pacman -S wtype (paste manually with Ctrl+V)")
return
subprocess.run(["wtype", "-M", "ctrl", "-k", "v", "-m", "ctrl"], capture_output=True)
# ── Notify ─────────────────────────────────────────────────────────────────────
def notify(title: str, body: str = "", error: bool = False) -> None:
"""
Send a desktop notification via `notify-send`.
Uses Popen because we don't need to wait for the notification to
be displayed — the daemon loop must keep running. Errors are discarded
so a missing notification server doesn't produce terminal noise.
The icon names conform to the freedesktop icon naming spec, so they
resolve correctly under common themes (Adwaita, Papirus, etc.).
"""
if not shutil.which("notify-send"):
return
icon = "dialog-error" if error else "edit-copy"
subprocess.Popen(
["notify-send", "-i", icon, "-t", "2500", title, body],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
# ── Expand and paste ───────────────────────────────────────────────────────────
def expand_and_paste(expansion: str, label: str) -> None:
"""
Write expansion to clipboard, auto‑paste, and notify the user.
If expansion is empty string "" the function performed a **SIDE‑EFFECT**
(e.g., opening an application, shutting down, etc.) and returned nothing
to paste. In that case we skip clipboard_write and autopaste entirely —
only the (optional) notification is shown, which the function already
sent itself before returning "".
WHY separate label parameter:
The label is the trigger (e.g., "date") or a short description,
used in the log and notification. This makes the daemon output
human‑readable even when the actual expansion is very long.
The preview truncation (60 chars) prevents the notification from being
overwhelmed by a multi‑paragraph expansion, while still giving context.
An ellipsis "…" is appended to show that truncation occurred.
"""
if not expansion:
return # action-only function — no paste needed
# Place the expansion on the clipboard so that Ctrl+V will pick it up.
clipboard_write(expansion)
# Allow a small delay for wl-copy to fully register the new content.
# Without this, the subsequent Ctrl+V might paste the old clipboard
# (the trigger) instead of the expansion. 150ms is a safe default.
time.sleep(PASTE_DELAY)
# Simulate Ctrl+V to replace the selected trigger with the expansion.
autopaste()
# Build a short preview for the log and notification.
preview = expansion if len(expansion) <= 60 else expansion[:59] + "…"
log.info("Expanded %-20s → %s", label, preview)
notify(f"📋 {label}", preview)
# ── Main loop ──────────────────────────────────────────────────────────────────
def main() -> None:
"""
Entry point for the expand‑daemon.
The function runs indefinitely, polling the Wayland clipboard every
POLL_INTERVAL seconds. When it detects text that starts with a trigger
prefix (':' or ';'), it resolves the expansion and performs an automatic
paste (Ctrl+V) into the currently focused window.
Signal handling:
SIGHUP – reload all configuration without restarting the daemon
SIGTERM – graceful shutdown (sent by systemd, niri, or kill)
SIGINT – Ctrl+C in a terminal
Design notes:
- Dependency checks happen early to avoid cryptic failures later.
- All data (snippets, function map, modules) are loaded into local
variables, and a SIGHUP handler updates them via `nonlocal`.
- The clipboard poll loop is deliberately simple: no threads, no
async — it's easy to reason about and consumes negligible CPU.
"""
# ── Sanity checks ────────────────────────────────────────────────────────
# Both wl-paste and wl-copy are required. Without them, the daemon can
# neither read nor write the clipboard. Exit immediately with a clear error.
for tool in ("wl-paste", "wl-copy"):
if not shutil.which(tool):
log.error("%s not found — sudo pacman -S wl-clipboard", tool)
sys.exit(1)
# ── Load initial configuration ────────────────────────────────────────────
# All three data sources are loaded at startup:
# snippets : dict mapping trigger → static text
# function_map : dict mapping trigger → Python function name
# functions_mod : imported module containing the actual functions
# If any file is missing, the corresponding loader returns an empty value,
# allowing the daemon to start with reduced functionality.
snippets = load_snippets(SNIPPET_FILE)
function_map = load_function_map(FUNCTION_FILE)
functions_mod = load_functions_module(FUNCTIONS_PY)
# ── SIGHUP handler ────────────────────────────────────────────────────────
# Reload all data files without restarting the daemon.
# This is triggered by: kill -HUP $(pgrep -f expand-daemon.py)
#
# The `nonlocal` statement allows the inner function to rebind the outer
# variables (snippets, function_map, functions_mod). Without it, Python
# would treat them as new local variables inside the closure.
#
# Each loader is called exactly as at startup. Errors are logged but
# do not stop the daemon; the previous data remains in use if loading fails.
def _on_sighup(sig, frame):
nonlocal snippets, function_map, functions_mod
log.info("SIGHUP — reloading all data files")
snippets = load_snippets(SNIPPET_FILE)
function_map = load_function_map(FUNCTION_FILE)
functions_mod = load_functions_module(FUNCTIONS_PY)
# We register _on_sighub as the handler for the SIGHUP signal.
signal.signal(signal.SIGHUP, _on_sighup)
# ── Termination handlers ──────────────────────────────────────────────────
# Both SIGTERM and SIGINT set a flag (`running[0]`). The main loop checks
# this flag on each iteration and exits cleanly when it becomes False.
#
# WHY a list instead of a simple boolean:
# In Python, assigning to a variable inside a nested function creates
# a new local variable unless declared `nonlocal`. Using a mutable
# container (a one‑element list) avoids the need for `nonlocal` in
# the signal handlers and makes the state change visible in the outer
# scope. It's a common pattern for simple flag-based shutdown in daemons.
running = [True]
def _stop(sig, frame):
log.info("Signal %d — stopping", sig)
# It modifies the content of the list running.
# It flips the flag that the main loop is watching
running[0] = False
signal.signal(signal.SIGTERM, _stop)
signal.signal(signal.SIGINT, _stop)
# ── Startup banner ────────────────────────────────────────────────────────
log.info("expand-daemon ready")
log.info(" Snippets %d (prefix '%s') e.g. :mywebsite", len(snippets), TRIGGER_PREFIX)
log.info(" Functions %d (prefix '%s') e.g. ;date ;uuid", len(function_map), TRIGGER_FUNCTION)
log.info(" Workflow type trigger → SELECT → Ctrl+C → auto-expands+pastes")
log.info(" Reload kill -HUP $(pgrep -f expand-daemon.py)")
# ── Clipboard change detection ────────────────────────────────────────────
# `last_clipboard` holds the **expansion result** when an expansion was
# just performed. This serves two purposes:
# 1. Avoid re‑triggering on the same text (if the user copies the same
# trigger twice, the clipboard hasn't changed, so we skip it).
# 2. After an expansion, the clipboard contains the expanded text. The
# daemon immediately sleeps for EXPAND_COOLDOWN seconds, so the
# comparison also ensures the expansion itself isn't treated as a
# new trigger (though the cooldown would also catch it).
last_clipboard = None
# ── Main loop ─────────────────────────────────────────────────────────────
while running[0]:
# Read the current clipboard. Returns None if empty or unreadable.
current = clipboard_read()
# Only process if text is present and it differs from what we last saw.
# This simple equality check eliminates most spurious triggers.
if current and current != last_clipboard:
last_clipboard = current
stripped = current.strip()
log.debug("Clipboard: %r", stripped[:80])
# ── Static snippet :keyword ─────────────────────────────────────
if stripped.startswith(TRIGGER_PREFIX):
# Extract the trigger word after the prefix, stripping whitespace.
candidate = stripped[len(TRIGGER_PREFIX):].strip()
if candidate in snippets:
# Before pasting, set last_clipboard to the expansion so the
# next read won't accidentally re‑process the same text.
last_clipboard = snippets[candidate]
expand_and_paste(snippets[candidate], f":{candidate}")
# Cooldown: wait EXPAND_COOLDOWN seconds so the pasted text
# (which may contain trigger prefixes) isn't immediately
# captured and re‑expanded by the next poll iteration.
time.sleep(EXPAND_COOLDOWN)
continue
log.debug("No snippet for %r", candidate)
# ── Dynamic function ;keyword ────────────────────────────────────
elif stripped.startswith(TRIGGER_FUNCTION):
candidate = stripped[len(TRIGGER_FUNCTION):].strip()
if candidate in function_map:
func_name = function_map[candidate]
if functions_mod is None:
# The Python module couldn't be loaded at startup or reload.
# Log an error but do not crash; the daemon stays alive.
log.error("myfunctions.py not loaded — cannot call %s", func_name)
else:
result = call_function(functions_mod, func_name, candidate)
if result is not None:
last_clipboard = result
expand_and_paste(result, f";{candidate}")
time.sleep(EXPAND_COOLDOWN)
continue
else:
log.debug("No function for %r", candidate)
# Sleep between polls. This sets the daemon's responsiveness.
# time.sleep is used instead of event-based waiting because Wayland
# does not expose a clipboard-change notification mechanism to clients.
time.sleep(POLL_INTERVAL)
log.info("expand-daemon stopped")
if __name__ == "__main__":
main()
# ── Systemd user service template ─────────────────────────────────────────────
#
# File: ~/.config/systemd/user/expand-daemon.service
#
# WHY A USER SERVICE (not a system service):
# The daemon needs access to the user's Wayland session (WAYLAND_DISPLAY,
# DBUS_SESSION_BUS_ADDRESS) to talk to the compositor for clipboard and
# wtype. A system service runs outside the user session and cannot access
# these environment variables. A user service runs *as the user* and inherits
# the session environment when started via `graphical-session.target`.
#
# WHY systemd OVER AUTOSTART SCRIPTS:
# - Reliable restarts on crash (`Restart=on-failure`).
# - Logging to journald, easy to inspect with `journalctl --user -u expand-daemon`.
# - Reload via SIGHUP without restarting (`ExecReload`).
# - Ordering with `After=graphical-session.target` ensures the compositor is ready.
[Unit]
Description=Clipboard text expander daemon
# The daemon requires a running graphical session (Wayland compositor + XDG env).
# `After=graphical-session.target` ensures systemd does not start the daemon
# before the session is fully set up. This target is pulled in by the user's
# session manager.
After=graphical-session.target
[Service]
# Type=simple means systemd considers the service started as soon as the main
# process is spawned. The daemon runs indefinitely; it doesn't fork or exit.
Type=simple
# ExecStart – the command to launch the daemon.
# Using `python3` explicitly avoids ambiguity with multiple Python versions.
# The full absolute path to the script is used because the service's working
# directory may be $HOME or /, and relative paths could break.
ExecStart=python3 /home/nmaximo7/dotfiles/myArch/admin/expand-daemon.py
# ExecReload – sends SIGHUP to the main PID to trigger a reload of configuration
# files without restarting. $MAINPID is a systemd variable that resolves to the
# PID of the main process. Using `/bin/kill` (instead of `kill` shell builtin)
# is more portable and avoids shell interpretation issues.
ExecReload=/bin/kill -HUP $MAINPID
# Restart=on-failure will restart the daemon if it exits with a non-zero code
# (e.g., unhandled exception) or is killed by a signal (except SIGHUP/INT/TERM
# which we handle ourselves). It does NOT restart on clean exit (exit 0) or
# when stopped by systemd. This prevents endless restart loops on misconfiguration.
Restart=on-failure
# Wait 3 seconds before restarting. This gives time for the system (or the user)
# to fix a transient issue, and avoids hammering the compositor if the daemon
# crashes immediately.
RestartSec=3
[Install]
# WantedBy=graphical-session.target makes the service start automatically when
# the user's graphical session is launched. This is the standard way to auto‑start
# user services under a Wayland compositor managed with systemd.
WantedBy=graphical-session.target
# ── Usage ─────────────────────────────────────────────────────────────────────
# Reload systemd and enable + start the service in one command:
# systemctl --user daemon-reload && systemctl --user enable --now expand-daemon
#
# Reload configuration without restarting (sends SIGHUP):
# systemctl --user reload expand-daemon
#
# View live logs:
# journalctl --user -u expand-daemon -f
#
# Stop the service:
# systemctl --user stop expand-daemon
#
# Disable autostart:
# systemctl --user disable expand-daemon
#
The daemon relies on two JSON files to map triggers to content. The first maps triggers to static text, while the second maps triggers to Python function names found in the module below.
{
"_comment": "Text snippet library — trigger:expansion pairs. Add your own below.",
"_format": "trigger = short keyword you type in rofi | text = what gets copied",
"snippets": [
{
"trigger": "mywebsite",
"text": "https://justtothepoint.com"
},
{
"trigger": "myemail",
"text": "nmaximo7@gmail.com"
},
{
"trigger": "name",
"text": "Máximo Núñez Alarcón"
},
[
/*
* ADD YOUR OWN SNIPPETS HERE.
* The array is open-ended; add more objects with "trigger" and "text".
*
* Field rules:
* - "trigger": the keyword the user types after the prefix (:),
* WITHOUT the prefix character. Must be unique within the file.
* - "text": the static string that replaces the clipboard content.
* Can include any Unicode characters, newlines escaped as \n, etc.
*
* Example:
* { "trigger": "welcome", "text": "Welcome to my site!\nFeel free to browse." }
*/
]
]
}
This file acts as a bridge between the trigger prefix (e.g., ;) and the actual function definitions in the Python module.
{
"_comment": "Dynamic function triggers — trigger:function_name pairs",
"_note": "Functions in myfunctions.py. Prefix ';'. Return '' = action only (no paste).",
"functions": [
{
"trigger": "date",
"function": "mydate",
"description": "Today: YYYY-MM-DD"
},
{
"trigger": "time",
"function": "mytime",
"description": "Time: HH:MM:SS"
},
[
/*
* ADD YOUR OWN FUNCTION TRIGGERS HERE.
* Each object maps a trigger keyword to a function name.
*
* Field rules:
* - "trigger": the keyword the user types after the prefix (;),
* WITHOUT the prefix character. Must be unique.
* - "function": the exact name of a callable defined in myfunctions.py.
* The function must accept one string argument (the trigger word
* after the prefix, e.g. "date") and return a string, or None
* to abort, or an empty string for a side-effect-only action.
* - "description": (optional) human-readable note; ignored by the daemon.
* Useful for self-documenting your configuration.
*
* Example:
* { "trigger": "uuid", "function": "generate_uuid", "description": "Random UUID v4" }
*/
]
]
}
This Python script contains the logic for dynamic expansions. Functions can generate timestamps, system info, interact with audio/media players via playerctl or wpctl, or launch applications.
"""
myfunctions.py — Dynamic expansion functions for expand-daemon.py
──────────────────────────────────────────────────────────────────
FUNCTION CONTRACT
• Accept one str argument (the trigger text after the prefix).
• Return str: non-empty → pasted into focused window.
"" → action only, nothing to paste.
• Never raise — catch all exceptions and return "" or a fallback.
WHY THIS CONTRACT?
The daemon calls these functions from inside its main loop. An unhandled
exception would crash the daemon or break the clipboard expansion. By
convention every function returns a string (or empty string) so the
caller can uniformly handle results without special cases.
The trigger text (everything after ';') is passed as the first argument.
This allows functions that parse parameters, e.g. `;fmt:DD/MM` could be
parsed inside the function. For simple functions the argument is ignored.
ADDING A NEW FUNCTION
1. Define def myname(text: str) -> str here.
2. Add to myfunctions.json: { "trigger": "mytrigger", "function": "myname" }
3. Reload: kill -HUP $(pgrep -f expand-daemon.py)
LOCATION: ~/dotfiles/myArch/config/snippets/myfunctions.py
"""
import datetime, calendar, uuid, random, string, socket, subprocess, shutil, os
# ── Helpers ───────────────────────────────────────────────────────────────────
def _notify(title, body="", error=False):
"""
Send a desktop notification via notify-send, if available.
This is a private helper used by side‑effect‑only functions (like net_ssh)
to inform the user that an action has been taken. Since those functions
return "" (nothing to paste), the daemon's standard notification (which
shows the expansion) is never called. We supply our own.
Uses Popen to avoid blocking the daemon loop. If notify-send is missing
the call is silently skipped.
"""
if not shutil.which("notify-send"):
return
icon = "dialog-error" if error else "dialog-information"
subprocess.Popen(["notify-send", "-i", icon, "-t", "3000", title, body],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
def _run(cmd, **kw):
"""
Convenience wrapper for subprocess.run with default DEVNULL I/O.
Most functions launch short‑lived commands that don't need stdin and whose
stdout/stderr are irrelevant. This helper sets those defaults, reducing
boilerplate. Individual calls can override by passing `stdout`, etc.
"""
kw.setdefault("stdout", subprocess.DEVNULL)
kw.setdefault("stderr", subprocess.DEVNULL)
return subprocess.run(cmd, **kw)
def _spawn(cmd):
"""
Launch a fully detached process that survives the daemon.
Used for actions like opening an SSH terminal. The process is detached via
start_new_session=True and its standard streams are sent to DEVNULL.
The daemon does not wait for it to finish – it walks away immediately.
"""
subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
start_new_session=True)
# ══════════════════════════════════════════════════════════════════════════════
# DATE / TIME
# ══════════════════════════════════════════════════════════════════════════════
def mydate(text: str) -> str:
"""Today's date in ISO format: 2026-04-19"""
# ISO 8601 is widely understood and sortable – the default for `date.isoformat()`.
return datetime.date.today().isoformat()
def mytime(text: str) -> str:
"""Current local time: 14:35:07"""
# 24‑hour local time with leading zeros makes it easy to paste into a timestamp.
return datetime.datetime.now().strftime("%H:%M:%S")
def mydatetime(text: str) -> str:
"""Date and time: 2026-04-19 14:35:07"""
# Combines date and time in a single, human‑readable format.
return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
def mycalendar(text: str) -> str:
"""Current month as a text calendar grid."""
now = datetime.date.today()
# calendar.month returns a multi‑line string with leading/trailing spaces.
# .strip() removes them for cleaner paste.
return calendar.month(now.year, now.month).strip()
def myweek(text: str) -> str:
"""ISO week number: Week 16"""
# isocalendar() returns (ISO year, ISO week number, ISO weekday).
# Week numbers are useful for planning and logging.
return f"Week {datetime.date.today().isocalendar()[1]}"
def mytimestamp(text: str) -> str:
"""Unix timestamp in seconds."""
# `time` is imported lazily – not needed at module level.
# This keeps the global namespace a tiny bit cleaner (though the daemon
# lives for a long time, so the difference is purely aesthetic).
import time; return str(int(time.time()))
# ══════════════════════════════════════════════════════════════════════════════
# RANDOM GENERATORS
# ══════════════════════════════════════════════════════════════════════════════
def myuuid(text: str) -> str:
"""Random UUID v4."""
# uuid.uuid4() generates a random UUID (v4) using os.urandom.
return str(uuid.uuid4())
def mypassword(text: str) -> str:
"""
Random 20‑character password with uppercase, lowercase, digits, symbols.
Uses secrets.choice for cryptographic strength if available (Python ≥3.6).
Falls back to random.choice on older Pythons – still fine for non‑critical
use but secrets is preferred because it returns tokens suitable for
password generation.
The construction guarantees at least one character from each required
category, then fills the remaining 16 slots from the full pool. Finally
the list is shuffled so the guaranteed characters appear in unpredictable
positions.
"""
try:
import secrets; choose = secrets.choice
except ImportError:
choose = random.choice
pool = string.ascii_uppercase + string.ascii_lowercase + string.digits + "!@#$%^&*()-_=+[]{}|;:,.<>?"
chars = ([choose(string.ascii_uppercase), choose(string.ascii_lowercase),
choose(string.digits), choose("!@#$%^&*()-_=+")] +
[choose(pool) for _ in range(16)])
random.shuffle(chars)
return "".join(chars)
def mypin(text: str) -> str:
"""Random 6-digit PIN."""
try:
import secrets;
# secrets.randbelow is cryptographically safe for small ranges.
return "".join(str(secrets.randbelow(10)) for _ in range(6))
except ImportError:
# Fallback to random.randint – still uniform, but not suitable for
# cryptographic secrecy.
return "".join(str(random.randint(0, 9)) for _ in range(6))
# ══════════════════════════════════════════════════════════════════════════════
# NETWORK
# ══════════════════════════════════════════════════════════════════════════════
def mylocalip(text: str) -> str:
"""
Local LAN IP (e.g. 192.168.1.46).
Falls back to '127.0.0.1' if no network is available.
"""
try:
# Routing trick: a UDP socket is created and we attempt to connect to a well‑known external address (8.8.8.8, Google DNS).
# No packet is actually sent – the OS merely determines the best local interface and IP address to reach that destination.
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(("8.8.8.8", 80));
# We read that local IP with getsockname().
# This is far more reliable than parsing ifconfig/ip output and handles multi‑homed machines correctly.
ip = s.getsockname()[0];
s.close()
return ip
except Exception:
return "127.0.0.1"
def net_serverip(text: str) -> str:
"""
Return the home server LAN IP for pasting into a command.
Hard‑coded because this is a personal constant.
"""
return "192.168.1.46"
def net_ssh(text: str) -> str:
"""
Open SSH session to 192.168.1.46 in a new Alacritty window.
This is a **side‑effect‑only** function: it spawns a terminal, connects
via SSH, and returns an empty string so the daemon does not attempt to
paste anything. A notification is shown to confirm the action.
The `exec bash` at the end keeps the window open even if the SSH session
ends (e.g., connection lost), preventing the terminal from vanishing
before the user can read an error message.
"""
_spawn(["alacritty", "--title", "SSH · nmaximo7@192.168.1.46",
"-e", "bash", "-c", "ssh nmaximo7@192.168.1.46; exec bash"])
_notify("SSH", "Connecting to 192.168.1.46…")
return ""
# ══════════════════════════════════════════════════════════════════════════════
# SYSTEM — power / session
# ══════════════════════════════════════════════════════════════════════════════
def sys_shutdown(text: str) -> str:
"""
Shut down the system (3‑second delay so you can cancel).
Cancel with: sudo shutdown -c
Returns "" — action only.
WHY a 3‑second delay?
Immediate poweroff would give no chance to abort. The delay allows
the user to run `sudo shutdown -c` in another terminal (or via a
quick launcher) if they triggered the shutdown accidentally.
The notification reminds them of the cancel command.
WHY `bash -c "sleep 3 && systemctl poweroff"` instead of separate cmds?
The process is spawned once and detaches. Chaining with `&&` ensures
the poweroff only happens if the sleep completes (unlikely to fail).
If the daemon or parent process dies, the spawned bash continues
because of `start_new_session=True`.
"""
_notify("Shutdown", "Powering off in 3 seconds…\nCancel: sudo shutdown -c")
# Use Popen with start_new_session to detach completely.
# The command runs in a sh/bash subshell.
subprocess.Popen(["bash", "-c", "sleep 3 && systemctl poweroff"],
start_new_session=True)
return ""
def sys_reboot(text: str) -> str:
"""Reboot the system (3-second delay)."""
_notify("Reboot", "Rebooting in 3 seconds…\nCancel: sudo shutdown -c")
subprocess.Popen(["bash", "-c", "sleep 3 && systemctl reboot"],
start_new_session=True)
return ""
def sys_logout(text: str) -> str:
"""
Log out of Niri and return to the greetd login screen.
Uses niri's IPC to quit the compositor gracefully.
`niri msg action quit` is equivalent to the default keybinding
(Mod+Shift+Q or whatever is configured). This avoids killing the
compositor forcefully and gives it a chance to clean up.
"""
_run(["niri", "msg", "action", "quit"])
return ""
def sys_suspend(text: str) -> str:
"""
Suspend (sleep) the system.
Relies on systemd-logind / polkit to allow the user to suspend without
a password. If that policy is not in place, the suspend will fail
silently (and the notification will already have been shown).
"""
_notify("Suspend", "Suspending…")
_run(["systemctl", "suspend"])
return ""
def sys_lock(text: str) -> str:
"""
Lock the screen via Noctalia IPC (qs ipc call lockScreen lock).
Falls back to swaylock if qs is unavailable.
Because swaylock must run continuously while the screen is locked,
we launch it with _spawn (detached, no wait). The user's compositor
should be configured to respect the locked state.
If neither tool is found, an error notification is shown.
"""
if shutil.which("qs"):
# qs is the Quintile Suite, a common Wayland session helper.
# The IPC call tells the session manager to lock the screen.
_run(["qs", "ipc", "call", "lockScreen", "lock"])
elif shutil.which("swaylock"):
# swaylock is a standalone screen locker compatible with wlroots.
# It must be started as a long‑running process, not waited on.
_spawn(["swaylock"])
else:
_notify("Lock", "No lock tool found (qs or swaylock)", error=True)
return ""
# ══════════════════════════════════════════════════════════════════════════════
# SYSTEM — information (paste to cursor)
# ══════════════════════════════════════════════════════════════════════════════
def sys_hostname(text: str) -> str:
"""This machine's hostname."""
# socket.gethostname() returns the FQDN or short name as configured.
return socket.gethostname()
def sys_osinfo(text: str) -> str:
"""
OS description from /etc/os-release: e.g. Arch Linux (x86_64)
Parses the freedesktop‑standard os‑release file. It is present on all
modern Linux distributions. Falls back to `os.uname().sysname` (e.g.,
"Linux") if the file is missing or unreadable.
"""
try:
info = {}
with open("/etc/os-release") as f:
# The file contains lines like:
# NAME="Arch Linux"
# PRETTY_NAME="Arch Linux"
# VERSION_ID=rolling
for line in f:
# Each line is like KEY="value" or KEY=value,
k, _, v = line.partition("=")
# Strip whitespace and quotes (the value is usually double‑quoted).
# .strip('"') removes surrounding double quotes, which are common in os-release.
info[k.strip()] = v.strip().strip('"')
# PRETTY_NAME is the canonical human‑friendly OS name. If it’s missing (rare), default to "Linux".
# os.uname().machine is the hardware architecture, e.g. "x86_64"
return f"{info.get('PRETTY_NAME', 'Linux')} ({os.uname().machine})"
except Exception:
return os.uname().sysname
def sys_uptime(text: str) -> str:
"""
System uptime: e.g. 'up 2 days, 03:45'
Reads the first value from /proc/uptime (seconds since boot).
Converts to days, hours, minutes. Omits the day part if < 1 day.
"""
try:
with open("/proc/uptime") as f:
secs = float(f.read().split()[0])
d = int(secs // 86400); h = int((secs % 86400) // 3600); m = int((secs % 3600) // 60)
# Pluralization: "1 day" vs "0 days, 2 days". We suppress the 's' for exactly 1.
return f"up {d} day{'s' if d != 1 else ''}, {h:02d}:{m:02d}" if d else f"up {h:02d}:{m:02d}"
except Exception:
return "uptime unavailable"
def sys_diskfree(text: str) -> str:
"""
Free / total disk space on / (e.g. 42.3 GB free / 500.0 GB total)
Uses os.statvfs() which works on any Unix. f_bavail is blocks available
to non‑root users, f_blocks is total blocks, f_frsize is fragment/block size.
The multiplication yields bytes, then we divide by 1024^3 for GiB.
"""
try:
st = os.statvfs("/")
# GB = bytes / (1024**3)
return (f"{st.f_bavail * st.f_frsize / 1024**3:.1f} GB free / "
f"{st.f_blocks * st.f_frsize / 1024**3:.1f} GB total")
except Exception:
return "disk info unavailable"
def sys_memfree(text: str) -> str:
"""
Available / total RAM from /proc/meminfo (e.g. 4.2 GB free / 16.0 GB total)
Parses the standard /proc/meminfo. Uses MemAvailable instead of MemFree
because it accounts for caches and buffers that can be reclaimed.
Divides by 1024^2 for MiB → GiB. Values are in kiB originally.
"""
try:
mem = {}
with open("/proc/meminfo") as f:
for line in f:
# Format: "MemTotal: 16384000 kB"
k, _, v = line.partition(":")
mem[k.strip()] = int(v.split()[0]) # first token is the number in kiB
return (f"{mem.get('MemAvailable', 0) / 1024**2:.1f} GB free / "
f"{mem.get('MemTotal', 0) / 1024**2:.1f} GB total")
except Exception:
return "memory info unavailable"
# ══════════════════════════════════════════════════════════════════════════════
# AUDIO — wpctl (WirePlumber native CLI)
# ══════════════════════════════════════════════════════════════════════════════
def _notify_volume():
"""
Read current volume from WirePlumber and send a notification with an ASCII bar.
WHY wpctl instead of pactl/pamixer?
WirePlumber is the current PipeWire session manager. Its native CLI
(wpctl) works without PulseAudio compatibility layers and gives direct
access to PipeWire objects. On a modern Wayland system, wpctl is the
recommended tool for scripting audio.
The notification shows the percentage and a 20‑character bar (█ = 5%).
If muted, the volume bar is still shown, but a different icon is used.
Errors are silently caught – if WirePlumber is unreachable, nothing appears.
"""
# wpctl get-volume outputs something like: "Volume: 0.42 [MUTED]"
r = subprocess.run(["wpctl", "get-volume", "@DEFAULT_AUDIO_SINK@"],
capture_output=True, text=True)
if r.returncode != 0:
return
try: # e.g. ["Volume:", "0.42", "[MUTED]"]
parts = r.stdout.strip().split()
pct = round(float(parts[1]) * 100) # 0.42 → 42
muted = "[MUTED]" in r.stdout
# Build the bar: 20 characters, filled proportionally.
bar = "█" * (pct // 5) + "░" * (20 - pct // 5)
icon = "🔇" if muted else ("🔊" if pct > 50 else "🔉")
_notify(f"{icon} {pct}%", bar)
except Exception:
pass # Not worth disturbing the user over a parsing glitch
def audio_mute(text: str) -> str:
"""
Toggle mute on the default audio sink and notify.
After toggling, we read the volume again to determine the new mute state
and display an appropriate icon and text. This gives immediate visual
feedback even when the only change is mute/unmute.
Returns "" because no text should be pasted.
"""
if not shutil.which("wpctl"):
_notify("Audio", "wpctl not found — sudo pacman -S wireplumber", error=True)
return ""
_run(["wpctl", "set-mute", "@DEFAULT_AUDIO_SINK@", "toggle"])
r = subprocess.run(["wpctl", "get-volume", "@DEFAULT_AUDIO_SINK@"],
capture_output=True, text=True)
if r.returncode == 0:
is_muted = "[MUTED]" in r.stdout
_notify("🔇 Muted" if is_muted else "🔊 Unmuted", r.stdout.strip())
return ""
def audio_volup(text: str) -> str:
"""
Volume +5% (capped at 100% by WirePlumber).
The limit flag ensures we never exceed 1.0 (100%). Without it the volume
could go to the hardware maximum, which may be undesirable.
After changing volume we call _notify_volume to show the result.
"""
if not shutil.which("wpctl"):
_notify("Audio", "wpctl not found", error=True); return ""
_run(["wpctl", "set-volume", "@DEFAULT_AUDIO_SINK@", "5%+", "--limit", "1.0"])
_notify_volume(); return ""
def audio_voldown(text: str) -> str:
"""Volume -5%. WirePlumber handles lower bound (mute at 0)."""
if not shutil.which("wpctl"):
_notify("Audio", "wpctl not found", error=True); return ""
_run(["wpctl", "set-volume", "@DEFAULT_AUDIO_SINK@", "5%-"])
_notify_volume(); return ""
# ══════════════════════════════════════════════════════════════════════════════
# MEDIA — playerctl (MPRIS D-Bus: Spotify, browsers, mpv, VLC…)
# ══════════════════════════════════════════════════════════════════════════════
def _playerctl(args):
"""
Convenience wrapper for MPRIS control via playerctl.
Returns the stripped stdout string on success, or an empty string on
failure (no player running, playerctl not installed). If playerctl is
missing entirely, a one‑time error notification is shown.
"""
if not shutil.which("playerctl"):
_notify("Media", "playerctl not found — sudo pacman -S playerctl", error=True)
return ""
r = subprocess.run(["playerctl"] + args, capture_output=True, text=True)
return r.stdout.strip() if r.returncode == 0 else ""
def media_playpause(text: str) -> str:
"""
Toggle play/pause on the active MPRIS player (Spotify, browser, mpv…).
After toggling, a small sleep is required because the status and metadata
may not be updated immediately by the player. 0.3 seconds is a reasonable
compromise between speed and reliability.
"""
import time as _t
_playerctl(["play-pause"]);
_t.sleep(0.3)
status = _playerctl(["status"])
track = _playerctl(["metadata", "--format", "{{artist}} — {{title}}"])
_notify("▶️ Playing" if status == "Playing" else "⏸️ Paused", track or "")
return ""
def media_next(text: str) -> str:
"""Skip to next track. A 0.5s sleep lets the player update before we fetch."""
import time as _t
_playerctl(["next"]);
_t.sleep(0.5)
_notify("⏭️ Next", _playerctl(["metadata", "--format", "{{artist}} — {{title}}"]))
return ""
def media_prev(text: str) -> str:
"""Go to previous track, same sleep for metadata."""
import time as _t
_playerctl(["previous"]); _t.sleep(0.5)
_notify("⏮️ Previous", _playerctl(["metadata", "--format", "{{artist}} — {{title}}"]))
return ""
def media_nowplaying(text: str) -> str:
"""
Return 'Artist — Title' for pasting (e.g. into a chat or document).
Non‑empty return → gets pasted at the cursor by the daemon.
If nothing is playing, we show a notification and return "" so nothing
is typed.
"""
track = _playerctl(["metadata", "--format", "{{artist}} — {{title}}"])
if not track or track.strip() == "—":
_notify("Now playing", "(nothing playing)"); return ""
return track
# ══════════════════════════════════════════════════════════════════════════════
# APPLICATIONS
# ══════════════════════════════════════════════════════════════════════════════
# All application launchers use _spawn to detach the new window from the daemon.
# They return "" because no text needs to be pasted – the side effect is sufficient.
# Names are hard‑coded to user preference; they can be customised freely.
def app_terminal(text: str) -> str:
"""Open a new Alacritty terminal."""
_spawn(["alacritty"]); return ""
def app_chrome(text: str) -> str:
"""Open Google Chrome."""
_spawn(["google-chrome-stable"]); return ""
def app_files(text: str) -> str:
"""Open Thunar file manager."""
_spawn(["thunar"]); return ""
def app_vscode(text: str) -> str:
"""Open VS Code."""
_spawn(["code"]); return ""
def app_keepass(text: str) -> str:
"""Open KeePassXC password manager."""
_spawn(["keepassxc"]); return ""
def app_gimp(text: str) -> str:
"""Open GIMP."""
_spawn(["gimp"]); return ""
# ══════════════════════════════════════════════════════════════════════════════
# CLIPBOARD
# ══════════════════════════════════════════════════════════════════════════════
def clip_clear(text: str) -> str:
"""
Clear the Wayland clipboard by writing an empty byte string to wl-copy.
wl-copy interprets an empty input as "clear the clipboard". We use the
same Popen + stdin.close() non‑blocking technique as in the daemon's
clipboard_write(). A notification confirms the action.
"""
proc = subprocess.Popen(["wl-copy"], stdin=subprocess.PIPE,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
start_new_session=True)
proc.stdin.write(b""); proc.stdin.close()
_notify("📋 Clipboard", "Cleared"); return ""
# ══════════════════════════════════════════════════════════════════════════════
# NIRI / DESKTOP
# ══════════════════════════════════════════════════════════════════════════════
def desktop_wallpaper(text: str) -> str:
"""
Select and apply a random wallpaper from ~/Pictures/Wallpapers.
The function searches the wallpaper directory for common image formats
(.jpg, .jpeg, .png, .webp). If no directory or no images exist, a desktop
notification is emitted and the function returns immediately.
Wallpaper application follows a two‑tier strategy:
1. **Primary path — swww**
If `swww` is installed *and* `swww-daemon` is currently running
(detected via `pgrep -x swww-daemon`), the function invokes:
swww img --transition-type --transition-duration 1 --transition-fps 60
A random transition effect is chosen from a small predefined set.
The call is made via `subprocess.Popen` to avoid blocking.
2. **Fallback path — swaybg**
If swww is unavailable or its daemon is not running, but `swaybg` is
installed, the function:
• kills any existing swaybg instance (`pkill swaybg`)
• spawns a new swaybg process in the background using `_spawn`
with `-m fill` to scale the image.
In all cases, a desktop notification is emitted with the chosen filename.
The function returns an empty string purely for convenience in menu-driven
scripts.
"""
import pathlib, random as _rnd
wall_dir = pathlib.Path.home() / "Pictures" / "Wallpapers"
exts = {".jpg", ".jpeg", ".png", ".webp"}
if not wall_dir.is_dir():
_notify("Wallpaper", f"Not found: {wall_dir}", error=True); return ""
images = [f for f in wall_dir.iterdir() if f.suffix.lower() in exts]
if not images:
_notify("Wallpaper", "No images in ~/Pictures/Wallpaper/", error=True); return ""
chosen = str(_rnd.choice(images))
# pgrep -x swww-daemon returns 0 if the daemon is running.
daemon_ok = subprocess.run(["pgrep", "-x", "swww-daemon"],
capture_output=True).returncode == 0
# If `swww` is installed *and* `swww-daemon` is currently running
if daemon_ok and shutil.which("swww"):
subprocess.Popen(["swww", "img", chosen,
"--transition-type", _rnd.choice(["fade","grow","wipe","wave"]),
"--transition-duration", "1", "--transition-fps", "60"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
# Fallback path — swaybg
elif shutil.which("swaybg"):
# It kills any existing swaybg instance
subprocess.run(["pkill", "swaybg"], capture_output=True)
# It spawns a new swaybg process in the background
_spawn(["swaybg", "-i", chosen, "-m", "fill"])
_notify("🖼️ Wallpaper", pathlib.Path(chosen).name); return ""
def niri_hotkeys(text: str) -> str:
"""Show the Niri hotkey overlay (Mod+Shift+Slash equivalent)."""
_run(["niri", "msg", "action", "show-hotkey-overlay"]); return ""
def niri_overview(text: str) -> str:
"""Toggle the Niri overview (bird's-eye view of all workspaces)."""
_run(["niri", "msg", "action", "toggle-overview"]); return ""
def niri_killwindow(text: str) -> str:
"""Close the currently focused window."""
_run(["niri", "msg", "action", "close-window"]); return ""