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);