att_wm
A dwm-like window manager for the river Wayland compositor.
river 0.4 is non-monolithic: it ships no window management policy of its own — no
riverctl, no rivertile — and instead hands the entire job to a single external
client speaking river-window-management-v1. att_wm is that client. It gives you
dwm's model on top of river: nine tags, a master/stack layout, monocle and tabbed
layouts, and keybindings compiled into the binary.
Tag and window state is published as JSON lines on a unix socket so bars such as
quickshell can render it, and att_wmctl drives the same
socket in the other direction.
Written in Zig, built with a Nix flake.
Status
Verified working against river 0.4.5 / Zig 0.16 / quickshell 0.3.0:
-
tags, master / monocle / tabbed layouts, floating and fullscreen windows
-
focus cycling,
zoom, stack reordering, per-tag layout state -
the IPC socket,
att_wmctl, and a quickshell bar that maps as a layer surface and whose exclusive zone correctly shrinks the tiling area -
keybindings: all 63 register and fire, verified by injecting real key events through a virtual keyboard (
wtype) into a nested river -
input: a keymap compiled from
layout/variant/optionsis accepted by river and assigned to each keyboard, per-device rules match on name glob and device type, and key repeat and scroll factor are applied.map_to_outputresolves the right output by name among several, and re-maps across an output being turned off and back on. The libinput half — tap to click and its neighbours — is written against the protocol but not yet exercised on hardware: river cannot expose libinput devices to a nested session, so it needs a real one to confirm.
Building
nix build # produces ./result/bin/{att_wm,att_wmctl}
nix develop # dev shell: zig, zls, river, quickshell
zig build # inside the dev shell
zig build test # unit tests for the layout maths and command parser
./dev.sh enters the dev shell without refetching nixpkgs when the pinned
revision (nixos-26.05) is already in the local store.
Running
river runs $XDG_CONFIG_HOME/river/init on startup. Start att_wm from there and
keep it in the foreground, so quitting it ends the session:
#!/bin/sh
# ~/.config/river/init
quickshell -p ~/.config/quickshell/att_wm &
exec att_wm
Or, for a one-off:
river -c att_wm
Trying it without touching your system config
river's wayland backend runs it as an ordinary window inside your existing session, so you can drive att_wm for real without installing anything or rebuilding NixOS:
nix develop # or: nix shell nixpkgs#river nixpkgs#foot nixpkgs#quickshell
./run-nested.sh --bar --term
That opens a nested river with att_wm, the quickshell bar and a terminal. Close the window to exit. It prints the nested display name, so you can drive that instance from another terminal:
WAYLAND_DISPLAY=wayland-2 att_wmctl state
WAYLAND_DISPLAY=wayland-2 att_wmctl layout tabbed
Two caveats when nested:
-
Your outer compositor sees keys first. If it already binds
Alt+Returnor similar, those never reach att_wm. Either test withatt_wmctl, or changemodinconfig.zigand rebuild — one line, dwm-style. -
Without a Wayland session (on a TTY) there is nothing to nest inside; use the headless backend instead, and drive it entirely over IPC:
WLR_BACKENDS=headless WLR_HEADLESS_OUTPUTS=1 WLR_RENDERER=pixman \ river -no-xwayland -c 'att_wm'
att_wm must be started by river — river_window_manager_v1 is what it binds, and
only one client may hold it at a time. If a window manager is already running,
att_wm reports it and exits rather than fighting for the global.
river 0.4 or newer is required. Check with
river -version. If it says0.3.xyou have river-classic, a different package that predates this protocol — it does its own window management and is configured withriverctl, so att_wm cannot drive it. Installing both leaves whichever comes first onPATHin charge, and the symptom is a bare background with no working keybindings: river-classic has no built-in bindings, and att_wm exits because the global it needs is missing. Remove river-classic, or call river 0.4 by its absolute path.
Home Manager
{
inputs.att_wm.url = "git+https://git.project-cloud.net/asmir/att_wm.git";
# ...
imports = [ inputs.att_wm.homeManagerModules.default ];
programs.att_wm = {
enable = true;
settings = ./my-config.zig; # optional, see Configuration
autostart = [ "quickshell" ];
};
}
Keybindings
Mod is Alt, as dwm ships it. Change mod in config.zig to Mods.super
if you would rather not compete with applications that bind Alt themselves.
| Binding | Action |
|---|---|
Mod+Shift+Return |
Spawn terminal |
Mod+p |
Spawn menu |
Mod+Shift+c |
Close focused window |
Mod+Shift+q |
Quit att_wm (river keeps running) |
Mod+Ctrl+Shift+q |
End the Wayland session |
Mod+j / Mod+k |
Focus next / previous window |
Mod+Shift+j / Mod+Shift+k |
Move focused window down / up the stack |
Mod+Return |
Zoom — promote focused window to master |
Mod+h / Mod+l |
Shrink / grow the master area |
Mod+i / Mod+d |
Increase / decrease windows in master |
Mod+t / Mod+m / Mod+u |
Master / monocle / tabbed layout |
Mod+space |
Toggle between current and previous layout |
Mod+Shift+space |
Toggle floating |
Mod+f |
Toggle fullscreen |
Mod+1..9 |
View tag |
Mod+Shift+1..9 |
Move focused window to tag |
Mod+Ctrl+1..9 |
Toggle tag in view |
Mod+Ctrl+Shift+1..9 |
Toggle tag on focused window |
Mod+0 / Mod+Shift+0 |
View all tags / put window on all tags |
Mod+Tab / Mod+Esc |
Back to previously viewed tags |
Mod+, / Mod+. |
Focus previous / next output |
Mod+Shift+, / Mod+Shift+. |
Send window to previous / next output |
Mod+Left drag |
Move window (floats it) |
Mod+Right drag |
Resize window |
river reserves Ctrl+Alt+F1–F12 for VT switching; no window manager can
override those.
Key repeat is handled by att_wm, not river: the protocol reports press and
release, so bindings where holding the key should keep acting (focus, swap,
nmaster, mfact) repeat on a timer — binding_repeat_delay and
binding_repeat_interval in config.zig. The repeat applications see is a
separate setting, repeat, under Input.
Layouts
master — dwm's tile. nmaster windows share a left-hand column of width
mfact; the rest divide the remainder. Leftover pixels are absorbed as the
column is divided, so the bottom edge always lands exactly on the output edge.
monocle — every window gets the full area; only the most recently focused one is rendered.
In every layout a newly mapped window goes to the front of the arrangement order — the first master slot — and takes focus, as in dwm. A window spawned onto a tag nobody is viewing leaves both the order and focus alone.
tabbed — same geometry as monocle, minus a strip at the top where att_wm
draws one tab per window, the focused one highlighted. Each tab is clickable and
carries the window's title, elided with an ellipsis when the tab is too narrow.
Titles are drawn with FreeType in whatever font the fontconfig pattern
tab_font matches ("sans:size=11" by default, so fc-match 'sans:size=11'
shows what you will get), antialiased and subpixel positioned, shrunk to fit
when tabbar_height leaves no room for the size asked for. Codepoints the
matched font has no glyph for send att_wm back to fontconfig for one that does,
so a CJK or emoji title is not a row of boxes; there is no shaping, so scripts
that need it come out as unjoined letters.
The same window list is published over IPC, so a bar can draw its own tab strip
alongside (the bundled quickshell config does). Set tabbar_height = 0 to drop
the built-in strip entirely and let the bar own it.
Layout, nmaster and mfact are kept per tag, per output — dwm with its
pertag patch. Setting a layout on tag 3 leaves tag 4 as it was, and switching
back to 3 restores it. Viewing several tags at once has no single tag whose
settings should win, so all such views share one extra set; the individual tags
keep theirs for the way back.
Configuration
att_wm is configured at compile time, like dwm. Edit src/config.zig and rebuild.
Nix users need not patch the source tree:
zig build -Dconfig=/path/to/my-config.zig
att_wm.override { configFile = ./my-config.zig; }
config.zig covers border width and colours, gaps, tab bar height, colours and
text size, the default layout, nmaster / mfact, tag names,
focus-follows-mouse, key and pointer bindings, autostart commands, input
devices, and window rules:
pub const rules = [_]Rule{
.{ .app_id = "pavucontrol", .floating = true },
.{ .title = "Picture-in-Picture", .floating = true },
};
Bindings are declared as data, and the same Action type backs both keys and
IPC commands — so anything bindable is scriptable and vice versa.
Input
river 0.4 hands input configuration to the window manager too, over three more
protocols: river-input-management-v1 enumerates devices, river-xkb-config-v1
compiles keymaps, and river-libinput-config-v1 exposes libinput's own settings.
There is no riverctl input, so this is where it lives.
Keyboard layout is the RMLVO that setxkbmap takes, compiled once and given
to every keyboard:
pub const keymap: input.Keymap = .{
.layout = "us,se",
.options = "grp:alt_shift_toggle,caps:escape",
};
Leaving it alone keeps river's default, which honours the XKB_DEFAULT_*
environment variables. With more than one layout, switching between them is a
grp: option — xkb does it itself, so att_wm needs no binding for it.
Key repeat, as applications see it. Not to be confused with
binding_repeat_delay / binding_repeat_interval, which are how fast a
held-down att_wm binding re-fires — river reports binding press and release and
leaves repeating to att_wm:
pub const repeat: input.Repeat = .{ .rate = 40, .delay = 400 };
Per-device settings are rules matched on name and type. name is a glob, so
* saves you writing out ELAN0501:00 04F3:3060 Touchpad in full:
pub const input_rules = [_]input.Rule{
.{
.name = "*Touchpad*",
.tap = true,
.natural_scroll = true,
.disable_while_typing = true,
.click_method = .clickfinger,
},
.{ .type = .keyboard, .repeat = .{ .rate = 50, .delay = 250 } },
.{ .name = "*Logitech*", .accel_profile = .flat, .scroll_factor = 1.5 },
};
Every setting defaults to null, meaning "leave libinput's own default alone", so a rule need only say what it wants changed. Rules apply in declaration order and a later one overrides an earlier one field by field, so a broad rule can set a house style and a narrower one dissent from it — the same last-one-wins as dwm's window rules.
src/input.zig is the full list. Beyond the above it covers tap_button_map,
drag, drag_lock, three_finger_drag, clickfinger_button_map,
middle_emulation, left_handed, scroll_method, scroll_button,
scroll_button_lock, accel_speed, disable_while_trackpointing, rotation,
send_events and map_to_output.
Note that a touchpad reports pointer, not touch — libinput models it as a
pointer that happens to support tapping. touch is a touchscreen.
Touchscreens and display rotation
An unmapped touchscreen spans the whole output layout, so on two monitors a touch
near the left edge of the panel lands on the wrong screen. map_to_output
confines it to one, named as river names it — the same name the IPC outputs
list uses:
.{ .type = .touch, .map_to_output = "eDP-1" },
.{ .type = .tablet, .map_to_output = "eDP-1" },
This is also all that is needed for touch to survive display rotation.
wlroots applies the output's transform to a mapped device's coordinates on every
event, reading the transform live — so rotating with wlr-randr --transform, or
with a daemon like rot8, rotates touch
along with the screen. Nothing is re-sent on rotation and no rotation hook is
needed:
rot8 # no --hooks, no calibration matrix
Do not also apply a libinput calibration matrix for rotation. The two transforms compose and the result is rotated twice. A calibration matrix is for correcting a panel that is wired wrong, which is a different job.
Mappings are re-evaluated as outputs come and go, because river drops its side of the mapping when the output named is destroyed — so undocking and redocking re-maps rather than silently losing touch. A rule naming an output that is not present says so once:
warning(input): cannot map Wacom HID 5380 Finger to output eDP-9: no output by that name
Window tiling follows rotation on its own: river reports the output's new dimensions and att_wm re-lays out.
att_wm logs one line per device as it appears, which is where the names come from:
info(input): input device: ELAN0501:00 04F3:3060 Touchpad (pointer)
info(input): input device: AT Translated Set 2 keyboard (keyboard)
There is no list-inputs command because the protocol shows input devices to the
window manager alone. Asking a device for something it cannot do is reported
rather than swallowed — libinput answers every request with success, unsupported
or invalid, and att_wm logs the latter two:
warning(input): Logitech USB Receiver does not support tap; setting ignored
Two things do not work everywhere:
- libinput settings need real hardware. river cannot expose libinput devices
when it has no access to them, which is exactly the case running nested inside
another compositor — so tap to click cannot be tested with
run-nested.sh. Keyboard layout and repeat work nested; the rest needs a real session. - river 0.4.5 or newer is required for these three protocols. On an older 0.4 att_wm warns once and carries on, rather than failing to start.
IPC
att_wm listens on $XDG_RUNTIME_DIR/att_wm-$WAYLAND_DISPLAY.sock (override with
ATT_WM_SOCKET). It is a line protocol: send a command line, get ok or
err <reason>. Send subscribe and you get a JSON state object on every
change, starting with one immediately.
A subscriber may keep sending commands on the same connection — the bundled quickshell config uses one socket for both, avoiding a process spawn per click.
State
{
"tag_count": 9,
"tag_names": ["1","2","3","4","5","6","7","8","9"],
"locked": false,
"outputs": [{
"name": "DP-1",
"focused": true,
"tags": 1,
"occupied": 5,
"layout": "master",
"layout_symbol": "[]=",
"nmaster": 1,
"mfact": 0.550,
"x": 0, "y": 0, "width": 2560, "height": 1440,
"usable": { "x": 0, "y": 26, "width": 2560, "height": 1414 },
"windows": [{
"id": "6389ff21ec27eefea415425a8eaa1fd7",
"title": "~/proj",
"app_id": "foot",
"tags": 1,
"focused": true,
"visible": true,
"floating": false,
"fullscreen": false
}]
}]
}
tags and occupied are bitmasks — occupied is the set of tags holding at
least one window, which is what dwm's bar draws its corner squares from.
usable is the area left after layer-shell exclusive zones, i.e. where windows
are actually laid out.
There is no urgent field: river-window-management-v1 has no
attention-request event, so att_wm does not pretend to model urgency.
id is river's window identifier: up to 32 printable ASCII bytes, unique and
never reused, and equal to the ext_foreign_toplevel_handle_v1.identifier of the
same window. It is the handle focus-window and close-window take, so a bar
can act on the window a click names rather than on whatever is focused.
att_wmctl
att_wmctl view 3 # view tag 3 (or: 0x4, mask:4, all)
att_wmctl toggle-view 3
att_wmctl tag 2 # move focused window to tag 2
att_wmctl focus next
att_wmctl focus-window 6389ff21ec27eefea415425a8eaa1fd7
att_wmctl swap prev
att_wmctl zoom
att_wmctl layout tabbed
att_wmctl cycle-layout next
att_wmctl nmaster +1 # +N/-N relative, bare N absolute
att_wmctl mfact 0.6
att_wmctl toggle-float
att_wmctl toggle-fullscreen
att_wmctl focus-output next
att_wmctl send-to-output next
att_wmctl spawn foot -e htop
att_wmctl close
att_wmctl close-window 6389ff21ec27eefea415425a8eaa1fd7
att_wmctl quit # stop att_wm, leave river running
att_wmctl exit-session # end the Wayland session
att_wmctl state # print state once
att_wmctl subscribe # stream state on every change
Exit status is non-zero on an unknown or malformed command.
quickshell
quickshell/ holds a dwm-style bar: clickable tags with occupied indicators,
the layout symbol, the focused window title, and a tab strip in tabbed layout.
quickshell -p /path/to/att_wm/quickshell
AttWm.qml is a singleton wrapping the socket — reconnecting if att_wm restarts,
exposing outputs, focusedOutput, focusedTitle, tagActive() and
tagOccupied(), plus send() for commands. Reuse it in your own bar and ignore
shell.qml.
Note that a bar only works because att_wm binds river_layer_shell_v1. river
refuses to map layer surfaces at all unless the window manager declares support
for them, so under a window manager that skips it, no bar can appear.
A task list must focus windows over this socket, not over
wlr-foreign-toplevel-management. river advertises that protocol, so a bar
built on it shows the right titles and tracks focus correctly — but
river-window-management-v1 has no activate_requested event, and focus is set
solely by the window manager through river_seat_v1.focus_window. An activate
request from a bar therefore reaches nobody and the click does nothing. Use
focus-window <id>; matching up the two protocols is not needed either, since
river's identifier is shared between them.
Layout of the source
| File | Purpose |
|---|---|
src/Wm.zig |
Globals, the manage/render sequence state machine, actions, event loop |
src/Window.zig |
Per-window state; handlers only mutate fields |
src/Output.zig |
Output tags, the per-tag layout/nmaster/mfact, and the tab bar |
src/Seat.zig |
Focus, key and pointer bindings, interactive move/resize |
src/InputManager.zig |
Input devices: keymaps, key repeat, libinput settings |
src/layout.zig |
Pure tiling geometry — no Wayland, unit tested |
src/action.zig |
The Action vocabulary shared by config and IPC |
src/input.zig |
Input configuration types — no Wayland, unit tested |
src/config.zig |
Compile-time configuration |
src/ipc.zig |
Socket server and JSON encoding |
src/shm.zig |
memfd buffers for the tab bar, and drawing into them |
src/Face.zig |
The tab bar's font: fontconfig matching, FreeType rasterising |
src/font.zig |
Measuring and eliding titles — no font needed, unit tested |
src/sys.zig |
Thin Linux syscall wrappers |
The single most important invariant: river only permits window management
state changes between manage_start and manage_finish, and rendering state
changes between those or render_start/render_finish. Every event handler
in att_wm therefore only mutates plain Zig fields and calls needsManage();
Wm.manage() and Wm.render() are the only places that issue protocol
requests. Violating this is a fatal protocol error — river also disconnects a
window manager that takes longer than 3 seconds inside a sequence.
Licence
GPL-3.0-only, as dwm and river are.