JustToThePoint English Website Version
JustToThePoint en español

Niri Desktop Utilities: Screenshots, Wallpaper, and Audio

A conversation is becoming an extreme competing event between two monologues in which the first person to draw a breath is considered the looser, Anawim, #justtothepoint.com

image info

Introduction

While the Wayland ecosystem provides a modern, secure graphical stack, it often requires a suite of specific command-line tools to manage the desktop state —taking screenshots, routing audio between headphones and speakers, or refreshing the visual vibe with a new wallpaper. Managing these tools individually can be disjointed and cumbersome.

In this article, we introduce myrofi_niri.py, a Python utility designed to act as the central control hub for the Niri compositor. This script unifies essential desktop utilities into a single, accessible interface. We explore how to leverage grim and slurp for high-fidelity screenshots, integrate with PipeWire via pactl for seamless audio sink switching, and manage wallpapers using swaybg, all accessible through a cohesive Rofi submenu.

#!/usr/bin/env python3
"""
myrofi_niri.py — Niri/Arch desktop utilities for the rofi launcher
────────────────────────────────────────────────────────────────────
The script is organised as a CLI dispatcher: it reads a single flag from sys.argv and runs the corresponding function. The six available actions are:

    --screenshot-region    interactive area selection with slurp + grim, save PNG to ~/Pictures/Screenshots/
    --screenshot-full      whole screen capture via Niri’s built‑in action (or grim fallback), save PNG to ~/Pictures/Screenshots/
    --hotkeys              Show Niri's built-in hotkey overlay
    --wallpaper            Pick a random wallpaper and and set it as wallpaper
    --audio                rofi menu for audio sink switching and mute toggle
    --submenu              a top‑level rofi menu that presents all the above (default if no flag given)

Usage from rofi-launcher.sh:
    python3 ~/dotfiles/myArch/admin/myrofi_niri.py --screenshot-region
    python3 ~/dotfiles/myArch/admin/myrofi_niri.py --screenshot-full
    python3 ~/dotfiles/myArch/admin/myrofi_niri.py --hotkeys
    python3 ~/dotfiles/myArch/admin/myrofi_niri.py --wallpaper
    python3 ~/dotfiles/myArch/admin/myrofi_niri.py --audio

    # Or one combined entry that opens a submenu
    python3 ~/dotfiles/scripts/myrofi_niri.py --submenu

Or you can also bind directly in niri config.kdl:
    Mod+S        { spawn "python3" "/home/nmaximo7/dotfiles/scripts/myrofi_niri.py" "--screenshot-region"; }
    Ctrl+Print   { spawn "python3" "/home/nmaximo7/dotfiles/scripts/myrofi_niri.py" "--screenshot-full"; }

The code is pure Python (no external packages) and relies on standard Arch/Wayland tools (all available in Arch official repos):
    pacman -S grim slurp python wl-clipboard pipewire wireplumber
    paru -S swww   (or pacman if available)
"""

import subprocess
import os
import sys
import time
import glob
import random
import datetime
import shutil


# ══════════════════════════════════════════════════════════════════════════════
# CONFIGURATION — edit these to match your setup
# ══════════════════════════════════════════════════════════════════════════════
# The config block is straightforward. A couple of observations:
# The path is expanded with os.path.expanduser.
# Ensure the theme file exists, otherwise rofi will fall back to its default style without error.
ROFI_THEME = os.path.expanduser("~/dotfiles/myArch/config/rofi/rounded-dark")
ROFI_FONT  = "hack 20"

# Screenshots are saved here with a timestamp filename
SCREENSHOT_DIR = os.path.expanduser("~/Pictures/Screenshots")

# Wallpapers are loaded from this directory
WALLPAPER_DIR = os.path.expanduser("~/Pictures/Wallpapers")

# Image file extensions recognised as wallpapers
WALLPAPER_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".bmp"}

# ══════════════════════════════════════════════════════════════════════════════
# LOW-LEVEL HELPERS
# ══════════════════════════════════════════════════════════════════════════════

def run(cmd: list[str], *, check: bool = False, capture: bool = False,
        input_text: str | None = None) -> subprocess.CompletedProcess:
    """
    Run an external command. A thin wrapper around subprocess.run that enforces safe list‑based command invocation.
    Notice that a bare * means that all parameters after this point must be passed by keyword (name = value), not position.
    Example:
    result = run(["ls", "-l"], capture=True) or run(["rm", "-rf", "/"], check=True, capture=True)
    print(result.stdout)

    Args:
        cmd:        Command as a list of strings — never passed through a shell,
                    so no quoting or escaping is needed, e.g., ["ls", "-l"]
        check:      If True, raise CalledProcessError on non-zero exit code.
        capture:    If True, capture stdout and stderr (available as .stdout/.stderr).
        input_text: Optional string sent to the process's stdin.

    Returns:
        CompletedProcess with .returncode, .stdout, .stderr.
    """
    # This initializes the dictionary that will be passed to subprocess.run().
    # This creates a normal Python dictionary named kwargs. Later, the function calls: subprocess.run(cmd, **kwargs)
    # The **kwargs syntax means: Expand this dictionary into keyword arguments.
    kwargs: dict = {"check": check}
    # If capturing output, set the right flags
    if capture:
        kwargs["stdout"] = subprocess.PIPE # stdout = PIPE, meaning capture stdout
        kwargs["stderr"] = subprocess.PIPE # stderr = PIPE, meaning capture stderr
        kwargs["text"]   = True # decode bytes into Python strings
        # This means the returned CompletedProcess will have .stdout and .stderr as strings.
    if input_text is not None:
        # If sending input, configure stdin.
        kwargs["input"] = input_text # Send input_text to the process's stdin
        kwargs["text"]  = True # Ensures the input is treated as text, not bytes
    # Finally, run the command
    # This executes the command with the assembled keyword arguments.
    return subprocess.run(cmd, **kwargs)
    # The return value is a CompletedProcess object containing: .args, .returncode, .stdout (if captured), .stderr (if captured)

