A window manager for the River Wayland compositor (v0.4+), written in Python 3. It implements the river-window-management-v1 and river-xkb-bindings-v1 protocols to provide an opinionated workflow with 4 desktops, a floating overlay stack, and 3 layout modes.
Desktop Management. The window manager provides 4 independent numbered desktops (1–4), each with its own layout mode and window stack. Switching between desktops is instant — the previous desktop's windows are hidden and the new desktop's windows are shown. A window belongs to exactly one desktop (or the floating overlay stack).
Floating Overlay Stack. A separate floating context renders on top of the current desktop. When activated, the user "enters" the floating stack and can freely position and resize windows using the pointer. The underlying desktop remains visible beneath the overlay.
Three Layout Modes. Each desktop independently supports three layout modes, switchable via keybindings:
| Mode | Description |
|---|---|
| Fullscreen | The focused window occupies the entire output (no gaps, no borders, covers panels). Other windows are hidden behind it. Cycling windows changes which window is fullscreen. |
| Max | The focused window occupies the entire usable area (respects panels/bars). Only one window is visible at a time. |
| 2-Split | The output is divided into a left and right stack separated by a vertical split. Each side has its own ordered stack; only the top window on each side is visible. New windows are auto-balanced to the empty side when the focused side already has a window. |
Popup/Dialog Layer. Dialog windows are automatically detected and rendered as small floating popups on top of the current view. Detection works via the parent property (xdg-toplevel parent) or size-based heuristics (windows dramatically smaller than their tile area). Popups can be moved with Super + Left Click, closed with Super + Q, and cycled with Super + J/K when focused. Clicking a background window shifts keyboard focus there while the popup stays visible; clicking the popup re-focuses it. Popup positions are preserved across hot-reload.
Focus Management. Keyboard focus is explicitly managed by the WM. In max/fullscreen modes, focus is always on the single visible window. In 2-split mode, focus is on one of the two visible windows and can be moved between sides.
Wallpaper. Built-in wallpaper rendering via wlr-layer-shell. Images are scaled (fill mode, center-crop) and rendered at the native physical pixel resolution of each output. Configure via wallpaper in config.toml. Requires Pillow.
Process Manager. Managed child processes can be declared in config.toml. They are started after protocol binding and automatically restarted on crash with exponential backoff. One-shot commands (restart = false) run once after protocols are bound; set rerun_on_output = true to re-run whenever a monitor is reconnected or wakes from DPMS — useful for display scaling (wlr-randr). Portal daemons (xdg-desktop-portal) should not be managed by wm2 — they are DBus-activated on demand. Instead, propagate the compositor environment into DBus so portals can connect when activated (see configuration example below).
The following are required to run wm2:
- River compositor
mainbranch (pre-0.4.0) or 0.4.0+ withriver-window-management-v1protocol support - Python 3.11+
- pywayland (
pip install pywayland) - Pillow (
pip install Pillow) — optional, required for wallpaper support - A Wayland-compatible terminal emulator (default:
foot) - An application launcher (default:
fuzzel)
git clone https://github.com/hholst80/wm2.git
cd wm2
pip install pywayland PillowProtocol bindings are checked in under protocols/. To regenerate them (only needed after protocol XML changes):
python3 -m pywayland.scanner \
-i /usr/share/wayland/wayland.xml \
/usr/share/wayland-protocols/stable/xdg-shell/xdg-shell.xml \
river-window-management-v1.xml \
river-xkb-bindings-v1.xml \
river-xkb-config-v1.xml \
river-input-management-v1.xml \
wlr-layer-shell-unstable-v1.xml \
-o protocols/wm2 is launched as part of your River init script. It connects to the Wayland display, binds the window management protocol, and enters its event loop.
# Launch directly (River must be running)
python3 /path/to/wm2/wm2.pyPlace the following in ~/.config/river/init and make it executable:
#!/bin/sh
exec python3 /path/to/wm2/wm2.pyEverything else (status bar, notification daemon, display scaling, wallpaper, portals) is configured as managed processes in config.toml.
All bindings use the Super (Logo) key as the primary modifier.
| Keybinding | Action |
|---|---|
Super + 1 |
Switch to desktop 1 |
Super + 2 |
Switch to desktop 2 |
Super + 3 |
Switch to desktop 3 |
Super + 4 |
Switch to desktop 4 |
Super + Shift + 1 |
Move focused window to desktop 1 |
Super + Shift + 2 |
Move focused window to desktop 2 |
Super + Shift + 3 |
Move focused window to desktop 3 |
Super + Shift + 4 |
Move focused window to desktop 4 |
Super + Space |
Toggle floating overlay |
Super + Shift + Space |
Toggle focused window between floating and tiled |
| Keybinding | Action |
|---|---|
Super + F |
Switch to fullscreen mode |
Super + M |
Switch to max mode |
Super + S |
Switch to 2-split mode |
| Keybinding | Action |
|---|---|
Super + J |
Cycle to next window in stack |
Super + K |
Cycle to previous window in stack |
Super + Tab |
Cycle to next window in stack (alias of Super + J) |
Super + H |
Focus left side (2-split mode) |
Super + L |
Focus right side (2-split mode) |
Super + N |
Toggle notification panel |
| Keybinding | Action |
|---|---|
Super + O |
Move window to other side (2-split mode) |
Super + Shift + Tab |
Move window to other side (2-split mode) |
Super + Shift + H |
Move window to left stack (2-split mode) |
Super + Shift + L |
Move window to right stack (2-split mode) |
Super + Shift + K |
Move window up in side's stack (2-split mode) |
Super + Shift + J |
Move window down in side's stack (2-split mode) |
| Keybinding | Action |
|---|---|
Super + Return |
Spawn terminal |
Super + D |
Spawn application launcher |
Super + P |
Spawn application launcher (alias) |
Super + Q |
Close focused window |
Super + Shift + R |
Restart / hot-reload WM |
Super + G |
Screenshot region to clipboard |
Super + Shift + G |
Screenshot region to file |
Print |
Full screen screenshot to clipboard |
XF86AudioRaiseVolume |
Volume up (configurable) |
XF86AudioLowerVolume |
Volume down (configurable) |
XF86AudioMute |
Toggle mute (configurable) |
Super + Left Click |
Interactive move (floating/popup windows) |
Super + Right Click |
Interactive resize (floating/popup windows) |
An optional TOML configuration file can be placed at ~/.config/wm2/config.toml. If the file does not exist, sensible defaults are used.
# Terminal emulator command
terminal = "foot"
# Application launcher command
launcher = "fuzzel"
# Wallpaper image path (PNG or JPEG). Empty or omitted = no wallpaper.
# wallpaper = "~/.config/river/bg.png"
# Border width in pixels (0 to disable)
border_width = 2
# Bar height in logical pixels (0 = auto-detect from waybar config)
bar_height = 0
# Default layout mode: "fullscreen", "max", or "split"
default_layout = "max"
[xkb]
layout = "us"
model = "pc105"
variant = "altgr-intl"
options = "ctrl:nocaps,compose:rctrl"
# Volume control — commands run when XF86Audio keys are pressed.
# Set any value to "" to disable that binding.
# Defaults use wpctl (PipeWire).
[volume]
up = "wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%+"
down = "wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%-"
mute = "wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle"
# Managed processes — started after protocol binding, restarted on crash.
# One-shot commands (restart = false) run once after protocols are bound.
# Set rerun_on_output = true to re-run on monitor reconnect/wake.
# Propagate compositor env vars into DBus so that DBus-activated services
# (xdg-desktop-portal, etc.) can connect to the Wayland display.
[[process]]
cmd = "dbus-update-activation-environment WAYLAND_DISPLAY XDG_CURRENT_DESKTOP XCURSOR_THEME XCURSOR_SIZE"
restart = false
[[process]]
cmd = "wlr-randr --output eDP-1 --scale 2"
restart = false
rerun_on_output = true
[[process]]
cmd = "waybar"
[[process]]
cmd = "swaync"graph TD
A[River Compositor] <-->|river-window-management-v1| B[wm2]
A <-->|river-xkb-bindings-v1| B
A <-->|river-xkb-config-v1| B
A <-->|river-layer-shell-v1| B
A <-->|wlr-layer-shell-unstable-v1| B
B --> C[Desktop 1]
B --> D[Desktop 2]
B --> E[Desktop 3]
B --> F[Desktop 4]
B --> G[Floating Overlay]
C --> H[Layout: Fullscreen / Max / Split]
D --> H
E --> H
F --> H
The window manager operates as a standalone Wayland client process. It communicates with the River compositor through a two-phase commit model:
-
Manage Sequence: The compositor sends state changes (new windows, closed windows, input events) followed by a
manage_startevent. The WM responds by modifying window management state (dimensions, focus, fullscreen) and sendsmanage_finish. -
Render Sequence: The compositor sends updated window dimensions followed by a
render_startevent. The WM responds by setting positions, visibility, z-order, and borders, then sendsrender_finish.
This separation ensures frame-perfect atomic updates — all state changes are applied together in a single frame.
Some Wayland clients (notably Wine-based applications like Sober/Roblox) refuse to accept resize proposals before their first render on screen. When such a window starts, it ignores the WM's initial propose_dimensions and renders at its own default size (e.g. 800x637 instead of the expected tile size). Because the River compositor deduplicates identical proposals, simply re-proposing the same target dimensions has no effect — the compositor never sends a new configure event.
The resize jolt works around this by proposing a size that differs by 1 pixel from the target after the window's first render. This forces the compositor to emit a new configure event, which the now-visible window accepts. The next manage cycle proposes the correct dimensions and the window stabilizes.
The sequence for each layout mode:
| Mode | Sequence |
|---|---|
| Split | stuck at 800x637 → jolt to half_w+1 → correct half_w (exact 50-50) |
| Max | stuck at 800x637 → jolt to ua_w-1 → correct ua_w |
| Fullscreen | stuck at 0x0 (deferred, not fullscreened) → renders at 800x637 → jolt to ua_w-1 → compositor fullscreens |
For fullscreen mode, an additional defer step is needed: windows that haven't rendered yet (0x0) are not fullscreened immediately, since some clients won't render at all when fullscreened before their first frame. Instead, propose_dimensions is used (like max mode) until the window renders, then the jolt unsticks it, and fullscreen is applied on the following cycle.
A 10-pixel tolerance prevents false-triggering on cell-aligned terminals (e.g. foot at 1919x1037 vs tile 1920x1038).
Sending SIGUSR1 to the wm2 process triggers a hot-reload (re-exec). This is equivalent to Super + Shift + R.
On hot-reload, the WM serializes its state (desktop assignments, layout modes, window positions, floating stack, popup stack) to a JSON file in $XDG_RUNTIME_DIR and re-adopts persistent managed processes by PID instead of restarting them. When the new instance starts, it restores each window to its previous desktop and layout position as the compositor re-advertises them.
kill -USR1 $(pgrep -f 'python3.*wm2.py')wm2/
├── wm2.py # Main window manager implementation
├── protocols/ # Generated pywayland protocol bindings
│ ├── river_window_management_v1/
│ ├── river_xkb_bindings_v1/
│ ├── river_xkb_config_v1/
│ ├── river_input_management_v1/
│ ├── river_layer_shell_v1/
│ ├── wlr_layer_shell_unstable_v1/
│ └── wayland/
├── config.toml.example # Example configuration file
├── init.example # Example River init script
└── README.md # This file
MIT