diff --git a/README.md b/README.md index 07c43bf..0fb0537 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,6 @@ SPDX-License-Identifier: GPL-3.0-or-later These are in rough order of my priority, though no promises I do them in this order. -- [ ] Support floating windows - [ ] Support wallpapers - [ ] Support a bar - [ ] Support starting programs at WM launch @@ -19,6 +18,7 @@ These are in rough order of my priority, though no promises I do them in this or - [ ] Make "orelse return" bits into errors; handle gracefully - [ ] Support multiple seats - [ ] Support clipping floating windows on edge of/between outputs -- [x] Support changeable primary count - [x] Support changeable primary ratio +- [x] Support changeable primary count - [x] Support multiple outputs +- [X] Support floating windows diff --git a/examples/config.kdl b/examples/config.kdl index 86b9dd4..0a42e88 100644 --- a/examples/config.kdl +++ b/examples/config.kdl @@ -1,31 +1,68 @@ +// Whether new windows should go to the top or bottom of the window stack attach_mode top +// Whether mousing over a new window should move focus focus_follows_pointer #true +// Whether the focus should warp to the center of newly-focused windows pointer_warp_on_focus_change #true borders { width 2 + // 8 or 10 digit hex color color_focused "0x89b4fa" color_unfocused "0x1e1e2e" } keybinds { + // Swap a window spawn Mod4 T foot + // Move focus up or down the windows stack focus_next_window Mod4 J focus_prev_window Mod4 K - focus_next_output Mod4+Shift J - focus_prev_output Mod4+Shift K - send_to_next_output Mod1+Shift J - send_to_prev_output Mod1+Shift K + // Move focus between windows + focus_next_output Mod4 Period + focus_prev_output Mod4 Comma + // Move windows between outputs + send_to_next_output Mod4+Shift Period + send_to_prev_output Mod4+Shift Comma + // Swap the currently-focused window with the current primary zoom Mod4 Z + // Float/unfloat the currently-focused window toggle_float Mod4+Shift F - change_ratio Mod4 H +0.05 + // Change the primary ratio of the current output + change_ratio Mod4 H 0.05 change_ratio Mod4 L -0.05 + // Change the number of windows in the primary side increment_primary_count Mod4 I decrement_primary_count Mod4 D + // Reload config file reload_config Mod4+Shift R + // Toggle fullscreen on the currently-focused window toggle_fullscreen Mod4 F + // Close the currently-focused window close_window Mod4+Shift Q - // Generates keybinds for keys 1-9 → tags 1<<0 through 1<<9 + // Move windows up or down the stack + swap_next Mod4+Shift N + swap_prev Mod4+Shift P + // Move floating windows; noop on tiled windows + move_left Mod4+Shift H 100 + move_down Mod4+Shift J 100 + move_up Mod4+Shift K 100 + move_right Mod4+Shift L 100 + // Resize floating windows; noop on tiled windows + resize_width Mod4+Alt+Shift H -100 + resize_height Mod4+Alt+Shift J 100 + resize_height Mod4+Alt+Shift K -100 + resize_width Mod4+Alt+Shift L 100 + // Special command to generate keybinds for keys 1-9 and 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 + tag_bind Mod4+Shift set_window_tags + tag_bind Mod4+Ctrl toggle_output_tags + tag_bind Mod4+Ctrl+Shift toggle_window_tags } +pointer_binds { + // Mod4 + Left click to move floating windows; + // tiled windows will automatically float if moved + move_window Mod4 BTN_LEFT + // Mod4 + Right click to resize floating windows; + // tiled windows will automatically float if resized + resize_window Mod4 BTN_RIGHT +} + diff --git a/src/Config.zig b/src/Config.zig index 65de920..e7820fd 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -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) ++ "\""), } } diff --git a/src/Seat.zig b/src/Seat.zig index 59613a7..77be600 100644 --- a/src/Seat.zig +++ b/src/Seat.zig @@ -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; diff --git a/src/Window.zig b/src/Window.zig index 53da74b..5acf4ff 100644 --- a/src/Window.zig +++ b/src/Window.zig @@ -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); diff --git a/src/WindowManager.zig b/src/WindowManager.zig index c32287d..1bbacd4 100644 --- a/src/WindowManager.zig +++ b/src/WindowManager.zig @@ -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(); + } } { diff --git a/src/XkbBindings.zig b/src/XkbBindings.zig index afb46cd..48f2847 100644 --- a/src/XkbBindings.zig +++ b/src/XkbBindings.zig @@ -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,