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

commitae29902cabd87a9c61679b6ba5f8f66c1708dab6
parent82487930d0
authorIsaac Freund <[email protected]>
date2026-01-09 18:55
protocol: implement river-xkb-config-v1

 build.zig                        |   2 +
 build.zig.zon                    |   4 +-
 protocol/river-xkb-config-v1.xml | 280 +++++++++++++++++++++++++++++++++++++++
 river/Config.zig                 |  42 ------
 river/Cursor.zig                 |   1 -
 river/InputDevice.zig            |   9 ++
 river/Keyboard.zig               |  35 +++--
 river/KeyboardGroup.zig          |  53 ++++++++
 river/Output.zig                 |   1 -
 river/Server.zig                 |  11 +-
 river/XkbConfig.zig              | 167 +++++++++++++++++++++++
 river/XkbKeyboard.zig            | 206 ++++++++++++++++++++++++++++
 river/XkbKeymap.zig              |  80 +++++++++++
 13 files changed, 830 insertions(+), 61 deletions(-)

diff --git a/build.zig b/build.zig
index 1987c4f..81b57d4 100644
--- a/build.zig
+++ b/build.zig
@@ -84,6 +84,7 @@ pub fn build(b: *Build) !void {
     scanner.addCustomProtocol(b.path("protocol/river-layer-shell-v1.xml"));
     scanner.addCustomProtocol(b.path("protocol/river-input-management-v1.xml"));
     scanner.addCustomProtocol(b.path("protocol/river-libinput-config-v1.xml"));
+    scanner.addCustomProtocol(b.path("protocol/river-xkb-config-v1.xml"));
 
     scanner.addCustomProtocol(b.path("protocol/upstream/wlr-layer-shell-unstable-v1.xml"));
     scanner.addCustomProtocol(b.path("protocol/upstream/wlr-output-power-management-unstable-v1.xml"));
@@ -115,6 +116,7 @@ pub fn build(b: *Build) !void {
     scanner.generate("river_layer_shell_v1", 1);
     scanner.generate("river_input_manager_v1", 1);
     scanner.generate("river_libinput_config_v1", 1);
+    scanner.generate("river_xkb_config_v1", 1);
 
     scanner.generate("zwlr_output_power_manager_v1", 1);
     scanner.generate("zwlr_layer_shell_v1", 4);
diff --git a/build.zig.zon b/build.zig.zon
index 25bfa49..e081b0b 100644
--- a/build.zig.zon
+++ b/build.zig.zon
@@ -24,8 +24,8 @@
             .hash = "wlroots-0.19.3-jmOlcuL_AwBHhLCwpFsXbTizE3q9BugFmGX-XIxqcPMc",
         },
         .xkbcommon = .{
-            .url = "https://codeberg.org/ifreund/zig-xkbcommon/archive/v0.3.0.tar.gz",
-            .hash = "xkbcommon-0.3.0-VDqIe3K9AQB2fG5ZeRcMC9i7kfrp5m2rWgLrmdNn9azr",
+            .url = "git+https://codeberg.org/ifreund/zig-xkbcommon?ref=main#a10651157cb1545ee3ae3818b4c7437c64d16462",
+            .hash = "xkbcommon-0.4.0-dev-VDqIe3bUAQB9reSurmSJxQjiQ6bnpekmOxIp_077GhVZ",
         },
     },
     .fingerprint = 0xf5e3672b8e8d6efc,