def notify(title: str, body: str = "", *, error: bool = False) -> None:
    """
    Send a desktop notification via notify-send with appropriate icons and timeouts.

    error=True uses the error icon and a longer timeout.
    """
    # Falls back silently if notify-send is not installed.
    if not shutil.which("notify-send"):
        return
    icon = "dialog-error" if error else "dialog-information"
    timeout = "6000" if error else "3000"
    run(["notify-send", "-i", icon, "-t", timeout, title, body])


def rofi_menu(options: list[str], prompt: str = "  Select",
              lines: int | None = None) -> str | None:
    """
    Display a Rofi dmenu with given options and return selected item.

    Args:
        options: List of strings to display in menu
        prompt: Text shown before options (default: "  Select")
        lines: Number of lines to show

    Returns:
        Selected string if confirmed, None if canceled/escaped

    Behavior:
        Case-insensitive matching (-i flag)
        Direct string output via -format s so the returned value directly identifies what was chosen, regardless of order.
        Uses custom theme/font from ROFI_THEME/ROFI_FONT
    """
    n = lines if lines is not None else min(len(options), 20)
    # The lines parameter defaults to min(len(options), 20), which keeps the menu from becoming absurdly tall.
    result = run(
        [
            "rofi", "-dmenu", "-i",
            "-p", prompt,
            "-no-custom", # It prevents the user from typing arbitrary input; only pre‑defined options can be selected.
            "-format", "s",  # It returns the selected string rather than an index. This is robust even if the option list changes order.
            "-lines",  str(n),
            "-theme",  ROFI_THEME,
            "-font",   ROFI_FONT,
        ],
        capture=True,
        input_text="\n".join(options),
    )
    if result.returncode != 0:
        return None
    selected = result.stdout.strip()
    return selected if selected else None

def screenshot_filename() -> str:
    """
    Return a full path for a new screenshot file in SCREENSHOT_DIR.
    Format: screenshot_YYYY-MM-DD_HH-MM-SS.png
    """
    # Creates the directory SCREENSHOT_DIR if it does not exist.
    os.makedirs(SCREENSHOT_DIR, exist_ok=True)
    ts = datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
    return os.path.join(SCREENSHOT_DIR, f"screenshot_{ts}.png")


# ══════════════════════════════════════════════════════════════════════════════
# FEATURE 1 — SCREENSHOTS
# ══════════════════════════════════════════════════════════════════════════════
#
# Interactive region screenshot using grim+slurp.
#
def screenshot_region() -> None:
    """
    Let the user drag a region with slurp, then capture it with grim.
    grim: Direct Wayland screen capture (avoids X11 limitations)
    slurp: Native region selector (respects Wayland compositors)

    Flow:
        1. Sleep briefly so rofi fully closes before the overlay appears.
        2. Get selection geometry via slurp. The user drags a rectangle.
           If the user presses Escape, slurp returns exit code 1 → we abort.
        3. Capture region with grim.
        4. Send a desktop notification with the filename.
    """
    # Dependency checks
    if not shutil.which("grim"):
        notify("Screenshot", "grim not found — install: sudo pacman -S grim", error=True)
        return
    if not shutil.which("slurp"):
        notify("Screenshot", "slurp not found — install: sudo pacman -S slurp", error=True)
        return

    # 1. Let rofi release its Wayland keyboard grab before slurp tries to grab it.
    # Without this, slurp may receive no input because rofi still holds focus.
    time.sleep(0.3)

    # 2. Run slurp to get the region coordinates
    slurp_result = run(["slurp"], capture=True)
    if slurp_result.returncode != 0 or not slurp_result.stdout.strip():
        # If the user presses Escape, slurp returns exit code 1, then we abort silently
        return

    # Get region coordinates and a timestamped screenshot path in SCREENSHOT_DIR.
    geometry = slurp_result.stdout.strip()
    outfile  = screenshot_filename()

    # 3. Capture only that rectangle with grim
    result = run(["grim", "-g", geometry, outfile], capture=True)
    # 4. Send a desktop notification
    if result.returncode == 0:
        notify("Screenshot", f"Saved: {os.path.basename(outfile)}")
    else:
        notify("Screenshot", f"grim error: {result.stderr.strip()}", error=True)


def screenshot_full() -> None:
    """
    This is a Wayland screenshot helper designed for systems running the Niri compositor. It tries to use:
    - Niri’s built‑in screenshot action (preferred)
    - If unavailable, grim (fallback)
    - If neither exists, show a notification error.

    It returns nothing, but it takes a screenshot.
    """
    # Step 1 — Check if Niri exists
    if not shutil.which("niri"):
        # Step 2 — Fallback to grim
        if not shutil.which("grim"):
            # If grim is also missing, we cannot take screenshots at all.
            notify("Screenshot", "Neither niri nor grim found", error=True)
            # Show a notification and exit
            return

        # Step 3 — Delay before screenshot
        # If the screenshot was triggered by a keybinding or UI action, the delay ensures the UI has time to hide overlays, menus, or the notification itself
        time.sleep(0.3)
        # Step 4 — Generate filename
        outfile = screenshot_filename()
        # Step 5 — Run grim
        result  = run(["grim", outfile], capture=True)
        # grim with a single argument captures all monitors
        # capture=True means we want .stdout and .stderr in the result

        # Step 6 — Check grim result
        if result.returncode == 0:
            # If grim succeeded → show success notification.
            notify("Screenshot", f"Saved: {os.path.basename(outfile)}")
        else:
            # If grim failed → show error notification with stderr
            notify("Screenshot", f"grim error: {result.stderr.strip()}", error=True)
        # We stop here because the fallback path is complete.
        return

    # Step 7. If Niri is available.
    # Use Niri's own screenshot-screen action — saves to ~/Pictures/Screenshots/
    time.sleep(0.3)
    result = run(["niri", "msg", "action", "screenshot-screen"], capture=True)
    # Step 8 — Check Niri result.
    if result.returncode == 0:
        notify("Screenshot", "Full screen saved to ~/Pictures/Screenshots/")
    else:
        notify("Screenshot", f"niri msg error: {result.stderr.strip()}", error=True)

# ══════════════════════════════════════════════════════════════════════════════
# FEATURE 2 — HOTKEY OVERLAY
# ══════════════════════════════════════════════════════════════════════════════
#
# Niri includes a built‑in hotkey overlay that displays all currently configured
# keybindings in a semi‑transparent window on top of the screen. This is the same
# overlay that appears when you press Mod+Shift+Slash interactively.
#
# The overlay is generated directly from your niri config.kdl, so it always
# reflects your actual setup —there's no need to maintain a separate cheatsheet.
#

