# 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. On more than one monitor the tags are split across the screens, so every tag lives on exactly one of them. 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 - multiple monitors, with the tag set split across them: verified on a headless river with two outputs, driven over IPC — the split, moving between screens, windows following their tags across, and views clamped to the screen's own tags - 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+,` / `Mod+.` | 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+h` / `Mod+l` | Focus the screen to the left / right | | `Mod+Shift+h` / `Mod+Shift+l` | Send window to the screen left / right | | `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. Tiled windows are drawn without a border in the stacking layouts, monocle and tabbed alike: with one window filling the area there is nothing for a border to separate it from. Floating windows keep theirs. 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. --- ## Multiple monitors The tag set is **split across the screens**: every tag lives on exactly one of them. With two monitors the left owns tags 1–5 and the right 6–9; with three they get 1–3, 4–6 and 7–9. Screens are ordered by where they sit in the output layout, left to right and then top to bottom — not by the order they were plugged in — so the arrangement follows the monitors on the desk rather than the cables behind it. | | | |---|---| | `Mod+h` / `Mod+l` | Move to the screen on the left / right | | `Mod+Shift+h` / `Mod+Shift+l` | Send the focused window there | | `Mod+7` | Go to the screen tag 7 lives on and show tag 7 | | `Mod+Shift+7` | Send the focused window to tag 7, wherever that is | Because a tag names a screen as well as a workspace, `Mod+7` and `Mod+h` are two ways of doing the same thing, and the tag keys alone are enough to drive the whole desk. Sending a window away leaves you where you are, as dwm's `tagmon` does; the keyboard goes to whatever is left on the screen you are still on. `Mod+0` means everything on *this* screen — a view is always clamped to the tags its screen owns, so no screen can be made to show another's tag. Unplugging a monitor hands its tags to the screens that remain, and the windows wearing those tags follow them there rather than being stranded on a tag nothing can show. Plug it back in and they go home. A laptop with nothing attached owns all nine tags and behaves exactly as it did before, which is why none of this is visible until there is a second screen. Set `split_tags = false` in `config.zig` for dwm's model instead: every screen gets a full set of nine tags of its own, and the tag keys never leave the screen you are on. `Mod+h`/`Mod+l` and `Mod+Shift+h`/`Mod+Shift+l` still move between screens and are then the only way to. Layer surfaces (bars) that do not name an output land on the focused screen. `warp_cursor` is worth turning on here: it pulls the pointer along when the keyboard moves to another screen, including onto an empty one, where there is no window to warp to and the cursor would otherwise be left behind. --- ## 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, 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: ```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, "owned_tags": 31, "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`, `owned_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. `owned_tags` is the slice of the tag set this screen owns — see [Multiple monitors](#multiple-monitors) — so a bar can draw its own screen's tags and leave the rest to the bar on the screen they belong to. It is all nine when `split_tags` is off, so a bar that honours it works either way. `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()`, `tagOccupied()` and `tagOwned()`, plus `send()` for commands. Reuse it in your own bar and ignore `shell.qml`. One bar is created per screen and each draws only the tags its screen owns, via `tagOwned()` — with the tag set split across monitors the other tags belong to the bar next door. 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 and which of them it owns, 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.