Files
att_wm/README.md
T

582 lines
24 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. 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 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:
```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 <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,
"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 <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.