From 6d4352a2171fe82102a792363e81cf8fb8d1e177 Mon Sep 17 00:00:00 2001 From: Ben Buhse Date: Thu, 5 Feb 2026 17:09:58 -0600 Subject: [PATCH 1/2] Implement floating windows As of this commit, there's not-yet a way to resize or move floating windows, but it's possible to create one and focus through all windows. Floating windows are always above tiled windows and, if floating window is focused, that window is always above any another floating windows. Windows have a separate float_{x, y, width, height} to remember their floating location if they go from float=>tiled=>float again. --- README.md | 2 ++ examples/config.kdl | 1 + src/Config.zig | 1 + src/Output.zig | 23 ++++++++++++++++++-- src/Window.zig | 50 ++++++++++++++++++++++++++++++++++++++++--- src/WindowManager.zig | 1 + src/XkbBindings.zig | 42 +++++++++++++++++++++++------------- 7 files changed, 100 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 5c3a3cb..07c43bf 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,9 @@ These are in rough order of my priority, though no promises I do them in this or - [ ] Support starting programs at WM launch - [ ] Support overriding config location - [ ] Add support for multimedia/brightness keys +- [ ] 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 multiple outputs diff --git a/examples/config.kdl b/examples/config.kdl index a3acd1e..86b9dd4 100644 --- a/examples/config.kdl +++ b/examples/config.kdl @@ -15,6 +15,7 @@ keybinds { send_to_next_output Mod1+Shift J send_to_prev_output Mod1+Shift K zoom Mod4 Z + toggle_float Mod4+Shift F change_ratio Mod4 H +0.05 change_ratio Mod4 L -0.05 increment_primary_count Mod4 I diff --git a/src/Config.zig b/src/Config.zig index 3e5795b..65de920 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -330,6 +330,7 @@ fn loadKeybindsChildBlock(config: *Config, parser: *kdl.Parser) !void { .focus_prev_output, .send_to_next_output, .send_to_prev_output, + .toggle_float, .zoom, .reload_config, .toggle_fullscreen, diff --git a/src/Output.zig b/src/Output.zig index 8bf432a..10d1092 100644 --- a/src/Output.zig +++ b/src/Output.zig @@ -188,9 +188,24 @@ pub fn manage(output: *Output) void { } pub fn render(output: *Output) void { + const seat = output.context.wm.seats.first(); + const focused = if (seat) |s| s.focused_window else null; + var it = output.windows.iterator(.forward); while (it.next()) |window| { window.render(); + + // Make sure floating windows are above tiled windows + if (window.floating and output.tags & window.tags != 0 and window != focused) { + window.river_node_v1.placeTop(); + } + } + + // Make sure that the *focused* floating window goes above any other floating windows + if (focused) |f| { + if (f.floating and f.output == output and output.tags & f.tags != 0) { + f.river_node_v1.placeTop(); + } } } @@ -206,8 +221,12 @@ fn calculatePrimaryStackLayout(output: *Output) void { var it = output.windows.iterator(.forward); while (it.next()) |window| { if (output.tags & window.tags != 0x0000) { - active_list.append(&window.active_list_node); - active_count += 1; + // Floating windows should be shown but not included in this layout generation + const will_float = window.pending_manage.floating orelse window.floating; + if (!will_float) { + active_count += 1; + active_list.append(&window.active_list_node); + } window.pending_render.show = true; } else { window.pending_render.show = false; diff --git a/src/Window.zig b/src/Window.zig index 864c036..53da74b 100644 --- a/src/Window.zig +++ b/src/Window.zig @@ -9,6 +9,7 @@ context: *Context, river_window_v1: *river.WindowV1, river_node_v1: *river.NodeV1, +// TODO: Could switch this to a Rect { x, y, width, height } width: u31 = 0, height: u31 = 0, x: i32 = 0, @@ -20,6 +21,12 @@ maximized: bool = false, tags: u32 = 0x0001, output: ?*Output, +floating: bool = false, +float_width: u31 = 0, +float_height: u31 = 0, +float_x: i32 = 0, +float_y: i32 = 0, + initialized: bool = false, /// State consumed in manage() phase, reset at end of manage(). @@ -44,6 +51,8 @@ pub const PendingManage = struct { tags: ?u32 = null, pending_output: ?PendingOutput = null, + floating: ?bool = null, + pub const PendingOutput = union(enum) { output: *Output, clear_output, @@ -128,6 +137,7 @@ fn windowListener(river_window_v1: *river.WindowV1, event: river.WindowV1.Event, } pub fn manage(window: *Window) void { + const river_window_v1 = window.river_window_v1; if (!window.initialized) { // Only happens once per Window @branchHint(.unlikely); @@ -135,15 +145,48 @@ pub fn manage(window: *Window) void { // TODO: We might want to think about paying attention to the decoration_hint event // If we do, this would need to move, I think? - window.river_window_v1.useSsd(); + river_window_v1.useSsd(); - window.river_window_v1.setCapabilities(.{ .fullscreen = true, .maximize = true }); - window.river_window_v1.setTiled(.{ .top = true, .bottom = true, .left = true, .right = true }); + river_window_v1.setCapabilities(.{ .fullscreen = true, .maximize = true }); + river_window_v1.setTiled(.{ .top = true, .bottom = true, .left = true, .right = true }); } // Updating state since the last manage event defer window.pending_manage = .{}; const pending_manage = window.pending_manage; + // Floating status + if (pending_manage.floating) |floating| blk: { + // This needs to be before proposing the new dimensions since we want to save the current ones! + // Skip the rest of the block if floating matches what is already set + if (floating == window.floating) break :blk; + + window.floating = floating; + if (floating) { + // Let the window know it isn't tiled + river_window_v1.setTiled(.{}); + + if (window.float_width == 0) { + // Never floated before; use current dimensions but centered on output + 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); + window.float_y = output.y + @divTrunc(output.height, 2) - @divTrunc(window.height, 2); + } + } else { + // Window has floated before; re-use its old dimensions + river_window_v1.proposeDimensions(window.float_width, window.float_height); + } + window.pending_render.x = window.float_x; + window.pending_render.y = window.float_y; + } else { + river_window_v1.setTiled(.{ .top = true, .bottom = true, .left = true, .right = true }); + // Save floating dimensions in case the window gets floated again + window.float_width = window.width; + window.float_height = window.height; + window.float_x = window.x; + window.float_y = window.y; + } + } // Layout (pre-computed by WindowManager.calculatePrimaryStackLayout()) if (pending_manage.width) |new_width| { if (pending_manage.height) |new_height| { @@ -177,6 +220,7 @@ pub fn manage(window: *Window) void { if (pending_manage.tags) |tags| { window.tags = tags; } + // New output if (pending_manage.pending_output) |pending_output| { switch (pending_output) { .output => |output| { diff --git a/src/WindowManager.zig b/src/WindowManager.zig index fdd4723..c32287d 100644 --- a/src/WindowManager.zig +++ b/src/WindowManager.zig @@ -172,6 +172,7 @@ fn windowManagerV1Listener(window_manager_v1: *river.WindowManagerV1, event: riv // and focus the first one first.pending_render.focused = true; } + // We clear any orphaned_windows if an output is added output.windows.appendList(&wm.orphan_windows); }, .seat => |ev| { diff --git a/src/XkbBindings.zig b/src/XkbBindings.zig index 4d85a80..afb46cd 100644 --- a/src/XkbBindings.zig +++ b/src/XkbBindings.zig @@ -13,6 +13,7 @@ pub const Command = union(enum) { send_to_next_output, send_to_prev_output, zoom, + toggle_float, // Changes the ratio on the focused output only change_ratio: f32, // Changes the primary count on the focus output only @@ -98,25 +99,36 @@ const XkbBinding = struct { .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 const output = current_focus.output orelse return; - const first_window: *Window = if (output.windows.first()) |first| blk: { - if (current_focus == first) { - // Try get the second window instead - const next = first.link.next orelse return; - // next is the sentinel; there's only one window - if (next == &output.windows.link) { - return; + const first_tiled: *Window = blk: { + var it = output.windows.iterator(.forward); + while (it.next()) |window| { + if (window != current_focus and !window.floating) { + break :blk window; } - break :blk @fieldParentPtr("link", next); - } else { - seat.pending_manage.should_warp_pointer = true; - break :blk first; } - } else { - // If current_focus is not null, we know that first_window *must not* be null. - unreachable; + // No (or only one) tiled windows, nothing to do + return; }; - current_focus.link.swapWith(&first_window.link); + + current_focus.link.swapWith(&first_tiled.link); + // Don't warp pointer if the first was the one focused before + if (output.windows.first() == current_focus) { + seat.pending_manage.should_warp_pointer = true; + } + }, + .toggle_float => { + const seat = context.wm.seats.first() 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(); }, .change_ratio => |diff| { const seat = context.wm.seats.first() orelse return; From 07fbe91c130aca9a7eb28760e86c2bdc4633fede Mon Sep 17 00:00:00 2001 From: Ben Buhse Date: Fri, 6 Feb 2026 14:22:32 -0600 Subject: [PATCH 2/2] 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. --- README.md | 4 +- examples/config.kdl | 55 ++++++++++--- src/Config.zig | 119 +++++++++++++++++++++++++++- src/Seat.zig | 180 ++++++++++++++++++++++++++++++++++++++++++ src/Window.zig | 52 ++++++++---- src/WindowManager.zig | 22 ++++++ src/XkbBindings.zig | 77 ++++++++++++++++++ 7 files changed, 481 insertions(+), 28 deletions(-) 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,