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

commitf67ec3b7a2e519def9f48adfe318374340af4bc4
parent2db9133216
authorIsaac Freund <[email protected]>
date2026-01-14 16:21
protocol: add xkb_bindings_seat_v1.ensure_next_key_eaten

 protocol/river-xkb-bindings-v1.xml |  80 ++++++++++++++++++++++++++++
 river/KeyboardGroup.zig            |  14 +++++
 river/Seat.zig                     |   6 +++
 river/XkbBindings.zig              |  14 ++++-
 river/XkbBindingsSeat.zig          | 105 +++++++++++++++++++++++++++++++++++++
 5 files changed, 218 insertions(+), 1 deletion(-)

diff --git a/protocol/river-xkb-bindings-v1.xml b/protocol/river-xkb-bindings-v1.xml
index c8e5dec..888eeea 100644
--- a/protocol/river-xkb-bindings-v1.xml
+++ b/protocol/river-xkb-bindings-v1.xml
@@ -44,6 +44,10 @@
       river_window_manager_v1 global is also advertised.
     </description>
 
+    <enum name="error" since="2">
+      <entry name="object_already_created" value="0" since="2"/>
+    </enum>
+
     <request name="destroy" type="destructor">
       <description summary="destroy the river_xkb_bindings_v1 object">
         This request indicates that the client will no longer use the
@@ -64,6 +68,17 @@
       <arg name="keysym" type="uint" summary="an xkbcommon keysym"/>
       <arg name="modifiers" type="uint" enum="river_seat_v1.modifiers"/>
     </request>
+
+    <request name="get_seat" since="2">
+      <description summary="manage seat-specific state">
+        Create an object to manage seat-specific xkb bindings state.
+
+        It is a protocol error to make this request more than once for a given
+        river_seat_v1 object.
+      </description>
+      <arg name="id" type="new_id" interface="river_xkb_bindings_seat_v1"/>
+      <arg name="seat" type="object" interface="river_seat_v1"/>
+    </request>
   </interface>
 
   <interface name="river_xkb_binding_v1" version="2">
@@ -174,4 +189,69 @@
       </description>
     </event>
   </interface>
+
+  <interface name="river_xkb_bindings_seat_v1" version="2">
+    <description summary="xkb bindings seat">
+      This object manages xkb bindings state associated with a specific seat.
+    </description>
+
+    <request name="destroy" type="destructor" since="2">
+      <description summary="destroy the object">
+        This request indicates that the client will no longer use the object and
+        that it may be safely destroyed.
+      </description>
+    </request>
+
+    <request name="ensure_next_key_eaten" since="2">
+      <description summary="ensure the next key press event is eaten">
+        Ensure that the next key press and corresponding release events for this
+        seat are not sent to the currently focused surface.
+
+        If the next key press triggers a binding, the pressed/released events are
+        sent to the river_xkb_binding_v1 object as usual.
+
+        If the next key press does not trigger a binding, the ate_unbound_key
+        event is sent instead.
+
+        Rationale: the window manager may wish to implement "chorded"
+        keybindings where triggering a binding activates a "submap" with a
+        different set of keybindings. Without a way to eat the next key
+        press event, there is no good way for the window manager to know that it
+        should error out and exit the submap when a key not bound in the submap
+        is pressed.
+
+        This request modifies window management state and may only be made as
+        part of a manage sequence, see the river_window_manager_v1 description.
+      </description>
+    </request>
+
+    <request name="cancel_ensure_next_key_eaten" since="2">
+      <description summary="cancel an ensure_next_key_eaten request">
+        This requests cancels the effect of the latest ensure_next_key_eaten
+        request if no key has been eaten due to the request yet. This request
+        has no effect if a key has already been eaten or no
+        ensure_next_key_eaten was made.
+
+        Rationale: the window manager may wish cancel an uncompleted "chorded"
+        keybinding after a timeout of a few seconds. Note that since this
+        timeout use-case requires the window manager to trigger a manage sequence
+        with the river_window_manager_v1.manage_dirty request it is possible that
+        the ate_unbound_key key event may be sent before the window manager has
+        a chance to make the cancel_ensure_next_key_eaten request.
+
+        This request modifies window management state and may only be made as
+        part of a manage sequence, see the river_window_manager_v1 description.
+      </description>
+    </request>
+
+    <event name="ate_unbound_key" since="2">
+      <description summary="an unbound key press event was eaten">
+        An unbound key press event was eaten due to the ensure_next_key_eaten
+        request.
+
+        This event will be followed by a manage_start event after all other new
+        state has been sent by the server.
+      </description>
+    </event>
+  </interface>
 </protocol>
