Zero-config, self-healing X11 display-layout daemon for dwm/i3 and other bare-X11 window managers. It watches for monitor hotplug/unplug, remembers each display by its EDID identity, and auto-places externals relative to a primary using a persistent attach-order policy — so your monitors end up where you left them, every time.
One runtime dependency: python-xlib
(≥ 0.31), used to talk to X11 and RandR directly rather than scraping subprocess
output. Everything else is the standard library. Optional extras:
xrandrw[journald] for systemd-python journald logging, xrandrw[wallpaper]
for the pure-Python Pillow wallpaper backend.
X11 only — there is no Wayland support and none is planned. xrandrw drives RandR directly; under a Wayland compositor it has nothing to talk to.
It is built for bare window managers (dwm, i3, bspwm, awesome, and similar) that have no display-configuration daemon of their own. It will fight a full desktop environment. GNOME (mutter) and KDE (KScreen) run their own output managers that reassert their saved layout, so the two will overwrite each other in a loop. Do not run xrandrw alongside them without first disabling their display management.
Beyond Python ≥ 3.9 and a running X server:
| Binary | Needed | Debian/Ubuntu | Arch |
|---|---|---|---|
xrandr |
Required — applying any layout. | x11-xserver-utils |
xorg-xrandr |
xset |
Required — polling for X readiness at startup. | x11-xserver-utils |
xorg-xset |
xinput |
Only if you set TOUCH_MAP. |
xinput |
xorg-xinput |
feh |
Optional wallpaper backend. | feh |
feh |
xwallpaper |
Optional wallpaper backend. | xwallpaper |
xwallpaper |
# Debian/Ubuntu — required only
sudo apt install x11-xserver-utils
# Arch — required only
sudo pacman -S xorg-xrandr xorg-xsetA missing optional binary is a silent no-op, not an error: with no
wallpaper backend installed, layout changes still apply and the wallpaper simply
is not reset. Likewise a TOUCH_MAP set without xinput present remaps
nothing. Run with LOG_LEVEL=debug if a feature seems inert.
(fehbg is a third-party helper script rather than a distro package. It is
auto-detected when on PATH, and it ignores WALL — see the WALL row in
Configuration.)
End-user (isolated, lands the xrandrw console-script at ~/.local/bin — never
touches system Python):
pipx install xrandrw # from PyPI
# or, from a checkout: pipx install .Optional journald logging extra:
pipx install "xrandrw[journald]"Development — always in a virtualenv, never a bare/root pip:
python -m venv .venv
. .venv/bin/activate
pip install -e ".[dev]" # editable + ruff, vulture, pytest, buildxrandrw exposes seven modes:
| Command | Description |
|---|---|
xrandrw --apply |
Apply the layout once (default when no flag is given). |
xrandrw --watch |
Event-driven watch: re-apply on RandR hotplug notifications. |
xrandrw --daemon |
The same event-driven watch, plus the systemd sd_notify calls and a daemon_start log line (the intended service entry point). |
xrandrw --print |
Print xrandr --query output and exit. |
xrandrw --set-pref OUTPUT_OR_ID SIDE |
Set a display's preferred side: right-of, left-of, above, below. |
xrandrw --list-state |
Dump the placement state JSON. |
xrandrw --window-state |
Print a JSON diagnostic of the window-mgmt feature state — enabled, dwm-ipc availability, and captured windows. |
--watch and --daemon run the identical loop. Both register for RandR
ScreenChange/OutputChange/CrtcChange notifications and block in select()
on the X connection — neither one busy-polls. The only differences are that
--daemon emits sd_notify("READY=1") (plus watchdog keep-alives) and logs a
daemon_start line. Note that both the documented and the shipped unit are
Type=simple, so systemd does not use READY=1 as a readiness gate — the
notification is sent but ignored, and the service is considered started as soon as
the process is forked. Switch the unit to Type=notify if you want the handshake
to actually gate readiness. Use --daemon under systemd and --watch when
running it by hand; the display behaviour is the same. (POLL_INTERVAL is the
select() timeout in both — a slow safety net, not the detection mechanism.)
Config resolves from (lowest to highest precedence): built-in defaults →
/etc/xdg/xrandrw.conf → ~/.config/xrandrw.conf → process environment.
Every key in the table below can be set in a config file (KEY=value lines) or
via an environment variable of the same name. LAYOUT_* is the exception: it
is config-file-only, because the environment overlay iterates a fixed key list
that cannot enumerate arbitrary profile names.
Config files are not shell scripts. They are parsed as plain KEY=value
lines; surrounding quotes are stripped and nothing else is interpreted. $HOME,
${XDG_DATA_HOME}, ~ and command substitution are not expanded — a WALL
of "$HOME/wall.jpg" is stored as that literal string and never resolves. Write
absolute paths in full.
| Key | Purpose |
|---|---|
USE_XWALLPAPER |
0 = feh/fehbg, 1 = xwallpaper. |
WALL |
Wallpaper image path. Not honoured by the fehbg backend — fehbg is a third-party script that picks its own image and takes no path argument, so WALL is a no-op there (logged as wallpaper_wall_ignored). Set WALLPAPER_ENGINE=feh (or xwallpaper/native) to have WALL applied. |
HIDPI_WIDTH |
Treat internal panel as HiDPI when preferred-mode width ≥ this. |
POLL_INTERVAL |
Slow safety-net select() timeout in seconds (default 45). Not the hotplug detection mechanism — detection is event-driven via RandR, and this is only the fallback wakeup for the rare case an event is missed. Lowering it does not make hotplug faster; it just wastes CPU. |
LOG_LEVEL |
none, err, info, notice, debug. |
LOG_FILE |
Optional JSON-lines log file (unset = journald/stderr). |
LOCKFILE |
Apply-lock path. Defaults to $XDG_RUNTIME_DIR/xrandrw.lock, falling back to /run/user/$UID/ and then ~/.local/share/xrandrw/. Deliberately never world-writable /tmp, so another local user cannot pre-create or squat the lock. Set it explicitly only if you have a reason to. |
STATE_LOCKFILE |
Not user-settable — derived alongside LOCKFILE in the same per-user runtime directory (xrandrw.state.lock) and listed here only so it is not a surprise in a directory listing. |
PREF_DEFAULT_SIDE |
Default side for new/unknown monitors. |
EXCESS_WINDOW_SEC / EXCESS_THRESHOLD |
Churn-backoff window and threshold. |
TOUCH_MAP |
Map touch/stylus devices to outputs, e.g. "ELAN Touchscreen:eDP-1"; ;-separate multiple. |
WALLPAPER_ENGINE |
feh, fehbg, xwallpaper, native, or empty for auto-detect (default). Unknown values fall back to auto-detect. |
APPLY_BACKEND |
subprocess (default). native is a seam-stub that warns and delegates to subprocess — not yet a real native path. |
LAYOUT_* |
Named device profile — a fixed xrandr layout for an exact set of connectors. |
WINDOW_MANAGEMENT |
Opt-in dwm-ipc window relocation on hotplug (0 = off default, 1 = on). See Window management (dwm-ipc). |
BOUNCE_SUSPECT_MS |
A disconnect arriving this soon after an apply is treated as a possible replug bounce rather than a genuine unplug (default 5000). |
BOUNCE_HOLDDOWN_MS |
How long to keep re-reading the topology before believing a suspect disconnect (default 3000). 0 disables the hold-down entirely. |
See xrandrw.conf.sample
for an annotated template —
copy it to ~/.config/xrandrw.conf and edit.
Three dwm-ipc knobs are environment-only. They are read directly by the
dwm-ipc client at import time and are not part of the config-file key set, so
setting them in ~/.config/xrandrw.conf does nothing — use the environment (or an
Environment= line in the systemd unit). A malformed value degrades to the default
with one dwmipc_env_invalid warning and never raises.
| Env var | Default | Minimum | Purpose |
|---|---|---|---|
DWM_SOCKET |
/tmp/dwm.sock |
— | Path to the dwm-ipc control socket. |
DWMIPC_TIMEOUT |
1.0 |
0.001 |
Per-operation socket timeout in seconds. Non-finite values (inf, nan) are rejected. |
DWMIPC_MAX_REPLY |
8388608 (8 MiB) |
1 |
Hard cap on a reply's advertised size, enforced before any allocation. |
When the generic attach-order policy is not what you want, LAYOUT_<NAME> pins
an exact layout for an exact set of connectors and overrides the policy
entirely.
# Laptop panel alone.
LAYOUT_SOLO="eDP-1:auto:primary:0x0"
# Laptop panel + desk monitor: 2560x1440 external above the internal panel.
LAYOUT_DESK="eDP-1:auto:primary:0x0;HDMI-1:2560x1440:secondary:above=eDP-1"With only the laptop open, LAYOUT_SOLO fires. Plug in the desk monitor and
LAYOUT_DESK fires instead.
Matching is exact set equality, not subset. A profile fires only when the
connected connector set equals its own. LAYOUT_SOLO above does not fire
when HDMI-1 is also connected — which is why the two-head case needs its own
profile. (Subset matching was removed as a defect: a one-connector profile would
win on a two-head topology and leave the second head unconfigured and black.)
If two profiles name the identical connector set, the alphabetically-first
LAYOUT_ name wins, so ties are deterministic rather than config-order
dependent.
Grammar — ;-separated outputs, :-separated fields:
connector:mode:role:position[:transform...]
| Field | Values |
|---|---|
connector |
Connector name, e.g. eDP-1, HDMI-1, DSI-1. |
mode |
An xrandr mode such as 1920x1080, or auto for --auto. |
role |
primary or secondary. |
position |
Absolute XxY (e.g. 1600x0), or relative side=CONNECTOR (e.g. right-of=eDP-1) where side is left-of/right-of/above/below. |
transform |
Optional and repeatable: scale=WxH, rotate=left|right|inverted|normal. |
A malformed LAYOUT_ line is skipped rather than crashing the daemon — but the
skip is entirely silent: parse_profile returns None and the profile is
simply omitted, with no log line at any level. So a typo disables that one
profile with no diagnostic anywhere. If a profile is not firing, re-read its
grammar here; the journal will not tell you.
A physical replug rarely presents one clean connect edge — the connector typically drops again a moment after coming back. Taking that second drop at face value powers the head off, so windows that had just been restored get evacuated and then dragged back: four visible window movements for one replug instead of two.
The hold-down closes that. When a disconnect arrives within BOUNCE_SUSPECT_MS
of the previous apply, the daemon treats it as suspect and re-reads the
topology for up to BOUNCE_HOLDDOWN_MS before acting. If the connector returns
within that window, the whole off/on cycle is suppressed and no window moves.
The wait is deliberately asymmetric: only the disconnect edge is held. Connect/replug edges are never delayed, and a genuine unplug — one arriving long after the previous apply, so outside the suspect window — is healed immediately with no added latency. That gating is the point: it keeps the common case fast while still absorbing the rare bounce.
Raise BOUNCE_HOLDDOWN_MS if your cable still produces a double cycle; lower
BOUNCE_SUSPECT_MS if a real unplug ever feels sluggish; set
BOUNCE_HOLDDOWN_MS=0 to turn the feature off.
The cost, stated plainly. A disconnect that does fall inside the suspect
window pays up to BOUNCE_HOLDDOWN_MS (default 3 s) of added latency before the
head is powered off — that is the trade the feature makes. And the default 3000
is a conservative bound, not a measured bounce duration: the actual dark
interval could not be resolved from either captured trace, because the daemon was
blocked inside its own modeset for most of it. Both traces bound it at roughly
1.7–3.0 s, so 3000 clears one comfortably and only just meets the other. Treat it
as a knob to tune on your own hardware, not a figure derived from measurement.
APPLY_BACKEND=nativeis a stub. It is a seam left in place for a future pure-Xlib apply path. Selecting it logs a warning and delegates to thesubprocessbackend; it does not change behaviour.subprocessis the only real backend today.- The bounce hold-down latency described just above.
- The window-management limitations listed under Window management, which also records that live hardware verification covers a single machine.
An opt-in feature (default off) that gives display hotplug an Apple-like feel: on
unplug, xrandrw relocates the removed display's windows onto a surviving display,
and on replug it moves the same process's window back where it was — keyed by
(pid, starttime) read from /proc, not by PID alone, so a recycled PID cannot
be mistaken for the original process. That key is also the limit of the feature's
granularity: at most one window per process is remembered (see Limitations).
Requirement. This needs a dwm built with the
mihirlad55/dwm-ipc patch, which exposes a
control socket at ${DWM_SOCKET:-/tmp/dwm.sock}. On any window manager without that IPC endpoint
(vanilla dwm, i3, RPi4 stock dwm) the feature is silently capability-gated off
and your display layout is entirely unaffected — nothing to configure, nothing to
break.
It also needs that dwm to focus a window when it receives _NET_ACTIVE_WINDOW
(the focusonnetactive behaviour). Restoring a window means selecting it first,
and the only handle xrandrw has is sending _NET_ACTIVE_WINDOW to the root
window — but stock dwm's clientmessage answers that atom with seturgent()
alone and never changes the selection. When focus cannot be confirmed the daemon
aborts that window's restore instead of issuing tag/tagmon against an
unconfirmed selection, which would otherwise retag and move whatever window you
had focused. So on a dwm-ipc dwm lacking focusonnetactive, relocation degrades
to a safe no-op and logs relocate_focus_unconfirmed at WARNING.
Enable it by setting WINDOW_MANAGEMENT=1 (env or config file). Effective-on
is WINDOW_MANAGEMENT=1 and a live dwm-ipc endpoint; either missing means the
subsystem is a no-op.
Inspect it with the read-only diagnostic:
xrandrw --window-stateIt always exits 0 — when the feature is off, when no endpoint is present, and even when the capture itself fails (never a traceback). The JSON keys are:
| Key | Type | Meaning |
|---|---|---|
enabled |
bool | Whether WINDOW_MANAGEMENT=1 is set. |
dwmipc_available |
bool | Whether a usable dwm-ipc socket was found. |
captured |
list | Window records read live from dwm. Empty unless both flags above are true. |
displaced |
list | Always empty here — see below. |
reason |
string | Present only on a degraded path, explaining which of the two gates failed. Absent when the feature is fully live. |
displaced is always [] from the CLI. Displaced records — the windows
evacuated from an unplugged output, awaiting its return — exist only in the
memory of the running daemon's relocation coordinator. --window-state is a
separate one-shot process with no coordinator of its own and no way to interrogate
another process's, so it has nothing to report. The key is emitted regardless to
keep the schema stable. To see relocation activity as it happens, read the
daemon's relocate_* log events (LOG_LEVEL=debug) rather than this command.
Limitations.
- Only one window per process is remembered. The capture snapshot is keyed by
(pid, starttime)alone — the window id is recorded on each record but is not part of the key — so when one process owns several top-level windows, all but one record are overwritten and only that surviving window is relocated and restored. Single-window-per-process applications, which is the usual case for terminals and editors, are unaffected. A multi-window app (a browser with several windows, or an editor with several frames from one process) will see only one of its windows come back; the others stay where dwm's own evacuation left them. - Fullscreen is not reapplied. A window's fullscreen state is captured but not restored (Phase-10 IN-03); a formerly-fullscreen window returns to its saved monitor/tag/floating-state/geometry but not its fullscreen flag.
- Cross-monitor relocation needs ≥ 2 heads — with a single output there is nowhere to relocate to.
- Monitor targeting is relative. Restore uses dwm's relative
tagmon, so the target monitor is derived from the current topology, not an absolute index. - Displaced windows are remembered by connector, not by monitor identity. If
you unplug a monitor and then plug a different monitor into the same port,
the first monitor's windows are restored onto the new one. Windows are keyed by
connector name (
HDMI-1), and although each record captures the monitor's EDID it is not currently consulted on restore. - A restore is skipped when the move would leave the source monitor empty and
the destination holding a single window. That exact pair of conditions —
no visible client left behind, some monitor ending at exactly one tiled client
— is what segfaults dwm builds carrying the
single-window-centerpatch, whosetile()dereferencesselmon->selafterfocus(NULL)has just nulled it. The daemon refuses the move rather than risk taking your window manager down with it. This is most likely to bite when your two monitors are showing different tags, because a window restored onto a tag you are not currently viewing is invisible on arrival and does not count toward the source monitor's visible set. The window is not lost: it stays on its current monitor with its original tag — switching to that tag reveals it — and the record is kept, so a later relocation cycle retries once the move is safe. The cause is a missing NULL guard in that dwm patch, not in xrandrw; on a dwm without the patch the restriction does not apply. Look forrelocate_tagmon_unsafeandrelocate_restore_incompleteat WARNING. - Mirrored outputs get no mapping, so relocation silently does nothing there.
Two outputs both connected at identical position and mode are genuinely
ambiguous, and the dwm-monitor→connector matcher refuses to guess: it returns
Nonerather than binding arbitrarily. Records carryingoutput=Noneare never eligible for displacement, so on a mirrored setup the feature is inert. This is a deliberate refusal — guessing would risk moving windows to the wrong head — but it is silent apart from awindow_monitor_unmatchedlog line.
Verification scope. The unplug/replug behaviour above has been confirmed by
physical hardware testing on exactly one machine — a Dell laptop, eDP-1 +
HDMI-1, dwm 6.5 with the dwm-ipc patch. Everything else is covered by automated
tests only. Other hardware, docks, and three-or-more-head configurations are
plausible but unproven; please report what you find.
xrandrw's core daemon — hotplug detection, layout/placement, EDID identity,
wallpaper reapply, and touch remap — is generic X11 and runs on any bare-X11
window manager with no window-management dependency. The window-management
relocate/restore feature is a strictly additive layer that requires a dwm
built with the mihirlad55/dwm-ipc patch
exposing ${DWM_SOCKET:-/tmp/dwm.sock}.
On any WM without that socket — vanilla dwm, i3, a stock Raspberry Pi 4 dwm —
the feature is silently capability-gated off and the display-layout daemon is
completely unaffected: no dwm-ipc call is ever issued and no window is ever
moved. With WINDOW_MANAGEMENT unset, the daemon and watch paths never probe the
socket at all — the relocation coordinator's gate tests the config flag first and
short-circuits, and the boot-time endpoint wait is skipped entirely, so a bare-X11
box pays no probe and no boot delay. The one exception is the explicit
xrandrw --window-state diagnostic, which probes the socket unconditionally before
it reports the config flag, so that it can tell you both gates' states in one
line. That is a manual, read-only command — nothing in normal operation. So adopting
xrandrw on a bare-X11 box costs nothing and breaks nothing.
This contract is not incidental — a dedicated anti-regression CI suite
(.github/workflows/regression.yml, REG-01) runs the gate-off / RPi4-style path
with no dwm-ipc socket present and fails the build if any future change ever
made the gated-off window-mgmt path run (issue a dwm-ipc call or move a window) on
a no-endpoint machine. The RPi4 / vanilla-dwm path can therefore never silently
break as the window-mgmt code evolves.
The suite runs in two tiers, split by the functional pytest marker.
Display-free tier — pytest -m 'not functional'. The default, and the bulk
of the suite. The window-management capture→restore lifecycle is driven against a
real AF_UNIX fake dwm-ipc server speaking the DWM-IPC wire protocol, a
mocked Xlib seam, and a fake /proc. None of it needs a display, so it
runs headless with no X server, no dwm, and no second monitor.
Real-dwm tier — pytest -m functional. The opposite: it needs a display and a
window manager. CI's functional job installs xvfb and xserver-xephyr, builds
a real dwm from a vendored checksum-pinned dwm-ipc.diff, and runs the suite
against it over a private socket (Xephyr nested in Xvfb to get two Xinerama heads).
This tier is new and barely exercised — it first went green on 2026-07-19 and
has only a handful of passing runs, so it has not yet protected anything
historically. Treat it as a gate going forward, not as retrospective evidence.
Neither tier can prove the real thing. No CI runner can flip a connector's
hotplug-detect bit, so the true unplug→relocate→replug→restore chain is verified
only by a physical hardware run, recorded in tests/functional/L3_PASS.stamp and
gated on at release time.
CI (ci.yml) runs nine jobs — eight gating, one advisory:
test—pytest -qpluspython -m buildacross Python 3.9 / 3.11 / 3.13.coverage— two separate runs: a line-coverage gate of 95 scoped toxrandrw.dwmipc/xrandrw.windows/xrandrw.relocate, and a whole-package--cov-branch --cov-fail-under=85ratchet.lint— baselineruff check ..lint-strict— the expandedB,SIM,PERF,C90,UP,RUFruleset across all ofsrc/xrandrw/.dupcode—pylintduplicate-code (R0801) at--min-similarity-lines=6.deadcode—vultureoversrc/.cli-smoke—pip install .(a real, non-editable install) then invokes the installedxrandrwconsole-script, catching entry-point regressions.functional— the real-dwm tier described above.functional-vkms(advisory,continue-on-error) — a best-effort real Xorg + dummy-driver / VKMS probe. It documents that a true output-status flip is still not reachable headlessly; it never gates.
Two further workflows sit outside ci.yml: regression.yml (the no-dwm-ipc
degradation gate, on every push/PR) and mutation.yml (a nightly mutation-testing
ratchet, also dispatchable on demand).
Running xrandrw --daemon as a --user service (with Restart=always) is the
intended deployment. The unit below is Type=simple: the daemon does send
sd_notify("READY=1"), but under Type=simple systemd ignores it rather than
treating it as a readiness gate. There are two paths.
The wheel on PyPI does not ship the unit file — a wheel unpacks into
site-packages, where systemctl --user cannot reach it. Write it yourself:
mkdir -p ~/.config/systemd/user
cat > ~/.config/systemd/user/xrandrw.service <<'EOF'
[Unit]
Description=xrandrw: automatic X11 display hotplug policy (dwm)
# Session helper: enabling this links it under dwm-session.target.wants/ and it
# stops with the session. Generic (non-dwm) setups can retarget both lines to
# graphical-session.target.
After=dwm-session.target
PartOf=dwm-session.target
StartLimitIntervalSec=0
[Service]
Type=simple
Environment=DISPLAY=:0
Environment=XAUTHORITY=%h/.Xauthority
Environment=PATH=%h/.local/bin:/usr/local/bin:/usr/bin
Environment=WALL=%h/.local/share/backgrounds/space.jpg
Environment=LOG_LEVEL=notice
# Environment=LOG_FILE=%h/.local/state/xrandrw/xrandrw.log
# Optional: USE_XWALLPAPER=1 ; HIDPI_WIDTH=3200 ; PREF_DEFAULT_SIDE=right-of
ExecStart=%h/.local/bin/xrandrw --daemon
Restart=always
RestartSec=1
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=dwm-session.target
EOFRead Choosing the right WantedBy target
before enabling it — the default target only exists on the author's machine.
Adjust WALL= to a wallpaper you actually have (or delete the line). pipx
places the console-script at ~/.local/bin/xrandrw, which is exactly the
ExecStart path above.
make install # pipx install . + copy conf sample + unit
make enable # systemctl --user daemon-reload && enable --nowRun systemctl --user daemon-reload after any reinstall.
If your session is not driven by a dwm-session.target, you must retarget the
unit — otherwise it will stop starting after your next reboot, with no error.
This is a silent failure, which is why it gets its own section. The shipped unit
uses dwm-session.target in After=, PartOf=, and WantedBy=. That target is
a personal session unit which this repo does not install. systemctl --user enable still succeeds against a non-existent target, and --now still starts
the service, so everything looks correct — until the next boot, when nothing
ever pulls the unit in.
If you do not have a dwm-session.target, replace all three references before
enabling:
After=graphical-session.target
PartOf=graphical-session.target
[Install]
WantedBy=graphical-session.targetOr, against an already-installed unit:
sed -i 's/dwm-session\.target/graphical-session.target/g' \
~/.config/systemd/user/xrandrw.service
systemctl --user daemon-reload
systemctl --user reenable xrandrw.service(reenable is required — the old symlink still points at the old target.)
List the targets your session actually provides with:
systemctl --user list-units --type=targetNote that graphical-session.target is only reached if something in your
session reaches it; bare WM setups launched from .xinitrc often do not. If it
is absent, either have your session run systemctl --user start graphical-session.target, or use default.target instead.
systemctl --user disable --now xrandrw # stop it and unlink from the target
rm -f ~/.config/systemd/user/xrandrw.service
systemctl --user daemon-reload
pipx uninstall xrandrwThat leaves no processes behind, but does not remove your data. To also drop the persistent state — the EDID-to-profile identity map and attach-order stack:
rm -rf "${XDG_DATA_HOME:-$HOME/.local/share}/xrandrw" # state.json (honours XDG_DATA_HOME)
rm -f ~/.config/xrandrw.conf # your config, if you made oneDeleting state.json alone is also the way to reset placement without
uninstalling: the daemon rebuilds it from scratch on the next apply, forgetting
every remembered monitor and side preference. The lock files live in
$XDG_RUNTIME_DIR and are cleared on reboot, so they never need cleaning up.
From a repo checkout, make uninstall does most of the first block: it stops
and disables the unit and removes the copied unit file, the ~/.local/bin entry
point and the installed config sample. It does not run systemctl --user daemon-reload (it only prints a reminder), and it does not run pipx uninstall xrandrw — the pipx virtualenv survives. Run those two by hand afterwards.
MIT — see LICENSE.