From 43e3d268c997023d236ea4862ab06e2de40dcb03 Mon Sep 17 00:00:00 2001 From: Ben Buhse Date: Tue, 27 Jan 2026 14:54:27 -0600 Subject: [PATCH 1/5] Implement initial config loading Config goes in $XDG_CONFIG_HOME/beansprout/config.kdl or $HOME/.config/beansprout/config.kdl Config is in the kdl format. Right now, the supported options are ```zig /// Width of window borders in pixels border_width: u8 = 2, /// Color of focused window's border in 0xRRGGBBAA or 0xRRGGBB form border_color_focused: RiverColor = utils.parseRgbaComptime("0x89b4fa"), /// Color of uffocused windows' borders in 0xRRGGBBAA or 0xRRGGBB form border_color_unfocused: RiverColor = utils.parseRgbaComptime("0x1e1e2e"), /// Where a new window should attach, top or bottom of the stack attach_mode: AttachMode = .top, /// Should focus change when the cursor moves onto a new window focus_follows_pointer: bool = true, /// Should the pointer warp to the center of newly-focused windows pointer_warp_on_focus_change: bool = true, ``` I plan to add Keybinds shortly. If parsing the configuration fails, the default config will be used and the WM will continue loading. --- build.zig | 10 ++ build.zig.zon | 8 ++ src/Config.zig | 236 +++++++++++++++++++++++++++++++++++++++++- src/Context.zig | 4 +- src/Output.zig | 2 - src/Window.zig | 8 +- src/WindowManager.zig | 6 +- src/main.zig | 5 +- 8 files changed, 261 insertions(+), 18 deletions(-) diff --git a/build.zig b/build.zig index 088d387..7741687 100644 --- a/build.zig +++ b/build.zig @@ -12,8 +12,12 @@ pub fn build(b: *std.Build) void { const strip = b.option(bool, "strip", "Omit debug information") orelse false; const pie = b.option(bool, "pie", "Build a Position Independent Executable") orelse false; + // Wayland const scanner = Scanner.create(b, .{}); const wayland = b.createModule(.{ .root_source_file = scanner.result }); + // Rest of the deps + const kdl = b.dependency("kdl", .{}).module("kdl"); + const known_folders = b.dependency("known_folders", .{}).module("known-folders"); const xkbcommon = b.dependency("xkbcommon", .{}).module("xkbcommon"); scanner.addCustomProtocol(b.path("protocol/river-window-management-v1.xml")); @@ -36,8 +40,11 @@ pub fn build(b: *std.Build) void { }); exe.pie = pie; + // Make sure to also add new imports to the exe_check step exe.root_module.addImport("wayland", wayland); exe.root_module.addImport("xkbcommon", xkbcommon); + exe.root_module.addImport("kdl", kdl); + exe.root_module.addImport("known_folders", known_folders); exe.linkLibC(); exe.linkSystemLibrary("wayland-client"); @@ -82,6 +89,9 @@ pub fn build(b: *std.Build) void { }); exe_check.root_module.addImport("wayland", wayland); + exe_check.root_module.addImport("xkbcommon", xkbcommon); + exe_check.root_module.addImport("kdl", kdl); + exe_check.root_module.addImport("known_folders", known_folders); exe_check.linkLibC(); exe_check.linkSystemLibrary("wayland-client"); diff --git a/build.zig.zon b/build.zig.zon index 2894df6..cb0acd3 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -16,6 +16,14 @@ .url = "https://codeberg.org/ifreund/zig-xkbcommon/archive/v0.3.0.tar.gz", .hash = "xkbcommon-0.3.0-VDqIe3K9AQB2fG5ZeRcMC9i7kfrp5m2rWgLrmdNn9azr", }, + .kdl = .{ + .url = "https://codeberg.org/desttinghim/zig-kdl/archive/edc943426ba1fc47606568a9fc7f402b2b1992e0.tar.gz", + .hash = "kdl-0.0.0-8rilEPw_AQDhyfjEIg9pzpBHUyz6bOQ6qCfZImzYn42A", + }, + .known_folders = .{ + .url = "https://github.com/ziglibs/known-folders/archive/83d39161eac2ed6f37ad3cb4d9dd518696ce90bb.tar.gz", + .hash = "known_folders-0.0.0-Fy-PJv3LAAABBRVoZWVrKZdyLoUfl5VRY5fqRRRdnF5L", + }, }, .paths = .{ diff --git a/src/Config.zig b/src/Config.zig index 8625feb..9a19e6d 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -4,10 +4,238 @@ const Config = @This(); -border_width: u3, -border_color_focused: RiverColor, -border_color_unfocused: RiverColor, +const CONFIG_FILE = "beansprout/config.kdl"; + +/// Width of window borders in pixels +border_width: u8 = 2, +/// Color of focused window's border in 0xRRGGBBAA or 0xRRGGBB form +border_color_focused: RiverColor = utils.parseRgbaComptime("0x89b4fa"), +/// Color of uffocused windows' borders in 0xRRGGBBAA or 0xRRGGBB form +border_color_unfocused: RiverColor = utils.parseRgbaComptime("0x1e1e2e"), + +/// Where a new window should attach, top or bottom of the stack +attach_mode: AttachMode = .top, +/// Should focus change when the cursor moves onto a new window +focus_follows_pointer: bool = true, +/// Should the pointer warp to the center of newly-focused windows +pointer_warp_on_focus_change: bool = true, + +pub const AttachMode = enum { + top, + bottom, +}; + +const NodeName = enum { + attach_mode, + focus_follows_pointer, + pointer_warp_on_focus_change, + borders, +}; + +const BorderNodeName = enum { + width, + color_focused, + color_unfocused, +}; + +pub fn create() !*Config { + var config: *Config = try utils.allocator.create(Config); + errdefer config.destroy(); + config.* = .{}; // create() gives us undefined memory + + if (try known_folders.getPath(utils.allocator, .local_configuration)) |config_dir| blk: { + defer utils.allocator.free(config_dir); + + const config_path = try std.fmt.allocPrint(utils.allocator, "{s}/{s}", .{ config_dir, CONFIG_FILE }); + defer utils.allocator.free(config_path); + + const file = fs.openFileAbsolute(config_path, .{}) catch break :blk; + + var read_buffer: [1024]u8 = undefined; + var file_reader = file.reader(&read_buffer); + + config.load(&file_reader.interface) catch |err| { + log.err("Error while loading config: {s}. Continuing with default config", .{@errorName(err)}); + // Overwrite any partially-loaded config + config.* = .{}; + }; + } + + return config; +} + +pub fn destroy(config: *Config) void { + utils.allocator.destroy(config); +} + +fn load(config: *Config, reader: *Io.Reader) !void { + var parser = try kdl.Parser.init(utils.allocator, reader, .{}); + defer parser.deinit(utils.allocator); + + var next_child_block: ?NodeName = null; + + // Parse the KDL config + while (try parser.next()) |event| { + // First, we switch on the type of event + switch (event) { + .node => |node| { + if (next_child_block) |child_block| { + log.warn("Expected child block for {s}, but got another node instead", .{@tagName(child_block)}); + } + // If it's a node, we check if it's a valid NodeName + const node_name = std.meta.stringToEnum(NodeName, node.name); + if (node_name) |name| { + // Next, we have to check the specifics for the NodeName + switch (name) { + .attach_mode => { + const attach_mode_str = node.arg(&parser, 0) orelse ""; + if (std.meta.stringToEnum(AttachMode, attach_mode_str)) |mode| { + config.attach_mode = mode; + log.debug("Setting attach_mode to {s}", .{@tagName(mode)}); + } else { + log.warn("Invalid \"attach_mode\". Using default", .{}); + continue; + } + }, + .focus_follows_pointer => { + const focus_follows_pointer_str = node.arg(&parser, 0) orelse ""; + if (config.boolFromKdlStr(focus_follows_pointer_str)) |focus_follows_pointer| { + config.focus_follows_pointer = focus_follows_pointer; + log.debug("Setting focus_follows_pointer to {}", .{focus_follows_pointer}); + } else { + log.warn("Invalid \"focus_follows_pointer\". Using default", .{}); + continue; + } + }, + .pointer_warp_on_focus_change => { + const pointer_warp_on_focus_change_str = node.arg(&parser, 0) orelse ""; + if (config.boolFromKdlStr(pointer_warp_on_focus_change_str)) |pointer_warp_on_focus_change| { + config.pointer_warp_on_focus_change = pointer_warp_on_focus_change; + log.debug("Setting pointer_warp_on_focus_change to {}", .{pointer_warp_on_focus_change}); + } else { + log.warn("Invalid \"pointer_warp_on_focus_change\". Using default", .{}); + continue; + } + }, + .borders => { + next_child_block = .borders; + }, + } + } else { + log.warn("Invalid KDL node {s}. Ignoring it and carrying on", .{node.name}); + } + }, + .child_block_begin => { + if (next_child_block) |child_block| { + switch (child_block) { + .borders => try config.loadBordersChildBlock(&parser), + else => { + // Nothing else should ever be marked as a next_child_block + unreachable; + }, + } + next_child_block = null; + } else { + try config.skipChildBlock(&parser); + } + }, + .child_block_end => { + @panic("Reached end of non-existant child block. A bug in zig-kdl?"); + }, + } + } +} + +fn loadBordersChildBlock(config: *Config, parser: *kdl.Parser) !void { + while (try parser.next()) |event| { + switch (event) { + .node => |node| { + // If it's a node, we check if it's a valid NodeName + const node_name = std.meta.stringToEnum(BorderNodeName, node.name); + if (node_name) |name| { + switch (name) { + .width => { + const width_str = node.arg(parser, 0) orelse ""; + config.border_width = fmt.parseInt(u8, width_str, 10) catch { + log.warn("Invalid border.width \"{s}\"", .{width_str}); + continue; + }; + log.debug("Setting border.width to {d}", .{config.border_width}); + }, + .color_focused => { + const color_str = node.arg(parser, 0) orelse ""; + config.border_color_focused = utils.parseRgba(color_str) catch { + log.warn("Invalid border.color_focused \"{s}\"", .{color_str}); + continue; + }; + log.debug("Setting border.color_focused to {s}", .{color_str}); + }, + .color_unfocused => { + const color_str = node.arg(parser, 0) orelse ""; + config.border_color_unfocused = utils.parseRgba(color_str) catch { + log.warn("Invalid border.color_unfocused \"{s}\"", .{color_str}); + continue; + }; + log.debug("Setting border.color_unfocused to {s}", .{color_str}); + }, + } + } else { + log.warn("Invalid KDL node {s}. Ignoring it and carrying on", .{node.name}); + } + }, + .child_block_begin => { + // borders should never have a nested child block + try config.skipChildBlock(parser); + }, + .child_block_end => { + // Done parsing the borders block; return + return; + }, + } + } +} + +/// Skips an entire child block in KDL +fn skipChildBlock(_: *Config, parser: *kdl.Parser) !void { + log.warn("Unexpected child block. Skipping it", .{}); + while (try parser.next()) |event| { + switch (event) { + .child_block_end => return, + else => { + // We don't care about anything else in this child block + }, + } + } +} + +/// Convert a KDL argument into a bool or null if the string is not a bool +/// "#true" and "true" => true +/// "#false" and "false" => false +/// else => null +fn boolFromKdlStr(_: Config, arg_str: []const u8) ?bool { + if (mem.eql(u8, arg_str, "#true") or + mem.eql(u8, arg_str, "true")) + { + return true; + } else if (mem.eql(u8, arg_str, "#false") or + mem.eql(u8, arg_str, "false")) + { + return false; + } + return null; +} const std = @import("std"); +const fmt = std.fmt; +const fs = std.fs; +const mem = std.mem; +const Io = std.Io; -const RiverColor = @import("utils.zig").RiverColor; +const kdl = @import("kdl"); +const known_folders = @import("known_folders"); +const KdlNode = kdl.Parser.Node; + +const utils = @import("utils.zig"); +const RiverColor = utils.RiverColor; + +const log = std.log.scoped(.Config); diff --git a/src/Context.zig b/src/Context.zig index 8001b4e..8fbdc5d 100644 --- a/src/Context.zig +++ b/src/Context.zig @@ -19,7 +19,7 @@ wm: *WindowManager, xkb_bindings: *XkbBindings, // WM Configuration -config: Config, +config: *Config, pub fn create( wl_display: *wl.Display, @@ -27,7 +27,7 @@ pub fn create( wl_compositor: *wl.Compositor, river_window_manager_v1: *river.WindowManagerV1, river_xkb_bindings_v1: *river.XkbBindingsV1, - config: Config, + config: *Config, ) !*Context { const context = try utils.allocator.create(Context); errdefer context.destroy(); diff --git a/src/Output.zig b/src/Output.zig index 7a07c21..75d35dc 100644 --- a/src/Output.zig +++ b/src/Output.zig @@ -56,8 +56,6 @@ fn outputListener(river_output_v1: *river.OutputV1, event: river.OutputV1.Event, output.height = ev.height; }, .position => |ev| { - // TODO - CONFIG: Allow setting output position (do I even need to - // do this, or do I just get told what the position is?) output.x = ev.x; output.y = ev.y; }, diff --git a/src/Window.zig b/src/Window.zig index b4b4102..6a20b5e 100644 --- a/src/Window.zig +++ b/src/Window.zig @@ -208,13 +208,9 @@ pub fn render(window: *Window) void { // Show or hide the Window if (window.pending_render.show) |show| { if (show) { - if (!window.show) { - window.river_window_v1.show(); - } + window.river_window_v1.show(); } else { - if (window.show) { - window.river_window_v1.hide(); - } + window.river_window_v1.hide(); } } } diff --git a/src/WindowManager.zig b/src/WindowManager.zig index cf4f0ff..e1b24d5 100644 --- a/src/WindowManager.zig +++ b/src/WindowManager.zig @@ -286,8 +286,10 @@ fn windowManagerV1Listener(window_manager_v1: *river.WindowManagerV1, event: riv const output = wm.outputs.first() orelse @panic("Failed to get output"); const window = Window.create(context, ev.id, output) catch @panic("Out of memory"); - // TODO - CONFIG: Allow appending window instead of prepending - wm.windows.prepend(window); + switch (context.config.attach_mode) { + .top => wm.windows.prepend(window), + .bottom => wm.windows.append(window), + } seat.pending_manage.pending_focus = .{ .window = window }; seat.pending_manage.should_warp_pointer = true; diff --git a/src/main.zig b/src/main.zig index c304dde..88bfbef 100644 --- a/src/main.zig +++ b/src/main.zig @@ -37,14 +37,15 @@ pub fn main() !void { 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 config = try Config.create(); + defer config.destroy(); const context = try Context.create( wl_display, wl_registry, wl_compositor, river_window_manager_v1, river_xkb_bindings_v1, - // Hardcoded config for now - .{ .border_width = 2, .border_color_focused = utils.parseRgbaComptime("0x89b4fa"), .border_color_unfocused = utils.parseRgbaComptime("0x1e1e2e") }, + config, ); defer context.destroy(); From c4d2c40f1aafb309ff67441786304796dd9e72fb Mon Sep 17 00:00:00 2001 From: Ben Buhse Date: Tue, 27 Jan 2026 15:13:02 -0600 Subject: [PATCH 2/5] Fix Config.skipChildBlock() Before, there would be issues for nested child blocks --- src/Config.zig | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/Config.zig b/src/Config.zig index 9a19e6d..0bc177f 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -139,9 +139,7 @@ fn load(config: *Config, reader: *Io.Reader) !void { try config.skipChildBlock(&parser); } }, - .child_block_end => { - @panic("Reached end of non-existant child block. A bug in zig-kdl?"); - }, + .child_block_end => log.err("Reached unexpected .child_block_end. Ignoring it", .{}), } } } @@ -198,11 +196,21 @@ fn loadBordersChildBlock(config: *Config, parser: *kdl.Parser) !void { /// Skips an entire child block in KDL fn skipChildBlock(_: *Config, parser: *kdl.Parser) !void { log.warn("Unexpected child block. Skipping it", .{}); + + var depth: usize = 0; while (try parser.next()) |event| { switch (event) { - .child_block_end => return, + // Nested child block + .child_block_begin => depth += 1, + .child_block_end => { + if (depth == 0) { + return; + } else { + depth -= 1; + } + }, else => { - // We don't care about anything else in this child block + // We don't care about any nodes in here }, } } From 676ca40891c990ff1a84312805b8c1f70cca25e2 Mon Sep 17 00:00:00 2001 From: Ben Buhse Date: Tue, 27 Jan 2026 15:30:30 -0600 Subject: [PATCH 3/5] Actually use focus_follows_pointer and pointer_warp_on_focus_change --- README.md | 7 +++++-- examples/config.kdl | 10 ++++++++++ examples/init | 6 ------ src/Config.zig | 11 ++++++----- src/Seat.zig | 29 ++++++++++++++++------------- 5 files changed, 37 insertions(+), 26 deletions(-) create mode 100644 examples/config.kdl delete mode 100755 examples/init diff --git a/README.md b/README.md index eaba651..f67f0b9 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,18 @@ # beansprout wm - ## TODOs [ ] Support multiple outputs [ ] Support multiple seats [ ] Support floating windows [ ] Support wallpapers [ ] Support a bar +[ ] Support starting programs at WM launch +[ ] Support changeable primary ratio +[ ] Support changeable primary count +[ ] Support overriding config location diff --git a/examples/config.kdl b/examples/config.kdl new file mode 100644 index 0000000..d565026 --- /dev/null +++ b/examples/config.kdl @@ -0,0 +1,10 @@ +attach_mode top + +focus_follows_pointer true +pointer_warp_on_focus_change true + +borders { + width 2 + color_focused 0x89b4fa + color_unfocused 0x1e1e2e +} diff --git a/examples/init b/examples/init deleted file mode 100755 index 96bc152..0000000 --- a/examples/init +++ /dev/null @@ -1,6 +0,0 @@ -#! /bin/sh - -./zig-out/bin/beansprout & - -/usr/bin/foot & -/usr/bin/foot & diff --git a/src/Config.zig b/src/Config.zig index 0bc177f..0edc44f 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -193,7 +193,7 @@ fn loadBordersChildBlock(config: *Config, parser: *kdl.Parser) !void { } } -/// Skips an entire child block in KDL +/// Skips an entire child block including any nested child blocks fn skipChildBlock(_: *Config, parser: *kdl.Parser) !void { log.warn("Unexpected child block. Skipping it", .{}); @@ -216,10 +216,11 @@ fn skipChildBlock(_: *Config, parser: *kdl.Parser) !void { } } -/// Convert a KDL argument into a bool or null if the string is not a bool -/// "#true" and "true" => true -/// "#false" and "false" => false -/// else => null +/// Convert a KDL argument into a bool +/// +/// if arg_str in ["#true", "true"], return true +/// if arg_str in ["#false", "false"], return false +/// else, return null fn boolFromKdlStr(_: Config, arg_str: []const u8) ?bool { if (mem.eql(u8, arg_str, "#true") or mem.eql(u8, arg_str, "true")) diff --git a/src/Seat.zig b/src/Seat.zig index fbe6839..38a460e 100644 --- a/src/Seat.zig +++ b/src/Seat.zig @@ -53,7 +53,9 @@ fn seatListener(river_seat_v1: *river.SeatV1, event: river.SeatV1.Event, seat: * .wl_seat => |ev| { log.debug("initializing new river_seat_v1 corresponding to wl_seat: {d}", .{ev.name}); }, - .pointer_enter => |ev| seat.setWindowFocus(ev.window), + .pointer_enter => |ev| if (seat.context.config.focus_follows_pointer) { + seat.setWindowFocus(ev.window); + }, .window_interaction => |ev| seat.setWindowFocus(ev.window), else => |ev| { log.debug("unhandled event: {s}", .{@tagName(ev)}); @@ -95,18 +97,19 @@ pub fn manage(seat: *Seat) void { } } - if (seat.pending_manage.should_warp_pointer) { - const window = seat.focused orelse { - log.err("Trying to warp-on-focus-change without a focused window.", .{}); - return; - }; - // TODO - CONFIG: Allow disabling this behaviour - // Warp pointer to center of focused window; - // because the x and y coords are set during render, we need to check if - // there are new coordinates in window.pending_render. - const pointer_x: i32 = (window.pending_render.x orelse window.x) + @divTrunc(window.width, 2); - const pointer_y: i32 = (window.pending_render.y orelse window.y) + @divTrunc(window.height, 2); - seat.river_seat_v1.pointerWarp(pointer_x, pointer_y); + if (seat.pending_manage.should_warp_pointer) blk: { + if (seat.context.config.pointer_warp_on_focus_change) { + const window = seat.focused orelse { + log.err("Trying to warp-on-focus-change without a focused window.", .{}); + break :blk; + }; + // Warp pointer to center of focused window; + // because the x and y coords are set during render, we need to check if + // there are new coordinates in window.pending_render. + const pointer_x: i32 = (window.pending_render.x orelse window.x) + @divTrunc(window.width, 2); + const pointer_y: i32 = (window.pending_render.y orelse window.y) + @divTrunc(window.height, 2); + seat.river_seat_v1.pointerWarp(pointer_x, pointer_y); + } } } From 9b524b810de2aa00fa644ba28632c818f0418914 Mon Sep 17 00:00:00 2001 From: Ben Buhse Date: Tue, 27 Jan 2026 18:37:15 -0600 Subject: [PATCH 4/5] Add helper functions for logging in Config.zig logWarnInvalidNodeArg() can be used for when a node has an invalid type for its argument. logDebugSettingNode() prints the configuration name and the value being set. --- src/Config.zig | 51 ++++++++++++++++++++++++++++++++++---------------- 1 file changed, 35 insertions(+), 16 deletions(-) diff --git a/src/Config.zig b/src/Config.zig index 0edc44f..be8cd1a 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -80,7 +80,8 @@ fn load(config: *Config, reader: *Io.Reader) !void { switch (event) { .node => |node| { if (next_child_block) |child_block| { - log.warn("Expected child block for {s}, but got another node instead", .{@tagName(child_block)}); + log.warn("Expected child block for {s}, but got another node instead. Continuing but ignoring {s}", .{ @tagName(child_block), @tagName(child_block) }); + next_child_block = null; } // If it's a node, we check if it's a valid NodeName const node_name = std.meta.stringToEnum(NodeName, node.name); @@ -91,29 +92,29 @@ fn load(config: *Config, reader: *Io.Reader) !void { const attach_mode_str = node.arg(&parser, 0) orelse ""; if (std.meta.stringToEnum(AttachMode, attach_mode_str)) |mode| { config.attach_mode = mode; - log.debug("Setting attach_mode to {s}", .{@tagName(mode)}); + logDebugSettingNode(name, attach_mode_str); } else { - log.warn("Invalid \"attach_mode\". Using default", .{}); + logWarnInvalidNodeArg(name); continue; } }, .focus_follows_pointer => { const focus_follows_pointer_str = node.arg(&parser, 0) orelse ""; - if (config.boolFromKdlStr(focus_follows_pointer_str)) |focus_follows_pointer| { + if (boolFromKdlStr(focus_follows_pointer_str)) |focus_follows_pointer| { config.focus_follows_pointer = focus_follows_pointer; - log.debug("Setting focus_follows_pointer to {}", .{focus_follows_pointer}); + logDebugSettingNode(name, focus_follows_pointer_str); } else { - log.warn("Invalid \"focus_follows_pointer\". Using default", .{}); + logWarnInvalidNodeArg(name); continue; } }, .pointer_warp_on_focus_change => { const pointer_warp_on_focus_change_str = node.arg(&parser, 0) orelse ""; - if (config.boolFromKdlStr(pointer_warp_on_focus_change_str)) |pointer_warp_on_focus_change| { + if (boolFromKdlStr(pointer_warp_on_focus_change_str)) |pointer_warp_on_focus_change| { config.pointer_warp_on_focus_change = pointer_warp_on_focus_change; - log.debug("Setting pointer_warp_on_focus_change to {}", .{pointer_warp_on_focus_change}); + logDebugSettingNode(name, pointer_warp_on_focus_change_str); } else { - log.warn("Invalid \"pointer_warp_on_focus_change\". Using default", .{}); + logWarnInvalidNodeArg(name); continue; } }, @@ -155,26 +156,26 @@ fn loadBordersChildBlock(config: *Config, parser: *kdl.Parser) !void { .width => { const width_str = node.arg(parser, 0) orelse ""; config.border_width = fmt.parseInt(u8, width_str, 10) catch { - log.warn("Invalid border.width \"{s}\"", .{width_str}); + logWarnInvalidNodeArg(name); continue; }; - log.debug("Setting border.width to {d}", .{config.border_width}); + logDebugSettingNode(name, width_str); }, .color_focused => { const color_str = node.arg(parser, 0) orelse ""; config.border_color_focused = utils.parseRgba(color_str) catch { - log.warn("Invalid border.color_focused \"{s}\"", .{color_str}); + logWarnInvalidNodeArg(name); continue; }; - log.debug("Setting border.color_focused to {s}", .{color_str}); + logDebugSettingNode(name, color_str); }, .color_unfocused => { const color_str = node.arg(parser, 0) orelse ""; config.border_color_unfocused = utils.parseRgba(color_str) catch { - log.warn("Invalid border.color_unfocused \"{s}\"", .{color_str}); + logWarnInvalidNodeArg(name); continue; }; - log.debug("Setting border.color_unfocused to {s}", .{color_str}); + logDebugSettingNode(name, color_str); }, } } else { @@ -221,7 +222,7 @@ fn skipChildBlock(_: *Config, parser: *kdl.Parser) !void { /// if arg_str in ["#true", "true"], return true /// if arg_str in ["#false", "false"], return false /// else, return null -fn boolFromKdlStr(_: Config, arg_str: []const u8) ?bool { +fn boolFromKdlStr(arg_str: []const u8) ?bool { if (mem.eql(u8, arg_str, "#true") or mem.eql(u8, arg_str, "true")) { @@ -234,6 +235,24 @@ fn boolFromKdlStr(_: Config, arg_str: []const u8) ?bool { return null; } +fn logWarnInvalidNodeArg(node_name: anytype) void { + const node_name_type = @TypeOf(node_name); + switch (node_name_type) { + NodeName => log.warn("Invalid \"{s}\". Using default value", .{@tagName(node_name)}), + BorderNodeName => log.warn("Invalid \"border.{s}\". Using default value", .{@tagName(node_name)}), + else => @compileError("This function does not (yet) support type \"" ++ @typeName(@TypeOf(node_name)) ++ "\""), + } +} + +fn logDebugSettingNode(node_name: anytype, node_value: []const u8) void { + const node_name_type = @TypeOf(node_name); + switch (node_name_type) { + NodeName => log.debug("Setting {s} to {s}", .{ @tagName(node_name), node_value }), + BorderNodeName => log.debug("Setting border.{s} to {s}", .{ @tagName(node_name), node_value }), + else => @compileError("This function does not (yet) support type \"" ++ @typeName(@TypeOf(node_name)) ++ "\""), + } +} + const std = @import("std"); const fmt = std.fmt; const fs = std.fs; From fd8b6d0d41acf6a927c85e66da7df46020ee1d96 Mon Sep 17 00:00:00 2001 From: Ben Buhse Date: Fri, 30 Jan 2026 19:43:49 -0600 Subject: [PATCH 5/5] Implement configuration for keybindings Keybinds go in a "keybinds" block and follow the format But there's also a special "tag_bind" command that just takes modifiers and one of set_output_tags, set_window_tags, toggle_output_tags, and toggle_window_tags. It will automatically be used to loop through the 1-9 keys on tags 1<<0 to 1<<9, however, you can still implement those commands individually if you want. --- build.zig | 2 + build.zig.zon | 4 +- examples/config.kdl | 24 +++-- src/Config.zig | 234 ++++++++++++++++++++++++++++++++++++++---- src/Window.zig | 2 +- src/WindowManager.zig | 40 +++++--- src/XkbBindings.zig | 40 +++++--- src/main.zig | 22 ++-- src/utils.zig | 66 +++++++++++- 9 files changed, 350 insertions(+), 84 deletions(-) diff --git a/build.zig b/build.zig index 7741687..f51a5d7 100644 --- a/build.zig +++ b/build.zig @@ -48,6 +48,7 @@ pub fn build(b: *std.Build) void { exe.linkLibC(); exe.linkSystemLibrary("wayland-client"); + exe.linkSystemLibrary("xkbcommon"); b.installArtifact(exe); @@ -95,6 +96,7 @@ pub fn build(b: *std.Build) void { exe_check.linkLibC(); exe_check.linkSystemLibrary("wayland-client"); + exe_check.linkSystemLibrary("xkbcommon"); const check = b.step("check", "Check if beanbag compiles"); check.dependOn(&exe_check.step); diff --git a/build.zig.zon b/build.zig.zon index cb0acd3..24164a5 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -13,8 +13,8 @@ .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", + .url = "https://codeberg.org/ifreund/zig-xkbcommon/archive/6786ca619bb442c3f523b5bb894e6a1e48d7e897.tar.gz", + .hash = "xkbcommon-0.4.0-dev-VDqIe0y2AgCNeWLthDZ3MUcUYzhyKXjK85ISm_zxk9Nk", }, .kdl = .{ .url = "https://codeberg.org/desttinghim/zig-kdl/archive/edc943426ba1fc47606568a9fc7f402b2b1992e0.tar.gz", diff --git a/examples/config.kdl b/examples/config.kdl index d565026..e1c345a 100644 --- a/examples/config.kdl +++ b/examples/config.kdl @@ -1,10 +1,20 @@ attach_mode top - -focus_follows_pointer true -pointer_warp_on_focus_change true - +focus_follows_pointer #true +pointer_warp_on_focus_change #true borders { - width 2 - color_focused 0x89b4fa - color_unfocused 0x1e1e2e + width 2 + color_focused "0x89b4fa" + color_unfocused "0x1e1e2e" +} +keybinds { + spawn mod4 t foot + focus_next mod4 j + focus_prev mod4 k + toggle_fullscreen mod4 f + close_window mod4+Shift q + // Generates keybinds for keys 1-9 → tags 1<<0 through 1<<9 + tag_bind mod4 set_output_tags + tag_bind mod4+shift set_window_tags + tag_bind mod4+ctrl toggle_output_tags + tag_bind mod4+ctrl+shift toggle_window_tags } diff --git a/src/Config.zig b/src/Config.zig index be8cd1a..f45abea 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -10,7 +10,7 @@ const CONFIG_FILE = "beansprout/config.kdl"; border_width: u8 = 2, /// Color of focused window's border in 0xRRGGBBAA or 0xRRGGBB form border_color_focused: RiverColor = utils.parseRgbaComptime("0x89b4fa"), -/// Color of uffocused windows' borders in 0xRRGGBBAA or 0xRRGGBB form +/// Color of unfocused windows' borders in 0xRRGGBBAA or 0xRRGGBB form border_color_unfocused: RiverColor = utils.parseRgbaComptime("0x1e1e2e"), /// Where a new window should attach, top or bottom of the stack @@ -20,6 +20,16 @@ focus_follows_pointer: bool = true, /// Should the pointer warp to the center of newly-focused windows pointer_warp_on_focus_change: bool = true, +/// Tag bind entries parsed from config (tag_bind nodes in keybinds block) +tag_binds: std.ArrayListUnmanaged(Keybind) = .{}, +keybinds: std.ArrayListUnmanaged(Keybind) = .{}, + +pub const Keybind = struct { + modifiers: river.SeatV1.Modifiers, + command: XkbBindings.Command, + keysym: ?xkbcommon.Keysym, +}; + pub const AttachMode = enum { top, bottom, @@ -30,6 +40,7 @@ const NodeName = enum { focus_follows_pointer, pointer_warp_on_focus_change, borders, + keybinds, }; const BorderNodeName = enum { @@ -38,6 +49,9 @@ const BorderNodeName = enum { color_unfocused, }; +// We can just directly use the tag type from Command as our node name +const KeybindNodeName = @typeInfo(XkbBindings.Command).@"union".tag_type.?; + pub fn create() !*Config { var config: *Config = try utils.allocator.create(Config); errdefer config.destroy(); @@ -56,7 +70,18 @@ pub fn create() !*Config { config.load(&file_reader.interface) catch |err| { log.err("Error while loading config: {s}. Continuing with default config", .{@errorName(err)}); - // Overwrite any partially-loaded config + // Free any partially-loaded state and reset to defaults + for (config.keybinds.items) |keybind| { + switch (keybind.command) { + .spawn => |argv| { + for (argv) |arg| utils.allocator.free(arg); + utils.allocator.free(argv); + }, + else => {}, + } + } + config.keybinds.clearAndFree(utils.allocator); + config.tag_binds.clearAndFree(utils.allocator); config.* = .{}; }; } @@ -65,9 +90,21 @@ pub fn create() !*Config { } pub fn destroy(config: *Config) void { + for (config.keybinds.items) |keybind| { + switch (keybind.command) { + .spawn => |argv| { + for (argv) |arg| utils.allocator.free(arg); + utils.allocator.free(argv); + }, + else => {}, + } + } + config.keybinds.deinit(utils.allocator); + config.tag_binds.deinit(utils.allocator); utils.allocator.destroy(config); } +// TODO: Support kdl properties to specific the hostname the config should affect fn load(config: *Config, reader: *Io.Reader) !void { var parser = try kdl.Parser.init(utils.allocator, reader, .{}); defer parser.deinit(utils.allocator); @@ -80,7 +117,7 @@ fn load(config: *Config, reader: *Io.Reader) !void { switch (event) { .node => |node| { if (next_child_block) |child_block| { - log.warn("Expected child block for {s}, but got another node instead. Continuing but ignoring {s}", .{ @tagName(child_block), @tagName(child_block) }); + logWarnMissingChildBlock(child_block); next_child_block = null; } // If it's a node, we check if it's a valid NodeName @@ -89,47 +126,51 @@ fn load(config: *Config, reader: *Io.Reader) !void { // Next, we have to check the specifics for the NodeName switch (name) { .attach_mode => { - const attach_mode_str = node.arg(&parser, 0) orelse ""; + const attach_mode_str = utils.stripQuotes(node.arg(&parser, 0) orelse ""); if (std.meta.stringToEnum(AttachMode, attach_mode_str)) |mode| { config.attach_mode = mode; logDebugSettingNode(name, attach_mode_str); } else { - logWarnInvalidNodeArg(name); + logWarnInvalidNodeArg(name, attach_mode_str); continue; } }, .focus_follows_pointer => { - const focus_follows_pointer_str = node.arg(&parser, 0) orelse ""; + const focus_follows_pointer_str = utils.stripQuotes(node.arg(&parser, 0) orelse ""); if (boolFromKdlStr(focus_follows_pointer_str)) |focus_follows_pointer| { config.focus_follows_pointer = focus_follows_pointer; logDebugSettingNode(name, focus_follows_pointer_str); } else { - logWarnInvalidNodeArg(name); + logWarnInvalidNodeArg(name, focus_follows_pointer_str); continue; } }, .pointer_warp_on_focus_change => { - const pointer_warp_on_focus_change_str = node.arg(&parser, 0) orelse ""; + const pointer_warp_on_focus_change_str = utils.stripQuotes(node.arg(&parser, 0) orelse ""); if (boolFromKdlStr(pointer_warp_on_focus_change_str)) |pointer_warp_on_focus_change| { config.pointer_warp_on_focus_change = pointer_warp_on_focus_change; logDebugSettingNode(name, pointer_warp_on_focus_change_str); } else { - logWarnInvalidNodeArg(name); + logWarnInvalidNodeArg(name, pointer_warp_on_focus_change_str); continue; } }, .borders => { next_child_block = .borders; }, + .keybinds => { + next_child_block = .keybinds; + }, } } else { - log.warn("Invalid KDL node {s}. Ignoring it and carrying on", .{node.name}); + logWarnInvalidNode(node.name); } }, .child_block_begin => { if (next_child_block) |child_block| { switch (child_block) { .borders => try config.loadBordersChildBlock(&parser), + .keybinds => try config.loadKeybindsChildBlock(&parser), else => { // Nothing else should ever be marked as a next_child_block unreachable; @@ -154,32 +195,32 @@ fn loadBordersChildBlock(config: *Config, parser: *kdl.Parser) !void { if (node_name) |name| { switch (name) { .width => { - const width_str = node.arg(parser, 0) orelse ""; + const width_str = utils.stripQuotes(node.arg(parser, 0) orelse ""); config.border_width = fmt.parseInt(u8, width_str, 10) catch { - logWarnInvalidNodeArg(name); + logWarnInvalidNodeArg(name, width_str); continue; }; logDebugSettingNode(name, width_str); }, .color_focused => { - const color_str = node.arg(parser, 0) orelse ""; + const color_str = utils.stripQuotes(node.arg(parser, 0) orelse ""); config.border_color_focused = utils.parseRgba(color_str) catch { - logWarnInvalidNodeArg(name); + logWarnInvalidNodeArg(name, color_str); continue; }; logDebugSettingNode(name, color_str); }, .color_unfocused => { - const color_str = node.arg(parser, 0) orelse ""; + const color_str = utils.stripQuotes(node.arg(parser, 0) orelse ""); config.border_color_unfocused = utils.parseRgba(color_str) catch { - logWarnInvalidNodeArg(name); + logWarnInvalidNodeArg(name, color_str); continue; }; logDebugSettingNode(name, color_str); }, } } else { - log.warn("Invalid KDL node {s}. Ignoring it and carrying on", .{node.name}); + logWarnInvalidNode(node.name); } }, .child_block_begin => { @@ -194,6 +235,130 @@ fn loadBordersChildBlock(config: *Config, parser: *kdl.Parser) !void { } } +fn loadKeybindsChildBlock(config: *Config, parser: *kdl.Parser) !void { + while (try parser.next()) |event| { + switch (event) { + .node => |node| { + // tag_bind is a special case node name + if (mem.eql(u8, node.name, "tag_bind")) { + const mod_str = utils.stripQuotes(node.arg(parser, 0) orelse { + log.warn("tag_bind: missing modifier argument. Ignoring", .{}); + continue; + }); + const cmd_str = utils.stripQuotes(node.arg(parser, 1) orelse { + log.warn("tag_bind: missing command argument. Ignoring", .{}); + continue; + }); + const modifiers = try utils.parseModifiers(mod_str) orelse { + log.warn("tag_bind: invalid modifiers \"{s}\". Ignoring", .{mod_str}); + continue; + }; + + const command_tag_type = std.meta.stringToEnum(KeybindNodeName, cmd_str) orelse { + log.warn("tag_bind: invalid command \"{s}\". Ignoring", .{cmd_str}); + continue; + }; + const command: XkbBindings.Command = switch (command_tag_type) { + // We can set these to "0" since they're not used (when in the tag_bind node) + .set_output_tags => .{ .set_output_tags = 0 }, + .set_window_tags => .{ .set_window_tags = 0 }, + .toggle_output_tags => .{ .toggle_output_tags = 0 }, + .toggle_window_tags => .{ .toggle_window_tags = 0 }, + else => { + log.warn("tag_bind: invalid command \"{s}\". Only tag-keybinds can be used with tag_binds. Ignoring", .{cmd_str}); + continue; + }, + }; + + try config.tag_binds.append(utils.allocator, .{ + .modifiers = modifiers, + .command = command, + .keysym = null, // Tag binds don't need a keysym (automatically 1-9) + }); + + // TODO: Make a logger for keybind settings + log.debug("tag_bind: {s} {s}", .{ mod_str, cmd_str }); + continue; + } + // Handle the rest of the possiblities like all the other types of block + const node_name = std.meta.stringToEnum(KeybindNodeName, node.name); + if (node_name) |name| { + // All nodes should have at least a command, modifiers, and a keysym + // Some may have more arguments handled in the switch + const mod_str = utils.stripQuotes(node.arg(parser, 0) orelse { + logWarnMissingNodeArg(name, "modifier(s)"); + continue; + }); + const modifiers = try utils.parseModifiers(mod_str) orelse { + log.warn("keybinds: invalid modifiers \"{s}\". Ignoring", .{mod_str}); + continue; + }; + + const key_str = utils.stripQuotes(node.arg(parser, 1) orelse { + logWarnMissingNodeArg(name, "keysym"); + continue; + }); + // Keysym.fromName() needs a [*:0]const u8 + const z = try utils.allocator.dupeZ(u8, key_str); + defer utils.allocator.free(z); + const keysym = xkbcommon.Keysym.fromName(z, .case_insensitive); + + const command: XkbBindings.Command = sw: switch (name) { + .spawn => { + // TODO: Add propert(ies) to support ENV vars + const exec_str = utils.stripQuotes(node.arg(parser, 2) orelse { + logWarnMissingNodeArg(name, "command"); + continue; + }); + const split_exec = try utils.tokenizeToOwnedSlices(exec_str, ' '); + break :sw .{ .spawn = split_exec }; + }, + .focus_next => { + break :sw .focus_next; + }, + .focus_prev => { + break :sw .focus_prev; + }, + .toggle_fullscreen => { + break :sw .toggle_fullscreen; + }, + .close_window => { + break :sw .close_window; + }, + inline .set_output_tags, .set_window_tags, .toggle_output_tags, .toggle_window_tags => |cmd| { + const tags_str = utils.stripQuotes(node.arg(parser, 2) orelse { + logWarnMissingNodeArg(name, "tags"); + continue; + }); + const tags = fmt.parseInt(u32, tags_str, 0) catch { + logWarnInvalidNodeArg(name, tags_str); + continue; + }; + break :sw @unionInit(XkbBindings.Command, @tagName(cmd), tags); + }, + }; + + try config.keybinds.append(utils.allocator, .{ + .modifiers = modifiers, + .command = command, + .keysym = keysym, + }); + } else { + logWarnInvalidNode(node.name); + } + }, + .child_block_begin => { + // keybinds should never have a nested child block + try config.skipChildBlock(parser); + }, + .child_block_end => { + // Done parsing the keybinds block; return + return; + }, + } + } +} + /// Skips an entire child block including any nested child blocks fn skipChildBlock(_: *Config, parser: *kdl.Parser) !void { log.warn("Unexpected child block. Skipping it", .{}); @@ -235,12 +400,33 @@ fn boolFromKdlStr(arg_str: []const u8) ?bool { return null; } -fn logWarnInvalidNodeArg(node_name: anytype) void { +fn logWarnInvalidNodeArg(node_name: anytype, node_value: []const u8) void { const node_name_type = @TypeOf(node_name); switch (node_name_type) { - NodeName => log.warn("Invalid \"{s}\". Using default value", .{@tagName(node_name)}), - BorderNodeName => log.warn("Invalid \"border.{s}\". Using default value", .{@tagName(node_name)}), - else => @compileError("This function does not (yet) support type \"" ++ @typeName(@TypeOf(node_name)) ++ "\""), + NodeName => log.warn("Invalid \"{s}\" ({s}). Using default value", .{ @tagName(node_name), node_value }), + BorderNodeName => log.warn("Invalid \"border.{s}\" ({s}). Using default value", .{ @tagName(node_name), node_value }), + KeybindNodeName => log.warn("Invalid \"keybind.{s}\" ({s}). Ignoring", .{ @tagName(node_name), node_value }), + else => @compileError("This function does not (yet) support type \"" ++ @typeName(node_name_type) ++ "\""), + } +} + +fn logWarnMissingNodeArg(node_name: anytype, comptime arg: []const u8) void { + const node_name_type = @TypeOf(node_name); + switch (node_name_type) { + KeybindNodeName => log.warn("\"keybind.{s}\" missing " ++ arg ++ " argument. Ignoring", .{@tagName(node_name)}), + else => @compileError("This function does not (yet) support type \"" ++ @typeName(node_name_type) ++ "\""), + } +} + +fn logWarnInvalidNode(node_name: []const u8) void { + log.warn("Invalid KDL node {s}. Ignoring it and carrying on", .{node_name}); +} + +fn logWarnMissingChildBlock(child_block: anytype) void { + const child_block_type = @TypeOf(child_block); + switch (child_block_type) { + NodeName => log.warn("Expected child block for {s}, but got another node instead. Continuing but ignoring {s}", .{ @tagName(child_block), @tagName(child_block) }), + else => @compileError("This function does not (yet) support type \"" ++ @typeName(child_block_type) ++ "\""), } } @@ -259,11 +445,15 @@ const fs = std.fs; const mem = std.mem; const Io = std.Io; +const wayland = @import("wayland"); +const river = wayland.client.river; + const kdl = @import("kdl"); const known_folders = @import("known_folders"); -const KdlNode = kdl.Parser.Node; +const xkbcommon = @import("xkbcommon"); const utils = @import("utils.zig"); const RiverColor = utils.RiverColor; +const XkbBindings = @import("XkbBindings.zig"); const log = std.log.scoped(.Config); diff --git a/src/Window.zig b/src/Window.zig index 6a20b5e..32c7204 100644 --- a/src/Window.zig +++ b/src/Window.zig @@ -33,7 +33,7 @@ pending_manage: PendingManage = .{}, pending_render: PendingRender = .{}, /// Used to put Windows into a list in -/// WindowManager.calculatePriamryStackLayout() +/// WindowManager.calculatePrimaryStackLayout() active_list_node: DoublyLinkedList.Node = .{}, link: wl.list.Link, diff --git a/src/WindowManager.zig b/src/WindowManager.zig index e1b24d5..2dccd99 100644 --- a/src/WindowManager.zig +++ b/src/WindowManager.zig @@ -185,23 +185,29 @@ fn manage_start(wm: *WindowManager) void { const seat = wm.seats.first() orelse @panic("Failed to get seat"); const river_seat_v1 = seat.river_seat_v1; - context.xkb_bindings.addBinding(river_seat_v1, xkbcommon.Keysym.t, .{ .mod4 = true }, .{ .spawn = &.{"foot"} }); - context.xkb_bindings.addBinding(river_seat_v1, xkbcommon.Keysym.j, .{ .mod4 = true }, .focus_next); - context.xkb_bindings.addBinding(river_seat_v1, xkbcommon.Keysym.k, .{ .mod4 = true }, .focus_prev); - context.xkb_bindings.addBinding(river_seat_v1, xkbcommon.Keysym.f, .{ .mod4 = true }, .toggle_fullscreen); - context.xkb_bindings.addBinding(river_seat_v1, xkbcommon.Keysym.q, .{ .mod4 = true, .shift = true }, .close_window); - context.xkb_bindings.addBinding(river_seat_v1, xkbcommon.Keysym.e, .{ .mod4 = true, .shift = true }, .exit); // Tag bindings - comptime var i: u8 = 1; - comptime var buffer: [1]u8 = undefined; - inline while (i <= 9) : (i += 1) { - const tags: u32 = 1 << (i - 1); - buffer[0] = i + '0'; - context.xkb_bindings.addBinding(river_seat_v1, @field(xkbcommon.Keysym, &buffer), .{ .mod4 = true }, .{ .set_output_tags = tags }); - context.xkb_bindings.addBinding(river_seat_v1, @field(xkbcommon.Keysym, &buffer), .{ .mod4 = true, .shift = true }, .{ .set_window_tags = tags }); - context.xkb_bindings.addBinding(river_seat_v1, @field(xkbcommon.Keysym, &buffer), .{ .mod4 = true, .ctrl = true }, .{ .toggle_output_tags = tags }); - context.xkb_bindings.addBinding(river_seat_v1, @field(xkbcommon.Keysym, &buffer), .{ .mod4 = true, .shift = true, .ctrl = true }, .{ .toggle_window_tags = tags }); + for (context.config.tag_binds.items) |tag_bind| { + comptime var i: u8 = 1; + comptime var buffer: [1]u8 = undefined; + inline while (i <= 9) : (i += 1) { + const tags: u32 = 1 << (i - 1); + buffer[0] = i + '0'; + const command: XkbBindings.Command = switch (tag_bind.command) { + .set_output_tags => .{ .set_output_tags = tags }, + .set_window_tags => .{ .set_window_tags = tags }, + .toggle_output_tags => .{ .toggle_output_tags = tags }, + .toggle_window_tags => .{ .toggle_window_tags = tags }, + else => unreachable, + }; + context.xkb_bindings.addBinding(river_seat_v1, @field(xkbcommon.Keysym, &buffer), tag_bind.modifiers, command); + } + } + // Rest of the keybinds + for (context.config.keybinds.items) |keybind| { + // Keysyms should only be null in tag_binds (above) + std.debug.assert(keybind.keysym != null); + context.xkb_bindings.addBinding(river_seat_v1, keybind.keysym.?, keybind.modifiers, keybind.command); } } @@ -256,8 +262,7 @@ fn windowManagerV1Listener(window_manager_v1: *river.WindowManagerV1, event: riv const context = wm.context; switch (event) { .unavailable => { - log.err("Window manager unavailable (some other wm instance is running). Exiting", .{}); - std.posix.exit(1); + fatal("Window manager unavailable (some other wm instance is running). Exiting", .{}); }, .manage_start => wm.manage_start(), .render_start => wm.render_start(), @@ -303,6 +308,7 @@ fn windowManagerV1Listener(window_manager_v1: *river.WindowManagerV1, event: riv const std = @import("std"); const assert = std.debug.assert; +const fatal = std.process.fatal; const DoublyLinkedList = std.DoublyLinkedList; const wayland = @import("wayland"); diff --git a/src/XkbBindings.zig b/src/XkbBindings.zig index 9adaa0b..7ce1c2f 100644 --- a/src/XkbBindings.zig +++ b/src/XkbBindings.zig @@ -8,17 +8,21 @@ pub const Command = union(enum) { spawn: []const []const u8, focus_next, focus_prev, + // zoom, // TODO + // ratio_up, // TODO + // ratio_down, // TODO + // reload_config, // TODO toggle_fullscreen, close_window, - exit, + // exit, // TODO: Delete? // Tag management set_output_tags: u32, set_window_tags: u32, toggle_output_tags: u32, toggle_window_tags: u32, - spawn_tagmask: u32, - focus_previous_tags, - send_to_previous_tags, + // spawn_tagmask: u32, // TODO + // focus_previous_tags, // TODO + // send_to_previous_tags, // TODO }; const XkbBinding = struct { @@ -114,10 +118,6 @@ const XkbBinding = struct { window.river_window_v1.close(); } }, - .exit => { - // TODO: Disabled while I'm working with river within river :P - // _ = std.process.Child.init(&.{"killall river"}, std.heap.c_allocator); - }, .set_output_tags => |tags| { // TODO: Support multiple outputs const output = context.wm.outputs.first() orelse return; @@ -135,8 +135,11 @@ const XkbBinding = struct { .toggle_output_tags => |tags| { const output = context.wm.outputs.first() orelse return; const old_tags = output.pending_manage.tags orelse output.tags; - output.pending_manage.tags = old_tags ^ tags; - context.wm.river_window_manager_v1.manageDirty(); + const new_tags = old_tags ^ tags; + if (new_tags != 0) { + output.pending_manage.tags = new_tags; + context.wm.river_window_manager_v1.manageDirty(); + } }, .toggle_window_tags => |tags| { const seat = context.wm.seats.first() orelse return; @@ -144,12 +147,15 @@ const XkbBinding = struct { // const window = seat.pending_manage.pending_focus orelse seat.focused; const window = seat.focused orelse return; const old_tags = window.pending_manage.tags orelse window.tags; - window.pending_manage.tags = old_tags ^ tags; - context.wm.river_window_manager_v1.manageDirty(); - }, - .spawn_tagmask, .focus_previous_tags, .send_to_previous_tags => { - @panic("Unimplemented"); + const new_tags = old_tags ^ tags; + if (new_tags != 0) { + window.pending_manage.tags = new_tags; + context.wm.river_window_manager_v1.manageDirty(); + } }, + // .spawn_tagmask, .focus_previous_tags, .send_to_previous_tags => { + // @panic("Unimplemented"); + // }, } } }; @@ -185,8 +191,8 @@ pub fn destroy(xkb_bindings: *XkbBindings) void { utils.allocator.destroy(xkb_bindings); } -pub fn addBinding(xkb_bindings: *XkbBindings, river_seat_v1: *river.SeatV1, keysym: u32, modifiers: river.SeatV1.Modifiers, command: Command) void { - const xkb_binding_v1 = xkb_bindings.xkb_bindings_v1.getXkbBinding(river_seat_v1, keysym, modifiers) catch |err| { +pub fn addBinding(xkb_bindings: *XkbBindings, river_seat_v1: *river.SeatV1, keysym: xkbcommon.Keysym, modifiers: river.SeatV1.Modifiers, command: Command) void { + const xkb_binding_v1 = xkb_bindings.xkb_bindings_v1.getXkbBinding(river_seat_v1, @intFromEnum(keysym), modifiers) catch |err| { log.err("Failed to get river xkb binding: {}", .{err}); return; }; diff --git a/src/main.zig b/src/main.zig index 88bfbef..22f63cd 100644 --- a/src/main.zig +++ b/src/main.zig @@ -11,14 +11,12 @@ const Globals = struct { pub fn main() !void { const wayland_display_var = try utils.allocator.dupeZ(u8, process.getEnvVarOwned(utils.allocator, "WAYLAND_DISPLAY") catch { - log.err("Error getting WAYLAND_DISPLAY environment variable. Exiting", .{}); - std.posix.exit(1); + fatal("Error getting WAYLAND_DISPLAY environment variable. Exiting", .{}); }); defer utils.allocator.free(wayland_display_var); const wl_display = wl.Display.connect(null) catch { - log.err("Error connecting to Wayland server. Exiting", .{}); - std.posix.exit(1); + fatal("Error connecting to Wayland server. Exiting", .{}); }; defer wl_display.disconnect(); @@ -29,8 +27,7 @@ pub fn main() !void { const errno = wl_display.roundtrip(); if (errno != .SUCCESS) { - log.err("Initial roundtrip failed: E{s}", .{@tagName(errno)}); - std.posix.exit(1); + fatal("Initial roundtrip failed: E{s}", .{@tagName(errno)}); } const wl_compositor = globals.wl_compositor orelse utils.interfaceNotAdvertised(wl.Compositor); @@ -51,8 +48,7 @@ pub fn main() !void { while (true) { if (wl_display.dispatch() != .SUCCESS) { - log.err("wayland display dispatch failed", .{}); - std.posix.exit(1); + fatal("wayland display dispatch failed", .{}); } } @@ -65,18 +61,15 @@ fn registryListener(registry: *wl.Registry, event: wl.Registry.Event, globals: * if (mem.orderZ(u8, ev.interface, wl.Compositor.interface.name) == .eq) { if (ev.version < 4) utils.versionNotSupported(wl.Compositor, ev.version, 4); globals.wl_compositor = registry.bind(ev.name, wl.Compositor, 4) catch |e| { - log.err("Failed to bind to compositor: {any}", .{@errorName(e)}); - std.posix.exit(1); + fatal("Failed to bind to compositor: {any}", .{@errorName(e)}); }; } else if (mem.orderZ(u8, ev.interface, river.WindowManagerV1.interface.name) == .eq) { globals.river_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); + fatal("Failed to bind to window_manager_v1: {any}", .{@errorName(e)}); }; } else if (mem.orderZ(u8, ev.interface, river.XkbBindingsV1.interface.name) == .eq) { globals.river_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); + fatal("Failed to bind to xkb_bindings_v1: {any}", .{@errorName(e)}); }; } }, @@ -87,6 +80,7 @@ fn registryListener(registry: *wl.Registry, event: wl.Registry.Event, globals: * const std = @import("std"); const mem = std.mem; +const fatal = std.process.fatal; const process = std.process; const wayland = @import("wayland"); diff --git a/src/utils.zig b/src/utils.zig index 049d81d..5b0193a 100644 --- a/src/utils.zig +++ b/src/utils.zig @@ -67,19 +67,77 @@ fn parseRgbaHelper(bytes: [4]u8) RiverColor { }; } +/// Parse a modifier string like "mod4+shift+ctrl" into river.SeatV1.Modifiers. +/// Modifier names are case-insensitive. Returns null if any modifier is unrecognized. +pub fn parseModifiers(s: []const u8) !?river.SeatV1.Modifiers { + var modifiers: river.SeatV1.Modifiers = .{}; + + var it = mem.splitScalar(u8, s, '+'); + while (it.next()) |part| { + // Modifier names are 3 (alt) to 5 (shift/super) characters long, + // other length can't be correctly formatted. + if (part.len < 3 or part.len > 5) return null; + + // Case-insensitive comparison by lowercasing + const lower = try std.ascii.allocLowerString(utils.allocator, part); + defer utils.allocator.free(lower); + + if (mem.eql(u8, lower, "mod4") or mem.eql(u8, lower, "super")) { + modifiers.mod4 = true; + } else if (mem.eql(u8, lower, "shift")) { + modifiers.shift = true; + } else if (mem.eql(u8, lower, "ctrl")) { + modifiers.ctrl = true; + } else if (mem.eql(u8, lower, "mod1") or mem.eql(u8, lower, "alt")) { + modifiers.mod1 = true; + } else if (mem.eql(u8, lower, "mod3")) { + modifiers.mod3 = true; + } else if (mem.eql(u8, lower, "mod5")) { + modifiers.mod5 = true; + } else { + return null; + } + } + + return modifiers; +} + +pub fn tokenizeToOwnedSlices(input: []const u8, delimiter: u8) ![]const []const u8 { + var list: std.ArrayList([]const u8) = .empty; + var it = std.mem.tokenizeScalar(u8, input, delimiter); + while (it.next()) |part| { + const duped = try allocator.dupe(u8, part); + try list.append(utils.allocator, duped); + log.debug("{s}", .{duped}); + } + return list.toOwnedSlice(utils.allocator); +} + +pub fn stripQuotes(s: []const u8) []const u8 { + if (s.len >= 2 and s[0] == '"' and s[s.len - 1] == '"') { + return s[1 .. s.len - 1]; + } + return s; +} + /// Report that the given WaylandGlobal wasn't advertised and exit the program pub fn interfaceNotAdvertised(comptime WaylandGlobal: type) noreturn { - log.err("{s} not advertised. Exiting", .{WaylandGlobal.interface.name}); - std.posix.exit(1); + fatal("{s} not advertised. Exiting", .{WaylandGlobal.interface.name}); } /// Report that the given WaylandGlobal was advertised but the support version was too low and exit the program pub fn versionNotSupported(comptime WaylandGlobal: type, have_version: u32, need_version: u32) noreturn { - log.err("The compositor only advertised {s} version {d} but version {d} is required. Exiting", .{ WaylandGlobal.interface.name, have_version, need_version }); - std.posix.exit(1); + fatal("The compositor only advertised {s} version {d} but version {d} is required. Exiting", .{ WaylandGlobal.interface.name, have_version, need_version }); } const std = @import("std"); +const fatal = std.process.fatal; const fmt = std.fmt; +const mem = std.mem; + +const wayland = @import("wayland"); +const river = wayland.client.river; + +const utils = @import("utils.zig"); const log = std.log.scoped(.utils);