git.lucas.co / cce-compositor
Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git

commitd9119791cd52e34612e17a09d0f12ec87e008356
parente43ba9ab6c
authorIsaac Freund <[email protected]>
date2025-08-11 10:52
rwm: extract xkb bindings to an extension protocol

This makes the core river-window-management-v1 protocol independent of
possible future (breaking) changes to xkbcommon, or possible replacement
with some other keyboard library in the hypothetical far future.

 build.zig                               |   2 +
 protocol/river-window-management-v1.xml | 122 -----------------------
 protocol/river-xkb-bindings-v1.xml      | 171 ++++++++++++++++++++++++++++++++
 river/Seat.zig                          |  14 ---
 river/Server.zig                        |   4 +
 river/XkbBindings.zig                   |  83 ++++++++++++++++
 rivercompat/WindowManager.zig           |   3 +
 rivercompat/XkbBinding.zig              |   4 +-
 rivercompat/main.zig                    |   7 +-
 9 files changed, 272 insertions(+), 138 deletions(-)

diff --git a/build.zig b/build.zig
index a32d3ca..ad37ef6 100644
--- a/build.zig
+++ b/build.zig
@@ -78,6 +78,7 @@ pub fn build(b: *Build) !void {
     scanner.addSystemProtocol("unstable/xdg-decoration/xdg-decoration-unstable-v1.xml");
 
     scanner.addCustomProtocol(b.path("protocol/river-window-management-v1.xml"));
+    scanner.addCustomProtocol(b.path("protocol/river-xkb-bindings-v1.xml"));
     scanner.addCustomProtocol(b.path("protocol/wlr-output-power-management-unstable-v1.xml"));
 
     // Some of these versions may be out of date with what wlroots implements.
@@ -103,6 +104,7 @@ pub fn build(b: *Build) !void {
     scanner.generate("wp_single_pixel_buffer_manager_v1", 1);
 
     scanner.generate("river_window_manager_v1", 1);
+    scanner.generate("river_xkb_bindings_v1", 1);
 
     scanner.generate("zwlr_output_power_manager_v1", 1);
 
diff --git a/protocol/river-window-management-v1.xml b/protocol/river-window-management-v1.xml
index 112c3b3..1942c9f 100644
--- a/protocol/river-window-management-v1.xml
+++ b/protocol/river-window-management-v1.xml
@@ -1531,19 +1531,6 @@
       <entry name="mod5" value="128"/>
     </enum>
 
-    <request name="get_xkb_binding">
-      <description summary="define a new xkbcommon key binding">
-        Define a key binding in terms of an xkbcommon keysym and other
-        configurable properties.
-
-        The new key binding is not enabled until initial configuration is
-        completed and the enable request is made during a manage sequence.
-      </description>
-      <arg name="id" type="new_id" interface="river_xkb_binding_v1"/>
-      <arg name="keysym" type="uint" summary="an xkbcommon keysym"/>
-      <arg name="modifiers" type="uint" enum="modifiers"/>
-    </request>
-
     <request name="get_pointer_binding">
       <description summary="define a new pointer binding">
         Define a pointer binding in terms of a pointer button, modifiers, and
@@ -1561,115 +1548,6 @@
     </request>
   </interface>
 
-  <interface name="river_xkb_binding_v1" version="1">
-    <description summary="configure a xkb key binding, receive trigger events">
-      This object allows the window manager to configure a xkbcommon key binding
-      and receive events when the key binding is triggered.
-
-      The new key binding is not enabled until the enable request is made during
-      a manage sequence.
-
-      Normally, all key events are sent to the surface with keyboard focus by
-      the compositor. Key events that trigger a key binding are not sent to the
-      surface with keyboard focus.
-
-      If multiple key bindings would be triggered by a single physical key event
-      on the compositor side, it is compositor policy which key binding(s) will
-      receive press/release events or if all of the matched key bindings receive
-      press/release events.
-
-      Key bindings might be matched by the same physical key event due to shared
-      keysym and modifiers. The layout override feature may also cause the same
-      physical key event to trigger two key bindings with different keysyms and
-      different layout overrides configured.
-    </description>
-
-    <request name="destroy" type="destructor">
-      <description summary="destroy the xkb binding object">
-        This request indicates that the client will no longer use the xkb key
-        binding object and that it may be safely destroyed.
-      </description>
-    </request>
-
-    <request name="set_layout_override">
-      <description summary="override currently active xkb layout">
-        Specify an xkb layout that should be used to translate key events for
-        the purpose of triggering this key binding irrespective of the currently
-        active xkb layout.
-
-        The layout argument is a 0-indexed xkbcommon layout number for the
-        keyboard that generated the key event.
-
-        If this request is never made, the currently active xkb layout of the
-        keyboard that generated the key event will be used.
-
-        This request modifies window management state and may only be made as
-        part of a manage sequence, see the river_window_manager_v1 description.
-      </description>
-      <arg name="layout" type="uint" summary="0-indexed xkbcommon layout"/>
-    </request>
-
-    <request name="enable">
-      <description summary="enable the key binding">
-        This request should be made after all initial configuration has been
-        completed and the window manager wishes the key binding to be able to be
-        triggered.
-
-        This request modifies window management state and may only be made as
-        part of a manage sequence, see the river_window_manager_v1 description.
-      </description>
-    </request>
-
-    <request name="disable">
-      <description summary="disable the key binding">
-        This request may be used to temporarily disable the key binding. It may
-        be later re-enabled with the enable request.
-
-        This request modifies window management state and may only be made as
-        part of a manage sequence, see the river_window_manager_v1 description.
-      </description>
-    </request>
-
-    <event name="pressed">
-      <description summary="the key triggering the binding has been pressed">
-        This event indicates that the physical key triggering the binding has
-        been pressed.
-
-        This event will be followed by a manage_start event after all other new
-        state has been sent by the server.
-
-        The compositor should wait for the manage sequence to complete before
-        processing further input events. This allows the window manager client
-        to, for example, modify key bindings and keyboard focus without racing
-        against future input events. The window manager should of course respond
-        as soon as possible as the capacity of the compositor to buffer incoming
-        input events is finite.
-      </description>
-    </event>
-
-    <event name="released">
-      <description summary="the key triggering the binding has been released">
-        This event indicates that the physical key triggering the binding has
-        been released.
-
-        Releasing the modifiers for the binding without releasing the "main"
-        physical key that produces the bound keysym does not trigger the release
-        event. This event is sent when the "main" key is released, even if the
-        modifiers have changed since the pressed event.
-
-        This event will be followed by a manage_start event after all other new
-        state has been sent by the server.
-
-        The compositor should wait for the manage sequence to complete before
-        processing further input events. This allows the window manager client
-        to, for example, modify key bindings and keyboard focus without racing
-        against future input events. The window manager should of course respond
-        as soon as possible as the capacity of the compositor to buffer incoming
-        input events is finite.
-      </description>
-    </event>
-  </interface>
-
   <interface name="river_pointer_binding_v1" version="1">
     <description summary="configure a pointer binding, receive trigger events">
       This object allows the window manager to configure a pointer binding and
diff --git a/protocol/river-xkb-bindings-v1.xml b/protocol/river-xkb-bindings-v1.xml
new file mode 100644
index 0000000..f04f106
--- /dev/null
+++ b/protocol/river-xkb-bindings-v1.xml
@@ -0,0 +1,171 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<protocol name="river_xkb_bindings_v1">
+  <copyright>
+    Copyright 2025 The River Developers
+
+    Permission to use, copy, modify, and/or distribute this software for any
+    purpose with or without fee is hereby granted, provided that the above
+    copyright notice and this permission notice appear in all copies.
+
+    THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+    WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+    MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
+    SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+    WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION
+    OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN
+    CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+  </copyright>
+
+  <description summary="frame-perfect window management">
+    This protocol allows a single "window manager" client to determine the
+    window management policy of the compositor. State is globally
+    double-buffered allowing for frame perfect state changes involving multiple
+    windows.
+
+    The key words "must", "must not", "required", "shall", "shall not",
+    "should", "should not", "recommended", "may", and "optional" in this
+    document are to be interpreted as described in IETF RFC 2119.
+
+    Warning! The protocol described in this file is currently in the testing
+    phase. Backward compatible changes may be added together with the
+    corresponding interface version bump. Backward incompatible changes can only
+    be done by creating a new major version of the extension.
+  </description>
+
+  <interface name="river_xkb_bindings_v1" version="1">
+    <description summary="xkbcommon bindings global interface">
+      This global interface should only be advertised to the client if the
+      river_window_manager_v1 global is also advertised.
+    </description>
+
+    <request name="destroy" type="destructor">
+      <description summary="destroy the river_xkb_bindings_v1 object">
+        This request indicates that the client will no longer use the
+        river_xkb_bindings_v1 object.
+      </description>
+    </request>
+
+    <request name="get_xkb_binding">
+      <description summary="define a new xkbcommon key binding">
+        Define a key binding for the given seat in terms of an xkbcommon keysym
+        and other configurable properties.
+
+        The new key binding is not enabled until initial configuration is
+        completed and the enable request is made during a manage sequence.
+      </description>
+      <arg name="seat" type="object" interface="river_seat_v1"/>
+      <arg name="id" type="new_id" interface="river_xkb_binding_v1"/>
+      <arg name="keysym" type="uint" summary="an xkbcommon keysym"/>
+      <arg name="modifiers" type="uint" enum="river_seat_v1.modifiers"/>
+    </request>
+  </interface>
+
+  <interface name="river_xkb_binding_v1" version="1">
+    <description summary="configure a xkb key binding, receive trigger events">
+      This object allows the window manager to configure a xkbcommon key binding
+      and receive events when the key binding is triggered.
+
+      The new key binding is not enabled until the enable request is made during
+      a manage sequence.
+
+      Normally, all key events are sent to the surface with keyboard focus by
+      the compositor. Key events that trigger a key binding are not sent to the
+      surface with keyboard focus.
+
+      If multiple key bindings would be triggered by a single physical key event
+      on the compositor side, it is compositor policy which key binding(s) will
+      receive press/release events or if all of the matched key bindings receive
+      press/release events.
+
+      Key bindings might be matched by the same physical key event due to shared
+      keysym and modifiers. The layout override feature may also cause the same
+      physical key event to trigger two key bindings with different keysyms and
+      different layout overrides configured.
+    </description>
+
+    <request name="destroy" type="destructor">
+      <description summary="destroy the xkb binding object">
+        This request indicates that the client will no longer use the xkb key
+        binding object and that it may be safely destroyed.
+      </description>
+    </request>
+
+    <request name="set_layout_override">
+      <description summary="override currently active xkb layout">
+        Specify an xkb layout that should be used to translate key events for
+        the purpose of triggering this key binding irrespective of the currently
+        active xkb layout.
+
+        The layout argument is a 0-indexed xkbcommon layout number for the
+        keyboard that generated the key event.
+
+        If this request is never made, the currently active xkb layout of the
+        keyboard that generated the key event will be used.
+
+        This request modifies window management state and may only be made as
+        part of a manage sequence, see the river_window_manager_v1 description.
+      </description>
+      <arg name="layout" type="uint" summary="0-indexed xkbcommon layout"/>
+    </request>
+
+    <request name="enable">
+      <description summary="enable the key binding">
+        This request should be made after all initial configuration has been
+        completed and the window manager wishes the key binding to be able to be
+        triggered.
+
+        This request modifies window management state and may only be made as
+        part of a manage sequence, see the river_window_manager_v1 description.
+      </description>
+    </request>
+
+    <request name="disable">
+      <description summary="disable the key binding">
+        This request may be used to temporarily disable the key binding. It may
+        be later re-enabled with the enable request.
+
+        This request modifies window management state and may only be made as
+        part of a manage sequence, see the river_window_manager_v1 description.
+      </description>
+    </request>
+
+    <event name="pressed">
+      <description summary="the key triggering the binding has been pressed">
+        This event indicates that the physical key triggering the binding has
+        been pressed.
+
+        This event will be followed by a manage_start event after all other new
+        state has been sent by the server.
+
+        The compositor should wait for the manage sequence to complete before
+        processing further input events. This allows the window manager client
+        to, for example, modify key bindings and keyboard focus without racing
+        against future input events. The window manager should of course respond
+        as soon as possible as the capacity of the compositor to buffer incoming
+        input events is finite.
+      </description>
+    </event>
+
+    <event name="released">
+      <description summary="the key triggering the binding has been released">
+        This event indicates that the physical key triggering the binding has
+        been released.
+
+        Releasing the modifiers for the binding without releasing the "main"
+        physical key that produces the bound keysym does not trigger the release
+        event. This event is sent when the "main" key is released, even if the
+        modifiers have changed since the pressed event.
+
+        This event will be followed by a manage_start event after all other new
+        state has been sent by the server.
+
+        The compositor should wait for the manage sequence to complete before
+        processing further input events. This allows the window manager client
+        to, for example, modify key bindings and keyboard focus without racing
+        against future input events. The window manager should of course respond
+        as soon as possible as the capacity of the compositor to buffer incoming
+        input events is finite.
+      </description>
+    </event>
+  </interface>
+</protocol>
diff --git a/river/Seat.zig b/river/Seat.zig
index fcb4853..8ad11a3 100644
--- a/river/Seat.zig
+++ b/river/Seat.zig
@@ -463,20 +463,6 @@ fn handleRequest(
         .pointer_confine_to_region => {},
         .pointer_warp => {},
 
-        .get_xkb_binding => |args| {
-            XkbBinding.create(
-                seat,
-                seat_v1.getClient(),
-                seat_v1.getVersion(),
-                args.id,
-                @enumFromInt(args.keysym),
-                args.modifiers,
-            ) catch {
-                seat_v1.getClient().postNoMemory();
-                log.err("out of memory", .{});
-                return;
-            };
-        },
         .get_pointer_binding => |args| {
             PointerBinding.create(
                 seat,
diff --git a/river/Server.zig b/river/Server.zig
index 74260a9..4835695 100644
--- a/river/Server.zig
+++ b/river/Server.zig
@@ -38,6 +38,7 @@ const SceneNodeData = @import("SceneNodeData.zig");
 const Seat = @import("Seat.zig");
 const TabletTool = @import("TabletTool.zig");
 const WindowManager = @import("WindowManager.zig");
+const XkbBindings = @import("XkbBindings.zig");
 const XdgDecoration = @import("XdgDecoration.zig");
 const XdgToplevel = @import("XdgToplevel.zig");
 const XwaylandOverrideRedirect = @import("XwaylandOverrideRedirect.zig");
@@ -89,6 +90,7 @@ config: Config,
 idle_inhibit_manager: IdleInhibitManager,
 lock_manager: LockManager,
 wm: WindowManager,
+xkb_bindings: XkbBindings,
 
 xwayland: if (build_options.xwayland) ?*wlr.Xwayland else void = if (build_options.xwayland) null,
 new_xsurface: if (build_options.xwayland) wl.Listener(*wlr.XwaylandSurface) else void =
@@ -155,6 +157,7 @@ pub fn init(server: *Server, runtime_xwayland: bool) !void {
         .idle_inhibit_manager = undefined,
         .lock_manager = undefined,
         .wm = undefined,
+        .xkb_bindings = undefined,
     };
 
     if (renderer.getTextureFormats(@intFromEnum(wlr.BufferCap.dmabuf)) != null) {
@@ -173,6 +176,7 @@ pub fn init(server: *Server, runtime_xwayland: bool) !void {
     }
 
     try server.wm.init();
+    try server.xkb_bindings.init();
     try server.scene.init();
     try server.om.init();
     try server.input_manager.init();
diff --git a/river/XkbBindings.zig b/river/XkbBindings.zig
new file mode 100644
index 0000000..b6f1398
--- /dev/null
+++ b/river/XkbBindings.zig
@@ -0,0 +1,83 @@
+// This file is part of river, a dynamic tiling wayland compositor.
+//
+// Copyright 2025 The River Developers
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, version 3.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+const XkbBindings = @This();
+
+const std = @import("std");
+const assert = std.debug.assert;
+const wl = @import("wayland").server.wl;
+const river = @import("wayland").server.river;
+
+const server = &@import("main.zig").server;
+const util = @import("util.zig");
+
+const Seat = @import("Seat.zig");
+const XkbBinding = @import("XkbBinding.zig");
+
+const log = std.log.scoped(.wm);
+
+global: *wl.Global,
+
+server_destroy: wl.Listener(*wl.Server) = .init(handleServerDestroy),
+
+pub fn init(bindings: *XkbBindings) !void {
+    bindings.* = .{
+        .global = try wl.Global.create(server.wl_server, river.XkbBindingsV1, 1, ?*anyopaque, null, bind),
+    };
+    server.wl_server.addDestroyListener(&bindings.server_destroy);
+}
+
+fn handleServerDestroy(listener: *wl.Listener(*wl.Server), _: *wl.Server) void {
+    const bindings: *XkbBindings = @fieldParentPtr("server_destroy", listener);
+
+    bindings.global.destroy();
+}
+
+fn bind(client: *wl.Client, _: ?*anyopaque, version: u32, id: u32) void {
+    const object = river.XkbBindingsV1.create(client, version, id) catch {
+        client.postNoMemory();
+        log.err("out of memory", .{});
+        return;
+    };
+
+    object.setHandler(?*anyopaque, handleRequest, null, null);
+}
+
+fn handleRequest(
+    object: *river.XkbBindingsV1,
+    request: river.XkbBindingsV1.Request,
+    _: ?*anyopaque,
+) void {
+    switch (request) {
+        .destroy => object.destroy(),
+        .get_xkb_binding => |args| {
+            const seat_data = args.seat.getUserData() orelse return;
+            const seat: *Seat = @ptrCast(@alignCast(seat_data));
+            XkbBinding.create(
+                seat,
+                object.getClient(),
+                object.getVersion(),
+                args.id,
+                @enumFromInt(args.keysym),
+                args.modifiers,
+            ) catch {
+                object.getClient().postNoMemory();
+                log.err("out of memory", .{});
+                return;
+            };
+        },
+    }
+}
diff --git a/rivercompat/WindowManager.zig b/rivercompat/WindowManager.zig
index 7a63313..5394703 100644
--- a/rivercompat/WindowManager.zig
+++ b/rivercompat/WindowManager.zig
@@ -31,6 +31,7 @@ const Seat = @import("Seat.zig");
 const Window = @import("Window.zig");
 
 wm_v1: *river.WindowManagerV1,
+xkb_bindings: *river.XkbBindingsV1,
 compositor: *wl.Compositor,
 viewporter: *wp.Viewporter,
 single_pixel: *wp.SinglePixelBufferManagerV1,
@@ -50,12 +51,14 @@ fallback_stack_focus: wl.list.Head(Window, .link_focus),
 pub fn init(
     wm: *WindowManager,
     wm_v1: *river.WindowManagerV1,
+    xkb_bindings: *river.XkbBindingsV1,
     compositor: *wl.Compositor,
     viewporter: *wp.Viewporter,
     single_pixel: *wp.SinglePixelBufferManagerV1,
 ) void {
     wm.* = .{
         .wm_v1 = wm_v1,
+        .xkb_bindings = xkb_bindings,
         .compositor = compositor,
         .viewporter = viewporter,
         .single_pixel = single_pixel,
diff --git a/rivercompat/XkbBinding.zig b/rivercompat/XkbBinding.zig
index 0b19e83..14663c3 100644
--- a/rivercompat/XkbBinding.zig
+++ b/rivercompat/XkbBinding.zig
@@ -22,6 +22,8 @@ const wayland = @import("wayland");
 const wl = wayland.client.wl;
 const river = wayland.client.river;
 
+const wm = &@import("root").wm;
+
 const Seat = @import("Seat.zig");
 const Window = @import("Window.zig");
 
@@ -37,7 +39,7 @@ pub fn create(
     modifiers: river.SeatV1.Modifiers,
     action: Seat.Action,
 ) void {
-    const xkb_binding_v1 = seat.seat_v1.getXkbBinding(keysym, modifiers) catch @panic("OOM");
+    const xkb_binding_v1 = wm.xkb_bindings.getXkbBinding(seat.seat_v1, keysym, modifiers) catch @panic("OOM");
     const binding = gpa.create(XkbBinding) catch @panic("OOM");
     binding.* = .{
         .seat = seat,
diff --git a/rivercompat/main.zig b/rivercompat/main.zig
index 1f72d35..8f4d81b 100644
--- a/rivercompat/main.zig
+++ b/rivercompat/main.zig
@@ -41,6 +41,7 @@ const usage =
 
 const Globals = struct {
     wm_v1: ?*river.WindowManagerV1 = null,
+    xkb_bindings: ?*river.XkbBindingsV1 = null,
     compositor: ?*wl.Compositor = null,
     viewporter: ?*wp.Viewporter = null,
     single_pixel: ?*wp.SinglePixelBufferManagerV1 = null,
@@ -50,6 +51,8 @@ const Globals = struct {
             .global => |global| {
                 if (mem.orderZ(u8, global.interface, river.WindowManagerV1.interface.name) == .eq) {
                     globals.wm_v1 = registry.bind(global.name, river.WindowManagerV1, 1) catch return;
+                } else if (mem.orderZ(u8, global.interface, river.XkbBindingsV1.interface.name) == .eq) {
+                    globals.xkb_bindings = registry.bind(global.name, river.XkbBindingsV1, 1) catch return;
                 } else if (mem.orderZ(u8, global.interface, wl.Compositor.interface.name) == .eq) {
                     globals.compositor = registry.bind(global.name, wl.Compositor, 4) catch return;
                 } else if (mem.orderZ(u8, global.interface, wp.Viewporter.interface.name) == .eq) {
@@ -112,6 +115,8 @@ pub fn main() !void {
 
     const wm_v1 = globals.wm_v1 orelse
         fatal("wayland compositor does not support river-window-management-v1", .{});
+    const xkb_bindings = globals.xkb_bindings orelse
+        fatal("wayland compositor does not support river-xkb_bindings-v1", .{});
     const compositor = globals.compositor orelse
         fatal("wayland compositor does not support wl_compositor", .{});
     const viewporter = globals.viewporter orelse
@@ -119,7 +124,7 @@ pub fn main() !void {
     const single_pixel = globals.single_pixel orelse
         fatal("wayland compositor does not support wp-single-pixel-buffer-v1", .{});
 
-    wm.init(wm_v1, compositor, viewporter, single_pixel);
+    wm.init(wm_v1, xkb_bindings, compositor, viewporter, single_pixel);
 
     while (true) {
         if (display.dispatch() != .SUCCESS) fatal("failed to dispatch wayland events", .{});