Implement floating windows with pointer and keyboard controls

Add interactive move/resize operations using configurable pointer bindings
(Mod4+BTN_LEFT to move, Mod4+BTN_RIGHT to resize). Tiled windows
automatically float when dragged or resized.

Add keyboard commands for floating windows:
- move_up/down/left/right: move by pixel amount
- resize_width/height: resize by pixel amount
- swap_next/swap_prev: swap position in window stack

Fix float dimension initialization when windows first become floating,
and fix clamp crash when resizing windows larger than output bounds.

Update example config with documented keybinds and new pointer_binds block.
This commit is contained in:
Ben Buhse 2026-02-06 14:22:32 -06:00
commit 07fbe91c13
No known key found for this signature in database
GPG key ID: 7916ACFCD38FD0B4
7 changed files with 481 additions and 28 deletions

View file

@ -21,8 +21,9 @@ focus_follows_pointer: bool = true,
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) = .{},
tag_binds: std.ArrayList(Keybind) = .{},
keybinds: std.ArrayList(Keybind) = .{},
pointer_binds: std.ArrayList(PointerBind) = .{},
pub const Keybind = struct {
modifiers: river.SeatV1.Modifiers,
@ -30,6 +31,17 @@ pub const Keybind = struct {
keysym: ?xkbcommon.Keysym,
};
pub const PointerBind = struct {
modifiers: river.SeatV1.Modifiers,
button: u32, // Linux button code (BTN_LEFT=0x110, BTN_RIGHT=0x111, BTN_MIDDLE=0x112)
action: PointerAction,
};
pub const PointerAction = enum {
move_window,
resize_window,
};
pub const AttachMode = enum {
top,
bottom,
@ -41,6 +53,7 @@ const NodeName = enum {
pointer_warp_on_focus_change,
borders,
keybinds,
pointer_binds,
};
const BorderNodeName = enum {
@ -49,6 +62,11 @@ const BorderNodeName = enum {
color_unfocused,
};
const PointerBindNodeName = enum {
move_window,
resize_window,
};
// We can just directly use the tag type from Command as our node name
const KeybindNodeName = @typeInfo(XkbBindings.Command).@"union".tag_type.?;
@ -82,6 +100,7 @@ pub fn create() !*Config {
}
config.keybinds.clearAndFree(utils.allocator);
config.tag_binds.clearAndFree(utils.allocator);
config.pointer_binds.clearAndFree(utils.allocator);
config.* = .{};
};
}
@ -101,6 +120,7 @@ pub fn destroy(config: *Config) void {
}
config.keybinds.deinit(utils.allocator);
config.tag_binds.deinit(utils.allocator);
config.pointer_binds.deinit(utils.allocator);
utils.allocator.destroy(config);
}
@ -161,6 +181,9 @@ fn load(config: *Config, reader: *Io.Reader) !void {
.keybinds => {
next_child_block = .keybinds;
},
.pointer_binds => {
next_child_block = .pointer_binds;
},
}
} else {
logWarnInvalidNode(node.name);
@ -171,6 +194,7 @@ fn load(config: *Config, reader: *Io.Reader) !void {
switch (child_block) {
.borders => try config.loadBordersChildBlock(&parser),
.keybinds => try config.loadKeybindsChildBlock(&parser),
.pointer_binds => try config.loadPointerBindsChildBlock(&parser),
else => {
// Nothing else should ever be marked as a next_child_block
unreachable;
@ -337,10 +361,29 @@ fn loadKeybindsChildBlock(config: *Config, parser: *kdl.Parser) !void {
.close_window,
.increment_primary_count,
.decrement_primary_count,
.swap_next,
.swap_prev,
=> |cmd| {
// None of these have arguments, just create the union and give it back
break :sw @unionInit(XkbBindings.Command, @tagName(cmd), {});
},
inline .move_up,
.move_down,
.move_left,
.move_right,
.resize_width,
.resize_height,
=> |cmd| {
const amount_str = utils.stripQuotes(node.arg(parser, 2) orelse {
logWarnMissingNodeArg(name, "amount");
continue;
});
const amount = fmt.parseInt(i32, amount_str, 0) catch {
logWarnInvalidNodeArg(name, amount_str);
continue;
};
break :sw @unionInit(XkbBindings.Command, @tagName(cmd), amount);
},
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");
@ -375,6 +418,76 @@ fn loadKeybindsChildBlock(config: *Config, parser: *kdl.Parser) !void {
}
}
fn loadPointerBindsChildBlock(config: *Config, parser: *kdl.Parser) !void {
while (try parser.next()) |event| {
switch (event) {
.node => |node| {
const node_name = std.meta.stringToEnum(PointerBindNodeName, node.name);
if (node_name) |name| {
// Parse modifiers (arg 0)
const mod_str = utils.stripQuotes(node.arg(parser, 0) orelse {
logWarnMissingNodeArg(name, "modifier(s)");
continue;
});
const modifiers = try utils.parseModifiers(mod_str) orelse {
logWarnInvalidNodeArg(name, mod_str);
continue;
};
// Parse button (arg 1)
const button_str = utils.stripQuotes(node.arg(parser, 1) orelse {
logWarnMissingNodeArg(name, "button");
continue;
});
const button = parseButton(button_str) orelse {
logWarnInvalidNodeArg(name, button_str);
continue;
};
const action: PointerAction = switch (name) {
.move_window => .move_window,
.resize_window => .resize_window,
};
try config.pointer_binds.append(utils.allocator, .{
.modifiers = modifiers,
.button = button,
.action = action,
});
log.debug("pointer_binds.{s}: {s} {s}", .{ @tagName(name), mod_str, button_str });
} else {
logWarnInvalidNode(node.name);
}
},
.child_block_begin => {
try config.skipChildBlock(parser);
},
.child_block_end => {
return;
},
}
}
}
fn parseButton(s: []const u8) ?u32 {
// Support both numeric and named buttons
var lower_buf: [16]u8 = undefined;
const len = @min(s.len, 16);
const lower = std.ascii.lowerString(lower_buf[0..len], s[0..len]);
if (mem.eql(u8, lower, "btn_left") or mem.eql(u8, lower, "button1")) {
return 0x110; // BTN_LEFT = 272
} else if (mem.eql(u8, lower, "btn_right") or mem.eql(u8, lower, "button3")) {
return 0x111; // BTN_RIGHT = 273
} else if (mem.eql(u8, lower, "btn_middle") or mem.eql(u8, lower, "button2")) {
return 0x112; // BTN_MIDDLE = 274
}
// Try parsing as hex or decimal
return fmt.parseInt(u32, s, 0) catch null;
}
/// Skips an entire child block including any nested child blocks
fn skipChildBlock(_: *Config, parser: *kdl.Parser) !void {
log.warn("Unexpected child block. Skipping it", .{});
@ -422,6 +535,7 @@ fn logWarnInvalidNodeArg(node_name: anytype, node_value: []const u8) void {
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 }),
PointerBindNodeName => log.warn("Invalid \"pointer_binds.{s}\" ({s}). Ignoring", .{ @tagName(node_name), node_value }),
else => @compileError("This function does not (yet) support type \"" ++ @typeName(node_name_type) ++ "\""),
}
}
@ -430,6 +544,7 @@ 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)}),
PointerBindNodeName => log.warn("\"pointer_binds.{s}\" missing " ++ arg ++ " argument. Ignoring", .{@tagName(node_name)}),
else => @compileError("This function does not (yet) support type \"" ++ @typeName(node_name_type) ++ "\""),
}
}

