Files
att_wm/README.md
T
2026-07-26 20:47:19 +02:00

19 KiB
Raw Blame History

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/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

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+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:

    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

{
  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+F1F12 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.