diff --git a/protocol/river-xkb-config-v1.xml b/protocol/river-xkb-config-v1.xml
new file mode 100644
index 0000000..5b4b3b0
--- /dev/null
+++ b/protocol/river-xkb-config-v1.xml
@@ -0,0 +1,280 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<protocol name="river_xkb_config_v1">
+  <copyright>
+    SPDX-FileCopyrightText: © 2026 Isaac Freund
+    SPDX-License-Identifier: MIT
+
+    Permission is hereby granted, free of charge, to any person obtaining a copy
+    of this software and associated documentation files (the "Software"), to
+    deal in the Software without restriction, including without limitation the
+    rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+    sell copies of the Software, and to permit persons to whom the Software is
+    furnished to do so, subject to the following conditions:
+
+    The above copyright notice and this permission notice shall be included in
+    all copies or substantial portions of the Software.
+
+    THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+    IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+    FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+    AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+    LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+    FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+    IN THE SOFTWARE.
+  </copyright>
+
+  <description summary="configure xkbcommon keyboard state">
+    This protocol allow a client to set the xkbcommon keymap of individual
+    keyboard input devices. It also allows switching between the layouts of a
+    keymap and toggling capslock/numlock state.
+
+    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_config_v1" version="1">
+    <description summary="xkb config global interface">
+      Global interface for configuring xkb devices.
+
+      This global should only be advertised if river_input_manager_v1 is
+      advertised as well.
+    </description>
+
+    <enum name="error">
+      <entry name="invalid_destroy" value="0"/>
+      <entry name="invalid_format" value="1"/>
+    </enum>
+
+    <request name="stop">
+      <description summary="stop sending events">
+        This request indicates that the client no longer wishes to receive
+        events on this object.
+
+        The Wayland protocol is asynchronous, which means the server may send
+        further events until the stop request is processed. The client must wait
+        for a river_xkb_config_v1.finished event before destroying this object.
+      </description>
+    </request>
+
+    <event name="finished">
+      <description summary="the server has finished with the object">
+        This event indicates that the server will send no further events on this
+        object. The client should destroy the object. See
+        river_xkb_config_v1.destroy for more information.
+      </description>
+    </event>
+
+    <request name="destroy" type="destructor">
+      <description summary="destroy the river_xkb_config_v1 object">
+        This request should be called after the finished event has been received
+        to complete destruction of the object.
+
+        It is a protocol error to make this request before the finished event
+        has been received.
+
+        If a client wishes to destroy this object it should send a
+        river_xkb_config_v1.stop request and wait for a
+        river_xkb_config_v1.finished event. Once the finished event is received
+        it is safe to destroy this object and any other objects created through
+        this interface.
+      </description>
+    </request>
+
+    <enum name="keymap_format">
+      <entry name="text_v1" value="1" summary="XKB_KEYMAP_FORMAT_TEXT_V1"/>
+      <entry name="text_v2" value="2" summary="XKB_KEYMAP_FORMAT_TEXT_V2"/>
+    </enum>
+
+    <request name="create_keymap">
+      <description summary="create a keymap object">
+        The server must be able to mmap the fd with MAP_PRIVATE.
+        The server will fstat the fd to obtain the size of the keymap.
+        The client must not modify the contents of the fd after making this request.
+        The client should seal the fd with fcntl.
+      </description>
+      <arg name="id" type="new_id" interface="river_xkb_keymap_v1"/>
+      <arg name="fd" type="fd"/>
+      <arg name="format" type="uint" enum="keymap_format"/>
+    </request>
+
+    <event name="xkb_keyboard">
+      <description summary="new xkb keyboard">
+        A new xkbcommon keyboard has been created. Not every
+        river_input_device_v1 is necessarily an xkbcommon keyboard as well.
+      </description>
+      <arg name="id" type="new_id" interface="river_xkb_keyboard_v1"/>
+    </event>
+  </interface>
+
+  <interface name="river_xkb_keymap_v1" version="1">
+    <description summary="xkbcommon keymap">
+      This object is the result of attempting to create an xkbcommon keymap.
+    </description>
+
+    <request name="destroy" type="destructor">
+      <description summary="destroy the keymap object">
+        This request indicates that the client will no longer use the keymap
+        object and that it may be safely destroyed.
+      </description>
+    </request>
+
+    <event name="success">
+      <description summary="keymap creation succeeded">
+        The keymap object was successfully created and may be used with the
+        river_xkb_keyboard_v1.set_keymap request.
+      </description>
+    </event>
+
+    <event name="failure">
+      <description summary="keymap creation failed">
+        The compositor failed to create a keymap from the given parameters.
+
+        It is a protocol error to use this keymap object with
+        river_xkb_keyboard_v1.set_keymap.
+      </description>
+      <arg name="error_msg" type="string"/>
+    </event>
+  </interface>
+
+  <interface name="river_xkb_keyboard_v1" version="1">
+    <description summary="xkbcommon keyboard device">
+      This object represent a physical keyboard which has its configuration and
+      state managed by xkbcommon.
+    </description>
+
+    <enum name="error">
+      <entry name="invalid_keymap" value="0"/>
+    </enum>
+
+    <request name="destroy" type="destructor">
+      <description summary="destroy the xkb keyboard object">
+        This request indicates that the client will no longer use the keyboard
+        object and that it may be safely destroyed.
+      </description>
+    </request>
+
+    <event name="removed">
+      <description summary="the xkb keyboard is removed">
+        This event indicates that the xkb keyboard has been removed.
+
+        The server will send no further events on this object and ignore any
+        request (other than river_xkb_keyboard_v1.destroy) made after this event
+        is sent. The client should destroy this object with the
+        river_xkb_keyboard_v1.destroy request to free up resources.
+      </description>
+    </event>
+
+    <event name="input_device">
+      <description summary="corresponding river input device">
+        The river_input_device_v1 corresponding to this xkb keyboard. This event
+        will always be the first event sent on the river_xkb_keyboard_v1 object,
+        and it will be sent exactly once.
+      </description>
+      <arg name="device" type="object" interface="river_input_device_v1"/>
+    </event>
+
+    <request name="set_keymap">
+      <description summary="set the keymap">
+        Set the keymap for the keyboard.
+
+        It is a protocol error to pass a keymap object for which the
+        river_xkb_keymap_v1.success event was not received.
+      </description>
+      <arg name="keymap" type="object" interface="river_xkb_keymap_v1"/>
+    </request>
+
+    <request name="set_layout_by_index">
+      <description summary="set the active layout by index">
+        Set the active layout for the keyboard's keymap. Has no effect if the
+        layout index is out of bounds for the current keymap.
+      </description>
+      <arg name="index" type="int"/>
+    </request>
+
+    <request name="set_layout_by_name">
+      <description summary="set the active layout by name">
+        Set the active layout for the keyboard's keymap. Has no effect if there
+        is no layout with the give name for the keyboard's keymap.
+      </description>
+      <arg name="name" type="string"/>
+    </request>
+
+    <event name="layout">
+      <description summary="currently active layout">
+        The currently active layout index and name. The name arg may be null if
+        the active layout does not have a name.
+
+        This event is sent once when the river_xkb_keyboard_v1 is created and
+        again whenever the layout changes.
+      </description>
+      <arg name="index" type="uint"/>
+      <arg name="name" type="string" allow-null="true"/>
+    </event>
+
+    <request name="capslock_enable">
+      <description summary="enable capslock">
+        Enable capslock for the keyboard.
+      </description>
+    </request>
+
+    <request name="capslock_disable">
+      <description summary="disable capslock">
+        Disable capslock for the keyboard.
+      </description>
+    </request>
+
+    <event name="capslock_enabled">
+      <description summary="capslock is currently enabled">
+        Capslock is currently enabled for the keyboard.
+
+        This event is sent once when the river_xkb_keyboard_v1 is created and
+        again whenever the capslock state changes.
+      </description>
+    </event>
+
+    <event name="capslock_disabled">
+      <description summary="capslock is currently disabled">
+        Capslock is currently disabled for the keyboard.
+
+        This event is sent once when the river_xkb_keyboard_v1 is created and
+        again whenever the capslock state changes.
+      </description>
+    </event>
+
+    <request name="numlock_enable">
+      <description summary="enable numlock">
+        Enable numlock for the keyboard.
+      </description>
+    </request>
+
+    <request name="numlock_disable">
+      <description summary="disable numlock">
+        Disable numlock for the keyboard.
+      </description>
+    </request>
+
+    <event name="numlock_enabled">
+      <description summary="numlock is currently enabled">
+        Capslock is currently enabled for the keyboard.
+
+        This event is sent once when the river_xkb_keyboard_v1 is created and
+        again whenever the numlock state changes.
+      </description>
+    </event>
+
+    <event name="numlock_disabled">
+      <description summary="numlock is currently disabled">
+        Capslock is currently disabled for the keyboard.
+
+        This event is sent once when the river_xkb_keyboard_v1 is created and
+        again whenever the numlock state changes.
+      </description>
+    </event>
+  </interface>
+</protocol>
diff --git a/river/Config.zig b/river/Config.zig
deleted file mode 100644
index b715e1b..0000000
--- a/river/Config.zig
+++ /dev/null
@@ -1,42 +0,0 @@
-// SPDX-FileCopyrightText: © 2020 The River Developers
-// SPDX-License-Identifier: GPL-3.0-only
-
-const Config = @This();
-
-const std = @import("std");
-const fmt = std.fmt;
-const mem = std.mem;
-const wlr = @import("wlroots");
-const xkb = @import("xkbcommon");
-
-const server = &@import("main.zig").server;
-const util = @import("util.zig");
-
-const Server = @import("Server.zig");
-
-xkb_context: *xkb.Context,
-/// The xkb keymap used for all keyboards
-keymap: *xkb.Keymap,
-
-pub fn init() !Config {
-    const xkb_context = xkb.Context.new(.no_flags) orelse return error.XkbContextFailed;
-    defer xkb_context.unref();
-
-    // Passing null here indicates that defaults from libxkbcommon and
-    // its XKB_DEFAULT_LAYOUT, XKB_DEFAULT_OPTIONS, etc. should be used.
-    const keymap = xkb.Keymap.newFromNames(xkb_context, null, .no_flags) orelse return error.XkbKeymapFailed;
-    defer keymap.unref();
-
-    var config = Config{
-        .xkb_context = xkb_context.ref(),
-        .keymap = keymap.ref(),
-    };
-    errdefer config.deinit();
-
-    return config;
-}
-
-pub fn deinit(config: *Config) void {
-    config.keymap.unref();
-    config.xkb_context.unref();
-}
diff --git a/river/Cursor.zig b/river/Cursor.zig
index e9e91ae..9bc21c6 100644
--- a/river/Cursor.zig
+++ b/river/Cursor.zig
@@ -16,7 +16,6 @@ const zwlr = wayland.server.zwlr;
 const server = &@import("main.zig").server;
 const util = @import("util.zig");
 
