diff --git a/build.zig b/build.zig
index 4179187..088d387 100644
--- a/build.zig
+++ b/build.zig
@@ -14,13 +14,16 @@ pub fn build(b: *std.Build) void {
const scanner = Scanner.create(b, .{});
const wayland = b.createModule(.{ .root_source_file = scanner.result });
+ const xkbcommon = b.dependency("xkbcommon", .{}).module("xkbcommon");
scanner.addCustomProtocol(b.path("protocol/river-window-management-v1.xml"));
+ scanner.addCustomProtocol(b.path("protocol/river-xkb-bindings-v1.xml"));
scanner.generate("wl_compositor", 4);
scanner.generate("wl_shm", 1);
scanner.generate("wl_output", 4);
- scanner.generate("river_window_manager_v1", 1);
+ scanner.generate("river_window_manager_v1", 3);
+ scanner.generate("river_xkb_bindings_v1", 2);
const exe = b.addExecutable(.{
.name = "beansprout",
@@ -34,6 +37,7 @@ pub fn build(b: *std.Build) void {
exe.pie = pie;
exe.root_module.addImport("wayland", wayland);
+ exe.root_module.addImport("xkbcommon", xkbcommon);
exe.linkLibC();
exe.linkSystemLibrary("wayland-client");
diff --git a/build.zig.zon b/build.zig.zon
index 2ee0788..2894df6 100644
--- a/build.zig.zon
+++ b/build.zig.zon
@@ -12,6 +12,10 @@
.url = "https://codeberg.org/ifreund/zig-wayland/archive/v0.4.0.tar.gz",
.hash = "wayland-0.4.0-lQa1khbMAQAsLS2eBR7M5lofyEGPIbu2iFDmoz8lPC27",
},
+ .xkbcommon = .{
+ .url = "https://codeberg.org/ifreund/zig-xkbcommon/archive/v0.3.0.tar.gz",
+ .hash = "xkbcommon-0.3.0-VDqIe3K9AQB2fG5ZeRcMC9i7kfrp5m2rWgLrmdNn9azr",
+ },
},
.paths = .{
diff --git a/protocol/river-xkb-bindings-v1.xml b/protocol/river-xkb-bindings-v1.xml
new file mode 100644
index 0000000..041b1bd
--- /dev/null
+++ b/protocol/river-xkb-bindings-v1.xml
@@ -0,0 +1,273 @@
+
+
+
+ SPDX-FileCopyrightText: © 2025 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.
+
+
+
+ This protocol allows the river-window-management-v1 window manager to
+ define key bindings in terms of xkbcommon keysyms and other configurable
+ properties.
+
+ 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.
+
+
+
+
+ This global interface should only be advertised to the client if the
+ river_window_manager_v1 global is also advertised.
+
+
+
+
+
+
+
+
+ This request indicates that the client will no longer use the
+ river_xkb_bindings_v1 object.
+
+
+
+
+
+ Define a key binding for the given seat in terms of an xkbcommon keysym
+ and other configurable properties.
+
+ The new key binding is not enabled until initial configuration is
+ completed and the enable request is made during a manage sequence.
+
+
+
+
+
+
+
+
+
+ Create an object to manage seat-specific xkb bindings state.
+
+ It is a protocol error to make this request more than once for a given
+ river_seat_v1 object.
+
+
+
+
+
+
+
+
+ This object allows the window manager to configure a xkbcommon key binding
+ and receive events when the key binding is triggered.
+
+ The new key binding is not enabled until the enable request is made during
+ a manage sequence.
+
+ Normally, all key events are sent to the surface with keyboard focus by
+ the compositor. Key events that trigger a key binding are not sent to the
+ surface with keyboard focus.
+
+ If multiple key bindings would be triggered by a single physical key event
+ on the compositor side, it is compositor policy which key binding(s) will
+ receive press/release events or if all of the matched key bindings receive
+ press/release events.
+
+ Key bindings might be matched by the same physical key event due to shared
+ keysym and modifiers. The layout override feature may also cause the same
+ physical key event to trigger two key bindings with different keysyms and
+ different layout overrides configured.
+
+
+
+
+ This request indicates that the client will no longer use the xkb key
+ binding object and that it may be safely destroyed.
+
+
+
+
+
+ Specify an xkb layout that should be used to translate key events for
+ the purpose of triggering this key binding irrespective of the currently
+ active xkb layout.
+
+ The layout argument is a 0-indexed xkbcommon layout number for the
+ keyboard that generated the key event.
+
+ If this request is never made, the currently active xkb layout of the
+ keyboard that generated the key event will be used.
+
+ This request modifies window management state and may only be made as
+ part of a manage sequence, see the river_window_manager_v1 description.
+
+
+
+
+
+
+ This request should be made after all initial configuration has been
+ completed and the window manager wishes the key binding to be able to be
+ triggered.
+
+ This request modifies window management state and may only be made as
+ part of a manage sequence, see the river_window_manager_v1 description.
+
+
+
+
+
+ This request may be used to temporarily disable the key binding. It may
+ be later re-enabled with the enable request.
+
+ This request modifies window management state and may only be made as
+ part of a manage sequence, see the river_window_manager_v1 description.
+
+
+
+
+
+ This event indicates that the physical key triggering the binding has
+ been pressed.
+
+ This event will be followed by a manage_start event after all other new
+ state has been sent by the server.
+
+ The compositor should wait for the manage sequence to complete before
+ processing further input events. This allows the window manager client
+ to, for example, modify key bindings and keyboard focus without racing
+ against future input events. The window manager should of course respond
+ as soon as possible as the capacity of the compositor to buffer incoming
+ input events is finite.
+
+
+
+
+
+ This event indicates that the physical key triggering the binding has
+ been released.
+
+ Releasing the modifiers for the binding without releasing the "main"
+ physical key that produces the bound keysym does not trigger the release
+ event. This event is sent when the "main" key is released, even if the
+ modifiers have changed since the pressed event.
+
+ This event will be followed by a manage_start event after all other new
+ state has been sent by the server.
+
+ The compositor should wait for the manage sequence to complete before
+ processing further input events. This allows the window manager client
+ to, for example, modify key bindings and keyboard focus without racing
+ against future input events. The window manager should of course respond
+ as soon as possible as the capacity of the compositor to buffer incoming
+ input events is finite.
+
+
+
+
+
+ This event indicates that repeating should be stopped for the binding if
+ the window manager has been repeating some action since the pressed
+ event.
+
+ This event is generally sent when some other (possible unbound) key is
+ pressed after the pressed event is sent and before the released event
+ is sent for this binding.
+
+ This event will be followed by a manage_start event after all other new
+ state has been sent by the server.
+
+
+
+
+
+
+ This object manages xkb bindings state associated with a specific seat.
+
+
+
+
+ This request indicates that the client will no longer use the object and
+ that it may be safely destroyed.
+
+
+
+
+
+ Ensure that the next non-modifier key press and corresponding release
+ events for this seat are not sent to the currently focused surface.
+
+ If the next non-modifier key press triggers a binding, the
+ pressed/released events are sent to the river_xkb_binding_v1 object as
+ usual.
+
+ If the next non-modifier key press does not trigger a binding, the
+ ate_unbound_key event is sent instead.
+
+ Rationale: the window manager may wish to implement "chorded"
+ keybindings where triggering a binding activates a "submap" with a
+ different set of keybindings. Without a way to eat the next key
+ press event, there is no good way for the window manager to know that it
+ should error out and exit the submap when a key not bound in the submap
+ is pressed.
+
+ This request modifies window management state and may only be made as
+ part of a manage sequence, see the river_window_manager_v1 description.
+
+
+
+
+
+ This requests cancels the effect of the latest ensure_next_key_eaten
+ request if no key has been eaten due to the request yet. This request
+ has no effect if a key has already been eaten or no
+ ensure_next_key_eaten was made.
+
+ Rationale: the window manager may wish cancel an uncompleted "chorded"
+ keybinding after a timeout of a few seconds. Note that since this
+ timeout use-case requires the window manager to trigger a manage sequence
+ with the river_window_manager_v1.manage_dirty request it is possible that
+ the ate_unbound_key key event may be sent before the window manager has
+ a chance to make the cancel_ensure_next_key_eaten request.
+
+ This request modifies window management state and may only be made as
+ part of a manage sequence, see the river_window_manager_v1 description.
+
+
+
+
+
+ An unbound key press event was eaten due to the ensure_next_key_eaten
+ request.
+
+ This event will be followed by a manage_start event after all other new
+ state has been sent by the server.
+
+
+
+
diff --git a/src/Window.zig b/src/Window.zig
index 71b90fe..0bcefae 100644
--- a/src/Window.zig
+++ b/src/Window.zig
@@ -1,4 +1,4 @@
-// SPDX-FileCopyrightText: 2025 Ben Buhse
+// SPDX-FileCopyrightText: 2025-2026 Ben Buhse
//
// SPDX-License-Identifier: GPL-3.0-or-later
@@ -19,6 +19,7 @@ new_x: i32 = 0,
new_y: i32 = 0,
link: wl.list.Link,
+is_head: bool = false,
pub fn init(window: *Window, context: *Context, river_window_v1: *river.WindowV1) void {
window.* = .{
@@ -47,7 +48,7 @@ fn windowListener(river_window_v1: *river.WindowV1, event: river.WindowV1.Event,
window.context.allocator.destroy(window);
},
.dimensions => |ev| {
- // Part of the protocol requires these are strictly greater-than zero
+ // The protocol requires these are strictly greather than zero.
assert(ev.width > 0 and ev.height > 0);
window.width = @intCast(ev.width);
window.height = @intCast(ev.height);
@@ -69,13 +70,16 @@ pub fn manage(window: *Window) void {
window.new_y = 0;
}
+ // TODO: Support multiple primaries
+ // TODO: Is this a valid way to check for the window's index?
+
// TODO: Remove this -- just fullscreen for now
if (window.width != window.context.wm.outputs.first().?.width or
window.height != window.context.wm.outputs.first().?.height)
{
window.width = @intCast(window.context.wm.outputs.first().?.width);
window.height = @intCast(window.context.wm.outputs.first().?.height);
- log.debug("setting window width={d} and height={d}", .{ window.width, window.height });
+ log.debug("setting window width={d} and height={d} {}", .{ window.width, window.height, window.is_head });
}
window.window_v1.proposeDimensions(window.width, window.height);
// window.window_v1.setTiled(.{ .top = true, .bottom = true, .left = true, .right = true });
diff --git a/src/WindowManager.zig b/src/WindowManager.zig
index 6dc0fed..516a2f6 100644
--- a/src/WindowManager.zig
+++ b/src/WindowManager.zig
@@ -1,4 +1,4 @@
-// SPDX-FileCopyrightText: 2025 Ben Buhse
+// SPDX-FileCopyrightText: 2025-2026 Ben Buhse
//
// SPDX-License-Identifier: GPL-3.0-or-later
@@ -12,6 +12,8 @@ seats: wl.list.Head(Seat, .link),
outputs: wl.list.Head(Output, .link),
windows: wl.list.Head(Window, .link),
+window_count: u8 = 0,
+
pub fn init(wm: *WindowManager, context: *Context, window_manager_v1: *river.WindowManagerV1) void {
assert(wm == &context.wm);
wm.* = .{
@@ -38,6 +40,10 @@ fn windowManagerV1Listener(window_manager_v1: *river.WindowManagerV1, event: riv
std.posix.exit(1);
},
.manage_start => {
+ if (!context.initialized) {
+ context.initialized = true;
+ context.xkb_bindings.addBinding(xkbcommon.Keysym.t, .{ .mod1 = true });
+ }
{
var it = wm.seats.iterator(.forward);
while (it.next()) |seat| {
@@ -96,6 +102,11 @@ fn windowManagerV1Listener(window_manager_v1: *river.WindowManagerV1, event: riv
var window = context.allocator.create(Window) catch @panic("out-of-memory; exiting.");
window.init(context, ev.id);
wm.windows.append(window);
+ wm.window_count += 1;
+ if (wm.window_count == 1) {
+ window.is_head = true;
+ }
+ log.debug("window_count = {d}", .{wm.window_count});
},
else => |ev| {
log.debug("unhandled event: {s}", .{@tagName(ev)});
@@ -110,10 +121,11 @@ const wayland = @import("wayland");
const wl = wayland.client.wl;
const river = wayland.client.river;
+const xkbcommon = @import("xkbcommon");
+
const Context = @import("main.zig").Context;
const Output = @import("Output.zig");
+const Seat = @import("Seat.zig");
const Window = @import("Window.zig");
const log = std.log.scoped(.WindowManager);
-
-const Seat = @import("Seat.zig");
diff --git a/src/XkbBindings.zig b/src/XkbBindings.zig
new file mode 100644
index 0000000..661cef2
--- /dev/null
+++ b/src/XkbBindings.zig
@@ -0,0 +1,87 @@
+// SPDX-FileCopyrightText: 2025-2026 Ben Buhse
+//
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+const XkbBindings = @This();
+
+const XkbBinding = struct {
+ xkb_binding_v1: *river.XkbBindingV1,
+ link: wl.list.Link,
+
+ fn init(xkb_binding: *XkbBinding, xkb_binding_v1: *river.XkbBindingV1) void {
+ xkb_binding.* = .{
+ .xkb_binding_v1 = xkb_binding_v1,
+ .link = undefined, // Handled by the wl.list
+ };
+
+ xkb_binding.xkb_binding_v1.setListener(*XkbBinding, xkbBindingListener, xkb_binding);
+ }
+
+ fn xkbBindingListener(xkb_binding_v1: *river.XkbBindingV1, event: river.XkbBindingV1.Event, xkb_binding: *XkbBinding) void {
+ assert(xkb_binding.xkb_binding_v1 == xkb_binding_v1);
+ switch (event) {
+ .pressed => {
+ var child = std.process.Child.init(&.{"foot"}, std.heap.c_allocator);
+ _ = child.spawn() catch |err| {
+ log.err("Failed to spawn foot: {}", .{err});
+ };
+ },
+ .released => {},
+ else => |ev| {
+ log.debug("unhandled event: {s}", .{@tagName(ev)});
+ },
+ }
+ }
+};
+
+context: *Context,
+
+xkb_bindings_v1: *river.XkbBindingsV1,
+
+bindings: wl.list.Head(XkbBinding, .link),
+
+xkb_bindings_seat_v1: ?*river.XkbBindingsSeatV1 = null,
+
+pub fn init(xkb_bindings: *XkbBindings, context: *Context, xkb_bindings_v1: *river.XkbBindingsV1) void {
+ assert(xkb_bindings == &context.xkb_bindings);
+ xkb_bindings.* = .{
+ .context = context,
+ .xkb_bindings_v1 = xkb_bindings_v1,
+ .bindings = undefined, // we will initialize this shortly
+ };
+
+ xkb_bindings.bindings.init();
+}
+
+pub fn getSeat(xkb_bindings: *XkbBindings) *river.SeatV1 {
+ const seat = xkb_bindings.context.wm.seats.first() orelse @panic("No seat available");
+ return seat.seat_v1;
+}
+
+pub fn addBinding(xkb_bindings: *XkbBindings, keysym: u32, modifiers: river.SeatV1.Modifiers) void {
+ const seat_v1 = xkb_bindings.getSeat();
+ const xkb_binding_v1 = xkb_bindings.xkb_bindings_v1.getXkbBinding(seat_v1, keysym, modifiers) catch |err| {
+ log.err("Failed to get xkb binding: {}", .{err});
+ return;
+ };
+
+ const xkb_binding = xkb_bindings.context.allocator.create(XkbBinding) catch @panic("out-of-memory");
+ xkb_binding.init(xkb_binding_v1);
+ xkb_bindings.bindings.append(xkb_binding);
+
+ xkb_binding_v1.enable();
+}
+
+const std = @import("std");
+const assert = std.debug.assert;
+
+const wayland = @import("wayland");
+const wl = wayland.client.wl;
+const river = wayland.client.river;
+
+const xkbcommon = @import("xkbcommon");
+
+const Context = @import("main.zig").Context;
+const Seat = @import("Seat.zig");
+
+const log = std.log.scoped(.XkbBindings);
diff --git a/src/main.zig b/src/main.zig
index 8792c0b..ca863ab 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -15,6 +15,7 @@ pub const Context = struct {
shm: ?*wl.Shm = null,
wm: WindowManager,
+ xkb_bindings: XkbBindings,
fn registryListener(registry: *wl.Registry, event: wl.Registry.Event, context: *Context) void {
switch (event) {
@@ -31,11 +32,17 @@ pub const Context = struct {
std.posix.exit(1);
};
} else if (mem.orderZ(u8, ev.interface, river.WindowManagerV1.interface.name) == .eq) {
- const window_manager_v1 = registry.bind(ev.name, river.WindowManagerV1, 1) catch |e| {
- log.err("Failed to bind to window_manager_v: {any}", .{@errorName(e)});
+ const window_manager_v1 = registry.bind(ev.name, river.WindowManagerV1, 3) catch |e| {
+ log.err("Failed to bind to window_manager_v1: {any}", .{@errorName(e)});
std.posix.exit(1);
};
context.wm.init(context, window_manager_v1);
+ } else if (mem.orderZ(u8, ev.interface, river.XkbBindingsV1.interface.name) == .eq) {
+ const xkb_bindings_v1 = registry.bind(ev.name, river.XkbBindingsV1, 2) catch |e| {
+ log.err("Failed to bind to xkb_bindings_v1: {any}", .{@errorName(e)});
+ std.posix.exit(1);
+ };
+ context.xkb_bindings.init(context, xkb_bindings_v1);
}
},
// We don't need .global_remove
@@ -69,6 +76,7 @@ pub fn main() !void {
.display = wl_display,
.registry = registry,
.wm = undefined,
+ .xkb_bindings = undefined,
};
registry.setListener(*Context, Context.registryListener, &context);
@@ -107,5 +115,6 @@ const river = wayland.client.river;
const wl = wayland.client.wl;
const WindowManager = @import("WindowManager.zig");
+const XkbBindings = @import("XkbBindings.zig");
const log = std.log.scoped(.main);