def show_hotkeys() -> None:
    """
    Display Niri's built-in keybinding reference overlay.

    This is equivalent to pressing Mod+Shift+Slash while inside Niri.
    The overlay stays on screen until the user dismisses it manually.
    """
    # If the niri binary is not found (e.g., running under a different compositor),
    # a notification is shown and the function exits gracefully.
    if not shutil.which("niri"):
        notify("Hotkeys", "niri command not found", error=True)
        return

    # We trigger it programmatically via `niri msg action show-hotkey-overlay`.
    # The user can then dismiss it by pressing any key (usually Escape).
    result = run(["niri", "msg", "action", "show-hotkey-overlay"], capture=True)
    if result.returncode != 0:
        notify("Hotkeys", f"niri msg error: {result.stderr.strip()}", error=True)


# ══════════════════════════════════════════════════════════════════════════════
# FEATURE 3 — WALLPAPER
# ══════════════════════════════════════════════════════════════════════════════
#
# We use swaybg, the simple Wayland background setter from the sway project.
# swaybg does not require a daemon; it runs as a single background process.
#
def _find_wallpapers() -> list[str]:
    """
    Return a sorted list of full paths to image files in WALLPAPER_DIR.

    Only files with extensions listed in WALLPAPER_EXTENSIONS are included.

    """
    if not os.path.isdir(WALLPAPER_DIR):
        # Returns an empty list if the directory does not exist or ...
        return []
    files = []
    for entry in os.scandir(WALLPAPER_DIR):
        # ... contains no images with the "right"extensions".
        if entry.is_file() and os.path.splitext(entry.name)[1].lower() in WALLPAPER_EXTENSIONS:
            files.append(entry.path)
    return sorted(files)

def set_wallpaper(path: str) -> None:
    """
    Set or Apply the wallpaper using swaybg.

    swaybg flags used:
        -i    Image file to display
        -m fill     Scale the image to fill the screen, preserving aspect ratio.

    """
    # Kill any previous swaybg instance so only one wallpaper is active.
    subprocess.run(
        ["pkill", "-x", "swaybg"],
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL,
        check=False,
    )

    # Launch new swaybg instance
    # We launch it with start_new_session=True so it survives our script's exit.
    # We suppress stdout/stderr to avoid polluting the journal.
    # `close_fds=True` ensures no unintended file descriptors are inherited.
    proc = subprocess.Popen(
        ["swaybg", "-i", path, "-m", "fill"],
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL,
        close_fds=True,
        start_new_session=True,
    )

    # Verify that the process started successfully. `poll()` returns None if
    # the process is still running, otherwise it returns the exit code.
    if proc.poll() is None:
        notify("Wallpaper", os.path.basename(path))
    else:
        notify("Wallpaper", "Failed to start swaybg", error=True)

def wallpaper_random() -> None:
    """
    Select a random image from WALLPAPER_DIR and apply it as the wallpaper.

    If no images are found, the user is notified with an error.
    """
    images = _find_wallpapers()
    if not images:
        notify("Wallpaper", f"No images found in {WALLPAPER_DIR}", error=True)
        return
    set_wallpaper(random.choice(images))

# ══════════════════════════════════════════════════════════════════════════════
# FEATURE 4 — AUDIO OUTPUT SWITCHER + MUTE TOGGLE
# ══════════════════════════════════════════════════════════════════════════════
#
# Audio stack on Arch with Niri:
#   PipeWire  — the core audio server (replaces PulseAudio)
#   WirePlumber — the session manager (handles routing and policy)
#   pactl     — PulseAudio-compatible CLI
#   wpctl     — WirePlumber native CLI (preferred for volume/mute)
#
#
# Sinks are where applications (like browsers or media players) send their audio to produce sound.
# Basically, a sink is an audio output device or a destination where audio data is sent to be played
# such as speakers, headphones, or an HDMI.
#
# WHY pactl for sink listing and switching:
#   - `pactl list sinks` provides both the internal sink name and a human‑readable
#     description, which we use to build the rofi menu.
#   - `pactl set-default-sink ` changes the **default sink** that new audio
#     streams will use when they are created. It does **not** move streams that are
#     already playing.
#   - To move existing streams (e.g., a YouTube video currently playing in Chrome),
#     we must explicitly call `pactl move-sink-input  ` for
#     each active stream. The script does this in a loop to ensure all current
#     audio follows the switch.
#
#
# WHY wpctl for mute toggle:
#   - `wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle` is the WirePlumber native way
#     to mute/unmute the default output. The `@DEFAULT_AUDIO_SINK@` alias always
#     points to the currently active default sink, so we don't need to know its
#     exact name.
#   - We also use `wpctl get-volume @DEFAULT_AUDIO_SINK@` to read the volume level
#     and mute state of the default sink, and display an accurate notification.
#
# Sink identification details:
#   - `pactl list sinks` (long form) output looks like:
#         Sink #42
#             Name: alsa_output.pci-0000_2d_00.1.hdmi-stereo
#             Description: GM204 High Definition Audio Controller Digital Stereo
#             ...
#   - We parse this block‑structured output to extract both the internal name
#     (used for `set-default-sink` and `move-sink-input`) and the friendly
#     description (shown in the rofi menu).
#   - The default sink is determined separately via `pactl get-default-sink`.

