From fff3a5e734a2db07b5692d52128b8d961badfc3a Mon Sep 17 00:00:00 2001 From: Asmir A Date: Sun, 26 Jul 2026 20:47:19 +0200 Subject: [PATCH] initial commit --- .gitignore | 6 + README.md | 500 ++++++ build.zig | 123 ++ build.zig.zon | 23 + dev.sh | 19 + flake.lock | 61 + flake.nix | 65 + nix/deps.nix | 20 + nix/hm-module.nix | 77 + nix/package.nix | 73 + protocol/river-input-management-v1.xml | 234 +++ protocol/river-layer-shell-v1.xml | 191 +++ protocol/river-libinput-config-v1.xml | 891 +++++++++++ protocol/river-window-management-v1.xml | 1854 +++++++++++++++++++++++ protocol/river-xkb-bindings-v1.xml | 314 ++++ protocol/river-xkb-config-v1.xml | 277 ++++ quickshell/AttWm.qml | 115 ++ quickshell/qmldir | 1 + quickshell/shell.qml | 175 +++ run-nested.sh | 61 + src/InputManager.zig | 755 +++++++++ src/Output.zig | 297 ++++ src/Seat.zig | 461 ++++++ src/Window.zig | 239 +++ src/Wm.zig | 1427 +++++++++++++++++ src/action.zig | 323 ++++ src/color.zig | 89 ++ src/config.zig | 235 +++ src/ctl.zig | 147 ++ src/input.zig | 281 ++++ src/ipc.zig | 398 +++++ src/layout.zig | 179 +++ src/main.zig | 73 + src/shm.zig | 149 ++ src/sock.zig | 19 + src/sys.zig | 204 +++ src/test.zig | 425 ++++++ 37 files changed, 10781 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 build.zig create mode 100644 build.zig.zon create mode 100755 dev.sh create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 nix/deps.nix create mode 100644 nix/hm-module.nix create mode 100644 nix/package.nix create mode 100644 protocol/river-input-management-v1.xml create mode 100644 protocol/river-layer-shell-v1.xml create mode 100644 protocol/river-libinput-config-v1.xml create mode 100644 protocol/river-window-management-v1.xml create mode 100644 protocol/river-xkb-bindings-v1.xml create mode 100644 protocol/river-xkb-config-v1.xml create mode 100644 quickshell/AttWm.qml create mode 100644 quickshell/qmldir create mode 100644 quickshell/shell.qml create mode 100755 run-nested.sh create mode 100644 src/InputManager.zig create mode 100644 src/Output.zig create mode 100644 src/Seat.zig create mode 100644 src/Window.zig create mode 100644 src/Wm.zig create mode 100644 src/action.zig create mode 100644 src/color.zig create mode 100644 src/config.zig create mode 100644 src/ctl.zig create mode 100644 src/input.zig create mode 100644 src/ipc.zig create mode 100644 src/layout.zig create mode 100644 src/main.zig create mode 100644 src/shm.zig create mode 100644 src/sock.zig create mode 100644 src/sys.zig create mode 100644 src/test.zig diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..add0e46 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/.zig-cache/ +/zig-out/ +/zig-pkg/ +/result +/result-* +core.* diff --git a/README.md b/README.md new file mode 100644 index 0000000..dca0a0d --- /dev/null +++ b/README.md @@ -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 `. 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 `; 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. diff --git a/build.zig b/build.zig new file mode 100644 index 0000000..ceb2bd6 --- /dev/null +++ b/build.zig @@ -0,0 +1,123 @@ +const std = @import("std"); +const Build = std.Build; + +const Scanner = @import("wayland").Scanner; + +pub fn build(b: *Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const strip = b.option(bool, "strip", "Omit debug information") orelse false; + const pie = b.option(bool, "pie", "Build a Position Independent Executable") orelse false; + + // Allow packagers and Nix users to swap in their own config.zig without + // patching the source tree, which is how one configures dwm too. + const config_path = b.option( + []const u8, + "config", + "Path to a config.zig to use instead of src/config.zig", + ); + + const scanner = Scanner.create(b, .{}); + + scanner.addCustomProtocol(b.path("protocol/river-window-management-v1.xml")); + scanner.addCustomProtocol(b.path("protocol/river-xkb-bindings-v1.xml")); + scanner.addCustomProtocol(b.path("protocol/river-layer-shell-v1.xml")); + scanner.addCustomProtocol(b.path("protocol/river-input-management-v1.xml")); + scanner.addCustomProtocol(b.path("protocol/river-libinput-config-v1.xml")); + scanner.addCustomProtocol(b.path("protocol/river-xkb-config-v1.xml")); + + // Besides the river protocols we are an ordinary Wayland client: wl_shm and + // wl_compositor back the tab bar, wl_seat lets us click on it. + scanner.generate("wl_compositor", 4); + scanner.generate("wl_shm", 1); + scanner.generate("wl_seat", 7); + scanner.generate("wl_output", 4); + + scanner.generate("river_window_manager_v1", 4); + scanner.generate("river_xkb_bindings_v1", 3); + scanner.generate("river_layer_shell_v1", 1); + scanner.generate("river_input_manager_v1", 1); + scanner.generate("river_libinput_config_v1", 1); + scanner.generate("river_xkb_config_v1", 1); + + const wayland = b.createModule(.{ .root_source_file = scanner.result }); + const xkbcommon = b.dependency("xkbcommon", .{}).module("xkbcommon"); + + // action.zig is deliberately free of any Wayland or compositor state so + // that config.zig can import it without a dependency cycle back into the + // window manager. + const action = b.createModule(.{ + .root_source_file = b.path("src/action.zig"), + }); + action.addImport("xkbcommon", xkbcommon); + + // Likewise Wayland-free, for the same reason: config.zig declares input + // device settings with these types, and the protocol objects they end up on + // live in src/InputManager.zig. + const input = b.createModule(.{ + .root_source_file = b.path("src/input.zig"), + }); + + const config = b.createModule(.{ + .root_source_file = if (config_path) |p| .{ .cwd_relative = p } else b.path("src/config.zig"), + }); + config.addImport("action", action); + config.addImport("input", input); + config.addImport("xkbcommon", xkbcommon); + + { + const exe = b.addExecutable(.{ + .name = "att_wm", + .root_module = b.createModule(.{ + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + .strip = strip, + .link_libc = true, + }), + }); + exe.root_module.addImport("wayland", wayland); + exe.root_module.addImport("xkbcommon", xkbcommon); + exe.root_module.addImport("action", action); + exe.root_module.addImport("input", input); + exe.root_module.addImport("config", config); + exe.root_module.linkSystemLibrary("wayland-client", .{}); + exe.root_module.linkSystemLibrary("xkbcommon", .{}); + exe.pie = pie; + + b.installArtifact(exe); + } + + { + const ctl = b.addExecutable(.{ + .name = "att_wmctl", + .root_module = b.createModule(.{ + .root_source_file = b.path("src/ctl.zig"), + .target = target, + .optimize = optimize, + .strip = strip, + .link_libc = true, + }), + }); + ctl.pie = pie; + b.installArtifact(ctl); + } + + { + const tests = b.addTest(.{ + .root_module = b.createModule(.{ + .root_source_file = b.path("src/test.zig"), + .target = target, + .optimize = optimize, + .link_libc = true, + }), + }); + tests.root_module.addImport("action", action); + tests.root_module.addImport("input", input); + tests.root_module.linkSystemLibrary("xkbcommon", .{}); + + const run_tests = b.addRunArtifact(tests); + b.step("test", "Run unit tests").dependOn(&run_tests.step); + } +} diff --git a/build.zig.zon b/build.zig.zon new file mode 100644 index 0000000..2733dfe --- /dev/null +++ b/build.zig.zon @@ -0,0 +1,23 @@ +.{ + .name = .att_wm, + .version = "0.1.0", + .minimum_zig_version = "0.16.0", + .paths = .{ + "build.zig", + "build.zig.zon", + "protocol", + "src", + "README.md", + }, + .dependencies = .{ + .wayland = .{ + .url = "https://codeberg.org/ifreund/zig-wayland/archive/v0.6.0.tar.gz", + .hash = "wayland-0.6.0-lQa1kqz8AQADQmdNJsNhLoNHcnEGEUjrOaPV-dtEnEmX", + }, + .xkbcommon = .{ + .url = "https://codeberg.org/ifreund/zig-xkbcommon/archive/v0.4.0.tar.gz", + .hash = "xkbcommon-0.4.0-VDqIe0i2AgDRsok2GpMFYJ8SVhQS10_PI2M_CnHXsJJZ", + }, + }, + .fingerprint = 0xaef897c1fba5ef4c, +} diff --git a/dev.sh b/dev.sh new file mode 100755 index 0000000..5701208 --- /dev/null +++ b/dev.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Enter the dev shell without re-fetching nixpkgs. +# +# flake.nix pins nixos-26.05. When the machine already has that revision in its +# store (NixOS systems built from the same channel usually do), pointing the +# input at the local path skips the tarball fetch entirely and makes +# `nix develop` near-instant. Falls back to the pinned input otherwise. +set -euo pipefail + +local_nixpkgs=$(nix registry list 2>/dev/null | + awk '$1 == "system" && $2 == "flake:nixpkgs" { print $3 }' | + sed 's/?.*//') + +args=() +if [[ -n ${local_nixpkgs} && -d ${local_nixpkgs#path:} ]]; then + args=(--override-input nixpkgs "${local_nixpkgs}") +fi + +exec nix develop "${args[@]}" --command "${@:-bash}" diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..9b788fa --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1784160687, + "narHash": "sha256-iYL/bixrb6FlHFu/gIuBYzq6c6lM5AAXsXNSWXtIgQc=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "4382ed2b7a6839d4280a9b386db49cbc5907414d", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-26.05", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..071e549 --- /dev/null +++ b/flake.nix @@ -0,0 +1,65 @@ +{ + description = "att_wm - a dwm-like window manager for the river Wayland compositor"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = + { + self, + nixpkgs, + flake-utils, + }: + flake-utils.lib.eachDefaultSystem ( + system: + let + pkgs = nixpkgs.legacyPackages.${system}; + in + { + packages = rec { + att_wm = pkgs.callPackage ./nix/package.nix { + inherit self; + zig = pkgs.zig_0_16; + }; + default = att_wm; + }; + + devShells.default = pkgs.mkShell { + strictDeps = false; + nativeBuildInputs = with pkgs; [ + zig_0_16 + zls + pkg-config + wayland-scanner + ]; + buildInputs = with pkgs; [ + wayland + wayland-protocols + libxkbcommon + ]; + packages = with pkgs; [ + river + quickshell + zon2nix + ]; + }; + + formatter = pkgs.nixfmt-rfc-style; + } + ) + // { + overlays.default = final: prev: { + att_wm = final.callPackage ./nix/package.nix { + inherit self; + zig = final.zig_0_16; + }; + }; + + # `homeModules` is the name nix recognises; the older spelling is kept + # so existing configs importing it keep working. + homeModules.default = import ./nix/hm-module.nix self; + homeManagerModules.default = import ./nix/hm-module.nix self; + }; +} diff --git a/nix/deps.nix b/nix/deps.nix new file mode 100644 index 0000000..9a0e00c --- /dev/null +++ b/nix/deps.nix @@ -0,0 +1,20 @@ +# generated by zon2nix (https://github.com/nix-community/zon2nix) + +{ linkFarm, fetchzip, fetchgit }: + +linkFarm "zig-packages" [ + { + name = "wayland-0.6.0-lQa1kqz8AQADQmdNJsNhLoNHcnEGEUjrOaPV-dtEnEmX"; + path = fetchzip { + url = "https://codeberg.org/ifreund/zig-wayland/archive/v0.6.0.tar.gz"; + hash = "sha256-3m/ITNhZUJ/5uD/Tqm+0uZSktGoYgWF5oldOqOCUkIE="; + }; + } + { + name = "xkbcommon-0.4.0-VDqIe0i2AgDRsok2GpMFYJ8SVhQS10_PI2M_CnHXsJJZ"; + path = fetchzip { + url = "https://codeberg.org/ifreund/zig-xkbcommon/archive/v0.4.0.tar.gz"; + hash = "sha256-zQkmP/cuhAtjOLqYS5D15khKzpqyhbyZ0TD6/8jOkqE="; + }; + } +] diff --git a/nix/hm-module.nix b/nix/hm-module.nix new file mode 100644 index 0000000..b0374d5 --- /dev/null +++ b/nix/hm-module.nix @@ -0,0 +1,77 @@ +self: +{ + config, + lib, + pkgs, + ... +}: + +let + cfg = config.programs.att_wm; +in +{ + options.programs.att_wm = { + enable = lib.mkEnableOption "att_wm, a dwm-like window manager for river"; + + package = lib.mkOption { + type = lib.types.package; + default = self.packages.${pkgs.stdenv.hostPlatform.system}.att_wm; + defaultText = lib.literalMD "the flake's `att_wm` package"; + description = "The att_wm package to use."; + }; + + settings = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + example = lib.literalExpression "./config.zig"; + description = '' + A replacement for att_wm's `src/config.zig`. att_wm is configured at + compile time in the manner of dwm, so setting this rebuilds it. + ''; + }; + + autostart = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + example = [ "quickshell" ]; + description = '' + Commands appended to river's init script, run once the session starts. + att_wm itself is always started last and kept in the foreground. + ''; + }; + + riverPackage = lib.mkOption { + type = lib.types.package; + default = pkgs.river; + description = "The river package the init script is written for."; + }; + }; + + config = lib.mkIf cfg.enable ( + let + att_wm = + if cfg.settings == null then + cfg.package + else + cfg.package.override { configFile = cfg.settings; }; + in + { + home.packages = [ + att_wm + cfg.riverPackage + ]; + + # river runs this executable on startup and expects the window manager to + # connect; keeping att_wm in the foreground ties the session's lifetime to + # it, so quitting att_wm ends the session cleanly. + xdg.configFile."river/init" = { + executable = true; + text = '' + #!${pkgs.runtimeShell} + ${lib.concatMapStringsSep "\n" (c: "${c} &") cfg.autostart} + exec ${lib.getExe att_wm} + ''; + }; + } + ); +} diff --git a/nix/package.nix b/nix/package.nix new file mode 100644 index 0000000..5c9b604 --- /dev/null +++ b/nix/package.nix @@ -0,0 +1,73 @@ +{ + lib, + self, + stdenv, + callPackage, + zig, + pkg-config, + wayland, + wayland-protocols, + wayland-scanner, + libxkbcommon, + # Path to a replacement src/config.zig. dwm-style compile-time configuration: + # att_wm.override { configFile = ./my-config.zig; } + # Deliberately not called `config`: callPackage would fill that from the + # nixpkgs config set. + configFile ? null, +}: + +let + deps = callPackage ./deps.nix { }; +in +stdenv.mkDerivation { + pname = "att_wm"; + version = "0.1.0"; + + src = lib.cleanSource self; + + strictDeps = true; + + nativeBuildInputs = [ + zig + pkg-config + wayland-scanner + ]; + + buildInputs = [ + wayland + wayland-protocols + wayland-scanner + libxkbcommon + ]; + + zigBuildFlags = [ + "--system" + "${deps}" + "-Dpie" + ] + ++ lib.optional (configFile != null) "-Dconfig=${configFile}"; + + doCheck = true; + + # The check phase gets its own flag list, so it needs --system too; without + # it `zig build test` tries to fetch the dependencies over the network and + # fails in the sandbox. + zigCheckFlags = [ + "--system" + "${deps}" + ]; + + meta = { + description = "A dwm-like window manager for the river Wayland compositor"; + longDescription = '' + att_wm implements river-window-management-v1, providing dwm's window + management model — tags, a master/stack layout, monocle and tabbed + layouts — on top of river 0.4's non-monolithic compositor. Tag and window + state is published over a JSON-lines unix socket for bars such as + quickshell, and driven back the other way with the att_wmctl CLI. + ''; + license = lib.licenses.gpl3Only; + platforms = lib.platforms.linux; + mainProgram = "att_wm"; + }; +} diff --git a/protocol/river-input-management-v1.xml b/protocol/river-input-management-v1.xml new file mode 100644 index 0000000..81f707c --- /dev/null +++ b/protocol/river-input-management-v1.xml @@ -0,0 +1,234 @@ + + + + SPDX-FileCopyrightText: © 2025 Isaac Freund + SPDX-License-Identifier: MIT + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to + deal in the Software without restriction, including without limitation the + rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + sell copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + IN THE SOFTWARE. + + + + This protocol supports creating/destroying seats, assigning input devices to + seats, and configuring input devices (e.g. setting keyboard repeat rate). + + The key words "must", "must not", "required", "shall", "shall not", + "should", "should not", "recommended", "may", and "optional" in this + document are to be interpreted as described in IETF RFC 2119. + + + + + Input manager global interface. + + + + + + + + + This request indicates that the client no longer wishes to receive + events on this object. + + The Wayland protocol is asynchronous, which means the server may send + further events until the stop request is processed. The client must wait + for a river_input_manager_v1.finished event before destroying this + object. + + + + + + This event indicates that the server will send no further events on this + object. The client should destroy the object. See + river_input_manager_v1.destroy for more information. + + + + + + This request should be called after the finished event has been received + to complete destruction of the object. + + It is a protocol error to make this request before the finished event + has been received. + + If a client wishes to destroy this object it should send a + river_input_manager_v1.stop request and wait for a + river_input_manager_v1.finished event. Once the finished event is + received it is safe to destroy this object and any other objects created + through this interface. + + + + + + Create a new seat with the given name. Has no effect if a seat with the + given name already exists. + + The default seat with name "default" always exists and does not need to + be explicitly created. + + + + + + + Destroy the seat with the given name. Has no effect if a seat with the + given name does not exist. + + The default seat with name "default" cannot be destroyed and attempting + to destroy it will have no effect. + + Any input devices assigned to the destroyed seat at the time of + destruction are assigned to the default seat. + + + + + + + A new input device has been created. + + + + + + + + An input device represents a physical keyboard, mouse, touchscreen, or + drawing tablet tool. It is assigned to exactly one seat at a time. + By default, all input devices are assigned to the default seat. + + + + + + + + + + + This request indicates that the client will no longer use the input + device object and that it may be safely destroyed. + + + + + + This event indicates that the input device has been removed. + + The server will send no further events on this object and ignore any + request (other than river_input_device_v1.destroy) made after this event is + sent. The client should destroy this object with the + river_input_device_v1.destroy request to free up resources. + + + + + + + + + + + + + The type of the input device. This event is sent once when the + river_input_device_v1 object is created. The device type cannot + change during the lifetime of the object. + + + + + + + The name of the input device. This event is sent once when the + river_input_device_v1 object is created. The device name cannot + change during the lifetime of the object. + + + + + + + Assign the input device to a seat. All input devices not explicitly + assigned to a seat are considered assigned to the default seat. + + Has no effect if a seat with the given name does not exist. + + + + + + + Set repeat rate and delay for a keyboard input device. Has no effect if + the device is not a keyboard. + + Negative values for either rate or delay are illegal. A rate of zero + will disable any repeating (regardless of the value of delay). + + + + + + + + Set the scroll factor for a pointer input device. Has no effect if the + device is not a pointer. + + For example, a factor of 0.5 will make scrolling twice as slow while a + factor of 3.0 will make scrolling 3 times as fast. + + Setting a scroll factor less than 0 is a protocol error. + + + + + + + Map the input device to the given output. Has no effect if the device is + not a pointer, touch, or tablet device. + + If mapped to both an output and a rectangle, the rectangle has priority. + + Passing null clears an existing mapping. + + + + + + + Map the input device to the given rectangle in the global compositor + coordinate space. Has no effect if the device is not a pointer, touch, + or tablet device. + + If mapped to both an output and a rectangle, the rectangle has priority. + + Width and height must be greater than or equal to 0. + + Passing 0 for width or height clears an existing mapping. + + + + + + + + diff --git a/protocol/river-layer-shell-v1.xml b/protocol/river-layer-shell-v1.xml new file mode 100644 index 0000000..0167e9d --- /dev/null +++ b/protocol/river-layer-shell-v1.xml @@ -0,0 +1,191 @@ + + + + SPDX-FileCopyrightText: © 2025 Isaac Freund + SPDX-License-Identifier: MIT + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to + deal in the Software without restriction, including without limitation the + rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + sell copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + IN THE SOFTWARE. + + + + This protocol allows the river-window-management-v1 window manager to + support the wlr-layer-shell-unstable-v1 protocol. + + The key words "must", "must not", "required", "shall", "shall not", + "should", "should not", "recommended", "may", and "optional" in this + document are to be interpreted as described in IETF RFC 2119. + + + + + This global interface should only be advertised to the client if the + river_window_manager_v1 global is also advertised. Binding this interface + indicates that the window manager supports layer shell. + + If the window manager does not bind this interface, the compositor should + not allow clients to map layer surfaces. This can be achieved by + closing layer surfaces immediately. + + + + + + + + + This request indicates that the client will no longer use the + river_layer_shell_v1 object. + + + + + + It is a protocol error to make this request more than once for a given + river_output_v1 object. + + + + + + + + It is a protocol error to make this request more than once for a given + river_seat_v1 object. + + + + + + + + + The lifetime of this object is tied to the corresponding river_output_v1. + This object is made inert when the river_output_v1.removed event is sent + and should be destroyed. + + + + + This request indicates that the client will no longer use the + river_layer_shell_output_v1 object and that it may be safely destroyed. + + This request should be made after the river_output_v1.removed event is + received to complete destruction of the output. + + + + + + This event indicates the area of the output remaining after subtracting + the exclusive zones of layer surfaces. Exclusive zones are a hint, the + window manager is free to ignore this area hint if it wishes. + + The x and y values are in the global coordinate space, not relative to + the position of the output. + + This event will be followed by a manage_start event after all other new + state has been sent by the server. + + + + + + + + + + Mark this output as the default for new layer surfaces which do not + request a specific output themselves. This request overrides any + previous set_default request on any river_layer_shell_output_v1 object. + + If no set_default request is made or if the default output is destroyed, + the default output is undefined until the next set_default request. + + This request modifies window management state and may only be made as + part of a manage sequence, see the river_window_manager_v1 description. + + + + + + + The lifetime of this object is tied to the corresponding river_seat_v1. + This object is made inert when the river_seat_v1.removed event is sent and + should be destroyed. + + + + + This request indicates that the client will no longer use the + river_layer_shell_seat_v1 object and that it may be safely destroyed. + + This request should be made after the river_seat_v1.removed event is + received to complete destruction of the seat. + + + + + + A layer shell surface will be given exclusive keyboard focus at the end + of the manage sequence in which this event is sent. The window manager + may want to update window decorations or similar to indicate that no + window is focused. + + Until the focus_non_exclusive or focus_none event is sent, all window + manager requests to change focus are ignored. + + This event will be followed by a manage_start event after all other new + state has been sent by the server. + + + + + + A layer shell surface will be given non-exclusive keyboard focus at the + end of the manage sequence in which this event is sent. The window + manager may want to update window decorations or similar to indicate + that no window is focused. + + The window manager continues to control focus and may choose to focus a + different window/shell surface at any time. If the window manager sets + focus during the same manage sequence in which this event is sent, the + layer surface will not be focused. + + If the layer surface with non-exclusive focus is closed or the window + manager chooses to move focus away from the layer surface, a focus_none + event will be sent in the next manage sequence. + + This event will be followed by a manage_start event after all other new + state has been sent by the server. + + + + + + No layer shell surface will have keyboard focus at the end of the manage + sequence in which this event is sent. The window manager may want to + return focus to whichever window last had focus, for example. + + This event will be followed by a manage_start event after all other new + state has been sent by the server. + + + + diff --git a/protocol/river-libinput-config-v1.xml b/protocol/river-libinput-config-v1.xml new file mode 100644 index 0000000..5e38b08 --- /dev/null +++ b/protocol/river-libinput-config-v1.xml @@ -0,0 +1,891 @@ + + + + SPDX-FileCopyrightText: © 2025 Isaac Freund + SPDX-License-Identifier: MIT + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to + deal in the Software without restriction, including without limitation the + rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + sell copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + IN THE SOFTWARE. + + + + This protocol exposes libinput device configuration APIs. The libinput + documentation should be referred to for detailed information on libinput's + behavior. + + Note that the compositor will not be able to expose libinput devices through + this protocol when it does not have access to the hardware, for example when + running nested in another Wayland compositor or X11 session. + + This protocol is designed so that (hopefully) any backwards compatible + change to libinput's API can be matched with a backwards compatible change + to this protocol. + + Note: the libinput API uses floating point types (float and double in C) + which are not (yet?) natively supported by the Wayland protocol. However, + the Wayland protocol does support sending arbitrary bytes through the array + argument type. This protocol uses e.g. type="array" summary="double" to + indicate a native-endian IEEE-754 64-bit double value. + + The key words "must", "must not", "required", "shall", "shall not", + "should", "should not", "recommended", "may", and "optional" in this + document are to be interpreted as described in IETF RFC 2119. + + + + + Global interface for configuring libinput devices. This global should + only be advertised if river_input_manager_v1 is advertised as well. + + + + + + + + + + This request indicates that the client no longer wishes to receive + events on this object. + + The Wayland protocol is asynchronous, which means the server may send + further events until the stop request is processed. The client must wait + for a river_libinput_config_v1.finished event before destroying this + object. + + + + + + This event indicates that the server will send no further events on this + object. The client should destroy the object. See + river_libinput_config_v1.destroy for more information. + + + + + + This request should be called after the finished event has been received + to complete destruction of the object. + + It is a protocol error to make this request before the finished event + has been received. + + If a client wishes to destroy this object it should send a + river_libinput_config_v1.stop request and wait for a + river_libinput_config_v1.finished event. Once the finished event is + received it is safe to destroy this object and any other objects created + through this interface. + + + + + + A new libinput device has been created. Not every river_input_device_v1 + is necessarily a libinput device as well. + + + + + + + Create a acceleration config which can be applied + with river_libinput_device_v1.apply_accel_config. + + + + + + + + + In general, *_support events will be sent exactly once directly after the + river_libinput_device_v1 is created. *_default events will be sent after + *_support events if the config option is supported, and *_current events + willl be sent after the *_default events and again whenever the config + option is changed. + + + + + + + + + This request indicates that the client will no longer use the input + device object and that it may be safely destroyed. + + + + + + This event indicates that the libinput device has been removed. + + The server will send no further events on this object and ignore any + request (other than river_libinput_device_v1.destroy) made after this + event is sent. The client should destroy this object with the + river_libinput_device_v1.destroy request to free up resources. + + + + + + The river_input_device_v1 corresponding to this libinput device. + This event will always be the first event sent on the + river_libinput_device_v1 object, and it will be sent exactly once. + + + + + + + + + + + + + Supported send events modes. + + + + + + + Default send events mode. + + + + + + + Current send events mode. + + + + + + + Set the send events mode for the device. + + + + + + + + + + + + + The number of fingers supported for tap-to-click/drag. + If finger_count is 0, tap-to-click and drag are unsupported. + + + + + + + Default tap-to-click state. + + + + + + + Current tap-to-click state. + + + + + + + Configure tap-to-click on this device, with a default mapping of + 1, 2, 3 finger tap mapping to left, right, middle click, respectively. + + + + + + + + + + + + + Default tap-to-click button map. + + + + + + + Current tap-to-click button map. + + + + + + + Set the finger number to button number mapping for tap-to-click. The + default mapping on most devices is to have a 1, 2 and 3 finger tap to + map to the left, right and middle button, respectively. + + + + + + + + + + + + + Default tap-and-drag state. + + + + + + + Current tap-and-drag state. + + + + + + + Configure tap-and-drag functionality on the device. + + + + + + + + + + + + + + Default drag lock state. + + + + + + + Current drag lock state. + + + + + + + Configure drag-lock during tapping on this device. When enabled, a + finger may be lifted and put back on the touchpad and the drag process + continues. A timeout for lifting the finger is optional. When disabled, + lifting the finger during a tap-and-drag will immediately stop the drag. + See the libinput documentation for more details. + + + + + + + + The number of fingers supported for three/four finger drag. + If finger_count is less than 3, three finger drag is unsupported. + + + + + + + + + + + + + Default three finger drag state. + + + + + + + Current three finger drag state. + + + + + + + Configure three finger drag functionality for the device. + + + + + + + + A calibration matrix is supported if the supported argument is non-zero. + + + + + + + Default calibration matrix. + + + + + + + Current calibration matrix. + + + + + + + Set calibration matrix. + + + + + + + + + + + + + + + + + + + + + + Supported acceleration profiles. + + + + + + + Default acceleration profile. + + + + + + + Current acceleration profile. + + + + + + + Set the acceleration profile. + + + + + + + + Default acceleration speed. + + + + + + + Current acceleration speed. + + + + + + + Set the acceleration speed within a range of [-1, 1], where 0 is + the default acceleration for this device, -1 is the slowest acceleration + and 1 is the maximum acceleration available on this device. + + + + + + + + Apply a pointer accleration config. + + + + + + + + Natural scroll is supported if the supported argument is non-zero. + + + + + + + + + + + + Default natural scroll. + + + + + + + Current natural scroll. + + + + + + + Set natural scroll state. + + + + + + + + Left-handed mode is supported if the supported argument is non-zero. + + + + + + + + + + + + Default left-handed mode. + + + + + + + Current left-handed mode. + + + + + + + Set left-handed mode state. + + + + + + + + + + + + + + + + + + + + The click methods supported by the device. + + + + + + + Default click method. + + + + + + + Current click method. + + + + + + + Set click method. + + + + + + + + + + + + + Default clickfinger button map. + Supported if click_methods.clickfinger is supported. + + + + + + + Current clickfinger button map. + Supported if click_methods.clickfinger is supported. + + + + + + + Set clickfinger button map. + Supported if click_methods.clickfinger is supported. + + + + + + + + Middle mouse button emulation is supported if the supported argument is + non-zero. + + + + + + + + + + + + Default middle mouse button emulation. + + + + + + + Current middle mouse button emulation. + + + + + + + Set middle mouse button emulation state. + + + + + + + + + + + + + + + + + + + + + + The scroll methods supported by the device. + + + + + + + Default scroll method. + + + + + + + Current scroll method. + + + + + + + Set scroll method. + + + + + + + + Default scroll button. + Supported if scroll_methods.on_button_down is supported. + + + + + + + Current scroll button. + Supported if scroll_methods.on_button_down is supported. + + + + + + + Set scroll button. + Supported if scroll_methods.on_button_down is supported. + + + + + + + + + + + + + Default scroll button lock state. + Supported if scroll_methods.on_button_down is supported. + + + + + + + Current scroll button lock state. + Supported if scroll_methods.on_button_down is supported. + + + + + + + Set scroll button lock state. + Supported if scroll_methods.on_button_down is supported. + + + + + + + + Disable-while-typing is supported if the supported argument is + non-zero. + + + + + + + + + + + + Default disable-while-typing state. + + + + + + + Current disable-while-typing state. + + + + + + + Set disable-while-typing state. + + + + + + + + Disable-while-trackpointing is supported if the supported argument is + non-zero. + + + + + + + + + + + + Default disable-while-trackpointing state. + + + + + + + Current disable-while-trackpointing state. + + + + + + + Set disable-while-trackpointing state. + + + + + + + + Rotation is supported if the supported argument is non-zero. + + + + + + + Default rotation angle. + + + + + + + Current rotation angle. + + + + + + + Set rotation angle in degrees clockwise off the logical neutral + position. Angle must be in the range [0-360). + + + + + + + + + The result returned by libinput on setting configuration for a device. + + + + + + + + + This request indicates that the client will no longer use the accel + config object and that it may be safely destroyed. + + + + + + + + + + + + Defines the acceleration function for a given movement type + in an acceleration configuration with custom accel profile. + + + + + + + + + + + The result returned by libinput on setting configuration for a device. + + + + + The configuration was successfully applied to the device. + + + + + + The configuration is unsupported by the device and was ignored. + + + + + + The configuration is invalid and was ignored. + + + + diff --git a/protocol/river-window-management-v1.xml b/protocol/river-window-management-v1.xml new file mode 100644 index 0000000..baa96d7 --- /dev/null +++ b/protocol/river-window-management-v1.xml @@ -0,0 +1,1854 @@ + + + + SPDX-FileCopyrightText: © 2024 Isaac Freund + SPDX-License-Identifier: MIT + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to + deal in the Software without restriction, including without limitation the + rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + sell copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + IN THE SOFTWARE. + + + + This protocol allows a single "window manager" client to determine the + window management policy of the compositor. State is globally + double-buffered allowing for frame perfect state changes involving multiple + windows. + + The key words "must", "must not", "required", "shall", "shall not", + "should", "should not", "recommended", "may", and "optional" in this + document are to be interpreted as described in IETF RFC 2119. + + + + + This global interface should only be advertised to the window manager + process. Only one window management client may be active at a time. The + compositor should use the unavailable event if necessary to enforce this. + + There are two disjoint categories of state managed by this protocol: + + Window management state influences the communication between the + compositor and individual windows (e.g. xdg_toplevels). Window management + state includes window dimensions, fullscreen state, keyboard focus, + keyboard bindings, and more. + + Rendering state only affects the rendered output of the compositor and + does not influence communication between the compositor and individual + windows. Rendering state includes the position and rendering order of + windows, shell surfaces, decoration surfaces, borders, and more. + + Window management state may only be modified by the window manager as part + of a manage sequence. A manage sequence is started with the manage_start + event and ended with the manage_finish request. It is a protocol error to + modify window management state outside of a manage sequence. + + A manage sequence is always followed by at least one render sequence. A + render sequence is started with the render_start event and ended with the + render_finish request. + + Rendering state may be modified by the window manager during a manage + sequence or a render sequence. Regardless of when the rendering state is + modified, it is applied with the next render_finish request. It is a + protocol error to modify rendering state outside of a manage or render + sequence. + + The server will start a manage sequence by sending new state and the + manage_start event as soon as possible whenever there is a change in state + that must be communicated with the window manager. + + If the window manager client needs to ensure a manage sequence is started + due to a state change the compositor is not aware of, it may send the + manage_dirty request. + + The server will start a render sequence by sending new state and the + render_start event as soon as possible whenever there is a change in + window dimensions that must be communicated with the window manager. + Multiple render sequences may be made consecutively without a manage + sequence in between, for example if a window independently changes its own + dimensions. + + To summarize, the main loop of this protocol is as follows: + + 1. The server sends events indicating all changes since the last + manage sequence followed by the manage_start event. + + 2. The client sends requests modifying window management state or + rendering state (as defined above) followed by the manage_finish + request. + + 3. The server sends new state to windows and waits for responses. + + 4. The server sends new window dimensions to the client followed by the + render_start event. + + 5. The client sends requests modifying rendering state (as defined above) + followed by the render_finish request. + + 6. If window dimensions change, loop back to step 4. + If state that requires a manage sequence changes or if the client makes + a manage_dirty request, loop back to step 1. + + For the purposes of frame perfection, the server may delay rendering new + state committed by the windows in step 3 until after step 5 is finished. + + It is a protocol error for the client to make a manage_finish or + render_finish request that violates this ordering. + + + + + + + + + + + This event indicates that window management is not available to the + client, perhaps due to another window management client already running. + The circumstances causing this event to be sent are compositor policy. + + If sent, this event is guaranteed to be the first and only event sent by + the server. + + The server will send no further events on this object. The client should + destroy this object and all objects created through this interface. + + + + + + This request indicates that the client no longer wishes to receive + events on this object. + + The Wayland protocol is asynchronous, which means the server may send + further events until the stop request is processed. The client must wait + for a river_window_manager_v1.finished event before destroying this + object. + + + + + + This event indicates that the server will send no further events on this + object. The client should destroy the object. See + river_window_manager_v1.destroy for more information. + + + + + + This request should be called after the finished event has been received + to complete destruction of the object. + + If a client wishes to destroy this object it should send a + river_window_manager_v1.stop request and wait for a + river_window_manager_v1.finished event. Once the finished event is + received it is safe to destroy this object and any other objects created + through this interface. + + + + + + This event indicates that the server has sent events indicating all + state changes since the last manage sequence. + + In response to this event, the client should make requests modifying + window management state as it chooses. Then, the client must make the + manage_finish request. + + See the description of the river_window_manager_v1 interface for a + complete overview of the manage/render sequence loop. + + + + + + This request indicates that the client has made all changes to window + management state it wishes to include in the current manage sequence and + that the server should atomically send these state changes to the + windows and continue with the manage sequence. + + After sending this request, it is a protocol error for the client to + make further changes to window management state until the next + manage_start event is received. + + See the description of the river_window_manager_v1 interface for a + complete overview of the manage/render sequence loop. + + + + + + This request ensures a manage sequence is started and that a + manage_start event is sent by the server. If this request is made during + an ongoing manage sequence, a new manage sequence will be started as + soon as the current one is completed. + + The client may want to use this request due to an internal state change + that the compositor is not aware of (e.g. a dbus event) which should + affect window management or rendering state. + + + + + + This event indicates that the server has sent all + river_window_v1.dimensions events necessary. + + In response to this event, the client should make requests modifying + rendering state as it chooses. Then, the client must make the + render_finish request. + + See the description of the river_window_manager_v1 interface for a + complete overview of the manage/render sequence loop. + + + + + + This request indicates that the client has made all changes to rendering + state it wishes to include in the current manage sequence and that the + server should atomically apply and display these state changes to the + user. + + After sending this request, it is a protocol error for the client to + make further changes to rendering state until the next manage_start or + render_start event is received, whichever comes first. + + See the description of the river_window_manager_v1 interface for a + complete overview of the manage/render sequence loop. + + + + + + This event indicates that the session has been locked. + + The window manager may wish to restrict which key bindings are available + while locked or otherwise use this information. + + This event will be followed by a manage_start event after all other new + state has been sent by the server. + + + + + + This event indicates that the session has been unlocked. + + This event will be followed by a manage_start event after all other new + state has been sent by the server. + + + + + + A new window has been created. + + This event will be followed by a manage_start event after all other new + state has been sent by the server. + + + + + + + A new logical output has been created, perhaps due to a new physical + monitor being plugged in or perhaps due to a change in configuration. + + This event will be followed by river_output_v1.position and dimensions + events as well as a manage_start event after all other new state has + been sent by the server. + + + + + + + A new seat has been created. + + This event will be followed by a manage_start event after all other new + state has been sent by the server. + + + + + + + Create a new shell surface for window manager UI and assign the + river_shell_surface_v1 role to the surface. + + Providing a wl_surface which already has a role or already has a buffer + attached or committed is a protocol error. + + + + + + + + End the current Wayland session and exit the compositor. + All Wayland clients running in the current session, including + the window manager, will be disconnected. + + Window managers should only make this request if the user explicitly + asks to exit the Wayland session, not for example on normal window + manager termination. + + + + + + + This represents a logical window. For example, a window may correspond to + an xdg_toplevel or Xwayland window. + + A newly created window will not be displayed until the window manager + makes a propose_dimensions or fullscreen request as part of a manage + sequence, the server replies with a dimensions event as part of a render + sequence, and that render sequence is finished. + + + + + + + + + + + + This request indicates that the client will no longer use the window + object and that it may be safely destroyed. + + This request should be made after the river_window_v1.closed event or + river_window_manager_v1.finished is received to complete destruction of + the window. + + + + + + The window has been closed by the server, perhaps due to an + xdg_toplevel.close request or similar. + + The server will send no further events on this object and ignore any + request other than river_window_v1.destroy made after this event is + sent. The client should destroy this object with the + river_window_v1.destroy request to free up resources. + + This event will be followed by a manage_start event after all other new + state has been sent by the server. + + + + + + Request that the window be closed. The window may ignore this request or + only close after some delay, perhaps opening a dialog asking the user to + save their work or similar. + + The server will send a river_window_v1.closed event if/when the window + has been closed. + + This request modifies window management state and may only be made as + part of a manage sequence, see the river_window_manager_v1 description. + + + + + + Get the node in the render list corresponding to the window. + + It is a protocol error to make this request more than once for a single + window. + + + + + + + This event informs the window manager of the window's preferred min/max + dimensions. These preferences are a hint, and the window manager is free + to propose dimensions outside of these bounds. + + All min/max width/height values must be strictly greater than or equal + to 0. A value of 0 indicates that the window has no preference for that + value. + + The min_width/min_height must be strictly less than or equal to the + max_width/max_height. + + This event will be followed by a manage_start event after all other new + state has been sent by the server. + + + + + + + + + + This event indicates the dimensions of the window in the compositor's + logical coordinate space. The width and height must be strictly greater + than zero. + + Note that the dimensions of a river_window_v1 refer to the dimensions of + the window content and are unaffected by the presence of borders or + decoration surfaces. + + This event is sent as part of a render sequence before the render_start + event. + + It may be sent due to a propose_dimensions or fullscreen request in a + previous manage sequence or because a window independently decides to + change its dimensions. + + The window will not be displayed until the first dimensions event is + received and the render sequence is finished. + + + + + + + + This request proposes dimensions for the window in the compositor's + logical coordinate space. + + The width and height must be greater than or equal to zero. If the width + or height is zero the window will be allowed to decide its own + dimensions. + + The window may not take the exact dimensions proposed. The actual + dimensions taken by the window will be sent in a subsequent + river_window_v1.dimensions event. For example, a terminal emulator may + only allow dimensions that are multiple of the cell size. + + When a propose_dimensions request is made, the server must send a + dimensions event in response as soon as possible. It may not be possible + to send a dimensions event in the very next render sequence if, for + example, the window takes too long to respond to the proposed + dimensions. In this case, the server will send the dimensions event in a + future render sequence. + + Note that the dimensions of a river_window_v1 refer to the dimensions of + the window content and are unaffected by the presence of borders or + decoration surfaces. + + This request modifies window management state and may only be made as + part of a manage sequence, see the river_window_manager_v1 description. + + + + + + + + Request that the window be hidden. Has no effect if the window is + already hidden. Hides any window borders and decorations as well. + + Newly created windows are considered shown unless explicitly hidden with + the hide request. + + This request modifies rendering state and may only be made as part of a + render sequence, see the river_window_manager_v1 description. + + + + + + Request that the window be shown. Has no effect if the window is not + hidden. Does not guarantee that the window is visible as it may be + completely obscured by other windows placed above it for example. + + Newly created windows are considered shown unless explicitly hidden with + the hide request. + + This request modifies rendering state and may only be made as part of a + render sequence, see the river_window_manager_v1 description. + + + + + + The window set an application ID. + + The app_id argument will be null if the window has never set an + application ID or if the window cleared its application ID. (Xwayland + windows may do this for example, though xdg-toplevels may not.) + + This event will be followed by a manage_start event after all other new + state has been sent by the server. + + + + + + + The window set a title. + + The title argument will be null if the window has never set a title or + if the window cleared its title. (Xwayland windows may do this for + example, though xdg-toplevels may not.) + + This event will be followed by a manage_start event after all other new + state has been sent by the server. + + + + + + + The window set a parent window. If this event is never received or if + the parent argument is null then the window has no parent. + + A surface with a parent set might be a dialog, file picker, or similar + for the parent window. + + Child windows should generally be rendered directly above their parent. + + The compositor must guarantee that there are no loops in the window + tree: a parent must not be the descendant of one of its children. + + This event will be followed by a manage_start event after all other new + state has been sent by the server. + + + + + + + + + + + + + + Information from the window about the supported and preferred client + side/server side decoration options. + + This event may be sent multiple times over the lifetime of the window if + the window changes its preferences. + + This event will be followed by a manage_start event after all other new + state has been sent by the server. + + + + + + + Tell the client to use client side decoration and draw its own title + bar, borders, etc. + + This is the default if neither this request nor the use_ssd request is + ever made. + + This request modifies window management state and may only be made as + part of a manage sequence, see the river_window_manager_v1 description. + + + + + + Tell the client to use server side decoration and not draw any client + side decorations. + + This request will have no effect if the client only supports client side + decoration, see the decoration_hint event. + + This request modifies window management state and may only be made as + part of a manage sequence, see the river_window_manager_v1 description. + + + + + + + + + + + + + + This request decorates the window with borders drawn by the compositor + on the specified edges of the window. Borders are drawn above the window + content. + + Corners are drawn only between borders on adjacent edges. If e.g. the + left edge has a border and the top edge does not, the border drawn on + the left edge will not extend vertically beyond the top edge of the + window. + + Borders are not drawn while the window is fullscreen. + + The color is defined by four 32-bit RGBA values. Unless specified in + another protocol extension, the RGBA values use pre-multiplied alpha. + + Setting the edges to none or the width to 0 disables the borders. + Setting a negative width is a protocol error. + + This request completely overrides all previous set_borders requests. + Only the most recent set_borders request has an effect. + + Note that the position/dimensions of a river_window_v1 refer to the + position/dimensions of the window content and are unaffected by the + presence of borders or decoration surfaces. + + This request modifies rendering state and may only be made as part of a + render sequence, see the river_window_manager_v1 description. + + + + + + + + + + + + Inform the window that it is part of a tiled layout and adjacent to + other elements in the tiled layout on the given edges. + + The window should use this information to change the style of its client + side decorations and avoid drawing e.g. drop shadows outside of the + window dimensions on the tiled edges. + + Setting the edges argument to none informs the window that it is not + part of a tiled layout. If this request is never made, the window is + informed that it is not part of a tiled layout. + + This request modifies window management state and may only be made as + part of a manage sequence, see the river_window_manager_v1 description. + + + + + + + Create a decoration surface and assign the river_decoration_v1 role to + the surface. The created decoration is placed above the window in + rendering order, see the description of river_decoration_v1. + + Providing a wl_surface which already has a role or already has a buffer + attached or committed is a protocol error. + + + + + + + + Create a decoration surface and assign the river_decoration_v1 role to + the surface. The created decoration is placed below the window in + rendering order, see the description of river_decoration_v1. + + Providing a wl_surface which already has a role or already has a buffer + attached or committed is a protocol error. + + + + + + + + This event informs the window manager that the window has requested to + be interactively moved using the pointer. The seat argument indicates the + seat for the move. + + The xdg-shell protocol for example allows windows to request that an + interactive move be started, perhaps when a client-side rendered + titlebar is dragged. + + The window manager may use the river_seat_v1.op_start_pointer request to + interactively move the window or ignore this event entirely. + + This event will be followed by a manage_start event after all other new + state has been sent by the server. + + + + + + + This event informs the window manager that the window has requested to + be interactively resized using the pointer. The seat argument indicates + the seat for the resize. + + The edges argument indicates which edges the window has requested to be + resized from. The edges argument will never be none and will never have + both top and bottom or both left and right edges set. + + The xdg-shell protocol for example allows windows to request that an + interactive resize be started, perhaps when the corner of client-side + rendered decorations is dragged. + + The window manager may use the river_seat_v1.op_start_pointer request to + interactively resize the window or ignore this event entirely. + + This event will be followed by a manage_start event after all other new + state has been sent by the server. + + + + + + + + Inform the window that it is being resized. The window manager should + use this request to inform windows that are the target of an interactive + resize for example. + + The window manager remains responsible for handling the position and + dimensions of the window while it is resizing. + + This request modifies window management state and may only be made as + part of a manage sequence, see the river_window_manager_v1 description. + + + + + + Inform the window that it is no longer being resized. The window manager + should use this request to inform windows that are the target of an + interactive resize that the interactive resize has ended for example. + + This request modifies window management state and may only be made as + part of a manage sequence, see the river_window_manager_v1 description. + + + + + + + + + + + + + This request informs the window of the capabilities supported by the + window manager. If the window manager, for example, ignores requests to + be maximized from the window it should not tell the window that it + supports the maximize capability. + + The window might use this information to, for example, only show a + maximize button if the window manager supports the maximize capability. + + The window manager client should use this request to set capabilities + for all new windows. If this request is never made, the compositor will + inform windows that all capabilities are supported. + + This request modifies window management state and may only be made as + part of a manage sequence, see the river_window_manager_v1 description. + + + + + + + The xdg-shell protocol for example allows windows to request that a + window menu be shown, for example when the user right clicks on client + side window decorations. + + A window menu might include options to maximize or minimize the window. + + The window manager is free to ignore this request and decide what the + window menu contains if it does choose to show one. + + The x and y arguments indicate where the window requested that the + window menu be shown. + + This event will be followed by a manage_start event after all other new + state has been sent by the server. + + + + + + + + The xdg-shell protocol for example allows windows to request to be + maximized. + + The window manager is free to honor this request using + river_window_v1.inform_maximized or ignore it. + + This event will be followed by a manage_start event after all other new + state has been sent by the server. + + + + + + The xdg-shell protocol for example allows windows to request to be + unmaximized. + + The window manager is free to honor this request using + river_window_v1.inform_unmaximized or ignore it. + + This event will be followed by a manage_start event after all other new + state has been sent by the server. + + + + + + Inform the window that it is maximized. The window might use this + information to adapt the style of its client-side window decorations for + example. + + The window manager remains responsible for handling the position and + dimensions of the window while it is maximized. + + This request modifies window management state and may only be made as + part of a manage sequence, see the river_window_manager_v1 description. + + + + + + Inform the window that it is unmaximized. The window might use this + information to adapt the style of its client-side window decorations for + example. + + This request modifies window management state and may only be made as + part of a manage sequence, see the river_window_manager_v1 description. + + + + + + The xdg-shell protocol for example allows windows to request that they + be made fullscreen and allows them to provide an optional output hint. + + If the output argument is null, the window has no preference and the + window manager should choose an output. + + The window manager is free to honor this request using + river_window_v1.fullscreen or ignore it. + + This event will be followed by a manage_start event after all other new + state has been sent by the server. + + + + + + + The xdg-shell protocol for example allows windows to request to exit + fullscreen. + + The window manager is free to honor this request using + river_window_v1.exit_fullscreen or ignore it. + + This event will be followed by a manage_start event after all other new + state has been sent by the server. + + + + + + Inform the window that it is fullscreen. The window might use this + information to adapt the style of its client-side window decorations for + example. + + This request does not affect the size/position of the window or cause it + to become the only window rendered, see the river_window_v1.fullscreen + and exit_fullscreen requests for that. + + This request modifies window management state and may only be made as + part of a manage sequence, see the river_window_manager_v1 description. + + + + + + Inform the window that it is not fullscreen. The window might use this + information to adapt the style of its client-side window decorations for + example. + + This request does not affect the size/position of the window or cause it + to become the only window rendered, see the river_window_v1.fullscreen + and exit_fullscreen requests for that. + + This request modifies window management state and may only be made as + part of a manage sequence, see the river_window_manager_v1 description. + + + + + + Make the window fullscreen on the given output. If multiple windows are + fullscreen on the same output at the same time only the "top" window in + rendering order shall be displayed. + + All river_shell_surface_v1 objects above the top fullscreen window in + the rendering order will continue to be rendered. + + The compositor will handle the position and dimensions of the window + while it is fullscreen. The set_position and propose_dimensions requests + shall not affect the current position and dimensions of a fullscreen + window. + + When a fullscreen request is made, the server must send a dimensions + event in response as soon as possible. It may not be possible to send a + dimensions event in the very next render sequence if, for example, the + window takes too long to respond. In this case, the server will send the + dimensions event in a future render sequence. + + The compositor will clip window content, decoration surfaces, and + borders to the given output's dimensions while the window is fullscreen. + The effects of set_clip_box and set_content_clip_box are ignored while + the window is fullscreen. + + If the output on which a window is currently fullscreen is removed, the + windowing state is modified as if there were an exit_fullscreen request + made in the same manage sequence as the river_output_v1.removed event. + + This request does not inform the window that it is fullscreen, see the + river_window_v1.inform_fullscreen and inform_not_fullscreen requests. + + This request modifies window management state and may only be made as + part of a manage sequence, see the river_window_manager_v1 description. + + + + + + + Make the window not fullscreen. + + The position and dimensions are undefined after this request is made + until a manage sequence in which the window manager makes the + propose_dimensions and set_position requests is completed. + + The window manager should make propose_dimensions and set_position + requests in the same manage sequence as the exit_fullscreen request for + frame perfection. + + This request does not inform the window that it is fullscreen, see the + river_window_v1.inform_fullscreen and inform_not_fullscreen requests. + + This request modifies window management state and may only be made as + part of a manage sequence, see the river_window_manager_v1 description. + + + + + + The xdg-shell protocol for example allows windows to request to be + minimized. + + The window manager is free to ignore this request, hide the window, or + do whatever else it chooses. + + This event will be followed by a manage_start event after all other new + state has been sent by the server. + + + + + + Clip the window, including borders and decoration surfaces, to the box + specified by the x, y, width, and height arguments. The x/y position of + the box is relative to the top left corner of the window. + + The width and height arguments must be greater than or equal to 0. + + Setting a clip box with 0 width or height disables clipping. + + The clip box is ignored while the window is fullscreen. + + Both set_clip_box and set_content_clip_box may be enabled simultaneously. + + This request modifies rendering state and may only be made as part of a + render sequence, see the river_window_manager_v1 description. + + + + + + + + + + This event gives an unreliable PID of the process that created the + window. Obtaining this information is inherently racy due to PID reuse. + Therefore, this PID must not be used for anything security sensitive. + + Note also that a single process may create multiple windows, so there is + not necessarily a 1-to-1 mapping from PID to window. Multiple windows + may have the same PID. + + This event is sent once when the river_window_v1 is created and never + sent again. + + + + + + + Clip the content of the window, excluding borders and decoration + surfaces, to the box specified by the x, y, width, and height arguments. + The x/y position of the box is relative to the top left corner of the + window. + + Borders drawn by the compositor (see set_borders) are placed around the + intersection of the window content (as defined by the dimensions event) + and the content clip box when content clipping is enabled. + + The width and height arguments must be greater than or equal to 0. + + Setting a box with 0 width or height disables content clipping. + + The content clip box is ignored while the window is fullscreen. + + Both set_clip_box and set_content_clip_box may be enabled simultaneously. + + This request modifies rendering state and may only be made as part of a + render sequence, see the river_window_manager_v1 description. + + + + + + + + + + This event communicates the window's preferred presentation mode. + + This event will be followed by a render_start event after all other new + state has been sent by the server. + + + + + + + The identifier is a string that contains up to 32 printable ASCII bytes. + The identifier must not be an empty string. + + It is compositor policy how the identifier is generated, but the following + properties must be upheld: + + 1. The identifier must uniquely identify the window. Two windows must not + share the same identifier. + + 2. The identifier must not be reused. This avoids races around window + creation/destruction when identifiers are used in out-of-band IPC. + + If the compositor implements the ext-foreign-toplevel-list-v1 protocol, + the river_window_v1.identifier event must match the corresponding + ext_foreign_toplevel_handle_v1.identifier event. + + This event is sent once when the river_window_v1 is created and never + sent again. + + + + + + + Recommend that the window keep its dimensions within a given + maximum width/height. This recommendation is only a hint and the window + may ignore it. + + Setting the width and height to 0 indicates that there are no bounds + and is equivalent to having never made this request. + + Setting width or height to a negative value is a protocol error. + + The server should communicate this hint to an xdg_toplevel window with + the xdg_toplevel.configure_bounds event for example. + + This request modifies window management state and may only be made as + part of a manage sequence, see the river_window_manager_v1 description. + + + + + + + + + The rendering order of windows with decorations is follows: + + 1. Decorations created with get_decoration_below at the bottom + 2. Window content + 3. Borders configured with river_window_v1.set_borders + 4. Decorations created with get_decoration_above at the top + + The relative ordering of decoration surfaces above/below a window is + undefined by this protocol and left up to the compositor. + + + + + + + + + This request indicates that the client will no longer use the decoration + object and that it may be safely destroyed. + + + + + + This request sets the offset of the decoration surface from the top left + corner of the window. + + If this request is never sent, the x and y offsets are undefined by this + protocol and left up to the compositor. + + This request modifies rendering state and may only be made as part of a + render sequence, see the river_window_manager_v1 description. + + + + + + + + Synchronize application of the next wl_surface.commit request on the + decoration surface with rest of the state atomically applied with the + next river_window_manager_v1.render_finish request. + + The client must make a wl_surface.commit request on the decoration + surface after this request and before the render_finish request, failure + to do so is a protocol error. + + This request modifies rendering state and may only be made as part of a + render sequence, see the river_window_manager_v1 description. + + + + + + + The window manager might use a shell surface to display a status bar, + background image, desktop notifications, launcher, desktop menu, or + whatever else it wants. + + + + + + + + + + This request indicates that the client will no longer use the shell + surface object and that it may be safely destroyed. + + + + + + Get the node in the render list corresponding to the shell surface. + + It is a protocol error to make this request more than once for a single + shell surface. + + + + + + + Synchronize application of the next wl_surface.commit request on the + shell surface with rest of the rendering state atomically applied with + the next river_window_manager_v1.render_finish request. + + The client must make a wl_surface.commit request on the shell surface + after this request and before the render_finish request, failure to do + so is a protocol error. + + This request modifies rendering state and may only be made as part of a + render sequence, see the river_window_manager_v1 description. + + + + + + + The render list is a list of nodes that determines the rendering order of + the compositor. Nodes may correspond to windows or shell surfaces. The + relative ordering of nodes may be changed with the place_above and + place_below requests, changing the rendering order. + + The initial position of a node in the render list is undefined, the window + manager client must use the place_above or place_below request to + guarantee a specific rendering order. + + + + + This request indicates that the client will no longer use the node + object and that it may be safely destroyed. + + + + + + Set the absolute position of the node in the compositor's logical + coordinate space. The x and y coordinates may be positive or negative. + + Note that the position of a river_window_v1 refers to the position of + the window content and is unaffected by the presence of borders or + decoration surfaces. + + If this request is never sent, the position of the node is undefined by + this protocol and left up to the compositor. + + This request modifies rendering state and may only be made as part of a + render sequence, see the river_window_manager_v1 description. + + + + + + + + This request places the node above all other nodes in the compositor's + render list. + + This request modifies rendering state and may only be made as part of a + render sequence, see the river_window_manager_v1 description. + + + + + + This request places the node below all other nodes in the compositor's + render list. + + This request modifies rendering state and may only be made as part of a + render sequence, see the river_window_manager_v1 description. + + + + + + This request places the node directly above another node in the + compositor's render list. + + Attempting to place a node above itself has no effect. + + This request modifies rendering state and may only be made as part of a + render sequence, see the river_window_manager_v1 description. + + + + + + + This request places the node directly below another node in the + compositor's render list. + + Attempting to place a node below itself has no effect. + + This request modifies rendering state and may only be made as part of a + render sequence, see the river_window_manager_v1 description. + + + + + + + + An area in the compositor's logical coordinate space that should be + treated as a single output for window management purposes. This area may + correspond to a single physical output or multiple physical outputs in the + case of mirroring or tiled monitors depending on the hardware and + compositor configuration. + + + + + This request indicates that the client will no longer use the output + object and that it may be safely destroyed. + + This request should be made after the river_output_v1.removed event is + received to complete destruction of the output. + + + + + + This event indicates that the logical output is no longer conceptually + part of window management space. + + The server will send no further events on this object and ignore any + request (other than river_output_v1.destroy) made after this event is + sent. The client should destroy this object with the + river_output_v1.destroy request to free up resources. + + This event may be sent because a corresponding physical output has been + physically unplugged or because some output configuration has changed. + + This event will be followed by a manage_start event after all other new + state has been sent by the server. + + + + + + The wl_output object corresponding to the river_output_v1. The argument + is the global name of the wl_output advertised with wl_registry.global. + + It is guaranteed that the corresponding wl_output is advertised before + this event is sent. + + This event is sent exactly once. The wl_output associated with a + river_output_v1 cannot change. It is guaranteed that there is a 1-to-1 + mapping between wl_output and river_output_v1 objects. + + The global_remove event for the corresponding wl_output may be sent + before the river_output_v1.removed event. This is due to the fact that + river_output_v1 state changes are synced to the river window management + manage sequence while changes to globals are not. + + Rationale: The window manager may need information provided by the + wl_output interface such as the name/description. It also may need the + wl_output object to start screencopy for example. + + + + + + + This event indicates the position of the output in the compositor's + logical coordinate space. The x and y coordinates may be positive or + negative. + + This event is sent once when the river_output_v1 is created and again + whenever the position changes. + + This event will be followed by a manage_start event after all other new + state has been sent by the server. + + The server must guarantee that the position and dimensions events do not + cause the areas of multiple logical outputs to overlap when the + corresponding manage_start event is received. + + + + + + + + This event indicates the dimensions of the output in the compositor's + logical coordinate space. The width and height will always be strictly + greater than zero. + + This event is sent once when the river_output_v1 is created and again + whenever the dimensions change. + + This event will be followed by a manage_start event after all other new + state has been sent by the server. + + The server must guarantee that the position and dimensions events do not + cause the areas of multiple logical outputs to overlap when the + corresponding manage_start event is received. + + + + + + + + + + + + + Output page-flips should be synchronized to the vertical blanking + period, eliminating tearing. This is the default presentation mode. + + + + + Output page-flips should not be synchronized to the vertical blanking + period, visual screen tearing may occur. + + + + + + + Set the preferred presentation mode of the output. The compositor should + always respect the preference of the window manager if possible. If this + request is never made, the preferred presentation mode is vsync. + + This request modifies rendering state and may only be made as part of a + render sequence, see the river_window_manager_v1 description. + + + + + + + + This object represents a single user's collection of input devices. It + allows the window manager to route keyboard input to windows, get + high-level information about pointer input, define pointer bindings, etc. + + For keyboard bindings, see the river-xkb-bindings-v1 protocol. + + Since version 4: The cursor surface/shape set by the window manager on the + wl_pointer of this seat is used when no client has pointer focus, for + example during a pointer operation. Since the window manager is allowed to + set cursor surface/shape even when it does not have pointer focus, the + compositor must ignore the serial argument of wl_pointer.set_cursor and + wp_cursor_shape_device_v1.set_shape requests made by the window manager. + + The most recent cursor surface/shape set by the window manager is + remembered by the compositor and restored whenever no client has pointer + focus. If the window manager never sets a cursor surface/shape, the + "default" shape is used. + + + + + This request indicates that the client will no longer use the seat + object and that it may be safely destroyed. + + This request should be made after the river_seat_v1.removed event is + received to complete destruction of the seat. + + + + + + This event indicates that seat is no longer in use and should be + destroyed. + + The server will send no further events on this object and ignore any + request (other than river_seat_v1.destroy) made after this event is + sent. The client should destroy this object with the + river_seat_v1.destroy request to free up resources. + + This event will be followed by a manage_start event after all other new + state has been sent by the server. + + + + + + The wl_seat object corresponding to the river_seat_v1. The argument is + the global name of the wl_seat advertised with wl_registry.global. + + It is guaranteed that the corresponding wl_seat is advertised before + this event is sent. + + This event is sent exactly once. The wl_seat associated with a + river_seat_v1 cannot change. It is guaranteed that there is a 1-to-1 + mapping between wl_seat and river_seat_v1 objects. + + The global_remove event for the corresponding wl_seat may be sent before + the river_seat_v1.removed event. This is due to the fact that + river_seat_v1 state changes are synced to the river window management + manage sequence while changes to globals are not. + + Rationale: The window manager may want to trigger window management + state changes based on normal input events received by its shell + surfaces for example. + + + + + + + Request that the compositor send keyboard input to the given window. + + This request modifies window management state and may only be made as + part of a manage sequence, see the river_window_manager_v1 description. + + + + + + + Request that the compositor send keyboard input to the given shell + surface. + + This request modifies window management state and may only be made as + part of a manage sequence, see the river_window_manager_v1 description. + + + + + + + Request that the compositor not send keyboard input to any client. + + This request modifies window management state and may only be made as + part of a manage sequence, see the river_window_manager_v1 description. + + + + + + The seat's pointer entered the given window's area. + + The area of a window is defined to include the area defined by the + window dimensions, borders configured using river_window_v1.set_borders, + and the input regions of decoration surfaces. In particular, it does not + include input regions of surfaces belonging to the window that extend + outside the window dimensions. + + The pointer of a seat may only enter a single window at a time. When the + pointer moves between windows, the pointer_leave event for the old + window must be sent before the pointer_enter event for the new window. + + This event will be followed by a manage_start event after all other new + state has been sent by the server. + + + + + + + The seat's pointer left the window for which pointer_enter was most + recently sent. See pointer_enter for details. + + This event will be followed by a manage_start event after all other new + state has been sent by the server. + + + + + + A window has been interacted with beyond the pointer merely passing over + it. This event might be sent due to a pointer button press or due to a + touch/tablet tool interaction with the window. + + There are no guarantees regarding how this event is sent in relation to + the pointer_enter and pointer_leave events as the interaction may use + touch or tablet tool input. + + Rationale: this event gives window managers necessary information to + determine when to send keyboard focus, raise a window that already has + keyboard focus, etc. Rather than expose all pointer, touch, and tablet + events to window managers, a policy over mechanism approach is taken. + + This event will be followed by a manage_start event after all other new + state has been sent by the server. + + + + + + + A shell surface has been interacted with beyond the pointer merely + passing over it. This event might be sent due to a pointer button press + or due to a touch/tablet tool interaction with the shell_surface. + + There are no guarantees regarding how this event is sent in relation to + the pointer_enter and pointer_leave events as the interaction may use + touch or tablet tool input. + + Rationale: While the shell surface does receive all wl_pointer, + wl_touch, etc. input events for the surface directly, these events do + not necessarily trigger a manage sequence and therefore do not allow the + window manager to update focus or perform other actions in response to + the input in a race-free way. + + This event will be followed by a manage_start event after all other new + state has been sent by the server. + + + + + + + Start an interactive pointer operation. During the operation, op_delta + events will be sent based on pointer input. + + When all pointer buttons are released, the op_release event is sent. + + The pointer operation continues until the op_end request is made during + a manage sequence and that manage sequence is finished. + + The window manager may use this operation to implement interactive + move/resize of windows by setting the position of windows and proposing + dimensions based off of the op_delta events. + + This request is ignored if an operation is already in progress. + + The compositor must ensure that no client has pointer focus from this + seat during the pointer operation. This means that the window manager + has control over the pointer's cursor surface/shape during the pointer + operation. See the river_seat_v1 description. + + This request modifies window management state and may only be made as + part of a manage sequence, see the river_window_manager_v1 description. + + + + + + This event indicates the total change in position since the start of the + operation of the pointer/touch point/etc. + + This event will be followed by a manage_start event after all other new + state has been sent by the server. + + + + + + + + The input driving the current interactive operation has been released. + For a pointer op for example, all pointer buttons have been released. + + Depending on the op type, op_delta events may continue to be sent until + the op is ended with the op_end request. + + This event is sent at most once during an interactive operation. + + This event will be followed by a manage_start event after all other new + state has been sent by the server. + + + + + + End an interactive operation. + + This request is ignored if there is no operation in progress. + + This request modifies window management state and may only be made as + part of a manage sequence, see the river_window_manager_v1 description. + + + + + + This enum is used to describe the keyboard modifiers that must be held + down to trigger a key binding or pointer binding. + + Note that river and wlroots use the values 2 and 16 for capslock and + numlock internally. It doesn't make sense to use locked modifiers for + bindings however so these values are not included in this enum. + + + + + + + + + + + + + Define a pointer binding in terms of a pointer button, keyboard + modifiers, and other configurable properties. + + The button argument is a Linux input event code defined in the + linux/input-event-codes.h header file (e.g. BTN_RIGHT). + + The new pointer binding is not enabled until initial configuration is + completed and the enable request is made during a manage sequence. + + + + + + + + + Set the XCursor theme for the seat. This theme is used for cursors + rendered by the compositor, but not necessarily for cursors rendered by + clients. + + Note: The window manager may also wish to set the XCURSOR_THEME and + XCURSOR_SIZE environment variable for programs it starts. + + + + + + + + The current position of the pointer in the compositor's logical + coordinate space. + + This state is special in that a change in pointer position alone must + not cause the compositor to start a manage sequence. + + Assuming the seat has a pointer, this event must be sent in every manage + sequence unless there is no change in x/y position since the last time this + event was sent. + + + + + + + + Warp the pointer to the given position in the compositor's logical + coordinate space. + + If the given position is outside the bounds of all outputs, the pointer + will be warped to the closest point inside an output instead. + + This request modifies window management state and may only be made as + part of a manage sequence, see the river_window_manager_v1 description. + + + + + + + + + This object allows the window manager to configure a pointer binding and + receive events when the binding is triggered. + + The new pointer binding is not enabled until the enable request is made + during a manage sequence. + + Normally, all pointer button events are sent to the surface with pointer + focus by the compositor. Pointer button events that trigger a pointer + binding are not sent to the surface with pointer focus. + + If multiple pointer bindings would be triggered by a single physical + pointer event on the compositor side, it is compositor policy which + pointer binding(s) will receive press/release events or if all of the + matched pointer bindings receive press/release events. + + + + + This request indicates that the client will no longer use the pointer + binding object and that it may be safely destroyed. + + + + + + This request should be made after all initial configuration has been + completed and the window manager wishes the pointer binding to be able + to be triggered. + + This request modifies window management state and may only be made as + part of a manage sequence, see the river_window_manager_v1 description. + + + + + + This request may be used to temporarily disable the pointer binding. It + may be later re-enabled with the enable request. + + This request modifies window management state and may only be made as + part of a manage sequence, see the river_window_manager_v1 description. + + + + + + This event indicates that the pointer button triggering the binding has + been pressed. + + This event will be followed by a manage_start event after all other new + state has been sent by the server. + + The compositor should wait for the manage sequence to complete before + processing further input events. This allows the window manager client + to, for example, modify key bindings and keyboard focus without racing + against future input events. The window manager should of course respond + as soon as possible as the capacity of the compositor to buffer incoming + input events is finite. + + + + + + This event indicates that the pointer button triggering the binding has + been released. + + Releasing the modifiers for the binding without releasing the pointer + button does not trigger the release event. This event is sent when the + pointer button is released, even if the modifiers have changed since the + pressed event. + + This event will be followed by a manage_start event after all other new + state has been sent by the server. + + The compositor should wait for the manage sequence to complete before + processing further input events. This allows the window manager client + to, for example, modify key bindings and keyboard focus without racing + against future input events. The window manager should of course respond + as soon as possible as the capacity of the compositor to buffer incoming + input events is finite. + + + + diff --git a/protocol/river-xkb-bindings-v1.xml b/protocol/river-xkb-bindings-v1.xml new file mode 100644 index 0000000..5d32967 --- /dev/null +++ b/protocol/river-xkb-bindings-v1.xml @@ -0,0 +1,314 @@ + + + + SPDX-FileCopyrightText: © 2025 Isaac Freund + SPDX-License-Identifier: MIT + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to + deal in the Software without restriction, including without limitation the + rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + sell copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + IN THE SOFTWARE. + + + + This protocol allows the river-window-management-v1 window manager to + define key bindings in terms of xkbcommon keysyms and other configurable + properties. + + The key words "must", "must not", "required", "shall", "shall not", + "should", "should not", "recommended", "may", and "optional" in this + document are to be interpreted as described in IETF RFC 2119. + + + + + This global interface should only be advertised to the client if the + river_window_manager_v1 global is also advertised. + + + + + + + + + This request indicates that the client will no longer use the + river_xkb_bindings_v1 object. + + + + + + Define a key binding for the given seat in terms of an xkbcommon keysym + and other configurable properties. + + The new key binding is not enabled until initial configuration is + completed and the enable request is made during a manage sequence. + + + + + + + + + + Create an object to manage seat-specific xkb bindings state. + + It is a protocol error to make this request more than once for a given + river_seat_v1 object. + + + + + + + + + This object allows the window manager to configure a xkbcommon key binding + and receive events when the key binding is triggered. + + The new key binding is not enabled until the enable request is made during + a manage sequence. + + Normally, all key events are sent to the surface with keyboard focus by + the compositor. Key events that trigger a key binding are not sent to the + surface with keyboard focus. + + If multiple key bindings would be triggered by a single physical key event + on the compositor side, it is compositor policy which key binding(s) will + receive press/release events or if all of the matched key bindings receive + press/release events. + + Key bindings might be matched by the same physical key event due to shared + keysym and modifiers. The layout override feature may also cause the same + physical key event to trigger two key bindings with different keysyms and + different layout overrides configured. + + + + + This request indicates that the client will no longer use the xkb key + binding object and that it may be safely destroyed. + + + + + + Specify an xkb layout that should be used to translate key events for + the purpose of triggering this key binding irrespective of the currently + active xkb layout. + + The layout argument is a 0-indexed xkbcommon layout number for the + keyboard that generated the key event. + + If this request is never made, the currently active xkb layout of the + keyboard that generated the key event will be used. + + This request modifies window management state and may only be made as + part of a manage sequence, see the river_window_manager_v1 description. + + + + + + + This request should be made after all initial configuration has been + completed and the window manager wishes the key binding to be able to be + triggered. + + This request modifies window management state and may only be made as + part of a manage sequence, see the river_window_manager_v1 description. + + + + + + This request may be used to temporarily disable the key binding. It may + be later re-enabled with the enable request. + + This request modifies window management state and may only be made as + part of a manage sequence, see the river_window_manager_v1 description. + + + + + + This event indicates that the physical key triggering the binding has + been pressed. + + This event will be followed by a manage_start event after all other new + state has been sent by the server. + + The compositor should wait for the manage sequence to complete before + processing further input events. This allows the window manager client + to, for example, modify key bindings and keyboard focus without racing + against future input events. The window manager should of course respond + as soon as possible as the capacity of the compositor to buffer incoming + input events is finite. + + + + + + This event indicates that the physical key triggering the binding has + been released. + + Releasing the modifiers for the binding without releasing the "main" + physical key that produces the bound keysym does not trigger the release + event. This event is sent when the "main" key is released, even if the + modifiers have changed since the pressed event. + + This event will be followed by a manage_start event after all other new + state has been sent by the server. + + The compositor should wait for the manage sequence to complete before + processing further input events. This allows the window manager client + to, for example, modify key bindings and keyboard focus without racing + against future input events. The window manager should of course respond + as soon as possible as the capacity of the compositor to buffer incoming + input events is finite. + + + + + + This event indicates that repeating should be stopped for the binding if + the window manager has been repeating some action since the pressed + event. + + This event is generally sent when some other (possible unbound) key is + pressed after the pressed event is sent and before the released event + is sent for this binding. + + This event will be followed by a manage_start event after all other new + state has been sent by the server. + + + + + + + This object manages xkb bindings state associated with a specific seat. + + + + + This request indicates that the client will no longer use the object and + that it may be safely destroyed. + + + + + + Ensure that the next non-modifier key press and corresponding release + events for this seat are not sent to the currently focused surface. + + If the next non-modifier key press triggers a binding, the + pressed/released events are sent to the river_xkb_binding_v1 object as + usual. + + If the next non-modifier key press does not trigger a binding, the + ate_unbound_key event is sent instead. + + Rationale: the window manager may wish to implement "chorded" + keybindings where triggering a binding activates a "submap" with a + different set of keybindings. Without a way to eat the next key + press event, there is no good way for the window manager to know that it + should error out and exit the submap when a key not bound in the submap + is pressed. + + This request modifies window management state and may only be made as + part of a manage sequence, see the river_window_manager_v1 description. + + + + + + This requests cancels the effect of the latest ensure_next_key_eaten + request if no key has been eaten due to the request yet. This request + has no effect if a key has already been eaten or no + ensure_next_key_eaten was made. + + Rationale: the window manager may wish cancel an uncompleted "chorded" + keybinding after a timeout of a few seconds. Note that since this + timeout use-case requires the window manager to trigger a manage sequence + with the river_window_manager_v1.manage_dirty request it is possible that + the ate_unbound_key key event may be sent before the window manager has + a chance to make the cancel_ensure_next_key_eaten request. + + This request modifies window management state and may only be made as + part of a manage sequence, see the river_window_manager_v1 description. + + + + + + An unbound key press event was eaten due to the ensure_next_key_eaten + request. + + This event will be followed by a manage_start event after all other new + state has been sent by the server. + + + + + + Request that the server send the modifiers_update event whenever a state + change occurs for at least one of the modifiers specified by the + modifiers argument. + + The window manager should make this request with the modifiers argument + set to 0 when it no longer wishes to take action based on a change in + modifiers. + + This request modifies window management state and may only be made as + part of a manage sequence, see the river_window_manager_v1 description. + + + + + + + The set of currently active modifiers for the seat changed. This event + is only sent when there is a change in state for modifiers marked as + watched using the modifiers_watch request. + + The old and new arguments convey the set of modifiers active before and + after the change. All modifiers are included in the old and new + arguments, including modifiers that are not watched. + + Since this event is only sent when there is a change in state for + watched modifiers, it follows that at least one watched modifier is + active in old but inactive in new or vice-versa. + + This event will be followed by a manage_start event after all other new + state has been sent by the server. + + The compositor should wait for the manage sequence to complete before + processing further input events. This allows the window manager client + to, for example, modify key bindings and keyboard focus without racing + against future input events. The window manager should of course respond + as soon as possible as the capacity of the compositor to buffer incoming + input events is finite. + + + + + + diff --git a/protocol/river-xkb-config-v1.xml b/protocol/river-xkb-config-v1.xml new file mode 100644 index 0000000..2e3b8dd --- /dev/null +++ b/protocol/river-xkb-config-v1.xml @@ -0,0 +1,277 @@ + + + + SPDX-FileCopyrightText: © 2026 Isaac Freund + SPDX-License-Identifier: MIT + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to + deal in the Software without restriction, including without limitation the + rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + sell copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + IN THE SOFTWARE. + + + + This protocol allow a client to set the xkbcommon keymap of individual + keyboard input devices. It also allows switching between the layouts of a + keymap and toggling capslock/numlock state. + + The key words "must", "must not", "required", "shall", "shall not", + "should", "should not", "recommended", "may", and "optional" in this + document are to be interpreted as described in IETF RFC 2119. + + + + + Global interface for configuring xkb devices. + + This global should only be advertised if river_input_manager_v1 is + advertised as well. + + + + + + + + + + This request indicates that the client no longer wishes to receive + events on this object. + + The Wayland protocol is asynchronous, which means the server may send + further events until the stop request is processed. The client must wait + for a river_xkb_config_v1.finished event before destroying this object. + + + + + + This event indicates that the server will send no further events on this + object. The client should destroy the object. See + river_xkb_config_v1.destroy for more information. + + + + + + This request should be called after the finished event has been received + to complete destruction of the object. + + It is a protocol error to make this request before the finished event + has been received. + + If a client wishes to destroy this object it should send a + river_xkb_config_v1.stop request and wait for a + river_xkb_config_v1.finished event. Once the finished event is received + it is safe to destroy this object and any other objects created through + this interface. + + + + + + + + + + + The server must be able to mmap the fd with MAP_PRIVATE. + The server will fstat the fd to obtain the size of the keymap. + The client must not modify the contents of the fd after making this request. + The client should seal the fd with fcntl. + + + + + + + + + A new xkbcommon keyboard has been created. Not every + river_input_device_v1 is necessarily an xkbcommon keyboard as well. + + + + + + + + This object is the result of attempting to create an xkbcommon keymap. + + + + + This request indicates that the client will no longer use the keymap + object and that it may be safely destroyed. + + + + + + The keymap object was successfully created and may be used with the + river_xkb_keyboard_v1.set_keymap request. + + + + + + The compositor failed to create a keymap from the given parameters. + + It is a protocol error to use this keymap object with + river_xkb_keyboard_v1.set_keymap. + + + + + + + + This object represent a physical keyboard which has its configuration and + state managed by xkbcommon. + + + + + + + + + This request indicates that the client will no longer use the keyboard + object and that it may be safely destroyed. + + + + + + This event indicates that the xkb keyboard has been removed. + + The server will send no further events on this object and ignore any + request (other than river_xkb_keyboard_v1.destroy) made after this event + is sent. The client should destroy this object with the + river_xkb_keyboard_v1.destroy request to free up resources. + + + + + + The river_input_device_v1 corresponding to this xkb keyboard. This event + will always be the first event sent on the river_xkb_keyboard_v1 object, + and it will be sent exactly once. + + + + + + + Set the keymap for the keyboard. + + Setting a keymap will reset all layout/modifier state. + + It is a protocol error to pass a keymap object for which the + river_xkb_keymap_v1.success event was not received. + + + + + + + Set the active layout for the keyboard's keymap. Has no effect if the + layout index is out of bounds for the current keymap. + + + + + + + Set the active layout for the keyboard's keymap. Has no effect if there + is no layout with the give name for the keyboard's keymap. + + + + + + + The currently active layout index and name. The name arg may be null if + the active layout does not have a name. + + This event is sent once when the river_xkb_keyboard_v1 is created and + again whenever the layout changes. + + + + + + + + Enable capslock for the keyboard. + + + + + + Disable capslock for the keyboard. + + + + + + Capslock is currently enabled for the keyboard. + + This event is sent once when the river_xkb_keyboard_v1 is created and + again whenever the capslock state changes. + + + + + + Capslock is currently disabled for the keyboard. + + This event is sent once when the river_xkb_keyboard_v1 is created and + again whenever the capslock state changes. + + + + + + Enable numlock for the keyboard. + + + + + + Disable numlock for the keyboard. + + + + + + Numlock is currently enabled for the keyboard. + + This event is sent once when the river_xkb_keyboard_v1 is created and + again whenever the numlock state changes. + + + + + + Numlock is currently disabled for the keyboard. + + This event is sent once when the river_xkb_keyboard_v1 is created and + again whenever the numlock state changes. + + + + diff --git a/quickshell/AttWm.qml b/quickshell/AttWm.qml new file mode 100644 index 0000000..8b74348 --- /dev/null +++ b/quickshell/AttWm.qml @@ -0,0 +1,115 @@ +// Singleton service wrapping att_wm's IPC socket. +// +// One connection does both jobs: it subscribes to state, and commands are +// written back down the same socket. att_wm keeps subscribers connected after a +// command precisely so a bar does not need a second connection or a process +// spawn per click. + +pragma Singleton + +import QtQuick +import Quickshell +import Quickshell.Io + +Singleton { + id: root + + // Latest state from att_wm, parsed. + readonly property var outputs: state.outputs ?? [] + readonly property int tagCount: state.tag_count ?? 9 + readonly property var tagNames: state.tag_names ?? [] + readonly property bool locked: state.locked ?? false + readonly property bool connected: link.item ? link.item.connected : false + + // The output the user is currently working on. + readonly property var focusedOutput: { + for (const o of outputs) + if (o.focused) return o; + return outputs.length > 0 ? outputs[0] : null; + } + + readonly property string focusedTitle: { + if (!focusedOutput) return ""; + for (const w of focusedOutput.windows) + if (w.focused) return w.title; + return ""; + } + + property var state: ({}) + + /// Look up an output by name, so a multi-monitor bar can show per-screen + /// state rather than the focused output's. + function outputFor(name) { + for (const o of outputs) + if (o.name === name) return o; + return null; + } + + /// Send a command. Same grammar as att_wmctl, e.g. send("view 3"). + function send(command) { + if (root.connected) link.item.write(command + "\n"); + } + + // A tag is "occupied" if some window carries it, mirroring dwm's bar. + function tagOccupied(output, index) { + return output ? (output.occupied & (1 << index)) !== 0 : false; + } + + function tagActive(output, index) { + return output ? (output.tags & (1 << index)) !== 0 : false; + } + + // A Socket that fails to connect cannot be revived: no state change is + // reported and re-asserting `connected` does not make it try again. So the + // socket lives in a Loader and a retry means building a new one. This is + // what lets the bar be started before att_wm, as `river/init` does. + Loader { + id: link + + active: true + sourceComponent: Socket { + // att_wm names its socket after the Wayland display, so several + // river sessions can run side by side without colliding. + path: { + const explicit = Quickshell.env("ATT_WM_SOCKET"); + if (explicit) return explicit; + const dir = Quickshell.env("XDG_RUNTIME_DIR"); + const display = Quickshell.env("WAYLAND_DISPLAY") || "wayland-0"; + return `${dir}/att_wm-${display}.sock`; + } + + connected: true + + // att_wm replies with a full state object immediately, so there is + // no empty-bar flash on connect. + onConnectionStateChanged: if (connected) write("subscribe\n") + + parser: SplitParser { + onRead: line => { + // Command acknowledgements ("ok" / "err ...") share the + // stream with state objects; only the latter are JSON. + if (!line.startsWith("{")) return; + try { + root.state = JSON.parse(line); + } catch (e) { + console.warn("att_wm: bad state line:", e); + } + } + } + } + } + + // att_wm may be restarted independently of the bar; keep trying. + Timer { + interval: 1000 + repeat: true + running: !root.connected + onTriggered: { + // Drop what the dead connection last said, so a reconnect cannot + // briefly show state from before the restart. + root.state = ({}); + link.active = false; + link.active = true; + } + } +} diff --git a/quickshell/qmldir b/quickshell/qmldir new file mode 100644 index 0000000..b9978ff --- /dev/null +++ b/quickshell/qmldir @@ -0,0 +1 @@ +singleton AttWm 1.0 AttWm.qml diff --git a/quickshell/shell.qml b/quickshell/shell.qml new file mode 100644 index 0000000..f70bdd2 --- /dev/null +++ b/quickshell/shell.qml @@ -0,0 +1,175 @@ +// A dwm-style bar for att_wm. +// +// Run with: quickshell -p /path/to/att_wm/quickshell +// +// Left click a tag to view it, right click to toggle it into the view, +// middle click to move the focused window there. The layout symbol cycles +// layouts on click, mirroring dwm's bar. + +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Io + +ShellRoot { + Variants { + // One bar per monitor. + model: Quickshell.screens + + PanelWindow { + id: bar + + required property var modelData + screen: modelData + + // att_wm reads layer-shell exclusive zones and lays windows out in + // what is left, so the bar never overlaps a window. + anchors { + top: true + left: true + right: true + } + implicitHeight: 26 + exclusiveZone: 26 + color: "#1a1a1a" + + readonly property var output: AttWm.outputFor(modelData.name) + + RowLayout { + anchors.fill: parent + spacing: 0 + + // ── Tags ──────────────────────────────────────────────── + Repeater { + model: AttWm.tagCount + + Rectangle { + required property int index + + readonly property bool active: AttWm.tagActive(bar.output, index) + readonly property bool occupied: AttWm.tagOccupied(bar.output, index) + + Layout.fillHeight: true + implicitWidth: label.implicitWidth + 16 + color: active ? "#5294e2" : "transparent" + + Text { + id: label + anchors.centerIn: parent + text: AttWm.tagNames[parent.index] ?? (parent.index + 1) + color: parent.active ? "#1a1a1a" : (parent.occupied ? "#eeeeee" : "#666666") + font.family: "monospace" + font.pixelSize: 13 + font.bold: parent.active + } + + // dwm draws a small square in the corner of a tag that + // holds windows but is not currently shown. + Rectangle { + visible: parent.occupied && !parent.active + x: 3 + y: 3 + width: 4 + height: 4 + color: "#5294e2" + } + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton + onClicked: event => { + const tag = parent.index + 1; + if (event.button === Qt.LeftButton) + AttWm.send(`view ${tag}`); + else if (event.button === Qt.RightButton) + AttWm.send(`toggle-view ${tag}`); + else + AttWm.send(`tag ${tag}`); + } + } + } + } + + // ── Layout symbol ─────────────────────────────────────── + Rectangle { + Layout.fillHeight: true + implicitWidth: layoutLabel.implicitWidth + 16 + color: "#242424" + + Text { + id: layoutLabel + anchors.centerIn: parent + text: bar.output ? bar.output.layout_symbol : "[]=" + color: "#bbbbbb" + font.family: "monospace" + font.pixelSize: 13 + } + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.LeftButton | Qt.RightButton + onClicked: event => AttWm.send( + event.button === Qt.LeftButton ? "cycle-layout next" : "cycle-layout prev") + } + } + + // ── Focused window title ──────────────────────────────── + Text { + Layout.fillWidth: true + Layout.leftMargin: 10 + elide: Text.ElideRight + text: AttWm.focusedTitle + color: "#dddddd" + font.pixelSize: 13 + verticalAlignment: Text.AlignVCenter + } + + // ── Tabs, for the tabbed layout ───────────────────────── + // + // att_wm draws its own solid-colour tab strip; this shows the + // titles alongside it. Set `tabbar_height = 0` in config.zig to + // rely on this instead. + RowLayout { + Layout.fillHeight: true + spacing: 2 + visible: bar.output && bar.output.layout === "tabbed" + + Repeater { + model: bar.output + ? bar.output.windows.filter(w => w.visible && !w.floating) + : [] + + Rectangle { + required property var modelData + + Layout.fillHeight: true + implicitWidth: Math.min(160, tabText.implicitWidth + 16) + color: modelData.focused ? "#5294e2" : "#2c2c2c" + + Text { + id: tabText + anchors.centerIn: parent + width: parent.width - 12 + elide: Text.ElideRight + horizontalAlignment: Text.AlignHCenter + text: modelData.title || modelData.app_id + color: parent.modelData.focused ? "#1a1a1a" : "#cccccc" + font.pixelSize: 12 + } + } + } + } + + // ── Connection indicator ──────────────────────────────── + Text { + Layout.rightMargin: 10 + visible: !AttWm.connected + text: "att_wm ✕" + color: "#e06c75" + font.family: "monospace" + font.pixelSize: 12 + } + } + } + } +} diff --git a/run-nested.sh b/run-nested.sh new file mode 100755 index 0000000..37f9e88 --- /dev/null +++ b/run-nested.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# Run att_wm inside a nested river, as a window in your existing session. +# +# Nothing is installed and no system rebuild is needed — river's wayland +# backend opens a normal window, and att_wm manages what is inside it. +# +# ./run-nested.sh # just att_wm +# ./run-nested.sh --bar # also start the bundled quickshell bar +# ./run-nested.sh --term # also open a terminal to look at +# +# Anything else you pass is run inside the session, e.g. +# ./run-nested.sh --bar --term +set -euo pipefail + +cd "$(dirname "$0")" + +if [[ -z ${WAYLAND_DISPLAY:-} ]]; then + echo "error: no WAYLAND_DISPLAY — this needs an existing Wayland session." >&2 + echo " On a TTY, use the headless backend instead:" >&2 + echo " WLR_BACKENDS=headless WLR_HEADLESS_OUTPUTS=1 ./run-nested.sh" >&2 + exit 1 +fi + +want_bar=0 +want_term=0 +for arg in "$@"; do + case $arg in + --bar) want_bar=1 ;; + --term) want_term=1 ;; + *) + echo "unknown option: $arg" >&2 + exit 1 + ;; + esac +done + +bin=zig-out/bin +if [[ ! -x ${bin}/att_wm ]]; then + echo "==> building" + ./dev.sh bash -c 'zig build' +fi +bin=$(realpath "${bin}") + +init=$(mktemp) +trap 'rm -f "${init}"' EXIT +{ + echo '#!/bin/sh' + # river exports the nested display to its children; surface it so you can + # point att_wmctl at this instance from another terminal. + echo 'echo "" >&2' + echo 'echo " nested session is on WAYLAND_DISPLAY=$WAYLAND_DISPLAY" >&2' + echo 'echo " drive it with: WAYLAND_DISPLAY=$WAYLAND_DISPLAY '"${bin}"'/att_wmctl state" >&2' + echo 'echo "" >&2' + [[ ${want_bar} -eq 1 ]] && echo "quickshell -p $(realpath quickshell) &" + [[ ${want_term} -eq 1 ]] && echo 'foot &' + echo "exec ${bin}/att_wm" +} >"${init}" +chmod +x "${init}" + +echo "==> starting nested river (close the window to exit)" +exec env WLR_BACKENDS=wayland river -no-xwayland -c "${init}" diff --git a/src/InputManager.zig b/src/InputManager.zig new file mode 100644 index 0000000..ace6f69 --- /dev/null +++ b/src/InputManager.zig @@ -0,0 +1,755 @@ +//! Input device configuration: keyboard layout and repeat, and the libinput +//! knobs that matter on a touchpad. +//! +//! Three river globals cooperate here. `river_input_manager_v1` enumerates +//! devices and owns the settings river implements itself (key repeat, scroll +//! factor). `river_xkb_config_v1` compiles keymaps and assigns them to +//! keyboards. `river_libinput_config_v1` exposes libinput's own configuration — +//! tap to click and the rest — one object per device that libinput drives. +//! +//! None of these requests are part of a manage sequence: unlike window state, +//! input configuration is not sequenced by river, so it can be sent the moment +//! we know what to send. +//! +//! What the handlers do *not* do is apply settings, because a device's identity +//! and its per-protocol objects arrive as separate events. They record what +//! arrived and mark us dirty; `flush()` — called once per event loop iteration, +//! after a whole batch of events has been dispatched — is the only place that +//! matches rules and issues requests. That way it never matters which order the +//! events came in. + +const InputManager = @This(); + +const std = @import("std"); +const posix = std.posix; +const linux = std.os.linux; + +const wayland = @import("wayland"); +const wl = wayland.client.wl; +const river = wayland.client.river; + +const xkb = @import("xkbcommon"); + +const config = @import("config"); +const input = @import("input"); + +const Wm = @import("Wm.zig"); +const sys = @import("sys.zig"); + +const log = std.log.scoped(.input); + +wm: *Wm, + +manager: *river.InputManagerV1, +libinput_config: ?*river.LibinputConfigV1 = null, +xkb_config: ?*river.XkbConfigV1 = null, + +devices: std.ArrayList(*Device) = .empty, + +/// libinput settings whose result river has yet to report. Tracked only so that +/// shutting down mid-flight frees them. +pending_results: std.ArrayList(*Result) = .empty, + +/// The keymap compiled from `config.keymap`. river validates it asynchronously, +/// so it may only be handed to a keyboard once `success` has arrived. +keymap: ?*river.XkbKeymapV1 = null, +keymap_state: enum { unset, pending, ready, failed } = .unset, + +/// Some event handler recorded something `flush` has yet to act on. +dirty: bool = false, + +/// One input device, and whichever of river's per-device objects have shown up +/// for it so far. +pub const Device = struct { + im: *InputManager, + device: *river.InputDeviceV1, + + name: ?[]u8 = null, + kind: ?input.Type = null, + + /// Present only for devices libinput drives. river cannot offer these at + /// all when it has no access to the hardware, which is the case whenever it + /// runs nested inside another compositor. + libinput: ?*river.LibinputDeviceV1 = null, + /// Present only for keyboards. + keyboard: ?*river.XkbKeyboardV1 = null, + + /// The settings living on each object, once sent. Tracked separately + /// because the objects appear independently of one another. + applied_core: bool = false, + applied_libinput: bool = false, + applied_keymap: bool = false, + + /// The output this device is currently mapped to, so the request is only + /// re-sent when it actually changes. Borrowed, and cleared by `forgetOutput` + /// before the proxy is destroyed. + mapped_output: ?*wl.Output = null, + /// Set once we have complained that a rule names an output that is not here, + /// so unplugging a monitor costs one log line rather than one per flush. + warned_missing_output: bool = false, + + removed: bool = false, + + fn destroy(self: *Device) void { + const gpa = self.im.wm.gpa; + if (self.name) |n| gpa.free(n); + if (self.libinput) |l| l.destroy(); + if (self.keyboard) |k| k.destroy(); + self.device.destroy(); + gpa.destroy(self); + } + + fn displayName(self: *const Device) []const u8 { + return self.name orelse ""; + } +}; + +pub fn create(wm: *Wm, manager: *river.InputManagerV1) !*InputManager { + const self = try wm.gpa.create(InputManager); + self.* = .{ .wm = wm, .manager = manager }; + manager.setListener(*InputManager, onManagerEvent, self); + return self; +} + +pub fn destroy(self: *InputManager) void { + const gpa = self.wm.gpa; + for (self.devices.items) |device| device.destroy(); + self.devices.deinit(gpa); + for (self.pending_results.items) |pending| gpa.destroy(pending); + self.pending_results.deinit(gpa); + if (self.keymap) |k| k.destroy(); + if (self.libinput_config) |l| l.destroy(); + if (self.xkb_config) |x| x.destroy(); + self.manager.destroy(); + gpa.destroy(self); +} + +/// Bind the two configuration globals, which must happen after +/// `river_input_manager_v1` — river only tells us which input device a libinput +/// device or xkb keyboard belongs to if we already hold an object for it. +pub fn bindConfigGlobals( + self: *InputManager, + registry: *wl.Registry, + libinput_name: ?u32, + xkb_name: ?u32, +) void { + if (libinput_name) |name| { + self.libinput_config = registry.bind(name, river.LibinputConfigV1, 1) catch null; + if (self.libinput_config) |lc| { + lc.setListener(*InputManager, onLibinputConfigEvent, self); + } + } + if (xkb_name) |name| { + self.xkb_config = registry.bind(name, river.XkbConfigV1, 1) catch null; + if (self.xkb_config) |xc| { + xc.setListener(*InputManager, onXkbConfigEvent, self); + self.createKeymap(xc); + } + } +} + +// ─── Events ────────────────────────────────────────────────────────────────── + +fn onManagerEvent(_: *river.InputManagerV1, event: river.InputManagerV1.Event, self: *InputManager) void { + switch (event) { + .input_device => |ev| { + const device = self.wm.gpa.create(Device) catch { + log.err("out of memory tracking a new input device", .{}); + ev.id.destroy(); + return; + }; + device.* = .{ .im = self, .device = ev.id }; + self.devices.append(self.wm.gpa, device) catch { + self.wm.gpa.destroy(device); + ev.id.destroy(); + return; + }; + // Lets the libinput and xkb objects find their way back to us from + // the river_input_device_v1 they name. + ev.id.setListener(*Device, onDeviceEvent, device); + self.dirty = true; + }, + .finished => {}, + } +} + +fn onDeviceEvent(_: *river.InputDeviceV1, event: river.InputDeviceV1.Event, device: *Device) void { + switch (event) { + .name => |ev| { + const gpa = device.im.wm.gpa; + if (device.name) |old| gpa.free(old); + device.name = gpa.dupe(u8, std.mem.span(ev.name)) catch null; + }, + .type => |ev| device.kind = switch (ev.type) { + .keyboard => .keyboard, + .pointer => .pointer, + .touch => .touch, + .tablet => .tablet, + // A device type this build of att_wm has never heard of. Leaving the + // kind unset means rules that name a type skip it, which is the + // conservative reading. + _ => null, + }, + .removed => device.removed = true, + } + device.im.dirty = true; +} + +fn onLibinputConfigEvent( + _: *river.LibinputConfigV1, + event: river.LibinputConfigV1.Event, + self: *InputManager, +) void { + switch (event) { + .libinput_device => |ev| { + // Which device it belongs to arrives in its own event; park a + // listener on it until then. + ev.id.setListener(*InputManager, onLibinputDeviceEvent, self); + }, + .finished => {}, + } +} + +fn onLibinputDeviceEvent( + proxy: *river.LibinputDeviceV1, + event: river.LibinputDeviceV1.Event, + self: *InputManager, +) void { + switch (event) { + .input_device => |ev| { + const device = deviceFromProxy(ev.device) orelse return; + device.libinput = proxy; + self.dirty = true; + }, + .removed => { + if (self.deviceForLibinput(proxy)) |device| device.libinput = null; + proxy.destroy(); + }, + // The support, default and current events describe what the device can + // do and what it is doing. att_wm states what it wants and lets the + // result object report whether the device could oblige, so none of this + // needs tracking. + else => {}, + } +} + +fn onXkbConfigEvent(_: *river.XkbConfigV1, event: river.XkbConfigV1.Event, self: *InputManager) void { + switch (event) { + .xkb_keyboard => |ev| ev.id.setListener(*InputManager, onXkbKeyboardEvent, self), + .finished => {}, + } +} + +fn onXkbKeyboardEvent( + proxy: *river.XkbKeyboardV1, + event: river.XkbKeyboardV1.Event, + self: *InputManager, +) void { + switch (event) { + .input_device => |ev| { + const device = deviceFromProxy(ev.device) orelse return; + device.keyboard = proxy; + self.dirty = true; + }, + .removed => { + if (self.deviceForKeyboard(proxy)) |device| device.keyboard = null; + proxy.destroy(); + }, + // Sent on creation and on every layout switch, so a `grp:` option makes + // this routine — hence debug rather than info. + .layout => |ev| { + const device = self.deviceForKeyboard(proxy); + log.debug("layout {d} ({s}) active on {s}", .{ + ev.index, + if (ev.name) |n| std.mem.span(n) else "unnamed", + if (device) |d| d.displayName() else "?", + }); + }, + // Capslock and numlock state; att_wm does not model either. + else => {}, + } +} + +/// setListener stores our pointer as the proxy's user data, which is how an +/// event naming a river_input_device_v1 gets back to our own struct. +fn deviceFromProxy(proxy: ?*river.InputDeviceV1) ?*Device { + const p = proxy orelse return null; + return @ptrCast(@alignCast(p.getUserData())); +} + +fn deviceForLibinput(self: *InputManager, proxy: *river.LibinputDeviceV1) ?*Device { + for (self.devices.items) |device| { + if (device.libinput == proxy) return device; + } + return null; +} + +fn deviceForKeyboard(self: *InputManager, proxy: *river.XkbKeyboardV1) ?*Device { + for (self.devices.items) |device| { + if (device.keyboard == proxy) return device; + } + return null; +} + +// ─── Applying configuration ────────────────────────────────────────────────── + +/// Act on everything the handlers have recorded since the last call. Safe to +/// call as often as you like; it does nothing unless something changed. +pub fn flush(self: *InputManager) void { + if (!self.dirty) return; + self.dirty = false; + + var i: usize = 0; + while (i < self.devices.items.len) { + const device = self.devices.items[i]; + if (device.removed) { + _ = self.devices.orderedRemove(i); + device.destroy(); + continue; + } + i += 1; + } + + for (self.devices.items) |device| { + // Both the name and the type are sent as the device object is created, + // so waiting for them costs at most one turn of the event loop, and + // matching a rule before they land would match the wrong thing. + const kind = device.kind orelse continue; + const name = device.name orelse continue; + + const rule = ruleFor(name, kind); + + if (!device.applied_core) { + device.applied_core = true; + log.info("input device: {s} ({t})", .{ name, kind }); + self.applyCore(device, kind, rule); + } + + if (device.libinput != null and !device.applied_libinput) { + device.applied_libinput = true; + self.applyLibinput(device, rule); + } + + self.applyOutputMapping(device, kind, rule); + + if (device.keyboard) |keyboard| { + if (!device.applied_keymap and self.keymap_state == .ready) { + device.applied_keymap = true; + keyboard.setKeymap(self.keymap.?); + log.debug("keymap set on {s}", .{name}); + } + } + } +} + +/// An output has appeared, or one has been named. Either may be the output an +/// input rule is waiting for. +pub fn outputsChanged(self: *InputManager) void { + self.dirty = true; +} + +/// An output is going away: drop it from any device mapped to it, so the proxy +/// is not remembered past its destruction and the device is mapped afresh should +/// the output return. +pub fn forgetOutput(self: *InputManager, proxy: *wl.Output) void { + for (self.devices.items) |device| { + if (device.mapped_output == proxy) device.mapped_output = null; + } + self.dirty = true; +} + +/// Confine a device to one output. +/// +/// Unlike every other setting this is re-evaluated on every flush rather than +/// applied once, because outputs come and go — and river drops its own side of +/// the mapping when the output named is destroyed, so a monitor that comes back +/// has to be mapped again. +/// +/// Rotation needs no such care: wlroots reads the output's transform on each +/// input event, so a mapped device follows the display around without anything +/// being re-sent. +fn applyOutputMapping(self: *InputManager, device: *Device, kind: input.Type, rule: input.Rule) void { + const want = rule.map_to_output orelse return; + // Keyboards have no coordinates to map, and river ignores the request for + // them. Skipping quietly keeps a catch-all rule from being noisy. + if (kind == .keyboard) return; + + const proxy = self.wm.wlOutputByName(want) orelse { + if (!device.warned_missing_output) { + device.warned_missing_output = true; + log.warn("cannot map {s} to output {s}: no output by that name", .{ + device.displayName(), + want, + }); + } + return; + }; + + if (device.mapped_output == proxy) return; + device.device.mapToOutput(proxy); + device.mapped_output = proxy; + device.warned_missing_output = false; + log.info("{s}: mapped to output {s}", .{ device.displayName(), want }); +} + +/// Fold every matching rule together, in declaration order, so a later rule can +/// override an earlier one field by field. +fn ruleFor(name: []const u8, kind: input.Type) input.Rule { + var rule: input.Rule = .{}; + for (config.input_rules) |candidate| { + if (candidate.matchesDevice(name, kind)) rule = rule.merge(candidate); + } + return rule; +} + +/// Well beyond anything usable, and comfortably inside what a 24.8 fixed point +/// number can hold. +const max_scroll_factor = 1000; + +/// The settings river implements itself, on river_input_device_v1. +fn applyCore(self: *InputManager, device: *Device, kind: input.Type, rule: input.Rule) void { + _ = self; + + if (kind == .keyboard) { + const repeat = rule.repeat orelse config.repeat; + // Either negative is a protocol error, and river would disconnect us + // over a typo in a config file. + if (repeat.rate < 0 or repeat.delay < 0) { + log.err("ignoring negative key repeat for {s}: rate {d}, delay {d}", .{ + device.displayName(), + repeat.rate, + repeat.delay, + }); + } else { + device.device.setRepeatInfo(repeat.rate, repeat.delay); + log.debug("{s}: repeat rate {d}, delay {d}", .{ + device.displayName(), + repeat.rate, + repeat.delay, + }); + } + } + + if (rule.scroll_factor) |factor| { + // Likewise a protocol error below zero. The upper bound is ours: the + // protocol carries the factor as a 24.8 fixed point number, and + // converting something that does not fit is undefined rather than + // merely wrong. + if (factor < 0 or factor > max_scroll_factor) { + log.err("ignoring out of range scroll factor for {s}: {d} (want 0 to {d})", .{ + device.displayName(), + factor, + max_scroll_factor, + }); + } else { + device.device.setScrollFactor(.fromDouble(factor)); + log.debug("{s}: scroll factor {d}", .{ device.displayName(), factor }); + } + } +} + +/// libinput's own configuration, on river_libinput_device_v1. +/// +/// Every request here returns a result object reporting whether the device could +/// honour it, which is the only way to find out that, say, a mouse has no tap to +/// click to enable. `track` attaches the listener that turns that into a log +/// line naming the setting. +fn applyLibinput(self: *InputManager, device: *Device, rule: input.Rule) void { + const li = device.libinput.?; + const name = device.displayName(); + + if (rule.tap) |on| { + self.track(name, "tap", li.setTap(if (on) .enabled else .disabled)); + } + if (rule.tap_button_map) |map| { + self.track(name, "tap-button-map", li.setTapButtonMap(switch (map) { + .lrm => .lrm, + .lmr => .lmr, + })); + } + if (rule.drag) |on| { + self.track(name, "drag", li.setDrag(if (on) .enabled else .disabled)); + } + if (rule.drag_lock) |state| { + self.track(name, "drag-lock", li.setDragLock(switch (state) { + .disabled => .disabled, + .timeout => .enabled_timeout, + .sticky => .enabled_sticky, + })); + } + if (rule.three_finger_drag) |state| { + self.track(name, "three-finger-drag", li.setThreeFingerDrag(switch (state) { + .disabled => .disabled, + .three_finger => .enabled_3fg, + .four_finger => .enabled_4fg, + })); + } + + if (rule.click_method) |method| { + self.track(name, "click-method", li.setClickMethod(switch (method) { + .none => .none, + .button_areas => .button_areas, + .clickfinger => .clickfinger, + })); + } + if (rule.clickfinger_button_map) |map| { + self.track(name, "clickfinger-button-map", li.setClickfingerButtonMap(switch (map) { + .lrm => .lrm, + .lmr => .lmr, + })); + } + if (rule.middle_emulation) |on| { + self.track(name, "middle-emulation", li.setMiddleEmulation(if (on) .enabled else .disabled)); + } + if (rule.left_handed) |on| { + self.track(name, "left-handed", li.setLeftHanded(if (on) .enabled else .disabled)); + } + + if (rule.natural_scroll) |on| { + self.track(name, "natural-scroll", li.setNaturalScroll(if (on) .enabled else .disabled)); + } + if (rule.scroll_method) |method| { + self.track(name, "scroll-method", li.setScrollMethod(switch (method) { + .none => .no_scroll, + .two_finger => .two_finger, + .edge => .edge, + .on_button_down => .on_button_down, + })); + } + if (rule.scroll_button) |button| { + self.track(name, "scroll-button", li.setScrollButton(button)); + } + if (rule.scroll_button_lock) |on| { + self.track(name, "scroll-button-lock", li.setScrollButtonLock(if (on) .enabled else .disabled)); + } + + if (rule.accel_profile) |profile| { + self.track(name, "accel-profile", li.setAccelProfile(switch (profile) { + .none => .none, + .flat => .flat, + .adaptive => .adaptive, + })); + } + if (rule.accel_speed) |speed| { + // libinput takes a native-endian double, which the protocol carries as + // an array of bytes for want of a floating point argument type. + var value = speed; + var array: wl.Array = .{ + .size = @sizeOf(f64), + .alloc = @sizeOf(f64), + .data = @ptrCast(&value), + }; + self.track(name, "accel-speed", li.setAccelSpeed(&array)); + } + + if (rule.disable_while_typing) |on| { + self.track(name, "disable-while-typing", li.setDwt(if (on) .enabled else .disabled)); + } + if (rule.disable_while_trackpointing) |on| { + self.track(name, "disable-while-trackpointing", li.setDwtp(if (on) .enabled else .disabled)); + } + + if (rule.rotation) |angle| { + self.track(name, "rotation", li.setRotation(angle)); + } + + if (rule.send_events) |mode| { + self.track(name, "send-events", li.setSendEvents(switch (mode) { + .enabled => .{}, + .disabled => .{ .disabled = true }, + .disabled_on_external_mouse => .{ .disabled_on_external_mouse = true }, + })); + } +} + +/// A pending libinput setting, waiting to hear whether it took. +/// +/// The device name is copied in rather than borrowed: a device can be unplugged +/// between the request and the reply, and a diagnostic is not worth a dangling +/// slice. `what` is always a literal from `applyLibinput`. +const Result = struct { + im: *InputManager, + what: []const u8, + name_buf: [64]u8 = undefined, + name_len: usize = 0, + + fn name(self: *const Result) []const u8 { + return self.name_buf[0..self.name_len]; + } +}; + +fn track( + self: *InputManager, + device_name: []const u8, + what: []const u8, + result: anyerror!*river.LibinputResultV1, +) void { + const object = result catch |err| { + log.err("failed to set {s} on {s}: {s}", .{ what, device_name, @errorName(err) }); + return; + }; + + const pending = self.wm.gpa.create(Result) catch { + // Without the listener we simply never learn the outcome; the setting + // itself was still requested. + object.destroy(); + return; + }; + pending.* = .{ .im = self, .what = what }; + pending.name_len = @min(device_name.len, pending.name_buf.len); + @memcpy(pending.name_buf[0..pending.name_len], device_name[0..pending.name_len]); + + self.pending_results.append(self.wm.gpa, pending) catch { + self.wm.gpa.destroy(pending); + object.destroy(); + return; + }; + object.setListener(*Result, onResultEvent, pending); +} + +fn onResultEvent( + object: *river.LibinputResultV1, + event: river.LibinputResultV1.Event, + pending: *Result, +) void { + switch (event) { + .success => {}, + .unsupported => log.warn( + "{s} does not support {s}; setting ignored", + .{ pending.name(), pending.what }, + ), + .invalid => log.err( + "invalid {s} setting for {s}; setting ignored", + .{ pending.what, pending.name() }, + ), + } + // All three events are destructors, so the object is spent either way. + object.destroy(); + pending.im.forgetResult(pending); +} + +fn forgetResult(self: *InputManager, pending: *Result) void { + for (self.pending_results.items, 0..) |item, i| { + if (item == pending) { + _ = self.pending_results.swapRemove(i); + break; + } + } + self.wm.gpa.destroy(pending); +} + +// ─── Keymap ────────────────────────────────────────────────────────────────── + +/// Compile `config.keymap` and hand it to river. +/// +/// river validates it asynchronously and answers on the keymap object, so +/// nothing can be assigned to a keyboard until then; `flush` picks it up once +/// `success` arrives. +fn createKeymap(self: *InputManager, xkb_config: *river.XkbConfigV1) void { + // All-null names are exactly what river compiles by default, so there is + // nothing to gain by sending our own. + if (comptime config.keymap.isDefault()) return; + + const names: xkb.RuleNames = comptime .{ + .rules = zeroTerminate(config.keymap.rules), + .model = zeroTerminate(config.keymap.model), + .layout = zeroTerminate(config.keymap.layout), + .variant = zeroTerminate(config.keymap.variant), + .options = zeroTerminate(config.keymap.options), + }; + + const context = xkb.Context.new(.no_flags) orelse { + log.err("failed to create an xkb context; keeping river's default keymap", .{}); + return; + }; + defer xkb_context_unref(context); + + const keymap = xkb.Keymap.newFromNames(context, &names, .no_flags) orelse { + log.err("failed to compile keymap (layout {s}, variant {s}, options {s})", .{ + config.keymap.layout orelse "default", + config.keymap.variant orelse "default", + config.keymap.options orelse "none", + }); + return; + }; + defer keymap.unref(); + + const text = keymap.getAsString(.text_v1) orelse { + log.err("failed to serialise the compiled keymap", .{}); + return; + }; + defer std.c.free(text); + + const fd = keymapFd(std.mem.span(text)) catch |err| { + log.err("failed to stage the keymap for river: {s}", .{@errorName(err)}); + return; + }; + defer sys.close(fd); + + self.keymap = xkb_config.createKeymap(fd, .text_v1) catch |err| { + log.err("failed to send the keymap to river: {s}", .{@errorName(err)}); + return; + }; + self.keymap.?.setListener(*InputManager, onKeymapEvent, self); + self.keymap_state = .pending; +} + +/// Put a keymap in a sealed memfd for river to mmap. +/// +/// The trailing NUL goes in the file: river sizes the keymap as the file length +/// minus one and requires the content to be zero terminated. +fn keymapFd(text: []const u8) !sys.fd_t { + const fd = try posix.memfd_create( + "att_wm-keymap", + linux.MFD.CLOEXEC | linux.MFD.ALLOW_SEALING, + ); + errdefer sys.close(fd); + + var written: usize = 0; + while (written < text.len) { + written += try sys.write(fd, text[written..]); + } + if (try sys.write(fd, &.{0}) != 1) return error.ShortWrite; + + // Sealing tells river the bytes cannot change under its mmap. Only a + // courtesy — it maps the fd read-only and privately either way — so a + // kernel that refuses is no reason to give up on the keymap. + sys.addSeals(fd, linux.F.SEAL_SHRINK | linux.F.SEAL_GROW | + linux.F.SEAL_WRITE | linux.F.SEAL_SEAL) catch {}; + + return fd; +} + +fn onKeymapEvent(_: *river.XkbKeymapV1, event: river.XkbKeymapV1.Event, self: *InputManager) void { + switch (event) { + .success => { + // Worth saying out loud: a rejected keymap leaves every keyboard on + // river's default, and the symptom is simply that the configured + // layout is not the one typing produces. + log.info("river accepted the keymap (layout {s}, variant {s}, options {s})", .{ + config.keymap.layout orelse "default", + config.keymap.variant orelse "default", + config.keymap.options orelse "none", + }); + self.keymap_state = .ready; + self.dirty = true; + }, + .failure => |ev| { + log.err("river rejected the keymap: {s}", .{std.mem.span(ev.error_msg)}); + self.keymap_state = .failed; + if (self.keymap) |k| k.destroy(); + self.keymap = null; + }, + } +} + +/// Turn a comptime config string into the NUL terminated one xkbcommon wants. +fn zeroTerminate(comptime s: ?[]const u8) ?[*:0]const u8 { + const value = s orelse return null; + return (value ++ "\x00")[0..value.len :0].ptr; +} + +/// zig-xkbcommon 0.4.0 aliases `Context.unref` to `xkb_rmlvo_builder_unref`, +/// which would hand a context to the wrong destructor. Declared here so we call +/// the right one. +extern fn xkb_context_unref(context: *xkb.Context) void; diff --git a/src/Output.zig b/src/Output.zig new file mode 100644 index 0000000..bff08bd --- /dev/null +++ b/src/Output.zig @@ -0,0 +1,297 @@ +//! A logical output, and the window management state that dwm keeps per +//! monitor: the visible tag set, the layout, nmaster and mfact. +//! +//! The arrangement settings are stored per tag rather than per output, as +//! dwm's pertag patch does, so switching tags restores the layout that tag was +//! last arranged with. + +const Output = @This(); + +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const wayland = @import("wayland"); +const wl = wayland.client.wl; +const river = wayland.client.river; + +const config = @import("config"); +const action = @import("action"); + +const Wm = @import("Wm.zig"); +const Window = @import("Window.zig"); +const shm = @import("shm.zig"); +const color = @import("color.zig"); +const layout = @import("layout.zig"); +const Box = layout.Box; + +wm: *Wm, +output: *river.OutputV1, +layer_output: ?*river.LayerShellOutputV1 = null, + +/// The global name of the corresponding wl_output, used to pair the two up. +wl_output_name: u32 = 0, +/// Owned by Wm.wl_outputs; holds the human readable output name. +wl_output: ?*Wm.WlOutput = null, + +/// Full output area in the compositor's logical coordinate space. +box: Box = .{}, +/// The part of `box` not covered by layer-shell exclusive zones. Windows are +/// laid out here so bars are not overlapped. +usable: Box = .{}, +/// Whether `usable` has been reported; before that it tracks `box`. +have_usable: bool = false, + +tags: u32 = config.default_tags, +prev_tags: u32 = config.default_tags, + +/// One slot per tag, plus slot 0 for views of more than one tag. Indexed +/// through `state()`, never directly. +tag_state: [action.tag_count + 1]TagState = @splat(.{}), + +removed: bool = false, + +/// Written by the layout pass each manage sequence, read by the render pass. +tabbar_box: ?Box = null, +/// True when the current layout stacks windows, so only `stack_top` shows. +stacked: bool = false, +stack_top: ?*Window = null, + +tabbar: TabBar, + +pub fn create(wm: *Wm, output: *river.OutputV1) !*Output { + const self = try wm.gpa.create(Output); + self.* = .{ + .wm = wm, + .output = output, + .tabbar = .{ .wm = wm }, + }; + output.setListener(*Output, onEvent, self); + + if (wm.layer_shell) |ls| { + self.layer_output = ls.getOutput(output) catch null; + if (self.layer_output) |lo| { + lo.setListener(*Output, onLayerEvent, self); + } + } + return self; +} + +pub fn destroy(self: *Output) void { + const gpa = self.wm.gpa; + self.tabbar.deinit(); + if (self.layer_output) |lo| lo.destroy(); + // The wl_output entry is owned by Wm; just break the back reference. + if (self.wl_output) |entry| entry.output = null; + self.output.destroy(); + gpa.destroy(self); +} + +pub fn displayName(self: *const Output) []const u8 { + const entry = self.wl_output orelse return "?"; + return entry.name orelse "?"; +} + +/// The area windows are laid out in. +pub fn layoutArea(self: *const Output) Box { + return if (self.have_usable) self.usable else self.box; +} + +/// How one tag is arranged. dwm's pertag patch keeps exactly these four. +pub const TagState = struct { + layout: action.Layout = config.default_layout, + prev_layout: action.Layout = config.default_layout, + nmaster: i32 = config.nmaster, + mfact: f32 = config.mfact, +}; + +/// The arrangement settings in force on this output right now, i.e. those of +/// the tag being viewed. See `action.tagSlot` for how a view picks its slot. +pub fn state(self: *Output) *TagState { + return &self.tag_state[action.tagSlot(self.tags)]; +} + +pub fn setLayout(self: *Output, mode: action.Layout) void { + const st = self.state(); + if (mode == st.layout) return; + st.prev_layout = st.layout; + st.layout = mode; +} + +pub fn setTags(self: *Output, tags: u32) void { + const masked = tags & action.all_tags; + if (masked == 0 or masked == self.tags) return; + self.prev_tags = self.tags; + self.tags = masked; +} + +fn onEvent(_: *river.OutputV1, event: river.OutputV1.Event, self: *Output) void { + switch (event) { + .removed => { + self.removed = true; + self.wm.needsManage(); + }, + .position => |ev| { + self.box.x = ev.x; + self.box.y = ev.y; + self.wm.needsManage(); + }, + .dimensions => |ev| { + self.box.width = ev.width; + self.box.height = ev.height; + self.wm.needsManage(); + }, + .wl_output => |ev| { + self.wl_output_name = ev.name; + self.wm.attachWlOutput(self); + }, + } +} + +fn onLayerEvent(_: *river.LayerShellOutputV1, event: river.LayerShellOutputV1.Event, self: *Output) void { + switch (event) { + .non_exclusive_area => |ev| { + self.usable = .{ .x = ev.x, .y = ev.y, .width = ev.width, .height = ev.height }; + self.have_usable = true; + self.wm.needsManage(); + }, + } +} + +/// The strip of solid colour blocks drawn above the windows in the tabbed +/// layout. One block per window, the focused one highlighted; titles are not +/// drawn here but are published over IPC for bars that want to render them. +pub const TabBar = struct { + wm: *Wm, + + surface: ?*wl.Surface = null, + shell: ?*river.ShellSurfaceV1 = null, + node: ?*river.NodeV1 = null, + pool: ?shm.Pool = null, + + /// Currently mapped, i.e. showing a buffer. + mapped: bool = false, + /// Where the bar is, in global coordinates. + box: Box = .{}, + /// Hit rectangles for the tabs currently drawn, in global coordinates, + /// parallel to `windows`. + rects: std.ArrayList(Box) = .empty, + windows: std.ArrayList(*Window) = .empty, + + pub fn deinit(self: *TabBar) void { + const gpa = self.wm.gpa; + self.rects.deinit(gpa); + self.windows.deinit(gpa); + if (self.pool) |*p| p.deinit(); + if (self.node) |n| n.destroy(); + if (self.shell) |s| s.destroy(); + if (self.surface) |s| s.destroy(); + } + + fn ensureSurface(self: *TabBar) !void { + if (self.surface != null) return; + + const compositor = self.wm.compositor orelse return error.NoCompositor; + const wm_proxy = self.wm.window_manager orelse return error.NoWindowManager; + const wl_shm = self.wm.shm orelse return error.NoShm; + + const surface = try compositor.createSurface(); + errdefer surface.destroy(); + + const shell = try wm_proxy.getShellSurface(surface); + errdefer shell.destroy(); + + const node = try shell.getNode(); + + self.surface = surface; + self.shell = shell; + self.node = node; + self.pool = shm.Pool.init(self.wm.gpa, wl_shm); + } + + /// Draw and place the bar. Must be called during a render sequence. + pub fn show(self: *TabBar, box: Box, windows: []const *Window, focused: ?*Window) void { + self.ensureSurface() catch |err| { + std.log.err("tab bar: {s}", .{@errorName(err)}); + return; + }; + if (box.width <= 0 or box.height <= 0 or windows.len == 0) { + self.hide(); + return; + } + + const gpa = self.wm.gpa; + const pool = &self.pool.?; + + const buffer = pool.acquire(box.width, box.height) catch |err| { + std.log.err("tab bar buffer: {s}", .{@errorName(err)}); + return; + }; + + // Recompute the hit rectangles, in buffer-local coordinates first. + self.rects.clearRetainingCapacity(); + self.windows.clearRetainingCapacity(); + self.rects.ensureTotalCapacity(gpa, windows.len) catch return; + self.windows.appendSlice(gpa, windows) catch return; + self.rects.resize(gpa, windows.len) catch return; + + const local: Box = .{ .x = 0, .y = 0, .width = box.width, .height = box.height }; + layout.tabRects(local, windows.len, self.rects.items); + + const sep = color.toArgb8888(config.tab_separator); + buffer.fill(local, sep); + + for (self.rects.items, windows) |rect, win| { + const is_focused = focused != null and focused.? == win; + const argb = color.toArgb8888(if (is_focused) config.tab_focused else config.tab_normal); + // Leave a one pixel separator on the right of every tab but the + // last, which the background colour shows through. + const inner: Box = .{ + .x = rect.x, + .y = rect.y, + .width = @max(0, rect.width - 1), + .height = rect.height, + }; + buffer.fill(inner, argb); + } + + // Translate the hit rectangles into global coordinates for click + // handling, now that drawing is done. + for (self.rects.items) |*rect| { + rect.x += box.x; + rect.y += box.y; + } + + const surface = self.surface.?; + buffer.busy = true; + surface.attach(buffer.wl_buffer, 0, 0); + surface.damageBuffer(0, 0, box.width, box.height); + self.shell.?.syncNextCommit(); + surface.commit(); + + self.node.?.setPosition(box.x, box.y); + self.box = box; + self.mapped = true; + } + + /// Unmap the bar. Must be called during a render sequence if it was + /// previously shown. + pub fn hide(self: *TabBar) void { + if (!self.mapped) return; + const surface = self.surface orelse return; + surface.attach(null, 0, 0); + self.shell.?.syncNextCommit(); + surface.commit(); + self.mapped = false; + self.rects.clearRetainingCapacity(); + self.windows.clearRetainingCapacity(); + } + + /// Which window's tab covers this global coordinate, if any. + pub fn windowAt(self: *const TabBar, x: i32, y: i32) ?*Window { + if (!self.mapped) return null; + for (self.rects.items, self.windows.items) |rect, win| { + if (rect.contains(x, y)) return win; + } + return null; + } +}; diff --git a/src/Seat.zig b/src/Seat.zig new file mode 100644 index 0000000..a1951d8 --- /dev/null +++ b/src/Seat.zig @@ -0,0 +1,461 @@ +//! A seat: keyboard focus, key and pointer bindings, and interactive +//! move/resize operations. +//! +//! river delivers binding and pointer events immediately but requires the +//! matching protocol requests to be made inside a manage sequence. Every +//! handler here therefore records intent in a field and calls `needsManage`; +//! `applyManage` is the only place that talks back to the compositor. + +const Seat = @This(); + +const std = @import("std"); + +const wayland = @import("wayland"); +const wl = wayland.client.wl; +const river = wayland.client.river; + +const config = @import("config"); +const action = @import("action"); + +const Wm = @import("Wm.zig"); +const Window = @import("Window.zig"); +const Output = @import("Output.zig"); +const layout = @import("layout.zig"); +const Box = layout.Box; + +pub const KeyBinding = struct { + seat: *Seat, + /// Index into config.keys. + index: usize, + object: *river.XkbBindingV1, +}; + +pub const PointerBinding = struct { + seat: *Seat, + /// Index into config.buttons. + index: usize, + object: *river.PointerBindingV1, +}; + +pub const Op = struct { + kind: enum { move, resize }, + window: *Window, + /// The window's cell when the operation started. + start: Box, + /// For resize: which corner is being dragged. + edges: river.WindowV1.Edges = .{}, +}; + +wm: *Wm, +seat: *river.SeatV1, +layer_seat: ?*river.LayerShellSeatV1 = null, + +wl_seat_name: u32 = 0, +wl_seat: ?*wl.Seat = null, +pointer: ?*wl.Pointer = null, + +keys: std.ArrayList(*KeyBinding) = .empty, +buttons: std.ArrayList(*PointerBinding) = .empty, +/// Set once the bindings have been enabled in a manage sequence. +bindings_enabled: bool = false, + +focused: ?*Window = null, +/// Focus we want river to apply in the next manage sequence. +pending_focus: ?*Window = null, +/// True when focus should be cleared rather than moved. +pending_clear_focus: bool = false, + +/// A layer surface (a bar, a launcher) holds focus; our focus requests are +/// either ignored or would steal it. +layer_focus: enum { none, exclusive, non_exclusive } = .none, + +/// Pointer position in the compositor's logical coordinate space. +pointer_x: i32 = 0, +pointer_y: i32 = 0, + +/// The window the pointer is currently inside. +hovered: ?*Window = null, + +op: ?Op = null, +/// An operation to start in the next manage sequence. +pending_op: ?Op = null, +/// The running operation should be ended in the next manage sequence. +pending_op_end: bool = false, + +/// One of our own surfaces has pointer focus (the tab bar). +pointer_surface: ?*wl.Surface = null, +pointer_local_x: f64 = 0, +pointer_local_y: f64 = 0, + +removed: bool = false, + +pub fn create(wm: *Wm, seat: *river.SeatV1) !*Seat { + const self = try wm.gpa.create(Seat); + self.* = .{ .wm = wm, .seat = seat }; + seat.setListener(*Seat, onEvent, self); + + if (wm.layer_shell) |ls| { + self.layer_seat = ls.getSeat(seat) catch null; + if (self.layer_seat) |lseat| lseat.setListener(*Seat, onLayerEvent, self); + } + + try self.createBindings(); + + if (config.cursor_theme) |theme| { + var buf: [256]u8 = undefined; + const z = std.fmt.bufPrintZ(&buf, "{s}", .{theme}) catch null; + if (z) |name| seat.setXcursorTheme(name, config.cursor_size); + } + + return self; +} + +pub fn destroy(self: *Seat) void { + const gpa = self.wm.gpa; + for (self.keys.items) |binding| { + binding.object.destroy(); + gpa.destroy(binding); + } + for (self.buttons.items) |binding| { + binding.object.destroy(); + gpa.destroy(binding); + } + self.keys.deinit(gpa); + self.buttons.deinit(gpa); + if (self.pointer) |p| p.release(); + if (self.wl_seat) |s| s.release(); + if (self.layer_seat) |l| l.destroy(); + self.seat.destroy(); + gpa.destroy(self); +} + +fn createBindings(self: *Seat) !void { + const gpa = self.wm.gpa; + + if (self.wm.xkb_bindings) |xkb_bindings| { + try self.keys.ensureTotalCapacity(gpa, config.keys.len); + for (config.keys, 0..) |key, i| { + const mods: river.SeatV1.Modifiers = @bitCast(key.mods); + const object = xkb_bindings.getXkbBinding(self.seat, @intFromEnum(key.keysym), mods) catch |err| { + std.log.err("failed to bind key {t}: {s}", .{ key.keysym, @errorName(err) }); + continue; + }; + const binding = try gpa.create(KeyBinding); + binding.* = .{ .seat = self, .index = i, .object = object }; + object.setListener(*KeyBinding, onKeyEvent, binding); + self.keys.appendAssumeCapacity(binding); + } + } + + std.log.info("registered {d}/{d} key bindings", .{ self.keys.items.len, config.keys.len }); + + try self.buttons.ensureTotalCapacity(gpa, config.buttons.len); + for (config.buttons, 0..) |button, i| { + const mods: river.SeatV1.Modifiers = @bitCast(button.mods); + const object = self.seat.getPointerBinding(button.button, mods) catch |err| { + std.log.err("failed to bind button {d}: {s}", .{ button.button, @errorName(err) }); + continue; + }; + const binding = try gpa.create(PointerBinding); + binding.* = .{ .seat = self, .index = i, .object = object }; + object.setListener(*PointerBinding, onButtonEvent, binding); + self.buttons.appendAssumeCapacity(binding); + } + + std.log.info("registered {d}/{d} pointer bindings", .{ self.buttons.items.len, config.buttons.len }); +} + +/// The output this seat is working on: the one holding the focused window, +/// else the one under the pointer. +pub fn currentOutput(self: *Seat) ?*Output { + if (self.focused) |win| { + if (win.output) |out| return out; + } + return self.wm.outputAt(self.pointer_x, self.pointer_y) orelse self.wm.firstOutput(); +} + +pub fn focus(self: *Seat, window: ?*Window) void { + if (window) |win| { + self.pending_focus = win; + self.pending_clear_focus = false; + } else { + self.pending_focus = null; + self.pending_clear_focus = true; + } + self.wm.needsManage(); +} + +pub fn startMove(self: *Seat, window: *Window) void { + if (window.fullscreen) return; + self.pending_op = .{ .kind = .move, .window = window, .start = window.cell }; + self.wm.needsManage(); +} + +pub fn startResize(self: *Seat, window: *Window, edges: river.WindowV1.Edges) void { + if (window.fullscreen) return; + self.pending_op = .{ + .kind = .resize, + .window = window, + .start = window.cell, + .edges = edges, + }; + self.wm.needsManage(); +} + +/// Issue the requests recorded by the event handlers. Manage sequence only. +pub fn applyManage(self: *Seat) void { + if (!self.bindings_enabled) { + for (self.keys.items) |binding| binding.object.enable(); + for (self.buttons.items) |binding| binding.object.enable(); + self.bindings_enabled = true; + } + + if (self.pending_op) |op| { + // Dragging a tiled window pops it out into floating, as in dwm. + if (!op.window.floating) { + op.window.floating = true; + op.window.floating_forced = true; + op.window.float_box = op.window.cell; + } + self.seat.opStartPointer(); + self.op = op; + self.op.?.start = op.window.cell; + self.pending_op = null; + op.window.window.informResizeStart(); + } + + if (self.pending_op_end) { + if (self.op) |op| { + self.seat.opEnd(); + if (!op.window.closed) op.window.window.informResizeEnd(); + } + self.op = null; + self.pending_op_end = false; + } + + // A layer surface with exclusive focus outranks us entirely. + if (self.layer_focus == .exclusive) { + self.pending_focus = null; + self.pending_clear_focus = false; + return; + } + + if (self.pending_focus) |win| { + if (!win.closed and win.mapped) { + self.seat.focusWindow(win.window); + self.focused = win; + self.wm.focus_serial += 1; + win.focus_serial = self.wm.focus_serial; + self.warpTo(win); + self.wm.ipcDirty(); + } + self.pending_focus = null; + } else if (self.pending_clear_focus) { + self.seat.clearFocus(); + self.focused = null; + self.pending_clear_focus = false; + self.wm.ipcDirty(); + } +} + +/// Pull the pointer to the middle of a newly focused window. +/// +/// Skipped when the pointer is already inside it, so keyboard focus following +/// the mouse does not yank the cursor out from under the user. Manage sequence +/// only. +fn warpTo(self: *Seat, win: *Window) void { + if (!config.warp_cursor) return; + if (self.op != null) return; + if (win.cell.contains(self.pointer_x, self.pointer_y)) return; + + self.seat.pointerWarp( + win.cell.x + @divTrunc(win.cell.width, 2), + win.cell.y + @divTrunc(win.cell.height, 2), + ); +} + +fn onEvent(_: *river.SeatV1, event: river.SeatV1.Event, self: *Seat) void { + switch (event) { + .removed => { + self.removed = true; + self.wm.needsManage(); + }, + + .wl_seat => |ev| { + self.wl_seat_name = ev.name; + self.wm.attachWlSeat(self); + }, + + .pointer_position => |ev| { + self.pointer_x = ev.x; + self.pointer_y = ev.y; + }, + + .pointer_enter => |ev| { + const win = Wm.windowFromProxy(ev.window) orelse return; + self.hovered = win; + if (config.focus_follows_mouse and self.op == null) { + if (win.mapped and win.visible) self.focus(win); + } + }, + + .pointer_leave => { + self.hovered = null; + }, + + .window_interaction => |ev| { + const win = Wm.windowFromProxy(ev.window) orelse return; + // Clicking a window focuses it and, if floating, raises it. + self.focus(win); + if (win.output) |out| self.wm.focusOutput(out); + }, + + .shell_surface_interaction => { + // Our own tab bar; handled through wl_pointer where we know the + // coordinates. + }, + + .op_delta => |ev| { + const op = self.op orelse return; + const win = op.window; + if (win.closed) return; + + switch (op.kind) { + .move => { + win.cell.x = op.start.x + ev.dx; + win.cell.y = op.start.y + ev.dy; + win.float_box = win.cell; + }, + .resize => { + var box = op.start; + if (op.edges.left) { + box.x = op.start.x + ev.dx; + box.width = op.start.width - ev.dx; + } else { + box.width = op.start.width + ev.dx; + } + if (op.edges.top) { + box.y = op.start.y + ev.dy; + box.height = op.start.height - ev.dy; + } else { + box.height = op.start.height + ev.dy; + } + const min = 2 * config.border_width + 1; + box.width = @max(min, box.width); + box.height = @max(min, box.height); + win.cell = box; + win.float_box = box; + }, + } + self.wm.needsManage(); + }, + + .op_release => { + self.pending_op_end = true; + self.wm.needsManage(); + }, + } +} + +fn onLayerEvent(_: *river.LayerShellSeatV1, event: river.LayerShellSeatV1.Event, self: *Seat) void { + switch (event) { + .focus_exclusive => self.layer_focus = .exclusive, + .focus_non_exclusive => self.layer_focus = .non_exclusive, + .focus_none => { + self.layer_focus = .none; + // Hand focus back to whatever the user was using. + if (self.focused) |win| { + if (!win.closed and win.visible) self.focus(win); + } + }, + } + self.wm.needsManage(); +} + +fn onKeyEvent(_: *river.XkbBindingV1, event: river.XkbBindingV1.Event, binding: *KeyBinding) void { + const self = binding.seat; + const key = config.keys[binding.index]; + switch (event) { + .pressed => { + self.wm.perform(self, key.action); + if (key.shouldRepeat()) self.wm.startRepeat(self, binding.index); + }, + .released, .stop_repeat => { + self.wm.stopRepeat(binding.index); + }, + } +} + +fn onButtonEvent(_: *river.PointerBindingV1, event: river.PointerBindingV1.Event, binding: *PointerBinding) void { + const self = binding.seat; + const button = config.buttons[binding.index]; + switch (event) { + .pressed => { + const win = self.hovered orelse self.wm.windowAt(self.pointer_x, self.pointer_y) orelse return; + self.focus(win); + switch (button.action) { + .move => self.startMove(win), + .resize => { + // Resize from whichever corner the pointer is nearest, so + // the drag pulls the expected edge. + const mid_x = win.cell.x + @divTrunc(win.cell.width, 2); + const mid_y = win.cell.y + @divTrunc(win.cell.height, 2); + self.startResize(win, .{ + .left = self.pointer_x < mid_x, + .right = self.pointer_x >= mid_x, + .top = self.pointer_y < mid_y, + .bottom = self.pointer_y >= mid_y, + }); + }, + } + }, + .released => { + self.pending_op_end = true; + self.wm.needsManage(); + }, + } +} + +// ─── wl_pointer, used only to click the tab bar ────────────────────────────── + +pub fn onWlSeatEvent(_: *wl.Seat, event: wl.Seat.Event, self: *Seat) void { + switch (event) { + .capabilities => |ev| { + if (ev.capabilities.pointer and self.pointer == null) { + self.pointer = self.wl_seat.?.getPointer() catch null; + if (self.pointer) |p| p.setListener(*Seat, onPointerEvent, self); + } + }, + .name => {}, + } +} + +fn onPointerEvent(_: *wl.Pointer, event: wl.Pointer.Event, self: *Seat) void { + switch (event) { + .enter => |ev| { + self.pointer_surface = ev.surface; + self.pointer_local_x = ev.surface_x.toDouble(); + self.pointer_local_y = ev.surface_y.toDouble(); + }, + .leave => { + self.pointer_surface = null; + }, + .motion => |ev| { + self.pointer_local_x = ev.surface_x.toDouble(); + self.pointer_local_y = ev.surface_y.toDouble(); + }, + .button => |ev| { + if (ev.state != .pressed) return; + const surface = self.pointer_surface orelse return; + const out = self.wm.outputForTabBarSurface(surface) orelse return; + + const gx = out.tabbar.box.x + @as(i32, @intFromFloat(self.pointer_local_x)); + const gy = out.tabbar.box.y + @as(i32, @intFromFloat(self.pointer_local_y)); + if (out.tabbar.windowAt(gx, gy)) |win| { + self.wm.focusOutput(out); + self.focus(win); + } + }, + else => {}, + } +} diff --git a/src/Window.zig b/src/Window.zig new file mode 100644 index 0000000..85da4e7 --- /dev/null +++ b/src/Window.zig @@ -0,0 +1,239 @@ +//! A single managed window. +//! +//! Event handlers here only ever mutate plain fields. Every protocol request +//! that changes window management or rendering state is issued from Wm's +//! manage/render sequence handlers, because river only permits those requests +//! between manage_start/manage_finish and render_start/render_finish. + +const Window = @This(); + +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const wayland = @import("wayland"); +const river = wayland.client.river; + +const config = @import("config"); + +const Wm = @import("Wm.zig"); +const Output = @import("Output.zig"); +const layout = @import("layout.zig"); +const Box = layout.Box; + +wm: *Wm, +window: *river.WindowV1, +/// Created lazily: the protocol allows get_node exactly once per window. +node: ?*river.NodeV1 = null, + +title: ?[]u8 = null, +app_id: ?[]u8 = null, +identifier: ?[]u8 = null, +parent: ?*Window = null, + +tags: u32 = 0, +output: ?*Output = null, + +floating: bool = false, +/// Set by rules or by the user; distinguishes "floating because it is a +/// dialog" from "floating because it was asked to be". +floating_forced: bool = false, + +fullscreen: bool = false, +/// What we last told the window, so we only send changes. +informed_fullscreen: bool = false, + +/// Target rectangle including the border, computed by the layout. +cell: Box = .{}, +/// Geometry to restore when a floating window stops being fullscreen. +float_box: Box = .{}, +/// The content size river last reported. +content_width: i32 = 0, +content_height: i32 = 0, +/// The last size we proposed, so we do not re-propose every manage sequence. +proposed_width: i32 = -1, +proposed_height: i32 = -1, + +min_width: i32 = 0, +min_height: i32 = 0, +max_width: i32 = 0, +max_height: i32 = 0, + +/// True once river has sent dimensions, i.e. the window is on screen. +mapped: bool = false, +/// True once we have sent the one-time setup requests. +configured: bool = false, +/// Computed each manage sequence. +visible: bool = false, +/// Whether the window is currently hidden, so we only send changes. +hidden: bool = false, +/// river has closed this window; it must be reaped and not touched again. +closed: bool = false, +/// A close was requested. `close` modifies window management state, so it has +/// to wait for the next manage sequence like everything else. +pending_close: bool = false, + +/// Bumped whenever the window takes focus, giving a cheap "most recently +/// focused" ordering without maintaining dwm's second linked list. +focus_serial: u64 = 0, +/// Cleared once the window has had its one chance at taking focus as it maps. +wants_initial_focus: bool = true, + +pub fn create(wm: *Wm, window: *river.WindowV1) !*Window { + const self = try wm.gpa.create(Window); + self.* = .{ .wm = wm, .window = window }; + window.setListener(*Window, onEvent, self); + return self; +} + +pub fn destroy(self: *Window) void { + const gpa = self.wm.gpa; + if (self.node) |node| node.destroy(); + self.window.destroy(); + if (self.title) |t| gpa.free(t); + if (self.app_id) |a| gpa.free(a); + if (self.identifier) |i| gpa.free(i); + gpa.destroy(self); +} + +/// The node is needed to position and stack the window; create it on demand. +pub fn getNode(self: *Window) ?*river.NodeV1 { + if (self.node) |node| return node; + self.node = self.window.getNode() catch |err| { + std.log.err("failed to create node for window: {s}", .{@errorName(err)}); + return null; + }; + return self.node; +} + +/// Clamp a proposed size to the window's advertised limits. These are hints, +/// but respecting them avoids pointless configure round-trips with windows +/// that will refuse the size anyway. +pub fn clampSize(self: *const Window, width: i32, height: i32) struct { i32, i32 } { + var w = width; + var h = height; + if (self.min_width > 0) w = @max(w, self.min_width); + if (self.min_height > 0) h = @max(h, self.min_height); + if (self.max_width > 0) w = @min(w, self.max_width); + if (self.max_height > 0) h = @min(h, self.max_height); + return .{ @max(1, w), @max(1, h) }; +} + +/// Apply matching rules from config to a newly created window. +pub fn applyRules(self: *Window) void { + for (config.rules) |rule| { + if (rule.app_id) |want| { + const have = self.app_id orelse continue; + if (!std.mem.eql(u8, want, have)) continue; + } + if (rule.title) |want| { + const have = self.title orelse continue; + if (std.mem.indexOf(u8, have, want) == null) continue; + } + if (rule.tags) |t| self.tags = t; + if (rule.floating) |f| { + self.floating = f; + self.floating_forced = f; + } + } +} + +fn setString(self: *Window, field: *?[]u8, value: ?[*:0]const u8) void { + const gpa = self.wm.gpa; + if (field.*) |old| gpa.free(old); + field.* = null; + if (value) |v| { + field.* = gpa.dupe(u8, std.mem.span(v)) catch null; + } +} + +fn onEvent(_: *river.WindowV1, event: river.WindowV1.Event, self: *Window) void { + switch (event) { + .closed => { + self.closed = true; + self.wm.needsManage(); + }, + + .dimensions => |ev| { + self.content_width = ev.width; + self.content_height = ev.height; + if (!self.mapped) { + self.mapped = true; + // Focus and stacking for a new window are settled in the + // manage sequence, and only once it is mapped. + self.wm.needsManage(); + self.wm.ipcDirty(); + } + // A window may resize itself; if it is floating its cell must + // follow, otherwise the border is drawn around the wrong area. + if (self.floating and !self.fullscreen) { + const bw = config.border_width; + self.cell.width = ev.width + 2 * bw; + self.cell.height = ev.height + 2 * bw; + self.float_box = self.cell; + } + }, + + .dimensions_hint => |ev| { + self.min_width = ev.min_width; + self.min_height = ev.min_height; + self.max_width = ev.max_width; + self.max_height = ev.max_height; + }, + + .app_id => |ev| { + self.setString(&self.app_id, ev.app_id); + self.applyRules(); + self.wm.ipcDirty(); + }, + + .title => |ev| { + self.setString(&self.title, ev.title); + self.applyRules(); + self.wm.ipcDirty(); + }, + + .identifier => |ev| { + self.setString(&self.identifier, ev.identifier); + }, + + .parent => |ev| { + self.parent = if (ev.parent) |p| Wm.windowFromProxy(p) else null; + // Dialogs and file pickers float, as in dwm. + if (config.float_children and self.parent != null and !self.floating_forced) { + self.floating = true; + } + }, + + .fullscreen_requested => |ev| { + self.fullscreen = true; + if (ev.output) |o| { + if (Wm.outputFromProxy(o)) |out| self.output = out; + } + self.wm.needsManage(); + }, + + .exit_fullscreen_requested => { + self.fullscreen = false; + self.wm.needsManage(); + }, + + .pointer_move_requested => |ev| { + if (Wm.seatFromProxy(ev.seat)) |seat| seat.startMove(self); + }, + + .pointer_resize_requested => |ev| { + if (Wm.seatFromProxy(ev.seat)) |seat| seat.startResize(self, ev.edges); + }, + + // We advertise only the fullscreen capability, so these should not + // arrive; ignoring them is the documented option either way. + .maximize_requested, + .unmaximize_requested, + .minimize_requested, + .show_window_menu_requested, + .decoration_hint, + .unreliable_pid, + .presentation_hint, + => {}, + } +} diff --git a/src/Wm.zig b/src/Wm.zig new file mode 100644 index 0000000..f758bb8 --- /dev/null +++ b/src/Wm.zig @@ -0,0 +1,1427 @@ +//! The window manager itself. +//! +//! river's protocol is strictly sequenced: window management state may only be +//! changed between `manage_start` and `manage_finish`, rendering state between +//! those or between `render_start` and `render_finish`. Everything else — +//! key bindings, pointer events, IPC commands — only mutates plain Zig fields +//! and calls `needsManage()`. `manage()` and `render()` are the sole places +//! that issue protocol requests, which is what keeps the sequencing rules from +//! leaking into every handler. + +const Wm = @This(); + +const std = @import("std"); +const posix = std.posix; +const linux = std.os.linux; +const Allocator = std.mem.Allocator; +const sys = @import("sys.zig"); + +const wayland = @import("wayland"); +const wl = wayland.client.wl; +const river = wayland.client.river; + +const config = @import("config"); +const act = @import("action"); + +const Window = @import("Window.zig"); +const Output = @import("Output.zig"); +const Seat = @import("Seat.zig"); +const InputManager = @import("InputManager.zig"); +const Ipc = @import("ipc.zig").Ipc; +const color = @import("color.zig"); +const layout = @import("layout.zig"); +const Box = layout.Box; + +gpa: Allocator, +environ: std.process.Environ, + +display: *wl.Display, +registry: *wl.Registry, + +compositor: ?*wl.Compositor = null, +shm: ?*wl.Shm = null, +window_manager: ?*river.WindowManagerV1 = null, +xkb_bindings: ?*river.XkbBindingsV1 = null, +layer_shell: ?*river.LayerShellV1 = null, + +/// Input device configuration. Null when river does not offer +/// river_input_manager_v1, which is to say when it predates 0.4.5. +input: ?*InputManager = null, + +/// The global name of river_window_manager_v1, bound after the first +/// roundtrip so that layer shell and xkb bindings exist before the first +/// window, output or seat event arrives. +wm_global: ?struct { name: u32, version: u32 } = null, + +/// Likewise deferred: river only reports which input device a libinput device +/// or xkb keyboard belongs to if we already hold a river_input_device_v1 for it, +/// so river_input_manager_v1 has to be bound before the two configuration +/// globals, whatever order the registry happens to advertise them in. +input_globals: struct { + manager: ?u32 = null, + libinput_config: ?u32 = null, + xkb_config: ?u32 = null, +} = .{}, + +/// Windows in arrangement order: index 0 is the master window. This is dwm's +/// client list; focus order is tracked separately via Window.focus_serial. +windows: std.ArrayList(*Window) = .empty, +outputs: std.ArrayList(*Output) = .empty, +seats: std.ArrayList(*Seat) = .empty, + +/// wl_output and wl_seat globals seen on the registry, awaiting the +/// river_output_v1.wl_output / river_seat_v1.wl_seat event that tells us which +/// river object each belongs to. +wl_outputs: std.AutoHashMapUnmanaged(u32, *WlOutput) = .empty, +wl_seats: std.AutoHashMapUnmanaged(u32, *wl.Seat) = .empty, + +focused_output: ?*Output = null, +/// The output layer surfaces default to, so we only re-send it on change. +default_layer_output: ?*Output = null, +focus_serial: u64 = 0, + +running: bool = true, +locked: bool = false, +/// Set while inside a manage sequence. +in_manage: bool = false, +/// A manage_dirty request is outstanding. +manage_requested: bool = false, +/// Autostart has been run. +autostarted: bool = false, + +ipc: Ipc, +ipc_dirty_flag: bool = false, + +/// Key repeat. river reports press and release and leaves repeating to us. +repeat_fd: sys.fd_t, +repeat_seat: ?*Seat = null, +repeat_index: ?usize = null, + +/// Scratch buffers reused by the layout pass to avoid per-frame allocation. +scratch_windows: std.ArrayList(*Window) = .empty, +scratch_cells: std.ArrayList(Box) = .empty, +scratch_order: std.ArrayList(*Window) = .empty, + +pub fn init(gpa: Allocator, environ: std.process.Environ) !*Wm { + const display = try wl.Display.connect(null); + errdefer display.disconnect(); + + const registry = try display.getRegistry(); + + const self = try gpa.create(Wm); + errdefer gpa.destroy(self); + + const repeat_fd = try sys.timerfdCreate(.{ .NONBLOCK = true, .CLOEXEC = true }); + errdefer sys.close(repeat_fd); + + var ipc = try Ipc.init(gpa, environ); + errdefer ipc.deinit(); + + self.* = .{ + .gpa = gpa, + .environ = environ, + .display = display, + .registry = registry, + .ipc = ipc, + .repeat_fd = repeat_fd, + }; + + registry.setListener(*Wm, onRegistry, self); + + // The roundtrip below binds globals and allocates output entries, so from + // here on a failure has to release them too. Failing to start is a normal + // outcome (river-classic on PATH, another window manager already running), + // and the leak report would otherwise bury the message explaining it. + errdefer self.freeGlobals(); + + // First roundtrip: bind everything except the window manager global. + if (display.roundtrip() != .SUCCESS) return error.RoundtripFailed; + + const global = self.wm_global orelse { + // Overwhelmingly this means river-classic (0.3.x) is first on PATH: + // it predates river-window-management-v1 and ships riverctl/rivertile + // instead. Say so, because the symptom otherwise is a bare background + // with no working keybindings and no visible explanation. + std.log.err( + \\river_window_manager_v1 is not available. + \\ + \\att_wm requires river 0.4 or newer, and must be started by river: + \\ river -c att_wm + \\ + \\Check `river -version`. If it reports 0.3.x you are running + \\river-classic, which cannot work with att_wm — it has its own + \\window management and is configured with riverctl. + , .{}); + return error.NoWindowManagerGlobal; + }; + + self.window_manager = try registry.bind(global.name, river.WindowManagerV1, global.version); + self.window_manager.?.setListener(*Wm, onWmEvent, self); + + try self.bindInput(); + + return self; +} + +/// Bind the input globals, in the order InputManager requires. +/// +/// A river without them is a river that cannot be told about keyboard layouts or +/// touchpads at all, so say so rather than silently ignoring the config — but +/// only when there is a config to ignore. +fn bindInput(self: *Wm) !void { + const name = self.input_globals.manager orelse { + if (config.input_rules.len > 0 or !config.keymap.isDefault()) { + std.log.warn( + "river_input_manager_v1 is not available; ignoring input configuration. " ++ + "river 0.4.5 or newer is required for it.", + .{}, + ); + } + return; + }; + + const manager = try self.registry.bind(name, river.InputManagerV1, 1); + self.input = try InputManager.create(self, manager); + self.input.?.bindConfigGlobals( + self.registry, + self.input_globals.libinput_config, + self.input_globals.xkb_config, + ); +} + +/// Release what the registry roundtrip allocated: the bound globals and the +/// wl_output bookkeeping. +/// +/// Split out because init's failure path cannot call deinit — the display, the +/// Wm allocation and the IPC socket are already covered there by their own +/// errdefers, and running both would double free. +fn freeGlobals(self: *Wm) void { + const gpa = self.gpa; + + var it = self.wl_outputs.valueIterator(); + while (it.next()) |entry| entry.*.destroy(); + self.wl_outputs.deinit(gpa); + self.wl_outputs = .empty; + self.wl_seats.deinit(gpa); + self.wl_seats = .empty; + + // Owns the per-device objects, so it goes before the globals it hangs off. + if (self.input) |i| i.destroy(); + self.input = null; + + if (self.window_manager) |w| w.destroy(); + if (self.xkb_bindings) |x| x.destroy(); + if (self.layer_shell) |l| l.destroy(); + if (self.compositor) |c| c.destroy(); + if (self.shm) |s| s.destroy(); + self.window_manager = null; + self.xkb_bindings = null; + self.layer_shell = null; + self.compositor = null; + self.shm = null; +} + +pub fn deinit(self: *Wm) void { + const gpa = self.gpa; + + for (self.windows.items) |win| win.destroy(); + // Outputs hold references into wl_outputs, so they must go first. + for (self.outputs.items) |out| out.destroy(); + for (self.seats.items) |seat| seat.destroy(); + self.windows.deinit(gpa); + self.outputs.deinit(gpa); + self.seats.deinit(gpa); + + self.freeGlobals(); + + self.scratch_windows.deinit(gpa); + self.scratch_cells.deinit(gpa); + self.scratch_order.deinit(gpa); + + self.ipc.deinit(); + sys.close(self.repeat_fd); + + self.registry.destroy(); + self.display.disconnect(); + gpa.destroy(self); +} + +/// A wl_output global and the name it reported. +/// +/// The name event is sent immediately on bind, well before river tells us +/// which river_output_v1 the global belongs to. Listening from the moment we +/// bind, and pairing later, is the only way not to miss it. +pub const WlOutput = struct { + wm: *Wm, + proxy: *wl.Output, + name: ?[]u8 = null, + output: ?*Output = null, + + fn onEvent(_: *wl.Output, event: wl.Output.Event, self: *WlOutput) void { + switch (event) { + .name => |ev| { + const gpa = self.wm.gpa; + if (self.name) |old| gpa.free(old); + self.name = gpa.dupe(u8, std.mem.span(ev.name)) catch null; + self.wm.ipcDirty(); + // Input rules name outputs by this name, so it is only now that + // a `map_to_output` can be resolved. + if (self.wm.input) |i| i.outputsChanged(); + }, + else => {}, + } + } + + fn destroy(self: *WlOutput) void { + const gpa = self.wm.gpa; + if (self.name) |n| gpa.free(n); + self.proxy.destroy(); + gpa.destroy(self); + } +}; + +// ─── Proxy → object lookup ─────────────────────────────────────────────────── +// +// setListener stores our pointer as the proxy's user data, so events carrying +// object arguments can be resolved back to our own structs. + +pub fn windowFromProxy(proxy: ?*river.WindowV1) ?*Window { + const p = proxy orelse return null; + return @ptrCast(@alignCast(p.getUserData())); +} + +pub fn outputFromProxy(proxy: ?*river.OutputV1) ?*Output { + const p = proxy orelse return null; + return @ptrCast(@alignCast(p.getUserData())); +} + +pub fn seatFromProxy(proxy: ?*river.SeatV1) ?*Seat { + const p = proxy orelse return null; + return @ptrCast(@alignCast(p.getUserData())); +} + +// ─── Registry ──────────────────────────────────────────────────────────────── + +fn onRegistry(registry: *wl.Registry, event: wl.Registry.Event, self: *Wm) void { + switch (event) { + .global => |g| { + const name = std.mem.span(g.interface); + + if (std.mem.eql(u8, name, "river_window_manager_v1")) { + // Deferred: see init(). + self.wm_global = .{ .name = g.name, .version = @min(g.version, 4) }; + } else if (std.mem.eql(u8, name, "river_xkb_bindings_v1")) { + self.xkb_bindings = registry.bind(g.name, river.XkbBindingsV1, @min(g.version, 3)) catch null; + } else if (std.mem.eql(u8, name, "river_input_manager_v1")) { + self.input_globals.manager = g.name; + } else if (std.mem.eql(u8, name, "river_libinput_config_v1")) { + self.input_globals.libinput_config = g.name; + } else if (std.mem.eql(u8, name, "river_xkb_config_v1")) { + self.input_globals.xkb_config = g.name; + } else if (std.mem.eql(u8, name, "river_layer_shell_v1")) { + // Binding this is what tells river we support layer shell. If + // we do not, it refuses to map layer surfaces at all and bars + // such as quickshell never appear. + self.layer_shell = registry.bind(g.name, river.LayerShellV1, 1) catch null; + } else if (std.mem.eql(u8, name, "wl_compositor")) { + self.compositor = registry.bind(g.name, wl.Compositor, @min(g.version, 4)) catch null; + } else if (std.mem.eql(u8, name, "wl_shm")) { + self.shm = registry.bind(g.name, wl.Shm, 1) catch null; + } else if (std.mem.eql(u8, name, "wl_output")) { + const proxy = registry.bind(g.name, wl.Output, @min(g.version, 4)) catch return; + const entry = self.gpa.create(WlOutput) catch return; + entry.* = .{ .wm = self, .proxy = proxy }; + proxy.setListener(*WlOutput, WlOutput.onEvent, entry); + self.wl_outputs.put(self.gpa, g.name, entry) catch { + entry.destroy(); + return; + }; + self.matchWlOutputs(); + } else if (std.mem.eql(u8, name, "wl_seat")) { + const seat = registry.bind(g.name, wl.Seat, @min(g.version, 7)) catch return; + self.wl_seats.put(self.gpa, g.name, seat) catch {}; + self.matchWlSeats(); + } + }, + .global_remove => |g| { + if (self.wl_outputs.fetchRemove(g.name)) |kv| { + if (kv.value.output) |out| out.wl_output = null; + // Before the proxy goes, so no device is left holding it. river + // drops its own side of the mapping when an output is destroyed, + // so a device mapped to this one has to be mapped afresh if the + // output comes back. + if (self.input) |i| i.forgetOutput(kv.value.proxy); + kv.value.destroy(); + } + _ = self.wl_seats.remove(g.name); + }, + } +} + +/// The river object and the wl_ global can arrive in either order, so both +/// sides call into these to pair them up. +pub fn attachWlOutput(self: *Wm, output: *Output) void { + if (output.wl_output != null) return; + if (self.wl_outputs.get(output.wl_output_name)) |entry| { + output.wl_output = entry; + entry.output = output; + self.ipcDirty(); + } +} + +fn matchWlOutputs(self: *Wm) void { + for (self.outputs.items) |out| { + if (out.wl_output_name != 0) self.attachWlOutput(out); + } +} + +/// The wl_output river calls `name`, for input rules that map a device to one. +/// +/// Names arrive on their own event after the global is bound, so an output whose +/// name has not landed yet simply does not match — `InputManager` retries as +/// each name arrives. +pub fn wlOutputByName(self: *Wm, name: []const u8) ?*wl.Output { + var it = self.wl_outputs.valueIterator(); + while (it.next()) |entry| { + const have = entry.*.name orelse continue; + if (std.mem.eql(u8, have, name)) return entry.*.proxy; + } + return null; +} + +pub fn attachWlSeat(self: *Wm, seat: *Seat) void { + if (seat.wl_seat != null) return; + if (self.wl_seats.get(seat.wl_seat_name)) |wl_seat| { + seat.wl_seat = wl_seat; + wl_seat.setListener(*Seat, Seat.onWlSeatEvent, seat); + } +} + +fn matchWlSeats(self: *Wm) void { + for (self.seats.items) |seat| { + if (seat.wl_seat_name != 0) self.attachWlSeat(seat); + } +} + +// ─── Window manager events ─────────────────────────────────────────────────── + +fn onWmEvent(_: *river.WindowManagerV1, event: river.WindowManagerV1.Event, self: *Wm) void { + switch (event) { + .unavailable => { + std.log.err("window management unavailable: another window manager is already running", .{}); + self.running = false; + }, + .finished => self.running = false, + + .manage_start => self.manage(), + .render_start => self.render(), + + .session_locked => { + self.locked = true; + self.ipcDirty(); + }, + .session_unlocked => { + self.locked = false; + self.ipcDirty(); + }, + + .window => |ev| { + const win = Window.create(self, ev.id) catch |err| { + std.log.err("failed to track new window: {s}", .{@errorName(err)}); + return; + }; + // New windows go to the end of the arrangement order and take the + // currently viewed tags of the output they land on. + self.windows.append(self.gpa, win) catch { + win.destroy(); + return; + }; + self.ipcDirty(); + }, + + .output => |ev| { + const out = Output.create(self, ev.id) catch |err| { + std.log.err("failed to track new output: {s}", .{@errorName(err)}); + return; + }; + self.outputs.append(self.gpa, out) catch { + out.destroy(); + return; + }; + if (self.focused_output == null) self.focused_output = out; + self.matchWlOutputs(); + self.ipcDirty(); + }, + + .seat => |ev| { + const seat = Seat.create(self, ev.id) catch |err| { + std.log.err("failed to track new seat: {s}", .{@errorName(err)}); + return; + }; + self.seats.append(self.gpa, seat) catch { + seat.destroy(); + return; + }; + self.matchWlSeats(); + }, + } +} + +/// Point layer shell at the focused output, so a bar started without an output +/// preference lands where the user is looking. Manage sequence only. +fn updateDefaultLayerOutput(self: *Wm) void { + const out = self.focused_output orelse self.firstOutput() orelse return; + if (self.default_layer_output == out) return; + const lo = out.layer_output orelse return; + lo.setDefault(); + self.default_layer_output = out; +} + +pub fn needsManage(self: *Wm) void { + if (self.manage_requested) return; + self.manage_requested = true; + if (self.window_manager) |wm| wm.manageDirty(); +} + +pub fn ipcDirty(self: *Wm) void { + self.ipc_dirty_flag = true; +} + +// ─── Manage sequence ───────────────────────────────────────────────────────── + +fn manage(self: *Wm) void { + self.manage_requested = false; + self.in_manage = true; + defer self.in_manage = false; + + self.reap(); + self.assignOutputs(); + self.focusNewWindows(); + self.arrangeAll(); + self.applyWindowState(); + + for (self.seats.items) |seat| { + self.validateFocus(seat); + seat.applyManage(); + } + + self.window_manager.?.manageFinish(); + + // Layer surfaces that do not name an output need somewhere to go. + self.updateDefaultLayerOutput(); + + if (!self.autostarted and self.outputs.items.len > 0) { + self.autostarted = true; + for (config.autostart) |cmd| self.spawn(cmd); + } + + self.flushIpc(); +} + +/// Destroy objects river has told us are gone. Done at the top of a manage +/// sequence so no handler ever observes a half-removed object. +fn reap(self: *Wm) void { + var i: usize = 0; + while (i < self.windows.items.len) { + const win = self.windows.items[i]; + if (win.closed) { + _ = self.windows.orderedRemove(i); + for (self.seats.items) |seat| { + if (seat.focused == win) seat.focused = null; + if (seat.pending_focus == win) seat.pending_focus = null; + if (seat.hovered == win) seat.hovered = null; + if (seat.op) |op| if (op.window == win) { + seat.op = null; + }; + if (seat.pending_op) |op| if (op.window == win) { + seat.pending_op = null; + }; + } + for (self.windows.items) |other| { + if (other.parent == win) other.parent = null; + } + win.destroy(); + self.ipcDirty(); + continue; + } + i += 1; + } + + i = 0; + while (i < self.outputs.items.len) { + const out = self.outputs.items[i]; + if (out.removed) { + _ = self.outputs.orderedRemove(i); + // Rehome the windows that were on it rather than losing them. + const fallback: ?*Output = if (self.outputs.items.len > 0) self.outputs.items[0] else null; + for (self.windows.items) |win| { + if (win.output == out) win.output = fallback; + } + if (self.focused_output == out) self.focused_output = fallback; + if (self.default_layer_output == out) self.default_layer_output = null; + for (self.seats.items) |seat| { + if (seat.focused) |f| if (f.output == null) { + seat.focused = null; + }; + } + out.destroy(); + self.ipcDirty(); + continue; + } + i += 1; + } + + i = 0; + while (i < self.seats.items.len) { + const seat = self.seats.items[i]; + if (seat.removed) { + _ = self.seats.orderedRemove(i); + if (self.repeat_seat == seat) self.clearRepeat(); + seat.destroy(); + continue; + } + i += 1; + } +} + +/// Give new windows an output and a tag set. +fn assignOutputs(self: *Wm) void { + const fallback = self.focused_output orelse self.firstOutput(); + for (self.windows.items) |win| { + if (win.output == null) win.output = fallback; + if (win.tags == 0) { + // Inherit the parent's tags for dialogs, else the viewed tags. + if (win.parent) |parent| { + win.tags = parent.tags; + win.output = parent.output; + } else if (win.output) |out| { + win.tags = out.tags; + } + win.applyRules(); + } + } +} + +/// Hand a freshly mapped window the newest focus serial, and focus it, when it +/// arrives into a stacking layout. +/// +/// Those layouts render only the most recently focused window, so a new window +/// that keeps its initial serial of zero is hidden the instant it appears and +/// cannot even be reached with the pointer. Making it the top of the stack +/// without also moving focus would be worse still — the keyboard would go on +/// talking to a window that is no longer on screen — so the two move together, +/// which is what dwm does for every new client anyway. +/// +/// In master a new window is visible wherever it lands in the order, so focus +/// is left where the user put it. +/// +/// Runs before `arrangeAll` so the serial set here is the one the layout picks +/// its top window with. +fn focusNewWindows(self: *Wm) void { + for (self.windows.items) |win| { + if (!win.wants_initial_focus) continue; + if (win.closed or !win.mapped) continue; + win.wants_initial_focus = false; + + // Floating and fullscreen windows are not part of the stack. + if (win.floating or win.fullscreen) continue; + + const out = win.output orelse continue; + if (!layout.stacks(out.state().layout)) continue; + // Spawned onto a tag nobody is looking at: nothing to come to the + // front of, and stealing focus would yank the user off their tag. + if ((win.tags & out.tags) == 0) continue; + + self.focus_serial += 1; + win.focus_serial = self.focus_serial; + for (self.seats.items) |seat| { + if (seat.currentOutput() == out) seat.focus(win); + } + } +} + +/// If a seat's focused window is no longer visible, move focus the way dwm +/// does: to the most recently focused visible window on the same output. +fn validateFocus(self: *Wm, seat: *Seat) void { + const still_ok = blk: { + const win = seat.focused orelse break :blk false; + break :blk !win.closed and win.visible; + }; + if (still_ok) return; + if (seat.pending_focus != null or seat.pending_clear_focus) return; + + const out = seat.currentOutput() orelse { + if (seat.focused != null) seat.focus(null); + return; + }; + if (self.topVisible(out)) |win| { + seat.focus(win); + } else if (seat.focused != null) { + seat.focus(null); + } +} + +/// The most recently focused visible window on an output. +fn topVisible(self: *Wm, out: *Output) ?*Window { + var best: ?*Window = null; + for (self.windows.items) |win| { + if (!win.visible or win.output != out) continue; + if (best == null or win.focus_serial > best.?.focus_serial) best = win; + } + return best; +} + +fn arrangeAll(self: *Wm) void { + for (self.windows.items) |win| win.visible = false; + for (self.outputs.items) |out| self.arrangeOutput(out); +} + +fn arrangeOutput(self: *Wm, out: *Output) void { + const gpa = self.gpa; + const bw = config.border_width; + + self.scratch_windows.clearRetainingCapacity(); + + // Tiled windows, in arrangement order. + for (self.windows.items) |win| { + if (win.output != out) continue; + if ((win.tags & out.tags) == 0) continue; + win.visible = true; + if (win.floating or win.fullscreen) continue; + self.scratch_windows.append(gpa, win) catch return; + } + + const tiled = self.scratch_windows.items; + self.scratch_cells.resize(gpa, tiled.len) catch return; + + const st = out.state(); + const params: layout.Params = .{ + .area = out.layoutArea(), + .nmaster = @intCast(@max(0, st.nmaster)), + .mfact = st.mfact, + .gap = config.gap, + .outer_gap = config.outer_gap, + .tabbar_height = config.tabbar_height, + }; + + const result = layout.arrange(st.layout, params, self.scratch_cells.items); + + for (tiled, self.scratch_cells.items) |win, cell| { + win.cell = cell; + } + + out.tabbar_box = result.tabbar; + out.stacked = result.stacked; + + // In a stacking layout only the top window is worth showing. + out.stack_top = if (result.stacked) blk: { + var best: ?*Window = null; + for (tiled) |win| { + if (best == null or win.focus_serial > best.?.focus_serial) best = win; + } + break :blk best; + } else null; + + // Floating windows keep their own geometry; give brand new ones something + // sensible, centred on the output. + const area = out.layoutArea(); + for (self.windows.items) |win| { + if (win.output != out or !win.visible) continue; + if (!win.floating or win.fullscreen) continue; + + if (win.float_box.width == 0 or win.float_box.height == 0) { + const w = if (win.content_width > 0) win.content_width + 2 * bw else @divTrunc(area.width, 2); + const h = if (win.content_height > 0) win.content_height + 2 * bw else @divTrunc(area.height, 2); + win.float_box = .{ + .x = area.x + @divTrunc(area.width - w, 2), + .y = area.y + @divTrunc(area.height - h, 2), + .width = w, + .height = h, + }; + } + win.cell = win.float_box; + } + + // Fullscreen windows cover the whole output; river owns their geometry. + for (self.windows.items) |win| { + if (win.output != out or !win.visible or !win.fullscreen) continue; + win.cell = out.box; + } +} + +/// Every edge of a window we lay out is tiled: it either abuts another window +/// or the edge of the usable area, both of which are reasons to drop shadows +/// and rounded corners. +/// +/// Reporting no tiled edges is not an option even when a window covers the +/// whole area on its own, as in monocle. Clients treat the tiled state as "the +/// compositor owns my size, use exactly what it proposes"; without it foot +/// rounds down to a whole number of character cells and leaves the rest of the +/// cell empty. Terminals that always take the proposed size, like alacritty, +/// never showed the difference. +const tiled_all: river.WindowV1.Edges = .{ + .left = true, + .right = true, + .top = true, + .bottom = true, +}; + +fn applyWindowState(self: *Wm) void { + const bw = config.border_width; + + for (self.windows.items) |win| { + if (win.closed) continue; + + if (win.pending_close) { + win.pending_close = false; + win.window.close(); + // Nothing else is worth saying to a window on its way out. + continue; + } + + if (!win.configured) { + win.configured = true; + // We honour fullscreen and nothing else, so do not claim to. + win.window.setCapabilities(.{ .fullscreen = true }); + win.window.useSsd(); + } + + if (win.fullscreen) { + if (win.output) |out| { + if (!win.informed_fullscreen) { + win.window.fullscreen(out.output); + win.window.informFullscreen(); + win.informed_fullscreen = true; + } + } + win.window.setTiled(.{}); + continue; + } + + if (win.informed_fullscreen) { + win.window.exitFullscreen(); + win.window.informNotFullscreen(); + win.informed_fullscreen = false; + // Force a re-propose: geometry after leaving fullscreen is + // undefined until we propose again. + win.proposed_width = -1; + win.proposed_height = -1; + } + + win.window.setTiled(if (win.floating) .{} else tiled_all); + + if (!win.visible) continue; + + const content = win.cell.inset(bw); + const w, const h = win.clampSize(content.width, content.height); + if (w != win.proposed_width or h != win.proposed_height) { + win.window.proposeDimensions(w, h); + win.proposed_width = w; + win.proposed_height = h; + } + // Only tiled windows are size-constrained by us; bounding a floating + // window would stop the user growing it past its current size. + if (!win.floating) win.window.setDimensionBounds(w, h); + } +} + +// ─── Render sequence ───────────────────────────────────────────────────────── + +fn render(self: *Wm) void { + const gpa = self.gpa; + const bw = config.border_width; + + self.scratch_order.clearRetainingCapacity(); + + for (self.outputs.items) |out| { + // Tiled windows, bottom of the stack. + for (self.windows.items) |win| { + if (win.output != out or win.closed) continue; + if (win.floating or win.fullscreen) continue; + + const show = win.visible and (!out.stacked or out.stack_top == win); + self.setHidden(win, !show); + if (!show) continue; + + self.place(win, bw); + self.scratch_order.append(gpa, win) catch {}; + } + + // The tab bar sits above the tiled windows it labels. + if (out.stacked and out.tabbar_box != null and config.tabbar_height > 0) { + self.scratch_windows.clearRetainingCapacity(); + for (self.windows.items) |win| { + if (win.output != out or win.closed) continue; + if (win.floating or win.fullscreen or !win.visible) continue; + self.scratch_windows.append(gpa, win) catch {}; + } + if (out.state().layout == .tabbed and self.scratch_windows.items.len > 0) { + out.tabbar.show(out.tabbar_box.?, self.scratch_windows.items, out.stack_top); + } else { + out.tabbar.hide(); + } + } else { + out.tabbar.hide(); + } + + // Floating windows above, oldest focus first so the most recently + // focused ends up on top. + self.scratch_windows.clearRetainingCapacity(); + for (self.windows.items) |win| { + if (win.output != out or win.closed) continue; + if (!win.floating or win.fullscreen) continue; + self.setHidden(win, !win.visible); + if (!win.visible) continue; + self.scratch_windows.append(gpa, win) catch {}; + } + std.mem.sort(*Window, self.scratch_windows.items, {}, lessByFocus); + for (self.scratch_windows.items) |win| { + self.place(win, bw); + self.scratch_order.append(gpa, win) catch {}; + } + + // Fullscreen windows cover everything on their output. + for (self.windows.items) |win| { + if (win.output != out or win.closed or !win.fullscreen) continue; + self.setHidden(win, !win.visible); + if (!win.visible) continue; + if (win.getNode()) |node| node.setPosition(win.cell.x, win.cell.y); + win.window.setBorders(.{}, 0, 0, 0, 0, 0); + self.scratch_order.append(gpa, win) catch {}; + } + } + + // Applying place_top from the bottom up yields exactly the order above. + for (self.scratch_order.items) |win| { + if (win.getNode()) |node| node.placeTop(); + } + for (self.outputs.items) |out| { + if (out.tabbar.mapped) { + if (out.tabbar.node) |node| node.placeTop(); + } + } + + self.window_manager.?.renderFinish(); +} + +fn lessByFocus(_: void, a: *Window, b: *Window) bool { + return a.focus_serial < b.focus_serial; +} + +fn setHidden(self: *Wm, win: *Window, hidden: bool) void { + _ = self; + if (win.hidden == hidden) return; + win.hidden = hidden; + if (hidden) win.window.hide() else win.window.show(); +} + +fn place(self: *Wm, win: *Window, bw: i32) void { + const content = win.cell.inset(bw); + if (win.getNode()) |node| node.setPosition(content.x, content.y); + + const focused = self.isFocusedAnywhere(win); + const rgba = if (focused) config.border_focused else config.border_normal; + const ch = color.toChannels(rgba); + win.window.setBorders( + .{ .top = true, .bottom = true, .left = true, .right = true }, + bw, + ch.r, + ch.g, + ch.b, + ch.a, + ); +} + +fn isFocusedAnywhere(self: *Wm, win: *Window) bool { + for (self.seats.items) |seat| { + if (seat.focused == win) return true; + } + return false; +} + +// ─── Queries ───────────────────────────────────────────────────────────────── + +pub fn firstOutput(self: *Wm) ?*Output { + return if (self.outputs.items.len > 0) self.outputs.items[0] else null; +} + +pub fn outputAt(self: *Wm, x: i32, y: i32) ?*Output { + for (self.outputs.items) |out| { + if (out.box.contains(x, y)) return out; + } + return null; +} + +pub fn windowAt(self: *Wm, x: i32, y: i32) ?*Window { + // Topmost first: reverse focus order among visible windows. + var best: ?*Window = null; + for (self.windows.items) |win| { + if (!win.visible or win.hidden) continue; + if (!win.cell.contains(x, y)) continue; + if (best == null or win.focus_serial > best.?.focus_serial) best = win; + } + return best; +} + +pub fn outputForTabBarSurface(self: *Wm, surface: *wl.Surface) ?*Output { + for (self.outputs.items) |out| { + if (out.tabbar.surface == surface) return out; + } + return null; +} + +pub fn focusOutput(self: *Wm, out: *Output) void { + if (self.focused_output == out) return; + self.focused_output = out; + self.ipcDirty(); +} + +/// Visible tiled-or-floating windows on an output, in arrangement order. +fn visibleOn(self: *Wm, out: *Output, buf: *std.ArrayList(*Window)) []*Window { + buf.clearRetainingCapacity(); + for (self.windows.items) |win| { + if (win.output == out and win.visible and !win.closed) { + buf.append(self.gpa, win) catch {}; + } + } + return buf.items; +} + +// ─── Actions ───────────────────────────────────────────────────────────────── + +pub fn perform(self: *Wm, seat: *Seat, action: act.Action) void { + // While the session is locked only quitting is safe; anything else would + // fight the lock screen for focus. + if (self.locked) switch (action) { + .quit, .exit_session => {}, + else => return, + }; + + switch (action) { + .spawn => |argv| self.spawn(argv), + .close => if (seat.focused) |win| { + win.pending_close = true; + }, + .quit => self.running = false, + .exit_session => if (self.window_manager) |wm| wm.exitSession(), + + .focus => |dir| self.cycleFocus(seat, dir), + .focus_window => |id| self.focusWindowById(seat, id), + .close_window => |id| if (self.windowById(id)) |win| { + win.pending_close = true; + }, + .swap => |dir| self.swapWindow(seat, dir), + .zoom => self.zoom(seat), + + .view => |mask| if (seat.currentOutput()) |out| { + out.setTags(mask); + self.ipcDirty(); + }, + .toggle_view => |mask| if (seat.currentOutput()) |out| { + out.setTags(out.tags ^ mask); + self.ipcDirty(); + }, + .view_prev => if (seat.currentOutput()) |out| { + out.setTags(out.prev_tags); + self.ipcDirty(); + }, + .tag => |mask| if (seat.focused) |win| { + win.tags = mask & act.all_tags; + self.ipcDirty(); + }, + .toggle_tag => |mask| if (seat.focused) |win| { + const new_tags = (win.tags ^ mask) & act.all_tags; + if (new_tags != 0) win.tags = new_tags; + self.ipcDirty(); + }, + + .set_layout => |mode| if (seat.currentOutput()) |out| { + out.setLayout(mode); + self.ipcDirty(); + }, + .toggle_layout => if (seat.currentOutput()) |out| { + out.setLayout(out.state().prev_layout); + self.ipcDirty(); + }, + .cycle_layout => |dir| if (seat.currentOutput()) |out| { + const modes = std.enums.values(act.Layout); + const cur = @intFromEnum(out.state().layout); + const n: i32 = @intCast(modes.len); + const step: i32 = if (dir == .next) 1 else -1; + const next = @mod(@as(i32, @intCast(cur)) + step + n, n); + out.setLayout(@enumFromInt(@as(usize, @intCast(next)))); + self.ipcDirty(); + }, + + .nmaster => |delta| if (seat.currentOutput()) |out| { + const st = out.state(); + st.nmaster = @max(0, delta.apply(st.nmaster)); + self.ipcDirty(); + }, + .mfact => |delta| if (seat.currentOutput()) |out| { + const st = out.state(); + st.mfact = std.math.clamp(delta.apply(st.mfact), config.mfact_min, config.mfact_max); + self.ipcDirty(); + }, + + .toggle_float => if (seat.focused) |win| { + if (!win.fullscreen) { + win.floating = !win.floating; + win.floating_forced = win.floating; + if (win.floating) win.float_box = win.cell; + win.proposed_width = -1; + win.proposed_height = -1; + self.ipcDirty(); + } + }, + .toggle_fullscreen => if (seat.focused) |win| { + win.fullscreen = !win.fullscreen; + win.proposed_width = -1; + win.proposed_height = -1; + self.ipcDirty(); + }, + + .focus_output => |dir| self.cycleOutput(seat, dir, false), + .send_to_output => |dir| self.cycleOutput(seat, dir, true), + + .refresh => self.ipcDirty(), + } + + self.needsManage(); +} + +fn cycleFocus(self: *Wm, seat: *Seat, dir: act.Direction) void { + const out = seat.currentOutput() orelse return; + const list = self.visibleOn(out, &self.scratch_windows); + if (list.len == 0) return; + + const current = seat.focused; + var idx: usize = 0; + if (current) |cur| { + for (list, 0..) |win, i| { + if (win == cur) { + idx = i; + break; + } + } else { + seat.focus(list[0]); + return; + } + } else { + seat.focus(list[0]); + return; + } + + const n = list.len; + const next = switch (dir) { + .next => (idx + 1) % n, + .prev => (idx + n - 1) % n, + }; + seat.focus(list[next]); +} + +/// Focus the window with this identifier, wherever it is. +/// +/// A task list shows every window on an output, including the ones sitting on +/// tags nobody is looking at, so the window clicked may not be visible. Focusing +/// it alone would not survive: `validateFocus` takes focus straight back off an +/// invisible window at the end of the manage sequence. Bringing its tags into +/// view first is what makes the click mean what the user expects, and it is also +/// what dwm does when it focuses a client on another tag. +fn focusWindowById(self: *Wm, seat: *Seat, id: []const u8) void { + const win = self.windowById(id) orelse return; + const out = win.output orelse return; + + if ((win.tags & out.tags) == 0) out.setTags(win.tags); + self.focusOutput(out); + + // In a stacking layout only the most recently focused window is rendered, + // and `arrangeAll` chooses it from the focus serials — before + // `Seat.applyManage` gets to bump the serial of the window being focused. + // Claiming the newest serial here is what brings the window to the front in + // the same pass; without it the click moves the keyboard to a window that + // stays hidden behind the one already on top. + self.focus_serial += 1; + win.focus_serial = self.focus_serial; + + seat.focus(win); + self.ipcDirty(); +} + +fn windowById(self: *Wm, id: []const u8) ?*Window { + for (self.windows.items) |win| { + if (win.closed) continue; + const wid = win.identifier orelse continue; + if (std.mem.eql(u8, wid, id)) return win; + } + return null; +} + +fn swapWindow(self: *Wm, seat: *Seat, dir: act.Direction) void { + const focused = seat.focused orelse return; + const out = focused.output orelse return; + const list = self.visibleOn(out, &self.scratch_windows); + if (list.len < 2) return; + + var idx: ?usize = null; + for (list, 0..) |win, i| { + if (win == focused) idx = i; + } + const i = idx orelse return; + const n = list.len; + const j = switch (dir) { + .next => (i + 1) % n, + .prev => (i + n - 1) % n, + }; + self.swapInOrder(list[i], list[j]); + self.ipcDirty(); +} + +fn swapInOrder(self: *Wm, a: *Window, b: *Window) void { + var ia: ?usize = null; + var ib: ?usize = null; + for (self.windows.items, 0..) |win, i| { + if (win == a) ia = i; + if (win == b) ib = i; + } + if (ia == null or ib == null) return; + std.mem.swap(*Window, &self.windows.items[ia.?], &self.windows.items[ib.?]); +} + +/// dwm's zoom: promote the focused window to master, unless it already is, in +/// which case promote the next one so the key toggles between two windows. +fn zoom(self: *Wm, seat: *Seat) void { + const focused = seat.focused orelse return; + const out = focused.output orelse return; + + self.scratch_windows.clearRetainingCapacity(); + for (self.windows.items) |win| { + if (win.output == out and win.visible and !win.floating and !win.fullscreen) { + self.scratch_windows.append(self.gpa, win) catch {}; + } + } + const tiled = self.scratch_windows.items; + if (tiled.len < 2) return; + + const target = if (tiled[0] == focused) tiled[1] else focused; + self.moveToFront(target, tiled[0]); + seat.focus(target); + self.ipcDirty(); +} + +/// Move `win` to the position `before` currently occupies. +fn moveToFront(self: *Wm, win: *Window, before: *Window) void { + var from: ?usize = null; + var to: ?usize = null; + for (self.windows.items, 0..) |w, i| { + if (w == win) from = i; + if (w == before) to = i; + } + const f = from orelse return; + const t = to orelse return; + if (f == t) return; + + const item = self.windows.orderedRemove(f); + const insert_at = if (f < t) t - 1 else t; + self.windows.insert(self.gpa, insert_at, item) catch { + // Put it back where it was rather than losing it. + self.windows.insert(self.gpa, f, item) catch {}; + }; +} + +fn cycleOutput(self: *Wm, seat: *Seat, dir: act.Direction, send: bool) void { + if (self.outputs.items.len < 2) return; + const current = seat.currentOutput() orelse return; + + var idx: usize = 0; + for (self.outputs.items, 0..) |out, i| { + if (out == current) idx = i; + } + const n = self.outputs.items.len; + const next = switch (dir) { + .next => (idx + 1) % n, + .prev => (idx + n - 1) % n, + }; + const target = self.outputs.items[next]; + + if (send) { + const win = seat.focused orelse return; + win.output = target; + win.tags = target.tags; + win.proposed_width = -1; + win.proposed_height = -1; + } else { + self.focusOutput(target); + if (self.topVisible(target)) |win| seat.focus(win) else seat.focus(null); + } + self.ipcDirty(); +} + +// ─── Key repeat ────────────────────────────────────────────────────────────── + +pub fn startRepeat(self: *Wm, seat: *Seat, index: usize) void { + self.repeat_seat = seat; + self.repeat_index = index; + self.armRepeat(config.binding_repeat_delay); +} + +pub fn stopRepeat(self: *Wm, index: usize) void { + if (self.repeat_index != index) return; + self.clearRepeat(); +} + +fn clearRepeat(self: *Wm) void { + self.repeat_seat = null; + self.repeat_index = null; + self.armRepeat(0); +} + +fn armRepeat(self: *Wm, delay_ms: u32) void { + const spec: linux.itimerspec = .{ + .it_interval = .{ + .sec = 0, + .nsec = if (delay_ms == 0) 0 else @as(isize, config.binding_repeat_interval) * std.time.ns_per_ms, + }, + .it_value = .{ + .sec = 0, + .nsec = if (delay_ms == 0) 0 else @as(isize, delay_ms) * std.time.ns_per_ms, + }, + }; + sys.timerfdSetTime(self.repeat_fd, &spec) catch |err| { + std.log.err("failed to arm repeat timer: {s}", .{@errorName(err)}); + }; +} + +fn onRepeatTick(self: *Wm) void { + var buf: [8]u8 = undefined; + _ = sys.read(self.repeat_fd, &buf) catch return; + + const seat = self.repeat_seat orelse return; + const index = self.repeat_index orelse return; + if (index >= config.keys.len) return; + self.perform(seat, config.keys[index].action); +} + +// ─── Spawning ──────────────────────────────────────────────────────────────── + +pub fn spawn(self: *Wm, argv: []const []const u8) void { + if (argv.len == 0) return; + + const pid = sys.fork() catch |err| { + std.log.err("fork failed: {s}", .{@errorName(err)}); + return; + }; + + if (pid == 0) { + // Double fork so the child is reparented to init and we never have to + // reap it; the window manager has no business waiting on user programs. + const inner = sys.fork() catch sys.exit(1); + if (inner != 0) sys.exit(0); + + sys.setsid(); + // Restore the default disposition so children are not born ignoring + // SIGPIPE, which breaks shell pipelines they spawn in turn. + const dfl: posix.Sigaction = .{ + .handler = .{ .handler = posix.SIG.DFL }, + .mask = posix.sigemptyset(), + .flags = 0, + }; + posix.sigaction(posix.SIG.PIPE, &dfl, null); + + var arena_state = std.heap.ArenaAllocator.init(std.heap.page_allocator); + const arena = arena_state.allocator(); + + const argv_z = arena.allocSentinel(?[*:0]const u8, argv.len, null) catch sys.exit(1); + for (argv, 0..) |arg, i| { + argv_z[i] = (arena.dupeZ(u8, arg) catch sys.exit(1)).ptr; + } + + const err = sys.execvpe( + argv_z.ptr, + self.environ.block.slice.ptr, + self.environ.getPosix("PATH"), + ); + std.log.err("exec {s} failed: {s}", .{ argv[0], @errorName(err) }); + sys.exit(1); + } + + // Reap the intermediate child immediately; it exits at once. + sys.waitpid(pid); +} + +// ─── IPC plumbing ──────────────────────────────────────────────────────────── + +fn flushIpc(self: *Wm) void { + if (!self.ipc_dirty_flag) return; + self.ipc_dirty_flag = false; + self.ipc.broadcast(self) catch |err| { + std.log.err("failed to broadcast state: {s}", .{@errorName(err)}); + }; +} + +/// Run an action that arrived over IPC. There is no seat in the request, so it +/// applies to the first seat, which is the only one on a normal setup. +pub fn performIpc(self: *Wm, action: act.Action) void { + const seat = if (self.seats.items.len > 0) self.seats.items[0] else return; + self.perform(seat, action); +} + +// ─── Event loop ────────────────────────────────────────────────────────────── + +pub fn run(self: *Wm) !void { + const wayland_fd = self.display.getFd(); + + while (self.running) { + // libwayland's read dance: prepare, flush, poll, read, dispatch. + while (!self.display.prepareRead()) { + if (self.display.dispatchPending() != .SUCCESS) return error.DispatchFailed; + } + + // EAGAIN just means the socket buffer is full; the poll below will + // wait and the next iteration flushes the rest. + switch (self.display.flush()) { + .SUCCESS, .AGAIN => {}, + else => { + self.display.cancelRead(); + return error.FlushFailed; + }, + } + + var fds: std.ArrayList(posix.pollfd) = .empty; + defer fds.deinit(self.gpa); + + try fds.append(self.gpa, .{ .fd = wayland_fd, .events = posix.POLL.IN, .revents = 0 }); + try fds.append(self.gpa, .{ .fd = self.repeat_fd, .events = posix.POLL.IN, .revents = 0 }); + try self.ipc.pollFds(&fds, self.gpa); + + const ready = posix.poll(fds.items, -1) catch |err| { + self.display.cancelRead(); + if (err == error.SignalInterrupt) continue; + return err; + }; + if (ready == 0) { + self.display.cancelRead(); + continue; + } + + if (fds.items[0].revents & posix.POLL.IN != 0) { + if (self.display.readEvents() != .SUCCESS) return error.ReadFailed; + } else { + self.display.cancelRead(); + } + + if (self.display.dispatchPending() != .SUCCESS) return error.DispatchFailed; + + // Input device events arrive across three globals and in no useful + // order, so acting on them waits until a whole batch is dispatched. + if (self.input) |i| i.flush(); + + if (fds.items[1].revents & posix.POLL.IN != 0) self.onRepeatTick(); + + try self.ipc.handle(self, fds.items[2..]); + + self.flushIpc(); + } + + _ = self.display.flush(); +} diff --git a/src/action.zig b/src/action.zig new file mode 100644 index 0000000..989cb4f --- /dev/null +++ b/src/action.zig @@ -0,0 +1,323 @@ +//! The vocabulary of things att_wm can be asked to do. +//! +//! This module deliberately depends on nothing but xkbcommon. Keeping it free +//! of Wayland objects and window manager state is what lets `config.zig` import +//! it to declare key bindings without creating an import cycle back into the +//! window manager, and it is what lets key bindings and IPC commands share a +//! single execution path: both become an `Action`, and `Wm.perform` is the only +//! place that interprets one. + +const std = @import("std"); +const mem = std.mem; + +pub const xkb = @import("xkbcommon"); + +/// Number of tags. Nine is dwm's default and what the example quickshell bar +/// assumes; changing it here changes it everywhere. +pub const tag_count = 9; + +pub const all_tags: u32 = (1 << tag_count) - 1; + +/// Which slot of an output's per-tag arrangement settings a view of `tags` uses. +/// +/// A view of exactly one tag gets that tag's own slot, numbered from 1. Viewing +/// several at once has no single tag whose settings should win, so all such +/// views share slot 0 — the compromise dwm's pertag patch makes. It leaves the +/// individual tags' settings untouched, so they are still there on the way back. +/// An empty mask is not reachable through `Output.setTags`, but shares slot 0 +/// too rather than being a case callers have to think about. +pub fn tagSlot(tags: u32) usize { + const t = tags & all_tags; + if (@popCount(t) != 1) return 0; + return @ctz(t) + 1; +} + +/// Keyboard modifiers, matching the values of river_seat_v1.modifiers so the +/// mask can be bit-cast straight into the protocol type. +pub const Mods = struct { + pub const none: u32 = 0; + pub const shift: u32 = 1; + pub const ctrl: u32 = 4; + /// Commonly called alt. + pub const alt: u32 = 8; + pub const mod3: u32 = 32; + /// Commonly called super or logo. + pub const super: u32 = 64; + pub const mod5: u32 = 128; +}; + +pub const Direction = enum { + next, + prev, + + pub fn parse(s: []const u8) ?Direction { + if (mem.eql(u8, s, "next")) return .next; + if (mem.eql(u8, s, "prev") or mem.eql(u8, s, "previous")) return .prev; + return null; + } +}; + +pub const Layout = enum { + master, + monocle, + tabbed, + + pub fn parse(s: []const u8) ?Layout { + return std.meta.stringToEnum(Layout, s); + } + + /// dwm-style short symbol for the bar. + pub fn symbol(self: Layout) []const u8 { + return switch (self) { + .master => "[]=", + .monocle => "[M]", + .tabbed => "|||", + }; + } +}; + +/// A relative or absolute adjustment to a numeric setting. dwm only ever does +/// relative ones, but IPC callers frequently want to set a value outright. +pub fn Delta(comptime T: type) type { + return union(enum) { + relative: T, + absolute: T, + + const Self = @This(); + + /// A leading `+` or `-` means relative, anything else absolute, so + /// `att_wmctl mfact +0.05` nudges and `att_wmctl mfact 0.5` sets. + pub fn parse(s: []const u8) ?Self { + if (s.len == 0) return null; + const signed = s[0] == '+' or s[0] == '-'; + const value = switch (@typeInfo(T)) { + .int => std.fmt.parseInt(T, s, 10) catch return null, + .float => std.fmt.parseFloat(T, s) catch return null, + else => @compileError("unsupported Delta type"), + }; + return if (signed) Self{ .relative = value } else Self{ .absolute = value }; + } + + pub fn apply(self: Self, current: T) T { + return switch (self) { + .relative => |d| current + d, + .absolute => |v| v, + }; + } + }; +} + +pub const Action = union(enum) { + /// Run a command. The slice is argv; it is executed without a shell. + spawn: []const []const u8, + /// Ask the focused window to close. + close, + /// Terminate att_wm, leaving river running. + quit, + /// End the Wayland session entirely (river exits too). + exit_session, + + /// Move keyboard focus through the visible windows of the focused output. + focus: Direction, + /// Focus one particular window, named by the identifier published over IPC. + /// Key bindings only ever want a direction; a bar's task list needs to name + /// the window the user clicked, and river's `identifier` is the only handle + /// that is stable and never reused. + focus_window: []const u8, + /// Close one particular window, likewise by identifier, so a bar need not + /// focus a window first just to close it. + close_window: []const u8, + /// Move the focused window through the arrangement order. + swap: Direction, + /// Promote the focused window to master, or if it is already master, + /// promote the one below it. This is dwm's zoom(). + zoom, + + /// Replace the set of visible tags on the focused output. + view: u32, + /// Add or remove tags from the visible set. + toggle_view: u32, + /// Switch back to the previously viewed tag set. + view_prev, + /// Replace the focused window's tags. + tag: u32, + /// Add or remove tags from the focused window's tags. + toggle_tag: u32, + + set_layout: Layout, + cycle_layout: Direction, + /// Toggle between the current layout and the previous one, as dwm's + /// Mod+space does. + toggle_layout, + + nmaster: Delta(i32), + mfact: Delta(f32), + + toggle_float, + toggle_fullscreen, + + focus_output: Direction, + send_to_output: Direction, + + /// Re-broadcast state to IPC subscribers. A hook for bars that reconnect. + refresh, + + /// True for actions where holding the key down should keep applying the + /// action. river reports key press/release and leaves repeat up to us. + pub fn repeats(self: Action) bool { + return switch (self) { + .focus, .swap, .nmaster, .mfact, .cycle_layout => true, + else => false, + }; + } +}; + +pub const ParseError = error{ + UnknownCommand, + MissingArgument, + InvalidArgument, +}; + +/// Parse an `att_wmctl` command line into an Action. +/// +/// Tag arguments accept either a 1-based tag index (`view 3`) or an explicit +/// bitmask (`view 0x4`, `view mask:4`, `view all`), because bars find masks +/// convenient and humans find indices convenient. +pub fn parse(argv: []const []const u8) ParseError!Action { + if (argv.len == 0) return error.UnknownCommand; + const rest = argv[1..]; + + const Cmd = enum { + spawn, + close, + quit, + @"exit-session", + focus, + @"focus-window", + @"close-window", + swap, + zoom, + view, + @"toggle-view", + @"view-prev", + tag, + @"toggle-tag", + layout, + @"cycle-layout", + @"toggle-layout", + nmaster, + mfact, + @"toggle-float", + @"toggle-fullscreen", + @"focus-output", + @"send-to-output", + refresh, + }; + + const c = std.meta.stringToEnum(Cmd, argv[0]) orelse return error.UnknownCommand; + + return switch (c) { + .spawn => if (rest.len == 0) error.MissingArgument else Action{ .spawn = rest }, + .close => .close, + .quit => .quit, + .@"exit-session" => .exit_session, + .zoom => .zoom, + .@"view-prev" => .view_prev, + .@"toggle-layout" => .toggle_layout, + .@"toggle-float" => .toggle_float, + .@"toggle-fullscreen" => .toggle_fullscreen, + .refresh => .refresh, + + .focus => .{ .focus = try dir(rest) }, + .@"focus-window" => .{ .focus_window = try windowId(rest) }, + .@"close-window" => .{ .close_window = try windowId(rest) }, + .swap => .{ .swap = try dir(rest) }, + .@"focus-output" => .{ .focus_output = try dir(rest) }, + .@"send-to-output" => .{ .send_to_output = try dir(rest) }, + .@"cycle-layout" => .{ .cycle_layout = dir(rest) catch .next }, + + .view => .{ .view = try tagMask(rest) }, + .@"toggle-view" => .{ .toggle_view = try tagMask(rest) }, + .tag => .{ .tag = try tagMask(rest) }, + .@"toggle-tag" => .{ .toggle_tag = try tagMask(rest) }, + + .layout => blk: { + if (rest.len == 0) return error.MissingArgument; + break :blk .{ .set_layout = Layout.parse(rest[0]) orelse return error.InvalidArgument }; + }, + .nmaster => blk: { + if (rest.len == 0) return error.MissingArgument; + break :blk .{ .nmaster = Delta(i32).parse(rest[0]) orelse return error.InvalidArgument }; + }, + .mfact => blk: { + if (rest.len == 0) return error.MissingArgument; + break :blk .{ .mfact = Delta(f32).parse(rest[0]) orelse return error.InvalidArgument }; + }, + }; +} + +fn dir(rest: []const []const u8) ParseError!Direction { + if (rest.len == 0) return error.MissingArgument; + return Direction.parse(rest[0]) orelse error.InvalidArgument; +} + +/// The identifier is opaque to us — river only promises up to 32 printable +/// ASCII bytes — so the one thing worth rejecting is an empty argument, which +/// would otherwise silently match no window. +fn windowId(rest: []const []const u8) ParseError![]const u8 { + if (rest.len == 0) return error.MissingArgument; + if (rest[0].len == 0) return error.InvalidArgument; + return rest[0]; +} + +fn tagMask(rest: []const []const u8) ParseError!u32 { + if (rest.len == 0) return error.MissingArgument; + const s = rest[0]; + + if (mem.eql(u8, s, "all")) return all_tags; + + if (mem.startsWith(u8, s, "mask:")) { + const v = std.fmt.parseInt(u32, s["mask:".len..], 0) catch return error.InvalidArgument; + return v & all_tags; + } + + // A 0x/0b-prefixed value is a mask; a bare decimal is a 1-based index. + if (mem.startsWith(u8, s, "0x") or mem.startsWith(u8, s, "0b")) { + const v = std.fmt.parseInt(u32, s, 0) catch return error.InvalidArgument; + return v & all_tags; + } + + const idx = std.fmt.parseInt(u32, s, 10) catch return error.InvalidArgument; + if (idx < 1 or idx > tag_count) return error.InvalidArgument; + return @as(u32, 1) << @intCast(idx - 1); +} + +/// A single key binding, as declared in config.zig. +pub const Key = struct { + mods: u32, + keysym: xkb.Keysym, + action: Action, + /// Overrides `Action.repeats()` when set. + repeat: ?bool = null, + + pub fn shouldRepeat(self: Key) bool { + return self.repeat orelse self.action.repeats(); + } +}; + +/// A pointer binding, as declared in config.zig. +pub const Button = struct { + mods: u32, + /// Linux input event code, e.g. `btn.left`. + button: u32, + action: PointerAction, +}; + +pub const PointerAction = enum { move, resize }; + +/// Linux input event codes for the buttons worth binding. +pub const btn = struct { + pub const left: u32 = 0x110; + pub const right: u32 = 0x111; + pub const middle: u32 = 0x112; +}; diff --git a/src/color.zig b/src/color.zig new file mode 100644 index 0000000..4e84363 --- /dev/null +++ b/src/color.zig @@ -0,0 +1,89 @@ +//! Colour conversion. +//! +//! Config declares colours as the familiar 0xRRGGBBAA with straight alpha. +//! The two sinks want something different: +//! +//! * `river_window_v1.set_borders` takes one full-range u32 per channel — +//! river divides each by maxInt(u32) — with premultiplied alpha. +//! * wl_shm ARGB8888 wants premultiplied 8-bit channels packed into a u32. + +const std = @import("std"); + +pub const Rgba = u32; + +pub const Channels = struct { + r: u32, + g: u32, + b: u32, + a: u32, +}; + +fn premul8(c: u8, a: u8) u8 { + // Round to nearest rather than truncating, so 0xff at full alpha stays + // 0xff instead of drifting down. + return @intCast((@as(u32, c) * @as(u32, a) + 127) / 255); +} + +/// Expand an 8-bit channel to the full u32 range: 0xff maps exactly to +/// 0xffffffff, which is what river treats as 1.0. +fn expand(c: u8) u32 { + return @as(u32, c) * 0x01010101; +} + +fn split(rgba: Rgba) [4]u8 { + return .{ + @intCast((rgba >> 24) & 0xff), + @intCast((rgba >> 16) & 0xff), + @intCast((rgba >> 8) & 0xff), + @intCast(rgba & 0xff), + }; +} + +/// Premultiplied, full-range channels for `set_borders`. +pub fn toChannels(rgba: Rgba) Channels { + const c = split(rgba); + const a = c[3]; + return .{ + .r = expand(premul8(c[0], a)), + .g = expand(premul8(c[1], a)), + .b = expand(premul8(c[2], a)), + .a = expand(a), + }; +} + +/// Premultiplied ARGB8888 as a native-endian u32, for wl_shm buffers. +pub fn toArgb8888(rgba: Rgba) u32 { + const c = split(rgba); + const a = c[3]; + return (@as(u32, a) << 24) | + (@as(u32, premul8(c[0], a)) << 16) | + (@as(u32, premul8(c[1], a)) << 8) | + @as(u32, premul8(c[2], a)); +} + +test "opaque white survives both conversions intact" { + const ch = toChannels(0xffffffff); + try std.testing.expectEqual(@as(u32, 0xffffffff), ch.r); + try std.testing.expectEqual(@as(u32, 0xffffffff), ch.a); + try std.testing.expectEqual(@as(u32, 0xffffffff), toArgb8888(0xffffffff)); +} + +test "fully transparent premultiplies to zero" { + const ch = toChannels(0xffffff00); + try std.testing.expectEqual(@as(u32, 0), ch.r); + try std.testing.expectEqual(@as(u32, 0), ch.a); + try std.testing.expectEqual(@as(u32, 0), toArgb8888(0xffffff00)); +} + +test "opaque colour keeps its channels in argb order" { + // 0xRRGGBBAA -> 0xAARRGGBB + try std.testing.expectEqual(@as(u32, 0xff5294e2), toArgb8888(0x5294e2ff)); +} + +test "half alpha premultiplies channels but not alpha" { + const ch = toChannels(0xff000080); + try std.testing.expectEqual(@as(u32, 0x80808080), ch.a); + // 0xff * 0x80 / 0xff == 0x80 + try std.testing.expectEqual(@as(u32, 0x80808080), ch.r); + try std.testing.expectEqual(@as(u32, 0), ch.g); +} diff --git a/src/config.zig b/src/config.zig new file mode 100644 index 0000000..fbe3a75 --- /dev/null +++ b/src/config.zig @@ -0,0 +1,235 @@ +//! att_wm configuration, in the spirit of dwm's config.h: edit and rebuild. +//! +//! Nix users need not patch the source tree — pass a replacement path instead: +//! +//! zig build -Dconfig=/path/to/my-config.zig +//! att_wm.override { config = ./my-config.zig; } + +const action = @import("action"); +const input = @import("input"); +const xkb = @import("xkbcommon"); + +const Key = action.Key; +const Button = action.Button; +const Mods = action.Mods; +const btn = action.btn; + +/// The dwm "MODKEY". Alt, as dwm ships it; use `Mods.super` if you would +/// rather not compete with applications that bind Alt themselves. +pub const mod = Mods.alt; + +// ─── Appearance ────────────────────────────────────────────────────────────── + +pub const border_width: i32 = 2; + +/// Gap between adjacent windows. Zero is dwm-faithful. +pub const gap: i32 = 0; +/// Gap between windows and the edge of the usable area. +pub const outer_gap: i32 = 0; + +/// Colours are 0xRRGGBBAA, straight-alpha; they are premultiplied on the way +/// to the protocol. +pub const border_focused: u32 = 0x5294e2ff; +pub const border_normal: u32 = 0x444444ff; + +/// Height of the tab bar drawn in the tabbed layout. Set to 0 to let a bar +/// such as quickshell draw the tabs instead, using the IPC `windows` list. +pub const tabbar_height: i32 = 22; + +pub const tab_focused: u32 = 0x5294e2ff; +pub const tab_normal: u32 = 0x2c2c2cff; +/// Drawn as a 1px line between adjacent tabs. +pub const tab_separator: u32 = 0x1a1a1aff; + +// ─── Layout ────────────────────────────────────────────────────────────────── + +pub const default_layout = action.Layout.master; + +/// Windows in the master area. +pub const nmaster: i32 = 1; +/// Fraction of the output width given to the master area. +pub const mfact: f32 = 0.55; +pub const mfact_min: f32 = 0.05; +pub const mfact_max: f32 = 0.95; + +/// Tags visible on a newly connected output. +pub const default_tags: u32 = 1; + +/// Names exported over IPC for bars to label tags with. +pub const tag_names = [action.tag_count][]const u8{ + "1", "2", "3", "4", "5", "6", "7", "8", "9", +}; + +// ─── Behaviour ─────────────────────────────────────────────────────────────── + +/// dwm's sloppy focus: moving the pointer over a window focuses it. +pub const focus_follows_mouse = false; + +/// Warp the pointer to the centre of a window when focus moves there by +/// keyboard. dwm does not do this; it is handy on multi-head setups. +pub const warp_cursor = false; + +/// Windows with a parent (dialogs, file pickers) start floating, as in dwm. +pub const float_children = true; + +/// How fast a held-down *binding* re-fires, in milliseconds. river reports key +/// press and release and leaves repeating to att_wm, so this is what governs +/// `Mod+j` held down — not what applications see, which is `repeat` below. +pub const binding_repeat_delay: u32 = 300; +pub const binding_repeat_interval: u32 = 40; + +pub const cursor_theme: ?[]const u8 = null; +pub const cursor_size: u32 = 24; + +/// Commands run once at startup, after the connection to river is up. +pub const autostart = [_][]const []const u8{ + // .{ "quickshell", "-c", "att_wm" }, +}; + +pub const terminal = [_][]const u8{"foot"}; +pub const menu = [_][]const u8{ "wmenu-run", "-f", "monospace 10" }; + +// ─── Input ─────────────────────────────────────────────────────────────────── + +/// The xkb layout every keyboard gets, as `setxkbmap` takes it. All-null — the +/// default — leaves river's own choice alone, which honours the `XKB_DEFAULT_*` +/// environment variables and otherwise gives you `us`. +pub const keymap: input.Keymap = .{ + // .layout = "us,se", + // .options = "grp:alt_shift_toggle,caps:escape", +}; + +/// Key repeat as applications see it — not to be confused with +/// `binding_repeat_delay` above, which is how fast a held-down att_wm binding +/// re-fires. Per-device overrides go in `input_rules`. +/// +/// These are river's own defaults, so leaving them alone changes nothing. +pub const repeat: input.Repeat = .{ + // Repeats per second. Zero turns key repeat off. + .rate = 70, + // Milliseconds a key is held before repeating starts. + .delay = 150, +}; + +/// Per-device settings, matched on name and type. `name` is a glob, so `*` does +/// the work of writing out "ELAN0501:00 04F3:3060 Touchpad" in full. +/// +/// att_wm logs one line per device as it appears — name and type — which is where +/// to find the names; there is no `list-inputs` command because the protocol +/// shows input devices to the window manager alone. +/// +/// Every setting defaults to null, meaning "leave libinput's own default". Rules +/// are applied in order and a later one overrides an earlier one field by field. +pub const input_rules = [_]input.Rule{ + // A laptop touchpad. Tap to click and ignoring the pad mid-keystroke are + // near-universally wanted; scroll direction and click method are matters of + // taste, so they are left to you. + .{ + .name = "*Touchpad*", + .tap = true, + .disable_while_typing = true, + // .natural_scroll = true, + // .click_method = .clickfinger, + }, + + // A per-device key repeat, faster than the default above. + // .{ .type = .keyboard, .repeat = .{ .rate = 50, .delay = 250 } }, + + // Confining a touchscreen or pen to one output is what makes touch follow + // display rotation: mapped devices have the output's transform applied to + // every event, so `wlr-randr --transform` or rot8 rotates touch with the + // screen — no rotation hook, no calibration matrix. It is also what stops a + // touchscreen spanning both monitors on a multi-head setup. + // + // The glob catches both halves of the panel — "Wacom HID 5380 Finger" is the + // touchscreen and "... Pen" the stylus, which river reports as `touch` and + // `tablet` respectively. `.{ .type = .touch, ... }` and a second rule for + // `.tablet` would do the same job without naming the hardware. + .{ .name = "Wacom HID 5380*", .map_to_output = "eDP-1" }, +}; + +// ─── Rules ─────────────────────────────────────────────────────────────────── + +/// Matched against a window's app_id and title. A null field matches anything. +pub const Rule = struct { + app_id: ?[]const u8 = null, + title: ?[]const u8 = null, + tags: ?u32 = null, + floating: ?bool = null, +}; + +pub const rules = [_]Rule{ + .{ .app_id = "pavucontrol", .floating = true }, + .{ .app_id = "org.pulseaudio.pavucontrol", .floating = true }, + .{ .title = "Picture-in-Picture", .floating = true }, +}; + +// ─── Key bindings ──────────────────────────────────────────────────────────── + +pub const keys = tagKeys() ++ [_]Key{ + .{ .mods = mod | Mods.shift, .keysym = xkb.Keysym.Return, .action = .{ .spawn = &terminal } }, + .{ .mods = mod, .keysym = xkb.Keysym.p, .action = .{ .spawn = &menu } }, + .{ .mods = mod | Mods.shift, .keysym = xkb.Keysym.c, .action = .close }, + .{ .mods = mod | Mods.shift, .keysym = xkb.Keysym.q, .action = .quit }, + .{ .mods = mod | Mods.ctrl | Mods.shift, .keysym = xkb.Keysym.q, .action = .exit_session }, + + // Focus and arrangement. + .{ .mods = mod, .keysym = xkb.Keysym.j, .action = .{ .focus = .next } }, + .{ .mods = mod, .keysym = xkb.Keysym.k, .action = .{ .focus = .prev } }, + .{ .mods = mod | Mods.shift, .keysym = xkb.Keysym.j, .action = .{ .swap = .next } }, + .{ .mods = mod | Mods.shift, .keysym = xkb.Keysym.k, .action = .{ .swap = .prev } }, + .{ .mods = mod, .keysym = xkb.Keysym.Return, .action = .zoom }, + + // Master area. + .{ .mods = mod, .keysym = xkb.Keysym.h, .action = .{ .mfact = .{ .relative = -0.05 } } }, + .{ .mods = mod, .keysym = xkb.Keysym.l, .action = .{ .mfact = .{ .relative = 0.05 } } }, + .{ .mods = mod, .keysym = xkb.Keysym.i, .action = .{ .nmaster = .{ .relative = 1 } } }, + .{ .mods = mod, .keysym = xkb.Keysym.d, .action = .{ .nmaster = .{ .relative = -1 } } }, + + // Layouts. + .{ .mods = mod, .keysym = xkb.Keysym.t, .action = .{ .set_layout = .master } }, + .{ .mods = mod, .keysym = xkb.Keysym.m, .action = .{ .set_layout = .monocle } }, + .{ .mods = mod, .keysym = xkb.Keysym.u, .action = .{ .set_layout = .tabbed } }, + .{ .mods = mod, .keysym = xkb.Keysym.space, .action = .toggle_layout }, + .{ .mods = mod | Mods.shift, .keysym = xkb.Keysym.space, .action = .toggle_float }, + .{ .mods = mod, .keysym = xkb.Keysym.f, .action = .toggle_fullscreen }, + + // Tags. + .{ .mods = mod, .keysym = xkb.Keysym.@"0", .action = .{ .view = action.all_tags } }, + .{ .mods = mod | Mods.shift, .keysym = xkb.Keysym.@"0", .action = .{ .tag = action.all_tags } }, + .{ .mods = mod, .keysym = xkb.Keysym.Tab, .action = .view_prev }, + + // Outputs. + .{ .mods = mod, .keysym = xkb.Keysym.comma, .action = .{ .focus_output = .prev } }, + .{ .mods = mod, .keysym = xkb.Keysym.period, .action = .{ .focus_output = .next } }, + .{ .mods = mod | Mods.shift, .keysym = xkb.Keysym.comma, .action = .{ .send_to_output = .prev } }, + .{ .mods = mod | Mods.shift, .keysym = xkb.Keysym.period, .action = .{ .send_to_output = .next } }, +}; + +/// dwm's TAGKEYS macro: Mod+N views, Mod+Shift+N tags, Mod+Ctrl+N toggles the +/// view, Mod+Ctrl+Shift+N toggles the window's tag. +fn tagKeys() [action.tag_count * 4]Key { + // Evaluated at comptime, so the loop costs nothing at runtime. + @setEvalBranchQuota(10_000); + var out: [action.tag_count * 4]Key = undefined; + for (0..action.tag_count) |i| { + const mask: u32 = @as(u32, 1) << @intCast(i); + const sym: xkb.Keysym = @enumFromInt(@intFromEnum(xkb.Keysym.@"1") + i); + out[i * 4 + 0] = .{ .mods = mod, .keysym = sym, .action = .{ .view = mask } }; + out[i * 4 + 1] = .{ .mods = mod | Mods.shift, .keysym = sym, .action = .{ .tag = mask } }; + out[i * 4 + 2] = .{ .mods = mod | Mods.ctrl, .keysym = sym, .action = .{ .toggle_view = mask } }; + out[i * 4 + 3] = .{ + .mods = mod | Mods.ctrl | Mods.shift, + .keysym = sym, + .action = .{ .toggle_tag = mask }, + }; + } + return out; +} + +// ─── Pointer bindings ──────────────────────────────────────────────────────── + +pub const buttons = [_]Button{ + .{ .mods = mod, .button = btn.left, .action = .move }, + .{ .mods = mod, .button = btn.right, .action = .resize }, +}; diff --git a/src/ctl.zig b/src/ctl.zig new file mode 100644 index 0000000..8d55d17 --- /dev/null +++ b/src/ctl.zig @@ -0,0 +1,147 @@ +//! att_wmctl - drive a running att_wm over its IPC socket. + +const std = @import("std"); +const posix = std.posix; +const linux = std.os.linux; +const sys = @import("sys.zig"); + +const sock = @import("sock.zig"); + +const usage = + \\att_wmctl - control a running att_wm + \\ + \\Usage: att_wmctl [arguments] + \\ + \\Tags are a 1-based index (3), a mask (0x4 or mask:4), or "all". + \\Window ids are the "id" field of each window in the state JSON. + \\ + \\Commands: + \\ view Show only these tags + \\ toggle-view Add or remove tags from the view + \\ view-prev Return to the previously viewed tags + \\ tag Move the focused window to these tags + \\ toggle-tag Add or remove tags from the focused window + \\ + \\ focus next|prev Move focus through the visible windows + \\ focus-window Focus this window, viewing its tags if need be + \\ swap next|prev Move the focused window in the arrangement + \\ zoom Promote the focused window to master + \\ close Close the focused window + \\ close-window Close this window + \\ + \\ layout master|monocle|tabbed + \\ cycle-layout [next|prev] + \\ toggle-layout Switch to the previous layout + \\ nmaster <+1|-1|N> Windows in the master area + \\ mfact <+0.05|-0.05|F> Master area width fraction + \\ + \\ toggle-float Float or tile the focused window + \\ toggle-fullscreen Fullscreen the focused window + \\ + \\ focus-output next|prev + \\ send-to-output next|prev + \\ + \\ spawn [args...] Run a command + \\ quit Stop att_wm (river keeps running) + \\ exit-session End the Wayland session + \\ + \\ state Print the current state as JSON and exit + \\ subscribe Stream a JSON state line on every change + \\ +; + +pub fn main(init: std.process.Init) !u8 { + const gpa = init.gpa; + + const args = try init.minimal.args.toSlice(init.arena.allocator()); + + if (args.len < 2 or isHelp(args[1])) { + sys.writeAllBestEffort(1, usage); + return if (args.len < 2) 1 else 0; + } + + const path = try sock.path(gpa, init.minimal.environ); + defer gpa.free(path); + + const fd = sys.socket(linux.AF.UNIX, linux.SOCK.STREAM | linux.SOCK.CLOEXEC, 0) catch |err| { + std.log.err("failed to create socket: {s}", .{@errorName(err)}); + return 1; + }; + defer sys.close(fd); + + const addr = sys.sockaddrUn(path) catch { + std.log.err("socket path too long: {s}", .{path}); + return 1; + }; + sys.connect(fd, @ptrCast(&addr), sys.sockaddrUnLen(&addr)) catch |err| { + std.log.err( + "cannot reach att_wm at {s}: {s}\nIs att_wm running under this Wayland display?", + .{ path, @errorName(err) }, + ); + return 1; + }; + + // Reassemble argv into one newline-terminated line. + var line: std.ArrayList(u8) = .empty; + defer line.deinit(gpa); + for (args[1..], 0..) |arg, i| { + if (i > 0) try line.append(gpa, ' '); + try line.appendSlice(gpa, arg); + } + try line.append(gpa, '\n'); + + try writeAll(fd, line.items); + + const streaming = std.mem.eql(u8, args[1], "subscribe"); + return relay(fd, streaming); +} + +fn isHelp(arg: []const u8) bool { + return std.mem.eql(u8, arg, "-h") or + std.mem.eql(u8, arg, "--help") or + std.mem.eql(u8, arg, "help"); +} + +fn writeAll(fd: sys.fd_t, bytes: []const u8) !void { + var written: usize = 0; + while (written < bytes.len) { + written += try sys.write(fd, bytes[written..]); + } +} + +/// Copy the reply to stdout. For one-shot commands att_wm closes the connection +/// after replying, so this returns; `subscribe` runs until interrupted. +fn relay(fd: sys.fd_t, streaming: bool) !u8 { + var buf: [8192]u8 = undefined; + // Copied out rather than aliased: `buf` is overwritten by later reads. + var first: [3]u8 = undefined; + var first_len: usize = 0; + + while (true) { + const n = sys.read(fd, &buf) catch |err| switch (err) { + // att_wm closes the connection after replying to a one-shot + // command; a reset here just means it got in first. + error.ConnectionReset => break, + else => { + std.log.err("read failed: {s}", .{@errorName(err)}); + return 1; + }, + }; + if (n == 0) break; + + if (first_len == 0 and n > 0) { + first_len = @min(n, first.len); + @memcpy(first[0..first_len], buf[0..first_len]); + } + + // "ok" is the success acknowledgement for a command; printing it would + // be noise, so swallow it and let the exit status speak. + if (!streaming and std.mem.startsWith(u8, buf[0..n], "ok\n")) { + if (n == 3) return 0; + } + try writeAll(1, buf[0..n]); + } + + if (std.mem.eql(u8, first[0..first_len], "err")) return 1; + return 0; +} diff --git a/src/input.zig b/src/input.zig new file mode 100644 index 0000000..c4f8adb --- /dev/null +++ b/src/input.zig @@ -0,0 +1,281 @@ +//! Input device configuration, as declared in config.zig. +//! +//! Like action.zig this module depends on nothing but the standard library, so +//! that config.zig can import it without a cycle back into the window manager. +//! `src/InputManager.zig` is what puts these values onto river's protocol +//! objects. +//! +//! Every device setting is optional, and null means "leave it alone" — libinput +//! picks per-device defaults that are usually right, so a rule should say only +//! what it wants changed. + +const std = @import("std"); + +/// The kind of device, mirroring `river_input_device_v1.type`. +/// +/// Note that a touchpad reports `pointer`, not `touch`: libinput models it as a +/// pointer that happens to support tapping. `touch` is a touchscreen. +pub const Type = enum { keyboard, pointer, touch, tablet }; + +/// xkb rule names — the RMLVO that `setxkbmap` and every other Wayland +/// compositor take. att_wm compiles these into a keymap and hands it to every +/// keyboard river reports. +/// +/// A null field is left to xkbcommon, which reads the `XKB_DEFAULT_*` +/// environment variables and otherwise falls back to a plain `us` layout. So +/// the default of all-null is exactly what you get without this protocol at all. +pub const Keymap = struct { + /// Rules file, e.g. "evdev". Rarely worth setting. + rules: ?[]const u8 = null, + model: ?[]const u8 = null, + /// One layout, or several separated by commas: "us,se". + layout: ?[]const u8 = null, + /// Variants, positionally matching `layout`: "dvorak," is dvorak for the + /// first layout and the default variant for the second. + variant: ?[]const u8 = null, + /// Comma separated, e.g. "grp:alt_shift_toggle,caps:escape". With more than + /// one layout configured, a `grp:` option is how you switch between them — + /// xkb does the switching itself, so att_wm needs no binding for it. + options: ?[]const u8 = null, + + /// True when nothing is set, in which case there is no point compiling a + /// keymap: river's own default is already what we would produce. + pub fn isDefault(self: Keymap) bool { + inline for (std.meta.fields(Keymap)) |field| { + if (@field(self, field.name) != null) return false; + } + return true; + } +}; + +/// Key repeat as applied by the compositor to the focused client. +/// +/// This is not the same thing as `config.binding_repeat_delay` and +/// `config.binding_repeat_interval`, which govern how fast att_wm re-runs a held-down +/// *binding*: river reports binding press and release and leaves repeating to +/// us. These two are what every other application sees. +/// The defaults are river's own, so a config that says nothing about repeat +/// leaves keyboards exactly as they would have been. +pub const Repeat = struct { + /// Repeats per second. Zero disables key repeat entirely. + rate: i32 = 40, + /// Milliseconds a key must be held before repeating starts. + delay: i32 = 400, +}; + +pub const ButtonMap = enum { + /// One finger left, two right, three middle. libinput's default. + lrm, + /// One finger left, two middle, three right. + lmr, +}; + +pub const DragLock = enum { + disabled, + /// Lifting the finger keeps the drag alive for a short timeout. + timeout, + /// Lifting the finger keeps the drag alive until the next tap. + sticky, +}; + +pub const ThreeFingerDrag = enum { disabled, three_finger, four_finger }; + +pub const ClickMethod = enum { + none, + /// Bottom of the touchpad split into left/middle/right zones. + button_areas, + /// Number of fingers on the pad decides the button. + clickfinger, +}; + +pub const AccelProfile = enum { + /// No acceleration: movement maps to pointer travel one to one. + none, + /// Constant factor, no acceleration. + flat, + /// Speed-dependent acceleration. libinput's default for most devices. + adaptive, +}; + +pub const ScrollMethod = enum { + none, + two_finger, + edge, + /// Moving the device while `scroll_button` is held scrolls. + on_button_down, +}; + +pub const SendEvents = enum { + enabled, + disabled, + /// Useful for a laptop touchpad that should go quiet when a mouse is + /// plugged in. + disabled_on_external_mouse, +}; + +/// Matched against the name and type of every input device river reports. +/// +/// att_wm logs one line per device at startup — name and type — which is where +/// the names come from; there is no `list-inputs` to run because the protocol +/// only shows devices to the window manager itself. +pub const Rule = struct { + /// Device name to match. `*` matches any run of characters, so + /// `"*Touchpad*"` catches the usual "ELAN0501:00 04F3:3060 Touchpad" + /// without you having to write it out. Null matches every device. + name: ?[]const u8 = null, + /// Restrict the rule to one kind of device. Null matches every kind. + type: ?Type = null, + + // ─── Keyboards ─── + + /// Per-device override of `config.repeat`. + repeat: ?Repeat = null, + + // ─── Pointers, touchpads, touchscreens ─── + + /// Confine a touchscreen or tablet to one output, named as river names it — + /// "eDP-1", the same name the IPC `outputs` list uses. + /// + /// Two reasons to want this. On multiple monitors an unmapped touchscreen + /// spans the whole output layout, so touching the left of the panel lands on + /// the wrong screen. And it is what makes touch survive **display + /// rotation**: a mapped device has the output's transform applied to its + /// coordinates on every event, so rotating with `wlr-randr` or rot8 rotates + /// touch along with it, with no rotation hook and no calibration matrix. + /// + /// Do not combine with an external calibration matrix for rotation — the two + /// transforms compose, and the result is rotated twice. + /// + /// Ignored for keyboards, which have no coordinates to map. + map_to_output: ?[]const u8 = null, + + /// Multiplier on scroll distance: 0.5 scrolls half as far, 3.0 three times + /// as far. Applied by river rather than libinput, so it works on any + /// pointer. + scroll_factor: ?f64 = null, + + /// Tap to click. + tap: ?bool = null, + /// Which button each finger count taps. + tap_button_map: ?ButtonMap = null, + /// Tap and then drag without a second tap. + drag: ?bool = null, + /// Whether lifting the finger mid-drag ends it. + drag_lock: ?DragLock = null, + /// Hold three (or four) fingers to drag. + three_finger_drag: ?ThreeFingerDrag = null, + + /// What a physical click on a touchpad means. + click_method: ?ClickMethod = null, + /// Which button each finger count clicks, under `.clickfinger`. + clickfinger_button_map: ?ButtonMap = null, + /// Left and right buttons together act as middle click. + middle_emulation: ?bool = null, + /// Swap left and right buttons. + left_handed: ?bool = null, + + /// Content follows the fingers rather than the viewport, as on a phone. + natural_scroll: ?bool = null, + scroll_method: ?ScrollMethod = null, + /// Linux input event code — `input.btn.middle` and friends. Only meaningful + /// with `scroll_method = .on_button_down`. + scroll_button: ?u32 = null, + /// Whether the scroll button must be held, or toggles. + scroll_button_lock: ?bool = null, + + accel_profile: ?AccelProfile = null, + /// Pointer speed in [-1, 1]; 0 is the device's default. + accel_speed: ?f64 = null, + + /// Ignore the touchpad while the keyboard is being typed on. + disable_while_typing: ?bool = null, + /// Ignore the touchpad while the trackpoint is in use. + disable_while_trackpointing: ?bool = null, + + /// Clockwise rotation in degrees, for a device mounted sideways. + rotation: ?u32 = null, + + /// Whether the device sends events at all. + send_events: ?SendEvents = null, + + /// Fields that select which devices a rule applies to rather than + /// configuring them, and so are not merged by `merge`. + const selectors = .{ "name", "type" }; + + /// Fold `other` on top of `self`: every setting `other` states wins, every + /// setting it leaves null keeps the value it had. + /// + /// Rules are applied in the order they are declared, so a broad rule can set + /// a house style and a later, narrower one can dissent from it — the same + /// last-one-wins that dwm's window rules have. + pub fn merge(self: Rule, other: Rule) Rule { + var out = self; + inline for (std.meta.fields(Rule)) |field| { + comptime var is_selector = false; + inline for (selectors) |name| { + if (comptime std.mem.eql(u8, field.name, name)) is_selector = true; + } + if (!is_selector) { + if (@field(other, field.name)) |v| @field(out, field.name) = v; + } + } + return out; + } + + /// True if this rule should apply to a device with the given name and type. + pub fn matchesDevice(self: Rule, device_name: []const u8, device_type: Type) bool { + if (self.type) |t| { + if (t != device_type) return false; + } + if (self.name) |pattern| { + if (!matches(pattern, device_name)) return false; + } + return true; + } +}; + +/// Glob match supporting `*` as "any run of characters, including none". +/// +/// Deliberately no `?` or character classes: device names are long, noisy and +/// full of punctuation, and `*` on either end is all anyone needs to pin one +/// down. Iterative with a backtrack point rather than recursive, so a pattern +/// like `"*a*a*a*"` cannot blow the stack. +pub fn matches(pattern: []const u8, name: []const u8) bool { + var p: usize = 0; + var n: usize = 0; + // Where to resume if the run we are in turns out not to match: the `*` that + // let us in, and how far it had consumed. + var star: ?usize = null; + var star_n: usize = 0; + + while (n < name.len) { + if (p < pattern.len and pattern[p] == '*') { + star = p; + p += 1; + star_n = n; + } else if (p < pattern.len and pattern[p] == name[n]) { + p += 1; + n += 1; + } else if (star) |s| { + // Let the last `*` swallow one more byte and try again. + p = s + 1; + star_n += 1; + n = star_n; + } else { + return false; + } + } + + // Trailing `*`s can still match the empty remainder. + while (p < pattern.len and pattern[p] == '*') p += 1; + return p == pattern.len; +} + +/// Linux input event codes for the buttons worth binding to scrolling. The same +/// values `action.btn` has; duplicated rather than imported so this module keeps +/// its single dependency on the standard library. +pub const btn = struct { + pub const left: u32 = 0x110; + pub const right: u32 = 0x111; + pub const middle: u32 = 0x112; +}; diff --git a/src/ipc.zig b/src/ipc.zig new file mode 100644 index 0000000..b67745f --- /dev/null +++ b/src/ipc.zig @@ -0,0 +1,398 @@ +//! JSON-lines IPC over a unix socket. +//! +//! Two things talk to this: bars (quickshell) which send `subscribe` and then +//! read a state object every time anything changes, and `att_wmctl` which sends +//! one command and reads one reply. Both directions are newline delimited so a +//! quickshell `SplitParser` can consume the stream directly. + +const std = @import("std"); +const posix = std.posix; +const Allocator = std.mem.Allocator; +const linux = std.os.linux; +const sys = @import("sys.zig"); + +const act = @import("action"); +const config = @import("config"); + +const Wm = @import("Wm.zig"); + +/// Generous, but a runaway subscriber must not be able to make the window +/// manager grow without bound. +const max_out_buffer = 1 << 20; +const max_in_buffer = 64 * 1024; + +const sock = @import("sock.zig"); + +const Client = struct { + fd: sys.fd_t, + /// Receives a state object on every change. + subscribed: bool = false, + in: std.ArrayList(u8) = .empty, + out: std.ArrayList(u8) = .empty, + /// Close once the output buffer has drained. + closing: bool = false, + + fn deinit(self: *Client, gpa: Allocator) void { + self.in.deinit(gpa); + self.out.deinit(gpa); + sys.close(self.fd); + } +}; + +pub const Ipc = struct { + gpa: Allocator, + path: []u8, + listener: sys.fd_t, + clients: std.ArrayList(*Client) = .empty, + + pub fn init(gpa: Allocator, environ: std.process.Environ) !Ipc { + const path = try sock.path(gpa, environ); + errdefer gpa.free(path); + + // A socket left behind by a crashed instance would block bind(); only + // remove it if nothing is listening, so we never kick out a running + // window manager. + if (isStale(path)) sys.unlink(path); + + const listener = try sys.socket( + linux.AF.UNIX, + linux.SOCK.STREAM | linux.SOCK.NONBLOCK | linux.SOCK.CLOEXEC, + 0, + ); + errdefer sys.close(listener); + + const addr = try sys.sockaddrUn(path); + try sys.bind(listener, @ptrCast(&addr), sys.sockaddrUnLen(&addr)); + try sys.listen(listener, 16); + + std.log.info("ipc socket: {s}", .{path}); + + return .{ .gpa = gpa, .path = path, .listener = listener }; + } + + /// True if a socket file is left over from a crashed instance. Connecting + /// is the only reliable test: a refused connection means nobody is + /// listening, whereas a missing file is not stale at all and a successful + /// connection means another att_wm owns it. + fn isStale(path: []const u8) bool { + const probe = sys.socket(linux.AF.UNIX, linux.SOCK.STREAM | linux.SOCK.CLOEXEC, 0) catch return false; + defer sys.close(probe); + const addr = sys.sockaddrUn(path) catch return false; + sys.connect(probe, @ptrCast(&addr), sys.sockaddrUnLen(&addr)) catch |err| { + return err == error.ConnectionRefused; + }; + return false; + } + + pub fn deinit(self: *Ipc) void { + for (self.clients.items) |client| { + client.deinit(self.gpa); + self.gpa.destroy(client); + } + self.clients.deinit(self.gpa); + sys.close(self.listener); + sys.unlink(self.path); + self.gpa.free(self.path); + } + + /// Append the listener and every client fd, in that order. `handle` expects + /// the same slice back. + pub fn pollFds(self: *Ipc, fds: *std.ArrayList(posix.pollfd), gpa: Allocator) !void { + try fds.append(gpa, .{ .fd = self.listener, .events = posix.POLL.IN, .revents = 0 }); + for (self.clients.items) |client| { + var events: i16 = posix.POLL.IN; + if (client.out.items.len > 0) events |= posix.POLL.OUT; + try fds.append(gpa, .{ .fd = client.fd, .events = events, .revents = 0 }); + } + } + + pub fn handle(self: *Ipc, wm: *Wm, fds: []posix.pollfd) !void { + if (fds.len == 0) return; + + if (fds[0].revents & posix.POLL.IN != 0) self.accept(); + + // Walk the poll results and look each client up by fd rather than by + // position. Dropping a client shifts the list, so index-based pairing + // would hand the next client the departed one's revents — and a HUP + // from a finished att_wmctl would then disconnect a subscribed bar. + for (fds[1..]) |pfd| { + const idx = self.indexOfFd(pfd.fd) orelse continue; + const client = self.clients.items[idx]; + var drop = false; + + if (pfd.revents & (posix.POLL.HUP | posix.POLL.ERR | posix.POLL.NVAL) != 0) { + drop = true; + } else { + if (pfd.revents & posix.POLL.IN != 0) drop = !self.read(wm, client); + if (!drop and pfd.revents & posix.POLL.OUT != 0) drop = !self.write(client); + } + + if (!drop and client.closing and client.out.items.len == 0) drop = true; + + if (drop) { + _ = self.clients.orderedRemove(idx); + client.deinit(self.gpa); + self.gpa.destroy(client); + } + } + } + + fn indexOfFd(self: *Ipc, fd: sys.fd_t) ?usize { + for (self.clients.items, 0..) |client, i| { + if (client.fd == fd) return i; + } + return null; + } + + fn accept(self: *Ipc) void { + while (true) { + const fd = sys.accept4( + self.listener, + linux.SOCK.NONBLOCK | linux.SOCK.CLOEXEC, + ) catch return; + + const client = self.gpa.create(Client) catch { + sys.close(fd); + return; + }; + client.* = .{ .fd = fd }; + self.clients.append(self.gpa, client) catch { + client.deinit(self.gpa); + self.gpa.destroy(client); + return; + }; + } + } + + /// Returns false if the client should be dropped. + fn read(self: *Ipc, wm: *Wm, client: *Client) bool { + var buf: [4096]u8 = undefined; + while (true) { + const n = sys.read(client.fd, &buf) catch |err| switch (err) { + error.Again => break, + else => return false, + }; + if (n == 0) return false; + if (client.in.items.len + n > max_in_buffer) return false; + client.in.appendSlice(self.gpa, buf[0..n]) catch return false; + } + + while (std.mem.indexOfScalar(u8, client.in.items, '\n')) |idx| { + const line = client.in.items[0..idx]; + self.command(wm, client, line); + // Drop the consumed line, including its newline. + const rest = client.in.items[idx + 1 ..]; + std.mem.copyForwards(u8, client.in.items, rest); + client.in.shrinkRetainingCapacity(rest.len); + } + return true; + } + + /// Returns false if the client should be dropped. + fn write(self: *Ipc, client: *Client) bool { + while (client.out.items.len > 0) { + const n = sys.write(client.fd, client.out.items) catch |err| switch (err) { + error.Again => return true, + else => return false, + }; + const rest = client.out.items[n..]; + std.mem.copyForwards(u8, client.out.items, rest); + client.out.shrinkRetainingCapacity(rest.len); + } + _ = self; + return true; + } + + fn send(self: *Ipc, client: *Client, bytes: []const u8) void { + if (client.out.items.len + bytes.len > max_out_buffer) { + // The peer is not reading. Dropping it beats unbounded growth. + client.closing = true; + client.out.clearRetainingCapacity(); + return; + } + client.out.appendSlice(self.gpa, bytes) catch { + client.closing = true; + }; + } + + fn command(self: *Ipc, wm: *Wm, client: *Client, line_raw: []const u8) void { + const line = std.mem.trim(u8, line_raw, " \t\r"); + if (line.len == 0) return; + + var argv: std.ArrayList([]const u8) = .empty; + defer argv.deinit(self.gpa); + var it = std.mem.tokenizeAny(u8, line, " \t"); + while (it.next()) |tok| argv.append(self.gpa, tok) catch return; + if (argv.items.len == 0) return; + + const cmd = argv.items[0]; + + if (std.mem.eql(u8, cmd, "subscribe")) { + client.subscribed = true; + self.sendState(wm, client); + return; + } + if (std.mem.eql(u8, cmd, "state")) { + self.sendState(wm, client); + // A subscriber asking for state is refreshing, not saying goodbye. + if (!client.subscribed) client.closing = true; + return; + } + + const action = act.parse(argv.items) catch |err| { + var buf: [128]u8 = undefined; + const msg = std.fmt.bufPrint(&buf, "err {s}\n", .{@errorName(err)}) catch "err\n"; + self.send(client, msg); + if (!client.subscribed) client.closing = true; + return; + }; + + wm.performIpc(action); + self.send(client, "ok\n"); + // One-shot clients (att_wmctl) are done; subscribers stay connected so a + // bar can drive the window manager over the same socket it listens on. + if (!client.subscribed) client.closing = true; + } + + pub fn broadcast(self: *Ipc, wm: *Wm) !void { + if (self.clients.items.len == 0) return; + + var json: std.ArrayList(u8) = .empty; + defer json.deinit(self.gpa); + try encodeState(wm, self.gpa, &json); + + for (self.clients.items) |client| { + if (!client.subscribed or client.closing) continue; + self.send(client, json.items); + } + + // Push it out now rather than waiting for the next poll, so bars update + // in the same frame the change happens. + for (self.clients.items) |client| { + _ = self.write(client); + } + } + + fn sendState(self: *Ipc, wm: *Wm, client: *Client) void { + var json: std.ArrayList(u8) = .empty; + defer json.deinit(self.gpa); + encodeState(wm, self.gpa, &json) catch return; + self.send(client, json.items); + } +}; + +/// Serialise the whole window manager state as one JSON object followed by a +/// newline. Sending everything on every change keeps bars stateless, and the +/// payload is small enough that diffing would not pay for itself. +fn encodeState(wm: *Wm, gpa: Allocator, out: *std.ArrayList(u8)) !void { + var allocating = std.Io.Writer.Allocating.fromArrayList(gpa, out); + defer out.* = allocating.toArrayList(); + const w = &allocating.writer; + + try w.writeAll("{\"tag_count\":"); + try w.print("{d}", .{act.tag_count}); + + try w.writeAll(",\"tag_names\":["); + for (config.tag_names, 0..) |name, i| { + if (i > 0) try w.writeAll(","); + try writeJsonString(w, name); + } + try w.writeAll("]"); + + try w.print(",\"locked\":{s}", .{if (wm.locked) "true" else "false"}); + + try w.writeAll(",\"outputs\":["); + for (wm.outputs.items, 0..) |output, oi| { + if (oi > 0) try w.writeAll(","); + + // A tag is "occupied" if any window carries it, and "urgent" is not + // modelled: river-window-management-v1 has no attention-request event. + var occupied: u32 = 0; + for (wm.windows.items) |win| { + if (win.output == output and !win.closed) occupied |= win.tags; + } + + // The layout and its knobs belong to the tag being viewed, so what is + // published is whatever is in force right now. + const st = output.state(); + + try w.writeAll("{\"name\":"); + try writeJsonString(w, output.displayName()); + try w.print( + ",\"focused\":{s},\"tags\":{d},\"occupied\":{d},\"layout\":\"{s}\",\"layout_symbol\":", + .{ + if (wm.focused_output == output) "true" else "false", + output.tags, + occupied & act.all_tags, + @tagName(st.layout), + }, + ); + try writeJsonString(w, st.layout.symbol()); + try w.print(",\"nmaster\":{d},\"mfact\":{d:.3}", .{ st.nmaster, st.mfact }); + try w.print( + ",\"x\":{d},\"y\":{d},\"width\":{d},\"height\":{d}", + .{ output.box.x, output.box.y, output.box.width, output.box.height }, + ); + // The area left after layer-shell exclusive zones, i.e. where windows + // actually get laid out. + const usable = output.layoutArea(); + try w.print( + ",\"usable\":{{\"x\":{d},\"y\":{d},\"width\":{d},\"height\":{d}}}", + .{ usable.x, usable.y, usable.width, usable.height }, + ); + + try w.writeAll(",\"windows\":["); + var first = true; + for (wm.windows.items) |win| { + if (win.output != output or win.closed) continue; + if (!first) try w.writeAll(","); + first = false; + + try w.writeAll("{\"id\":"); + try writeJsonString(w, win.identifier orelse ""); + try w.writeAll(",\"title\":"); + try writeJsonString(w, win.title orelse ""); + try w.writeAll(",\"app_id\":"); + try writeJsonString(w, win.app_id orelse ""); + try w.print( + ",\"tags\":{d},\"focused\":{s},\"visible\":{s},\"floating\":{s},\"fullscreen\":{s}", + .{ + win.tags, + if (isFocused(wm, win)) "true" else "false", + if (win.visible) "true" else "false", + if (win.floating) "true" else "false", + if (win.fullscreen) "true" else "false", + }, + ); + try w.writeAll("}"); + } + try w.writeAll("]}"); + } + try w.writeAll("]}\n"); +} + +fn isFocused(wm: *Wm, win: anytype) bool { + for (wm.seats.items) |seat| { + if (seat.focused == win) return true; + } + return false; +} + +fn writeJsonString(w: *std.Io.Writer, s: []const u8) !void { + try w.writeAll("\""); + for (s) |c| switch (c) { + '"' => try w.writeAll("\\\""), + '\\' => try w.writeAll("\\\\"), + '\n' => try w.writeAll("\\n"), + '\r' => try w.writeAll("\\r"), + '\t' => try w.writeAll("\\t"), + else => { + if (c < 0x20) { + try w.print("\\u{x:0>4}", .{c}); + } else { + try w.writeByte(c); + } + }, + }; + try w.writeAll("\""); +} diff --git a/src/layout.zig b/src/layout.zig new file mode 100644 index 0000000..f06b535 --- /dev/null +++ b/src/layout.zig @@ -0,0 +1,179 @@ +//! Pure layout geometry. +//! +//! Nothing here touches Wayland or window manager state: `arrange` is given an +//! area and a window count and fills in a slice of cells. That keeps the +//! tiling maths unit-testable without a compositor, which matters because the +//! master/stack remainder handling is fiddly and easy to get subtly wrong. + +const std = @import("std"); +const math = std.math; + +const action = @import("action"); + +pub const Layout = action.Layout; + +pub const Box = struct { + x: i32 = 0, + y: i32 = 0, + width: i32 = 0, + height: i32 = 0, + + pub fn contains(self: Box, x: i32, y: i32) bool { + return x >= self.x and x < self.x + self.width and + y >= self.y and y < self.y + self.height; + } + + /// Shrink by `amount` on every side, never going below zero size. + pub fn inset(self: Box, amount: i32) Box { + return .{ + .x = self.x + amount, + .y = self.y + amount, + .width = @max(0, self.width - 2 * amount), + .height = @max(0, self.height - 2 * amount), + }; + } +}; + +pub const Params = struct { + /// The area available for tiling: the output minus any layer-shell + /// exclusive zones. + area: Box, + nmaster: u32, + mfact: f32, + /// Gap between adjacent windows. + gap: i32 = 0, + /// Gap between the windows and the edge of the usable area. + outer_gap: i32 = 0, + /// Height of the tab bar strip in the tabbed layout. + tabbar_height: i32 = 0, +}; + +pub const Result = struct { + /// Where the tab bar goes, if this layout has one. + tabbar: ?Box = null, + /// True when the layout stacks all windows in the same place, so only the + /// topmost one is worth showing. + stacked: bool = false, +}; + +/// Whether a layout puts every window in the same place, so only the top one +/// is rendered. `arrange` reports the same thing after the fact; this answers +/// it for callers that need to know before the geometry is computed. +pub fn stacks(layout: Layout) bool { + return switch (layout) { + .master => false, + .monocle, .tabbed => true, + }; +} + +/// Fill `cells` with one rectangle per window, in arrangement order. +/// +/// Each cell is the *outer* rectangle including space for the border; the +/// caller insets by the border width to get the content geometry to propose. +pub fn arrange(layout: Layout, p: Params, cells: []Box) Result { + if (cells.len == 0) return .{}; + + const area = p.area.inset(p.outer_gap); + + return switch (layout) { + .master => tile(p, area, cells), + .monocle => stack(p, area, cells, null), + .tabbed => blk: { + // Reserve the strip at the top for the tab bar. If the area is too + // short to give the windows anything, drop the bar rather than + // producing zero-height windows. + if (area.height <= p.tabbar_height * 2) break :blk stack(p, area, cells, null); + const bar: Box = .{ + .x = area.x, + .y = area.y, + .width = area.width, + .height = p.tabbar_height, + }; + const rest: Box = .{ + .x = area.x, + .y = area.y + p.tabbar_height, + .width = area.width, + .height = area.height - p.tabbar_height, + }; + break :blk stack(p, rest, cells, bar); + }, + }; +} + +/// dwm's tile(): `nmaster` windows share a column of width `mfact`, the rest +/// share the remainder. Height is divided by "remaining space / remaining +/// windows" so leftover pixels are absorbed rather than accumulating a gap at +/// the bottom. +fn tile(p: Params, area: Box, cells: []Box) Result { + const n: u32 = @intCast(cells.len); + const half_gap = @divTrunc(p.gap, 2); + + const nmaster = @min(p.nmaster, n); + + const mw: i32 = if (n > nmaster) + (if (nmaster > 0) @as(i32, @intFromFloat(@as(f32, @floatFromInt(area.width)) * p.mfact)) else 0) + else + area.width; + + var my: i32 = 0; + var ty: i32 = 0; + + for (cells, 0..) |*cell, i| { + const idx: u32 = @intCast(i); + if (idx < nmaster) { + const remaining = nmaster - idx; + const h = @divTrunc(area.height - my, @as(i32, @intCast(remaining))); + cell.* = .{ + .x = area.x, + .y = area.y + my, + .width = mw, + .height = h, + }; + my += h; + } else { + const remaining = n - idx; + const h = @divTrunc(area.height - ty, @as(i32, @intCast(remaining))); + cell.* = .{ + .x = area.x + mw, + .y = area.y + ty, + .width = area.width - mw, + .height = h, + }; + ty += h; + } + if (half_gap > 0) cell.* = cell.inset(half_gap); + } + + return .{}; +} + +/// Every window fills the whole area; only the top one is worth rendering. +fn stack(p: Params, area: Box, cells: []Box, bar: ?Box) Result { + const half_gap = @divTrunc(p.gap, 2); + for (cells) |*cell| { + cell.* = if (half_gap > 0) area.inset(half_gap) else area; + } + return .{ .tabbar = bar, .stacked = true }; +} + +/// Split a tab bar into one rectangle per tab, absorbing the remainder into +/// the leftmost tabs so the strip is exactly filled. +pub fn tabRects(bar: Box, count: usize, out: []Box) void { + std.debug.assert(out.len >= count); + if (count == 0) return; + + const n: i32 = @intCast(count); + const base = @divTrunc(bar.width, n); + var extra = @mod(bar.width, n); + + var x = bar.x; + for (out[0..count]) |*rect| { + var w = base; + if (extra > 0) { + w += 1; + extra -= 1; + } + rect.* = .{ .x = x, .y = bar.y, .width = w, .height = bar.height }; + x += w; + } +} diff --git a/src/main.zig b/src/main.zig new file mode 100644 index 0000000..94906c4 --- /dev/null +++ b/src/main.zig @@ -0,0 +1,73 @@ +const std = @import("std"); +const posix = std.posix; +const sys = @import("sys.zig"); + +const Wm = @import("Wm.zig"); + +pub const std_options: std.Options = .{ + .log_level = if (@import("builtin").mode == .Debug) .debug else .info, +}; + +const version = "0.1.0"; + +const usage = + \\att_wm - a dwm-like window manager for the river Wayland compositor + \\ + \\Usage: att_wm [options] + \\ + \\att_wm is a river-window-management-v1 client and must be started by river + \\0.4 or newer: + \\ + \\ river -c att_wm + \\ + \\Options: + \\ -h, --help Show this help + \\ -v, --version Show the version + \\ +; + +pub fn main(init: std.process.Init) !u8 { + const gpa = init.gpa; + + var args = init.minimal.args.iterate(); + _ = args.next(); + while (args.next()) |arg| { + if (std.mem.eql(u8, arg, "-h") or std.mem.eql(u8, arg, "--help")) { + sys.writeAllBestEffort(1, usage); + return 0; + } + if (std.mem.eql(u8, arg, "-v") or std.mem.eql(u8, arg, "--version")) { + sys.writeAllBestEffort(1, version ++ "\n"); + return 0; + } + std.log.err("unknown argument: {s}", .{arg}); + sys.writeAllBestEffort(2, usage); + return 1; + } + + // Spawned children are double-forked and reparented to init, so we never + // wait on them. Ignoring SIGPIPE keeps a bar disconnecting mid-write from + // taking the window manager down with it. + const ignore: posix.Sigaction = .{ + .handler = .{ .handler = posix.SIG.IGN }, + .mask = posix.sigemptyset(), + .flags = 0, + }; + posix.sigaction(posix.SIG.PIPE, &ignore, null); + + const wm = Wm.init(gpa, init.minimal.environ) catch |err| switch (err) { + error.NoWindowManagerGlobal => return 1, + else => { + std.log.err("failed to start: {s}", .{@errorName(err)}); + return 1; + }, + }; + defer wm.deinit(); + + wm.run() catch |err| { + std.log.err("event loop failed: {s}", .{@errorName(err)}); + return 1; + }; + + return 0; +} diff --git a/src/shm.zig b/src/shm.zig new file mode 100644 index 0000000..28cfd2e --- /dev/null +++ b/src/shm.zig @@ -0,0 +1,149 @@ +//! Minimal wl_shm buffer pool for the tab bar. +//! +//! The tab bar is the only thing att_wm draws itself, and it draws nothing but +//! solid rectangles, so this deliberately stops at "memfd, mmap, fill" rather +//! than pulling in pixman or a font stack. + +const std = @import("std"); +const posix = std.posix; +const Allocator = std.mem.Allocator; +const sys = @import("sys.zig"); + +const wayland = @import("wayland"); +const wl = wayland.client.wl; + +const layout = @import("layout.zig"); +const Box = layout.Box; + +/// Two buffers is enough: we redraw at most once per render sequence and the +/// compositor releases the previous one promptly. +const buffer_count = 2; + +pub const Buffer = struct { + wl_buffer: *wl.Buffer, + data: []align(std.heap.page_size_min) u8, + width: i32, + height: i32, + /// Held by the compositor; must not be drawn into until released. + busy: bool = false, + + fn onRelease(_: *wl.Buffer, event: wl.Buffer.Event, self: *Buffer) void { + switch (event) { + .release => self.busy = false, + } + } + + pub fn pixels(self: *Buffer) []u32 { + const count: usize = @intCast(self.width * self.height); + const ptr: [*]u32 = @ptrCast(@alignCast(self.data.ptr)); + return ptr[0..count]; + } + + /// Fill a rectangle, in buffer-local coordinates, clipped to the buffer. + pub fn fill(self: *Buffer, rect: Box, argb: u32) void { + const x0 = @max(0, rect.x); + const y0 = @max(0, rect.y); + const x1 = @min(self.width, rect.x + rect.width); + const y1 = @min(self.height, rect.y + rect.height); + if (x1 <= x0 or y1 <= y0) return; + + const px = self.pixels(); + const stride: usize = @intCast(self.width); + var y: i32 = y0; + while (y < y1) : (y += 1) { + const row_start = @as(usize, @intCast(y)) * stride; + const from = row_start + @as(usize, @intCast(x0)); + const to = row_start + @as(usize, @intCast(x1)); + @memset(px[from..to], argb); + } + } + + fn deinit(self: *Buffer, gpa: Allocator) void { + self.wl_buffer.destroy(); + posix.munmap(self.data); + gpa.destroy(self); + } +}; + +pub const Pool = struct { + gpa: Allocator, + shm: *wl.Shm, + buffers: [buffer_count]?*Buffer = @splat(null), + + pub fn init(gpa: Allocator, shm: *wl.Shm) Pool { + return .{ .gpa = gpa, .shm = shm }; + } + + pub fn deinit(self: *Pool) void { + for (&self.buffers) |*slot| { + if (slot.*) |buf| buf.deinit(self.gpa); + slot.* = null; + } + } + + /// Return a buffer of the requested size that the compositor is not + /// currently reading from, creating or resizing one as needed. + pub fn acquire(self: *Pool, width: i32, height: i32) !*Buffer { + if (width <= 0 or height <= 0) return error.InvalidSize; + + // Reuse an idle buffer that is already the right size. + for (self.buffers) |maybe| { + if (maybe) |buf| { + if (!buf.busy and buf.width == width and buf.height == height) return buf; + } + } + + // Otherwise take a free slot, evicting an idle wrong-sized buffer. + for (&self.buffers) |*slot| { + if (slot.* == null) { + slot.* = try self.create(width, height); + return slot.*.?; + } + } + for (&self.buffers) |*slot| { + const buf = slot.*.?; + if (!buf.busy) { + buf.deinit(self.gpa); + slot.* = try self.create(width, height); + return slot.*.?; + } + } + + return error.AllBuffersBusy; + } + + fn create(self: *Pool, width: i32, height: i32) !*Buffer { + const stride = width * 4; + const size: usize = @intCast(stride * height); + + const fd = try posix.memfd_create("att_wm-shm", std.os.linux.MFD.CLOEXEC); + defer sys.close(fd); + try sys.ftruncate(fd, size); + + const data = try posix.mmap( + null, + size, + .{ .READ = true, .WRITE = true }, + .{ .TYPE = .SHARED }, + fd, + 0, + ); + errdefer posix.munmap(data); + + const shm_pool = try self.shm.createPool(fd, @intCast(size)); + defer shm_pool.destroy(); + + const wl_buffer = try shm_pool.createBuffer(0, width, height, stride, .argb8888); + errdefer wl_buffer.destroy(); + + const buf = try self.gpa.create(Buffer); + buf.* = .{ + .wl_buffer = wl_buffer, + .data = data, + .width = width, + .height = height, + }; + wl_buffer.setListener(*Buffer, Buffer.onRelease, buf); + return buf; + } +}; diff --git a/src/sock.zig b/src/sock.zig new file mode 100644 index 0000000..dfe2d86 --- /dev/null +++ b/src/sock.zig @@ -0,0 +1,19 @@ +//! Where the IPC socket lives. Shared by the window manager and att_wmctl, so +//! it deliberately imports nothing else. + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const Environ = std.process.Environ; + +/// One socket per Wayland display, so nested or parallel river sessions do not +/// collide. `ATT_WM_SOCKET` overrides it outright. +pub fn path(gpa: Allocator, environ: Environ) ![]u8 { + if (environ.getPosix("ATT_WM_SOCKET")) |explicit| { + return gpa.dupe(u8, explicit); + } + const display = environ.getPosix("WAYLAND_DISPLAY") orelse "wayland-0"; + if (environ.getPosix("XDG_RUNTIME_DIR")) |dir| { + return std.fmt.allocPrint(gpa, "{s}/att_wm-{s}.sock", .{ dir, display }); + } + return std.fmt.allocPrint(gpa, "/tmp/att_wm-{d}-{s}.sock", .{ std.os.linux.getuid(), display }); +} diff --git a/src/sys.zig b/src/sys.zig new file mode 100644 index 0000000..4258843 --- /dev/null +++ b/src/sys.zig @@ -0,0 +1,204 @@ +//! Thin typed wrappers over the Linux syscalls att_wm needs. +//! +//! Zig 0.16 moved most of `std.posix` behind the new `std.Io` interface, which +//! is the wrong shape for a window manager: everything here is a raw fd driven +//! by a single `poll()` loop, with no allocator and no async runtime. Going +//! straight to `std.os.linux` is both simpler and closer to what the code +//! actually does. + +const std = @import("std"); +const linux = std.os.linux; + +/// Must be the linux decoder, not `std.posix.errno`: with libc linked the +/// latter expects a libc-style -1 return and reports every raw syscall error +/// as success, which then overflows the casts below. +const errno = linux.errno; + +pub const fd_t = linux.fd_t; +pub const pid_t = linux.pid_t; +pub const E = linux.E; + +pub const Error = error{ + Again, + Interrupted, + ConnectionReset, + AddressInUse, + NotFound, + PermissionDenied, + ConnectionRefused, + BrokenPipe, + NameTooLong, + OutOfMemory, + Unexpected, +}; + +fn check(rc: usize) Error!usize { + return switch (errno(rc)) { + .SUCCESS => rc, + .AGAIN => error.Again, + .INTR => error.Interrupted, + .ADDRINUSE => error.AddressInUse, + .NOENT => error.NotFound, + .ACCES, .PERM => error.PermissionDenied, + .CONNREFUSED => error.ConnectionRefused, + .CONNRESET => error.ConnectionReset, + .PIPE => error.BrokenPipe, + .NAMETOOLONG => error.NameTooLong, + .NOMEM => error.OutOfMemory, + else => error.Unexpected, + }; +} + +pub fn read(fd: fd_t, buf: []u8) Error!usize { + if (buf.len == 0) return 0; + while (true) { + return check(linux.read(fd, buf.ptr, buf.len)) catch |err| switch (err) { + error.Interrupted => continue, + else => err, + }; + } +} + +pub fn write(fd: fd_t, bytes: []const u8) Error!usize { + if (bytes.len == 0) return 0; + return check(linux.write(fd, bytes.ptr, bytes.len)); +} + +/// Write everything, retrying short writes. Best effort: errors are swallowed +/// because every caller is emitting diagnostics or usage text. +pub fn writeAllBestEffort(fd: fd_t, bytes: []const u8) void { + var off: usize = 0; + while (off < bytes.len) { + off += write(fd, bytes[off..]) catch return; + } +} + +pub fn close(fd: fd_t) void { + _ = linux.close(fd); +} + +pub fn socket(domain: u32, socket_type: u32, protocol: u32) Error!fd_t { + return @intCast(try check(linux.socket(domain, socket_type, protocol))); +} + +pub fn bind(fd: fd_t, addr: *const linux.sockaddr, len: linux.socklen_t) Error!void { + _ = try check(linux.bind(fd, addr, len)); +} + +pub fn listen(fd: fd_t, backlog: u31) Error!void { + _ = try check(linux.listen(fd, backlog)); +} + +pub fn accept4(fd: fd_t, flags: u32) Error!fd_t { + return @intCast(try check(linux.accept4(fd, null, null, flags))); +} + +pub fn connect(fd: fd_t, addr: *const linux.sockaddr, len: linux.socklen_t) Error!void { + _ = try check(linux.connect(fd, addr, len)); +} + +pub fn ftruncate(fd: fd_t, length: u64) Error!void { + _ = try check(linux.ftruncate(fd, @intCast(length))); +} + +/// Make a memfd immutable, so a compositor mapping it cannot have the bytes +/// changed underneath it. +pub fn addSeals(fd: fd_t, seals: usize) Error!void { + _ = try check(linux.fcntl(fd, linux.F.ADD_SEALS, seals)); +} + +pub fn timerfdCreate(flags: linux.TFD) Error!fd_t { + return @intCast(try check(linux.timerfd_create(.MONOTONIC, flags))); +} + +pub fn timerfdSetTime(fd: fd_t, spec: *const linux.itimerspec) Error!void { + _ = try check(linux.timerfd_settime(fd, .{}, spec, null)); +} + +pub fn fork() Error!pid_t { + return @intCast(try check(linux.fork())); +} + +pub fn setsid() void { + _ = linux.setsid(); +} + +pub fn exit(code: u8) noreturn { + linux.exit(code); +} + +pub fn waitpid(pid: pid_t) void { + var status: u32 = undefined; + while (true) { + const rc = linux.wait4(pid, &status, 0, null); + switch (errno(rc)) { + .INTR => continue, + else => return, + } + } +} + +/// Remove a path. Best effort: the only caller is clearing a stale socket. +pub fn unlink(path: []const u8) void { + var buf: [std.fs.max_path_bytes]u8 = undefined; + if (path.len >= buf.len) return; + @memcpy(buf[0..path.len], path); + buf[path.len] = 0; + _ = linux.unlink(@ptrCast(&buf)); +} + +/// Build a unix socket address. Paths must fit in sun_path with room for the +/// terminating NUL. +pub fn sockaddrUn(path: []const u8) Error!linux.sockaddr.un { + var addr: linux.sockaddr.un = .{ .family = linux.AF.UNIX, .path = undefined }; + if (path.len >= addr.path.len) return error.NameTooLong; + @memset(&addr.path, 0); + @memcpy(addr.path[0..path.len], path); + return addr; +} + +pub fn sockaddrUnLen(addr: *const linux.sockaddr.un) linux.socklen_t { + _ = addr; + return @sizeOf(linux.sockaddr.un); +} + +/// execvp: run `argv[0]`, searching PATH when it contains no slash. +/// +/// Only ever called between fork() and exec in the child, so it must not +/// allocate; the candidate path is assembled in a stack buffer. +pub fn execvpe( + argv: [*:null]const ?[*:0]const u8, + envp: [*:null]const ?[*:0]const u8, + path_env: ?[]const u8, +) Error { + const file = std.mem.span(argv[0].?); + + if (std.mem.indexOfScalar(u8, file, '/') != null) { + return execErr(linux.execve(argv[0].?, argv, envp)); + } + + const search = path_env orelse "/usr/local/bin:/usr/bin:/bin"; + var buf: [std.fs.max_path_bytes]u8 = undefined; + var last: Error = error.NotFound; + + var it = std.mem.tokenizeScalar(u8, search, ':'); + while (it.next()) |dir| { + if (dir.len + 1 + file.len + 1 > buf.len) continue; + @memcpy(buf[0..dir.len], dir); + buf[dir.len] = '/'; + @memcpy(buf[dir.len + 1 ..][0..file.len], file); + buf[dir.len + 1 + file.len] = 0; + const candidate: [*:0]const u8 = @ptrCast(&buf); + + last = execErr(linux.execve(candidate, argv, envp)); + // ENOENT just means "not in this directory"; keep looking. + if (last != error.NotFound) return last; + } + return last; +} + +/// execve only returns on failure, so its result is always an error. +fn execErr(rc: usize) Error { + _ = check(rc) catch |err| return err; + return error.Unexpected; +} diff --git a/src/test.zig b/src/test.zig new file mode 100644 index 0000000..aa8ce83 --- /dev/null +++ b/src/test.zig @@ -0,0 +1,425 @@ +const std = @import("std"); +const testing = std.testing; + +const act = @import("action"); +const input = @import("input"); +const layout = @import("layout.zig"); +const Box = layout.Box; + +comptime { + _ = act; + _ = input; + _ = @import("color.zig"); +} + +const area: Box = .{ .x = 0, .y = 0, .width = 1000, .height = 600 }; + +fn params(nmaster: u32, mfact: f32) layout.Params { + return .{ .area = area, .nmaster = nmaster, .mfact = mfact }; +} + +// ─── master/stack tiling ───────────────────────────────────────────────────── + +test "single window fills the whole area" { + var cells: [1]Box = undefined; + _ = layout.arrange(.master, params(1, 0.55), &cells); + try testing.expectEqual(area, cells[0]); +} + +test "two windows split at mfact" { + var cells: [2]Box = undefined; + _ = layout.arrange(.master, params(1, 0.55), &cells); + + try testing.expectEqual(@as(i32, 550), cells[0].width); + try testing.expectEqual(@as(i32, 600), cells[0].height); + + try testing.expectEqual(@as(i32, 550), cells[1].x); + try testing.expectEqual(@as(i32, 450), cells[1].width); + try testing.expectEqual(@as(i32, 600), cells[1].height); +} + +test "stack column divides height with no gap or overlap" { + // Three windows, one master: the stack column holds two. + var cells: [3]Box = undefined; + _ = layout.arrange(.master, params(1, 0.5), &cells); + + try testing.expectEqual(@as(i32, 600), cells[0].height); + try testing.expectEqual(cells[1].y + cells[1].height, cells[2].y); + try testing.expectEqual(area.height, cells[1].height + cells[2].height); +} + +test "odd heights are absorbed rather than leaving a gap at the bottom" { + // 600 / 7 does not divide evenly; the last window must still end exactly + // at the bottom edge. + var cells: [7]Box = undefined; + _ = layout.arrange(.master, params(0, 0.55), &cells); + + var y: i32 = area.y; + for (cells) |cell| { + try testing.expectEqual(y, cell.y); + y += cell.height; + } + try testing.expectEqual(area.y + area.height, y); +} + +test "nmaster zero puts every window in the stack column" { + var cells: [3]Box = undefined; + _ = layout.arrange(.master, params(0, 0.55), &cells); + for (cells) |cell| { + try testing.expectEqual(@as(i32, 0), cell.x); + try testing.expectEqual(area.width, cell.width); + } +} + +test "windows all fit in master when count does not exceed nmaster" { + var cells: [2]Box = undefined; + _ = layout.arrange(.master, params(3, 0.55), &cells); + // No stack column, so master spans the full width. + for (cells) |cell| { + try testing.expectEqual(area.width, cell.width); + } + try testing.expectEqual(area.height, cells[0].height + cells[1].height); +} + +test "master column also divides height when nmaster exceeds one" { + var cells: [4]Box = undefined; + _ = layout.arrange(.master, params(2, 0.5), &cells); + + try testing.expectEqual(@as(i32, 500), cells[0].width); + try testing.expectEqual(@as(i32, 500), cells[1].width); + try testing.expectEqual(area.height, cells[0].height + cells[1].height); + try testing.expectEqual(area.height, cells[2].height + cells[3].height); +} + +// ─── stacking layouts ──────────────────────────────────────────────────────── + +test "monocle gives every window the full area and reports stacked" { + var cells: [3]Box = undefined; + const result = layout.arrange(.monocle, params(1, 0.55), &cells); + + try testing.expect(result.stacked); + try testing.expect(result.tabbar == null); + for (cells) |cell| try testing.expectEqual(area, cell); +} + +test "stacks agrees with what arrange reports" { + var cells: [2]Box = undefined; + var p = params(1, 0.55); + p.tabbar_height = 22; + for ([_]layout.Layout{ .master, .monocle, .tabbed }) |mode| { + try testing.expectEqual(layout.arrange(mode, p, &cells).stacked, layout.stacks(mode)); + } +} + +test "tabbed reserves the bar strip above the windows" { + var cells: [3]Box = undefined; + var p = params(1, 0.55); + p.tabbar_height = 22; + const result = layout.arrange(.tabbed, p, &cells); + + try testing.expect(result.stacked); + const bar = result.tabbar.?; + try testing.expectEqual(@as(i32, 22), bar.height); + try testing.expectEqual(area.y, bar.y); + + // Windows start below the bar and the two together cover the area exactly. + for (cells) |cell| { + try testing.expectEqual(area.y + 22, cell.y); + try testing.expectEqual(area.height - 22, cell.height); + } +} + +test "tabbed drops the bar rather than crushing the windows" { + var cells: [2]Box = undefined; + var p: layout.Params = .{ + .area = .{ .x = 0, .y = 0, .width = 400, .height = 30 }, + .nmaster = 1, + .mfact = 0.55, + .tabbar_height = 22, + }; + const result = layout.arrange(.tabbed, p, &cells); + try testing.expect(result.tabbar == null); + try testing.expectEqual(@as(i32, 30), cells[0].height); + p.tabbar_height = 0; +} + +test "no windows is not a crash" { + var cells: [0]Box = undefined; + const result = layout.arrange(.master, params(1, 0.55), &cells); + try testing.expect(result.tabbar == null); +} + +// ─── gaps ──────────────────────────────────────────────────────────────────── + +test "outer gap insets the whole area" { + var cells: [1]Box = undefined; + var p = params(1, 0.55); + p.outer_gap = 10; + _ = layout.arrange(.master, p, &cells); + + try testing.expectEqual(@as(i32, 10), cells[0].x); + try testing.expectEqual(@as(i32, 10), cells[0].y); + try testing.expectEqual(@as(i32, 980), cells[0].width); + try testing.expectEqual(@as(i32, 580), cells[0].height); +} + +test "inner gap separates adjacent windows" { + var cells: [2]Box = undefined; + var p = params(1, 0.5); + p.gap = 10; + _ = layout.arrange(.master, p, &cells); + + // Each cell shrinks by half the gap per side, leaving a full gap between. + const right_of_master = cells[0].x + cells[0].width; + try testing.expect(cells[1].x - right_of_master == 10); +} + +// ─── tab rectangles ────────────────────────────────────────────────────────── + +test "tab rects tile the bar exactly with no rounding gap" { + const bar: Box = .{ .x = 5, .y = 0, .width = 101, .height = 22 }; + var rects: [4]Box = undefined; + layout.tabRects(bar, 4, &rects); + + try testing.expectEqual(bar.x, rects[0].x); + var total: i32 = 0; + for (rects, 0..) |rect, i| { + total += rect.width; + if (i > 0) { + try testing.expectEqual(rects[i - 1].x + rects[i - 1].width, rect.x); + } + } + try testing.expectEqual(bar.width, total); + try testing.expectEqual(bar.x + bar.width, rects[3].x + rects[3].width); +} + +test "single tab spans the bar" { + const bar: Box = .{ .x = 0, .y = 0, .width = 300, .height = 22 }; + var rects: [1]Box = undefined; + layout.tabRects(bar, 1, &rects); + try testing.expectEqual(@as(i32, 300), rects[0].width); +} + +// ─── Box helpers ───────────────────────────────────────────────────────────── + +test "inset never produces negative dimensions" { + const tiny: Box = .{ .x = 0, .y = 0, .width = 4, .height = 4 }; + const r = tiny.inset(10); + try testing.expectEqual(@as(i32, 0), r.width); + try testing.expectEqual(@as(i32, 0), r.height); +} + +test "contains is half open on the far edges" { + const b: Box = .{ .x = 10, .y = 10, .width = 100, .height = 50 }; + try testing.expect(b.contains(10, 10)); + try testing.expect(b.contains(109, 59)); + try testing.expect(!b.contains(110, 30)); + try testing.expect(!b.contains(9, 30)); +} + +// ─── per-tag settings slots ────────────────────────────────────────────────── + +test "each single tag gets its own settings slot" { + for (0..act.tag_count) |i| { + const mask = @as(u32, 1) << @intCast(i); + try testing.expectEqual(i + 1, act.tagSlot(mask)); + } +} + +test "views of more than one tag share the shared slot" { + try testing.expectEqual(@as(usize, 0), act.tagSlot(0b11)); + try testing.expectEqual(@as(usize, 0), act.tagSlot(0b101)); + try testing.expectEqual(@as(usize, 0), act.tagSlot(act.all_tags)); +} + +test "an empty view falls back to the shared slot" { + try testing.expectEqual(@as(usize, 0), act.tagSlot(0)); +} + +test "bits above the tag range do not affect the slot" { + // A single valid tag stays on its own slot even with junk in the high bits, + // so a mask that survived a sloppy IPC caller cannot index past the array. + const junk: u32 = ~act.all_tags; + try testing.expectEqual(@as(usize, 1), act.tagSlot(0b1 | junk)); + try testing.expectEqual(act.tag_count, act.tagSlot(@as(u32, 1) << (act.tag_count - 1))); +} + +// ─── command parsing ───────────────────────────────────────────────────────── + +fn parseOk(argv: []const []const u8) act.Action { + return act.parse(argv) catch unreachable; +} + +test "tag arguments accept indices, masks and all" { + try testing.expectEqual(@as(u32, 1), parseOk(&.{ "view", "1" }).view); + try testing.expectEqual(@as(u32, 4), parseOk(&.{ "view", "3" }).view); + try testing.expectEqual(@as(u32, 4), parseOk(&.{ "view", "0x4" }).view); + try testing.expectEqual(@as(u32, 4), parseOk(&.{ "view", "mask:4" }).view); + try testing.expectEqual(act.all_tags, parseOk(&.{ "view", "all" }).view); +} + +test "tag indices outside the range are rejected" { + try testing.expectError(error.InvalidArgument, act.parse(&.{ "view", "0" })); + try testing.expectError(error.InvalidArgument, act.parse(&.{ "view", "10" })); + try testing.expectError(error.InvalidArgument, act.parse(&.{ "view", "nope" })); + try testing.expectError(error.MissingArgument, act.parse(&.{"view"})); +} + +test "masks are clamped to the valid tag range" { + try testing.expectEqual(act.all_tags, parseOk(&.{ "view", "0xffffffff" }).view); +} + +test "deltas distinguish relative from absolute" { + switch (parseOk(&.{ "mfact", "+0.05" }).mfact) { + .relative => |v| try testing.expectApproxEqAbs(@as(f32, 0.05), v, 1e-6), + .absolute => return error.TestUnexpectedResult, + } + switch (parseOk(&.{ "mfact", "0.5" }).mfact) { + .absolute => |v| try testing.expectApproxEqAbs(@as(f32, 0.5), v, 1e-6), + .relative => return error.TestUnexpectedResult, + } + switch (parseOk(&.{ "nmaster", "-1" }).nmaster) { + .relative => |v| try testing.expectEqual(@as(i32, -1), v), + .absolute => return error.TestUnexpectedResult, + } +} + +test "delta application respects relative and absolute" { + const rel = act.Delta(i32){ .relative = -1 }; + const abs = act.Delta(i32){ .absolute = 3 }; + try testing.expectEqual(@as(i32, 4), rel.apply(5)); + try testing.expectEqual(@as(i32, 3), abs.apply(5)); +} + +test "unknown commands are rejected rather than guessed at" { + try testing.expectError(error.UnknownCommand, act.parse(&.{"nonsense"})); + try testing.expectError(error.UnknownCommand, act.parse(&.{})); +} + +test "spawn keeps its whole argv" { + const a = parseOk(&.{ "spawn", "foot", "-e", "htop" }); + try testing.expectEqual(@as(usize, 3), a.spawn.len); + try testing.expectEqualStrings("htop", a.spawn[2]); + try testing.expectError(error.MissingArgument, act.parse(&.{"spawn"})); +} + +test "directions parse both spellings" { + try testing.expectEqual(act.Direction.next, parseOk(&.{ "focus", "next" }).focus); + try testing.expectEqual(act.Direction.prev, parseOk(&.{ "focus", "prev" }).focus); + try testing.expectEqual(act.Direction.prev, parseOk(&.{ "focus", "previous" }).focus); + try testing.expectError(error.InvalidArgument, act.parse(&.{ "focus", "sideways" })); +} + +test "window commands keep the identifier verbatim" { + try testing.expectEqualStrings("w-17", parseOk(&.{ "focus-window", "w-17" }).focus_window); + try testing.expectEqualStrings("w-17", parseOk(&.{ "close-window", "w-17" }).close_window); + // An identifier is opaque, so a direction-looking one is still an id. + try testing.expectEqualStrings("next", parseOk(&.{ "focus-window", "next" }).focus_window); + try testing.expectError(error.MissingArgument, act.parse(&.{"focus-window"})); + try testing.expectError(error.InvalidArgument, act.parse(&.{ "focus-window", "" })); +} + +test "layouts parse by name" { + try testing.expectEqual(act.Layout.monocle, parseOk(&.{ "layout", "monocle" }).set_layout); + try testing.expectEqual(act.Layout.tabbed, parseOk(&.{ "layout", "tabbed" }).set_layout); + try testing.expectError(error.InvalidArgument, act.parse(&.{ "layout", "spiral" })); +} + +test "only navigation-style actions repeat on key hold" { + try testing.expect(parseOk(&.{ "focus", "next" }).repeats()); + try testing.expect(parseOk(&.{ "mfact", "+0.05" }).repeats()); + try testing.expect(!parseOk(&.{"zoom"}).repeats()); + try testing.expect(!parseOk(&.{"close"}).repeats()); + try testing.expect(!parseOk(&.{ "view", "1" }).repeats()); +} + +// ─── input device rules ────────────────────────────────────────────────────── + +test "device name globs match the way a config author expects" { + const touchpad = "ELAN0501:00 04F3:3060 Touchpad"; + + try testing.expect(input.matches("*Touchpad*", touchpad)); + try testing.expect(input.matches("*Touchpad", touchpad)); + try testing.expect(input.matches("ELAN*", touchpad)); + try testing.expect(input.matches("*", touchpad)); + try testing.expect(input.matches(touchpad, touchpad)); + + try testing.expect(!input.matches("*Trackpoint*", touchpad)); + try testing.expect(!input.matches("Touchpad", touchpad)); + // A literal pattern must match the whole name, not merely a prefix. + try testing.expect(!input.matches("ELAN0501", touchpad)); +} + +test "globs handle empty runs and repeated stars" { + try testing.expect(input.matches("", "")); + try testing.expect(input.matches("*", "")); + try testing.expect(input.matches("***", "")); + try testing.expect(!input.matches("a", "")); + + // The backtracking case: each star has to be willing to give ground. + try testing.expect(input.matches("*a*b*c*", "xxaxxbxxcxx")); + try testing.expect(!input.matches("*a*b*c*", "xxaxxcxxbxx")); + // Only the last 'a' lets the rest of the pattern through. + try testing.expect(input.matches("*aab", "aaab")); +} + +test "rules match on name and type independently" { + const rule: input.Rule = .{ .name = "*Touchpad*", .type = .pointer, .tap = true }; + + try testing.expect(rule.matchesDevice("Foo Touchpad", .pointer)); + // Right name, wrong kind of device. + try testing.expect(!rule.matchesDevice("Foo Touchpad", .touch)); + try testing.expect(!rule.matchesDevice("Foo Keyboard", .pointer)); + + // A rule with neither selector applies to everything. + const catch_all: input.Rule = .{ .natural_scroll = true }; + try testing.expect(catch_all.matchesDevice("anything", .tablet)); + try testing.expect(catch_all.matchesDevice("", .keyboard)); +} + +test "later rules override earlier ones field by field" { + const broad: input.Rule = .{ .name = "*", .tap = true, .natural_scroll = true }; + const narrow: input.Rule = .{ .name = "*Touchpad*", .tap = false, .click_method = .clickfinger }; + + const merged = (input.Rule{}).merge(broad).merge(narrow); + + // Stated twice: the later rule wins. + try testing.expectEqual(@as(?bool, false), merged.tap); + // Stated only by the broad rule: survives. + try testing.expectEqual(@as(?bool, true), merged.natural_scroll); + // Stated only by the narrow rule: applied. + try testing.expectEqual(@as(?input.ClickMethod, .clickfinger), merged.click_method); + // Stated by neither: still null, so the device keeps libinput's default. + try testing.expectEqual(@as(?bool, null), merged.middle_emulation); +} + +test "merging leaves the selectors alone" { + // Otherwise a merged rule would claim to be about whichever device matched + // last, which is not a thing anything should be able to read back out. + const merged = (input.Rule{ .name = "a", .type = .pointer }) + .merge(.{ .name = "b", .type = .keyboard, .tap = true }); + + try testing.expectEqualStrings("a", merged.name.?); + try testing.expectEqual(@as(?input.Type, .pointer), merged.type); + try testing.expectEqual(@as(?bool, true), merged.tap); +} + +test "an unset keymap is recognised as the default" { + try testing.expect((input.Keymap{}).isDefault()); + try testing.expect(!(input.Keymap{ .layout = "us" }).isDefault()); + try testing.expect(!(input.Keymap{ .options = "caps:escape" }).isDefault()); +} + +test "map_to_output merges like any other setting" { + // It is a string rather than a scalar, so worth pinning that the generic + // merge handles it and that a rule silent about it does not clear it. + const merged = (input.Rule{}) + .merge(.{ .type = .touch, .map_to_output = "eDP-1" }) + .merge(.{ .name = "*", .tap = true }); + + try testing.expectEqualStrings("eDP-1", merged.map_to_output.?); + + // And that a later rule naming a different output does win. + const moved = merged.merge(.{ .map_to_output = "DP-2" }); + try testing.expectEqualStrings("DP-2", moved.map_to_output.?); +}