diff --git a/river/KeyboardGroup.zig b/river/KeyboardGroup.zig
index ec74805..0a9f06b 100644
--- a/river/KeyboardGroup.zig
+++ b/river/KeyboardGroup.zig
@@ -24,6 +24,9 @@ const KeyConsumer = union(enum) {
     /// A null value indicates that the xkb_binding_v1 was destroyed or that
     /// a press event was already sent due to a press on a different keyboard.
     binding: ?*XkbBinding,
+    /// The river_xkb_bindings_seat_v1.ensure_next_key_eaten request caused
+    /// the key to be eaten.
+    ensure_eaten,
     im_grab,
     /// Seat's focused client
     focus,
@@ -194,10 +197,15 @@ fn handleKey(listener: *wl.Listener(*wlr.Keyboard.event.Key), event: *wlr.Keyboa
         }
         if (group.seat.matchXkbBinding(xkb_keycode, modifiers, xkb_state)) |binding| {
             log.debug("matched xkb binding", .{});
+            group.seat.xkb_bindings_seat.ensure_next_key_eaten = false;
             break :blk .{
                 .binding = if (binding.sent_pressed) null else binding,
             };
         }
+        if (group.seat.xkb_bindings_seat.ensure_next_key_eaten) {
+            group.seat.xkb_bindings_seat.ensure_next_key_eaten = false;
+            break :blk .ensure_eaten;
+        }
         if (group.getInputMethodGrab() != null) {
             break :blk .im_grab;
         }
@@ -220,6 +228,12 @@ fn handleKey(listener: *wl.Listener(*wlr.Keyboard.event.Key), event: *wlr.Keyboa
                 binding.released();
             }
         },
+        .ensure_eaten => {
+            if (event.state == .pressed) {
+                group.seat.xkb_bindings_seat.scheduled.ate_unbound_key = true;
+                server.wm.dirtyWindowing();
+            }
+        },
         .im_grab => if (group.getInputMethodGrab()) |keyboard_grab| {
             keyboard_grab.setKeyboard(&group.state);
             keyboard_grab.sendKey(event.time_msec, event.keycode, event.state);
diff --git a/river/Seat.zig b/river/Seat.zig
index 27ad75d..48c906e 100644
--- a/river/Seat.zig
+++ b/river/Seat.zig
@@ -34,6 +34,7 @@ const ShellSurface = @import("ShellSurface.zig");
 const Tablet = @import("Tablet.zig");
 const Window = @import("Window.zig");
 const XkbBinding = @import("XkbBinding.zig");
+const XkbBindingsSeat = @import("XkbBindingsSeat.zig");
 const XwaylandOverrideRedirect = @import("XwaylandOverrideRedirect.zig");
 
 const log = std.log.scoped(.input);
@@ -96,6 +97,7 @@ destroying: bool = false,
 
 object: ?*river.SeatV1 = null,
 layer_shell: LayerShellSeat = .{},
+xkb_bindings_seat: XkbBindingsSeat = .{},
 
 event_queue: Deque(Event),
 
@@ -331,6 +333,7 @@ pub fn manageStart(seat: *Seat) void {
             seat_v1.sendRemoved();
             seat_v1.setHandler(?*anyopaque, handleRequestInert, null, null);
             seat.layer_shell.makeInert();
+            seat.xkb_bindings_seat.makeInert();
             seat.object = null;
         }
         seat.destroy();
@@ -338,6 +341,7 @@ pub fn manageStart(seat: *Seat) void {
     }
 
     seat.layer_shell.manageStart();
+    seat.xkb_bindings_seat.manageStart();
 
     if (server.wm.object) |wm_v1| {
         const new = seat.object == null;
@@ -538,6 +542,8 @@ fn handleRequest(
 }
 
 pub fn manageFinish(seat: *Seat) void {
+    seat.xkb_bindings_seat.manageFinish();
+
     if (server.lock_manager.state != .unlocked) return;
 
     switch (seat.layer_shell.sent.focus) {
diff --git a/river/XkbBindings.zig b/river/XkbBindings.zig
index 470bda2..8f8a558 100644
--- a/river/XkbBindings.zig
+++ b/river/XkbBindings.zig
@@ -22,7 +22,7 @@ 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),
+        .global = try wl.Global.create(server.wl_server, river.XkbBindingsV1, 2, ?*anyopaque, null, bind),
     };
     server.wl_server.addDestroyListener(&bindings.server_destroy);
 }
@@ -66,5 +66,17 @@ fn handleRequest(
                 return;
             };
         },