def _get_sinks() -> list[dict]:
    """
    Enumerate all PipeWire/PulseAudio sinks available on the system.

    Returns list[dict]
        A list of dictionaries, one per sink. Each dictionary contains:
        name        — internal sink name, e.g. "alsa_output.pci-0000_0b_00.4.analog-stereo".
        description —  A human‑readable label for the sink, as shown in tools like
            `pavucontrol`, e.g. "Starship/Matisse HD Audio Controller Analog Stereo".
        is_default  —  True if this sink is currently the system‑wide default output sink.

    Notes
    • We intentionally parse the *long* form of `pactl list sinks` rather than
      `pactl list short sinks`, because only the long form includes the
      "Description:" field.

    • The function gracefully handles missing `pactl` binaries or command
      failures by returning an empty list.

    • Determining the default sink is done via `pactl get-default-sink`,
      which returns the internal sink name.

    Returns an empty list if:
        - `pactl` is not installed,
        - the `pactl list sinks` command fails,
        - no sinks are detected.

    """
    # Bail out early if pactl isn't installed
    if not shutil.which("pactl"):
        return []

    # Run pactl and capture its stdout
    result = run(["pactl", "list", "sinks"], capture=True)
    if result.returncode != 0:
        return []

    sinks     = [] # list of sink dicts we'll build
    current   = {} # the sink dict we're currently populating
    in_sink   = False # are we currently inside a "Sink #N" block?

    # Iterate over each line of the pactl output
    for line in result.stdout.splitlines():
        stripped = line.strip()

        # --- Detect the start of a new sink block ---
        # The long output uses "Sink #N" as a header for each sink.
        if stripped.startswith("Sink #"):
            # Before we start parsing a new sink, we must save the one we just
            # finished (if any). On the very first "Sink #N" line we encounter,
            # `current` is still an empty dict ({}), so the condition `if current`
            # evaluates to False and we skip appending an empty entry.
            if current:
                sinks.append(current)
            # Prepare a fresh dict for the incoming sink data
            current = {"name": "", "description": "", "is_default": False}
            # Mark that we are now inside a sink block, so we know to parse
            # subsequent lines as part of this sink's properties.
            in_sink = True
            continue # move to the next line (the "Sink #N" header itself has no data we need)

        # --- Skip lines that are not part of a sink block ---
        # This condition handles two scenarios:
        # 1. `not in_sink` – We haven't encountered a "Sink #N" line yet.
        #    (e.g., the very beginning of the output may contain a "Sinks:" header
        #     or blank lines). We ignore these.
        # 2. `not current` – Should never happen if `in_sink` is True, but this
        #    is a defensive safety check to avoid accessing an uninitialized dict.
        if not in_sink or not current:
            continue

        # --- Parse relevant properties within the current sink block ---
        # The line "Name: alsa_output.pci-0000_2d_00.1.hdmi-stereo"
        if stripped.startswith("Name:"):
            # Split at the first colon and take the right side, stripping whitespace
            current["name"] = stripped.split(":", 1)[1].strip()

        # The line "Description: Starship/Matisse HD Audio Controller Analog Stereo"
        elif stripped.startswith("Description:"):
            current["description"] = stripped.split(":", 1)[1].strip()

        # Note: We intentionally ignore other properties like "State:", "Volume:",
        # "Mute:", etc. We only need the name and description for the rofi menu.
        # The default sink status is determined separately with `pactl get-default-sink`

    # --- After the loop, save the last sink we were parsing ---
    # The loop ends when we run out of lines. The final sink block we were
    # building (`current`) hasn't been appended yet, because we only append
    # when we see the *next* "Sink #N" header. This line adds that last sink.
    if current:
        sinks.append(current)

    # --- Mark which sink is the current default ---
    # `pactl get-default-sink` returns the internal name of the default sink.
    default_result = run(["pactl", "get-default-sink"], capture=True)
    if default_result.returncode == 0:
        default_name = default_result.stdout.strip()
        for s in sinks:
            if s["name"] == default_name:
                s["is_default"] = True

    return sinks

def _get_mute_state() -> bool | None:
    """
    Return True if the default sink is currently muted, False if unmuted.
    Returns None if the state cannot be determined.

    wpctl status outputs a tree; we use wpctl get-volume which is simpler:
        wpctl get-volume @DEFAULT_AUDIO_SINK@
    outputs: "Volume: 0.65" or "Volume: 0.65 [MUTED]"
    """
    #  wpctl is part of wireplumber; if it's missing, we can't determine mute state.
    if not shutil.which("wpctl"):
        return None

    # Run `wpctl get-volume @DEFAULT_AUDIO_SINK@`.
    # The special token `@DEFAULT_AUDIO_SINK@` resolves to the current default sink,
    # so we don't need to know its exact name.
    result = run(["wpctl", "get-volume", "@DEFAULT_AUDIO_SINK@"], capture=True)

    # If the command failed (e.g., PipeWire isn't running, or the default sink
    # doesn't exist), we cannot determine the mute state.
    if result.returncode != 0:
        return None

    # The output is a single line like "Volume: 0.65" or "Volume: 0.65 [MUTED]"
    # We simply check for the presence of the substring "[MUTED]".
    return "[MUTED]" in result.stdout

def _get_sink_inputs() -> list[str]:
    """
    Return a list of all currently active sink-input IDs (audio streams).

    A sink-input is one audio stream connected to a sink — e.g. Chrome tab,
    mpv, Spotify. Each has a numeric ID that pactl move-sink-input needs.

    pactl list short sink-inputs produces lines like:
        42  alsa_output.pci-0000_2d_00.1  PipeWire  s16le 2ch 44100Hz  RUNNING
    We extract only the first field (the ID).
    """

    # Run `pactl list short sink-inputs` and capture its output.
    # The "short" format gives one line per stream, perfect for parsing.
    result = run(["pactl", "list", "short", "sink-inputs"], capture=True)

    # If the command fails (pactl missing, PipeWire not running, etc.), return an empty list.
    if result.returncode != 0:
        return []

    ids = []
    # Iterate over each line of output. Example line:
    # "42  alsa_output.pci-0000_2d_00.1  PipeWire  s16le 2ch 44100Hz  RUNNING"
    for line in result.stdout.splitlines():
        # Split the line on whitespace. The first token is the stream ID.
        parts = line.split()
        # If the line is not empty (should always be true for real output, but
        # defensive programming never hurts), append the ID to our list.
        if parts:
            ids.append(parts[0])

    # Return the list of stream IDs (as strings). These are used later in
    # `switch_audio_sink()` with `pactl move-sink-input  `.
    return ids