-const Config = @import("Config.zig");
 const DragIcon = @import("DragIcon.zig");
 const InputDevice = @import("InputDevice.zig");
 const LockSurface = @import("LockSurface.zig");
diff --git a/river/InputDevice.zig b/river/InputDevice.zig
index 0846b71..afca476 100644
--- a/river/InputDevice.zig
+++ b/river/InputDevice.zig
@@ -18,6 +18,7 @@ const Keyboard = @import("Keyboard.zig");
 const LibinputDevice = @import("LibinputDevice.zig");
 const Seat = @import("Seat.zig");
 const Tablet = @import("Tablet.zig");
+const XkbKeyboard = @import("XkbKeyboard.zig");
 
 const log = std.log.scoped(.input);
 
@@ -27,6 +28,7 @@ virtual: bool,
 objects: wl.list.Head(river.InputDeviceV1, null),
 
 libinput: LibinputDevice,
+xkb_keyboard: XkbKeyboard,
 
 remove: wl.Listener(*wlr.InputDevice) = .init(handleRemove),
 
@@ -50,6 +52,7 @@ pub fn init(
         .wlr_device = wlr_device,
         .virtual = virtual,
         .libinput = undefined,
+        .xkb_keyboard = undefined,
         .objects = undefined,
         .link = undefined,
     };
@@ -93,6 +96,9 @@ pub fn init(
             }
         }
     }
