Files
att_wm/README.md
T

504 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 <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.