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

commit3278f657e8701d1857003fac725157018cb481ec
parentc41ffa9776
authorIsaac Freund <[email protected]>
date2024-12-28 14:28
river: implement river_xkb_binding_v1

 build.zig                               |   1 +
 build.zig.zon                           |   4 +-
 protocol/river-window-management-v1.xml |   4 +-
 river/Config.zig                        |   2 -
 river/Keyboard.zig                      |  64 +++++-----
 river/Mapping.zig                       |  97 ---------------
 river/Seat.zig                          | 106 +++++++++++-----
 river/XkbBinding.zig                    | 211 ++++++++++++++++++++++++++++++++
 rivercompat/Seat.zig                    |  37 +++++-
 rivercompat/WindowManager.zig           |   2 +-
 rivercompat/XkbBinding.zig              |  56 +++++++++
 11 files changed, 415 insertions(+), 169 deletions(-)

diff --git a/build.zig b/build.zig
index 250874a..17cee2f 100644
--- a/build.zig
+++ b/build.zig
@@ -202,6 +202,7 @@ pub fn build(b: *Build) !void {
 
         rivercompat.root_module.addImport("flags", flags);
         rivercompat.root_module.addImport("wayland", wayland);
+        rivercompat.root_module.addImport("xkbcommon", xkbcommon);
         rivercompat.linkLibC();
         rivercompat.linkSystemLibrary("wayland-client");
 
diff --git a/build.zig.zon b/build.zig.zon
index e967d71..0b14132 100644
--- a/build.zig.zon
+++ b/build.zig.zon
@@ -8,8 +8,8 @@
             .hash = "12209db20ce873af176138b76632931def33a10539387cba745db72933c43d274d56",
         },
         .@"zig-wayland" = .{
-            .url = "https://codeberg.org/ifreund/zig-wayland/archive/2500fc313608be05370ebb67426a05604f2fd937.tar.gz",
-            .hash = "12200e8929e6f520bab7c6229a5d550f818bd6109077d2b99dee4aa79aa6f4835344",
+            .url = "https://codeberg.org/ifreund/zig-wayland/archive/a5e2e9b6a6d7fba638ace4d4b24a3b576a02685b.tar.gz",
+            .hash = "1220d41b23ae70e93355bb29dac1c07aa6aeb92427a2dffc4375e94b4de18111248c",
         },
         .@"zig-wlroots" = .{
             .url = "https://codeberg.org/ifreund/zig-wlroots/archive/70a4de5cd6886003e54f55239d2753b025271bc9.tar.gz",
diff --git a/protocol/river-window-management-v1.xml b/protocol/river-window-management-v1.xml
index 31abbec..453880f 100644
--- a/protocol/river-window-management-v1.xml
+++ b/protocol/river-window-management-v1.xml
@@ -1177,7 +1177,7 @@
       <entry name="mod5" value="128"/>
     </enum>
 
-    <request name="define_xkb_binding">
+    <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.
@@ -1191,7 +1191,7 @@
       <arg name="modifiers" type="uint" enum="modifiers"/>
     </request>
 
-    <request name="define_pointer_binding">
+    <request name="get_pointer_binding">
       <description summary="define a new pointer binding">
         Define a pointer binding in terms of a pointer button, modifiers, and
         other configurable properties.
diff --git a/river/Config.zig b/river/Config.zig
index fd1b129..a36b73e 100644
--- a/river/Config.zig
+++ b/river/Config.zig
@@ -28,7 +28,6 @@ const util = @import("util.zig");
 
 const Server = @import("Server.zig");
 const Output = @import("Output.zig");
-const Mapping = @import("Mapping.zig");
 const Switch = @import("Switch.zig");
 const Window = @import("Window.zig");
 
@@ -51,7 +50,6 @@ border_width: u31 = 2,
 /// Color of border in RGBA with premultiplied alpha
 border_color: [4]f32 = [_]f32{ 0.34509804, 0.43137255, 0.45882353, 1.0 }, // Solarized base01
 
-mappings: std.ArrayListUnmanaged(Mapping) = .{},
 pointer_mappings: std.ArrayListUnmanaged(struct {
     event_code: u32,
     modifiers: wlr.Keyboard.ModifierMask,
diff --git a/river/Keyboard.zig b/river/Keyboard.zig
index baf2ba2..7cf32ee 100644
--- a/river/Keyboard.zig
+++ b/river/Keyboard.zig
@@ -26,8 +26,9 @@ const globber = @import("globber");
 const server = &@import("main.zig").server;
 const util = @import("util.zig");
 
-const Seat = @import("Seat.zig");
 const InputDevice = @import("InputDevice.zig");
+const Seat = @import("Seat.zig");
+const XkbBinding = @import("XkbBinding.zig");
 
 const log = std.log.scoped(.input);
 
@@ -36,10 +37,12 @@ pub const Event = union(enum) {
     modifiers: wlr.Keyboard.Modifiers,
 };
 
-const KeyConsumer = enum {
-    mapping,
+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,
     im_grab,
-    /// Seat's focused client (xdg or layer shell)
+    /// Seat's focused client
     focus,
 };
 
@@ -152,23 +155,16 @@ pub fn deinit(keyboard: *Keyboard) void {
 pub fn processKey(keyboard: *Keyboard, event: *const wlr.Keyboard.event.Key) void {
     const wlr_keyboard = keyboard.device.wlr_device.toKeyboard();
 
-    // If the keyboard is in a group, this event will be handled by the group's Keyboard instance.
-    if (wlr_keyboard.group != null) return;
-
-    keyboard.device.seat.handleActivity();
-
     // Translate libinput keycode -> xkbcommon
-    const keycode = event.keycode + 8;
+    const xkb_keycode = event.keycode + 8;
 
     // XXX this is not ok, we need to store current modifiers per-Keyboard ourselves
     const modifiers = wlr_keyboard.getModifiers();
     const released = event.state == .released;
 
-    // We must ref() the state here as a mapping could change the keyboard layout.
-    const xkb_state = (wlr_keyboard.xkb_state orelse return).ref();
-    defer xkb_state.unref();
+    const xkb_state = wlr_keyboard.xkb_state orelse return;
 
-    const keysyms = xkb_state.keyGetSyms(keycode);
+    const keysyms = xkb_state.keyGetSyms(xkb_keycode);
 
     for (keysyms) |sym| {
         if (!released and handleBuiltinMapping(sym)) return;
@@ -179,7 +175,7 @@ pub fn processKey(keyboard: *Keyboard, event: *const wlr.Keyboard.event.Key) voi
     // other than to ignore any newer presses. No need to worry about pairing
     // the correct release, as the client is unlikely to send all of them
     // (and we already ignore releasing keys we don't know were pressed).
-    if (!released and keyboard.pressed.contains(event.keycode)) {
+    if (!released and keyboard.pressed.contains(xkb_keycode)) {
         log.err("key pressed again without release, virtual-keyboard client bug?", .{});
         return;
     }
@@ -193,7 +189,7 @@ pub fn processKey(keyboard: *Keyboard, event: *const wlr.Keyboard.event.Key) voi
         if (released) {
             // The released key might not be in the pressed set when switching from a different tty
             // or if the press was ignored due to >32 keys being pressed simultaneously.
-            break :blk keyboard.pressed.remove(event.keycode) orelse return;
+            break :blk keyboard.pressed.remove(xkb_keycode) orelse return;
         }
 
         // Ignore key presses beyond 32 simultaneously pressed keys (see comments in Pressed).
@@ -201,8 +197,11 @@ pub fn processKey(keyboard: *Keyboard, event: *const wlr.Keyboard.event.Key) voi
         // both the press and release mapping for certain key or neither mapping.
         keyboard.pressed.keys.ensureUnusedCapacity(1) catch return;
 
-        if (keyboard.device.seat.handleMapping(keycode, modifiers, released, xkb_state)) {
-            break :blk .mapping;
+        if (keyboard.device.seat.matchXkbBinding(xkb_keycode, modifiers, xkb_state)) |binding| {
+            log.debug("matched xkb binding", .{});
+            break :blk .{
+                .binding = if (binding.sent_pressed) null else binding,
+            };
         } else if (keyboard.getInputMethodGrab() != null) {
             break :blk .im_grab;
         }
@@ -211,13 +210,17 @@ pub fn processKey(keyboard: *Keyboard, event: *const wlr.Keyboard.event.Key) voi
     };
 
     if (!released) {
-        keyboard.pressed.addAssumeCapacity(.{ .code = event.keycode, .consumer = consumer });
+        keyboard.pressed.addAssumeCapacity(.{ .code = xkb_keycode, .consumer = consumer });
     }
 
     switch (consumer) {
-        // Press mappings are handled above when determining the consumer of the press
-        // Release mappings are handled separately as they are executed independent of the consumer.
-        .mapping => {},
+        .binding => |b| if (b) |binding| {
+            if (released) {
+                binding.released();
+            } else {
+                binding.pressed();
+            }
+        },
         .im_grab => if (keyboard.getInputMethodGrab()) |keyboard_grab| {
             keyboard_grab.setKeyboard(keyboard_grab.keyboard);
             keyboard_grab.sendKey(event.time_msec, event.keycode, event.state);
@@ -228,17 +231,9 @@ pub fn processKey(keyboard: *Keyboard, event: *const wlr.Keyboard.event.Key) voi
             wlr_seat.keyboardNotifyKey(event.time_msec, event.keycode, event.state);
         },
     }
-
-    // Release mappings don't interact with anything
-    if (released) _ = keyboard.device.seat.handleMapping(keycode, modifiers, released, xkb_state);
 }
 
 pub fn processModifiers(keyboard: *Keyboard, modifiers: *const wlr.Keyboard.Modifiers) void {
-    const wlr_keyboard = keyboard.device.wlr_device.toKeyboard();
-
-    // If the keyboard is in a group, this event will be handled by the group's Keyboard instance.
-    if (wlr_keyboard.group != null) return;
-
     if (keyboard.getInputMethodGrab()) |keyboard_grab| {
         keyboard_grab.setKeyboard(keyboard_grab.keyboard);
         keyboard_grab.sendModifiers(modifiers);
@@ -284,10 +279,19 @@ fn getInputMethodGrab(keyboard: Keyboard) ?*wlr.InputMethodV2.KeyboardGrab {
 
 fn queueKey(listener: *wl.Listener(*wlr.Keyboard.event.Key), event: *wlr.Keyboard.event.Key) void {
     const keyboard: *Keyboard = @fieldParentPtr("key", listener);
+    const wlr_keyboard = keyboard.device.wlr_device.toKeyboard();
+
+    // If the keyboard is in a group, this event will be handled by the group's Keyboard instance.
+    if (wlr_keyboard.group != null) return;
+
     keyboard.device.seat.queueEvent(.{ .keyboard_key = .{ .keyboard = keyboard, .key = event.* } });
 }
 
 fn queueModifiers(listener: *wl.Listener(*wlr.Keyboard), wlr_keyboard: *wlr.Keyboard) void {
     const keyboard: *Keyboard = @fieldParentPtr("modifiers", listener);
+
+    // If the keyboard is in a group, this event will be handled by the group's Keyboard instance.
+    if (wlr_keyboard.group != null) return;
+
     keyboard.device.seat.queueEvent(.{ .keyboard_modifiers = .{ .keyboard = keyboard, .modifiers = wlr_keyboard.modifiers } });
 }
diff --git a/river/Mapping.zig b/river/Mapping.zig
deleted file mode 100644
index 0393cb7..0000000
--- a/river/Mapping.zig
+++ /dev/null
@@ -1,97 +0,0 @@
-// This file is part of river, a dynamic tiling wayland compositor.
-//
-// Copyright 2020 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 Mapping = @This();
-
-const std = @import("std");
-const wlr = @import("wlroots");
-const xkb = @import("xkbcommon");
-
-const util = @import("util.zig");
-
-keysym: xkb.Keysym,
-modifiers: wlr.Keyboard.ModifierMask,
-options: Options,
-
-pub const Options = struct {
-    /// When set to true the mapping will be executed on key release rather than on press
-    release: bool,
-    // This is set for mappings with layout-pinning
-    // If set, the layout with this index is always used to translate the given keycode
-    layout_index: ?u32,
-};
-
-/// Compare mapping with given keycode, modifiers and keyboard state
-pub fn match(
-    mapping: Mapping,
-    keycode: xkb.Keycode,
-    modifiers: wlr.Keyboard.ModifierMask,
-    released: bool,
-    xkb_state: *xkb.State,
-    method: enum { no_translate, translate },
-) bool {
-    if (released != mapping.options.release) return false;
-
-    const keymap = xkb_state.getKeymap();
-
-    // If the mapping has no pinned layout, use the active layout.
-    // It doesn't matter if the index is out of range, since xkbcommon
-    // will fall back to the active layout if so.
-    const layout_index = mapping.options.layout_index orelse xkb_state.keyGetLayout(keycode);
-
-    switch (method) {
-        .no_translate => {
-            // Get keysyms from the base layer, as if modifiers didn't change keysyms.
-            // E.g. pressing `Super+Shift 1` does not translate to `Super Exclam`.
-            const keysyms = keymap.keyGetSymsByLevel(
-                keycode,
-                layout_index,
-                0,
-            );
-
-            if (@as(u32, @bitCast(modifiers)) == @as(u32, @bitCast(mapping.modifiers))) {
-                for (keysyms) |sym| {
-                    if (sym == mapping.keysym) {
-                        return true;
-                    }
-                }
-            }
-        },
-        .translate => {
-            // Keysyms and modifiers as translated by xkb.
-            // Modifiers used to translate the key are consumed.
-            // E.g. pressing `Super+Shift 1` translates to `Super Exclam`.
-            const keysyms_translated = keymap.keyGetSymsByLevel(
-                keycode,
-                layout_index,
-                xkb_state.keyGetLevel(keycode, layout_index),
-            );
-
-            const consumed = xkb_state.keyGetConsumedMods2(keycode, .xkb);
-            const modifiers_translated = @as(u32, @bitCast(modifiers)) & ~consumed;
-
-            if (modifiers_translated == @as(u32, @bitCast(mapping.modifiers))) {
-                for (keysyms_translated) |sym| {
-                    if (sym == mapping.keysym) {
-                        return true;
-                    }
-                }
-            }
-        },
-    }
-
-    return false;
-}
diff --git a/river/Seat.zig b/river/Seat.zig
index 8f9e3c0..82c9c8f 100644
--- a/river/Seat.zig
+++ b/river/Seat.zig
@@ -36,12 +36,12 @@ const InputRelay = @import("InputRelay.zig");
 const Keyboard = @import("Keyboard.zig");
 const KeyboardGroup = @import("KeyboardGroup.zig");
 const LockSurface = @import("LockSurface.zig");
-const Mapping = @import("Mapping.zig");
 const Output = @import("Output.zig");
 const PointerConstraint = @import("PointerConstraint.zig");
 const Switch = @import("Switch.zig");
 const Tablet = @import("Tablet.zig");
 const Window = @import("Window.zig");
+const XkbBinding = @import("XkbBinding.zig");
 const XwaylandOverrideRedirect = @import("XwaylandOverrideRedirect.zig");
 
 const log = std.log.scoped(.input);
@@ -136,6 +136,8 @@ uncommitted: WmState = .{},
 /// State requested by the window manager client and committed.
 committed: WmState = .{},
 
+xkb_bindings: wl.list.Head(XkbBinding, .link),
+
 /// Multiple physical mice are handled by the same Cursor
 cursor: Cursor,
 
@@ -171,6 +173,7 @@ pub fn create(name: [*:0]const u8) !void {
         .link = undefined,
         .link_pending = undefined,
         .link_sent = undefined,
+        .xkb_bindings = undefined,
         .cursor = undefined,
         .relay = undefined,
     };
@@ -181,6 +184,8 @@ pub fn create(name: [*:0]const u8) !void {
     seat.link_sent.init();
     server.wm.dirtyPending();
 
+    seat.xkb_bindings.init();
+
     try seat.cursor.init(seat);
     seat.relay.init();
 
@@ -337,6 +342,26 @@ pub fn sendDirty(seat: *Seat) void {
                 seat.pending.window_interaction = null;
             }
         }
+
+        {
+            var it = seat.xkb_bindings.iterator(.forward);
+            while (it.next()) |binding| {
+                switch (binding.pending.state_change) {
+                    .none => {},
+                    .pressed => {
+                        assert(!binding.sent_pressed);
+                        binding.sent_pressed = true;
+                        binding.object.sendPressed();
+                    },
+                    .released => {
+                        assert(binding.sent_pressed);
+                        binding.sent_pressed = false;
+                        binding.object.sendReleased();
+                    },
+                }
+                binding.pending.state_change = .none;
+            }
+        }
     }
 }
 
@@ -374,12 +399,32 @@ fn handleRequest(
         .pointer_resize_window => {},
         .pointer_confine_to_region => {},
         .pointer_warp => {},
-        .define_xkb_binding => {},
-        .define_pointer_binding => {},
+        .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 => {},
     }
 }
 
 pub fn commitWmState(seat: *Seat) void {
+    {
+        var it = seat.xkb_bindings.iterator(.forward);
+        while (it.next()) |binding| {
+            binding.committed = binding.uncommitted;
+        }
+    }
+
     seat.committed = seat.uncommitted;
 }
 
@@ -475,34 +520,34 @@ pub fn handleActivity(seat: Seat) void {
 
 /// Handle any user-defined mapping for passed keycode, modifiers and keyboard state
 /// Returns true if a mapping was run
-pub fn handleMapping(
-    _: *Seat,
+pub fn matchXkbBinding(
+    seat: *Seat,
     keycode: xkb.Keycode,
     modifiers: wlr.Keyboard.ModifierMask,
-    released: bool,
     xkb_state: *xkb.State,
-) bool {
-    const mappings = &server.config.mappings;
-
-    // It is possible for more than one mapping to be matched due to the
-    // existence of layout-independent mappings. It is also possible due to
+) ?*XkbBinding {
+    // It is possible for more than one binding to be matched due to the
+    // existence of layout-independent bindings. It is also possible due to
     // translation by xkbcommon consuming modifiers. On the swedish layout
     // for example, translating Super+Shift+Space may consume the Shift
-    // modifier and confict with a mapping for Super+Space. For this reason,
+    // modifier and confict with a binding for Super+Space. For this reason,
     // matching wihout xkbcommon translation is done first and after a match
     // has been found all further matches are ignored.
-    var found: ?*Mapping = null;
+    var found: ?*XkbBinding = null;
 
     // First check for matches without translating keysyms with xkbcommon.
     // That is, if the physical keys Mod+Shift+1 are pressed on a US layout don't
     // translate the keysym 1 to an exclamation mark. This behavior is generally
     // what is desired.
-    for (mappings.items) |*mapping| {
-        if (mapping.match(keycode, modifiers, released, xkb_state, .no_translate)) {
-            if (found == null) {
-                found = mapping;
-            } else {
-                log.debug("already found a matching mapping, ignoring additional match", .{});
+    {
+        var it = seat.xkb_bindings.iterator(.forward);
+        while (it.next()) |binding| {
+            if (binding.match(keycode, modifiers, xkb_state, .no_translate)) {
+                if (found == null) {
+                    found = binding;
+                } else {
+                    log.debug("already found a matching binding, ignoring additional match", .{});
+                }
             }
         }
     }
@@ -511,23 +556,20 @@ pub fn handleMapping(
     // with xkbcommon for intuitive behavior. For example, layouts may require
     // translation with the numlock modifier to obtain keypad number keysyms
     // (e.g. KP_1).
-    for (mappings.items) |*mapping| {
-        if (mapping.match(keycode, modifiers, released, xkb_state, .translate)) {
-            if (found == null) {
-                found = mapping;
-            } else {
-                log.debug("already found a matching mapping, ignoring additional match", .{});
+    {
+        var it = seat.xkb_bindings.iterator(.forward);
+        while (it.next()) |binding| {
+            if (binding.match(keycode, modifiers, xkb_state, .translate)) {
+                if (found == null) {
+                    found = binding;
+                } else {
+                    log.debug("already found a matching binding, ignoring additional match", .{});
+                }
             }
         }
     }
 
-    // The mapped command must be run outside of the loop above as it may modify
-    // the list of mappings we are iterating through, possibly causing it to be re-allocated.
-    if (found) |_| {
-        return true;
-    }
-
-    return false;
+    return found;
 }
 
 /// Handle any user-defined mapping for switches
diff --git a/river/XkbBinding.zig b/river/XkbBinding.zig
new file mode 100644
index 0000000..f9644e0
--- /dev/null
+++ b/river/XkbBinding.zig
@@ -0,0 +1,211 @@
+// This file is part of river, a dynamic tiling wayland compositor.
+//
+// Copyright 2020-2024 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 XkbBinding = @This();
+
+const std = @import("std");
+const assert = std.debug.assert;
+const wlr = @import("wlroots");
+const xkb = @import("xkbcommon");
+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 Keyboard = @import("Keyboard.zig");
+const Seat = @import("Seat.zig");
+
+const log = std.log.scoped(.input);
+
+const WmState = struct {
+    enabled: bool = false,
+    // This is set for mappings with layout-pinning
+    // If set, the layout with this index is always used to translate the given keycode
+    layout: ?u32 = null,
+};
+
+seat: *Seat,
+object: *river.XkbBindingV1,
+
+keysym: xkb.Keysym,
+modifiers: river.SeatV1.Modifiers,
+
+pending: struct {
+    state_change: enum {
+        none,
+        pressed,
+        released,
+    } = .none,
+} = .{},
+uncommitted: WmState = .{},
+committed: WmState = .{},
+
+/// This bit of state is used to ensure that multiple simultaneous
+/// presses across multiple keyboards do not cause multiple press
+/// events to be sent to the window manager.
+sent_pressed: bool = false,
+
+/// Seat.xkb_bindings
+link: wl.list.Link,
+
+pub fn create(
+    seat: *Seat,
+    client: *wl.Client,
+    version: u32,
+    id: u32,
+    keysym: xkb.Keysym,
+    modifiers: river.SeatV1.Modifiers,
+) !void {
+    const binding = try util.gpa.create(XkbBinding);
+    errdefer util.gpa.destroy(binding);
+
+    const xkb_binding_v1 = try river.XkbBindingV1.create(client, version, id);
+    errdefer comptime unreachable;
+
+    {
+        var buffer: [64]u8 = undefined;
+        _ = keysym.getName(&buffer, buffer.len);
+        log.debug("new river_xkb_binding_v1: keysym: {d}({s}) modifiers: {d}", .{
+            @intFromEnum(keysym),
+            &buffer,
+            @as(u32, @bitCast(modifiers)),
+        });
+    }
+
+    binding.* = .{
+        .seat = seat,
+        .object = xkb_binding_v1,
+        .keysym = keysym,
+        .modifiers = modifiers,
+        .link = undefined,
+    };
+    xkb_binding_v1.setHandler(*XkbBinding, handleRequest, handleDestroy, binding);
+
+    seat.xkb_bindings.append(binding);
+}
+
+fn handleDestroy(_: *river.XkbBindingV1, binding: *XkbBinding) void {
+    {
+        var it = server.input_manager.devices.iterator(.forward);
+        while (it.next()) |device| {
+            if (device.seat == binding.seat and device.wlr_device.type == .keyboard) {
+                const keyboard: *Keyboard = @fieldParentPtr("device", device);
+                for (keyboard.pressed.keys.slice()) |*key| {
+                    if (key.consumer == .binding and key.consumer.binding == binding) {
+                        key.consumer.binding = null;
+                    }
+                }
+            }
+        }
+    }
+    binding.link.remove();
+    util.gpa.destroy(binding);
+}
+
+fn handleRequest(
+    xkb_binding_v1: *river.XkbBindingV1,
+    request: river.XkbBindingV1.Request,
+    binding: *XkbBinding,
+) void {
+    assert(binding.object == xkb_binding_v1);
+    switch (request) {
+        .destroy => xkb_binding_v1.destroy(),
+        .set_layout_override => |args| binding.uncommitted.layout = args.layout,
+        .enable => binding.uncommitted.enabled = true,
+        .disable => binding.uncommitted.enabled = false,
+    }
+}
+
+pub fn pressed(binding: *XkbBinding) void {
+    assert(!binding.sent_pressed);
+    // Input event processing should not continue after a press/release event
+    // until that event is sent to the window manager in an update and acked.
+    assert(binding.pending.state_change == .none);
+    binding.pending.state_change = .pressed;
+    server.wm.dirtyPending();
+}
+
+pub fn released(binding: *XkbBinding) void {
+    assert(binding.sent_pressed);
+    // Input event processing should not continue after a press/release event
+    // until that event is sent to the window manager in an update and acked.
+    assert(binding.pending.state_change == .none);
+    binding.pending.state_change = .released;
+    server.wm.dirtyPending();
+}
+
+/// Compare binding with given keycode, modifiers and keyboard state
+pub fn match(
+    binding: *const XkbBinding,
+    keycode: xkb.Keycode,
+    modifiers: wlr.Keyboard.ModifierMask,
+    xkb_state: *xkb.State,
+    method: enum { no_translate, translate },
+) bool {
+    if (!binding.committed.enabled) return false;
+
+    const keymap = xkb_state.getKeymap();
+
+    // If the binding has no pinned layout, use the active layout.
+    // It doesn't matter if the index is out of range, since xkbcommon
+    // will fall back to the active layout if so.
+    const layout = binding.committed.layout orelse xkb_state.keyGetLayout(keycode);
+
+    switch (method) {
+        .no_translate => {
+            // Get keysyms from the base layer, as if modifiers didn't change keysyms.
+            // E.g. pressing `Super+Shift 1` does not translate to `Super Exclam`.
+            const keysyms = keymap.keyGetSymsByLevel(
+                keycode,
+                layout,
+                0,
+            );
+
+            if (@as(u32, @bitCast(modifiers)) == @as(u32, @bitCast(binding.modifiers))) {
+                for (keysyms) |sym| {
+                    if (sym == binding.keysym) {
+                        return true;
+                    }
+                }
+            }
+        },
+        .translate => {
+            // Keysyms and modifiers as translated by xkb.
+            // Modifiers used to translate the key are consumed.
+            // E.g. pressing `Super+Shift 1` translates to `Super Exclam`.
+            const keysyms_translated = keymap.keyGetSymsByLevel(
+                keycode,
+                layout,
+                xkb_state.keyGetLevel(keycode, layout),
+            );
+
+            const consumed = xkb_state.keyGetConsumedMods2(keycode, .xkb);
+            const modifiers_translated = @as(u32, @bitCast(modifiers)) & ~consumed;
+
+            if (modifiers_translated == @as(u32, @bitCast(binding.modifiers))) {
+                for (keysyms_translated) |sym| {
+                    if (sym == binding.keysym) {
+                        return true;
+                    }
+                }
+            }
+        },
+    }
+
+    return false;
+}
diff --git a/rivercompat/Seat.zig b/rivercompat/Seat.zig
index 72d1d15..6bf1b25 100644
--- a/rivercompat/Seat.zig
+++ b/rivercompat/Seat.zig
@@ -19,21 +19,53 @@ const Seat = @This();
 const std = @import("std");
 const assert = std.debug.assert;
 const wayland = @import("wayland");
+const xkb = @import("xkbcommon");
 const wl = wayland.client.wl;
 const river = wayland.client.river;
 
 const Window = @import("Window.zig");
+const WindowManager = @import("WindowManager.zig");
+const XkbBinding = @import("XkbBinding.zig");
 
 const gpa = std.heap.c_allocator;
 
+wm: *WindowManager,
 seat_v1: *river.SeatV1,
+focused: ?*Window = null,
 
-pub fn create(seat_v1: *river.SeatV1) void {
+pub fn create(wm: *WindowManager, seat_v1: *river.SeatV1) void {
     const seat = gpa.create(Seat) catch @panic("OOM");
     seat.* = .{
+        .wm = wm,
         .seat_v1 = seat_v1,
     };
     seat_v1.setListener(*Seat, handleEvent, seat);
+
+    XkbBinding.create(seat, xkb.Keysym.n, .{ .mod1 = true });
+}
+
+pub fn focus(seat: *Seat, target: ?*Window) void {
+    if (target) |window| {
+        seat.seat_v1.focusWindow(window.window_v1);
+        seat.focused = window;
+
+        window.link.remove();
+        seat.wm.windows.prepend(window);
+
+        window.node_v1.placeTop();
+    } else {
+        seat.seat_v1.clearFocus();
+    }
+}
+
+pub fn focusNext(seat: *Seat) void {
+    if (seat.focused != null) {
+        if (seat.wm.windows.length() >= 2) {
+            seat.focus(seat.wm.windows.last().?);
+        }
+    } else if (seat.wm.windows.first()) |top| {
+        seat.focus(top);
+    }
 }
 
 fn handleEvent(seat_v1: *river.SeatV1, event: river.SeatV1.Event, seat: *Seat) void {
@@ -49,8 +81,7 @@ fn handleEvent(seat_v1: *river.SeatV1, event: river.SeatV1.Event, seat: *Seat) v
         .window_interaction => |args| {
             const window_v1 = args.window orelse return;
             const window: *Window = @ptrCast(@alignCast(window_v1.getUserData()));
-            seat_v1.focusWindow(window_v1);
-            window.node_v1.placeTop();
+            seat.focus(window);
         },
     }
 }
diff --git a/rivercompat/WindowManager.zig b/rivercompat/WindowManager.zig
index 1e3b00f..b0a42c5 100644
--- a/rivercompat/WindowManager.zig
+++ b/rivercompat/WindowManager.zig
@@ -58,7 +58,7 @@ fn handleEvent(wm_v1: *river.WindowManagerV1, event: river.WindowManagerV1.Event
             _ = args;
         },
         .seat => |args| {
-            Seat.create(args.id);
+            Seat.create(wm, args.id);
         },
     }
 }
diff --git a/rivercompat/XkbBinding.zig b/rivercompat/XkbBinding.zig
new file mode 100644
index 0000000..6bdf856
--- /dev/null
+++ b/rivercompat/XkbBinding.zig
@@ -0,0 +1,56 @@
+// This file is part of river, a dynamic tiling wayland compositor.
+//
+// Copyright 2024 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 XkbBinding = @This();
+
+const std = @import("std");
+const assert = std.debug.assert;
+const wayland = @import("wayland");
+const wl = wayland.client.wl;
+const river = wayland.client.river;
+
+const Seat = @import("Seat.zig");
+const Window = @import("Window.zig");
+
+const gpa = std.heap.c_allocator;
+
+seat: *Seat,
+xkb_binding_v1: *river.XkbBindingV1,
+
+pub fn create(
+    seat: *Seat,
+    keysym: u32,
+    modifiers: river.SeatV1.Modifiers,
+) void {
+    const xkb_binding_v1 = seat.seat_v1.getXkbBinding(keysym, modifiers) catch @panic("OOM");
+    const binding = gpa.create(XkbBinding) catch @panic("OOM");
+    binding.* = .{
+        .seat = seat,
+        .xkb_binding_v1 = xkb_binding_v1,
+    };
+    xkb_binding_v1.setListener(*XkbBinding, handleEvent, binding);
+    xkb_binding_v1.enable();
+}
+
+fn handleEvent(xkb_binding_v1: *river.XkbBindingV1, event: river.XkbBindingV1.Event, binding: *XkbBinding) void {
+    assert(binding.xkb_binding_v1 == xkb_binding_v1);
+    switch (event) {
+        .pressed => {
+            binding.seat.focusNext();
+        },
+        .released => {},
+    }
+}