def switch_audio_sink(sink_name: str) -> None:
    """
    Switch ALL audio output to the given sink — both new and existing streams.

    Why pactl instead of wpctl for moving streams:
        wpctl does not expose a move-stream command.
        pactl move-sink-input is the standard PulseAudio/PipeWire way to do this
        and works reliably with pipewire-pulse (the PulseAudio compatibility layer
        that PipeWire provides on Arch).
    """
    # Step 1: Change the default sink for *future* audio streams.
    # `pactl set-default-sink ` updates the default-sink setting stored by
    # PipeWire. Any application that starts *after* this command will use the new
    # sink automatically. However, applications already running and playing audio
    # will *not* be moved — they remain connected to their original sink.
    result = run(["pactl", "set-default-sink", sink_name], capture=True)
    if result.returncode != 0:
        # If setting the default sink fails, something is seriously wrong (e.g., sink
        # name doesn't exist, PipeWire is down). Notify the user and abort.
        notify("Audio", f"Could not set default sink: {result.stderr.strip()}", error=True)
        return

    # Step 2: Move every currently‑playing audio stream to the new sink.
    # This is what actually moves Chrome — it has an active sink-input right now.
    # pactl move-sink-input    (for every stream)
    # Moves each currently-playing audio stream to the new sink.
    # This is the step that actually makes Chrome switch mid-session.
    # Without it, Chrome keeps playing on the old sink until restarted.

    # `_get_sink_inputs()` returns a list of numeric IDs for all active streams
    # (e.g., "42", "47"). These IDs are passed to `pactl move-sink-input`.
    stream_ids  = _get_sink_inputs()
    moved       = 0 # count of successfully moved streams (for debugging)
    failed      = [] # list of IDs that could not be moved (we try to move them, but fail)

    for sid in stream_ids:
        # Move this specific stream (sid) to the target sink (sink_name).
        r = run(["pactl", "move-sink-input", sid, sink_name], capture=True)
        if r.returncode == 0:
            moved += 1
        else:
            failed.append(sid)

    # If any streams failed to move, log the IDs to stderr.
    # Usually these are internal monitor streams or virtual devices that don't
    # support being moved. This is not a fatal error — the user still hears
    # audio from the correct device for most applications.
    if failed:
        # Some streams could not be moved — usually virtual/monitor streams,
        # not real application audio. Log them but we don't treat this as fatal.
        print(f"myrofi_niri: could not move sink-inputs {failed}", file=sys.stderr)

def toggle_mute() -> None:
    """
    Toggle mute on the default audio sink using wpctl.
    """
    # Check that wpctl is available (part of wireplumber).
    if not shutil.which("wpctl"):
        notify("Audio", "wpctl not found — install: sudo pacman -S wireplumber", error=True)
        return

    # Execute the mute toggle command.
    # `wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle` does exactly what it says:
    # it flips the mute state of the current default audio output.
    # The `@DEFAULT_AUDIO_SINK@` token is a special WirePlumber alias that
    # always points to the sink currently marked as default, so we don't need
    # to know its name.
    result = run(["wpctl", "set-mute", "@DEFAULT_AUDIO_SINK@", "toggle"], capture=True)
    if result.returncode != 0:
        notify("Audio", f"wpctl error: {result.stderr.strip()}", error=True)
        return

    # Determine the new mute state for an accurate notification.
    # `_get_mute_state()` reads the current mute status by parsing the output
    # of `wpctl get-volume @DEFAULT_AUDIO_SINK@`. It returns True if muted,
    # False if unmuted, or None if the state couldn't be determined.
    muted = _get_mute_state()
    if muted is True:
        notify("Audio", "Muted 🔇")
    elif muted is False:
        notify("Audio", "Unmuted 🔊")
    else:
        # Fallback in case we couldn't read the state (should be rare).
        notify("Audio", "Mute toggled")

def audio_menu() -> None:
    """
    Show a rofi menu with:
        - One entry per available audio sink (headphones / HDMI monitor / others)
        - A mute/unmute toggle at the top

    The current default sink is marked with ● so you can see at a glance
    which device is active before making a change.
    """
    # Step 1: Gather all available audio sinks.
    # `_get_sinks()` returns a list of dicts, each containing:
    #   - "name"        : internal sink identifier (used for switching)
    #   - "description" : human‑readable label (shown in the rofi menu)
    #   - "is_default"  : boolean indicating if this is the current default sink
    sinks = _get_sinks()
    if not sinks:
        # If no sinks are found, PipeWire is probably not running or pactl is
        # missing. Notify the user and abort.
        notify("Audio", "No audio sinks found. Is PipeWire running?", error=True)
        return

    # Step 2: Determine the current mute state for the toggle entry.
    # `_get_mute_state()` returns True if muted, False if unmuted, or None if
    # undetermined. We use this to show a context‑aware icon (🔇 or 🔊).
    muted = _get_mute_state()
    # Choose the appropriate icon and label for the mute/unmute toggle entry.
    # If `muted` is False, the sink is currently **unmuted**, so the action
    # will mute it, so we show "🔇  Mute / Unmute".
    # If `muted` is True (or None), the action will unmute it, so we show "🔊  Mute / Unmute".
    mute_label = "🔇  Mute / Unmute" if muted is False else "🔊  Mute / Unmute"

    # Step 3: Build the list of rofi menu options.
    # The first option is always the mute/unmute toggle.
    options  = [mute_label]

    # We need to map the displayed string back to the internal sink name.
    # `sink_map` is a dictionary: display_line → sink_name.
    # When the user selects an entry, we look up the sink name from this map.
    sink_map = {}

    for s in sinks:
        # Mark the current default sink with a ● symbol for visual feedback.
        # Non‑default sinks are indented with three spaces to align the text.
        marker = "●  " if s["is_default"] else "   "
        # Use the human‑readable description (e.g., "Starship/Matisse HD Audio…")
        # as the display string. This is much friendlier than the internal name.
        label = f"{marker}{s['description']}"
        options.append(label)
        # Store the mapping (display_line → sink_name) so we know which sink to switch to
        # when this label is selected.
        sink_map[label] = s["name"]

    # Step 4: Show the rofi menu and wait for the user's selection.
    # `rofi_menu()` returns the exact string the user selected, or None if they
    # pressed Escape.
    selected = rofi_menu(options, prompt="  Audio", lines=len(options))
    if selected is None:
        # User cancelled – do nothing.
        return

    # Step 5: Handle the user's selection.
    # Case A: The user picked the mute/unmute toggle.
    if "Mute" in selected or "Unmute" in selected:
        toggle_mute()
    # Case B: The selected string exactly matches a key in sink_map.
    # This is the normal path when the user chooses a sink entry.
    elif selected in sink_map:
        sink_name = sink_map[selected]
        switch_audio_sink(sink_name)
        # Find the friendly description for the notification.
        # `next(…)` efficiently retrieves the description from the original
        # `sinks` list. If for some reason it's missing, fall back to the
        # internal name.
        desc = next((s["description"] for s in sinks if s["name"] == sink_name), sink_name)
        # This means “Loop over all s in sinks, and for the one whose name matches sink_name, yield s["description"].”
        # If the sink exists, this generator yields its description
        # If it does not, it yields the default value we pass as the second argument, sink_name, the internal name.
        notify("Audio", f"Switched to: {desc}")
    else:
        # Case C: Direct lookup failed — fallback substring matching.
        # We iterate over all entries in `sink_map` and try to match the selected
        # string against a cleaned version of the label.
        for label, name in sink_map.items():
            # Remove leading/trailing whitespace, then remove the ● marker if present.
            # Then check if this cleaned label is a substring of the selected text,
            # or if the selected text is a substring of the original label.
            if label.strip().lstrip("●").strip() in selected or selected in label:
                switch_audio_sink(name)
                desc = next((s["description"] for s in sinks if s["name"] == name), name)
                notify("Audio", f"Switched to: {desc}")
                break # Stop after the first match (should be only one)

