From 6d4352a2171fe82102a792363e81cf8fb8d1e177 Mon Sep 17 00:00:00 2001 From: Ben Buhse Date: Thu, 5 Feb 2026 17:09:58 -0600 Subject: [PATCH] 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;