19 KiB
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 |
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. A window spawned into monocle or tabbed takes focus as it maps, so it comes up in front instead of hidden behind the current one.
tabbed — same geometry as monocle, minus a strip at the top where att_wm
draws one solid colour block per window, the focused one highlighted. The blocks
are clickable. Titles are not drawn — that would mean a font stack — but the
window list is published over IPC, so a bar can draw a textual 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 and colours,
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 |
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.