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

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