2026-08-01 15:03:55 +02:00
2026-07-26 20:47:19 +02:00
2026-08-18 21:30:51 +02:00
2026-07-26 20:47:19 +02:00
2026-08-01 15:03:55 +02:00
2026-07-26 20:47:19 +02:00
2026-07-26 20:47:19 +02:00
2026-08-29 12:55:24 +02:00
2026-08-01 15:03:55 +02:00
2026-07-26 20:47:19 +02:00

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

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+, / 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+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. 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 floating or fullscreen window is not part of the arrangement, so it stays where it is, but it takes focus like any other new window. A window spawned onto a tag nobody is viewing leaves both the order and focus alone.

Dialogs. A window that names a parent — a dialog, a file picker — starts floating, which float_children turns off. Floating windows are stacked by focus, but a window and its parents and children are stacked as one family: the family rises together on the most recent focus any of its members has had, and within it a child is always drawn directly above its parent. Without that, a click on the parent of a modal dialog would bury the dialog behind the one window that will not answer, with no way to raise it again. A dialog opened by a fullscreen window is drawn above the fullscreen layer for the same reason.

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 15 and the right 69; with three they get 13, 46 and 79. 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:

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,
    "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 — 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

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(), 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 <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 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.

S
Description
No description provided
Readme 612 KiB
Languages
Zig 92.8%
QML 4.2%
Nix 2.1%
Shell 0.9%