View file

@ -11,16 +11,27 @@ river_seat_v1: *river.SeatV1,
focused_window: ?*Window,
focused_output: ?*Output,
pointer_op: PointerOp = .none,
/// State consumed in manage phase, reset at end of manage().
pending_manage: PendingManage = .{},
link: wl.list.Link,
// Pointer bindings for interactive move/resize
move_pointer_binding: ?*river.PointerBindingV1 = null,
resize_pointer_binding: ?*river.PointerBindingV1 = null,
pub const PendingManage = struct {
window: ?PendingWindow = null,
output: ?PendingOutput = null,
should_warp_pointer: bool = false,
op_delta: ?struct { dx: i32, dy: i32 } = null,
op_released: bool = false,
pointer_move_request: ?*Window = null,
pointer_resize_request: ?struct { window: *Window, edges: river.WindowV1.Edges } = null,
pub const PendingWindow = union(enum) {
window: *Window,
clear_focus,
@ -32,6 +43,19 @@ pub const PendingManage = struct {
};
};
pub const PointerOp = union(enum) {
none,
move: struct { window: *Window, start_x: i32, start_y: i32 },
resize: struct {
window: *Window,
start_width: u31,
start_height: u31,
start_x: i32,
start_y: i32,
edges: river.WindowV1.Edges,
},
};
pub fn create(context: *Context, river_seat_v1: *river.SeatV1) !*Seat {
var seat = try utils.allocator.create(Seat);
errdefer seat.destroy();
@ -50,6 +74,8 @@ pub fn create(context: *Context, river_seat_v1: *river.SeatV1) !*Seat {
}
pub fn destroy(seat: *Seat) void {
if (seat.move_pointer_binding) |binding| binding.destroy();
if (seat.resize_pointer_binding) |binding| binding.destroy();
seat.river_seat_v1.destroy();
utils.allocator.destroy(seat);
}
@ -65,6 +91,12 @@ fn seatListener(river_seat_v1: *river.SeatV1, event: river.SeatV1.Event, seat: *
seat.setWindowFocus(ev.window);
},
.window_interaction => |ev| seat.setWindowFocus(ev.window),
.op_delta => |ev| {
seat.pending_manage.op_delta = .{ .dx = ev.dx, .dy = ev.dy };
},
.op_release => {
seat.pending_manage.op_released = true;
},
else => |ev| {
log.debug("unhandled event: {s}", .{@tagName(ev)});
},
@ -129,12 +161,160 @@ pub fn manage(seat: *Seat) void {
seat.river_seat_v1.pointerWarp(pointer_x, pointer_y);
}
}
// Interactive move/resize operations
// Start move operation
if (seat.pending_manage.pointer_move_request) |window| {
if (window.floating) {
seat.pointer_op = .{
.move = .{
.window = window,
.start_x = window.float_x,
.start_y = window.float_y,
},
};
seat.river_seat_v1.opStartPointer();
}
}
// Start resize operation
if (seat.pending_manage.pointer_resize_request) |req| {
if (req.window.floating) {
seat.pointer_op = .{
.resize = .{
.window = req.window,
.start_width = req.window.float_width,
.start_height = req.window.float_height,
.start_x = req.window.float_x,
.start_y = req.window.float_y,
.edges = req.edges,
},
};
seat.river_seat_v1.opStartPointer();
req.window.river_window_v1.informResizeStart();
}
}
// Process pointer delta (mouse movement during operation)
if (seat.pending_manage.op_delta) |delta| {
switch (seat.pointer_op) {
.none => {},
.move => |op| {
const output = op.window.output orelse return;
const min_x = output.x;
const max_x = output.x + output.width - @as(i32, op.window.float_width);
const min_y = output.y;
const max_y = output.y + output.height - @as(i32, op.window.float_height);
op.window.float_x = std.math.clamp(op.start_x + delta.dx, min_x, @max(min_x, max_x));
op.window.float_y = std.math.clamp(op.start_y + delta.dy, min_y, @max(min_y, max_y));
op.window.pending_render.x = op.window.float_x;
op.window.pending_render.y = op.window.float_y;
},
.resize => |op| {
var new_width: i32 = op.start_width;
var new_height: i32 = op.start_height;
var new_x: i32 = op.start_x;
var new_y: i32 = op.start_y;
// Adjust based on which edges are being dragged
if (op.edges.right) new_width += delta.dx;
if (op.edges.left) {
new_width -= delta.dx;
new_x += delta.dx;
}
if (op.edges.bottom) new_height += delta.dy;
if (op.edges.top) {
new_height -= delta.dy;
new_y += delta.dy;
}
// Clamp to minimum size
const min_size: i32 = 50;
if (new_width < min_size) {
if (op.edges.left) new_x -= min_size - new_width;
new_width = min_size;
}
if (new_height < min_size) {
if (op.edges.top) new_y -= min_size - new_height;
new_height = min_size;
}
// Clamp position to output bounds
const output = op.window.output orelse return;
new_x = std.math.clamp(new_x, output.x, @max(output.x, output.x + output.width - new_width));
new_y = std.math.clamp(new_y, output.y, @max(output.y, output.y + output.height - new_height));
op.window.float_width = @intCast(new_width);
op.window.float_height = @intCast(new_height);
op.window.float_x = new_x;
op.window.float_y = new_y;
op.window.river_window_v1.proposeDimensions(
op.window.float_width,
op.window.float_height,
);
op.window.pending_render.x = op.window.float_x;
op.window.pending_render.y = op.window.float_y;
},
}
}
// Process pointer release (end of operation)
if (seat.pending_manage.op_released) {
switch (seat.pointer_op) {
.none => {},
.move => {
seat.river_seat_v1.opEnd();
seat.pointer_op = .none;
},
.resize => |op| {
op.window.river_window_v1.informResizeEnd();
seat.river_seat_v1.opEnd();
seat.pointer_op = .none;
},
}
}
}
pub fn render(seat: *Seat) void {
_ = seat;
}
pub fn movePointerBindingListener(_: *river.PointerBindingV1, event: river.PointerBindingV1.Event, seat: *Seat) void {
switch (event) {
.pressed => {
const window = seat.focused_window orelse return;
if (!window.floating) {
// Auto-float on drag
window.pending_manage.floating = true;
}
seat.pending_manage.pointer_move_request = window;
seat.context.wm.river_window_manager_v1.manageDirty();
},
.released => {},
}
}
pub fn resizePointerBindingListener(_: *river.PointerBindingV1, event: river.PointerBindingV1.Event, seat: *Seat) void {
switch (event) {
.pressed => {
const window = seat.focused_window orelse return;
if (!window.floating) {
// Auto-float on drag
window.pending_manage.floating = true;
}
seat.pending_manage.pointer_resize_request = .{
.window = window,
.edges = .{ .bottom = true, .right = true },
};
seat.context.wm.river_window_manager_v1.manageDirty();
},
.released => {},
}
}
const std = @import("std");
const assert = std.debug.assert;

