initial commit

This commit is contained in:
2026-07-26 20:47:19 +02:00
commit fff3a5e734
37 changed files with 10781 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
/.zig-cache/
/zig-out/
/zig-pkg/
/result
/result-*
core.*
+500
View File
@@ -0,0 +1,500 @@
# att_wm
A dwm-like window manager for the [river](https://codeberg.org/river/river) Wayland compositor.
river 0.4 is *non-monolithic*: it ships no window management policy of its own — no
`riverctl`, no `rivertile` — and instead hands the entire job to a single external
client speaking `river-window-management-v1`. att_wm is that client. It gives you
dwm's model on top of river: nine tags, a master/stack layout, monocle and tabbed
layouts, and keybindings compiled into the binary.
Tag and window state is published as JSON lines on a unix socket so bars such as
[quickshell](https://quickshell.org) can render it, and `att_wmctl` drives the same
socket in the other direction.
Written in Zig, built with a Nix flake.
---
## Status
Verified working against river 0.4.5 / Zig 0.16 / quickshell 0.3.0:
- tags, master / monocle / tabbed layouts, floating and fullscreen windows
- focus cycling, `zoom`, stack reordering, per-tag layout state
- the IPC socket, `att_wmctl`, and a quickshell bar that maps as a layer surface
and whose exclusive zone correctly shrinks the tiling area
- keybindings: all 63 register and fire, verified by injecting real key events
through a virtual keyboard (`wtype`) into a nested river
- input: a keymap compiled from `layout`/`variant`/`options` is accepted by river
and assigned to each keyboard, per-device rules match on name glob and device
type, and key repeat and scroll factor are applied. `map_to_output` resolves
the right output by name among several, and re-maps across an output being
turned off and back on. The **libinput** half — tap to click and its
neighbours — is written against the protocol but not yet exercised on hardware:
river cannot expose libinput devices to a nested session, so it needs a real
one to confirm.
---
## Building
```sh
nix build # produces ./result/bin/{att_wm,att_wmctl}
nix develop # dev shell: zig, zls, river, quickshell
zig build # inside the dev shell
zig build test # unit tests for the layout maths and command parser
```
`./dev.sh` enters the dev shell without refetching nixpkgs when the pinned
revision (nixos-26.05) is already in the local store.
## Running
river runs `$XDG_CONFIG_HOME/river/init` on startup. Start att_wm from there and
keep it in the foreground, so quitting it ends the session:
```sh
#!/bin/sh
# ~/.config/river/init
quickshell -p ~/.config/quickshell/att_wm &
exec att_wm
```
Or, for a one-off:
```sh
river -c att_wm
```
### Trying it without touching your system config
river's wayland backend runs it as an ordinary window inside your existing
session, so you can drive att_wm for real without installing anything or
rebuilding NixOS:
```sh
nix develop # or: nix shell nixpkgs#river nixpkgs#foot nixpkgs#quickshell
./run-nested.sh --bar --term
```
That opens a nested river with att_wm, the quickshell bar and a terminal. Close
the window to exit. It prints the nested display name, so you can drive that
instance from another terminal:
```sh
WAYLAND_DISPLAY=wayland-2 att_wmctl state
WAYLAND_DISPLAY=wayland-2 att_wmctl layout tabbed
```
Two caveats when nested:
- **Your outer compositor sees keys first.** If it already binds `Alt+Return` or
similar, those never reach att_wm. Either test with `att_wmctl`, or change `mod`
in `config.zig` and rebuild — one line, dwm-style.
- Without a Wayland session (on a TTY) there is nothing to nest inside; use the
headless backend instead, and drive it entirely over IPC:
```sh
WLR_BACKENDS=headless WLR_HEADLESS_OUTPUTS=1 WLR_RENDERER=pixman \
river -no-xwayland -c 'att_wm'
```
att_wm must be started by river — `river_window_manager_v1` is what it binds, and
only one client may hold it at a time. If a window manager is already running,
att_wm reports it and exits rather than fighting for the global.
> **river 0.4 or newer is required.** Check with `river -version`. If it says
> `0.3.x` you have **river-classic**, a different package that predates this
> protocol — it does its own window management and is configured with
> `riverctl`, so att_wm cannot drive it. Installing both leaves whichever comes
> first on `PATH` in charge, and the symptom is a bare background with no
> working keybindings: river-classic has no built-in bindings, and att_wm exits
> because the global it needs is missing. Remove river-classic, or call river
> 0.4 by its absolute path.
### Home Manager
```nix
{
inputs.att_wm.url = "git+https://git.project-cloud.net/asmir/att_wm.git";
# ...
imports = [ inputs.att_wm.homeManagerModules.default ];
programs.att_wm = {
enable = true;
settings = ./my-config.zig; # optional, see Configuration
autostart = [ "quickshell" ];
};
}
```
---
## Keybindings
`Mod` is **Alt**, as dwm ships it. Change `mod` in `config.zig` to `Mods.super`
if you would rather not compete with applications that bind Alt themselves.
| Binding | Action |
| --- | --- |
| `Mod+Shift+Return` | Spawn terminal |
| `Mod+p` | Spawn menu |
| `Mod+Shift+c` | Close focused window |
| `Mod+Shift+q` | Quit att_wm (river keeps running) |
| `Mod+Ctrl+Shift+q` | End the Wayland session |
| `Mod+j` / `Mod+k` | Focus next / previous window |
| `Mod+Shift+j` / `Mod+Shift+k` | Move focused window down / up the stack |
| `Mod+Return` | Zoom — promote focused window to master |
| `Mod+h` / `Mod+l` | Shrink / grow the master area |
| `Mod+i` / `Mod+d` | Increase / decrease windows in master |
| `Mod+t` / `Mod+m` / `Mod+u` | Master / monocle / tabbed layout |
| `Mod+space` | Toggle between current and previous layout |
| `Mod+Shift+space` | Toggle floating |
| `Mod+f` | Toggle fullscreen |
| `Mod+1..9` | View tag |
| `Mod+Shift+1..9` | Move focused window to tag |
| `Mod+Ctrl+1..9` | Toggle tag in view |
| `Mod+Ctrl+Shift+1..9` | Toggle tag on focused window |
| `Mod+0` / `Mod+Shift+0` | View all tags / put window on all tags |
| `Mod+Tab` | Back to previously viewed tags |
| `Mod+,` / `Mod+.` | Focus previous / next output |
| `Mod+Shift+,` / `Mod+Shift+.` | Send window to previous / next output |
| `Mod+Left drag` | Move window (floats it) |
| `Mod+Right drag` | Resize window |
river reserves `Ctrl+Alt+F1``F12` for VT switching; no window manager can
override those.
Key repeat is handled by att_wm, not river: the protocol reports press and
release, so bindings where holding the key should keep acting (`focus`, `swap`,
`nmaster`, `mfact`) repeat on a timer — `binding_repeat_delay` and
`binding_repeat_interval` in `config.zig`. The repeat *applications* see is a
separate setting, `repeat`, under [Input](#input).
## Layouts
**master** — dwm's tile. `nmaster` windows share a left-hand column of width
`mfact`; the rest divide the remainder. Leftover pixels are absorbed as the
column is divided, so the bottom edge always lands exactly on the output edge.
**monocle** — every window gets the full area; only the most recently focused
one is rendered. A window spawned into monocle or tabbed takes focus as it
maps, so it comes up in front instead of hidden behind the current one.
**tabbed** — same geometry as monocle, minus a strip at the top where att_wm
draws one solid colour block per window, the focused one highlighted. The blocks
are clickable. Titles are *not* drawn — that would mean a font stack — but the
window list is published over IPC, so a bar can draw a textual tab strip
alongside (the bundled quickshell config does). Set `tabbar_height = 0` to drop
the built-in strip entirely and let the bar own it.
Layout, `nmaster` and `mfact` are kept per tag, per output — dwm with its
pertag patch. Setting a layout on tag 3 leaves tag 4 as it was, and switching
back to 3 restores it. Viewing several tags at once has no single tag whose
settings should win, so all such views share one extra set; the individual tags
keep theirs for the way back.
---
## Configuration
att_wm is configured at compile time, like dwm. Edit `src/config.zig` and rebuild.
Nix users need not patch the source tree:
```sh
zig build -Dconfig=/path/to/my-config.zig
```
```nix
att_wm.override { configFile = ./my-config.zig; }
```
`config.zig` covers border width and colours, gaps, tab bar height and colours,
the default layout, `nmaster` / `mfact`, tag names, focus-follows-mouse, key and
pointer bindings, autostart commands, input devices, and window rules:
```zig
pub const rules = [_]Rule{
.{ .app_id = "pavucontrol", .floating = true },
.{ .title = "Picture-in-Picture", .floating = true },
};
```
Bindings are declared as data, and the same `Action` type backs both keys and
IPC commands — so anything bindable is scriptable and vice versa.
### Input
river 0.4 hands input configuration to the window manager too, over three more
protocols: `river-input-management-v1` enumerates devices, `river-xkb-config-v1`
compiles keymaps, and `river-libinput-config-v1` exposes libinput's own settings.
There is no `riverctl input`, so this is where it lives.
**Keyboard layout** is the RMLVO that `setxkbmap` takes, compiled once and given
to every keyboard:
```zig
pub const keymap: input.Keymap = .{
.layout = "us,se",
.options = "grp:alt_shift_toggle,caps:escape",
};
```
Leaving it alone keeps river's default, which honours the `XKB_DEFAULT_*`
environment variables. With more than one layout, switching between them is a
`grp:` option — xkb does it itself, so att_wm needs no binding for it.
**Key repeat**, as applications see it. Not to be confused with
`binding_repeat_delay` / `binding_repeat_interval`, which are how fast a
held-down att_wm *binding* re-fires — river reports binding press and release and
leaves repeating to att_wm:
```zig
pub const repeat: input.Repeat = .{ .rate = 40, .delay = 400 };
```
**Per-device settings** are rules matched on name and type. `name` is a glob, so
`*` saves you writing out `ELAN0501:00 04F3:3060 Touchpad` in full:
```zig
pub const input_rules = [_]input.Rule{
.{
.name = "*Touchpad*",
.tap = true,
.natural_scroll = true,
.disable_while_typing = true,
.click_method = .clickfinger,
},
.{ .type = .keyboard, .repeat = .{ .rate = 50, .delay = 250 } },
.{ .name = "*Logitech*", .accel_profile = .flat, .scroll_factor = 1.5 },
};
```
Every setting defaults to null, meaning "leave libinput's own default alone", so
a rule need only say what it wants changed. Rules apply in declaration order and
a later one overrides an earlier one field by field, so a broad rule can set a
house style and a narrower one dissent from it — the same last-one-wins as dwm's
window rules.
`src/input.zig` is the full list. Beyond the above it covers `tap_button_map`,
`drag`, `drag_lock`, `three_finger_drag`, `clickfinger_button_map`,
`middle_emulation`, `left_handed`, `scroll_method`, `scroll_button`,
`scroll_button_lock`, `accel_speed`, `disable_while_trackpointing`, `rotation`,
`send_events` and `map_to_output`.
Note that a **touchpad reports `pointer`, not `touch`** — libinput models it as a
pointer that happens to support tapping. `touch` is a touchscreen.
#### Touchscreens and display rotation
An unmapped touchscreen spans the whole output layout, so on two monitors a touch
near the left edge of the panel lands on the wrong screen. `map_to_output`
confines it to one, named as river names it — the same name the IPC `outputs`
list uses:
```zig
.{ .type = .touch, .map_to_output = "eDP-1" },
.{ .type = .tablet, .map_to_output = "eDP-1" },
```
This is also **all that is needed for touch to survive display rotation**.
wlroots applies the output's transform to a mapped device's coordinates on every
event, reading the transform live — so rotating with `wlr-randr --transform`, or
with a daemon like [rot8](https://github.com/efernandesng/rot8), rotates touch
along with the screen. Nothing is re-sent on rotation and no rotation hook is
needed:
```sh
rot8 # no --hooks, no calibration matrix
```
Do **not** also apply a libinput calibration matrix for rotation. The two
transforms compose and the result is rotated twice. A calibration matrix is for
correcting a panel that is wired wrong, which is a different job.
Mappings are re-evaluated as outputs come and go, because river drops its side of
the mapping when the output named is destroyed — so undocking and redocking
re-maps rather than silently losing touch. A rule naming an output that is not
present says so once:
```
warning(input): cannot map Wacom HID 5380 Finger to output eDP-9: no output by that name
```
Window tiling follows rotation on its own: river reports the output's new
dimensions and att_wm re-lays out.
att_wm logs one line per device as it appears, which is where the names come from:
```
info(input): input device: ELAN0501:00 04F3:3060 Touchpad (pointer)
info(input): input device: AT Translated Set 2 keyboard (keyboard)
```
There is no `list-inputs` command because the protocol shows input devices to the
window manager alone. Asking a device for something it cannot do is reported
rather than swallowed — libinput answers every request with success, unsupported
or invalid, and att_wm logs the latter two:
```
warning(input): Logitech USB Receiver does not support tap; setting ignored
```
Two things do not work everywhere:
- **libinput settings need real hardware.** river cannot expose libinput devices
when it has no access to them, which is exactly the case running nested inside
another compositor — so tap to click cannot be tested with `run-nested.sh`.
Keyboard layout and repeat work nested; the rest needs a real session.
- **river 0.4.5 or newer** is required for these three protocols. On an older
0.4 att_wm warns once and carries on, rather than failing to start.
---
## IPC
att_wm listens on `$XDG_RUNTIME_DIR/att_wm-$WAYLAND_DISPLAY.sock` (override with
`ATT_WM_SOCKET`). It is a line protocol: send a command line, get `ok` or
`err <reason>`. Send `subscribe` and you get a JSON state object on every
change, starting with one immediately.
A subscriber may keep sending commands on the same connection — the bundled
quickshell config uses one socket for both, avoiding a process spawn per click.
### State
```json
{
"tag_count": 9,
"tag_names": ["1","2","3","4","5","6","7","8","9"],
"locked": false,
"outputs": [{
"name": "DP-1",
"focused": true,
"tags": 1,
"occupied": 5,
"layout": "master",
"layout_symbol": "[]=",
"nmaster": 1,
"mfact": 0.550,
"x": 0, "y": 0, "width": 2560, "height": 1440,
"usable": { "x": 0, "y": 26, "width": 2560, "height": 1414 },
"windows": [{
"id": "6389ff21ec27eefea415425a8eaa1fd7",
"title": "~/proj",
"app_id": "foot",
"tags": 1,
"focused": true,
"visible": true,
"floating": false,
"fullscreen": false
}]
}]
}
```
`tags` and `occupied` are bitmasks — `occupied` is the set of tags holding at
least one window, which is what dwm's bar draws its corner squares from.
`usable` is the area left after layer-shell exclusive zones, i.e. where windows
are actually laid out.
There is no `urgent` field: `river-window-management-v1` has no
attention-request event, so att_wm does not pretend to model urgency.
`id` is river's window identifier: up to 32 printable ASCII bytes, unique and
never reused, and equal to the `ext_foreign_toplevel_handle_v1.identifier` of the
same window. It is the handle `focus-window` and `close-window` take, so a bar
can act on the window a click names rather than on whatever is focused.
### att_wmctl
```sh
att_wmctl view 3 # view tag 3 (or: 0x4, mask:4, all)
att_wmctl toggle-view 3
att_wmctl tag 2 # move focused window to tag 2
att_wmctl focus next
att_wmctl focus-window 6389ff21ec27eefea415425a8eaa1fd7
att_wmctl swap prev
att_wmctl zoom
att_wmctl layout tabbed
att_wmctl cycle-layout next
att_wmctl nmaster +1 # +N/-N relative, bare N absolute
att_wmctl mfact 0.6
att_wmctl toggle-float
att_wmctl toggle-fullscreen
att_wmctl focus-output next
att_wmctl send-to-output next
att_wmctl spawn foot -e htop
att_wmctl close
att_wmctl close-window 6389ff21ec27eefea415425a8eaa1fd7
att_wmctl quit # stop att_wm, leave river running
att_wmctl exit-session # end the Wayland session
att_wmctl state # print state once
att_wmctl subscribe # stream state on every change
```
Exit status is non-zero on an unknown or malformed command.
## quickshell
`quickshell/` holds a dwm-style bar: clickable tags with occupied indicators,
the layout symbol, the focused window title, and a tab strip in tabbed layout.
```sh
quickshell -p /path/to/att_wm/quickshell
```
`AttWm.qml` is a singleton wrapping the socket — reconnecting if att_wm restarts,
exposing `outputs`, `focusedOutput`, `focusedTitle`, `tagActive()` and
`tagOccupied()`, plus `send()` for commands. Reuse it in your own bar and ignore
`shell.qml`.
Note that **a bar only works because att_wm binds `river_layer_shell_v1`**. river
refuses to map layer surfaces at all unless the window manager declares support
for them, so under a window manager that skips it, no bar can appear.
**A task list must focus windows over this socket, not over
`wlr-foreign-toplevel-management`.** river advertises that protocol, so a bar
built on it shows the right titles and tracks focus correctly — but
`river-window-management-v1` has no `activate_requested` event, and focus is set
solely by the window manager through `river_seat_v1.focus_window`. An `activate`
request from a bar therefore reaches nobody and the click does nothing. Use
`focus-window <id>`; matching up the two protocols is not needed either, since
river's identifier is shared between them.
---
## Layout of the source
| File | Purpose |
| --- | --- |
| `src/Wm.zig` | Globals, the manage/render sequence state machine, actions, event loop |
| `src/Window.zig` | Per-window state; handlers only mutate fields |
| `src/Output.zig` | Output tags, the per-tag layout/nmaster/mfact, and the tab bar |
| `src/Seat.zig` | Focus, key and pointer bindings, interactive move/resize |
| `src/InputManager.zig` | Input devices: keymaps, key repeat, libinput settings |
| `src/layout.zig` | Pure tiling geometry — no Wayland, unit tested |
| `src/action.zig` | The Action vocabulary shared by config and IPC |
| `src/input.zig` | Input configuration types — no Wayland, unit tested |
| `src/config.zig` | Compile-time configuration |
| `src/ipc.zig` | Socket server and JSON encoding |
| `src/shm.zig` | memfd buffers for the tab bar |
| `src/sys.zig` | Thin Linux syscall wrappers |
The single most important invariant: **river only permits window management
state changes between `manage_start` and `manage_finish`, and rendering state
changes between those or `render_start`/`render_finish`.** Every event handler
in att_wm therefore only mutates plain Zig fields and calls `needsManage()`;
`Wm.manage()` and `Wm.render()` are the only places that issue protocol
requests. Violating this is a fatal protocol error — river also disconnects a
window manager that takes longer than 3 seconds inside a sequence.
## Licence
GPL-3.0-only, as dwm and river are.
+123
View File
@@ -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);
}
}
+23
View File
@@ -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,
}
Executable
+19
View File
@@ -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}"
Generated
+61
View File
@@ -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
}
+65
View File
@@ -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;
};
}
+20
View File
@@ -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=";
};
}
]
+77
View File
@@ -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}
'';
};
}
);
}
+73
View File
@@ -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";
};
}
+234
View File
@@ -0,0 +1,234 @@
<?xml version="1.0" encoding="UTF-8"?>
<protocol name="river_input_management_v1">
<copyright>
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.
</copyright>
<description summary="manage seats and input devices">
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.
</description>
<interface name="river_input_manager_v1" version="1">
<description summary="input manager global interface">
Input manager global interface.
</description>
<enum name="error">
<entry name="invalid_destroy" value="0"/>
</enum>
<request name="stop">
<description summary="stop sending events">
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.
</description>
</request>
<event name="finished">
<description summary="the server has finished with the input manager">
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.
</description>
</event>
<request name="destroy" type="destructor">
<description summary="destroy the river_input_manager_v1 object">
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.
</description>
</request>
<request name="create_seat">
<description summary="create a new seat">
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.
</description>
<arg name="name" type="string"/>
</request>
<request name="destroy_seat">
<description summary="destroy a seat">
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.
</description>
<arg name="name" type="string"/>
</request>
<event name="input_device">
<description summary="new input device">
A new input device has been created.
</description>
<arg name="id" type="new_id" interface="river_input_device_v1"/>
</event>
</interface>
<interface name="river_input_device_v1" version="1">
<description summary="an input device">
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.
</description>
<enum name="error">
<entry name="invalid_repeat_info" value="0"/>
<entry name="invalid_scroll_factor" value="1"/>
<entry name="invalid_map_to_rectangle" value="2"/>
</enum>
<request name="destroy" type="destructor">
<description summary="destroy the input device object">
This request indicates that the client will no longer use the input
device object and that it may be safely destroyed.
</description>
</request>
<event name="removed">
<description summary="the input device is removed">
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.
</description>
</event>
<enum name="type">
<entry name="keyboard" value="0"/>
<entry name="pointer" value="1"/>
<entry name="touch" value="2"/>
<entry name="tablet" value="3"/>
</enum>
<event name="type">
<description summary="the type of the input device">
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.
</description>
<arg name="type" type="uint" enum="type"/>
</event>
<event name="name">
<description summary="the name of the input device">
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.
</description>
<arg name="name" type="string"/>
</event>
<request name="assign_to_seat">
<description summary="assign the input device to a seat">
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.
</description>
<arg name="name" type="string" summary="name of the seat"/>
</request>
<request name="set_repeat_info">
<description summary="set keyboard repeat rate and delay">
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).
</description>
<arg name="rate" type="int" summary="rate in key repeats per second"/>
<arg name="delay" type="int" summary="delay in milliseconds"/>
</request>
<request name="set_scroll_factor">
<description summary="set scroll factor">
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.
</description>
<arg name="factor" type="fixed"/>
</request>
<request name="map_to_output">
<description summary="map input device to the given output">
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.
</description>
<arg name="output" type="object" interface="wl_output" allow-null="true"/>
</request>
<request name="map_to_rectangle">
<description summary="map input device to the given rectangle">
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.
</description>
<arg name="x" type="int"/>
<arg name="y" type="int"/>
<arg name="width" type="int"/>
<arg name="height" type="int"/>
</request>
</interface>
</protocol>
+191
View File
@@ -0,0 +1,191 @@
<?xml version="1.0" encoding="UTF-8"?>
<protocol name="river_layer_shell_v1">
<copyright>
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.
</copyright>
<description summary="optional layer shell support">
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.
</description>
<interface name="river_layer_shell_v1" version="1">
<description summary="river layer shell global interface">
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.
</description>
<enum name="error">
<entry name="object_already_created" value="0"
summary="the layer_shell_output/seat object was already created."/>
</enum>
<request name="destroy" type="destructor">
<description summary="destroy the river_layer_shell_v1 object">
This request indicates that the client will no longer use the
river_layer_shell_v1 object.
</description>
</request>
<request name="get_output">
<description summary="get layer shell output state">
It is a protocol error to make this request more than once for a given
river_output_v1 object.
</description>
<arg name="id" type="new_id" interface="river_layer_shell_output_v1"/>
<arg name="output" type="object" interface="river_output_v1"/>
</request>
<request name="get_seat">
<description summary="get layer shell seat state">
It is a protocol error to make this request more than once for a given
river_seat_v1 object.
</description>
<arg name="id" type="new_id" interface="river_layer_shell_seat_v1"/>
<arg name="seat" type="object" interface="river_seat_v1"/>
</request>
</interface>
<interface name="river_layer_shell_output_v1" version="1">
<description summary="layer shell output state">
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.
</description>
<request name="destroy" type="destructor">
<description summary="destroy the object">
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.
</description>
</request>
<event name="non_exclusive_area">
<description summary="area left after subtracting exclusive zones">
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.
</description>
<arg name="x" type="int" summary="global x coordinate"/>
<arg name="y" type="int" summary="global y coordinate"/>
<arg name="width" type="int" summary="area width"/>
<arg name="height" type="int" summary="area height"/>
</event>
<request name="set_default">
<description summary="Set default output for layer surfaces">
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.
</description>
</request>
</interface>
<interface name="river_layer_shell_seat_v1" version="1">
<description summary="layer shell seat state">
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.
</description>
<request name="destroy" type="destructor">
<description summary="destroy the object">
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.
</description>
</request>
<event name="focus_exclusive">
<description summary="layer shell surface has exclusive focus">
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.
</description>
</event>
<event name="focus_non_exclusive">
<description summary="layer shell surface wants non-exclusive focus">
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.
</description>
</event>
<event name="focus_none">
<description summary="no layer shell surface has focus">
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.
</description>
</event>
</interface>
</protocol>
+891
View File
@@ -0,0 +1,891 @@
<?xml version="1.0" encoding="UTF-8"?>
<protocol name="river_libinput_config_v1">
<copyright>
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.
</copyright>
<description summary="configure libinput devices">
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.
</description>
<interface name="river_libinput_config_v1" version="1">
<description summary="libinput config global interface">
Global interface for configuring libinput devices. This global should
only be advertised if river_input_manager_v1 is advertised as well.
</description>
<enum name="error">
<entry name="invalid_arg" value="0"
summary="invalid enum value or similar"/>
<entry name="invalid_destroy" value="1"/>
</enum>
<request name="stop">
<description summary="stop sending events">
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.
</description>
</request>
<event name="finished">
<description summary="the server has finished with the 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.
</description>
</event>
<request name="destroy" type="destructor">
<description summary="destroy the river_libinput_config_v1 object">
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.
</description>
</request>
<event name="libinput_device">
<description summary="new libinput device">
A new libinput device has been created. Not every river_input_device_v1
is necessarily a libinput device as well.
</description>
<arg name="id" type="new_id" interface="river_libinput_device_v1"/>
</event>
<request name="create_accel_config">
<description summary="create a acceleration config">
Create a acceleration config which can be applied
with river_libinput_device_v1.apply_accel_config.
</description>
<arg name="id" type="new_id"
interface="river_libinput_accel_config_v1"/>
<arg name="profile" type="uint"
enum="river_libinput_device_v1.accel_profile"/>
</request>
</interface>
<interface name="river_libinput_device_v1" version="1">
<description summary="a libinput device">
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.
</description>
<enum name="error">
<entry name="invalid_arg" value="0"
summary="invalid enum value or similar"/>
</enum>
<request name="destroy" type="destructor">
<description summary="destroy the libinput device object">
This request indicates that the client will no longer use the input
device object and that it may be safely destroyed.
</description>
</request>
<event name="removed">
<description summary="the libinput device is removed">
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.
</description>
</event>
<event name="input_device">
<description summary="corresponding river input device">
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.
</description>
<arg name="device" type="object" interface="river_input_device_v1"/>
</event>
<enum name="send_events_modes" bitfield="true">
<entry name="enabled" value="0"/>
<entry name="disabled" value="1"/>
<entry name="disabled_on_external_mouse" value="2"/>
</enum>
<event name="send_events_support">
<description summary="supported send events modes">
Supported send events modes.
</description>
<arg name="modes" type="uint" enum="send_events_modes"/>
</event>
<event name="send_events_default">
<description summary="default send events mode">
Default send events mode.
</description>
<arg name="mode" type="uint" enum="send_events_modes"/>
</event>
<event name="send_events_current">
<description summary="current send events mode">
Current send events mode.
</description>
<arg name="mode" type="uint" enum="send_events_modes"/>
</event>
<request name="set_send_events">
<description summary="set send events mode">
Set the send events mode for the device.
</description>
<arg name="result" type="new_id" interface="river_libinput_result_v1"/>
<arg name="mode" type="uint" enum="send_events_modes"/>
</request>
<enum name="tap_state">
<entry name="disabled" value="0"/>
<entry name="enabled" value="1"/>
</enum>
<event name="tap_support">
<description summary="tap-to-click/drag support">
The number of fingers supported for tap-to-click/drag.
If finger_count is 0, tap-to-click and drag are unsupported.
</description>
<arg name="finger_count" type="int"/>
</event>
<event name="tap_default">
<description summary="default tap-to-click state">
Default tap-to-click state.
</description>
<arg name="state" type="uint" enum="tap_state"/>
</event>
<event name="tap_current">
<description summary="current tap-to-click state">
Current tap-to-click state.
</description>
<arg name="state" type="uint" enum="tap_state"/>
</event>
<request name="set_tap">
<description summary="enable/disable tap-to-click">
Configure tap-to-click on this device, with a default mapping of
1, 2, 3 finger tap mapping to left, right, middle click, respectively.
</description>
<arg name="result" type="new_id" interface="river_libinput_result_v1"/>
<arg name="state" type="uint" enum="tap_state"/>
</request>
<enum name="tap_button_map">
<entry name="lrm" value="0"
summary="1/2/3 finger tap maps to left/right/middle"/>
<entry name="lmr" value="1"
summary="1/2/3 finger tap maps to left/middle/right"/>
</enum>
<event name="tap_button_map_default">
<description summary="default tap-to-click button map">
Default tap-to-click button map.
</description>
<arg name="button_map" type="uint" enum="tap_button_map"/>
</event>
<event name="tap_button_map_current">
<description summary="current tap-to-click button map">
Current tap-to-click button map.
</description>
<arg name="button_map" type="uint" enum="tap_button_map"/>
</event>
<request name="set_tap_button_map">
<description summary="set 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.
</description>
<arg name="result" type="new_id" interface="river_libinput_result_v1"/>
<arg name="button_map" type="uint" enum="tap_button_map"/>
</request>
<enum name="drag_state">
<entry name="disabled" value="0"/>
<entry name="enabled" value="1"/>
</enum>
<event name="drag_default">
<description summary="default tap-and-drag state">
Default tap-and-drag state.
</description>
<arg name="state" type="uint" enum="drag_state"/>
</event>
<event name="drag_current">
<description summary="current tap-and-drag state">
Current tap-and-drag state.
</description>
<arg name="state" type="uint" enum="drag_state"/>
</event>
<request name="set_drag">
<description summary="set tap-and-drag state">
Configure tap-and-drag functionality on the device.
</description>
<arg name="result" type="new_id" interface="river_libinput_result_v1"/>
<arg name="state" type="uint" enum="drag_state"/>
</request>
<enum name="drag_lock_state">
<entry name="disabled" value="0"/>
<entry name="enabled_timeout" value="1"/>
<entry name="enabled_sticky" value="2"/>
</enum>
<event name="drag_lock_default">
<description summary="default drag lock state">
Default drag lock state.
</description>
<arg name="state" type="uint" enum="drag_lock_state"/>
</event>
<event name="drag_lock_current">
<description summary="current drag lock state">
Current drag lock state.
</description>
<arg name="state" type="uint" enum="drag_lock_state"/>
</event>
<request name="set_drag_lock">
<description summary="set 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.
</description>
<arg name="result" type="new_id" interface="river_libinput_result_v1"/>
<arg name="state" type="uint" enum="drag_lock_state"/>
</request>
<event name="three_finger_drag_support">
<description summary="three finger drag support">
The number of fingers supported for three/four finger drag.
If finger_count is less than 3, three finger drag is unsupported.
</description>
<arg name="finger_count" type="int"/>
</event>
<enum name="three_finger_drag_state">
<entry name="disabled" value="0"/>
<entry name="enabled_3fg" value="1"/>
<entry name="enabled_4fg" value="2"/>
</enum>
<event name="three_finger_drag_default">
<description summary="default three finger drag state">
Default three finger drag state.
</description>
<arg name="state" type="uint" enum="three_finger_drag_state"/>
</event>
<event name="three_finger_drag_current">
<description summary="current three finger drag state">
Current three finger drag state.
</description>
<arg name="state" type="uint" enum="three_finger_drag_state"/>
</event>
<request name="set_three_finger_drag">
<description summary="set three finger drag state">
Configure three finger drag functionality for the device.
</description>
<arg name="result" type="new_id" interface="river_libinput_result_v1"/>
<arg name="state" type="uint" enum="three_finger_drag_state"/>
</request>
<event name="calibration_matrix_support">
<description summary="support for a calibration matrix">
A calibration matrix is supported if the supported argument is non-zero.
</description>
<arg name="supported" type="int" summary="boolean"/>
</event>
<event name="calibration_matrix_default">
<description summary="default calibration matrix">
Default calibration matrix.
</description>
<arg name="matrix" type="array" summary="array of 6 floats"/>
</event>
<event name="calibration_matrix_current">
<description summary="current calibration matrix">
Current calibration matrix.
</description>
<arg name="matrix" type="array" summary="array of 6 floats"/>
</event>
<request name="set_calibration_matrix">
<description summary="set calibration matrix">
Set calibration matrix.
</description>
<arg name="result" type="new_id" interface="river_libinput_result_v1"/>
<arg name="matrix" type="array" summary="array of 6 floats"/>
</request>
<enum name="accel_profile">
<entry name="none" value="0"/>
<entry name="flat" value="1"/>
<entry name="adaptive" value="2"/>
<entry name="custom" value="4"/>
</enum>
<enum name="accel_profiles" bitfield="true">
<entry name="none" value="0"/>
<entry name="flat" value="1"/>
<entry name="adaptive" value="2"/>
<entry name="custom" value="4"/>
</enum>
<event name="accel_profiles_support">
<description summary="supported acceleration profiles">
Supported acceleration profiles.
</description>
<arg name="profiles" type="uint" enum="accel_profiles"/>
</event>
<event name="accel_profile_default">
<description summary="default acceleration profile">
Default acceleration profile.
</description>
<arg name="profile" type="uint" enum="accel_profile"/>
</event>
<event name="accel_profile_current">
<description summary="current send events mode">
Current acceleration profile.
</description>
<arg name="profile" type="uint" enum="accel_profile"/>
</event>
<request name="set_accel_profile">
<description summary="set send events mode">
Set the acceleration profile.
</description>
<arg name="result" type="new_id" interface="river_libinput_result_v1"/>
<arg name="profile" type="uint" enum="accel_profile"/>
</request>
<event name="accel_speed_default">
<description summary="default acceleration speed">
Default acceleration speed.
</description>
<arg name="speed" type="array" summary="double"/>
</event>
<event name="accel_speed_current">
<description summary="current acceleration speed">
Current acceleration speed.
</description>
<arg name="speed" type="array" summary="double"/>
</event>
<request name="set_accel_speed">
<description summary="set 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.
</description>
<arg name="result" type="new_id" interface="river_libinput_result_v1"/>
<arg name="speed" type="array" summary="double"/>
</request>
<request name="apply_accel_config">
<description summary="apply acceleration config">
Apply a pointer accleration config.
</description>
<arg name="result" type="new_id" interface="river_libinput_result_v1"/>
<arg name="config" type="object" interface="river_libinput_accel_config_v1"/>
</request>
<event name="natural_scroll_support">
<description summary="support for natural scroll">
Natural scroll is supported if the supported argument is non-zero.
</description>
<arg name="supported" type="int" summary="boolean"/>
</event>
<enum name="natural_scroll_state">
<entry name="disabled" value="0"/>
<entry name="enabled" value="1"/>
</enum>
<event name="natural_scroll_default">
<description summary="default natural scroll">
Default natural scroll.
</description>
<arg name="state" type="uint" enum="natural_scroll_state"/>
</event>
<event name="natural_scroll_current">
<description summary="current natural scroll state">
Current natural scroll.
</description>
<arg name="state" type="uint" enum="natural_scroll_state"/>
</event>
<request name="set_natural_scroll">
<description summary="set natural scroll state">
Set natural scroll state.
</description>
<arg name="result" type="new_id" interface="river_libinput_result_v1"/>
<arg name="state" type="uint" enum="natural_scroll_state"/>
</request>
<event name="left_handed_support">
<description summary="support for left-handed mode">
Left-handed mode is supported if the supported argument is non-zero.
</description>
<arg name="supported" type="int" summary="boolean"/>
</event>
<enum name="left_handed_state">
<entry name="disabled" value="0"/>
<entry name="enabled" value="1"/>
</enum>
<event name="left_handed_default">
<description summary="default left-handed mode">
Default left-handed mode.
</description>
<arg name="state" type="uint" enum="left_handed_state"/>
</event>
<event name="left_handed_current">
<description summary="current left-handed mode state">
Current left-handed mode.
</description>
<arg name="state" type="uint" enum="left_handed_state"/>
</event>
<request name="set_left_handed">
<description summary="set left-handed mode state">
Set left-handed mode state.
</description>
<arg name="result" type="new_id" interface="river_libinput_result_v1"/>
<arg name="state" type="uint" enum="left_handed_state"/>
</request>
<enum name="click_method">
<entry name="none" value="0"/>
<entry name="button_areas" value="1"/>
<entry name="clickfinger" value="2"/>
</enum>
<enum name="click_methods" bitfield="true">
<entry name="none" value="0"/>
<entry name="button_areas" value="1"/>
<entry name="clickfinger" value="2"/>
</enum>
<event name="click_method_support">
<description summary="supported click methods">
The click methods supported by the device.
</description>
<arg name="methods" type="uint" enum="click_methods"/>
</event>
<event name="click_method_default">
<description summary="default click method">
Default click method.
</description>
<arg name="method" type="uint" enum="click_method"/>
</event>
<event name="click_method_current">
<description summary="current click method">
Current click method.
</description>
<arg name="method" type="uint" enum="click_method"/>
</event>
<request name="set_click_method">
<description summary="set click method">
Set click method.
</description>
<arg name="result" type="new_id" interface="river_libinput_result_v1"/>
<arg name="method" type="uint" enum="click_method"/>
</request>
<enum name="clickfinger_button_map">
<entry name="lrm" value="0"/>
<entry name="lmr" value="1"/>
</enum>
<event name="clickfinger_button_map_default">
<description summary="default clickfinger button map">
Default clickfinger button map.
Supported if click_methods.clickfinger is supported.
</description>
<arg name="button_map" type="uint" enum="clickfinger_button_map"/>
</event>
<event name="clickfinger_button_map_current">
<description summary="current clickfinger button map">
Current clickfinger button map.
Supported if click_methods.clickfinger is supported.
</description>
<arg name="button_map" type="uint" enum="clickfinger_button_map"/>
</event>
<request name="set_clickfinger_button_map">
<description summary="set clickfinger button map">
Set clickfinger button map.
Supported if click_methods.clickfinger is supported.
</description>
<arg name="result" type="new_id" interface="river_libinput_result_v1"/>
<arg name="button_map" type="uint" enum="clickfinger_button_map"/>
</request>
<event name="middle_emulation_support">
<description summary="support for middle mouse button emulation">
Middle mouse button emulation is supported if the supported argument is
non-zero.
</description>
<arg name="supported" type="int" summary="boolean"/>
</event>
<enum name="middle_emulation_state">
<entry name="disabled" value="0"/>
<entry name="enabled" value="1"/>
</enum>
<event name="middle_emulation_default">
<description summary="default middle mouse button emulation">
Default middle mouse button emulation.
</description>
<arg name="state" type="uint" enum="middle_emulation_state"/>
</event>
<event name="middle_emulation_current">
<description summary="current middle mouse button emulation state">
Current middle mouse button emulation.
</description>
<arg name="state" type="uint" enum="middle_emulation_state"/>
</event>
<request name="set_middle_emulation">
<description summary="set middle mouse button emulation state">
Set middle mouse button emulation state.
</description>
<arg name="result" type="new_id" interface="river_libinput_result_v1"/>
<arg name="state" type="uint" enum="middle_emulation_state"/>
</request>
<enum name="scroll_method">
<entry name="no_scroll" value="0"/>
<entry name="two_finger" value="1"/>
<entry name="edge" value="2"/>
<entry name="on_button_down" value="4"/>
</enum>
<enum name="scroll_methods" bitfield="true">
<entry name="no_scroll" value="0"/>
<entry name="two_finger" value="1"/>
<entry name="edge" value="2"/>
<entry name="on_button_down" value="4"/>
</enum>
<event name="scroll_method_support">
<description summary="supported scroll methods">
The scroll methods supported by the device.
</description>
<arg name="methods" type="uint" enum="scroll_methods"/>
</event>
<event name="scroll_method_default">
<description summary="default scroll method">
Default scroll method.
</description>
<arg name="method" type="uint" enum="scroll_method"/>
</event>
<event name="scroll_method_current">
<description summary="current scroll method">
Current scroll method.
</description>
<arg name="method" type="uint" enum="scroll_method"/>
</event>
<request name="set_scroll_method">
<description summary="set scroll method">
Set scroll method.
</description>
<arg name="result" type="new_id" interface="river_libinput_result_v1"/>
<arg name="method" type="uint" enum="scroll_method"/>
</request>
<event name="scroll_button_default">
<description summary="default scroll button">
Default scroll button.
Supported if scroll_methods.on_button_down is supported.
</description>
<arg name="button" type="uint"/>
</event>
<event name="scroll_button_current">
<description summary="current scroll button">
Current scroll button.
Supported if scroll_methods.on_button_down is supported.
</description>
<arg name="button" type="uint"/>
</event>
<request name="set_scroll_button">
<description summary="set scroll button">
Set scroll button.
Supported if scroll_methods.on_button_down is supported.
</description>
<arg name="result" type="new_id" interface="river_libinput_result_v1"/>
<arg name="button" type="uint"/>
</request>
<enum name="scroll_button_lock_state">
<entry name="disabled" value="0"/>
<entry name="enabled" value="1"/>
</enum>
<event name="scroll_button_lock_default">
<description summary="default scroll button lock state">
Default scroll button lock state.
Supported if scroll_methods.on_button_down is supported.
</description>
<arg name="state" type="uint" enum="scroll_button_lock_state"/>
</event>
<event name="scroll_button_lock_current">
<description summary="current scroll button lock state">
Current scroll button lock state.
Supported if scroll_methods.on_button_down is supported.
</description>
<arg name="state" type="uint" enum="scroll_button_lock_state"/>
</event>
<request name="set_scroll_button_lock">
<description summary="set scroll button lock state">
Set scroll button lock state.
Supported if scroll_methods.on_button_down is supported.
</description>
<arg name="result" type="new_id" interface="river_libinput_result_v1"/>
<arg name="state" type="uint" enum="scroll_button_lock_state"/>
</request>
<event name="dwt_support">
<description summary="support for disable-while-typing">
Disable-while-typing is supported if the supported argument is
non-zero.
</description>
<arg name="supported" type="int" summary="boolean"/>
</event>
<enum name="dwt_state">
<entry name="disabled" value="0"/>
<entry name="enabled" value="1"/>
</enum>
<event name="dwt_default">
<description summary="default disable-while-typing state">
Default disable-while-typing state.
</description>
<arg name="state" type="uint" enum="dwt_state"/>
</event>
<event name="dwt_current">
<description summary="current disable-while-typing state">
Current disable-while-typing state.
</description>
<arg name="state" type="uint" enum="dwt_state"/>
</event>
<request name="set_dwt">
<description summary="set disable-while-typing state">
Set disable-while-typing state.
</description>
<arg name="result" type="new_id" interface="river_libinput_result_v1"/>
<arg name="state" type="uint" enum="dwt_state"/>
</request>
<event name="dwtp_support">
<description summary="support for disable-while-trackpointing">
Disable-while-trackpointing is supported if the supported argument is
non-zero.
</description>
<arg name="supported" type="int" summary="boolean"/>
</event>
<enum name="dwtp_state">
<entry name="disabled" value="0"/>
<entry name="enabled" value="1"/>
</enum>
<event name="dwtp_default">
<description summary="default disable-while-trackpointing state">
Default disable-while-trackpointing state.
</description>
<arg name="state" type="uint" enum="dwtp_state"/>
</event>
<event name="dwtp_current">
<description summary="current disable-while-trackpointing state">
Current disable-while-trackpointing state.
</description>
<arg name="state" type="uint" enum="dwtp_state"/>
</event>
<request name="set_dwtp">
<description summary="set disable-while-trackpointing state">
Set disable-while-trackpointing state.
</description>
<arg name="result" type="new_id" interface="river_libinput_result_v1"/>
<arg name="state" type="uint" enum="dwtp_state"/>
</request>
<event name="rotation_support">
<description summary="support for rotation">
Rotation is supported if the supported argument is non-zero.
</description>
<arg name="supported" type="int" summary="boolean"/>
</event>
<event name="rotation_default">
<description summary="default rotation angle">
Default rotation angle.
</description>
<arg name="angle" type="uint"/>
</event>
<event name="rotation_current">
<description summary="current rotation angle">
Current rotation angle.
</description>
<arg name="angle" type="uint"/>
</event>
<request name="set_rotation">
<description summary="set rotation angle">
Set rotation angle in degrees clockwise off the logical neutral
position. Angle must be in the range [0-360).
</description>
<arg name="result" type="new_id" interface="river_libinput_result_v1"/>
<arg name="angle" type="uint"/>
</request>
</interface>
<interface name="river_libinput_accel_config_v1" version="1">
<description summary="acceleration config">
The result returned by libinput on setting configuration for a device.
</description>
<enum name="error">
<entry name="invalid_arg" value="0"
summary="invalid enum value or similar"/>
</enum>
<request name="destroy" type="destructor">
<description summary="destroy the accel object">
This request indicates that the client will no longer use the accel
config object and that it may be safely destroyed.
</description>
</request>
<enum name="accel_type">
<entry name="fallback" value="0"/>
<entry name="motion" value="1"/>
<entry name="scroll" value="2"/>
</enum>
<request name="set_points">
<description summary="define custom acceleration function">
Defines the acceleration function for a given movement type
in an acceleration configuration with custom accel profile.
</description>
<arg name="result" type="new_id" interface="river_libinput_result_v1"/>
<arg name="type" type="uint" enum="accel_type"/>
<arg name="step" type="array" summary="double"/>
<arg name="points" type="array" summary="array of doubles"/>
</request>
</interface>
<interface name="river_libinput_result_v1" version="1">
<description summary="config application result">
The result returned by libinput on setting configuration for a device.
</description>
<event name="success" type="destructor">
<description summary="config success">
The configuration was successfully applied to the device.
</description>
</event>
<event name="unsupported" type="destructor">
<description summary="config unsupported">
The configuration is unsupported by the device and was ignored.
</description>
</event>
<event name="invalid" type="destructor">
<description summary="config invalid">
The configuration is invalid and was ignored.
</description>
</event>
</interface>
</protocol>
File diff suppressed because it is too large Load Diff
+314
View File
@@ -0,0 +1,314 @@
<?xml version="1.0" encoding="UTF-8"?>
<protocol name="river_xkb_bindings_v1">
<copyright>
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.
</copyright>
<description summary="xkbcommon-based key bindings">
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.
</description>
<interface name="river_xkb_bindings_v1" version="3">
<description summary="xkbcommon bindings global interface">
This global interface should only be advertised to the client if the
river_window_manager_v1 global is also advertised.
</description>
<enum name="error" since="2">
<entry name="object_already_created" value="0" since="2"/>
</enum>
<request name="destroy" type="destructor">
<description summary="destroy the river_xkb_bindings_v1 object">
This request indicates that the client will no longer use the
river_xkb_bindings_v1 object.
</description>
</request>
<request name="get_xkb_binding">
<description summary="define a new xkbcommon key binding">
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.
</description>
<arg name="seat" type="object" interface="river_seat_v1"/>
<arg name="id" type="new_id" interface="river_xkb_binding_v1"/>
<arg name="keysym" type="uint" summary="an xkbcommon keysym"/>
<arg name="modifiers" type="uint" enum="river_seat_v1.modifiers"/>
</request>
<request name="get_seat" since="2">
<description summary="manage seat-specific state">
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.
</description>
<arg name="id" type="new_id" interface="river_xkb_bindings_seat_v1"/>
<arg name="seat" type="object" interface="river_seat_v1"/>
</request>
</interface>
<interface name="river_xkb_binding_v1" version="3">
<description summary="configure a xkb key binding, receive trigger events">
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.
</description>
<request name="destroy" type="destructor">
<description summary="destroy the xkb binding object">
This request indicates that the client will no longer use the xkb key
binding object and that it may be safely destroyed.
</description>
</request>
<request name="set_layout_override">
<description summary="override currently active xkb layout">
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.
</description>
<arg name="layout" type="uint" summary="0-indexed xkbcommon layout"/>
</request>
<request name="enable">
<description summary="enable the key binding">
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.
</description>
</request>
<request name="disable">
<description summary="disable the key binding">
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.
</description>
</request>
<event name="pressed">
<description summary="the key triggering the binding has been pressed">
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.
</description>
</event>
<event name="released">
<description summary="the key triggering the binding has been released">
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.
</description>
</event>
<event name="stop_repeat" since="2">
<description summary="repeating should be stopped">
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.
</description>
</event>
</interface>
<interface name="river_xkb_bindings_seat_v1" version="3">
<description summary="xkb bindings seat">
This object manages xkb bindings state associated with a specific seat.
</description>
<request name="destroy" type="destructor" since="2">
<description summary="destroy the object">
This request indicates that the client will no longer use the object and
that it may be safely destroyed.
</description>
</request>
<request name="ensure_next_key_eaten" since="2">
<description summary="ensure the next key press event is eaten">
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.
</description>
</request>
<request name="cancel_ensure_next_key_eaten" since="2">
<description summary="cancel an ensure_next_key_eaten request">
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.
</description>
</request>
<event name="ate_unbound_key" since="2">
<description summary="an unbound key press event was eaten">
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.
</description>
</event>
<request name="modifiers_watch" since="3">
<description summary="watch for change in active modifiers">
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.
</description>
<arg name="modifiers" type="uint" enum="river_seat_v1.modifiers"/>
</request>
<event name="modifiers_update" since="3">
<description summary="active modifiers for the seat changed">
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.
</description>
<arg name="old" type="uint" enum="river_seat_v1.modifiers"
summary="previously active modifiers"/>
<arg name="new" type="uint" enum="river_seat_v1.modifiers"
summary="currently active modifiers"/>
</event>
</interface>
</protocol>
+277
View File
@@ -0,0 +1,277 @@
<?xml version="1.0" encoding="UTF-8"?>
<protocol name="river_xkb_config_v1">
<copyright>
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.
</copyright>
<description summary="configure xkbcommon keyboards">
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.
</description>
<interface name="river_xkb_config_v1" version="1">
<description summary="xkb config global interface">
Global interface for configuring xkb devices.
This global should only be advertised if river_input_manager_v1 is
advertised as well.
</description>
<enum name="error">
<entry name="invalid_destroy" value="0"/>
<entry name="invalid_format" value="1"/>
</enum>
<request name="stop">
<description summary="stop sending events">
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.
</description>
</request>
<event name="finished">
<description summary="the server has finished with the 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.
</description>
</event>
<request name="destroy" type="destructor">
<description summary="destroy the river_xkb_config_v1 object">
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.
</description>
</request>
<enum name="keymap_format">
<entry name="text_v1" value="1" summary="XKB_KEYMAP_FORMAT_TEXT_V1"/>
<entry name="text_v2" value="2" summary="XKB_KEYMAP_FORMAT_TEXT_V2"/>
</enum>
<request name="create_keymap">
<description summary="create a keymap object">
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.
</description>
<arg name="id" type="new_id" interface="river_xkb_keymap_v1"/>
<arg name="fd" type="fd"/>
<arg name="format" type="uint" enum="keymap_format"/>
</request>
<event name="xkb_keyboard">
<description summary="new xkb keyboard">
A new xkbcommon keyboard has been created. Not every
river_input_device_v1 is necessarily an xkbcommon keyboard as well.
</description>
<arg name="id" type="new_id" interface="river_xkb_keyboard_v1"/>
</event>
</interface>
<interface name="river_xkb_keymap_v1" version="1">
<description summary="xkbcommon keymap">
This object is the result of attempting to create an xkbcommon keymap.
</description>
<request name="destroy" type="destructor">
<description summary="destroy the keymap object">
This request indicates that the client will no longer use the keymap
object and that it may be safely destroyed.
</description>
</request>
<event name="success">
<description summary="keymap creation succeeded">
The keymap object was successfully created and may be used with the
river_xkb_keyboard_v1.set_keymap request.
</description>
</event>
<event name="failure">
<description summary="keymap creation failed">
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.
</description>
<arg name="error_msg" type="string"/>
</event>
</interface>
<interface name="river_xkb_keyboard_v1" version="1">
<description summary="xkbcommon keyboard device">
This object represent a physical keyboard which has its configuration and
state managed by xkbcommon.
</description>
<enum name="error">
<entry name="invalid_keymap" value="0"/>
</enum>
<request name="destroy" type="destructor">
<description summary="destroy the xkb keyboard object">
This request indicates that the client will no longer use the keyboard
object and that it may be safely destroyed.
</description>
</request>
<event name="removed">
<description summary="the xkb keyboard is removed">
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.
</description>
</event>
<event name="input_device">
<description summary="corresponding river input device">
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.
</description>
<arg name="device" type="object" interface="river_input_device_v1"/>
</event>
<request name="set_keymap">
<description summary="set the keymap">
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.
</description>
<arg name="keymap" type="object" interface="river_xkb_keymap_v1"/>
</request>
<request name="set_layout_by_index">
<description summary="set the active layout by index">
Set the active layout for the keyboard's keymap. Has no effect if the
layout index is out of bounds for the current keymap.
</description>
<arg name="index" type="int"/>
</request>
<request name="set_layout_by_name">
<description summary="set the active layout by name">
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.
</description>
<arg name="name" type="string"/>
</request>
<event name="layout">
<description summary="currently active layout">
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.
</description>
<arg name="index" type="uint"/>
<arg name="name" type="string" allow-null="true"/>
</event>
<request name="capslock_enable">
<description summary="enable capslock">
Enable capslock for the keyboard.
</description>
</request>
<request name="capslock_disable">
<description summary="disable capslock">
Disable capslock for the keyboard.
</description>
</request>
<event name="capslock_enabled">
<description summary="capslock is currently enabled">
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.
</description>
</event>
<event name="capslock_disabled">
<description summary="capslock is currently disabled">
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.
</description>
</event>
<request name="numlock_enable">
<description summary="enable numlock">
Enable numlock for the keyboard.
</description>
</request>
<request name="numlock_disable">
<description summary="disable numlock">
Disable numlock for the keyboard.
</description>
</request>
<event name="numlock_enabled">
<description summary="numlock is currently enabled">
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.
</description>
</event>
<event name="numlock_disabled">
<description summary="numlock is currently disabled">
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.
</description>
</event>
</interface>
</protocol>
+115
View File
@@ -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;
}
}
}
+1
View File
@@ -0,0 +1 @@
singleton AttWm 1.0 AttWm.qml
+175
View File
@@ -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
}
}
}
}
}
Executable
+61
View File
@@ -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}"
+755
View File
@@ -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;
+297
View File
@@ -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;
}
};
+461
View File
@@ -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 => {},
}
}
+239
View File
@@ -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,
=> {},
}
}
+1427
View File
File diff suppressed because it is too large Load Diff
+323
View File
@@ -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;
};
+89
View File
@@ -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);
}
+235
View File
@@ -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 },
};
+147
View File
@@ -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 <command> [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 <tag> Show only these tags
\\ toggle-view <tag> Add or remove tags from the view
\\ view-prev Return to the previously viewed tags
\\ tag <tag> Move the focused window to these tags
\\ toggle-tag <tag> Add or remove tags from the focused window
\\
\\ focus next|prev Move focus through the visible windows
\\ focus-window <id> 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 <id> 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 <cmd> [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;
}
+281
View File
@@ -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;
};
+398
View File
@@ -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("\"");
}
+179
View File
@@ -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;
}
}
+73
View File
@@ -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;
}
+149
View File
@@ -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;
}
};
+19
View File
@@ -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 });
}
+204
View File
@@ -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;
}
+425
View File
@@ -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.?);
}