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

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