# att_wm A dwm-like window manager for the [river](https://codeberg.org/river/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](https://quickshell.org) 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`/`options` is 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_output` resolves 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 ```sh 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: ```sh #!/bin/sh # ~/.config/river/init quickshell -p ~/.config/quickshell/att_wm & exec att_wm ``` Or, for a one-off: ```sh 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: ```sh 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: ```sh 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+Return` or similar, those never reach att_wm. Either test with `att_wmctl`, or change `mod` in `config.zig` and 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: ```sh 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 says > `0.3.x` you have **river-classic**, a different package that predates this > protocol — it does its own window management and is configured with > `riverctl`, so att_wm cannot drive it. Installing both leaves whichever comes > first on `PATH` in 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 ```nix { 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](#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 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: ```sh zig build -Dconfig=/path/to/my-config.zig ``` ```nix 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: ```zig 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: ```zig 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: ```zig 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: ```zig 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: ```zig .{ .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](https://github.com/efernandesng/rot8), rotates touch along with the screen. Nothing is re-sent on rotation and no rotation hook is needed: ```sh 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 `. 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 ```json { "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 ```sh 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. ```sh 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 `; 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.