# ══════════════════════════════════════════════════════════════════════════════
# FEATURE 5 — VOLUME CONTROL
# ══════════════════════════════════════════════════════════════════════════════
#
# WHY wpctl instead of pactl for volume:
#   wpctl is WirePlumber's native CLI. It operates directly on PipeWire objects
#   and is the recommended way to control volume on modern Arch+PipeWire setups.
#   @DEFAULT_AUDIO_SINK@ always resolves to the current default output — no
#   need to know the sink name.
#
# WHY --limit 1.0 on volume up:
#   Without --limit, wpctl allows volume above 100% (boosted output).
#   --limit 1.0 caps at exactly 100% so we never accidentally overdrive speakers.

def volume_up(step: str = "5%") -> None:
    """Increase default sink volume by `step` (default 5%)."""
    # `wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%+ --limit 1.0`
    # The `5%+` syntax means "increase by 5%". The `--limit 1.0` prevents the
    # volume from exceeding 100% (1.0 in linear scale).
    result = run(["wpctl", "set-volume", "@DEFAULT_AUDIO_SINK@",
                  f"{step}+", "--limit", "1.0"], capture=True)
    if result.returncode == 0:
        _show_volume_notification()
    else:
        notify("Volume", f"wpctl error: {result.stderr.strip()}", error=True)


def volume_down(step: str = "5%") -> None:
    """Decrease default sink volume by `step` (default 5%)."""
    # `wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%-`
    # The `5%-` syntax means "decrease by 5%". No limit is needed because
    # volume cannot go below 0% (muted) anyway.
    result = run(["wpctl", "set-volume", "@DEFAULT_AUDIO_SINK@",
                  f"{step}-"], capture=True)
    if result.returncode == 0:
        _show_volume_notification()
    else:
        notify("Volume", f"wpctl error: {result.stderr.strip()}", error=True)

def _get_volume_pct() -> int | None:
    """
    Return the current default sink volume as an integer percentage (0-100).
    Returns None if the value cannot be read.
    """
    # Step 1: Query the current volume from WirePlumber.
    # `wpctl get-volume @DEFAULT_AUDIO_SINK@` returns a single line like:
    #   "Volume: 0.65"
    # or if muted:
    #   "Volume: 0.65 [MUTED]"
    result = run(["wpctl", "get-volume", "@DEFAULT_AUDIO_SINK@"], capture=True)
    # If the command fails (e.g., wpctl missing, PipeWire not running, or the
    # default sink doesn't exist), we cannot determine the volume. Return None
    # to signal failure to the caller.
    if result.returncode != 0:
        return None

    # Step 2: Parse the volume float from the output.
    try:
        # Split the line on whitespace. Example: ["Volume:", "0.65"] or
        # ["Volume:", "0.65", "[MUTED]"]
        parts = result.stdout.strip().split()
        # The volume value is always the second token (index 1).
        # Convert the string to a float, multiply by 100 to get the percentage,
        # and round it to the nearest integer.
        pct = round(float(parts[1]) * 100)
        return pct
    except (IndexError, ValueError):
        # If `parts` doesn't have at least two elements (unexpected output)
        # or if `parts[1]` isn't a valid float (e.g., "unknown"), we can't
        # parse the volume. Return None gracefully.
        return None

def _show_volume_notification() -> None:
    """Send a notification showing the current volume level as a bar."""
    # Step 1: Gather current volume and mute state.
    # Get the volume percentage (0-100) from the helper above.
    pct     = _get_volume_pct()
    # Get the mute state (True = muted, False = unmuted, None = unknown).
    muted   = _get_mute_state()

    # If we couldn't read the volume percentage, there's nothing meaningful to
    # show (or do) in the notification. Exit quietly.
    if pct is None:
        return

    # Step 2: Build an ASCII bar representing the volume level: ████████░░░░░░░░░ 65%
    # We divide the percentage by 5 to get a 0–20 scale (each segment = 5%).
    # For example, 65% → 13 filled segments, 7 empty segments, 20 segments total
    filled  = round(pct / 5)
    # Create the bar string using the block character '█' for filled segments
    # and '░' for empty ones. Total length is 20 characters.
    bar     = "█" * filled + "░" * (20 - filled)

    # Step 3: Choose an appropriate speaker icon based on volume and mute state.
    # If muted, always show the mute icon (🔇).
    # If unmuted and volume > 50%, show high-volume icon (🔊).
    # If unmuted and volume > 0%, show medium-volume icon (🔉).
    # If unmuted and volume == 0%, show low-volume icon (🔈).
    icon    = "🔇" if muted else ("🔊" if pct > 50 else "🔉" if pct > 0 else "🔈")
    # This is equivalent to:
    # if muted:
    #       icon = "🔇"
    # elif pct > 50:
    #       icon = "🔊"
    # elif pct > 0:
    #       icon = "🔉"
    # else:
    #       icon = "🔈"

    # Step 4: Send the desktop notification.
    # The `notify()` helper sends a notification with a title and body.
    # Title: icon + percentage (e.g., "🔊  65%")
    # Body:  the ASCII bar (e.g., "█████████████░░░░░░░")
    notify(f"{icon}  {pct}%", bar)