+    if (wlr_device.type == .keyboard) {
+        device.xkb_keyboard.init();
+    }
 }
 
 pub fn createObject(device: *InputDevice, im_v1: *river.InputManagerV1) void {
@@ -123,6 +129,9 @@ pub fn deinit(device: *InputDevice) void {
     if (device.wlr_device.getLibinputDevice() != null) {
         device.libinput.deinit();
     }
+    if (device.wlr_device.type == .keyboard) {
+        device.xkb_keyboard.deinit();
+    }
 
     device.link.remove();
     device.seat.updateCapabilities();
diff --git a/river/Keyboard.zig b/river/Keyboard.zig
index 4c59d2c..0825497 100644
--- a/river/Keyboard.zig
+++ b/river/Keyboard.zig
@@ -24,13 +24,6 @@ pub const Config = struct {
     repeat_rate: u31 = 80,
     /// Repeat delay in milliseconds
     repeat_delay: u31 = 300,
-
-    pub fn eql(a: *const Config, b: *const Config) bool {
-        // TODO this probably isn't a sufficient way to compare keymaps?
-        return a.keymap == b.keymap and
-            a.repeat_rate == b.repeat_rate and
-            a.repeat_delay == b.repeat_delay;
-    }
 };
 
 device: InputDevice,
@@ -53,10 +46,22 @@ pub fn create(seat: *Seat, wlr_device: *wlr.InputDevice, virtual: bool) !*Keyboa
 
     keyboard.* = .{
         .config = .{
-            .keymap = if (virtual) wlr_keyboard.keymap else server.config.keymap,
+            .keymap = blk: {
+                if (virtual) {
+                    if (wlr_keyboard.keymap) |keymap| {
+                        break :blk keymap.ref();
+                    } else {
+                        break :blk null;
+                    }
+                } else {
+                    break :blk server.xkb_config.default_keymap.ref();
+                }
+            },
         },
         .device = undefined,
     };
+    errdefer if (keyboard.config.keymap) |keymap| keymap.unref();
+
     try keyboard.device.init(seat, wlr_device, virtual);
     errdefer keyboard.device.deinit();
 
@@ -77,7 +82,7 @@ pub fn setGroup(keyboard: *Keyboard) void {
     if (!keyboard.device.virtual) {
         var it = seat.keyboard_groups.iterator(.forward);
         while (it.next()) |group| {
-            if (keyboard.config.eql(&group.config)) {
+            if (group.match(&keyboard.config)) {
                 keyboard.group = group.ref();
                 return;
             }
@@ -102,6 +107,17 @@ pub fn setRepeatInfo(keyboard: *Keyboard, rate: u31, delay: u31) void {
     keyboard.setGroup();
 }
 
+pub fn setKeymap(keyboard: *Keyboard, keymap: *xkb.Keymap) void {
+    assert(!keyboard.device.virtual);
+    if (keyboard.config.keymap) |old| old.unref();
+    keyboard.config.keymap = keymap.ref();
+    if (keyboard.group) |group| {
+        group.unref();
+        keyboard.group = null;
+    }
+    keyboard.setGroup();
+}
+
 pub fn deviceDestroy(keyboard: *Keyboard) void {
     assert(!keyboard.device_destroyed);
     keyboard.device_destroyed = true;
@@ -120,6 +136,7 @@ fn maybeDestroy(keyboard: *Keyboard) void {
         return;
     }
 
+    if (keyboard.config.keymap) |keymap| keymap.unref();
     if (keyboard.group) |group| group.unref();
 
     util.gpa.destroy(keyboard);
diff --git a/river/KeyboardGroup.zig b/river/KeyboardGroup.zig
index 4972f3a..df6f9d3 100644
--- a/river/KeyboardGroup.zig
+++ b/river/KeyboardGroup.zig
@@ -15,6 +15,7 @@ const util = @import("util.zig");
 const Keyboard = @import("Keyboard.zig");
 const Seat = @import("Seat.zig");
 const XkbBinding = @import("XkbBinding.zig");
+const InputDevice = @import("InputDevice.zig");
 
 const log = std.log.scoped(.input);
 
@@ -135,6 +136,36 @@ pub fn unref(group: *KeyboardGroup) void {
     util.gpa.destroy(group);
 }
 
+pub fn match(group: *const KeyboardGroup, config: *Keyboard.Config) bool {
+    const a = &group.config;
+    const b = config;
+    if (a.repeat_rate != b.repeat_rate) return false;
+    if (a.repeat_delay != b.repeat_delay) return false;
+
+    if (a.keymap == b.keymap) return true;
+    if (a.keymap == null or b.keymap == null) return false;
+
+    // Can't get away with a cheap pointer comparison.
+    // TODO implement a non-terrible way to do this upstream in xkbcommon
+    const a_string = a.keymap.?.getAsString2(.use_original_format, .{});
+    defer std.c.free(a_string);
+    const b_string = b.keymap.?.getAsString2(.use_original_format, .{});
+    defer std.c.free(b_string);
+    if (a_string == null or b_string == null) {
+        // Ugh, no good options here, we don't know why the function failed.
+        // xkbcommon really needs a better API for this.
+        log.err("xkb_keymap_get_as_string2() failed", .{});
+        return false;
+    }
+    if (std.mem.orderZ(u8, a_string.?, b_string.?) == .eq) {
+        // Consolidate so we don't have to do this expensive/silly comparison again
+        config.keymap.?.unref();
+        config.keymap = group.config.keymap.?.ref();
+        return true;
+    }
+    return false;
+}
+
 pub fn processKey(group: *KeyboardGroup, event: *const wlr.Keyboard.event.Key) void {
     if (group.pressed.getPtr(event.keycode)) |key| {
         assert(key.count > 0);
@@ -266,6 +297,8 @@ fn handleKey(listener: *wl.Listener(*wlr.Keyboard.event.Key), event: *wlr.Keyboa
             group.seat.wlr_seat.keyboardNotifyKey(event.time_msec, event.keycode, event.state);
         },
     }
+
+    group.sendState();
 }
 
 fn keysymIsModifier(keysym: xkb.Keysym) bool {
@@ -325,6 +358,7 @@ fn handleModifiers(listener: *wl.Listener(*wlr.Keyboard), _: *wlr.Keyboard) void
         group.seat.wlr_seat.setKeyboard(&group.state);
         group.seat.wlr_seat.keyboardNotifyModifiers(&group.state.modifiers);
     }
+    group.sendState();
 }
 
 /// Handle any builtin, hardcoded compositor keybindings such as VT switching.
@@ -374,3 +408,22 @@ pub fn processKeymap(group: *KeyboardGroup, keymap: *xkb.Keymap) void {
     // wlroots will log an error on failure, there's not much we can do to recover unfortunately.
     _ = group.state.setKeymap(keymap);
 }
+
+pub fn sendState(group: *KeyboardGroup) void {
+    const keymap = group.config.keymap.?;
+    const layout_index = group.state.modifiers.group;
+    const layout_name = keymap.layoutGetName(layout_index);
+    const caps_mask = keymap.modGetMask(xkb.names.mod.caps);
+    const capslock = group.state.modifiers.locked & caps_mask != 0;
+    const num_mask = keymap.modGetMask(xkb.names.vmod.num);
+    const numlock = group.state.modifiers.locked & num_mask != 0;
+
+    var it = server.xkb_config.keyboards.iterator(.forward);
+    while (it.next()) |xkb_keyboard| {
+        const device: *InputDevice = @fieldParentPtr("xkb_keyboard", xkb_keyboard);
+        const keyboard: *Keyboard = @fieldParentPtr("device", device);
+        if (keyboard.group != group) continue;
+
+        xkb_keyboard.sendState(layout_index, layout_name, capslock, numlock);
+    }
+}
diff --git a/river/Output.zig b/river/Output.zig
index 48a3e59..f2f9530 100644
--- a/river/Output.zig
+++ b/river/Output.zig
@@ -22,7 +22,6 @@ const LayerShellOutput = @import("LayerShellOutput.zig");
 const LockSurface = @import("LockSurface.zig");
 const SceneNodeData = @import("SceneNodeData.zig");
 const Window = @import("Window.zig");
-const Config = @import("Config.zig");
 
 const log = std.log.scoped(.output);
 
diff --git a/river/Server.zig b/river/Server.zig
index 4e7dfde..7b8053b 100644
--- a/river/Server.zig
+++ b/river/Server.zig
@@ -14,7 +14,6 @@ const wl = @import("wayland").server.wl;
 const c = @import("c.zig").c;
 const util = @import("util.zig");
 
-const Config = @import("Config.zig");
 const IdleInhibitManager = @import("IdleInhibitManager.zig");
 const InputManager = @import("InputManager.zig");
 const LockManager = @import("LockManager.zig");
@@ -28,6 +27,7 @@ const WindowManager = @import("WindowManager.zig");
 const XkbBindings = @import("XkbBindings.zig");
 const LayerShell = @import("LayerShell.zig");
 const LibinputConfig = @import("LibinputConfig.zig");
+const XkbConfig = @import("XkbConfig.zig");
 const XdgDecoration = @import("XdgDecoration.zig");
 const XdgToplevel = @import("XdgToplevel.zig");
 const XwaylandOverrideRedirect = @import("XwaylandOverrideRedirect.zig");
@@ -76,8 +76,8 @@ foreign_toplevel_list: *wlr.ExtForeignToplevelListV1,
 scene: Scene,
 input_manager: InputManager,
 libinput_config: LibinputConfig,
+xkb_config: XkbConfig,
 om: OutputManager,
-config: Config,
 idle_inhibit_manager: IdleInhibitManager,
 lock_manager: LockManager,
 wm: WindowManager,
@@ -142,12 +142,11 @@ pub fn init(server: *Server, runtime_xwayland: bool) !void {
 
         .foreign_toplevel_list = try wlr.ExtForeignToplevelListV1.create(wl_server, 1),
 
-        .config = try Config.init(),
-
         .scene = undefined,
         .om = undefined,
         .input_manager = undefined,
         .libinput_config = undefined,
+        .xkb_config = undefined,
         .idle_inhibit_manager = undefined,
         .lock_manager = undefined,
         .wm = undefined,
@@ -177,6 +176,7 @@ pub fn init(server: *Server, runtime_xwayland: bool) !void {
     try server.om.init();
     try server.input_manager.init();
     try server.libinput_config.init();
+    try server.xkb_config.init();
     try server.idle_inhibit_manager.init();
     try server.lock_manager.init();
 
@@ -229,8 +229,6 @@ pub fn deinit(server: *Server) void {
     server.layer_shell.deinit();
 
     server.wl_server.destroy();
-
-    server.config.deinit();
 }
 
 /// Create the socket, start the backend, and setup the environment
@@ -331,6 +329,7 @@ fn blocklist(server: *Server, global: *const wl.Global) bool {
         global == server.om.power_manager.global or
         global == server.om.gamma_control_manager.global or
         global == server.libinput_config.global or
+        global == server.xkb_config.global or
         global == server.input_manager.global or
         global == server.input_manager.idle_notifier.global or
         global == server.input_manager.virtual_pointer_manager.global or
diff --git a/river/XkbConfig.zig b/river/XkbConfig.zig
new file mode 100644
index 0000000..cc81cdc
--- /dev/null
+++ b/river/XkbConfig.zig
@@ -0,0 +1,167 @@
+// SPDX-FileCopyrightText: © 2026 The River Developers
+// SPDX-License-Identifier: GPL-3.0-only
+
+const XkbConfig = @This();
+
+const std = @import("std");
+const assert = std.debug.assert;
+const wl = @import("wayland").server.wl;
+const river = @import("wayland").server.river;
+const xkb = @import("xkbcommon");
+
+const server = &@import("main.zig").server;
+
+const XkbKeymap = @import("XkbKeymap.zig");
+const XkbKeyboard = @import("XkbKeyboard.zig");
+
+const log = std.log.scoped(.input);
+
+global: *wl.Global,
+objects: wl.list.Head(river.XkbConfigV1, null),
+keymaps: wl.list.Head(XkbKeymap, .link),
+keyboards: wl.list.Head(XkbKeyboard, .link),
+
+context: *xkb.Context,
+default_keymap: *xkb.Keymap,
+
+server_destroy: wl.Listener(*wl.Server) = .init(handleServerDestroy),
+
+pub fn init(config: *XkbConfig) !void {
+    const context = xkb.Context.new(.no_flags) orelse return error.XkbContextFailed;
+    defer context.unref();
+
+    // Passing null here indicates that defaults from libxkbcommon and
+    // its XKB_DEFAULT_LAYOUT, XKB_DEFAULT_OPTIONS, etc. should be used.
+    const default_keymap = xkb.Keymap.newFromNames(context, null, .no_flags) orelse return error.XkbKeymapFailed;
+    defer default_keymap.unref();
+
+    config.* = .{
+        .global = try wl.Global.create(server.wl_server, river.XkbConfigV1, 1, *XkbConfig, config, bind),
+        .context = context.ref(),
+        .default_keymap = default_keymap.ref(),
+        .objects = undefined,
+        .keymaps = undefined,
+        .keyboards = undefined,
+    };
+    errdefer comptime unreachable;
+    config.objects.init();
+    config.keymaps.init();
+    config.keyboards.init();
+
+    server.wl_server.addDestroyListener(&config.server_destroy);
+}
+
+fn handleServerDestroy(listener: *wl.Listener(*wl.Server), _: *wl.Server) void {
+    const config: *XkbConfig = @fieldParentPtr("server_destroy", listener);
+
+    config.global.destroy();
+    config.context.unref();
+    config.default_keymap.unref();
+}
+
+fn bind(client: *wl.Client, config: *XkbConfig, version: u32, id: u32) void {
+    const object = river.XkbConfigV1.create(client, version, id) catch {
+        client.postNoMemory();
+        log.err("out of memory", .{});
+        return;
+    };
+    object.setHandler(*XkbConfig, handleRequest, handleDestroy, config);
+    config.objects.append(object);
+    {
+        var it = config.keyboards.iterator(.forward);
+        while (it.next()) |device| device.createObject(object);
+    }
+}
+
+fn handleRequestInert(
+    object: *river.XkbConfigV1,
+    request: river.XkbConfigV1.Request,
+    _: ?*anyopaque,
+) void {
+    if (request == .destroy) object.destroy();
+}
+
+fn handleDestroy(object: *river.XkbConfigV1, _: *XkbConfig) void {
+    object.getLink().remove();
+}
+
+fn handleRequest(
+    object: *river.XkbConfigV1,
+    request: river.XkbConfigV1.Request,
+    _: *XkbConfig,
+) void {
+    switch (request) {
+        .stop => {
+            object.getLink().remove();
+            object.sendFinished();
+            object.setHandler(?*anyopaque, handleRequestInert, null, null);
+        },
+        .destroy => {
+            object.postError(.invalid_destroy, "destroy before finished event sent");
+        },
+        .create_keymap => |args| {
+            const format: xkb.Keymap.Format = switch (args.format) {
+                .text_v1 => .text_v1,
+                .text_v2 => .text_v2,
+                _ => {
+                    object.postError(.invalid_format, "invalid format enum value");
+                    return;
+                },
+            };
+            createKeymap(object, args.id, format, args.fd) catch |err| switch (err) {
+                error.OutOfMemory, error.ResourceCreateFailed => {
+                    log.err("out of memory", .{});
+                    object.postNoMemory();
+                    return;
+                },
+            };
+        },
+    }
+}
+
+/// The goal of this function is to handle whatever fd the client has sent us without crashing.
+/// The fd may be invalid, impossible to mmap, not contain a valid keymap, etc.
+/// This requires us to avoid the syscall wrappers in std.posix which assert on EBADF for example.
+fn createKeymap(object: *river.XkbConfigV1, id: u32, format: xkb.Keymap.Format, fd: i32) !void {
+    defer _ = std.c.close(fd);
+
+    var stat: std.c.Stat = std.mem.zeroes(std.c.Stat);
+    switch (std.posix.errno(std.c.fstat(fd, &stat))) {
+        .SUCCESS => {},
+        else => |e| {
+            log.err("failed to fstat() keymap fd: E{s}", .{@tagName(e)});
+            return XkbKeymap.createFailed(object.getClient(), object.getVersion(), id, "failed to fstat() keymap fd");
+        },
+    }
+    // Must be zero terminated
+    if (stat.size < 1) {
+        log.err("keymap too small", .{});
+        return XkbKeymap.createFailed(object.getClient(), object.getVersion(), id, "keymap too small");
+    }
+    if (stat.size > 1024 * 1024) {
+        log.err("keymap too large: {d} bytes", .{stat.size});
+        return XkbKeymap.createFailed(object.getClient(), object.getVersion(), id, "keymap too large");
+    }
+    const keymap_len: usize = @intCast(stat.size - 1);
+
+    const keymap_ptr = std.c.mmap(null, keymap_len, std.c.PROT.READ, .{ .TYPE = .PRIVATE }, fd, 0);
+    if (keymap_ptr == std.c.MAP_FAILED) {
+        log.err("failed to mmap() keymap fd: {s}", .{@tagName(@as(std.c.E, @enumFromInt(std.c._errno().*)))});
+        return XkbKeymap.createFailed(object.getClient(), object.getVersion(), id, "failed to mmap() keymap fd");
+    }
+    defer _ = std.c.munmap(@alignCast(keymap_ptr), keymap_len);
+
+    const keymap = xkb.Keymap.newFromBuffer(
+        server.xkb_config.context,
+        @ptrCast(keymap_ptr),
+        keymap_len,
+        format,
+        .no_flags,
+    ) orelse {
+        log.err("failed to parse xkb keymap", .{});
+        return XkbKeymap.createFailed(object.getClient(), object.getVersion(), id, "failed to parse xkb keymap");
+    };
+    defer keymap.unref();
+
+    try XkbKeymap.create(object.getClient(), object.getVersion(), id, keymap);
+}
diff --git a/river/XkbKeyboard.zig b/river/XkbKeyboard.zig
new file mode 100644
index 0000000..c00c8f9
--- /dev/null
+++ b/river/XkbKeyboard.zig
@@ -0,0 +1,206 @@
+// SPDX-FileCopyrightText: © 2026 The River Developers
+// SPDX-License-Identifier: GPL-3.0-only
+
+const XkbKeyboard = @This();
+
+const std = @import("std");
+const assert = std.debug.assert;
+const mem = std.mem;
+const wlr = @import("wlroots");
+const wl = @import("wayland").server.wl;
+const river = @import("wayland").server.river;
+const xkb = @import("xkbcommon");
+
+const server = &@import("main.zig").server;
+const util = @import("util.zig");
+
+const InputDevice = @import("InputDevice.zig");
+const Keyboard = @import("Keyboard.zig");
+const XkbKeymap = @import("XkbKeymap.zig");
+
+const log = std.log.scoped(.input);
+
+objects: wl.list.Head(river.XkbKeyboardV1, null),
+
+sent: struct {
+    layout_index: ?u32 = null,
+    /// This string is owned by our global xkb.Context.
+    layout_name: ?[*:0]const u8 = null,
+    capslock: ?bool = null,
+    numlock: ?bool = null,
+} = .{},
+
+/// XkbConfig.keyboards
+link: wl.list.Link,
+
+pub fn init(xkb_keyboard: *XkbKeyboard) void {
+    xkb_keyboard.* = .{
+        .objects = undefined,
+        .link = undefined,
+    };
+    xkb_keyboard.objects.init();
+    server.xkb_config.keyboards.append(xkb_keyboard);
+    {
+        var it = server.xkb_config.objects.iterator(.forward);
+        while (it.next()) |config_v1| xkb_keyboard.createObject(config_v1);
+    }
+}
+
+pub fn createObject(xkb_keyboard: *XkbKeyboard, config_v1: *river.XkbConfigV1) void {
+    const object = river.XkbKeyboardV1.create(config_v1.getClient(), config_v1.getVersion(), 0) catch {
+        log.err("out of memory", .{});
+        config_v1.postNoMemory();
+        return;
+    };
+    xkb_keyboard.objects.append(object);
+    object.setHandler(*XkbKeyboard, handleRequest, handleDestroy, xkb_keyboard);
+    config_v1.sendXkbKeyboard(object);
+    {
+        const device: *InputDevice = @fieldParentPtr("xkb_keyboard", xkb_keyboard);
+        var it = device.objects.iterator(.forward);
+        while (it.next()) |input_device_v1| {
+            if (object.getClient() == input_device_v1.getClient()) {
+                object.sendInputDevice(input_device_v1);
+            }
+        }
+    }
+    const sent = &xkb_keyboard.sent;
+    if (sent.layout_index) |layout_index| {
+        object.sendLayout(layout_index, sent.layout_name);
+    }
+    if (sent.capslock) |capslock| {
+        if (capslock) {
+            object.sendCapslockEnabled();
+        } else {
+            object.sendCapslockDisabled();
+        }
+    }
+    if (sent.numlock) |numlock| {
+        if (numlock) {
+            object.sendNumlockEnabled();
+        } else {
+            object.sendNumlockDisabled();
+        }
+    }
+}
+
+pub fn deinit(xkb_keyboard: *XkbKeyboard) void {
+    {
+        var it = xkb_keyboard.objects.iterator(.forward);
+        while (it.next()) |object| {
+            object.getLink().remove();
+            object.sendRemoved();
+            object.setHandler(?*anyopaque, handleRequestInert, null, null);
+        }
+    }
+    assert(xkb_keyboard.objects.empty());
+    xkb_keyboard.link.remove();
+}
+
+fn handleRequestInert(
+    object: *river.XkbKeyboardV1,
+    request: river.XkbKeyboardV1.Request,
+    _: ?*anyopaque,
+) void {
+    if (request == .destroy) object.destroy();
+}
+
+fn handleDestroy(object: *river.XkbKeyboardV1, _: *XkbKeyboard) void {
+    object.getLink().remove();
+}
+
+fn handleRequest(
+    object: *river.XkbKeyboardV1,
+    request: river.XkbKeyboardV1.Request,
+    xkb_keyboard: *XkbKeyboard,
+) void {
+    const device: *InputDevice = @fieldParentPtr("xkb_keyboard", xkb_keyboard);
+    const keyboard: *Keyboard = @fieldParentPtr("device", device);
+    const group = keyboard.group.?;
+    switch (request) {
+        .destroy => object.destroy(),
+        .set_keymap => |args| {
+            const keymap: ?*XkbKeymap = @ptrCast(@alignCast(args.keymap.getUserData()));
+            if (keymap) |k| {
+                keyboard.setKeymap(k.xkb_keymap);
+            } else {
+                object.postError(.invalid_keymap, "client set invalid keymap");
+                return;
+            }
+        },
+        .set_layout_by_index => |args| {
+            if (args.index < 0 or args.index >= group.config.keymap.?.numLayouts()) return;
+            var modifiers = group.state.modifiers;
+            modifiers.group = @intCast(args.index);
+            group.state.notifyModifiers(modifiers);
+        },
+        .set_layout_by_name => |args| {
+            const index = group.config.keymap.?.layoutGetIndex(args.name);
+            if (index == xkb.layout_invalid) return;
+            var modifiers = group.state.modifiers;
+            modifiers.group = index;
+            group.state.notifyModifiers(modifiers);
+        },
+        .numlock_enable => {
+            const mask = group.config.keymap.?.modGetMask(xkb.names.vmod.num);
+            var modifiers = group.state.modifiers;
+            modifiers.locked |= mask;
+            group.state.notifyModifiers(modifiers);
+        },
+        .numlock_disable => {
+            const mask = group.config.keymap.?.modGetMask(xkb.names.vmod.num);
+            var modifiers = group.state.modifiers;
+            modifiers.locked &= ~mask;
+            group.state.notifyModifiers(modifiers);
+        },
+        .capslock_enable => {
+            const mask = group.config.keymap.?.modGetMask(xkb.names.mod.caps);
+            var modifiers = group.state.modifiers;
+            modifiers.locked |= mask;
+            group.state.notifyModifiers(modifiers);
+        },
+        .capslock_disable => {
+            const mask = group.config.keymap.?.modGetMask(xkb.names.mod.caps);
+            var modifiers = group.state.modifiers;
+            modifiers.locked &= ~mask;
+            group.state.notifyModifiers(modifiers);
+        },
+    }
+}
+
+pub fn sendState(
+    xkb_keyboard: *XkbKeyboard,
+    layout_index: xkb.LayoutIndex,
+    layout_name: ?[*:0]const u8,
+    capslock: bool,
+    numlock: bool,
+) void {
+    const sent = &xkb_keyboard.sent;
+    var it = xkb_keyboard.objects.iterator(.forward);
+    while (it.next()) |object| {
+        if (sent.layout_index != layout_index or
+            (sent.layout_name == null) != (layout_name == null) or
+            (layout_name != null and mem.orderZ(u8, layout_name.?, sent.layout_name.?) != .eq))
+        {
+            object.sendLayout(layout_index, layout_name);
+        }
+        if (sent.capslock != capslock) {
+            if (capslock) {
+                object.sendCapslockEnabled();
+            } else {
+                object.sendCapslockDisabled();
+            }
+        }
+        if (sent.numlock != numlock) {
+            if (numlock) {
+                object.sendNumlockEnabled();
+            } else {
+                object.sendNumlockDisabled();
+            }
+        }
+    }
+    sent.layout_index = layout_index;
+    sent.layout_name = layout_name;
+    sent.capslock = capslock;
+    sent.numlock = numlock;
+}
diff --git a/river/XkbKeymap.zig b/river/XkbKeymap.zig
new file mode 100644
index 0000000..1233927
--- /dev/null
+++ b/river/XkbKeymap.zig
@@ -0,0 +1,80 @@
+// SPDX-FileCopyrightText: © 2026 The River Developers
+// SPDX-License-Identifier: GPL-3.0-only
+
+const XkbKeymap = @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 log = std.log.scoped(.input);
+
+object: *river.XkbKeymapV1,
+xkb_keymap: *xkb.Keymap,
+
+/// XkbConfig.keymaps
+link: wl.list.Link,
+
+pub fn create(
+    client: *wl.Client,
+    version: u32,
+    id: u32,
+    xkb_keymap: *xkb.Keymap,
+) !void {
+    const keymap = try util.gpa.create(XkbKeymap);
+    errdefer util.gpa.destroy(keymap);
+
+    const object = try river.XkbKeymapV1.create(client, version, id);
+    errdefer comptime unreachable;
+
+    keymap.* = .{
+        .object = object,
+        .xkb_keymap = xkb_keymap.ref(),
+        .link = undefined,
+    };
+    server.xkb_config.keymaps.append(keymap);
+
+    object.setHandler(*XkbKeymap, handleRequest, handleDestroy, keymap);
+    object.sendSuccess();
+}
+
+pub fn createFailed(client: *wl.Client, version: u32, id: u32, error_msg: [*:0]const u8) !void {
+    const object = try river.XkbKeymapV1.create(client, version, id);
+    errdefer comptime unreachable;
+    object.setHandler(?*anyopaque, handleRequestInert, null, null);
+    object.sendFailure(error_msg);
+}
+
+fn handleRequestInert(
+    object: *river.XkbKeymapV1,
+    request: river.XkbKeymapV1.Request,
+    _: ?*anyopaque,
+) void {
+    switch (request) {
+        .destroy => object.destroy(),
+    }
+}
+
+fn handleDestroy(_: *river.XkbKeymapV1, keymap: *XkbKeymap) void {
+    keymap.xkb_keymap.unref();
+    keymap.link.remove();
+    util.gpa.destroy(keymap);
+}
+
+fn handleRequest(
+    object: *river.XkbKeymapV1,
+    request: river.XkbKeymapV1.Request,
+    keymap: *XkbKeymap,
+) void {
+    assert(keymap.object == object);
+    switch (request) {
+        .destroy => object.destroy(),
+    }
+}