JustToThePoint English Website Version
JustToThePoint en español

Clipboard-based text expander for Wayland

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.

Image 

Introduction

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.

The Daemon Script (expand-daemon.py)

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 – expand-daemon.service

# ── 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
#

Configuration Files (JSON)

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.

Static Snippets (mysnippets.json)

{
    "_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." }
             */
        ]
    ]
}

Dynamic Function Triggers (myfunctions.json)

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" }
             */
        ]
    ]
}

Dynamic Functions (myfunctions.py)

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 ""
Bitcoin donation

JustToThePoint Copyright © 2011 - 2026 Anawim. ALL RIGHTS RESERVED. Bilingual e-books, articles, and videos to help your child and your entire family succeed, develop a healthy lifestyle, and have a lot of fun. Social Issues, Join us.

This website uses cookies to improve your navigation experience.
By continuing, you are consenting to our use of cookies, in accordance with our Cookies Policy and Website Terms and Conditions of use.