# ══════════════════════════════════════════════════════════════════════════════
# FEATURE 6 — MEDIA PLAYER CONTROL (Spotify, mpv, browsers, etc.)
# ══════════════════════════════════════════════════════════════════════════════
#
# playerctl uses the MPRIS D-Bus interface — the standard Linux protocol for
# media player control. Any player that implements MPRIS works automatically, e.g.,
# Spotify, Firefox / Chrome (playing YouTube, SoundCloud, etc.), mpv, VLC, Rhythmbox, Shortwave, etc.
#
# playerctld (the daemon) tracks which player was most recently active and
# routes commands to it. Start it with:
#   spawn-at-startup "playerctld"   in niri config.kdl
#
# playerctl --player=spotify targets Spotify specifically.
# Without --player it targets the most recently active player.

def _playerctl(args: list[str], player: str | None = None) -> bool:
    """
    Run a playerctl command. Returns True on success.
    If player is given, targets only that player.
    """
    # Step 1: Ensure playerctl is installed.
    # playerctl is the command‑line client for the MPRIS D‑Bus interface.
    # If it's missing, none of the media control features can work.
    if not shutil.which("playerctl"):
        notify("Media", "playerctl not found.\nsudo pacman -S playerctl", error=True)
        return False

    # Step 2: Build the command argument list.
    # We need to start with the base command.
    cmd = ["playerctl"]
    # If a specific player name was provided (e.g., "spotify", "firefox"),
    # add the `--player` flag to restrict the command to that player only.
    # Otherwise, playerctl will target the most recently active player
    # (as tracked by playerctld, if running) or fall back to the first available.
    if player:
        cmd += ["--player", player]
    # Append the action arguments (e.g., "play-pause", "next", "metadata").
    cmd += args

    # Step 3: Execute the command and return success/failure.
    # We capture stdout/stderr but don't need to parse them here.
    # Returncode 0 means the command succeeded (player responded to the action).
    result = run(cmd, capture=True)
    return result.returncode == 0

def _get_now_playing() -> str | None:
    """
    Return 'Artist — Title' for the currently playing track, or None.
    Used in notifications so you can see what was affected.
    """
    # Query playerctl for the current track's metadata.
    # `playerctl metadata` returns a set of key‑value pairs (artist, title,
    # album, artUrl, etc.). The `--format` flag lets us extract exactly the
    # string we want using printf‑style placeholders.
    #   {{artist}} → the track's artist name
    #   {{title}}  → the track's title
    # If the player is stopped or no track is loaded, these may be empty.
    result = run(
        ["playerctl", "metadata", "--format", "{{artist}} — {{title}}"],
        capture=True,
    )

    # If the command succeeded, inspect the output.
    if result.returncode == 0:
        text = result.stdout.strip()
        # If the output is empty or exactly " — " (meaning both artist and title
        # are empty), there is no meaningful track information to display.
        # Return None so the notification can fall back gracefully.
        return text if text and text != " — " else None
    return None

def media_play_pause() -> None:
    """Toggle play/pause on the active media player."""
    # Step 1: Send the play‑pause command to the active player.
    # `playerctl play-pause` toggles between play and pause states.
    # It's a single atomic action that works regardless of the current state.
    ok = _playerctl(["play-pause"])

    # If the command failed, we exit early (no notification).
    if not ok:
        return

    if ok:
        # Step 2: Wait briefly fo playerctl has time to update state before we read it
        # The MPRIS D‑Bus interface updates its properties asynchronously. If we
        # query the state immediately after sending the command, we might still
        # read the old state. A short sleep (0.3 seconds) gives the player time
        # to report its new status.
        import time as _t; _t.sleep(0.3)
        # Step 3: Query the new state and track info for the notification.
        # `playerctl status` returns either "Playing" or "Paused" (or "Stopped").
        # We use this to pick an appropriate icon.
        state = run(["playerctl", "status"], capture=True).stdout.strip()
        # Get the artist‑title string for the current track, if available.
        track = _get_now_playing()
        # Choose an appropiate icon: ▶️ for Playing, ⏸️ for Paused/Stopped.
        icon  = "▶️" if state == "Playing" else "⏸️"

        # Show the notification with the new state and track info.
        notify(f"{icon}  {state}", track or "")


def media_next() -> None:
    """Skip to the next track."""
    # Step 1: Send the "next" command to the active player.
    ok = _playerctl(["next"])
    if not ok:
        return

    if ok:
        # Step 2: Wait for the player to change tracks.
        # Skipping tracks often involves a brief loading period. A 0.5‑second delay
        # gives the player enough time to fetch the new track's metadata so the
        # notification can show accurate information.
        import time as _t; _t.sleep(0.5)
        # Step 3: Fetch the new track info and notify the user.
        track = _get_now_playing()
        # Show a notification with a "next track" ⏭️ icon and the new track's details.
        # If no track info is available, the body is empty (still shows the icon).
        notify("⏭️  Next track", track or "")


def media_prev() -> None:
    """Go back to the previous track (or restart current if >3s in).
    Note on playerctl behaviour:
        Most MPRIS players interpret "previous" as:
        - If the track has been playing for less than ~3 seconds, skip to the
          actual previous track in the history.
        - If the track has been playing for longer, restart the current track
          from the beginning.
        This matches the behaviour of physical media keys.
    """
    # Step 1: Send the "previous" command to the active player.
    ok = _playerctl(["previous"])
    if not ok:
        return

    if ok:
        # Step 2: Wait for the player to update.
        # Similar to `media_next()`, we wait briefly for the player to update its
        # metadata after the track change (or restart).
        import time as _t; _t.sleep(0.5)
        # Step 3: Fetch the (potentially new) track info and notify.
        track = _get_now_playing()
        notify("⏮️  Previous track", track or "")


