beansprout-custom/src/XkbBindings.zig
Ben Buhse f16f07fa26
Fix zombie processes from keybind spawns
Previously, beansprout used std.process.Child.spawn without ever calling
wait(), leaving exited children as zombies. This commit switches to a
double-fork based off river-classic's spawn handling, where the actual
spawned process gets orphaned and reparented to PID 1. This way, the
parent (i.e. beansprout) only has to wait for the intermediate child.

Also switch tokenizeShell and expandTilde to produce [:0]const u8 tokens
so the argv array for execvpeZ can be built without copying each string.

Fixes: #12
2026-04-26 11:22:35 -05:00

582 lines
23 KiB
Zig

// SPDX-FileCopyrightText: 2026 Ben Buhse <me@benbuhse.email>
//
// SPDX-License-Identifier: GPL-3.0-only
const XkbBindings = @This();
pub const Command = union(enum) {
spawn: []const [:0]const u8,
focus_next_window,
focus_prev_window,
focus_next_output,
focus_prev_output,
send_to_next_output,
send_to_prev_output,
zoom,
toggle_float,
// Changes the ratio on the focused output only
change_primary_ratio: f32,
// Changes the ratio on the focused output only
change_single_window_ratio: f32,
// Changes the primary count on the focus output only
increment_primary_count,
decrement_primary_count,
reload_config,
toggle_fullscreen,
close_window,
exit_river,
// Tag management
set_output_tags: u32,
set_window_tags: u32,
toggle_output_tags: u32,
toggle_window_tags: u32,
// Move floating window by pixels
move_up: i32,
move_down: i32,
move_left: i32,
move_right: i32,
// Resize floating window by pixels
resize_width: i32,
resize_height: i32,
// Center floating window on its output
center_float,
// Swap window position in stack
swap_next,
swap_prev,
// When passthrough is enabled, only keybinds set to toggle_passthrough are active
toggle_passthrough,
/// Explicitly list each variant so that, if we add a new one,
/// we'll get a reminder to free it here (instead of it being
/// swallowed by an `else =>`)
pub fn deinit(self: Command) void {
switch (self) {
.spawn => |argv| {
for (argv) |arg| utils.gpa.free(arg);
utils.gpa.free(argv);
},
.focus_next_window,
.focus_prev_window,
.focus_next_output,
.focus_prev_output,
.send_to_next_output,
.send_to_prev_output,
.zoom,
.toggle_float,
.change_primary_ratio,
.change_single_window_ratio,
.increment_primary_count,
.decrement_primary_count,
.reload_config,
.toggle_fullscreen,
.close_window,
.exit_river,
.set_output_tags,
.set_window_tags,
.toggle_output_tags,
.toggle_window_tags,
.move_up,
.move_down,
.move_left,
.move_right,
.resize_width,
.resize_height,
.center_float,
.swap_next,
.swap_prev,
.toggle_passthrough,
=> {},
}
}
};
const XkbBinding = struct {
xkb_binding_v1: *river.XkbBindingV1,
command: Command,
context: *Context,
link: wl.list.Link,
const FocusDirection = enum { next, prev };
fn create(xkb_binding_v1: *river.XkbBindingV1, command: Command, context: *Context) !*XkbBinding {
var xkb_binding = try utils.gpa.create(XkbBinding);
errdefer utils.gpa.destroy(xkb_binding);
xkb_binding.* = .{
.xkb_binding_v1 = xkb_binding_v1,
.command = command,
.context = context,
.link = undefined, // Handled by the wl.list
};
xkb_binding.xkb_binding_v1.setListener(*XkbBinding, xkbBindingListener, xkb_binding);
return xkb_binding;
}
pub fn destroy(xkb_binding: *XkbBinding) void {
xkb_binding.xkb_binding_v1.destroy();
xkb_binding.link.remove();
utils.gpa.destroy(xkb_binding);
}
fn xkbBindingListener(river_xkb_binding_v1: *river.XkbBindingV1, event: river.XkbBindingV1.Event, xkb_binding: *XkbBinding) void {
assert(xkb_binding.xkb_binding_v1 == river_xkb_binding_v1);
switch (event) {
.pressed => xkb_binding.executeCommand(),
else => {},
}
}
fn executeCommand(xkb_binding: *XkbBinding) void {
const context = xkb_binding.context;
const first_seat = context.wm.seats.first() orelse null;
// TODO: Should I log.warn when commands return early?
switch (xkb_binding.command) {
.spawn => |cmd| spawnProcess(cmd),
.focus_next_window => focusWindow(context, .next),
.focus_prev_window => focusWindow(context, .prev),
.focus_next_output => focusOutput(context, .next),
.focus_prev_output => focusOutput(context, .prev),
.send_to_next_output => sendWindowToOutput(context, .next),
.send_to_prev_output => sendWindowToOutput(context, .prev),
.zoom => {
const seat = first_seat orelse return;
const current_focus = if (seat.pending_manage.window) |pending_focus| blk: {
switch (pending_focus) {
.clear_focus => return,
.window => |window| break :blk window,
}
} else seat.focused_window orelse return;
// Noop if the focused window is floating
if (current_focus.floating) return;
// Get the first tiled window to try zoom with
// If the first window is focused, we instead try to get the second tiled window
const output = current_focus.output orelse {
log.warn("Focused window has no output during zoom", .{});
return;
};
var focus_is_first_tiled = false;
const first_tiled: *Window = blk: {
var it = output.windows.iterator(.forward);
while (it.next()) |window| {
if (window.floating or output.tags & window.tags == 0) continue;
if (window == current_focus) {
focus_is_first_tiled = true;
continue;
}
break :blk window;
}
// No (or only one) visible tiled windows, nothing to do
return;
};
current_focus.link.swapWith(&first_tiled.link);
// Update focus
seat.pending_manage.window = .{ .window = current_focus };
// Warp pointer when the focused window is promoted to primary
if (!focus_is_first_tiled) {
seat.pending_manage.should_warp_pointer = true;
}
},
.toggle_float => {
const seat = first_seat orelse return;
const window = seat.focused_window orelse return;
// Noop if the window is fullscreened
if (window.fullscreen) return;
window.pending_manage.floating = !window.floating;
context.wm.river_window_manager_v1.manageDirty();
},
inline .change_primary_ratio, .change_single_window_ratio => |diff, cmd| {
const seat = first_seat orelse return;
const output = seat.focused_output orelse return;
// Get rid of the "change_" from the start of the command name
const field_name = @tagName(cmd)[7..];
@field(output.pending_manage, field_name) = @field(output, field_name) + diff;
context.wm.river_window_manager_v1.manageDirty();
},
.increment_primary_count => {
const seat = first_seat orelse return;
const output = seat.focused_output orelse return;
output.pending_manage.primary_count = output.primary_count + 1;
context.wm.river_window_manager_v1.manageDirty();
},
.decrement_primary_count => {
const seat = first_seat orelse return;
const output = seat.focused_output orelse return;
output.pending_manage.primary_count = output.primary_count - 1;
context.wm.river_window_manager_v1.manageDirty();
},
.reload_config => {
// Try create new config
const new_config = Config.create() catch {
// We do this so that, if the Config fails to reload, the
// user still has *some* config.
log.err("Failed to reload Config. Not deleting old one", .{});
return;
};
if (context.pending_manage.config) |old_pending| {
// Need to prevent memory leaks in case multiple reloads are sent before a manage
old_pending.destroy();
}
// Send the config to the WM to handle during next manage
context.pending_manage.config = new_config;
context.wm.river_window_manager_v1.manageDirty();
},
.toggle_fullscreen => {
const seat = first_seat orelse return;
const window = seat.focused_window orelse return;
window.pending_manage.fullscreen = !window.fullscreen;
context.wm.river_window_manager_v1.manageDirty();
},
.close_window => {
const seat = first_seat orelse return;
if (seat.focused_window) |window| {
window.river_window_v1.close();
}
},
.exit_river => context.wm.river_window_manager_v1.exitSession(),
.set_output_tags => |tags| {
const seat = first_seat orelse return;
const output = seat.focused_output orelse return;
output.pending_manage.tags = tags;
context.wm.river_window_manager_v1.manageDirty();
},
.set_window_tags => |tags| {
const seat = first_seat orelse return;
// TODO: I don't think pending_focus should ever be set at this point?
// const window = seat.pending_manage.pending_focus orelse seat.focused;
const window = seat.focused_window orelse return;
window.pending_manage.tags = tags;
context.wm.river_window_manager_v1.manageDirty();
},
.toggle_output_tags => |tags| {
const seat = first_seat orelse return;
const output = seat.focused_output orelse return;
const old_tags = output.pending_manage.tags orelse output.tags;
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 = first_seat orelse return;
// TODO: I don't think pending_focus should ever be set at this point?
// const window = seat.pending_manage.pending_focus orelse seat.focused;
const window = seat.focused_window orelse return;
const old_tags = window.pending_manage.tags orelse window.tags;
const new_tags = old_tags ^ tags;
if (new_tags != 0) {
window.pending_manage.tags = new_tags;
context.wm.river_window_manager_v1.manageDirty();
}
},
.move_up => |pixels| moveFloatingWindow(context, 0, -pixels),
.move_down => |pixels| moveFloatingWindow(context, 0, pixels),
.move_left => |pixels| moveFloatingWindow(context, -pixels, 0),
.move_right => |pixels| moveFloatingWindow(context, pixels, 0),
.resize_width => |delta| resizeFloatingWindow(context, delta, 0),
.resize_height => |delta| resizeFloatingWindow(context, 0, delta),
.center_float => centerFloatingWindow(context),
.swap_next => swapWindow(context, .next),
.swap_prev => swapWindow(context, .prev),
.toggle_passthrough => {
context.xkb_bindings.pending_manage.toggle_passthrough = true;
context.wm.river_window_manager_v1.manageDirty();
},
}
}
fn focusWindow(context: *Context, direction: FocusDirection) void {
const seat = context.wm.seats.first() orelse return;
const output = seat.focused_output orelse return;
const pending_focus = if (seat.focused_window) |current| blk: {
assert(current.output == output);
break :blk switch (direction) {
.next => output.nextWindow(current),
.prev => output.prevWindow(current),
};
} else switch (direction) {
.next => output.windows.first(),
.prev => output.windows.last(),
};
if (pending_focus) |window| {
seat.pending_manage.window = .{ .window = window };
seat.pending_manage.should_warp_pointer = true;
} else {
seat.pending_manage.window = .clear_focus;
}
}
fn focusOutput(context: *Context, direction: FocusDirection) void {
const wm = context.wm;
const seat = wm.seats.first() orelse return;
if (seat.focused_window) |window| {
assert(window.output == seat.focused_output);
}
const pending_focus = if (seat.focused_output) |current|
switch (direction) {
.next => wm.nextOutput(current),
.prev => wm.prevOutput(current),
}
else switch (direction) {
.next => wm.outputs.first(),
.prev => wm.outputs.last(),
};
if (pending_focus) |output| blk: {
// This should be a noop if there's only one output
if (output == seat.focused_output) {
log.debug("focusOutput(): trying to focus current output", .{});
break :blk;
}
seat.pending_manage.output = .{ .output = output };
// We got the new output, but we need to switch window focus, too
// First tell the old one
if (seat.focused_window) |current_focus| {
current_focus.pending_render.focused = false;
}
// Then set the new one
if (output.windows.first()) |window| {
seat.pending_manage.window = .{ .window = window };
// Pointer won't warp if window is empty
seat.pending_manage.should_warp_pointer = true;
} else {
// Clear old focus
seat.pending_manage.window = .clear_focus;
}
} else {
log.warn("focusOutput(): no outputs", .{});
// This should only ever be reached if there were no outputs and then the user
// tries to change the focused output again.
seat.pending_manage.output = .clear_focus;
}
}
/// This function requires that the window is currently on an output
/// AND that the output is the currently focused output on the first seat.
fn sendWindowToOutput(context: *Context, direction: FocusDirection) void {
const wm = context.wm;
const seat = wm.seats.first() orelse return;
const window = seat.focused_window orelse return;
assert(window.output == seat.focused_output);
assert(seat.focused_output != null);
const current_output = seat.focused_output.?;
const pending_output = switch (direction) {
.next => wm.nextOutput(current_output),
.prev => wm.prevOutput(current_output),
};
if (pending_output) |output| blk: {
// This should be a noop if there's only one output
if (output == window.output) {
break :blk;
}
// We have to remove window from current output's windows list first
window.link.remove();
output.windows.append(window);
seat.pending_manage.output = .{ .output = output };
seat.pending_manage.should_warp_pointer = true;
window.pending_manage.pending_output = .{ .output = output };
}
}
// Borrowed and modified from river-classic
// https://codeberg.org/river/river-classic/src/commit/d72408df18310d5945147d485fe4bb66eef043d3/river/process.zig
fn spawnProcess(cmd: []const [:0]const u8) void {
const pid = posix.fork() catch |err| {
return log.err("Failed to fork \"{s}\": {}", .{ cmd[0], err });
};
if (pid == 0) {
cleanupChild();
const c_argv = utils.gpa.allocSentinel(?[*:0]const u8, cmd.len, null) catch posix.exit(1);
for (cmd, 0..) |arg, i| c_argv[i] = arg.ptr;
const pid2 = posix.fork() catch posix.exit(1);
if (pid2 == 0) {
posix.execvpeZ(c_argv[0].?, c_argv, std.c.environ) catch posix.exit(1);
}
posix.exit(0);
}
_ = posix.waitpid(pid, 0);
}
// Borrowed and modified from river-classic
// https://codeberg.org/river/river-classic/src/commit/d72408df18310d5945147d485fe4bb66eef043d3/river/process.zig
fn cleanupChild() void {
_ = posix.setsid() catch unreachable;
if (posix.system.sigprocmask(posix.SIG.SETMASK, &posix.sigemptyset(), null) < 0) unreachable;
const sig_dfl = posix.Sigaction{
.handler = .{ .handler = posix.SIG.DFL },
.mask = posix.sigemptyset(),
.flags = 0,
};
posix.sigaction(posix.SIG.PIPE, &sig_dfl, null);
}
fn moveFloatingWindow(context: *Context, dx: i32, dy: i32) void {
const seat = context.wm.seats.first() orelse return;
const window = seat.focused_window orelse return;
if (!window.floating) return;
window.floating_rect.x += dx;
window.floating_rect.y += dy;
window.pending_render.position = .{ .x = window.floating_rect.x, .y = window.floating_rect.y };
context.wm.river_window_manager_v1.manageDirty();
}
fn resizeFloatingWindow(context: *Context, dw: i32, dh: i32) void {
const seat = context.wm.seats.first() orelse return;
const window = seat.focused_window orelse return;
if (!window.floating) return;
const new_width: i32 = @as(i32, window.floating_rect.width) + dw;
const new_height: i32 = @as(i32, window.floating_rect.height) + dh;
window.floating_rect.width = @intCast(@max(50, new_width));
window.floating_rect.height = @intCast(@max(50, new_height));
window.pending_manage.dimensions = .{ .width = window.floating_rect.width, .height = window.floating_rect.height };
context.wm.river_window_manager_v1.manageDirty();
}
fn centerFloatingWindow(context: *Context) void {
const seat = context.wm.seats.first() orelse return;
const window = seat.focused_window orelse return;
if (!window.floating) return;
const output = window.output orelse return;
window.floating_rect.x = output.usable_geometry.x + @divFloor(output.usable_geometry.width, 2) - @divFloor(window.floating_rect.width, 2);
window.floating_rect.y = output.usable_geometry.y + @divFloor(output.usable_geometry.height, 2) - @divFloor(window.floating_rect.height, 2);
window.pending_render.position = .{ .x = window.floating_rect.x, .y = window.floating_rect.y };
context.wm.river_window_manager_v1.manageDirty();
}
fn swapWindow(context: *Context, comptime direction: enum { next, prev }) void {
const seat = context.wm.seats.first() orelse return;
const window = seat.focused_window orelse return;
const output = window.output orelse {
log.err("focused window has no output during swap", .{});
return;
};
const target = switch (direction) {
.next => output.nextWindow(window),
.prev => output.prevWindow(window),
} orelse return;
if (target != window) {
window.link.swapWith(&target.link);
seat.pending_manage.should_warp_pointer = true;
context.wm.river_window_manager_v1.manageDirty();
}
}
};
context: *Context,
xkb_bindings_v1: *river.XkbBindingsV1,
/// When passthrough_active is true, keybinds (except for passthrough_toggle) are disabled.
passthrough_active: bool = false,
bindings: wl.list.Head(XkbBinding, .link),
pending_manage: PendingManage = .{},
const PendingManage = struct {
toggle_passthrough: bool = false,
};
pub fn create(context: *Context, xkb_bindings_v1: *river.XkbBindingsV1) !*XkbBindings {
const xkb_bindings = try utils.gpa.create(XkbBindings);
errdefer utils.gpa.destroy(xkb_bindings);
xkb_bindings.* = .{
.context = context,
.xkb_bindings_v1 = xkb_bindings_v1,
.bindings = undefined, // we will initialize this shortly
};
xkb_bindings.bindings.init();
return xkb_bindings;
}
pub fn destroy(xkb_bindings: *XkbBindings) void {
var it = xkb_bindings.bindings.safeIterator(.forward);
while (it.next()) |binding| {
binding.link.remove();
binding.xkb_binding_v1.destroy();
utils.gpa.destroy(binding);
}
xkb_bindings.xkb_bindings_v1.destroy();
utils.gpa.destroy(xkb_bindings);
}
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;
};
const xkb_binding = XkbBinding.create(xkb_binding_v1, command, xkb_bindings.context) catch @panic("Out of memory");
xkb_bindings.bindings.append(xkb_binding);
xkb_binding_v1.enable();
}
pub fn manage(xkb_bindings: *XkbBindings) void {
defer xkb_bindings.pending_manage = .{};
if (xkb_bindings.pending_manage.toggle_passthrough) {
xkb_bindings.passthrough_active = !xkb_bindings.passthrough_active;
var it = xkb_bindings.bindings.iterator(.forward);
while (it.next()) |binding| {
if (binding.command != .toggle_passthrough) {
if (xkb_bindings.passthrough_active) {
binding.xkb_binding_v1.disable();
} else {
binding.xkb_binding_v1.enable();
}
}
}
}
}
const std = @import("std");
const assert = std.debug.assert;
const posix = std.posix;
const process = std.process;
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 Config = @import("Config.zig");
const Seat = @import("Seat.zig");
const Window = @import("Window.zig");
const log = std.log.scoped(.XkbBindings);