initial commit

This commit is contained in:
2026-07-26 20:47:19 +02:00
commit fff3a5e734
37 changed files with 10781 additions and 0 deletions
+500
View File
@@ -0,0 +1,500 @@
# 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` | 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. 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:
```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 <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
```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 <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.