def media_stop() -> None:
    """Stop playback entirely."""
    # Send the "stop" command to the active player.
    # `playerctl stop` stops playback and typically resets the play position.
    # Unlike pause, stop fully terminates the playback session for many players.
    _playerctl(["stop"])
    # Notify the user that playback has been stopped. No track info is shown
    # because the player is no longer playing anything.
    notify("⏹️  Stopped", "")

def media_menu() -> None:
    """
    Show a rofi menu with all media and volume controls.
    Lists currently playing track at the top so you can see what's active.
    """
    # Step 1: Gather current state information for dynamic labels.
    # Get the current volume percentage (0‑100) or None if unknown.
    pct        = _get_volume_pct()
    # Get the mute state: True = muted, False = unmuted, None = unknown.
    muted      = _get_mute_state()
    # Get the currently playing track info (e.g., "Artist — Title") or None.
    now        = _get_now_playing()

    # Build a dynamic volume label that reflects the current state.
    # If muted, show "🔇 Muted".
    # If unmuted and we have a percentage, show "🔊 65%".
    # Fallback: just "🔊 Volume".
    vol_label  = f"🔇 Muted" if muted else f"🔊 {pct}%" if pct is not None else "🔊 Volume"

    # Build a dynamic "now playing" label to show at the top of the menu.
    # If a track is playing, show "🎵  Artist — Title".
    # Otherwise, show "🎵  (nothing playing) "
    now_label  = f"🎵  {now}" if now else "🎵  (nothing playing)"

    # Step 2: Construct the rofi menu options list.
    # The menu uses visual separator lines (not selectable, but provide structure)
    # and indented actionable items. The current track is displayed as a non‑actionable
    # info line at the very top.
    options = [
        f"   {now_label}",  # Info line (not selectable)
        "── ▶  PLAYBACK ──────────────────────────────────",
        "   ⏯️   Play / Pause",
        "   ⏭️   Next track",
        "   ⏮️   Previous track",
        "   ⏹️   Stop",
        "── 🔊  VOLUME ───────────────────────────────────",
        f"   🔼  Volume +5%       (now: {vol_label})",   # Shows current volume/mute state
        "   🔽  Volume -5%",
        "   🔇  Mute / Unmute",
        "── 🎵  AUDIO OUTPUT ─────────────────────────────",
        "   🎧  Switch audio device",
    ]

    # Step 3: Display the rofi menu and capture the user's selection.
    pick = rofi_menu(options, prompt="  Media & Volume", lines=len(options))
    if pick is None:
        # User pressed Escape – exit without action.
        return

    # Step 4: Dispatch the selected action based on substring matching.
    # Each condition checks for a unique keyword in the selected string.
    # The order doesn't matter because the strings are mutually exclusive.
    if "Play / Pause"    in pick: media_play_pause()
    elif "Next"          in pick: media_next()
    elif "Previous"      in pick: media_prev()
    elif "Stop"          in pick: media_stop()
    elif "Volume +5%"    in pick: volume_up()
    elif "Volume -5%"    in pick: volume_down()
    elif "Mute"          in pick: toggle_mute()
    elif "Switch audio"  in pick: audio_menu()

# ══════════════════════════════════════════════════════════════════════════════
# SUBMENU — all features in one rofi dmenu
# ══════════════════════════════════════════════════════════════════════════════

def submenu() -> None:
    """
    Top-level rofi menu listing all available actions.
    One entry point you can bind to a single key in rofi-launcher.sh.
    """
    options = [
        "── 🖼️  WALLPAPER ────────────────────────────────────────",
        "   🎲  Random Wallpaper",
        "── 📸  SCREENSHOTS ──────────────────────────────────────",
        "   ✂️   Screenshot — Region",
        "   🖥️   Screenshot — Full Screen",
        "── ⌨️   NIRI ─────────────────────────────────────────────",
        "   ❓  Show Hotkey Overlay",
        "── 🎵  MEDIA & VOLUME ───────────────────────────────────",
        "   ⏯️   Play / Pause",
        "   ⏭️   Next track",
        "   ⏮️   Previous track",
        "   🔼  Volume +5%",
        "   🔽  Volume -5%",
        "   🔇  Mute / Unmute",
        "   📺  Media & Volume menu",
        "── 🔊  AUDIO OUTPUT ─────────────────────────────────────",
        "   🎧  Switch Audio Device",
    ]

    pick = rofi_menu(options, prompt="  Niri", lines=len(options))
    if pick is None:
        return

    if "Random Wallpaper"      in pick: wallpaper_random()
    elif "Region"              in pick: screenshot_region()
    elif "Full Screen"         in pick: screenshot_full()
    elif "Hotkey"              in pick: show_hotkeys()
    elif "Play / Pause"        in pick: media_play_pause()
    elif "Next track"          in pick: media_next()
    elif "Previous track"      in pick: media_prev()
    elif "Volume +5%"          in pick: volume_up()
    elif "Volume -5%"          in pick: volume_down()
    elif "Mute / Unmute"       in pick: toggle_mute()
    elif "Media & Volume menu" in pick: media_menu()
    elif "Switch Audio Device" in pick: audio_menu()


ACTIONS: dict[str, object] = {
    "--screenshot-region": screenshot_region,
    "--screenshot-full":   screenshot_full,
    "--hotkeys":           show_hotkeys,
    "--wallpaper":         wallpaper_random,
    "--audio":             audio_menu,
    "--volume-up":         volume_up,
    "--volume-down":       volume_down,
    "--mute":              toggle_mute,
    "--play-pause":        media_play_pause,
    "--next":              media_next,
    "--prev":              media_prev,
    "--media":             media_menu,
    "--submenu":           submenu,
}


def main() -> None:
    flag = sys.argv[1] if len(sys.argv) > 1 else "--submenu"

    if flag in ACTIONS:
        ACTIONS[flag]()   # type: ignore[operator]
    else:
        print(f"myrofi_niri: unknown flag {flag!r}", file=sys.stderr)
        print(f"Valid flags: {', '.join(ACTIONS)}", file=sys.stderr)
        sys.exit(1)


if __name__ == "__main__":
    main()
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.