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

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

View file

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

View file

@ -134,6 +134,11 @@ fn initialize(wm: *WindowManager) void {
}
binding.enable();
}
// Apply keyboard layout from config
if (context.xkb_config) |xkb_config| {
xkb_config.applyKeyboardLayout();
}
}
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_window_manager_v1: ?*river.WindowManagerV1 = null,
river_xkb_bindings_v1: ?*river.XkbBindingsV1 = null,
river_xkb_config_v1: ?*river.XkbConfigV1 = null,
wl_compositor: ?*wl.Compositor = 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_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_config_v1 = globals.river_xkb_config_v1 orelse utils.interfaceNotAdvertised(river.XkbConfigV1);
const config = try Config.create();
defer config.destroy();
@ -78,6 +80,7 @@ pub fn main() !void {
.river_layer_shell_v1 = river_layer_shell_v1,
.river_window_manager_v1 = river_window_manager_v1,
.river_xkb_bindings_v1 = river_xkb_bindings_v1,
.river_xkb_config_v1 = river_xkb_config_v1,
.config = config,
});
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) {
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
} else if (mem.orderZ(u8, ev.interface, wl.Shm.interface.name) == .eq) {
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| {
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 => |_| {