View file

@ -97,24 +97,43 @@ fn windowListener(river_window_v1: *river.WindowV1, event: river.WindowV1.Event,
assert(window.river_window_v1 == river_window_v1);
switch (event) {
.closed => {
{
// If there's no output, we don't really care about focus and can skip this event
const output = if (window.output) |output| output else return;
var it = window.context.wm.seats.iterator(.forward);
while (it.next()) |seat| {
if (seat.focused_window == window) {
// Find another window to focus and warp pointer there
if (output.prevWindow(window)) |next_focus| {
if (next_focus != window) {
seat.pending_manage.window = .{ .window = next_focus };
seat.pending_manage.should_warp_pointer = true;
} else {
// Only window in list - clear focus
seat.pending_manage.window = .clear_focus;
}
// Clear any pointer operations referencing this window
var seat_it = window.context.wm.seats.iterator(.forward);
while (seat_it.next()) |seat| {
switch (seat.pointer_op) {
.move => |op| if (op.window == window) {
seat.river_seat_v1.opEnd();
seat.pointer_op = .none;
},
.resize => |op| if (op.window == window) {
seat.river_seat_v1.opEnd();
seat.pointer_op = .none;
},
.none => {},
}
if (seat.pending_manage.pointer_move_request == window)
seat.pending_manage.pointer_move_request = null;
if (seat.pending_manage.pointer_resize_request) |req| {
if (req.window == window)
seat.pending_manage.pointer_resize_request = null;
}
}
// If there's no output, we don't really care about focus and can skip this event
const output = if (window.output) |output| output else return;
var it = window.context.wm.seats.iterator(.forward);
while (it.next()) |seat| {
if (seat.focused_window == window) {
// Find another window to focus and warp pointer there
if (output.prevWindow(window)) |next_focus| {
if (next_focus != window) {
seat.pending_manage.window = .{ .window = next_focus };
seat.pending_manage.should_warp_pointer = true;
} else {
// Only window in list - clear focus
seat.pending_manage.window = .clear_focus;
}
} else {
seat.pending_manage.window = .clear_focus;
}
}
}
@ -167,6 +186,8 @@ pub fn manage(window: *Window) void {
if (window.float_width == 0) {
// Never floated before; use current dimensions but centered on output
window.float_width = window.width;
window.float_height = window.height;
if (window.output) |output| {
// Need to find center and then subtract half of the window's width/height
window.float_x = output.x + @divTrunc(output.width, 2) - @divTrunc(window.width, 2);
@ -287,5 +308,6 @@ const river = wayland.client.river;
const utils = @import("utils.zig");
const Context = @import("Context.zig");
const Output = @import("Output.zig");
const Seat = @import("Seat.zig");
const log = std.log.scoped(.Window);

View file

@ -110,6 +110,28 @@ fn manage_start(wm: *WindowManager) void {
std.debug.assert(keybind.keysym != null);
context.xkb_bindings.addBinding(river_seat_v1, keybind.keysym.?, keybind.modifiers, keybind.command);
}
// Pointer bindings
for (context.config.pointer_binds.items) |pointer_bind| {
const binding = river_seat_v1.getPointerBinding(pointer_bind.button, pointer_bind.modifiers) catch {
log.err("Failed to create pointer binding", .{});
continue;
};
switch (pointer_bind.action) {
.move_window => {
if (seat.move_pointer_binding) |old| old.destroy();
binding.setListener(*Seat, Seat.movePointerBindingListener, seat);
seat.move_pointer_binding = binding;
},
.resize_window => {
if (seat.resize_pointer_binding) |old| old.destroy();
binding.setListener(*Seat, Seat.resizePointerBindingListener, seat);
seat.resize_pointer_binding = binding;
},
}
binding.enable();
}
}
{

View file

@ -30,6 +30,20 @@ pub const Command = union(enum) {
// spawn_tagmask: u32, // TODO
// focus_previous_tags, // TODO
// send_to_previous_tags, // TODO
// 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,
// Swap window position in stack
swap_next,
swap_prev,
};
const XkbBinding = struct {
@ -214,6 +228,14 @@ const XkbBinding = struct {
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),
.swap_next => swapWindow(context, .next),
.swap_prev => swapWindow(context, .prev),
}
}
@ -306,6 +328,61 @@ const XkbBinding = struct {
window.pending_manage.pending_output = .{ .output = output };
}
}
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;
const output = window.output orelse return;
const min_x = output.x;
const max_x = output.x + output.width - @as(i32, window.float_width);
const min_y = output.y;
const max_y = output.y + output.height - @as(i32, window.float_height);
window.float_x = std.math.clamp(window.float_x + dx, min_x, @max(min_x, max_x));
window.float_y = std.math.clamp(window.float_y + dy, min_y, @max(min_y, max_y));
window.pending_render.x = window.float_x;
window.pending_render.y = window.float_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 output = window.output orelse return;
const new_width: i32 = @as(i32, window.float_width) + dw;
const new_height: i32 = @as(i32, window.float_height) + dh;
window.float_width = @intCast(@max(50, new_width));
window.float_height = @intCast(@max(50, new_height));
// Clamp position to keep window on screen after resize
const max_x = output.x + output.width - @as(i32, window.float_width);
const max_y = output.y + output.height - @as(i32, window.float_height);
window.float_x = std.math.clamp(window.float_x, output.x, @max(output.x, max_x));
window.float_y = std.math.clamp(window.float_y, output.y, @max(output.y, max_y));
window.pending_render.x = window.float_x;
window.pending_render.y = window.float_y;
window.river_window_v1.proposeDimensions(window.float_width, window.float_height);
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 return;
const target = switch (direction) {
.next => output.nextWindow(window),
.prev => output.prevWindow(window),
} orelse return;
if (target != window) {
window.link.swapWith(&target.link);
context.wm.river_window_manager_v1.manageDirty();
}
}
};
context: *Context,