commit fff3a5e734a2db07b5692d52128b8d961badfc3a Author: Asmir A Date: Sun Jul 26 20:47:19 2026 +0200 initial commit 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.?); +}