+        .get_seat => |args| {
+            const seat_data = args.seat.getUserData() orelse return;
+            const seat: *Seat = @ptrCast(@alignCast(seat_data));
+            if (seat.xkb_bindings_seat.object != null) {
+                object.postError(
+                    .object_already_created,
+                    "river_xkb_bindings_seat_v1 already created",
+                );
+                return;
+            }
+            seat.xkb_bindings_seat.createObject(object.getClient(), object.getVersion(), args.id);
+        },
     }
 }
diff --git a/river/XkbBindingsSeat.zig b/river/XkbBindingsSeat.zig
new file mode 100644
index 0000000..932d70d
--- /dev/null
+++ b/river/XkbBindingsSeat.zig
@@ -0,0 +1,105 @@
+// SPDX-FileCopyrightText: © 2026 The River Developers
+// SPDX-License-Identifier: GPL-3.0-only
+
+const XkbBindingsSeat = @This();
+
+const std = @import("std");
+const assert = std.debug.assert;
+const wlr = @import("wlroots");
+const wayland = @import("wayland");
+const wl = wayland.server.wl;
+const river = wayland.server.river;
+
+const server = &@import("main.zig").server;
+const util = @import("util.zig");
+
+const Seat = @import("Seat.zig");
+
+const log = std.log.scoped(.wm);
+
+object: ?*river.XkbBindingsSeatV1 = null,
+
+scheduled: struct {
+    ate_unbound_key: bool = false,
+} = .{},
+requested: struct {
+    next_key_change: enum {
+        none,
+        ensure_eaten,
+        cancel_ensure_eaten,
+    } = .none,
+} = .{},
+
+ensure_next_key_eaten: bool = false,
+
+pub fn createObject(
+    bindings_seat: *XkbBindingsSeat,
+    client: *wl.Client,
+    version: u32,
+    id: u32,
+) void {
+    assert(bindings_seat.object == null);
+    bindings_seat.object = river.XkbBindingsSeatV1.create(client, version, id) catch {
+        client.postNoMemory();
+        return;
+    };
+    bindings_seat.object.?.setHandler(*XkbBindingsSeat, handleRequest, handleDestroy, bindings_seat);
+}
+
+pub fn makeInert(bindings_seat: *XkbBindingsSeat) void {
+    if (bindings_seat.object) |object| {
+        object.setHandler(?*anyopaque, handleRequestInert, null, null);
+        bindings_seat.object = null;
+    }
+}
+
+fn handleRequestInert(
+    object: *river.XkbBindingsSeatV1,
+    request: river.XkbBindingsSeatV1.Request,
+    _: ?*anyopaque,
+) void {
+    if (request == .destroy) object.destroy();
+}
+
+fn handleDestroy(_: *river.XkbBindingsSeatV1, bindings_seat: *XkbBindingsSeat) void {
+    bindings_seat.object = null;
+}
+
+fn handleRequest(
+    object: *river.XkbBindingsSeatV1,
+    request: river.XkbBindingsSeatV1.Request,
+    bindings_seat: *XkbBindingsSeat,
+) void {
+    assert(bindings_seat.object == object);
+    switch (request) {
+        .destroy => object.destroy(),
+        .ensure_next_key_eaten => {
+            if (!server.wm.ensureWindowing()) return;
+            bindings_seat.requested.next_key_change = .ensure_eaten;
+        },
+        .cancel_ensure_next_key_eaten => {
+            if (!server.wm.ensureWindowing()) return;
+            bindings_seat.requested.next_key_change = .cancel_ensure_eaten;
+        },
+    }
+}
+
+pub fn manageStart(bindings_seat: *XkbBindingsSeat) void {
+    if (bindings_seat.scheduled.ate_unbound_key) {
+        if (bindings_seat.object) |object| {
+            if (object.getVersion() >= 2) {
+                object.sendAteUnboundKey();
+            }
+        }
+        bindings_seat.scheduled.ate_unbound_key = false;
+    }
+}
+
+pub fn manageFinish(bindings_seat: *XkbBindingsSeat) void {
+    switch (bindings_seat.requested.next_key_change) {
+        .none => {},
+        .ensure_eaten => bindings_seat.ensure_next_key_eaten = true,
+        .cancel_ensure_eaten => bindings_seat.ensure_next_key_eaten = false,
+    }
+    bindings_seat.requested.next_key_change = .none;
+}