initial commit
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user