Implement river-xkb-config-v1

This commit adds support for the river-xkb-config-v1 protocol. There's
a new keyboard_layout block in config that can take options from
xkeyboard-config(7).
This commit is contained in:
Ben Buhse 2026-03-16 08:41:49 -05:00
commit a1bd356943
No known key found for this signature in database
GPG key ID: 7916ACFCD38FD0B4
12 changed files with 714 additions and 6 deletions

View file

@ -41,6 +41,7 @@ pub fn build(b: *std.Build) !void {
scanner.addCustomProtocol(b.path("protocol/river-layer-shell-v1.xml")); scanner.addCustomProtocol(b.path("protocol/river-layer-shell-v1.xml"));
scanner.addCustomProtocol(b.path("protocol/river-window-management-v1.xml")); scanner.addCustomProtocol(b.path("protocol/river-window-management-v1.xml"));
scanner.addCustomProtocol(b.path("protocol/river-xkb-bindings-v1.xml")); scanner.addCustomProtocol(b.path("protocol/river-xkb-bindings-v1.xml"));
scanner.addCustomProtocol(b.path("protocol/river-xkb-config-v1.xml"));
scanner.addSystemProtocol("stable/xdg-shell/xdg-shell.xml"); // dep of wlr-layer-shell-unstable-v1 scanner.addSystemProtocol("stable/xdg-shell/xdg-shell.xml"); // dep of wlr-layer-shell-unstable-v1
scanner.addCustomProtocol(b.path("protocol/wlr-layer-shell-unstable-v1.xml")); scanner.addCustomProtocol(b.path("protocol/wlr-layer-shell-unstable-v1.xml"));
@ -52,6 +53,7 @@ pub fn build(b: *std.Build) !void {
scanner.generate("river_layer_shell_v1", 1); scanner.generate("river_layer_shell_v1", 1);
scanner.generate("river_window_manager_v1", 4); scanner.generate("river_window_manager_v1", 4);
scanner.generate("river_xkb_bindings_v1", 2); scanner.generate("river_xkb_bindings_v1", 2);
scanner.generate("river_xkb_config_v1", 1);
scanner.generate("zwlr_layer_shell_v1", 3); scanner.generate("zwlr_layer_shell_v1", 3);
const options = b.addOptions(); const options = b.addOptions();

View file

@ -13,9 +13,9 @@ An example config can be found at [examples/config.kdl](../examples/config.kdl).
If the config file is missing or fails to load, beansprout falls back to its built-in If the config file is missing or fails to load, beansprout falls back to its built-in
defaults. If no keybinds are configured, the following fallback keybinds are added: defaults. If no keybinds are configured, the following fallback keybinds are added:
- `Ctrl+Alt Delete` exit the River session - `Ctrl+Alt Delete`: exit the River session
- `Super+Shift R` reload config - `Super+Shift R`: reload config
- `Super T` spawn `foot` - `Super T`: spawn `foot`
Similarly, if an individual node or block is invalid, it will Similarly, if an individual node or block is invalid, it will
try to ignore the error and continue on. try to ignore the error and continue on.
@ -201,6 +201,37 @@ tag_overlay {
| `square_inactive_border_color` | color | `0x6c7086` | Inactive tag square border | | `square_inactive_border_color` | color | `0x6c7086` | Inactive tag square border |
| `square_inactive_occupied_color` | color | `0xcdd6f4` | Inactive tag occupied indicator | | `square_inactive_occupied_color` | color | `0xcdd6f4` | Inactive tag occupied indicator |
## Keyboard Layout
Keyboard layout settings are placed inside a `keyboard_layout` block. These
configure the XKB keymap applied to all keyboard devices via the
`river-xkb-config` protocol. All fields are optional and default to
system/xkbcommon defaults.
```kdl
keyboard_layout {
layout "us"
variant "dvorak"
options "compose:rctrl"
}
```
| Setting | Type | Default | Description |
|-----------|--------|----------------|--------------------------------------------------|
| `rules` | string | system default | XKB rules file (almost always `evdev`) |
| `model` | string | system default | Keyboard model (e.g., `pc104`, `pc105`) |
| `layout` | string | system default | Keyboard layout (e.g., `us`, `de`, `fr`) |
| `variant` | string | none | Layout variant (e.g., `dvorak`, `colemak`, `intl`)|
| `options` | string | none | XKB options (e.g., `compose:rctrl`, `caps:escape`)|
Multiple options can be separated by commas: `"compose:rctrl,caps:escape"`.
If the `keyboard_layout` block is removed from the config and reloaded, beansprout
will revert to the system/xkbcommon default keymap.
See `xkeyboard-config(7)` for a full list of available rules, models, layouts,
variants, and options.
## Keybinds ## Keybinds
Keyboard bindings are placed inside a `keybinds` block. Each binding has the Keyboard bindings are placed inside a `keybinds` block. Each binding has the

View file

@ -123,6 +123,14 @@ pointer_binds {
// tiled windows will automatically float if resized // tiled windows will automatically float if resized
resize_window Mod4 BTN_RIGHT resize_window Mod4 BTN_RIGHT
} }
// Keyboard layout configuration
// All fields are optional and default to system/xkbcommon defaults
keyboard_layout {
layout "us"
// variant "dvorak"
options "compose:rctrl"
}
// Default input config for all devices // Default input config for all devices
input { input {
accel_profile "flat" accel_profile "flat"

View file

@ -216,6 +216,43 @@ default to 0.
*top*, *right*, *bottom*, *left* *top*, *right*, *bottom*, *left*
# KEYBOARD LAYOUT
Keyboard layout settings are placed inside a *keyboard_layout* block. These
configure the XKB keymap applied to all keyboard devices via the
river-xkb-config protocol. All fields are optional and default to
system/xkbcommon defaults.
```
keyboard_layout {
layout "us"
variant "dvorak"
options "compose:rctrl"
}
```
*rules* _rules_
XKB rules file. Almost always "evdev". (Default: system default)
*model* _model_
Keyboard model, e.g., "pc104", "pc105". (Default: system default)
*layout* _layout_
Keyboard layout, e.g., "us", "de", "fr". (Default: system default)
*variant* _variant_
Layout variant, e.g., "dvorak", "colemak", "intl".
*options* _options_
XKB options, e.g., "compose:rctrl", "caps:escape". Multiple options
can be separated by commas.
If the *keyboard_layout* block is removed from the config and reloaded,
beansprout will revert to the system/xkbcommon default keymap.
See *xkeyboard-config*(7) for a full list of available rules, models, layouts,
variants, and options.
# KEYBINDS # KEYBINDS
Keyboard bindings are placed inside a *keybinds* block. Each binding has the Keyboard bindings are placed inside a *keybinds* block. Each binding has the
@ -383,4 +420,4 @@ All libinput configuration options supported by river are also supported by
# SEE ALSO # SEE ALSO
*beansprout*(1), *river*(1), *strftime*(3) *beansprout*(1), *river*(1), *strftime*(3), *xkeyboard-config*(7)

View file

@ -0,0 +1,275 @@
<?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 keyboards">
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.
</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">
Numlock 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">
Numlock 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>

View file

@ -282,7 +282,7 @@ fn draw(bar: *Bar) !void {
} }
}.f; }.f;
// Measure center first needed to constrain left and right widths // Measure center first; needed to constrain left and right widths
const center_codepoints = componentSlice(options.center, clock_codepoints, title_codepoints, wm_info_codepoints); const center_codepoints = componentSlice(options.center, clock_codepoints, title_codepoints, wm_info_codepoints);
const center_width = try bar.textWidth(center_codepoints); const center_width = try bar.textWidth(center_codepoints);
var center_x: i32 = @divFloor(buffer.width - center_width, 2); var center_x: i32 = @divFloor(buffer.width - center_width, 2);

View file

@ -46,6 +46,9 @@ tag_overlay_config: ?TagOverlayConfig = null,
/// Bar configuration. If null, no bar is created. /// Bar configuration. If null, no bar is created.
bar_config: ?BarConfig = null, bar_config: ?BarConfig = null,
/// Keyboard layout configuration
keyboard_layout: KeyboardLayoutConfig = .{},
/// Tag bind entries parsed from config (tag_bind nodes in keybinds block) /// Tag bind entries parsed from config (tag_bind nodes in keybinds block)
tag_binds: std.ArrayList(Keybind) = .{}, tag_binds: std.ArrayList(Keybind) = .{},
// We use a hash map so that duplicate keybinds can be easily de-duplicated // We use a hash map so that duplicate keybinds can be easily de-duplicated
@ -77,6 +80,7 @@ const NodeName = enum {
bar, bar,
borders, borders,
input, input,
keyboard_layout,
keybinds, keybinds,
pointer_binds, pointer_binds,
tag_overlay, tag_overlay,
@ -120,6 +124,11 @@ pub fn create() !*Config {
if (config.bar_config) |bc| { if (config.bar_config) |bc| {
if (bc.fonts) |fonts| utils.gpa.free(fonts); if (bc.fonts) |fonts| utils.gpa.free(fonts);
} }
if (config.keyboard_layout.rules) |s| utils.gpa.free(s);
if (config.keyboard_layout.model) |s| utils.gpa.free(s);
if (config.keyboard_layout.layout) |s| utils.gpa.free(s);
if (config.keyboard_layout.variant) |s| utils.gpa.free(s);
if (config.keyboard_layout.options) |s| utils.gpa.free(s);
if (config.wallpaper_image_path) |path| { if (config.wallpaper_image_path) |path| {
utils.gpa.free(path); utils.gpa.free(path);
} }
@ -167,6 +176,11 @@ pub fn destroy(config: *Config) void {
if (config.bar_config) |bc| { if (config.bar_config) |bc| {
if (bc.fonts) |fonts| utils.gpa.free(fonts); if (bc.fonts) |fonts| utils.gpa.free(fonts);
} }
if (config.keyboard_layout.rules) |s| utils.gpa.free(s);
if (config.keyboard_layout.model) |s| utils.gpa.free(s);
if (config.keyboard_layout.layout) |s| utils.gpa.free(s);
if (config.keyboard_layout.variant) |s| utils.gpa.free(s);
if (config.keyboard_layout.options) |s| utils.gpa.free(s);
if (config.wallpaper_image_path) |path| { if (config.wallpaper_image_path) |path| {
utils.gpa.free(path); utils.gpa.free(path);
} }
@ -305,6 +319,7 @@ fn load(config: *Config, reader: *Io.Reader) !void {
}, },
inline .bar, inline .bar,
.borders, .borders,
.keyboard_layout,
.keybinds, .keybinds,
.pointer_binds, .pointer_binds,
.tag_overlay, .tag_overlay,
@ -320,6 +335,7 @@ fn load(config: *Config, reader: *Io.Reader) !void {
switch (child_block) { switch (child_block) {
.bar => try BarConfig.load(config, &parser, hostname), .bar => try BarConfig.load(config, &parser, hostname),
.borders => try border.load(config, &parser, hostname), .borders => try border.load(config, &parser, hostname),
.keyboard_layout => try KeyboardLayoutConfig.load(config, &parser, hostname),
.keybinds => try keybind.load(config, &parser, hostname), .keybinds => try keybind.load(config, &parser, hostname),
.pointer_binds => try pointer_bind.load(config, &parser, hostname), .pointer_binds => try pointer_bind.load(config, &parser, hostname),
.input => { .input => {
@ -375,6 +391,7 @@ const XkbBindings = @import("XkbBindings.zig");
const border = @import("config/border.zig"); const border = @import("config/border.zig");
const helpers = @import("config/helpers.zig"); const helpers = @import("config/helpers.zig");
const KeyboardLayoutConfig = @import("config/KeyboardLayoutConfig.zig");
const keybind = @import("config/keybind.zig"); const keybind = @import("config/keybind.zig");
const pointer_bind = @import("config/pointer_bind.zig"); const pointer_bind = @import("config/pointer_bind.zig");
const window_rule = @import("config/window_rule.zig"); const window_rule = @import("config/window_rule.zig");

View file

@ -21,6 +21,7 @@ river_layer_shell_v1: *river.LayerShellV1,
im: *InputManager, im: *InputManager,
wm: *WindowManager, wm: *WindowManager,
xkb_bindings: *XkbBindings, xkb_bindings: *XkbBindings,
xkb_config: ?*XkbConfig,
/// Pool of Buffers used for rendering wallpapers /// Pool of Buffers used for rendering wallpapers
buffer_pool: BufferPool = .{}, buffer_pool: BufferPool = .{},
@ -52,6 +53,7 @@ pub const Options = struct {
river_layer_shell_v1: *river.LayerShellV1, river_layer_shell_v1: *river.LayerShellV1,
river_window_manager_v1: *river.WindowManagerV1, river_window_manager_v1: *river.WindowManagerV1,
river_xkb_bindings_v1: *river.XkbBindingsV1, river_xkb_bindings_v1: *river.XkbBindingsV1,
river_xkb_config_v1: *river.XkbConfigV1,
config: *Config, config: *Config,
}; };
@ -66,6 +68,8 @@ pub fn create(options: Options) !*Context {
errdefer wm.destroy(); errdefer wm.destroy();
const xkb_bindings = try XkbBindings.create(context, options.river_xkb_bindings_v1); const xkb_bindings = try XkbBindings.create(context, options.river_xkb_bindings_v1);
errdefer xkb_bindings.destroy(); errdefer xkb_bindings.destroy();
const xkb_config = try XkbConfig.create(context, options.river_xkb_config_v1);
errdefer xkb_config.destroy();
var env = try process.getEnvMap(utils.gpa); var env = try process.getEnvMap(utils.gpa);
errdefer env.deinit(); errdefer env.deinit();
@ -85,6 +89,7 @@ pub fn create(options: Options) !*Context {
.im = im, .im = im,
.wm = wm, .wm = wm,
.xkb_bindings = xkb_bindings, .xkb_bindings = xkb_bindings,
.xkb_config = xkb_config,
.config = options.config, .config = options.config,
}; };
@ -96,6 +101,9 @@ pub fn destroy(context: *Context) void {
context.im.destroy(); context.im.destroy();
context.wm.destroy(); context.wm.destroy();
context.xkb_bindings.destroy(); context.xkb_bindings.destroy();
if (context.xkb_config) |xkb_config| {
xkb_config.destroy();
}
if (context.wallpaper_image) |wallpaper_image| { if (context.wallpaper_image) |wallpaper_image| {
wallpaper_image.destroy(); wallpaper_image.destroy();
@ -269,5 +277,6 @@ const TagOverlay = @import("TagOverlay.zig");
const Wallpaper = @import("Wallpaper.zig"); const Wallpaper = @import("Wallpaper.zig");
const WindowManager = @import("WindowManager.zig"); const WindowManager = @import("WindowManager.zig");
const XkbBindings = @import("XkbBindings.zig"); const XkbBindings = @import("XkbBindings.zig");
const XkbConfig = @import("XkbConfig.zig");
const log = std.log.scoped(.Context); const log = std.log.scoped(.Context);

View file

@ -134,6 +134,11 @@ fn initialize(wm: *WindowManager) void {
} }
binding.enable(); binding.enable();
} }
// Apply keyboard layout from config
if (context.xkb_config) |xkb_config| {
xkb_config.applyKeyboardLayout();
}
} }
fn manage(wm: *WindowManager) void { fn manage(wm: *WindowManager) void {

242
src/XkbConfig.zig Normal file
View file

@ -0,0 +1,242 @@
// SPDX-FileCopyrightText: 2026 Ben Buhse <me@benbuhse.email>
//
// SPDX-License-Identifier: GPL-3.0-only
const XkbConfig = @This();
context: *Context,
xkb_config_v1: *river.XkbConfigV1,
/// The keymap object sent to the compositor, if any.
/// Only valid to use with set_keymap once xkb_keymap_confirmed is true.
xkb_keymap_v1: ?*river.XkbKeymapV1 = null,
/// True once xkb_keymap_v1 has received the success event.
xkb_keymap_confirmed: bool = false,
/// Tracked keyboards so we can apply keymaps to all of them.
keyboards: wl.list.Head(Keyboard, .link),
const Keyboard = struct {
xkb_keyboard_v1: *river.XkbKeyboardV1,
xkb_config: *XkbConfig,
link: wl.list.Link,
fn create(xkb_keyboard_v1: *river.XkbKeyboardV1, xkb_config: *XkbConfig) !*Keyboard {
const keyboard = try utils.gpa.create(Keyboard);
keyboard.* = .{
.xkb_keyboard_v1 = xkb_keyboard_v1,
.xkb_config = xkb_config,
.link = undefined,
};
xkb_keyboard_v1.setListener(*Keyboard, keyboardListener, keyboard);
xkb_config.keyboards.append(keyboard);
// Only apply if the keymap has been confirmed via the success event.
// Applying an unconfirmed keymap is a protocol error.
if (xkb_config.xkb_keymap_confirmed) {
xkb_keyboard_v1.setKeymap(xkb_config.xkb_keymap_v1.?);
}
return keyboard;
}
fn destroy(keyboard: *Keyboard) void {
keyboard.link.remove();
keyboard.xkb_keyboard_v1.destroy();
utils.gpa.destroy(keyboard);
}
fn keyboardListener(_: *river.XkbKeyboardV1, event: river.XkbKeyboardV1.Event, keyboard: *Keyboard) void {
switch (event) {
.removed => keyboard.destroy(),
.input_device, .layout, .capslock_enabled, .capslock_disabled, .numlock_enabled, .numlock_disabled => {},
}
}
};
pub fn create(context: *Context, xkb_config_v1: *river.XkbConfigV1) !*XkbConfig {
const xkb_config = try utils.gpa.create(XkbConfig);
errdefer utils.gpa.destroy(xkb_config);
xkb_config.* = .{
.context = context,
.xkb_config_v1 = xkb_config_v1,
.keyboards = undefined, // we will initialize this shortly
};
xkb_config.keyboards.init();
xkb_config_v1.setListener(*XkbConfig, riverXkbConfigV1Listener, xkb_config);
return xkb_config;
}
pub fn destroy(xkb_config: *XkbConfig) void {
var it = xkb_config.keyboards.safeIterator(.forward);
while (it.next()) |keyboard| {
keyboard.destroy();
}
if (xkb_config.xkb_keymap_v1) |keymap_v1| {
keymap_v1.destroy();
}
xkb_config.xkb_config_v1.destroy();
utils.gpa.destroy(xkb_config);
}
fn riverXkbConfigV1Listener(xkb_config_v1: *river.XkbConfigV1, event: river.XkbConfigV1.Event, xkb_config: *XkbConfig) void {
assert(xkb_config.xkb_config_v1 == xkb_config_v1);
switch (event) {
.finished => {
xkb_config.context.xkb_config = null;
xkb_config.destroy();
},
.xkb_keyboard => |ev| {
_ = Keyboard.create(ev.id, xkb_config) catch |e| {
log.err("Failed to create keyboard: {}", .{e});
return;
};
},
}
}
/// Build a keymap from the config's keyboard_layout settings and send it
/// to the compositor. On success, apply it to all tracked keyboards.
pub fn applyKeyboardLayout(xkb_config: *XkbConfig) void {
const layout_config = xkb_config.context.config.keyboard_layout;
// If nothing is configured and we haven't previously applied a custom keymap,
// there's nothing to do. If we did previously apply one, fall through to apply
// the system default, reverting the custom keymap.
if (layout_config.rules == null and
layout_config.model == null and
layout_config.layout == null and
layout_config.variant == null and
layout_config.options == null and
!xkb_config.xkb_keymap_confirmed)
{
return;
}
// Convert ?[]const u8 to ?[*:0]const u8 for xkbcommon
const rules_z = dupeZOrNull(layout_config.rules);
defer if (rules_z) |s| utils.gpa.free(s);
const model_z = dupeZOrNull(layout_config.model);
defer if (model_z) |s| utils.gpa.free(s);
const layout_z = dupeZOrNull(layout_config.layout);
defer if (layout_z) |s| utils.gpa.free(s);
const variant_z = dupeZOrNull(layout_config.variant);
defer if (variant_z) |s| utils.gpa.free(s);
const options_z = dupeZOrNull(layout_config.options);
defer if (options_z) |s| utils.gpa.free(s);
const names = xkbcommon.RuleNames{
.rules = if (rules_z) |s| s.ptr else null,
.model = if (model_z) |s| s.ptr else null,
.layout = if (layout_z) |s| s.ptr else null,
.variant = if (variant_z) |s| s.ptr else null,
.options = if (options_z) |s| s.ptr else null,
};
// Create xkb context and keymap
const xkb_context = xkbcommon.Context.new(.no_flags) orelse {
log.err("Failed to create XKB context", .{});
return;
};
defer xkb_context.unref();
const keymap = xkbcommon.Keymap.newFromNames(xkb_context, &names, .no_flags) orelse {
log.err("Invalid keyboard layout configuration", .{});
return;
};
defer keymap.unref();
// Serialize keymap to string
const keymap_str = keymap.getAsString(.text_v1) orelse {
log.err("Failed to serialize keymap", .{});
return;
};
const keymap_len = mem.len(keymap_str);
// Include the null terminator in the buffer sent to the compositor
const keymap_buf = keymap_str[0 .. keymap_len + 1];
defer utils.gpa.free(keymap_buf);
// Create memfd and write keymap
const fd = switch (builtin.target.os.tag) {
.linux => posix.memfd_createZ("beansprout-xkb-keymap", os.linux.MFD.CLOEXEC | os.linux.MFD.ALLOW_SEALING),
.freebsd => posix.memfd_createZ("beansprout-xkb-keymap", std.c.MFD.CLOEXEC | std.c.MFD.ALLOW_SEALING),
else => @compileError("target OS not supported"),
} catch |err| {
log.err("Failed to create memfd: {}", .{err});
return;
};
defer posix.close(fd);
_ = posix.write(fd, keymap_buf) catch |err| {
log.err("Failed to write keymap to fd: {}", .{err});
return;
};
// Send to compositor
const keymap_v1 = xkb_config.xkb_config_v1.createKeymap(fd, .text_v1) catch |err| {
log.err("Failed to create keymap object: {}", .{err});
return;
};
// Destroy old keymap if any
if (xkb_config.xkb_keymap_v1) |old| {
old.destroy();
}
xkb_config.xkb_keymap_confirmed = false;
keymap_v1.setListener(*XkbConfig, keymapListener, xkb_config);
xkb_config.xkb_keymap_v1 = keymap_v1;
}
fn keymapListener(_: *river.XkbKeymapV1, event: river.XkbKeymapV1.Event, xkb_config: *XkbConfig) void {
switch (event) {
.success => {
log.info("Keyboard layout applied successfully", .{});
xkb_config.xkb_keymap_confirmed = true;
// Apply to all tracked keyboards
var it = xkb_config.keyboards.iterator(.forward);
while (it.next()) |keyboard| {
keyboard.xkb_keyboard_v1.setKeymap(xkb_config.xkb_keymap_v1.?);
}
},
.failure => |ev| {
log.err("Failed to apply keyboard layout: {s}", .{ev.error_msg});
xkb_config.xkb_keymap_confirmed = false;
if (xkb_config.xkb_keymap_v1) |keymap| {
keymap.destroy();
xkb_config.xkb_keymap_v1 = null;
}
},
}
}
fn dupeZOrNull(opt: ?[]const u8) ?[:0]u8 {
const slice = opt orelse return null;
return utils.gpa.dupeZ(u8, slice) catch {
log.err("Out of memory", .{});
return null;
};
}
const builtin = @import("builtin");
const std = @import("std");
const mem = std.mem;
const os = std.os;
const posix = std.posix;
const assert = std.debug.assert;
const wayland = @import("wayland");
const wl = wayland.client.wl;
const river = wayland.client.river;
const xkbcommon = @import("xkbcommon");
const utils = @import("utils.zig");
const Context = @import("Context.zig");
const Seat = @import("Seat.zig");
const log = std.log.scoped(.XkbConfig);

View file

@ -0,0 +1,75 @@
// SPDX-FileCopyrightText: 2026 Ben Buhse <me@benbuhse.email>
//
// SPDX-License-Identifier: GPL-3.0-only
const KeyboardLayoutConfig = @This();
const NodeName = enum {
rules,
model,
layout,
variant,
options,
};
rules: ?[]const u8 = null,
model: ?[]const u8 = null,
layout: ?[]const u8 = null,
variant: ?[]const u8 = null,
options: ?[]const u8 = null,
pub fn load(config: *Config, parser: *kdl.Parser, hostname: ?[]const u8) !void {
while (try parser.next()) |event| {
switch (event) {
.node => |node| {
const node_name = std.meta.stringToEnum(NodeName, node.name);
if (node_name) |name| {
if (!helpers.hostMatches(node, parser, hostname)) {
log.debug("Skipping \"keyboard_layout.{s}\" (host mismatch)", .{@tagName(name)});
continue;
}
const value = utils.stripQuotes(node.arg(parser, 0) orelse {
logWarnMissingNodeArg(name);
continue;
});
const duped = try utils.gpa.dupe(u8, value);
const target = switch (name) {
.rules => &config.keyboard_layout.rules,
.model => &config.keyboard_layout.model,
.layout => &config.keyboard_layout.layout,
.variant => &config.keyboard_layout.variant,
.options => &config.keyboard_layout.options,
};
if (target.*) |old| {
utils.gpa.free(old);
}
target.* = duped;
logDebugSettingNode(name, value);
} else {
helpers.logWarnInvalidNode(node.name);
}
},
.child_block_begin => try helpers.skipChildBlock(parser),
.child_block_end => return,
}
}
}
inline fn logDebugSettingNode(node_name: NodeName, node_value: []const u8) void {
log.debug("Setting keyboard_layout.{s} to {s}", .{ @tagName(node_name), node_value });
}
inline fn logWarnMissingNodeArg(node_name: NodeName) void {
log.warn("\"keyboard_layout.{s}\" missing argument. Ignoring", .{@tagName(node_name)});
}
const std = @import("std");
const kdl = @import("kdl");
const utils = @import("../utils.zig");
const Config = @import("../Config.zig");
const helpers = @import("helpers.zig");
const log = std.log.scoped(.config_keyboard_layout);

View file

@ -9,6 +9,7 @@ const Globals = struct {
river_layer_shell_v1: ?*river.LayerShellV1 = null, river_layer_shell_v1: ?*river.LayerShellV1 = null,
river_window_manager_v1: ?*river.WindowManagerV1 = null, river_window_manager_v1: ?*river.WindowManagerV1 = null,
river_xkb_bindings_v1: ?*river.XkbBindingsV1 = null, river_xkb_bindings_v1: ?*river.XkbBindingsV1 = null,
river_xkb_config_v1: ?*river.XkbConfigV1 = null,
wl_compositor: ?*wl.Compositor = null, wl_compositor: ?*wl.Compositor = null,
wl_shm: ?*wl.Shm = null, wl_shm: ?*wl.Shm = null,
@ -65,6 +66,7 @@ pub fn main() !void {
const river_layer_shell_v1 = globals.river_layer_shell_v1 orelse utils.interfaceNotAdvertised(river.LayerShellV1); const river_layer_shell_v1 = globals.river_layer_shell_v1 orelse utils.interfaceNotAdvertised(river.LayerShellV1);
const river_window_manager_v1 = globals.river_window_manager_v1 orelse utils.interfaceNotAdvertised(river.WindowManagerV1); const river_window_manager_v1 = globals.river_window_manager_v1 orelse utils.interfaceNotAdvertised(river.WindowManagerV1);
const river_xkb_bindings_v1 = globals.river_xkb_bindings_v1 orelse utils.interfaceNotAdvertised(river.XkbBindingsV1); const river_xkb_bindings_v1 = globals.river_xkb_bindings_v1 orelse utils.interfaceNotAdvertised(river.XkbBindingsV1);
const river_xkb_config_v1 = globals.river_xkb_config_v1 orelse utils.interfaceNotAdvertised(river.XkbConfigV1);
const config = try Config.create(); const config = try Config.create();
defer config.destroy(); defer config.destroy();
@ -78,6 +80,7 @@ pub fn main() !void {
.river_layer_shell_v1 = river_layer_shell_v1, .river_layer_shell_v1 = river_layer_shell_v1,
.river_window_manager_v1 = river_window_manager_v1, .river_window_manager_v1 = river_window_manager_v1,
.river_xkb_bindings_v1 = river_xkb_bindings_v1, .river_xkb_bindings_v1 = river_xkb_bindings_v1,
.river_xkb_config_v1 = river_xkb_config_v1,
.config = config, .config = config,
}); });
defer context.destroy(); defer context.destroy();
@ -243,7 +246,7 @@ fn registryListener(registry: *wl.Registry, event: wl.Registry.Event, globals: *
}; };
} else if (mem.orderZ(u8, ev.interface, wl.Output.interface.name) == .eq) { } else if (mem.orderZ(u8, ev.interface, wl.Output.interface.name) == .eq) {
if (ev.version < 4) utils.versionNotSupported(wl.Output, ev.version, 4); if (ev.version < 4) utils.versionNotSupported(wl.Output, ev.version, 4);
// We don't bind wl_output until the river_output send its .wl_output event // We don't bind wl_output until the river_output sends its .wl_output event
// This way, we don't miss the initial configuration events // This way, we don't miss the initial configuration events
} else if (mem.orderZ(u8, ev.interface, wl.Shm.interface.name) == .eq) { } else if (mem.orderZ(u8, ev.interface, wl.Shm.interface.name) == .eq) {
globals.wl_shm = registry.bind(ev.name, wl.Shm, 1) catch |e| { globals.wl_shm = registry.bind(ev.name, wl.Shm, 1) catch |e| {
@ -270,6 +273,10 @@ fn registryListener(registry: *wl.Registry, event: wl.Registry.Event, globals: *
globals.river_xkb_bindings_v1 = registry.bind(ev.name, river.XkbBindingsV1, 2) catch |e| { globals.river_xkb_bindings_v1 = registry.bind(ev.name, river.XkbBindingsV1, 2) catch |e| {
fatal("Failed to bind to river_xkb_bindings_v1: {any}", .{@errorName(e)}); fatal("Failed to bind to river_xkb_bindings_v1: {any}", .{@errorName(e)});
}; };
} else if (mem.orderZ(u8, ev.interface, river.XkbConfigV1.interface.name) == .eq) {
globals.river_xkb_config_v1 = registry.bind(ev.name, river.XkbConfigV1, 1) catch |e| {
fatal("Failed to bind to river_xkb_config_v1: {any}", .{@errorName(e)});
};
} }
}, },
.global_remove => |_| { .global_remove => |_| {