commit d6baf4c8547ed0443d3a6e243dda9a0b96585fcb from: mtmn date: Thu Sep 24 00:09:53 2026 UTC match input patterns, fix crashes, hardening commit - 120ceaf05e0feb30b65124801b12ece290bb9338 commit + d6baf4c8547ed0443d3a6e243dda9a0b96585fcb blob - 98217fe532330103bd4e4bfcf8adbed591b5537e (mode 644) blob + /dev/null --- .builds/alpine.yml +++ /dev/null @@ -1,55 +0,0 @@ -image: alpine/edge -packages: - - eudev-dev - - libinput-dev - - libseat-dev - - mesa-dev - - libffi-dev - - expat-dev - - hwdata-dev - - libdisplay-info-dev - - libxkbcommon-dev - - xcb-util-image-dev - - xcb-util-renderutil-dev - - xcb-util-wm-dev - - pixman-dev - - libevdev-dev - - wayland-dev - - wayland-protocols - - xwayland-dev - - meson - - scdoc - - tar - - wget - - xz -sources: - - https://codeberg.org/river/river-classic - - https://gitlab.freedesktop.org/wlroots/wlroots.git -tasks: - - install_deps: | - cd wlroots - git checkout 0.20.0 - meson setup build --auto-features=enabled -Drenderers=gles2 \ - -Dcolor-management=disabled -Dlibliftoff=disabled \ - -Dexamples=false -Dwerror=false -Db_ndebug=false \ - -Dxcb-errors=disabled --prefix /usr - sudo ninja -C build/ install - cd .. - - # Eat Github's resources rather than the Zig Software Foundation's resources! - wget -nv https://github.com/ifreund/zig-tarball-mirror/releases/download/0.16.0/zig-x86_64-linux-0.16.0.tar.xz - tar xf zig-x86_64-linux-0.16.0.tar.xz - sudo mv zig-x86_64-linux-0.16.0/zig /usr/bin/ - sudo mv zig-x86_64-linux-0.16.0/lib /usr/lib/zig - - build: | - cd river-classic - zig build --summary all - - build_xwayland: | - cd river-classic - zig build --summary all -Dxwayland - - fmt: | - cd river-classic - zig fmt --check river/ - zig fmt --check ponton/ - zig fmt --check riverctl/ - zig fmt --check build.zig blob - d1d74a050aa7ff36900d8179660a9011156c70fb (mode 644) blob + /dev/null --- .builds/archlinux.yml +++ /dev/null @@ -1,54 +0,0 @@ -image: archlinux -packages: - - libdisplay-info - - libevdev - - libinput - - libxkbcommon - - mesa - - meson - - pixman - - wayland - - wayland-protocols - - hwdata - - xcb-util-errors - - xcb-util-image - - xcb-util-renderutil - - xcb-util-wm - - xorg-xwayland - - seatd - - tar - - wget - - xz -sources: - - https://codeberg.org/river/river-classic - - https://gitlab.freedesktop.org/wlroots/wlroots.git -tasks: - - install_deps: | - cd wlroots - git checkout 0.20.0 - meson setup build --auto-features=enabled -Drenderers=gles2 \ - -Dcolor-management=disabled -Dlibliftoff=disabled \ - -Dexamples=false -Dwerror=false -Db_ndebug=false \ - -Dxcb-errors=disabled --prefix /usr - sudo ninja -C build/ install - cd .. - - # Eat Github's resources rather than the Zig Software Foundation's resources! - wget -nv https://github.com/ifreund/zig-tarball-mirror/releases/download/0.16.0/zig-x86_64-linux-0.16.0.tar.xz - tar xf zig-x86_64-linux-0.16.0.tar.xz - sudo mv zig-x86_64-linux-0.16.0/zig /usr/bin/ - sudo mv zig-x86_64-linux-0.16.0/lib /usr/lib/zig - - build: | - cd river-classic - # Arch's glibc version has SFrame sections in crt objects, force llvm/lld usage as a workaround - # https://codeberg.org/ziglang/zig/issues/31272 - zig build -Dllvm --summary all - - build_xwayland: | - cd river-classic - zig build -Dllvm --summary all -Dxwayland - - fmt: | - cd river-classic - zig fmt --check river/ - zig fmt --check ponton/ - zig fmt --check riverctl/ - zig fmt --check build.zig blob - fb925e13e54dfe2e037b89e9619aeeb631dc9964 (mode 644) blob + /dev/null --- .builds/freebsd.yml +++ /dev/null @@ -1,59 +0,0 @@ -image: freebsd/latest -packages: - - devel/evdev-proto - - devel/libevdev - - devel/libepoll-shim - - devel/libudev-devd - - devel/meson - - devel/pkgconf - - graphics/mesa-libs - - graphics/wayland - - graphics/wayland-protocols - - misc/hwdata - - x11/libX11 - - x11/libinput - - x11/libxcb - - x11/libxkbcommon - - x11/pixman - - x11/xcb-util-errors - - x11/xcb-util-renderutil - - x11/xcb-util-wm - - x11-servers/xwayland - - security/ca_root_nss - - sysutils/seatd - - sysutils/libdisplay-info - - gmake - - scdoc - - wget -sources: - - https://codeberg.org/river/river-classic - - https://gitlab.freedesktop.org/wlroots/wlroots.git -tasks: - - install_deps: | - cd wlroots - git checkout 0.20.0 - meson setup build --auto-features=enabled -Drenderers=gles2 \ - -Dallocators=gbm \ - -Dcolor-management=disabled -Dlibliftoff=disabled \ - -Dexamples=false -Dwerror=false -Db_ndebug=false \ - -Dxcb-errors=disabled --prefix /usr - sudo ninja -C build/ install - cd .. - - # Eat Github's resources rather than the Zig Software Foundation's resources! - wget -nv https://github.com/ifreund/zig-tarball-mirror/releases/download/0.16.0/zig-x86_64-freebsd-0.16.0.tar.xz - tar xf zig-x86_64-freebsd-0.16.0.tar.xz - sudo mv zig-x86_64-freebsd-0.16.0/zig /usr/bin/ - sudo mv zig-x86_64-freebsd-0.16.0/lib /usr/lib/zig - - build: | - cd river-classic - zig build --summary all - - build_xwayland: | - cd river-classic - zig build --summary all -Dxwayland - - fmt: | - cd river-classic - zig fmt --check river/ - zig fmt --check ponton/ - zig fmt --check riverctl/ - zig fmt --check build.zig blob - 90cc5f1e2048aacbb7849aa88138588a0a9a4abd (mode 644) blob + /dev/null --- .editorconfig +++ /dev/null @@ -1,23 +0,0 @@ -root = true - -[*] -charset = utf-8 -end_of_line = lf -insert_final_newline = true -trim_trailing_whitespace = true - -[*.zig] -indent_style = space -indent_size = 4 - -[*.{xml,yml}] -indent_style = space -indent_size = 2 - -[*.scd] -indent_style = tab -indent_size = 4 - -[completions/bash/*] -indent_style = tab -indent_size = 4 blob - 43fc383b4480f359cbd2b155481b6d6360f64e7e (mode 644) blob + /dev/null --- .gitea/issue_template/bug.yml +++ /dev/null @@ -1,55 +0,0 @@ -name: Bug Report -description: File a bug report -labels: ["bug"] -body: - - type: markdown - attributes: - value: | - Please provide as many details as possible, we must be able to - understand the bug in order to fix it. - - Don't forget to search the issue tracker in case there is - already an open issue for the bug you found. - - type: input - id: version - attributes: - label: river Version - description: "The output of `river -version`" - placeholder: "0.3.0-dev.293+b77b42f" - validations: - required: true - - type: textarea - id: repro - attributes: - label: Description of Bug and Steps to Reproduce - description: Exactly what steps can someone else take to see the bug themselves? What happens? - validations: - required: true - - type: markdown - attributes: - value: | - Please provide as many details as possible, we must be able to - understand the bug in order to fix it. - - Use a debug build of river if possible to get a better quality - stacktrace in the case of a crash: - ``` - zig build -Doptimize=Debug - ``` - - Set the log level to debug and redirect river's stderr to a file: - ``` - river -log-level debug 2> river.log - ``` - Reproduce your problem and exit river. - - If the issue is with a specific Wayland client (e.g. firefox) a log of the - Wayland protocol messages between the client and river is often very helpful. - Obtain one by setting WAYLAND_DEBUG=1 and redirecting stderr to a file. - ``` - WAYLAND_DEBUG=1 firefox 2> firefox.log - ``` - - type: textarea - id: logs - attributes: - label: Relevant logs, Stacktraces, etc. blob - c3a6568115cc0da46736c3c575035e679582c067 (mode 644) blob + /dev/null --- .gitea/issue_template/config.yml +++ /dev/null @@ -1,5 +0,0 @@ -blank_issues_enabled: true -contact_links: - - name: IRC - url: https://web.libera.chat/?channels=#river - about: Join the IRC channel for river-related discussion and support blob - 9bdc49aac0a74371ed3756848385c89b985b6477 (mode 644) blob + /dev/null --- .mailmap +++ /dev/null @@ -1,3 +0,0 @@ -Isaac Freund -Hugo Machet -Hugo Machet blob - 11d5b10534b3ceb16e234404a33d153b09c3ccd7 (mode 644) blob + /dev/null --- ARCHITECTURE.md +++ /dev/null @@ -1,45 +0,0 @@ - - -# Architecture - -This is a high level overview of the structure of river's code base. - -See also the [doc/internal](doc/internal) directory for in-depth documentation -of complex corners of the code base. If you are struggling to understand some -part of river's code and feel like it should have documentation in that -directory, feel free to open an issue. - -## Window Management State Machine - -At the heart of river lies the window management state machine specified in the -[river-window-management-v1] protocol and described in an [introductory blog -post] and [FOSDEM talk]. If you have not yet familiarized yourself with the -state machine, that should be your first step. - -In river's code base the state machine is implemented in `WindowManager`. The -three core objects in the state machine are `Window`, `Output`, and `Seat`. -Each of these files contain `manageStart()`, `manageFinish()`, `renderStart()`, -and `renderFinish()` functions which progress the state machine, with the top -level WindowManager.zig calling into the subordinate objects. - -## Input - -The top level code handling input (e.g. from keyboard or pointer hardware) is -located in `InputManager`. `InputDevice` represents a single hardware or virtual -input source. Each `InputDevice` is assigned to exactly one `Seat`, which is -exposed to Wayland clients and the window management state machine. - -## Output - -The top level code handling output (i.e. compositing and displaying buffers on -your monitor) is located in `OutputManager`. Individual output devices are -represented by the `Output` type. For frame perfection, all changes to rendered -state are double-buffered and synchronized with the window management state -machine. - -[river-window-management-v1]: https://isaacfreund.com/docs/wayland/river-window-management-v1/ -[introductory blog post]: https://isaacfreund.com/blog/river-window-management/ -[FOSDEM talk]: https://fosdem.org/2026/schedule/event/GR8BFE-separating_the_wayland_compositor_and_window_manager/ blob - fd88526c8fa1369bba08588e2968ddf6a6286ddb (mode 644) blob + /dev/null --- CODE_OF_CONDUCT.md +++ /dev/null @@ -1,83 +0,0 @@ -# River Code of Conduct - -## Our Pledge - -We as members, contributors, and leaders pledge to make participation in our -community a harassment-free experience for everyone, regardless of age, body -size, visible or invisible disability, ethnicity, sex characteristics, gender -identity and expression, level of experience, education, socio-economic status, -nationality, personal appearance, race, caste, color, religion, or sexual -identity and orientation. - -We pledge to act and interact in ways that contribute to an open, welcoming, -diverse, inclusive, and healthy community. - -## Our Standards - -Examples of behavior that contributes to a positive environment for our -community include: - -* Demonstrating empathy and kindness toward other people -* Being respectful of differing opinions, viewpoints, and experiences -* Giving and gracefully accepting constructive feedback -* Accepting responsibility and apologizing to those affected by our mistakes, - and learning from the experience -* Focusing on what is best not just for us as individuals, but for the overall - community - -Examples of unacceptable behavior include: - -* The use of sexualized language or imagery, and sexual attention or advances of - any kind -* Trolling, insulting or derogatory comments, and personal or political attacks -* Public or private harassment -* Publishing others' private information, such as a physical or email address, - without their explicit permission -* Other conduct which could reasonably be considered inappropriate in a - professional setting - -## Enforcement Responsibilities - -Community leaders are responsible for clarifying and enforcing our standards of -acceptable behavior and will take appropriate and fair corrective action in -response to any behavior that they deem inappropriate, threatening, offensive, -or harmful. - -Community leaders have the right and responsibility to remove, edit, or reject -comments, commits, code, wiki edits, issues, and other contributions that are -not aligned to this Code of Conduct, and will communicate reasons for moderation -decisions when appropriate. - -## Scope - -Participants in the river community are expected to uphold the described -standards not only in official community spaces (issue trackers, IRC channels, -etc.) but in all public spaces. The Code of Conduct however does acknowledge -that people are fallible and that it is possible to truely correct a past -pattern of unacceptable behavior. That is to say, the scope of the Code of -Conduct does not necessarily extend into the distant past. - -## Enforcement - -Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported to the community leaders responsible for enforcement at -[mail@isaacfreund.com](mailto:mail@isaacfreund.com). -All complaints will be reviewed and investigated promptly and fairly. - -All community leaders are obligated to respect the privacy and security of the -reporter of any incident. - -The consequences for Code of Conduct violations will be decided upon and -enforced by community leaders. These may include a formal warning, a temporary -ban from community spaces, a permanent ban from community spaces, etc. - -There are no hard and fast rules for exactly what behavior in which space will -result in what consequences, it is up to the community leaders to enforce the -Code of Conduct in a way that they believe best promotes a healthy community. - -## Attribution - -This Code of Conduct is adapted from the -[Contributor Covenant](https://www.contributor-covenant.org/), -version 2.1, available at -https://www.contributor-covenant.org/version/2/1/code_of_conduct.html. blob - cbabc5794c8ce2851d00273dc781edbbc6fd1f8e (mode 644) blob + /dev/null --- CONTRIBUTING.md +++ /dev/null @@ -1,22 +0,0 @@ -## Contributing to river-classic - -Open a pull request on Codeberg after discussing substantial changes with the -river developers. - -## Commit messages - -Commit messages should start with the affected area followed by a short summary. -For example: - -```text -protocol: add window management support -classic: handle window dimensions -``` - -## Coding style - -Follow Zig's style guide. Run `zig fmt` before every commit. Keep lines below -100 columns where practical. Use braces for multi-line conditionals. - -Keep compositor code policy-free. Window manager policy belongs in `classic/`. -Do not add old control, status or layout protocols back to the compositor. blob - 66ad60f93de8bb3806aaf8774c7692118ccf3d4d blob + 6d67ee0780c07b3536202775bb3c0f20d30acf15 --- PACKAGING.md +++ PACKAGING.md @@ -1,184 +1,33 @@ -# Packaging river-classic for distribution +# Packaging river and laguna -First of all, I apologize for writing river-classic in Zig. It will likely make -your job harder until Zig is more mature/stable. I do however believe that -writing my software in Zig allows me to deliver the best quality I can despite -the drawbacks of depending on a relatively immature language/toolchain. +This repository contains laguna, a river window manager client. +Install upstream river separately. -## Source tarballs +## Dependencies -Source tarballs with stable checksums may be found on the -[codeberg releases page](https://codeberg.org/river/river-classic/releases). -These tarballs are signed with the PGP key available on my website at -. +Build requires Zig 0.16, wayland, wayland-protocols, xkbcommon 1.12 or +newer, pkg-config, and scdoc for man pages. -For the 0.3.12 release for example, the tarball and signature URLs are: -``` -https://codeberg.org/river/river-classic/releases/download/v0.1.3/river-classic-0.3.12.tar.gz -https://codeberg.org/river/river-classic/releases/download/v0.1.3/river-classic-0.3.12.tar.gz.sig -``` +The exact Zig package versions are pinned in `build.zig.zon`. -## Zig version +## Building -Until Zig 1.0, Zig releases will often have breaking changes that prevent -river-classic from building. river-classic tracks the latest minor version Zig -release and is only compatible with that release and any patch releases. At the -time of writing for example river is compatible with Zig 0.9.0 and 0.9.1 but not -Zig 0.8.0 or 0.10.0. - -## Zig Package Manager - -river-classic uses the built-in Zig package manager for its (few) Zig -dependencies. By default, running `zig build` will fetch river-classic's Zig -dependencies from the internet and store them in the global zig cache before -building river-classic. Since accessing the internet is forbidden or at least -frowned upon by most distro packaging infrastructure, there are ways to fetch -the Zig dependencies in a separate step before building river-classic: - -1. Fetch step with internet access: - - For each package in the `build.zig.zon` manifest file run the following command - with the tarball URL in the `build.zig.zon`: - - ``` - zig fetch --global-cache-dir /tmp/foobar $URL - ``` - - This command will download and unpack the tarball, hash the contents of the - tarball, and store the contents in the `/tmp/foobar/p/$HASH` directory. This - hash should match the corresponding hash field in the `build.zig.zon`. - -2. Build step with no internet access: - - The `--system` flag for `zig build` takes a path to an arbitrary directory in - which zig packages stored in subdirectories matching their hash can be found. - - ``` - zig build --system /tmp/foobar/p/ ... - ``` - - This flag will disable all internet access and error if a package is not found - in the provided directory. - -It is also possible for distros to distribute Zig package manager packages as -distro packages, although there are still some rough edges as the support for -this is not yet mature. See this patchset for Chimera Linux for an example of -how this can work: https://github.com/chimera-linux/cports/pull/1395 - -## Build options - -River is built using the Zig build system. To see all available build -options run `zig build --help`. - -Important: By default Zig will build for the host system/cpu using the -equivalent of `-march=native`. To produce a portable binary `-Dcpu=baseline` -at a minimum must be passed. - -Here are a few other options that are particularly relevant to packagers: - -- `-Dcpu=baseline`: Build for the "baseline" CPU of the target architecture, -or any other CPU/feature set (e.g. `-Dcpu=x86_64_v2`). - - - Individual features can be added/removed with `+`/`-` - (e.g. `-Dcpu=x86_64+avx2-cmov`). - - - For a list of CPUs see for example `zig targets | jq '.cpus.x86_64 | - keys'`. - - - For a list of features see for example `zig targets | jq - '.cpusFeatures.x86_64'`. - -- `-Dtarget=x86_64-linux-gnu`: Target architecture, OS, and ABI triple. See -the output of `zig targets` for an exhaustive list of targets and CPU features, -use of `jq(1)` to inspect the output recommended. - -- `-Dpie`: Build a position independent executable. - -- `-Dstrip`: Build without debug info. This not the same as invoking `strip(1)` -on the resulting binary as it prevents the compiler from emitting debug info -in the first place. For greatest effect, both may be used. - -- `--sysroot /path/to/sysroot`: Set the sysroot for cross compilation. - -- `--libc my_libc.txt`: Set system libc paths for cross compilation. Run -`zig libc` to see a documented template for what this file should contain. - -- Enable compiler optimizations: - - - `-Doptimize=ReleaseSafe`: Optimize for execution speed, - keep all assertions and runtime safety checks active. - - - `-Doptimize=ReleaseFast`: Optimize for execution speed, - disable all assertions and runtime safety checks. - - - `-Doptimize=ReleaseSmall`: Optimize for binary size, - disable all assertions and runtime safety checks. - -Please use `-Doptimize=ReleaseSafe` when building river-classic for general use. -CPU execution speed is not the performance bottleneck for river-classic, the GPU -is. Additionally, the increased safety is more than worth the binary size -trade-off in my opinion. - -## Build prefix and DESTDIR - -To control the build prefix and directory use `--prefix` and the `DESTDIR` -environment variable. For example -```bash -DESTDIR="/foo/bar" zig build --prefix /usr install +```sh +zig build -Doptimize=ReleaseSafe ``` -will install river-classic to `/foo/bar/usr/bin/river`. -The Zig build system only has a single install step, there is no way to build -artifacts for a given prefix and then install those artifacts to that prefix -at some later time. However, much existing distribution packaging tooling -expect separate build and install steps. To fit the Zig build system into this -tooling, I recommend the following pattern: +Use `-Dman-pages=false` when scdoc is unavailable. -```bash -build() { - DESTDIR="/tmp/river-destdir" zig build --prefix /usr install -} +## Installing -install() { - cp -r /tmp/river-destdir/* /desired/install/location -} +```sh +zig build -Doptimize=ReleaseSafe --prefix /usr install ``` -## River specific suggestions +Install the `laguna` binary. Install the example +configuration and generated man pages when packaging distribution files. -I recommend installing the example init file found at `example/init` to -`/usr/share/examples/river-classic/init` or similar if your distribution has such -a convention. +## Runtime -## Examples - -Build for the host architecture and libc ABI: -```bash -DESTDIR=/foo/bar zig build -Doptimize=ReleaseSafe -Dcpu=baseline \ - -Dstrip -Dpie --prefix /usr install -``` - -Cross compile for aarch64 musl libc based linux: -```bash -cat > xbps_zig_libc.txt <<-EOF - include_dir=${XBPS_CROSS_BASE}/usr/include - sys_include_dir=${XBPS_CROSS_BASE}/usr/include - crt_dir=${XBPS_CROSS_BASE}/usr/lib - msvc_lib_dir= - kernel32_lib_dir= - gcc_dir= -EOF - -DESTDIR="/foo/bar" zig build \ - --sysroot "${XBPS_CROSS_BASE}" \ - --libc xbps_zig_libc.txt \ - -Dtarget=aarch64-linux-musl -Dcpu=baseline \ - -Doptimize=ReleaseSafe -Dstrip -Dpie \ - --prefix /usr install -``` - -## Questions? - -If you have any questions feel free to reach out to me at -`mail@isaacfreund.com` or in `#zig` or `#river` on `irc.libera.chat`, my -nick is `ifreund`. +Start `river` from the session startup command. Start `laguna` from +`$XDG_CONFIG_HOME/river/init` or `$HOME/.config/river/init`. blob - f6ae5ef4a5f71ec964c67490c96382c21e45445a blob + 09951de80c8396e6156f2c49334a016a3a669840 --- README.md +++ README.md @@ -1,61 +1,55 @@ -# ponton +# laguna -`ponton` is a dynamic tiling window manager for the non-monolithic `river` -compositor, rewritten from river-classic onto the stable river protocols. -`river` owns Wayland clients, rendering and frame synchronisation. `ponton` -owns tags, focus, keybindings, layout policy and window management decisions. +laguna is a dynamic tiling window manager for the non-monolithic `river` +compositor. It uses upstream river v0.4.8 and stable river protocols only. -This tree builds `ponton` and `riverctl` only. The `river` compositor comes -from upstream river and is installed separately. - -The compositor and window manager communicate through the stable +`river` owns Wayland clients, rendering and frame synchronisation. `laguna` +owns tags, focus, keybindings, layout policy and window management +decisions. The two programs communicate through the stable `river-window-management-v1` protocol. Input configuration uses stable river -input protocols. Old compositor-side control, status and layout-generator -protocols are not part of this tree. Commands without a protocol surface -are documented under NOT SUPPORTED in `riverctl`(1): external layout -generators, compositor background and cursor hiding, lid-switch bindings -and keyboard groups. +input protocols. -## Building +This repository builds `laguna`. Install upstream river separately. -Dependencies: +Features without a protocol surface are not supported: +- external layout generators +- compositor background and cursor hiding +- lid-switch bindings +- keyboard groups + +## Build + +You need: + - Zig 0.16 - wayland - wayland-protocols -- wlroots 0.20 - xkbcommon 1.12 or newer -- libevdev -- libinput -- pixman - pkg-config - scdoc for man pages -Build and install: +Build and install with: ```text zig build -Doptimize=ReleaseSafe --prefix ~/.local install ``` -Pass `-Dxwayland` to enable Xwayland support. +## Run -## Running +Start `river` from your session startup command. Start `laguna` from +`$XDG_CONFIG_HOME/river/init` or `$HOME/.config/river/init`. -Start upstream `river` from your session startup command. Start `ponton` -from `$XDG_CONFIG_HOME/river/init` or `$HOME/.config/river/init`. +## Configure -`ponton` creates a private control socket at: +`laguna` reads its configuration from `$XDG_CONFIG_HOME/river/laguna.zon` +and falls back to `$HOME/.config/river/laguna.zon`. Unknown fields are +rejected, so typos fail loudly at startup. See `example/laguna.zon` for a +full configuration with keybindings, input settings, rules and theme. -```text -$XDG_RUNTIME_DIR/ponton.sock -``` - -`riverctl` sends commands to this socket. It does not bind an old river -Wayland control protocol. - -`ponton` supports protocol lifecycle, tiling layout with adjustable main +`laguna` supports protocol lifecycle, tiling layout with an adjustable main area, tag visibility, focus cycling, keyboard and pointer bindings with modes, interactive move and resize, float and fullscreen state, window rules, input device configuration, keyboard layouts, layer-shell awareness, -floating window ops, multi-output focus and routing, cursor policy, borders, -`close` and `exit`. +floating window operations, multi-output focus and routing, cursor policy, +borders, `close` and `exit`. blob - /dev/null blob + f210d9ae57f31e596872fe6f52f6f5dd14feebd5 (mode 644) --- /dev/null +++ src/Binding.zig @@ -0,0 +1,97 @@ +// SPDX-FileCopyrightText: © 2026 The River Developers +// SPDX-License-Identifier: GPL-3.0-only +// Analyzer does not resolve method-call syntax to free functions. +// Module type aliases use PascalCase per Zig convention. +// zwanzig-disable: identifier-style, unused-decl + +const Binding = @This(); + +const std = @import("std"); +const river = @import("wayland").client.river; +const xkb = @import("xkbcommon"); + +const WindowManager = @import("WindowManager.zig"); + +const gpa = std.heap.c_allocator; + +object: *river.XkbBindingV1, +keysym: u32, +modifiers: river.SeatV1.Modifiers, +command: [:0]const u8, +mode_index: usize = 0, +manager: *WindowManager, +enabled: bool = false, + +pub fn init( + binding: *Binding, + object: *river.XkbBindingV1, + keysym: u32, + modifiers: river.SeatV1.Modifiers, + command: [:0]const u8, + mode_index: usize, + manager: *WindowManager, +) void { + binding.* = .{ + .object = object, + .keysym = keysym, + .modifiers = modifiers, + .command = command, + .mode_index = mode_index, + .manager = manager, + }; + object.setListener(*Binding, handleEvent, binding); +} + +pub fn deinit(binding: *Binding) void { + binding.object.destroy(); + gpa.free(binding.command); +} + +fn handleEvent(_: *river.XkbBindingV1, event: river.XkbBindingV1.Event, binding: *Binding) void { + switch (event) { + .pressed => { + if (binding.lockedOut()) return; + binding.manager.fireBinding(binding.command); + }, + .released, .stop_repeat => {}, + } +} + +/// While the session is locked only bindings from the locked mode fire. +fn lockedOut(binding: *const Binding) bool { + if (!binding.manager.locked) return false; + const mode = binding.manager.modes.items[binding.mode_index]; + return !std.mem.eql(u8, mode.name, "locked"); +} + +pub fn parseModifiers(text: []const u8) error{InvalidModifier}!river.SeatV1.Modifiers { + var modifiers: river.SeatV1.Modifiers = .{}; + var parts = std.mem.splitScalar(u8, text, '+'); + while (parts.next()) |name| { + if (std.mem.eql(u8, name, "None")) continue; + inline for (.{ + .{ "Shift", "shift" }, + .{ "Control", "ctrl" }, + .{ "Alt", "mod1" }, + .{ "Mod1", "mod1" }, + .{ "Mod3", "mod3" }, + .{ "Super", "mod4" }, + .{ "Mod4", "mod4" }, + .{ "Mod5", "mod5" }, + }) |pair| { + if (std.mem.eql(u8, name, pair[0])) { + @field(modifiers, pair[1]) = true; + break; + } + } else return error.InvalidModifier; + } + return modifiers; +} + +pub fn parseKeysym(text: []const u8) error{ InvalidKeysym, OutOfMemory }!u32 { + const name = try gpa.dupeZ(u8, text); + defer gpa.free(name); + const keysym = xkb.Keysym.fromName(name, .case_insensitive); + if (keysym == .NoSymbol) return error.InvalidKeysym; + return @intFromEnum(keysym); +} blob - /dev/null blob + 5a9985cfe1e1a6cfd7813a4ff7268b68a2013716 (mode 644) --- /dev/null +++ src/Command.zig @@ -0,0 +1,115 @@ +// SPDX-FileCopyrightText: © 2026 The River Developers +// SPDX-License-Identifier: GPL-3.0-only + +const Command = @This(); + +const std = @import("std"); + +pub const arguments_max = 32; +pub const size_max = 4096; + +buffer: [size_max]u8 = .{0} ** size_max, +/// End offset of each word inside buffer. Offsets, not pointers, so the +/// struct stays valid after a by-value copy into the pending queue. +ends: [arguments_max]u16 = .{0} ** arguments_max, +count: usize = 0, + +pub const Cursor = struct { + command: *const Command, + index: usize = 0, + start: usize = 0, + + pub fn next(cursor: *Cursor) ?[]const u8 { + if (cursor.index == cursor.command.count) return null; + const end = cursor.command.ends[cursor.index]; + const argument = cursor.command.buffer[cursor.start..end]; + cursor.start = end; + cursor.index += 1; + return argument; + } +}; + +pub fn arguments(command: *const Command) Cursor { + return .{ .command = command }; +} + +test "fromText splits arguments" { + const testing = std.testing; + var command: Command = undefined; + try command.fromText("map normal Super Q close"); + try testing.expectEqual(@as(usize, 5), command.count); + var cursor = command.arguments(); + try testing.expectEqualStrings("map", cursor.next().?); + try testing.expectEqualStrings("normal", cursor.next().?); + try testing.expectEqualStrings("Super", cursor.next().?); + try testing.expectEqualStrings("Q", cursor.next().?); + try testing.expectEqualStrings("close", cursor.next().?); + try testing.expectEqual(@as(?[]const u8, null), cursor.next()); +} + +test "fromSlices stores arguments" { + const testing = std.testing; + var command: Command = undefined; + try command.fromSlices(&.{ "map", "normal", "Super", "Q", "close" }); + try testing.expectEqual(@as(usize, 5), command.count); + var cursor = command.arguments(); + try testing.expectEqualStrings("map", cursor.next().?); + try testing.expectEqualStrings("normal", cursor.next().?); + try testing.expectEqualStrings("Super", cursor.next().?); + try testing.expectEqualStrings("Q", cursor.next().?); + try testing.expectEqualStrings("close", cursor.next().?); + try testing.expectEqual(@as(?[]const u8, null), cursor.next()); +} + +test "command survives a by-value copy" { + const testing = std.testing; + var command: Command = undefined; + try command.fromSlices(&.{ "map", "normal", "Super", "Q", "close" }); + const copied = command; + var cursor = copied.arguments(); + try testing.expectEqualStrings("map", cursor.next().?); + try testing.expectEqualStrings("normal", cursor.next().?); + try testing.expectEqualStrings("Super", cursor.next().?); + try testing.expectEqualStrings("Q", cursor.next().?); + try testing.expectEqualStrings("close", cursor.next().?); + try testing.expectEqual(@as(?[]const u8, null), cursor.next()); +} + +/// Split text on spaces into an owned command. Used to run bound +/// commands, which may be shell spawns or window-manager commands. +pub fn fromText( + command: *Command, + text: []const u8, +) error{ CommandTooLong, TooManyArguments }!void { + command.* = .{}; + if (text.len > size_max) return error.CommandTooLong; + var used: usize = 0; + var parts = std.mem.splitScalar(u8, text, ' '); + while (parts.next()) |part| { + if (part.len == 0) continue; + if (command.count == arguments_max) return error.TooManyArguments; + if (part.len > size_max - used) return error.CommandTooLong; + @memcpy(command.buffer[used..][0..part.len], part); + used += part.len; + command.ends[command.count] = @intCast(used); + command.count += 1; + } +} + +/// Store argument slices in the command buffer. Used to build commands +/// programmatically, for example from the static config file. +pub fn fromSlices( + command: *Command, + argument_list: []const []const u8, +) error{ CommandTooLong, TooManyArguments }!void { + command.* = .{}; + if (argument_list.len > arguments_max) return error.TooManyArguments; + var used: usize = 0; + for (argument_list) |argument| { + if (argument.len > size_max - used) return error.CommandTooLong; + @memcpy(command.buffer[used..][0..argument.len], argument); + used += argument.len; + command.ends[command.count] = @intCast(used); + command.count += 1; + } +} blob - /dev/null blob + 564c4c051d55937cf9340af781defc46b19efc65 (mode 644) --- /dev/null +++ src/Config.zig @@ -0,0 +1,182 @@ +// SPDX-FileCopyrightText: © 2026 The River Developers +// SPDX-License-Identifier: GPL-3.0-only + +const Config = @This(); + +const std = @import("std"); + +pub const XcursorTheme = struct { + name: []const u8, + size: u32 = 24, +}; + +pub const Repeat = struct { + rate: i32, + delay: i32, +}; + +pub const KeyboardLayout = struct { + layout: []const u8, + rules: ?[]const u8 = null, + model: ?[]const u8 = null, + variant: ?[]const u8 = null, + options: ?[]const u8 = null, +}; + +pub const Binding = struct { + keysym: []const u8, + command: []const u8, +}; + +pub const BindingGroup = struct { + modifiers: []const []const u8, + bindings: []const Binding = &.{}, +}; + +pub const PointerBinding = struct { + button: []const u8, + action: []const u8, +}; + +pub const PointerBindingGroup = struct { + modifiers: []const []const u8, + bindings: []const PointerBinding = &.{}, +}; + +pub const Mode = struct { + name: []const u8, + bindings: []const BindingGroup = &.{}, + pointer_bindings: []const PointerBindingGroup = &.{}, +}; + +pub const InputRule = struct { + pattern: []const u8, + category: ?[]const u8 = null, + setting: []const u8, + value: ?[]const u8 = null, +}; + +pub const Rule = struct { + app_id: ?[]const u8 = null, + title: ?[]const u8 = null, + action: []const u8, + tags: ?u32 = null, +}; + +main_ratio: ?f32 = null, +main_count: ?u32 = null, +main_location: ?[]const u8 = null, +attach_mode: ?[]const u8 = null, +output_attach_mode: ?[]const u8 = null, +spawn_tagmask: ?u32 = null, +border_width: ?i32 = null, +border_color_focused: ?[]const u8 = null, +border_color_unfocused: ?[]const u8 = null, +focus_follows_cursor: ?[]const u8 = null, +cursor_warp: ?[]const u8 = null, +xcursor_theme: ?XcursorTheme = null, +repeat: ?Repeat = null, +keyboard_layout: ?KeyboardLayout = null, +keyboard_layout_file: ?[]const u8 = null, +modes: []const Mode = &.{}, +inputs: []const InputRule = &.{}, +rules: []const Rule = &.{}, + +/// Parse config source. Unknown fields are rejected so typos fail loudly. +/// Diagnostics are left to the caller so tests stay silent on purpose. +/// The returned value borrows from both source and allocator-owned memory. +/// The source must be a ZON document. +pub fn parse( + allocator: std.mem.Allocator, + source: [:0]const u8, + diagnostics: ?*std.zon.parse.Diagnostics, +) !Config { + @setEvalBranchQuota(10000); + return std.zon.parse.fromSliceAlloc(Config, allocator, source, diagnostics, .{}); +} + +/// Default config path: $XDG_CONFIG_HOME/river/laguna.zon, falling back to +/// $HOME/.config/river/laguna.zon. Returns null when neither variable exists. +pub fn defaultPath(allocator: std.mem.Allocator) !?[]const u8 { + if (std.c.getenv("XDG_CONFIG_HOME")) |home| { + return try std.fs.path.join(allocator, &.{ std.mem.sliceTo(home, 0), "river/laguna.zon" }); + } + if (std.c.getenv("HOME")) |home| { + return try std.fs.path.join(allocator, &.{ std.mem.sliceTo(home, 0), ".config/river/laguna.zon" }); + } + return null; +} + +test "parses a representative config" { + const testing = std.testing; + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const source = + \\.{ + \\ .main_ratio = 0.5, + \\ .border_width = 1, + \\ .xcursor_theme = .{ .name = "McMojave", .size = 16 }, + \\ .modes = .{ + \\ .{ .name = "normal", .bindings = .{ + \\ .{ .modifiers = .{ "Mod1+Shift" }, .bindings = .{ + \\ .{ .keysym = "Return", .command = "spawn foot" }, + \\ } }, + \\ } }, + \\ .{ .name = "locked" }, + \\ }, + \\ .inputs = .{ + \\ .{ .pattern = "*", .category = "touchpad", .setting = "tap", .value = "disable" }, + \\ }, + \\ .rules = .{ + \\ .{ .app_id = "float*", .action = "float" }, + \\ }, + \\} + ; + const owned = try arena.allocator().dupeZ(u8, source); + const config = try parse(arena.allocator(), owned, null); + try testing.expectEqual(@as(?f32, 0.5), config.main_ratio); + try testing.expectEqual(@as(?i32, 1), config.border_width); + try testing.expectEqualStrings("McMojave", config.xcursor_theme.?.name); + try testing.expectEqual(@as(usize, 2), config.modes.len); + try testing.expectEqualStrings("normal", config.modes[0].name); + try testing.expectEqualStrings("spawn foot", config.modes[0].bindings[0].bindings[0].command); + try testing.expectEqualStrings("touchpad", config.inputs[0].category.?); + try testing.expectEqual(@as(?u32, null), config.rules[0].tags); +} + +test "shipped example parses" { + const testing = std.testing; + const io = std.Io.Threaded.global_single_threaded.io(); + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const source = std.Io.Dir.cwd().readFileAlloc(io, "example/laguna.zon", arena.allocator(), .limited(1024 * 1024)) catch |err| { + if (err == error.FileNotFound) return error.SkipZigTest; + return err; + }; + const owned = try arena.allocator().dupeZ(u8, source); + const config = try parse(arena.allocator(), owned, null); + var binding_count: usize = 0; + for (config.modes) |mode| { + for (mode.bindings) |group| { + binding_count += group.bindings.len * group.modifiers.len; + } + } + try testing.expectEqual(@as(usize, 130), binding_count); + try testing.expectEqual(@as(usize, 1), config.modes.len); + try testing.expectEqual(@as(usize, 7), config.inputs.len); + try testing.expectEqual(@as(?f32, 0.5), config.main_ratio); +} + +test "defaultPath uses laguna zon" { + const allocator = std.testing.allocator; + const path = try defaultPath(allocator); + defer allocator.free(path.?); + try std.testing.expect(std.mem.endsWith(u8, path.?, "/river/laguna.zon")); +} +test "rejects unknown fields" { + const testing = std.testing; + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const owned = try arena.allocator().dupeZ(u8, ".{ .main_ration = 0.5, }"); + try testing.expectError(error.ParseZon, parse(arena.allocator(), owned, null)); +} blob - /dev/null blob + c2efa7f5b279c4630e5faad976f9a805a9fa4c44 (mode 644) --- /dev/null +++ src/Input.zig @@ -0,0 +1,692 @@ +// SPDX-FileCopyrightText: © 2026 The River Developers +// SPDX-License-Identifier: GPL-3.0-only +// Analyzer does not resolve method-call syntax to free functions. +// Module type aliases use PascalCase per Zig convention. +// zwanzig-disable: identifier-style, unused-decl + +const Input = @This(); + +const std = @import("std"); +const river = @import("wayland").client.river; +const xkb = @import("xkbcommon"); + +const PointerBinding = @import("PointerBinding.zig"); +const Rule = @import("Rule.zig"); + +const gpa = std.heap.c_allocator; +const log = std.log.scoped(.laguna); + +/// libwayland-client exports this, but zig-wayland 0.6.0 does not wrap it. +/// The generated dispatcher passes the proxy user data to the Zig listener +/// on every event, so updating it retargets events between device records. +extern fn wl_proxy_set_user_data(proxy: *anyopaque, user_data: ?*anyopaque) void; + +pub const Device = struct { + owner: *Input, + input: ?*river.InputDeviceV1 = null, + libinput: ?*river.LibinputDeviceV1 = null, + keyboard: ?*river.XkbKeyboardV1 = null, + kind: ?river.InputDeviceV1.Type = null, + name: ?[]const u8 = null, +}; + +pub const StoredConfig = struct { + pattern: []const u8, + category: ?[]const u8, + setting: []const u8, + value: ?[]const u8, +}; + +input_manager: ?*river.InputManagerV1 = null, +libinput_config: ?*river.LibinputConfigV1 = null, +xkb_config: ?*river.XkbConfigV1 = null, +devices: std.ArrayListUnmanaged(*Device) = .empty, +keymap: ?*river.XkbKeymapV1 = null, +repeat_rate: ?i32 = null, +repeat_delay: ?i32 = null, +stored_configs: std.ArrayListUnmanaged(StoredConfig) = .empty, + +pub fn deinit(input: *Input) void { + for (input.devices.items) |device| { + destroyDevice(device); + gpa.destroy(device); + } + for (input.stored_configs.items) |stored| { + gpa.free(stored.pattern); + if (stored.category) |category| gpa.free(category); + gpa.free(stored.setting); + if (stored.value) |value| gpa.free(value); + } + input.stored_configs.deinit(gpa); + if (input.keymap) |keymap| keymap.destroy(); + if (input.input_manager) |object| object.destroy(); + if (input.libinput_config) |object| object.destroy(); + if (input.xkb_config) |object| object.destroy(); + input.input_manager = null; + input.libinput_config = null; + input.xkb_config = null; + input.keymap = null; + input.devices.deinit(gpa); +} + +pub fn setGlobals( + input: *Input, + input_manager: ?*river.InputManagerV1, + libinput_config: ?*river.LibinputConfigV1, + xkb_config: ?*river.XkbConfigV1, +) void { + input.input_manager = input_manager; + input.libinput_config = libinput_config; + input.xkb_config = xkb_config; + if (input_manager) |object| object.setListener(*Input, handleManagerEvent, input); + if (libinput_config) |object| object.setListener(*Input, handleConfigEvent, input); + if (xkb_config) |object| object.setListener(*Input, handleXkbConfigEvent, input); +} + +fn handleManagerEvent( + _: *river.InputManagerV1, + event: river.InputManagerV1.Event, + input: *Input, +) void { + switch (event) { + .finished => {}, + .input_device => |created| input.addInputDevice(created.id), + } +} + +fn handleConfigEvent( + _: *river.LibinputConfigV1, + event: river.LibinputConfigV1.Event, + input: *Input, +) void { + switch (event) { + .finished => {}, + .libinput_device => |created| input.addLibinputDevice(created.id), + } +} + +fn handleXkbConfigEvent(_: *river.XkbConfigV1, event: river.XkbConfigV1.Event, input: *Input) void { + switch (event) { + .finished => {}, + .xkb_keyboard => |created| input.addKeyboard(created.id), + } +} + +fn blank(input: *Input) *Device { + const device = gpa.create(Device) catch std.process.fatal("out of memory", .{}); + device.* = .{ .owner = input }; + input.devices.append(gpa, device) catch std.process.fatal("out of memory", .{}); + return device; +} + +fn addInputDevice(input: *Input, object: *river.InputDeviceV1) void { + // A pairing event can adopt this object before the manager announces + // it. The placeholder already carries the listener, so only stored + // configs need to be applied. + for (input.devices.items) |device| { + if (device.input != object) continue; + input.applyStoredConfigs(); + return; + } + const device = input.blank(); + device.input = object; + object.setListener(*Device, handleInputEvent, device); + input.applyStoredConfigs(); +} + +fn addLibinputDevice(input: *Input, object: *river.LibinputDeviceV1) void { + const device = input.blank(); + device.libinput = object; + object.setListener(*Device, handleLibinputEvent, device); + input.applyStoredConfigs(); +} + +fn addKeyboard(input: *Input, object: *river.XkbKeyboardV1) void { + const device = input.blank(); + device.keyboard = object; + object.setListener(*Device, handleKeyboardEvent, device); + if (input.keymap) |keymap| object.setKeymap(keymap); + input.applyStoredConfigs(); +} + +fn handleInputEvent( + _: *river.InputDeviceV1, + event: river.InputDeviceV1.Event, + device: *Device, +) void { + switch (event) { + .removed => { + if (device.input) |object| object.destroy(); + device.input = null; + reclaimIfRemoved(device); + }, + .type => |typed| { + device.kind = typed.type; + // Keyboards arriving after set-repeat still get the stored rate. + if (typed.type == .keyboard) { + if (device.owner.repeat_rate) |rate| { + if (device.owner.repeat_delay) |delay| { + if (device.input) |object| object.setRepeatInfo(rate, delay); + } + } + } + }, + .name => |named| { + if (device.name) |old| gpa.free(old); + device.name = gpa.dupe(u8, std.mem.sliceTo(named.name, 0)) catch |err| blk: { + log.warn("failed to store input device name: {s}", .{@errorName(err)}); + break :blk null; + }; + if (device.name) |name| log.info("input device: {s}", .{name}); + }, + .done => {}, + } +} + +fn handleLibinputEvent( + _: *river.LibinputDeviceV1, + event: river.LibinputDeviceV1.Event, + device: *Device, +) void { + switch (event) { + .removed => { + if (device.libinput) |object| object.destroy(); + device.libinput = null; + reclaimIfRemoved(device); + }, + .input_device => |paired| merge(device, paired.device), + else => {}, + } +} + +fn handleKeyboardEvent( + _: *river.XkbKeyboardV1, + event: river.XkbKeyboardV1.Event, + device: *Device, +) void { + switch (event) { + .removed => { + if (device.keyboard) |object| object.destroy(); + device.keyboard = null; + reclaimIfRemoved(device); + }, + .input_device => |paired| merge(device, paired.device), + else => {}, + } +} + +fn handleResult(_: *river.LibinputResultV1, event: river.LibinputResultV1.Event, _: ?*anyopaque) void { + switch (event) { + .success => {}, + .unsupported => log.warn("input setting unsupported by device", .{}), + .invalid => log.warn("input setting invalid for device", .{}), + } +} + +/// Merge a libinput or keyboard placeholder into the device owning the +/// matching input object. Placeholders exist because the pairing event may +/// arrive before or after the input device event. +fn merge(device: *Device, input_object: ?*river.InputDeviceV1) void { + const target = input_object orelse return; + if (device.input == target) return; + // reclaimIfRemoved may free the placeholder, so read owner first. + const owner = device.owner; + for (owner.devices.items) |other| { + if (other == device or other.input != target) continue; + if (device.libinput) |libinput| { + other.libinput = libinput; + device.libinput = null; + // The proxy keeps dispatching after the move. Point its user + // data at the receiving device or the next event reaches the + // placeholder after it is freed. + wl_proxy_set_user_data(@ptrCast(libinput), other); + } + if (device.keyboard) |keyboard| { + other.keyboard = keyboard; + device.keyboard = null; + wl_proxy_set_user_data(@ptrCast(keyboard), other); + } + // The placeholder now holds nothing, so reclaim it instead of + // leaving an empty entry in the device list. + reclaimIfRemoved(device); + owner.applyStoredConfigs(); + return; + } + device.input = target; + // Adopted before the input manager announced the object, so no + // listener exists yet. Attach one now; addInputDevice later finds + // this placeholder and must not add a second listener. + target.setListener(*Device, handleInputEvent, device); + owner.applyStoredConfigs(); +} + +fn reclaimIfRemoved(device: *Device) void { + if (device.input != null or device.libinput != null or device.keyboard != null) return; + const input = device.owner; + for (input.devices.items, 0..) |candidate, index| { + if (candidate != device) continue; + _ = input.devices.swapRemove(index); + destroyDevice(device); + gpa.destroy(device); + return; + } +} + +fn destroyDevice(device: *Device) void { + if (device.input) |object| object.destroy(); + if (device.libinput) |object| object.destroy(); + if (device.keyboard) |object| object.destroy(); + if (device.name) |name| gpa.free(name); + device.input = null; + device.libinput = null; + device.keyboard = null; + device.name = null; +} + +pub fn setRepeat(input: *Input, rate: i32, delay: i32) void { + input.repeat_rate = rate; + input.repeat_delay = delay; + for (input.devices.items) |device| { + const object = device.input orelse continue; + if (device.kind != .keyboard) continue; + object.setRepeatInfo(rate, delay); + } +} + +/// Remember an input setting so devices arriving later receive it too. +/// Strings are duplicated; the input owns them until deinit. +pub fn storeConfig( + input: *Input, + pattern: []const u8, + category: ?[]const u8, + setting: []const u8, + value: ?[]const u8, +) error{OutOfMemory}!void { + const owned_pattern = try gpa.dupe(u8, pattern); + errdefer gpa.free(owned_pattern); + const owned_category = if (category) |text| try gpa.dupe(u8, text) else null; + errdefer if (owned_category) |text| gpa.free(text); + const owned_setting = try gpa.dupe(u8, setting); + errdefer gpa.free(owned_setting); + const owned_value = if (value) |text| try gpa.dupe(u8, text) else null; + errdefer if (owned_value) |text| gpa.free(text); + try input.stored_configs.append(gpa, .{ + .pattern = owned_pattern, + .category = owned_category, + .setting = owned_setting, + .value = owned_value, + }); + input.applyStoredConfigs(); +} + +/// Reapply every stored input setting to currently known devices. +/// Runs after topology changes so late devices converge on the same state. +fn applyStoredConfigs(input: *Input) void { + var matches: std.ArrayListUnmanaged(*Device) = .empty; + defer matches.deinit(gpa); + for (input.stored_configs.items) |stored| { + matches.clearRetainingCapacity(); + input.matchAll(stored.pattern, stored.category, &matches); + for (matches.items) |device| { + Input.configure(device, stored.setting, stored.value) catch |err| { + const name = device.name orelse "(unnamed)"; + log.warn("input setting failed for {s}: {s}", .{ name, @errorName(err) }); + }; + } + } +} + +/// Match devices by glob over the device name, with an optional category +/// filter such as touchpad, pointer, keyboard or tablet. +pub fn matchAll( + input: *Input, + pattern: []const u8, + category: ?[]const u8, + matches: *std.ArrayListUnmanaged(*Device), +) void { + Rule.validate(pattern) catch |err| { + log.debug("skipping device match with invalid pattern: {s}", .{@errorName(err)}); + return; + }; + for (input.devices.items) |device| { + if (device.input == null) continue; + const name = device.name orelse continue; + if (!Rule.match(name, pattern)) continue; + if (category) |wanted| { + if (!matchCategory(device, name, wanted)) continue; + } + matches.append(gpa, device) catch |err| { + log.warn("failed to collect matching input device: {s}", .{@errorName(err)}); + continue; + }; + } +} + +fn matchCategory(device: *Device, name: []const u8, wanted: []const u8) bool { + if (std.mem.eql(u8, wanted, "touchpad")) { + return std.ascii.indexOfIgnoreCase(name, "touchpad") != null; + } + if (std.mem.eql(u8, wanted, "pointer") or std.mem.eql(u8, wanted, "mouse")) { + return device.kind == .pointer; + } + if (std.mem.eql(u8, wanted, "keyboard")) { + return device.kind == .keyboard; + } + if (std.mem.eql(u8, wanted, "touch")) { + return device.kind == .touch; + } + if (std.mem.eql(u8, wanted, "tablet")) { + return device.kind == .tablet; + } + return false; +} + +pub const Error = error{ + UnknownSetting, + InvalidValue, + NoLibinputDevice, + CannotReadFile, + CannotParseFile, + OutOfMemory, +}; + +/// Compile a keymap from xkb rule names and push it to all keyboards. +pub fn setLayout( + input: *Input, + rules: ?[]const u8, + model: ?[]const u8, + layout: []const u8, + variant: ?[]const u8, + options: ?[]const u8, +) Error!void { + const context = xkb.Context.new(.no_flags) orelse return error.OutOfMemory; + defer context.unref(); + + const owned_rules = dupeZOptional(rules) catch return error.OutOfMemory; + defer gpaFreeOptional(owned_rules); + const owned_model = dupeZOptional(model) catch return error.OutOfMemory; + defer gpaFreeOptional(owned_model); + const owned_layout = gpa.dupeZ(u8, layout) catch return error.OutOfMemory; + defer gpa.free(owned_layout); + const owned_variant = dupeZOptional(variant) catch return error.OutOfMemory; + defer gpaFreeOptional(owned_variant); + const owned_options = dupeZOptional(options) catch return error.OutOfMemory; + defer gpaFreeOptional(owned_options); + + const names = xkb.RuleNames{ + .rules = asSentinel(owned_rules), + .model = asSentinel(owned_model), + .layout = asSentinel(owned_layout), + .variant = asSentinel(owned_variant), + .options = asSentinel(owned_options), + }; + const keymap = xkb.Keymap.newFromNames(context, &names, .no_flags) orelse { + return error.InvalidValue; + }; + defer keymap.unref(); + try input.submitKeymap(keymap); +} + +/// Read a keymap file and push it to all keyboards. +pub fn setLayoutFile(input: *Input, path: []const u8) Error!void { + const io = std.Io.Threaded.global_single_threaded.io(); + var file = std.Io.Dir.cwd().openFile(io, path, .{}) catch return error.CannotReadFile; + defer file.close(io); + var file_buffer: [4096]u8 = undefined; + var reader = file.reader(io, &file_buffer); + const bytes = reader.interface.allocRemaining(gpa, .limited(1024 * 1024)) catch { + return error.CannotReadFile; + }; + defer gpa.free(bytes); + + const context = xkb.Context.new(.no_flags) orelse return error.OutOfMemory; + defer context.unref(); + const keymap = xkb.Keymap.newFromBuffer(context, bytes.ptr, bytes.len, .text_v1, .no_flags) orelse { + return error.CannotParseFile; + }; + defer keymap.unref(); + try input.submitKeymap(keymap); +} + +fn dupeZOptional(text: ?[]const u8) error{OutOfMemory}!?[:0]u8 { + const bytes = text orelse return null; + const owned: [:0]u8 = try gpa.dupeZ(u8, bytes); + return owned; +} + +fn gpaFreeOptional(text: ?[:0]u8) void { + if (text) |owned| gpa.free(owned); +} + +fn asSentinel(text: ?[:0]u8) ?[*:0]const u8 { + const owned = text orelse return null; + return owned; +} + +fn submitKeymap(input: *Input, keymap: *xkb.Keymap) Error!void { + const config = input.xkb_config orelse return error.InvalidValue; + const text = keymap.getAsString(.text_v1) orelse return error.OutOfMemory; + defer std.c.free(text); + const size = std.mem.len(text); + + const file_descriptor = std.c.memfd_create( + "river-keymap", + std.c.MFD.CLOEXEC | std.c.MFD.ALLOW_SEALING, + ); + if (file_descriptor < 0) return error.OutOfMemory; + errdefer _ = std.c.close(file_descriptor); + + var written: usize = 0; + while (written < size) { + const bytes_written = std.c.write(file_descriptor, text[written..size].ptr, size - written); + if (bytes_written < 0) return error.CannotReadFile; + if (bytes_written == 0) return error.CannotReadFile; + written += @intCast(bytes_written); + } + const seals: c_uint = std.c.F.SEAL_SHRINK | std.c.F.SEAL_GROW | std.c.F.SEAL_WRITE; + if (std.c.fcntl(file_descriptor, std.c.F.ADD_SEALS, seals) < 0) return error.CannotReadFile; + + const pending = gpa.create(PendingKeymap) catch return error.OutOfMemory; + // SAFETY: The protocol object is assigned immediately after allocation. + pending.* = .{ .owner = input, .file_descriptor = file_descriptor, .object = undefined }; + const object = config.createKeymap(file_descriptor, .text_v1) catch { + gpa.destroy(pending); + return error.OutOfMemory; + }; + pending.object = object; + object.setListener(*PendingKeymap, handleKeymapEvent, pending); +} + +const PendingKeymap = struct { + owner: *Input, + object: *river.XkbKeymapV1, + file_descriptor: std.c.fd_t, +}; + +fn handleKeymapEvent(_: *river.XkbKeymapV1, event: river.XkbKeymapV1.Event, pending: *PendingKeymap) void { + defer { + _ = std.c.close(pending.file_descriptor); + gpa.destroy(pending); + } + switch (event) { + .success => pending.owner.adoptKeymap(pending.object), + .failure => |failure| { + log.err("keymap rejected: {s}", .{failure.error_msg}); + pending.object.destroy(); + }, + } +} + +fn adoptKeymap(input: *Input, object: *river.XkbKeymapV1) void { + if (input.keymap) |old| old.destroy(); + input.keymap = object; + for (input.devices.items) |device| { + const keyboard = device.keyboard orelse continue; + keyboard.setKeymap(object); + } +} + +pub fn configure(device: *Device, setting: []const u8, value: ?[]const u8) Error!void { + const libinput = device.libinput orelse return error.NoLibinputDevice; + if (std.mem.eql(u8, setting, "tap")) { + try setResult(libinput.setTap(try parseToggle(river.LibinputDeviceV1.TapState, value))); + } else if (std.mem.eql(u8, setting, "tap-button-map")) { + const map: river.LibinputDeviceV1.TapButtonMap = if (equal(value, "lrm")) + .lrm + else if (equal(value, "lmr")) + .lmr + else + return error.InvalidValue; + try setResult(libinput.setTapButtonMap(map)); + } else if (std.mem.eql(u8, setting, "drag")) { + try setResult(libinput.setDrag(try parseToggle(river.LibinputDeviceV1.DragState, value))); + } else if (std.mem.eql(u8, setting, "drag-lock")) { + const state: river.LibinputDeviceV1.DragLockState = if (equal(value, "disabled")) + .disabled + else if (equal(value, "timeout")) + .enabled_timeout + else if (equal(value, "sticky")) + .enabled_sticky + else + return error.InvalidValue; + try setResult(libinput.setDragLock(state)); + } else if (std.mem.eql(u8, setting, "three-finger-drag")) { + const state: river.LibinputDeviceV1.ThreeFingerDragState = if (equal(value, "enabled")) + .enabled_3fg + else if (equal(value, "disabled")) + .disabled + else + return error.InvalidValue; + try setResult(libinput.setThreeFingerDrag(state)); + } else if (std.mem.eql(u8, setting, "natural-scroll")) { + try setResult(libinput.setNaturalScroll( + try parseToggle(river.LibinputDeviceV1.NaturalScrollState, value), + )); + } else if (std.mem.eql(u8, setting, "left-handed")) { + try setResult(libinput.setLeftHanded( + try parseToggle(river.LibinputDeviceV1.LeftHandedState, value), + )); + } else if (std.mem.eql(u8, setting, "click-method")) { + const method: river.LibinputDeviceV1.ClickMethod = if (equal(value, "none")) + .none + else if (equal(value, "button-areas")) + .button_areas + else if (equal(value, "clickfinger")) + .clickfinger + else + return error.InvalidValue; + try setResult(libinput.setClickMethod(method)); + } else if (std.mem.eql(u8, setting, "clickfinger-button-map")) { + const map: river.LibinputDeviceV1.ClickfingerButtonMap = if (equal(value, "lrm")) + .lrm + else if (equal(value, "lmr")) + .lmr + else + return error.InvalidValue; + try setResult(libinput.setClickfingerButtonMap(map)); + } else if (std.mem.eql(u8, setting, "middle-emulation")) { + try setResult(libinput.setMiddleEmulation( + try parseToggle(river.LibinputDeviceV1.MiddleEmulationState, value), + )); + } else if (std.mem.eql(u8, setting, "scroll-method")) { + const method: river.LibinputDeviceV1.ScrollMethod = if (equal(value, "none")) + .no_scroll + else if (equal(value, "two-finger")) + .two_finger + else if (equal(value, "edge")) + .edge + else if (equal(value, "button")) + .on_button_down + else + return error.InvalidValue; + try setResult(libinput.setScrollMethod(method)); + } else if (std.mem.eql(u8, setting, "scroll-button")) { + const text = value orelse return error.InvalidValue; + const button = PointerBinding.parseButton(text) catch return error.InvalidValue; + try setResult(libinput.setScrollButton(button)); + } else if (std.mem.eql(u8, setting, "scroll-button-lock")) { + try setResult(libinput.setScrollButtonLock( + try parseToggle(river.LibinputDeviceV1.ScrollButtonLockState, value), + )); + } else if (std.mem.eql(u8, setting, "dwt")) { + try setResult(libinput.setDwt(try parseToggle(river.LibinputDeviceV1.DwtState, value))); + } else if (std.mem.eql(u8, setting, "dwtp")) { + try setResult(libinput.setDwtp(try parseToggle(river.LibinputDeviceV1.DwtpState, value))); + } else if (std.mem.eql(u8, setting, "send-events")) { + const mode: river.LibinputDeviceV1.SendEventsModes = if (equal(value, "enabled")) + .{} + else if (equal(value, "disabled")) + .{ .disabled = true } + else if (equal(value, "disabled-on-external-mouse")) + .{ .disabled_on_external_mouse = true } + else + return error.InvalidValue; + try setResult(libinput.setSendEvents(mode)); + } else if (std.mem.eql(u8, setting, "pointer-accel") or + std.mem.eql(u8, setting, "accel-speed")) + { + const text = value orelse return error.InvalidValue; + const speed = std.fmt.parseFloat(f64, text) catch return error.InvalidValue; + if (!std.math.isFinite(speed) or speed < -1.0 or speed > 1.0) return error.InvalidValue; + const Fn = @typeInfo(@TypeOf(river.LibinputDeviceV1.setAccelSpeed)).@"fn"; + const ArrayT = @typeInfo(Fn.params[1].type orelse return error.InvalidValue).pointer.child; + var storage: [1]f64 = .{speed}; + // SAFETY: All fields are assigned before the array is passed to the protocol call. + var array: ArrayT = undefined; + array.size = @sizeOf(f64); + array.alloc = @sizeOf(f64); + array.data = @ptrCast(&storage); + try setResult(libinput.setAccelSpeed(&array)); + } else if (std.mem.eql(u8, setting, "scroll-factor")) { + const input_object = device.input orelse return error.NoLibinputDevice; + const text = value orelse return error.InvalidValue; + const factor = std.fmt.parseFloat(f64, text) catch return error.InvalidValue; + // NaN passes the range check below and is illegal behavior in the + // intFromFloat that follows, so reject non-finite values first. + if (!std.math.isFinite(factor) or factor < 0.0) return error.InvalidValue; + const Fn = @typeInfo(@TypeOf(river.InputDeviceV1.setScrollFactor)).@"fn"; + const FixedT = Fn.params[1].type orelse return error.InvalidValue; + const fixed: FixedT = @enumFromInt(@as(i32, @intFromFloat(factor * 256.0))); + input_object.setScrollFactor(fixed); + } else if (std.mem.eql(u8, setting, "accel-profile")) { + const profile: river.LibinputDeviceV1.AccelProfile = if (equal(value, "none")) + .none + else if (equal(value, "flat")) + .flat + else if (equal(value, "adaptive")) + .adaptive + else + return error.InvalidValue; + try setResult(libinput.setAccelProfile(profile)); + } else return error.UnknownSetting; +} + +fn equal(value: ?[]const u8, expected: []const u8) bool { + const text = value orelse return false; + if (std.mem.eql(u8, text, expected)) return true; + return dashed(text, expected); +} + +/// Accept dashes and underscores interchangeably, and enable alongside +/// enabled for boolean settings. +fn dashed(text: []const u8, expected: []const u8) bool { + if (text.len != expected.len) return false; + for (text, expected) |a, b| { + const left = if (a == '-') '_' else a; + const right = if (b == '-') '_' else b; + if (left != right) return false; + } + return true; +} + +fn parseToggle(comptime T: type, value: ?[]const u8) error{InvalidValue}!T { + const text = value orelse return error.InvalidValue; + if (equal(text, "enabled") or std.mem.eql(u8, text, "enable")) return .enabled; + if (equal(text, "disabled") or std.mem.eql(u8, text, "disable")) return .disabled; + return error.InvalidValue; +} + +fn setResult(result: anytype) Error!void { + const object = result catch return error.OutOfMemory; + object.setListener(?*anyopaque, handleResult, null); +} blob - /dev/null blob + 7da08d49b0d8e992dbe3d2fd46a758a8e5814079 (mode 644) --- /dev/null +++ src/Layout.zig @@ -0,0 +1,216 @@ +// zwanzig-disable: file-as-struct +// Pure-function module keeps the PascalCase name per project convention. +// SPDX-FileCopyrightText: © 2026 The River Developers +// SPDX-License-Identifier: GPL-3.0-only + +const std = @import("std"); + +pub const Location = enum { + top, + right, + bottom, + left, +}; + +pub const Attach = enum { + top, + bottom, +}; + +pub const Rect = struct { + x: i32, + y: i32, + width: i32, + height: i32, +}; + +pub const Placement = struct { + x: i32, + y: i32, + width: i32, + height: i32, +}; + +/// Split the output rectangle between a main area and a secondary area. +/// Windows divide their area evenly, the last window takes any remainder. +/// Pure and total: safe for any count, including zero. +pub fn compute( + placements: []Placement, + output: Rect, + main_count: u32, + ratio: f32, + location: Location, +) void { + const count = placements.len; + if (count == 0) return; + if (output.width <= 0 or output.height <= 0) { + for (placements) |*placement| { + placement.* = .{ + .x = output.x, + .y = output.y, + .width = 0, + .height = 0, + }; + } + return; + } + + const clamped_ratio = if (std.math.isFinite(ratio)) std.math.clamp(ratio, 0.1, 0.9) else 0.5; + const mains: usize = @min(@as(usize, @intCast(main_count)), count); + + const vertical = location == .left or location == .right; + const output_width: f32 = @floatFromInt(@max(output.width, 0)); + const output_height: f32 = @floatFromInt(@max(output.height, 0)); + + const main_width: i32 = @min( + @max(@as(i32, @intFromFloat(output_width * clamped_ratio)), 0), + @max(output.width, 0), + ); + const main_height: i32 = @min( + @max(@as(i32, @intFromFloat(output_height * clamped_ratio)), 0), + @max(output.height, 0), + ); + + const main_rect, const secondary_rect = switch (location) { + .left => blk: { + break :blk .{ + Rect{ .x = output.x, .y = output.y, .width = main_width, .height = output.height }, + Rect{ + .x = output.x + main_width, + .y = output.y, + .width = output.width - main_width, + .height = output.height, + }, + }; + }, + .right => blk: { + break :blk .{ + Rect{ + .x = output.x + output.width - main_width, + .y = output.y, + .width = main_width, + .height = output.height, + }, + Rect{ .x = output.x, .y = output.y, .width = output.width - main_width, .height = output.height }, + }; + }, + .top => blk: { + break :blk .{ + Rect{ .x = output.x, .y = output.y, .width = output.width, .height = main_height }, + Rect{ + .x = output.x, + .y = output.y + main_height, + .width = output.width, + .height = output.height - main_height, + }, + }; + }, + .bottom => blk: { + break :blk .{ + Rect{ + .x = output.x, + .y = output.y + output.height - main_height, + .width = output.width, + .height = main_height, + }, + Rect{ .x = output.x, .y = output.y, .width = output.width, .height = output.height - main_height }, + }; + }, + }; + + split(placements[0..mains], main_rect, vertical); + split(placements[mains..], secondary_rect, vertical); +} + +fn split(placements: []Placement, area: Rect, vertical: bool) void { + if (placements.len == 0) return; + if (vertical) { + const height_each = @divFloor(area.height, @as(i32, @intCast(placements.len))); + for (placements[0 .. placements.len - 1], 0..) |*placement, i| { + placement.* = .{ + .x = area.x, + .y = area.y + @as(i32, @intCast(i)) * height_each, + .width = area.width, + .height = height_each, + }; + } + const last = &placements[placements.len - 1]; + last.* = .{ + .x = area.x, + .y = area.y + @as(i32, @intCast(placements.len - 1)) * height_each, + .width = area.width, + .height = area.height - height_each * @as(i32, @intCast(placements.len - 1)), + }; + } else { + const width_each = @divFloor(area.width, @as(i32, @intCast(placements.len))); + for (placements[0 .. placements.len - 1], 0..) |*placement, i| { + placement.* = .{ + .x = area.x + @as(i32, @intCast(i)) * width_each, + .y = area.y, + .width = width_each, + .height = area.height, + }; + } + const last = &placements[placements.len - 1]; + last.* = .{ + .x = area.x + @as(i32, @intCast(placements.len - 1)) * width_each, + .y = area.y, + .width = area.width - width_each * @as(i32, @intCast(placements.len - 1)), + .height = area.height, + }; + } +} + +test "single window fills output" { + var placements: [1]Placement = undefined; + compute(&placements, .{ .x = 0, .y = 0, .width = 1920, .height = 1080 }, 1, 0.6, .left); + try std.testing.expectEqualDeep(Placement{ .x = 0, .y = 0, .width = 1152, .height = 1080 }, placements[0]); +} + +test "no windows is a no-op" { + compute(&.{}, .{ .x = 0, .y = 0, .width = 1920, .height = 1080 }, 1, 0.6, .left); +} + +test "two windows split main and secondary" { + var placements: [2]Placement = undefined; + compute(&placements, .{ .x = 0, .y = 0, .width = 1000, .height = 800 }, 1, 0.6, .left); + try std.testing.expectEqualDeep(Placement{ .x = 0, .y = 0, .width = 600, .height = 800 }, placements[0]); + try std.testing.expectEqualDeep(Placement{ .x = 600, .y = 0, .width = 400, .height = 800 }, placements[1]); +} + +test "right location mirrors areas" { + var placements: [2]Placement = undefined; + compute(&placements, .{ .x = 0, .y = 0, .width = 1000, .height = 800 }, 1, 0.6, .right); + try std.testing.expectEqualDeep(Placement{ .x = 400, .y = 0, .width = 600, .height = 800 }, placements[0]); + try std.testing.expectEqualDeep(Placement{ .x = 0, .y = 0, .width = 400, .height = 800 }, placements[1]); +} + +test "main count divides main area evenly" { + var placements: [3]Placement = undefined; + compute(&placements, .{ .x = 0, .y = 0, .width = 1000, .height = 900 }, 2, 0.5, .left); + try std.testing.expectEqualDeep(Placement{ .x = 0, .y = 0, .width = 500, .height = 450 }, placements[0]); + try std.testing.expectEqualDeep(Placement{ .x = 0, .y = 450, .width = 500, .height = 450 }, placements[1]); + try std.testing.expectEqualDeep(Placement{ .x = 500, .y = 0, .width = 500, .height = 900 }, placements[2]); +} + +test "ratio clamps to sane bounds" { + var placements: [1]Placement = undefined; + compute(&placements, .{ .x = 0, .y = 0, .width = 1000, .height = 800 }, 1, 99.0, .left); + try std.testing.expectEqualDeep(Placement{ .x = 0, .y = 0, .width = 900, .height = 800 }, placements[0]); +} + +test "invalid dimensions produce non-negative placements" { + var placements: [2]Placement = undefined; + compute(&placements, .{ .x = 0, .y = 0, .width = -100, .height = -50 }, 1, 0.6, .left); + for (placements) |placement| { + try std.testing.expect(placement.width >= 0); + try std.testing.expect(placement.height >= 0); + } +} + +test "non-finite ratio remains safe" { + var placements: [1]Placement = undefined; + compute(&placements, .{ .x = 0, .y = 0, .width = 1000, .height = 800 }, 1, std.math.nan(f32), .left); + try std.testing.expect(placements[0].width >= 0); + try std.testing.expect(placements[0].height >= 0); +} blob - /dev/null blob + caf89f2afb8eb42284ba360cf8cbe78af50d659c (mode 644) --- /dev/null +++ src/Output.zig @@ -0,0 +1,82 @@ +// SPDX-FileCopyrightText: © 2026 The River Developers +// SPDX-License-Identifier: GPL-3.0-only +// Analyzer does not resolve method-call syntax to free functions. +// Module type aliases use PascalCase per Zig convention. +// zwanzig-disable: identifier-style, unused-decl + +const Output = @This(); + +const river = @import("wayland").client.river; + +const Layout = @import("Layout.zig"); + +object: *river.OutputV1, +layer_output: ?*river.LayerShellOutputV1 = null, +wl_name: ?u32 = null, +x: i32 = 0, +y: i32 = 0, +width: i32 = 1, +height: i32 = 1, +removed: bool = false, +tags: u32 = 1, +previous_tags: u32 = 1, +attach_mode: ?Layout.Attach = null, +usable: Layout.Rect = .{ .x = 0, .y = 0, .width = 1, .height = 1 }, +usable_valid: bool = false, +main_count: u32 = 1, +main_ratio: f32 = 0.6, +main_location: Layout.Location = .left, + +pub fn init(output: *Output, object: *river.OutputV1) void { + output.* = .{ .object = object }; + object.setListener(*Output, handleEvent, output); +} + +pub fn bindLayerShell(output: *Output, layer_shell: *river.LayerShellV1) void { + if (output.layer_output != null) return; + const layer_output = layer_shell.getOutput(output.object) catch return; + output.layer_output = layer_output; + layer_output.setListener(*Output, handleLayerEvent, output); +} + +pub fn deinit(output: *Output) void { + if (output.layer_output) |layer_output| { + layer_output.destroy(); + output.layer_output = null; + } + output.object.destroy(); +} + +fn handleLayerEvent( + _: *river.LayerShellOutputV1, + event: river.LayerShellOutputV1.Event, + output: *Output, +) void { + switch (event) { + .non_exclusive_area => |area| { + output.usable = .{ + .x = area.x, + .y = area.y, + .width = @max(area.width, 0), + .height = @max(area.height, 0), + }; + output.usable_valid = true; + }, + } +} + +fn handleEvent(_: *river.OutputV1, event: river.OutputV1.Event, output: *Output) void { + switch (event) { + .removed => output.removed = true, + .wl_output => |named| output.wl_name = named.name, + .position => |position| { + output.x = position.x; + output.y = position.y; + }, + .dimensions => |dimensions| { + output.width = @max(dimensions.width, 1); + output.height = @max(dimensions.height, 1); + }, + .capture_sessions => {}, + } +} blob - /dev/null blob + 2fe65c83b1abb27cb3b534de0a870ea8ab050f95 (mode 644) --- /dev/null +++ src/PointerBinding.zig @@ -0,0 +1,89 @@ +// SPDX-FileCopyrightText: © 2026 The River Developers +// SPDX-License-Identifier: GPL-3.0-only +// Module type aliases use PascalCase per Zig convention. +// zwanzig-disable: identifier-style + +const PointerBinding = @This(); + +const std = @import("std"); +const river = @import("wayland").client.river; + +const Seat = @import("Seat.zig"); +const WindowManager = @import("WindowManager.zig"); + +const gpa = std.heap.c_allocator; + +pub const Action = enum { + move, + resize, + command, +}; + +object: *river.PointerBindingV1, +button: u32, +modifiers: river.SeatV1.Modifiers, +action: Action, +command: [:0]const u8, +manager: *WindowManager, +seat: *Seat, +enabled: bool = false, + +pub fn init( + binding: *PointerBinding, + object: *river.PointerBindingV1, + button: u32, + modifiers: river.SeatV1.Modifiers, + action: Action, + command: [:0]const u8, + manager: *WindowManager, + seat: *Seat, +) void { + binding.* = .{ + .object = object, + .button = button, + .modifiers = modifiers, + .action = action, + .command = command, + .manager = manager, + .seat = seat, + }; + object.setListener(*PointerBinding, handleEvent, binding); +} + +pub fn deinit(binding: *PointerBinding) void { + binding.object.destroy(); + gpa.free(binding.command); +} + +fn handleEvent( + _: *river.PointerBindingV1, + event: river.PointerBindingV1.Event, + binding: *PointerBinding, +) void { + switch (event) { + .pressed => { + if (binding.action == .command) { + binding.manager.fireBinding(binding.command); + } else { + binding.manager.requestPointerOperation(binding.seat, binding); + } + }, + .released => {}, + } +} + +pub fn parseButton(text: []const u8) error{InvalidButton}!u32 { + inline for (.{ + .{ "BTN_LEFT", 0x110 }, + .{ "BTN_RIGHT", 0x111 }, + .{ "BTN_MIDDLE", 0x112 }, + .{ "BTN_SIDE", 0x113 }, + .{ "BTN_EXTRA", 0x114 }, + .{ "BTN_FORWARD", 0x115 }, + .{ "BTN_BACK", 0x116 }, + .{ "BTN_TASK", 0x117 }, + }) |pair| { + if (std.mem.eql(u8, text, pair[0])) return pair[1]; + } + return std.fmt.parseInt(u32, text, 10) catch return error.InvalidButton; +} blob - /dev/null blob + 5e93c4ae77f6ef409894be7d08b6237dbdef3e1a (mode 644) --- /dev/null +++ src/Rule.zig @@ -0,0 +1,108 @@ +// zwanzig-disable: file-as-struct +// Pure-function module keeps the PascalCase name per project convention. +// SPDX-FileCopyrightText: © 2026 The River Developers +// SPDX-License-Identifier: GPL-3.0-only + +const std = @import("std"); +const mem = std.mem; + +/// Validate a glob, returning error.InvalidGlob if it is empty, "**" or has a +/// '*' at any position other than the first and/or last byte. +pub fn validate(glob: []const u8) error{InvalidGlob}!void { + switch (glob.len) { + 0 => return error.InvalidGlob, + 1 => {}, + 2 => if (glob[0] == '*' and glob[1] == '*') return error.InvalidGlob, + else => if (mem.indexOfScalar(u8, glob[1 .. glob.len - 1], '*') != null) { + return error.InvalidGlob; + }, + } +} + +/// Return true if text is matched by glob. The glob must be valid, +/// see validate(). +pub fn match(text: []const u8, glob: []const u8) bool { + if (glob.len == 1) { + return glob[0] == '*' or mem.eql(u8, text, glob); + } + + const suffix_match = glob[0] == '*'; + const prefix_match = glob[glob.len - 1] == '*'; + + if (suffix_match and prefix_match) { + return mem.indexOf(u8, text, glob[1 .. glob.len - 1]) != null; + } else if (suffix_match) { + return mem.endsWith(u8, text, glob[1..]); + } else if (prefix_match) { + return mem.startsWith(u8, text, glob[0 .. glob.len - 1]); + } else { + return mem.eql(u8, text, glob); + } +} + +pub const Action = enum { + float, + fullscreen, + ssd, + csd, + tags, +}; + +pub const Rule = struct { + app_id_glob: []const u8, + title_glob: []const u8, + action: Action, + tags: u32 = 0, +}; + +test validate { + const testing = std.testing; + + try validate("*"); + try validate("a"); + try validate("*a"); + try validate("a*"); + try validate("*a*"); + try validate("ab"); + + try testing.expectError(error.InvalidGlob, validate("")); + try testing.expectError(error.InvalidGlob, validate("**")); + try testing.expectError(error.InvalidGlob, validate("***")); + try testing.expectError(error.InvalidGlob, validate("a*c")); + try testing.expectError(error.InvalidGlob, validate("ab*c*")); + try testing.expectError(error.InvalidGlob, validate("*ab*c")); + try testing.expectError(error.InvalidGlob, validate("**a")); + try testing.expectError(error.InvalidGlob, validate("abc**")); +} + +test match { + const testing = std.testing; + + try testing.expect(match("", "*")); + try testing.expect(match("a", "*")); + try testing.expect(match("a", "*a*")); + try testing.expect(match("a", "a*")); + try testing.expect(match("a", "*a")); + try testing.expect(match("a", "a")); + + try testing.expect(!match("a", "b")); + try testing.expect(!match("a", "*b*")); + try testing.expect(!match("a", "b*")); + try testing.expect(!match("a", "*b")); + + try testing.expect(match("ab", "*ab*")); + try testing.expect(match("ab", "*b")); + try testing.expect(match("ab", "a*")); + + try testing.expect(!match("ab", "b*")); + try testing.expect(!match("ab", "*a")); + try testing.expect(!match("ab", "ac")); + try testing.expect(!match("ab", "*ac*")); +} + +test "rule matches app id and title together" { + const testing = std.testing; + try testing.expect(match("float-term", "float*")); + try testing.expect(!match("other", "float*")); + try testing.expect(match("foo", "*")); +} blob - /dev/null blob + c0d8d9706c5d361c538b594791d064e27fbca8a4 (mode 644) --- /dev/null +++ src/Seat.zig @@ -0,0 +1,110 @@ +// SPDX-FileCopyrightText: © 2026 The River Developers +// SPDX-License-Identifier: GPL-3.0-only +// Module type aliases use PascalCase per Zig convention. +// zwanzig-disable: identifier-style + +const Seat = @This(); + +const std = @import("std"); +const river = @import("wayland").client.river; + +const Binding = @import("Binding.zig"); +const Output = @import("Output.zig"); +const PointerBinding = @import("PointerBinding.zig"); +const Window = @import("Window.zig"); + +const gpa = std.heap.c_allocator; + +pub const PointerOperation = struct { + action: PointerBinding.Action, + window: *Window, + start_x: i32, + start_y: i32, + start_width: i32, + start_height: i32, + pending_delta: ?struct { delta_x: i32, delta_y: i32 } = null, + released: bool = false, +}; + +object: *river.SeatV1, +layer_seat: ?*river.LayerShellSeatV1 = null, +focused_output: ?*Output = null, +hovered_window: ?*river.WindowV1 = null, +last_focused: ?*river.WindowV1 = null, +focused_window: ?*river.WindowV1 = null, +removed: bool = false, +mode_index: usize = 0, +/// Mode to restore when the session unlocks. Set while the locked mode +/// is active. +unlocked_mode_index: ?usize = null, +bindings: std.ArrayListUnmanaged(*Binding) = .empty, +pointer_bindings: std.ArrayListUnmanaged(*PointerBinding) = .empty, +operation: ?PointerOperation = null, +pending_operation: ?*PointerBinding = null, + +pub fn init(seat: *Seat, object: *river.SeatV1) void { + seat.* = .{ .object = object }; + object.setListener(*Seat, handleEvent, seat); +} + +pub fn bindLayerShell(seat: *Seat, layer_shell: *river.LayerShellV1) void { + if (seat.layer_seat != null) return; + const layer_seat = layer_shell.getSeat(seat.object) catch return; + seat.layer_seat = layer_seat; + layer_seat.setListener(*Seat, handleLayerSeatEvent, seat); +} + +fn handleLayerSeatEvent( + _: *river.LayerShellSeatV1, + event: river.LayerShellSeatV1.Event, + seat: *Seat, +) void { + _ = seat; + switch (event) { + .focus_exclusive, .focus_non_exclusive, .focus_none => {}, + } +} + +pub fn deinit(seat: *Seat) void { + if (seat.layer_seat) |layer_seat| { + layer_seat.destroy(); + seat.layer_seat = null; + } + for (seat.bindings.items) |binding| { + binding.deinit(); + gpa.destroy(binding); + } + for (seat.pointer_bindings.items) |binding| { + binding.deinit(); + gpa.destroy(binding); + } + seat.bindings.deinit(gpa); + seat.pointer_bindings.deinit(gpa); + seat.object.destroy(); +} + +pub fn focus(seat: *Seat, window: *river.WindowV1) void { + seat.focused_window = window; + seat.object.focusWindow(window); +} + +fn handleEvent(_: *river.SeatV1, event: river.SeatV1.Event, seat: *Seat) void { + switch (event) { + .removed => seat.removed = true, + .pointer_enter => |entered| seat.hovered_window = entered.window, + .pointer_leave => seat.hovered_window = null, + .op_delta => |delta| { + if (seat.operation) |*operation| { + operation.pending_delta = .{ .delta_x = delta.dx, .delta_y = delta.dy }; + } + }, + .op_release => { + if (seat.operation) |*operation| operation.released = true; + }, + .window_interaction, + .shell_surface_interaction, + .pointer_position, + .wl_seat, + => {}, + } +} blob - /dev/null blob + cad833680126c28a668219afdb099dc1c40c175f (mode 644) --- /dev/null +++ src/Spawn.zig @@ -0,0 +1,47 @@ +// zwanzig-disable: file-as-struct +// Pure-function module keeps the PascalCase name per project convention. +// SPDX-FileCopyrightText: © 2026 The River Developers +// SPDX-License-Identifier: GPL-3.0-only + +const std = @import("std"); + +const gpa = std.heap.c_allocator; + +/// Run command with `/bin/sh -c` in a detached grandchild process. +pub fn run(command: []const u8) void { + const owned = gpa.dupeZ(u8, command) catch return; + defer gpa.free(owned); + + const shell: [*:0]const u8 = "/bin/sh"; + const child_args = [_:null]?[*:0]const u8{ shell, "-c", owned, null }; + + const process_id: std.c.pid_t = forkProcessId() orelse return; + if (process_id == 0) { + _ = std.c.setsid(); + + const grandchild: std.c.pid_t = forkProcessId() orelse std.c._exit(1); + if (grandchild == 0) { + _ = std.c.execve(shell, &child_args, std.c.environ); + std.c._exit(1); + } + std.c._exit(0); + } + + var status: c_int = 0; + while (true) { + switch (std.c.errno(std.c.waitpid(process_id, &status, 0))) { + .SUCCESS => return, + .INTR => continue, + else => return, + } + } +} + +fn forkProcessId() ?std.c.pid_t { + const fork_result = std.c.fork(); + switch (std.c.errno(fork_result)) { + .SUCCESS => {}, + else => return null, + } + return @intCast(fork_result); +} blob - /dev/null blob + 9ebfb53d5f10a270d85e1e6dd0563b163d0c4fb8 (mode 644) --- /dev/null +++ src/Window.zig @@ -0,0 +1,152 @@ +// SPDX-FileCopyrightText: © 2026 The River Developers +// SPDX-License-Identifier: GPL-3.0-only +// Module type aliases use PascalCase per Zig convention. +// zwanzig-disable: identifier-style + +const Window = @This(); + +const std = @import("std"); +const river = @import("wayland").client.river; + +const Output = @import("Output.zig"); +const WindowManager = @import("WindowManager.zig"); + +const gpa = std.heap.c_allocator; + +object: ?*river.WindowV1, +manager: *WindowManager, +node: ?*river.NodeV1 = null, +closed: bool = false, +tags: u32 = 1, +placed: bool = false, +output: ?*Output = null, +float: bool = false, +fullscreen: bool = false, +focused: bool = false, +app_id: ?[]const u8 = null, +title: ?[]const u8 = null, +width: i32 = 800, +height: i32 = 600, +x: i32 = 0, +y: i32 = 0, +/// Last border state sent to the compositor, to avoid redundant requests. +rendered_border_width: i32 = -1, +rendered_border_color: [4]u32 = .{ 0, 0, 0, 0 }, +shown: bool = false, + +pub fn init(window: *Window, object: *river.WindowV1, manager: *WindowManager) void { + window.* = .{ .object = object, .manager = manager }; + object.setListener(*Window, handleEvent, window); +} + +pub fn deinit(window: *Window) void { + if (window.node) |node| { + node.destroy(); + window.node = null; + } + if (window.object) |object| { + object.destroy(); + window.object = null; + } + if (window.app_id) |app_id| gpa.free(app_id); + if (window.title) |title| gpa.free(title); + window.app_id = null; + window.title = null; +} + +pub fn configure(window: *Window, width: i32, height: i32) void { + const object = window.object orelse return; + window.width = @max(width, 1); + window.height = @max(height, 1); + object.proposeDimensions(window.width, window.height); +} + +pub fn render(window: *Window, border_width: i32, border_color: [4]u32) void { + const object = window.object orelse return; + const node = window.node orelse return; + node.setPosition(window.x, window.y); + if (window.rendered_border_width != border_width or + !std.mem.eql(u32, &window.rendered_border_color, &border_color)) + { + object.setBorders( + .{ .top = true, .bottom = true, .left = true, .right = true }, + border_width, + border_color[0], + border_color[1], + border_color[2], + border_color[3], + ); + window.rendered_border_width = border_width; + window.rendered_border_color = border_color; + } + if (!window.shown) { + object.show(); + window.shown = true; + } + node.placeTop(); +} + +pub fn hide(window: *Window) void { + const object = window.object orelse return; + if (!window.shown) return; + object.hide(); + window.shown = false; +} + +fn handleEvent(_: *river.WindowV1, event: river.WindowV1.Event, window: *Window) void { + switch (event) { + .closed => { + window.closed = true; + if (window.node) |node| { + node.destroy(); + window.node = null; + } + }, + .dimensions => |dimensions| { + window.width = @max(dimensions.width, 1); + window.height = @max(dimensions.height, 1); + }, + .app_id => |app_id| { + const had_app_id = window.app_id != null; + if (window.app_id) |old| gpa.free(old); + window.app_id = if (app_id.app_id) |id| + gpa.dupe(u8, std.mem.sliceTo(id, 0)) catch null + else + null; + // The first manage sequence may run before the app_id arrives, + // so rules evaluated then missed it. Re-apply once the identity + // is known; later changes only update decorations. + if (window.node != null) { + if (!had_app_id and window.app_id != null) { + window.manager.applyRules(window); + } else { + window.manager.applyDecorationRules(window); + } + } + }, + .title => |title| { + if (window.title) |old| gpa.free(old); + window.title = if (title.title) |name| + gpa.dupe(u8, std.mem.sliceTo(name, 0)) catch null + else + null; + if (window.node != null) window.manager.applyDecorationRules(window); + }, + .dimensions_hint, + .parent, + .decoration_hint, + .pointer_move_requested, + .pointer_resize_requested, + .show_window_menu_requested, + .maximize_requested, + .unmaximize_requested, + .fullscreen_requested, + .exit_fullscreen_requested, + .minimize_requested, + .unreliable_pid, + .presentation_hint, + .identifier, + .capture_sessions, + => {}, + } +} blob - /dev/null blob + 85384c83dda71601e22e92ab40f41b701545a035 (mode 644) --- /dev/null +++ src/WindowManager.zig @@ -0,0 +1,2018 @@ +// SPDX-FileCopyrightText: © 2026 The River Developers +// SPDX-License-Identifier: GPL-3.0-only +// Analyzer does not resolve method-call syntax to free functions. +// Module type aliases use PascalCase per Zig convention. +// zwanzig-disable: identifier-style, unused-decl + +const WindowManager = @This(); + +const std = @import("std"); +const wl = @import("wayland").client.wl; +const river = @import("wayland").client.river; + +const Binding = @import("Binding.zig"); +const Command = @import("Command.zig"); +const Config = @import("Config.zig"); +const Input = @import("Input.zig"); +const Layout = @import("Layout.zig"); +const Output = @import("Output.zig"); +const PointerBinding = @import("PointerBinding.zig"); +const Rule = @import("Rule.zig"); +const Seat = @import("Seat.zig"); +const Spawn = @import("Spawn.zig"); +const Window = @import("Window.zig"); + +const gpa = std.heap.c_allocator; +const log = std.log.scoped(.laguna); + +const pending_max = 64; + +const BindingSpecification = struct { + keysym: u32, + modifiers: river.SeatV1.Modifiers, + command: [:0]const u8, +}; + +const PointerSpecification = struct { + button: u32, + modifiers: river.SeatV1.Modifiers, + action: PointerBinding.Action, + command: [:0]const u8, +}; + +const Mode = struct { + name: []const u8, + specifications: std.ArrayListUnmanaged(BindingSpecification) = .empty, + pointer_specifications: std.ArrayListUnmanaged(PointerSpecification) = .empty, +}; + +pub const WlOutput = struct { + global_name: u32, + object: *wl.Output, + connector: ?[]const u8 = null, +}; + +windows: std.ArrayListUnmanaged(*Window) = .empty, +outputs: std.ArrayListUnmanaged(*Output) = .empty, +seats: std.ArrayListUnmanaged(*Seat) = .empty, +wl_outputs: ?*std.ArrayListUnmanaged(*WlOutput) = null, +input: Input = .{}, +modes: std.ArrayListUnmanaged(Mode) = .empty, +rules: std.ArrayListUnmanaged(Rule.Rule) = .empty, +pending: std.ArrayListUnmanaged(Command) = .empty, +object: *river.WindowManagerV1, +default_attach: Layout.Attach = .top, +spawn_tagmask: ?u32 = null, +locked: bool = false, +border_width: i32 = 2, +border_focused: [4]u32 = expandHex(0x93a1a1ff), +border_unfocused: [4]u32 = expandHex(0x586e75ff), +focus_follows_cursor: bool = false, +cursor_warp: bool = false, +cursor_theme_name: ?[]const u8 = null, +cursor_theme_size: u32 = 24, +xkb: ?*river.XkbBindingsV1 = null, +layer_shell: ?*river.LayerShellV1 = null, +layer_default_set: bool = false, + +pub fn init( + manager: *WindowManager, + object: *river.WindowManagerV1, + xkb: ?*river.XkbBindingsV1, + layer_shell: ?*river.LayerShellV1, +) void { + manager.* = .{ .object = object, .xkb = xkb, .layer_shell = layer_shell }; + object.setListener(*WindowManager, handleEvent, manager); + _ = manager.ensureMode("normal"); +} + +pub fn deinit(manager: *WindowManager) void { + for (manager.windows.items) |window| { + window.deinit(); + gpa.destroy(window); + } + for (manager.outputs.items) |output| { + output.deinit(); + gpa.destroy(output); + } + for (manager.seats.items) |seat| { + seat.deinit(); + gpa.destroy(seat); + } + if (manager.cursor_theme_name) |name| gpa.free(name); + for (manager.modes.items) |*mode| { + for (mode.specifications.items) |specification| gpa.free(specification.command); + for (mode.pointer_specifications.items) |specification| gpa.free(specification.command); + mode.specifications.deinit(gpa); + mode.pointer_specifications.deinit(gpa); + gpa.free(mode.name); + } + for (manager.rules.items) |rule| { + gpa.free(rule.app_id_glob); + gpa.free(rule.title_glob); + } + manager.input.deinit(); + manager.windows.deinit(gpa); + manager.outputs.deinit(gpa); + manager.seats.deinit(gpa); + manager.modes.deinit(gpa); + manager.rules.deinit(gpa); + manager.pending.deinit(gpa); + manager.object.destroy(); + if (manager.xkb) |object| object.destroy(); + if (manager.layer_shell) |object| object.destroy(); + manager.xkb = null; + manager.layer_shell = null; +} + +/// Queue a command for the next manage sequence. Protocol state may only +/// change inside manage_start, so commands never execute inline. +/// Unknown commands fail here so callers get an error synchronously +/// instead of a late warning with an earlier ok reply. +pub fn queueCommand(manager: *WindowManager, command: *const Command) error{ QueueFull, UnknownCommand }!void { + var arguments = command.arguments(); + const name = arguments.next() orelse return error.UnknownCommand; + if (!isKnownCommand(name)) return error.UnknownCommand; + if (manager.pending.items.len == pending_max) return error.QueueFull; + manager.pending.append(gpa, command.*) catch return error.QueueFull; + manager.object.manageDirty(); +} + +const known_commands = [_][]const u8{ + "exit", "close", "focus-view", + "set-focused-tags", "toggle-focused-tags", "set-view-tags", + "toggle-view-tags", "focus-output", "send-to-output", + "declare-mode", "enter-mode", "map", + "map-pointer", "unmap", "unmap-pointer", + "main-ratio", "main-count", "main-location", + "zoom", "spawn", "swap", + "move", "resize", "snap", + "toggle-float", "toggle-fullscreen", "rule-add", + "rule-del", "set-repeat", "input", + "keyboard-layout", "keyboard-layout-file", "attach-mode", + "default-attach-mode", "output-attach-mode", "spawn-tagmask", + "focus-previous-tags", "send-to-previous-tags", "border-width", + "border-color-focused", "border-color-unfocused", "focus-follows-cursor", + "set-cursor-warp", "xcursor-theme", +}; + +fn isKnownCommand(name: []const u8) bool { + for (known_commands) |known| { + if (std.mem.eql(u8, name, known)) return true; + } + return false; +} + +test "all commands validate" { + const testing = std.testing; + for (known_commands) |name| try testing.expect(isKnownCommand(name)); + try testing.expect(!isKnownCommand("frobnicate")); + try testing.expect(!isKnownCommand("")); +} + +/// Queue one parsed command for the next manage sequence. +fn queueParsed(manager: *WindowManager, arguments: []const []const u8) !void { + // SAFETY: fromSlices resets and fully writes the command before any read. + var command: Command = undefined; + command.fromSlices(arguments) catch |err| { + log.err("invalid config command: {s}", .{@errorName(err)}); + return err; + }; + manager.pending.append(gpa, command) catch return error.OutOfMemory; +} + +/// Format a number into the scratch buffer for command arguments. +fn formatNumber(scratch: *[32]u8, value: anytype) error{NoSpaceLeft}![]const u8 { + return std.fmt.bufPrint(scratch, "{d}", .{value}); +} + +/// Apply the static config file. Every entry becomes a queued command that +/// runs in the next manage sequence, exactly as if sent at startup. +/// Input settings are also stored, so devices arriving later converge. +pub fn loadConfig(manager: *WindowManager, config: *const Config) !void { + // Two scratch buffers: set-repeat formats two numbers into one command. + var scratch: [2][32]u8 = undefined; + // SAFETY: Only indices below the running count are ever read. + var words: [Command.arguments_max][]const u8 = undefined; + + for (config.modes) |mode| { + try manager.queueParsed(&.{ "declare-mode", mode.name }); + } + if (config.main_ratio) |ratio| { + try manager.queueParsed(&.{ "main-ratio", try formatNumber(&scratch[0], ratio) }); + } + if (config.main_count) |count| { + try manager.queueParsed(&.{ "main-count", try formatNumber(&scratch[0], count) }); + } + if (config.main_location) |location| { + try manager.queueParsed(&.{ "main-location", location }); + } + if (config.attach_mode) |mode| { + try manager.queueParsed(&.{ "default-attach-mode", mode }); + } + if (config.output_attach_mode) |mode| { + try manager.queueParsed(&.{ "output-attach-mode", mode }); + } + if (config.spawn_tagmask) |mask| { + try manager.queueParsed(&.{ "spawn-tagmask", try formatNumber(&scratch[0], mask) }); + } + if (config.border_width) |width| { + try manager.queueParsed(&.{ "border-width", try formatNumber(&scratch[0], width) }); + } + if (config.border_color_focused) |color| { + try manager.queueParsed(&.{ "border-color-focused", color }); + } + if (config.border_color_unfocused) |color| { + try manager.queueParsed(&.{ "border-color-unfocused", color }); + } + if (config.focus_follows_cursor) |mode| { + try manager.queueParsed(&.{ "focus-follows-cursor", mode }); + } + if (config.cursor_warp) |mode| { + try manager.queueParsed(&.{ "set-cursor-warp", mode }); + } + if (config.xcursor_theme) |theme| { + try manager.queueParsed(&.{ + "xcursor-theme", + theme.name, + try formatNumber(&scratch[0], theme.size), + }); + } + if (config.repeat) |repeat| { + try manager.queueParsed(&.{ + "set-repeat", + try formatNumber(&scratch[0], repeat.rate), + try formatNumber(&scratch[1], repeat.delay), + }); + } + if (config.keyboard_layout) |layout| { + var count: usize = 0; + words[count] = "keyboard-layout"; + count += 1; + if (layout.rules) |rules| { + words[count] = "-rules"; + words[count + 1] = rules; + count += 2; + } + if (layout.model) |model| { + words[count] = "-model"; + words[count + 1] = model; + count += 2; + } + if (layout.variant) |variant| { + words[count] = "-variant"; + words[count + 1] = variant; + count += 2; + } + if (layout.options) |options| { + words[count] = "-options"; + words[count + 1] = options; + count += 2; + } + words[count] = layout.layout; + count += 1; + try manager.queueParsed(words[0..count]); + } + if (config.keyboard_layout_file) |path| { + try manager.queueParsed(&.{ "keyboard-layout-file", path }); + } + for (config.inputs) |rule| { + try manager.input.storeConfig(rule.pattern, rule.category, rule.setting, rule.value); + var count: usize = 0; + words[count] = "input"; + words[count + 1] = rule.pattern; + count += 2; + if (rule.category) |category| { + words[count] = category; + count += 1; + } + words[count] = rule.setting; + count += 1; + if (rule.value) |value| { + words[count] = value; + count += 1; + } + try manager.queueParsed(words[0..count]); + } + for (config.rules) |rule| { + var count: usize = 0; + words[count] = "rule-add"; + count += 1; + if (rule.app_id) |app_id| { + words[count] = "-app-id"; + words[count + 1] = app_id; + count += 2; + } + if (rule.title) |title| { + words[count] = "-title"; + words[count + 1] = title; + count += 2; + } + words[count] = rule.action; + count += 1; + if (rule.tags) |tags| { + words[count] = try formatNumber(&scratch[0], tags); + count += 1; + } + try manager.queueParsed(words[0..count]); + } + for (config.modes) |mode| { + for (mode.bindings) |group| { + for (group.modifiers) |modifiers| { + for (group.bindings) |binding| { + var count: usize = 0; + words[count] = "map"; + words[count + 1] = mode.name; + words[count + 2] = modifiers; + words[count + 3] = binding.keysym; + count += 4; + count += try splitCommand(binding.command, words[count..]); + try manager.queueParsed(words[0..count]); + } + } + } + for (mode.pointer_bindings) |pointer_group| { + for (pointer_group.modifiers) |modifiers| { + for (pointer_group.bindings) |binding| { + var count: usize = 0; + words[count] = "map-pointer"; + words[count + 1] = mode.name; + words[count + 2] = modifiers; + words[count + 3] = binding.button; + count += 4; + count += try splitCommand(binding.action, words[count..]); + try manager.queueParsed(words[0..count]); + } + } + } + } + if (manager.pending.items.len > 0) manager.object.manageDirty(); +} + +/// Split a command string on spaces, mirroring how riverctl argv arrives. +/// Returns the number of words written. +fn splitCommand(text: []const u8, out: [][]const u8) error{TooManyArguments}!usize { + var count: usize = 0; + var parts = std.mem.splitScalar(u8, text, ' '); + while (parts.next()) |part| { + if (part.len == 0) continue; + if (count == out.len) return error.TooManyArguments; + out[count] = part; + count += 1; + } + return count; +} + +fn execute(manager: *WindowManager, command: *const Command) Error!void { + var arguments = command.arguments(); + const name = arguments.next() orelse return error.UnknownCommand; + + if (std.mem.eql(u8, name, "exit")) { + if (arguments.next() != null) return error.InvalidArgument; + manager.object.exitSession(); + return; + } + if (std.mem.eql(u8, name, "close")) { + if (arguments.next() != null) return error.InvalidArgument; + if (manager.seats.items.len == 0) return; + const window = manager.seats.items[0].focused_window orelse return; + window.close(); + return; + } + if (std.mem.eql(u8, name, "focus-view")) { + const raw = arguments.next() orelse return error.InvalidArgument; + if (arguments.next() != null) return error.InvalidArgument; + if (std.mem.eql(u8, raw, "next")) { + manager.cycleFocus(true); + } else if (std.mem.eql(u8, raw, "previous")) { + manager.cycleFocus(false); + } else { + manager.focusDirection(raw) catch return error.InvalidArgument; + } + return; + } + if (std.mem.eql(u8, name, "attach-mode") or std.mem.eql(u8, name, "default-attach-mode")) { + const raw = arguments.next() orelse return error.InvalidArgument; + if (arguments.next() != null) return error.InvalidArgument; + manager.default_attach = parseAttach(raw) catch return error.InvalidArgument; + return; + } + if (std.mem.eql(u8, name, "output-attach-mode")) { + const raw = arguments.next() orelse return error.InvalidArgument; + if (arguments.next() != null) return error.InvalidArgument; + const output = manager.focusedOutput() orelse return; + output.attach_mode = parseAttach(raw) catch return error.InvalidArgument; + return; + } + if (std.mem.eql(u8, name, "spawn-tagmask")) { + const raw = arguments.next() orelse return error.InvalidArgument; + if (arguments.next() != null) return error.InvalidArgument; + manager.spawn_tagmask = std.fmt.parseInt(u32, raw, 10) catch return error.InvalidArgument; + return; + } + if (std.mem.eql(u8, name, "focus-previous-tags")) { + if (arguments.next() != null) return error.InvalidArgument; + const output = manager.focusedOutput() orelse return; + const current = output.tags; + output.tags = output.previous_tags; + output.previous_tags = current; + return; + } + if (std.mem.eql(u8, name, "send-to-previous-tags")) { + if (arguments.next() != null) return error.InvalidArgument; + const output = manager.focusedOutput() orelse return; + const window = manager.focusedWindow() orelse return; + window.tags = output.previous_tags; + return; + } + if (std.mem.eql(u8, name, "set-focused-tags")) { + const raw = arguments.next() orelse return error.InvalidArgument; + if (arguments.next() != null) return error.InvalidArgument; + const mask = std.fmt.parseInt(u32, raw, 10) catch return error.InvalidArgument; + const output = manager.focusedOutput() orelse return; + output.previous_tags = output.tags; + output.tags = mask; + return; + } + if (std.mem.eql(u8, name, "toggle-focused-tags")) { + const raw = arguments.next() orelse return error.InvalidArgument; + if (arguments.next() != null) return error.InvalidArgument; + const mask = std.fmt.parseInt(u32, raw, 10) catch return error.InvalidArgument; + const output = manager.focusedOutput() orelse return; + output.previous_tags = output.tags; + output.tags ^= mask; + return; + } + if (std.mem.eql(u8, name, "toggle-view-tags")) { + const raw = arguments.next() orelse return error.InvalidArgument; + if (arguments.next() != null) return error.InvalidArgument; + const mask = std.fmt.parseInt(u32, raw, 10) catch return error.InvalidArgument; + const window = manager.focusedWindow() orelse return; + window.tags ^= mask; + return; + } + if (std.mem.eql(u8, name, "focus-output")) { + const target = arguments.next() orelse return error.InvalidArgument; + if (arguments.next() != null) return error.InvalidArgument; + const output = manager.resolveOutput(target) orelse return error.InvalidArgument; + if (manager.seats.items.len == 0) return; + manager.seats.items[0].focused_output = output; + return; + } + if (std.mem.eql(u8, name, "send-to-output")) { + var current_tags = false; + var target = arguments.next() orelse return error.InvalidArgument; + if (std.mem.eql(u8, target, "-current-tags")) { + current_tags = true; + target = arguments.next() orelse return error.InvalidArgument; + } + if (arguments.next() != null) return error.InvalidArgument; + const output = manager.resolveOutput(target) orelse return error.InvalidArgument; + const window = manager.focusedWindow() orelse return; + window.output = output; + if (current_tags) window.tags = output.tags; + return; + } + if (std.mem.eql(u8, name, "set-view-tags")) { + const raw = arguments.next() orelse return error.InvalidArgument; + if (arguments.next() != null) return error.InvalidArgument; + const mask = std.fmt.parseInt(u32, raw, 10) catch return error.InvalidArgument; + if (manager.seats.items.len == 0) return; + const seat = manager.seats.items[0]; + const window = seat.focused_window orelse return; + const target = manager.findWindow(window) orelse return; + target.tags = mask; + return; + } + if (std.mem.eql(u8, name, "declare-mode")) { + const mode_name = arguments.next() orelse return error.InvalidArgument; + if (arguments.next() != null) return error.InvalidArgument; + _ = manager.ensureMode(mode_name); + return; + } + if (std.mem.eql(u8, name, "enter-mode")) { + const mode_name = arguments.next() orelse return error.InvalidArgument; + if (arguments.next() != null) return error.InvalidArgument; + const index = manager.modeIndex(mode_name) orelse return error.InvalidArgument; + for (manager.seats.items) |seat| manager.setSeatMode(seat, index); + return; + } + if (std.mem.eql(u8, name, "map")) { + try manager.defineBinding(&arguments); + return; + } + if (std.mem.eql(u8, name, "map-pointer")) { + try manager.definePointerBinding(&arguments); + return; + } + if (std.mem.eql(u8, name, "unmap")) { + const mode_name = arguments.next() orelse return error.InvalidArgument; + const modifiers_raw = arguments.next() orelse return error.InvalidArgument; + const keysym_raw = arguments.next() orelse return error.InvalidArgument; + if (arguments.next() != null) return error.InvalidArgument; + const modifiers = Binding.parseModifiers(modifiers_raw) catch return error.InvalidModifier; + const keysym = Binding.parseKeysym(keysym_raw) catch return error.InvalidArgument; + manager.removeBinding(mode_name, keysym, modifiers); + return; + } + if (std.mem.eql(u8, name, "unmap-pointer")) { + const mode_name = arguments.next() orelse return error.InvalidArgument; + const modifiers_raw = arguments.next() orelse return error.InvalidArgument; + const button_raw = arguments.next() orelse return error.InvalidArgument; + if (arguments.next() != null) return error.InvalidArgument; + const modifiers = Binding.parseModifiers(modifiers_raw) catch return error.InvalidModifier; + const button = PointerBinding.parseButton(button_raw) catch return error.InvalidButton; + manager.removePointerBinding(mode_name, button, modifiers); + return; + } + if (std.mem.eql(u8, name, "main-ratio")) { + const raw = arguments.next() orelse return error.InvalidArgument; + if (arguments.next() != null) return error.InvalidArgument; + manager.adjustRatio(raw) catch return error.InvalidArgument; + return; + } + if (std.mem.eql(u8, name, "main-count")) { + const raw = arguments.next() orelse return error.InvalidArgument; + if (arguments.next() != null) return error.InvalidArgument; + manager.adjustCount(raw) catch return error.InvalidArgument; + return; + } + if (std.mem.eql(u8, name, "main-location")) { + const raw = arguments.next() orelse return error.InvalidArgument; + if (arguments.next() != null) return error.InvalidArgument; + manager.setLocation(raw) catch return error.InvalidArgument; + return; + } + if (std.mem.eql(u8, name, "zoom")) { + if (arguments.next() != null) return error.InvalidArgument; + manager.zoom(); + return; + } + if (std.mem.eql(u8, name, "spawn")) { + const program = arguments.next() orelse return error.InvalidArgument; + const shell_command = try joinArguments(&arguments, program); + defer gpa.free(shell_command); + Spawn.run(shell_command); + return; + } + if (std.mem.eql(u8, name, "swap")) { + const raw = arguments.next() orelse return error.InvalidArgument; + if (arguments.next() != null) return error.InvalidArgument; + const forward = if (std.mem.eql(u8, raw, "next")) + true + else if (std.mem.eql(u8, raw, "previous")) + false + else + return error.InvalidArgument; + manager.swap(forward); + return; + } + if (std.mem.eql(u8, name, "move")) { + const direction = arguments.next() orelse return error.InvalidArgument; + const delta_raw = arguments.next() orelse return error.InvalidArgument; + if (arguments.next() != null) return error.InvalidArgument; + const delta = std.fmt.parseInt(i32, delta_raw, 10) catch return error.InvalidArgument; + manager.moveFocused(direction, delta) catch return error.InvalidArgument; + return; + } + if (std.mem.eql(u8, name, "resize")) { + const axis = arguments.next() orelse return error.InvalidArgument; + const delta_raw = arguments.next() orelse return error.InvalidArgument; + if (arguments.next() != null) return error.InvalidArgument; + const delta = std.fmt.parseInt(i32, delta_raw, 10) catch return error.InvalidArgument; + manager.resizeFocused(axis, delta) catch return error.InvalidArgument; + return; + } + if (std.mem.eql(u8, name, "border-width")) { + const raw = arguments.next() orelse return error.InvalidArgument; + if (arguments.next() != null) return error.InvalidArgument; + const width = std.fmt.parseInt(i32, raw, 10) catch return error.InvalidArgument; + if (width < 0) return error.InvalidArgument; + manager.border_width = width; + return; + } + if (std.mem.eql(u8, name, "border-color-focused") or std.mem.eql(u8, name, "border-color-unfocused")) { + const raw = arguments.next() orelse return error.InvalidArgument; + if (arguments.next() != null) return error.InvalidArgument; + const color = parseRGBA(raw) catch return error.InvalidArgument; + if (std.mem.eql(u8, name, "border-color-focused")) { + manager.border_focused = color; + } else { + manager.border_unfocused = color; + } + return; + } + if (std.mem.eql(u8, name, "focus-follows-cursor")) { + const raw = arguments.next() orelse return error.InvalidArgument; + if (arguments.next() != null) return error.InvalidArgument; + if (std.mem.eql(u8, raw, "always") or std.mem.eql(u8, raw, "normal")) { + manager.focus_follows_cursor = true; + } else if (std.mem.eql(u8, raw, "disabled")) { + manager.focus_follows_cursor = false; + } else return error.InvalidArgument; + return; + } + if (std.mem.eql(u8, name, "set-cursor-warp")) { + const raw = arguments.next() orelse return error.InvalidArgument; + if (arguments.next() != null) return error.InvalidArgument; + if (std.mem.eql(u8, raw, "on-focus")) { + manager.cursor_warp = true; + } else if (std.mem.eql(u8, raw, "disabled")) { + manager.cursor_warp = false; + } else return error.InvalidArgument; + return; + } + if (std.mem.eql(u8, name, "xcursor-theme")) { + const theme = arguments.next() orelse return error.InvalidArgument; + const size_raw = arguments.next() orelse return error.InvalidArgument; + if (arguments.next() != null) return error.InvalidArgument; + const size = std.fmt.parseInt(u32, size_raw, 10) catch return error.InvalidArgument; + if (manager.cursor_theme_name) |old| gpa.free(old); + manager.cursor_theme_name = gpa.dupe(u8, theme) catch return error.OutOfMemory; + manager.cursor_theme_size = size; + for (manager.seats.items) |seat| manager.applyCursorTheme(seat); + return; + } + if (std.mem.eql(u8, name, "snap")) { + const direction = arguments.next() orelse return error.InvalidArgument; + if (arguments.next() != null) return error.InvalidArgument; + manager.snapFocused(direction) catch return error.InvalidArgument; + return; + } + if (std.mem.eql(u8, name, "set-repeat")) { + const rate_raw = arguments.next() orelse return error.InvalidArgument; + const delay_raw = arguments.next() orelse return error.InvalidArgument; + if (arguments.next() != null) return error.InvalidArgument; + const rate = std.fmt.parseInt(i32, rate_raw, 10) catch return error.InvalidArgument; + const delay = std.fmt.parseInt(i32, delay_raw, 10) catch return error.InvalidArgument; + if (rate < 0 or delay < 0) return error.InvalidArgument; + manager.input.setRepeat(rate, delay); + return; + } + if (std.mem.eql(u8, name, "keyboard-layout")) { + var rules: ?[]const u8 = null; + var model: ?[]const u8 = null; + var variant: ?[]const u8 = null; + var options: ?[]const u8 = null; + var layout: ?[]const u8 = null; + while (arguments.next()) |argument| { + if (std.mem.eql(u8, argument, "-rules")) { + rules = arguments.next() orelse return error.InvalidArgument; + } else if (std.mem.eql(u8, argument, "-model")) { + model = arguments.next() orelse return error.InvalidArgument; + } else if (std.mem.eql(u8, argument, "-variant")) { + variant = arguments.next() orelse return error.InvalidArgument; + } else if (std.mem.eql(u8, argument, "-options")) { + options = arguments.next() orelse return error.InvalidArgument; + } else if (layout == null) { + layout = argument; + } else return error.InvalidArgument; + } + const chosen = layout orelse return error.InvalidArgument; + Input.setLayout(&manager.input, rules, model, chosen, variant, options) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => return error.InvalidArgument, + }; + return; + } + if (std.mem.eql(u8, name, "keyboard-layout-file")) { + const path = arguments.next() orelse return error.InvalidArgument; + if (arguments.next() != null) return error.InvalidArgument; + Input.setLayoutFile(&manager.input, path) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => return error.InvalidArgument, + }; + return; + } + if (std.mem.eql(u8, name, "input")) { + const pattern = arguments.next() orelse return error.InvalidArgument; + var setting = arguments.next() orelse return error.InvalidArgument; + var value = arguments.next(); + var extra = arguments.next(); + var category: ?[]const u8 = null; + if (extra != null) { + category = setting; + setting = value orelse return error.InvalidArgument; + value = extra; + extra = arguments.next(); + } + if (extra != null) return error.InvalidArgument; + var matches: std.ArrayListUnmanaged(*Input.Device) = .empty; + defer matches.deinit(gpa); + manager.input.matchAll(pattern, category, &matches); + if (matches.items.len == 0) { + // Not an error: the setting is stored and applied to any + // device that appears later. A pattern that never matches + // logs here once per apply, which is enough to spot typos. + log.warn("no input device matches '{s}' yet", .{pattern}); + return; + } + for (matches.items) |device| { + Input.configure(device, setting, value) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + // Virtual devices such as nested-backend keyboards have + // no libinput object. Skip them and keep applying to the + // remaining matches. + error.NoLibinputDevice => continue, + else => { + const device_name = device.name orelse "(unnamed)"; + log.warn("input setting '{s}' failed for {s}: {s}", .{ + setting, device_name, @errorName(err), + }); + }, + }; + } + return; + } + if (std.mem.eql(u8, name, "toggle-float")) { + if (arguments.next() != null) return error.InvalidArgument; + if (manager.seats.items.len == 0) return; + const focused = manager.seats.items[0].focused_window orelse return; + const window = manager.findWindow(focused) orelse return; + window.float = !window.float; + return; + } + if (std.mem.eql(u8, name, "toggle-fullscreen")) { + if (arguments.next() != null) return error.InvalidArgument; + if (manager.seats.items.len == 0) return; + const focused = manager.seats.items[0].focused_window orelse return; + const window = manager.findWindow(focused) orelse return; + window.fullscreen = !window.fullscreen; + if (window.object) |object| { + if (window.fullscreen) { + const target = window.output orelse manager.focusedOutput(); + if (target) |output| object.fullscreen(output.object); + object.informFullscreen(); + } else { + object.exitFullscreen(); + object.informNotFullscreen(); + } + } + return; + } + if (std.mem.eql(u8, name, "rule-add") or std.mem.eql(u8, name, "rule-del")) { + try manager.defineRule(&arguments, std.mem.eql(u8, name, "rule-del")); + return; + } + return error.UnknownCommand; +} + +/// rule-add [-app-id glob] [-title glob] [value] +/// rule-del [-app-id glob] [-title glob] +fn defineRule(manager: *WindowManager, arguments: *Command.Cursor, remove: bool) Error!void { + var app_id_glob: []const u8 = "*"; + var title_glob: []const u8 = "*"; + var action_raw: ?[]const u8 = null; + var value_raw: ?[]const u8 = null; + while (arguments.next()) |argument| { + if (std.mem.eql(u8, argument, "-app-id")) { + app_id_glob = arguments.next() orelse return error.InvalidArgument; + } else if (std.mem.eql(u8, argument, "-title")) { + title_glob = arguments.next() orelse return error.InvalidArgument; + } else if (action_raw == null) { + action_raw = argument; + } else if (value_raw == null) { + value_raw = argument; + } else return error.InvalidArgument; + } + const action_name = action_raw orelse return error.InvalidArgument; + const action: Rule.Action = if (std.mem.eql(u8, action_name, "float")) + .float + else if (std.mem.eql(u8, action_name, "fullscreen")) + .fullscreen + else if (std.mem.eql(u8, action_name, "ssd")) + .ssd + else if (std.mem.eql(u8, action_name, "csd")) + .csd + else if (std.mem.eql(u8, action_name, "tags")) + .tags + else + return error.InvalidArgument; + + Rule.validate(app_id_glob) catch return error.InvalidGlob; + Rule.validate(title_glob) catch return error.InvalidGlob; + + if (remove) { + var index: usize = 0; + while (index < manager.rules.items.len) { + const rule = manager.rules.items[index]; + if (rule.action == action and + std.mem.eql(u8, rule.app_id_glob, app_id_glob) and + std.mem.eql(u8, rule.title_glob, title_glob)) + { + _ = manager.rules.swapRemove(index); + gpa.free(rule.app_id_glob); + gpa.free(rule.title_glob); + return; + } + index += 1; + } + return; + } + + var tags: u32 = 0; + if (action == .tags) { + const raw = value_raw orelse return error.InvalidArgument; + tags = std.fmt.parseInt(u32, raw, 10) catch return error.InvalidArgument; + } else if (value_raw != null) return error.InvalidArgument; + + const owned_app_id = gpa.dupe(u8, app_id_glob) catch return error.OutOfMemory; + errdefer gpa.free(owned_app_id); + const owned_title = gpa.dupe(u8, title_glob) catch return error.OutOfMemory; + errdefer gpa.free(owned_title); + for (manager.rules.items) |*rule| { + if (rule.action != action or + !std.mem.eql(u8, rule.app_id_glob, app_id_glob) or + !std.mem.eql(u8, rule.title_glob, title_glob)) continue; + gpa.free(rule.app_id_glob); + gpa.free(rule.title_glob); + rule.* = .{ + .app_id_glob = owned_app_id, + .title_glob = owned_title, + .action = action, + .tags = tags, + }; + return; + } + manager.rules.append(gpa, .{ + .app_id_glob = owned_app_id, + .title_glob = owned_title, + .action = action, + .tags = tags, + }) catch return error.OutOfMemory; +} + +const Error = error{ + UnknownCommand, + InvalidArgument, + InvalidModifier, + InvalidKeysym, + InvalidButton, + InvalidGlob, + OutOfMemory, +}; + +/// map +fn defineBinding(manager: *WindowManager, arguments: *Command.Cursor) Error!void { + const mode_name = arguments.next() orelse return error.InvalidArgument; + const modifiers_raw = arguments.next() orelse return error.InvalidArgument; + const keysym_raw = arguments.next() orelse return error.InvalidArgument; + + const modifiers = Binding.parseModifiers(modifiers_raw) catch return error.InvalidModifier; + const keysym = try Binding.parseKeysym(keysym_raw); + + const first = arguments.next() orelse return error.InvalidArgument; + const command = try joinArguments(arguments, first); + errdefer gpa.free(command); + + const mode = manager.ensureMode(mode_name); + for (mode.specifications.items) |*specification| { + if (specification.keysym == keysym and std.meta.eql(specification.modifiers, modifiers)) { + gpa.free(specification.command); + specification.* = .{ .keysym = keysym, .modifiers = modifiers, .command = command }; + manager.refreshBindings(mode); + return; + } + } + mode.specifications.append(gpa, .{ + .keysym = keysym, + .modifiers = modifiers, + .command = command, + }) catch { + gpa.free(command); + return error.OutOfMemory; + }; + manager.refreshBindings(mode); +} + +fn removeBinding(manager: *WindowManager, mode_name: []const u8, keysym: u32, modifiers: river.SeatV1.Modifiers) void { + const index = manager.modeIndex(mode_name) orelse return; + const mode = &manager.modes.items[index]; + for (mode.specifications.items, 0..) |specification, specification_index| { + if (specification.keysym != keysym or + !std.meta.eql(specification.modifiers, modifiers)) continue; + _ = mode.specifications.swapRemove(specification_index); + gpa.free(specification.command); + break; + } + for (manager.seats.items) |seat| { + if (!std.mem.eql(u8, manager.modes.items[seat.mode_index].name, mode.name)) continue; + var binding_index: usize = 0; + while (binding_index < seat.bindings.items.len) { + const binding = seat.bindings.items[binding_index]; + if (binding.keysym != keysym or !std.meta.eql(binding.modifiers, modifiers)) { + binding_index += 1; + continue; + } + _ = seat.bindings.swapRemove(binding_index); + binding.deinit(); + gpa.destroy(binding); + } + } +} + +fn removePointerBinding(manager: *WindowManager, mode_name: []const u8, button: u32, modifiers: river.SeatV1.Modifiers) void { + const index = manager.modeIndex(mode_name) orelse return; + const mode = &manager.modes.items[index]; + for (mode.pointer_specifications.items, 0..) |specification, specification_index| { + if (specification.button != button or + !std.meta.eql(specification.modifiers, modifiers)) continue; + _ = mode.pointer_specifications.swapRemove(specification_index); + gpa.free(specification.command); + break; + } + for (manager.seats.items) |seat| { + if (!std.mem.eql(u8, manager.modes.items[seat.mode_index].name, mode.name)) continue; + var binding_index: usize = 0; + while (binding_index < seat.pointer_bindings.items.len) { + const binding = seat.pointer_bindings.items[binding_index]; + if (binding.button != button or !std.meta.eql(binding.modifiers, modifiers)) { + binding_index += 1; + continue; + } + _ = seat.pointer_bindings.swapRemove(binding_index); + binding.deinit(); + gpa.destroy(binding); + } + } +} + +/// map-pointer