Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git
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;
+}