beansprout-custom/src/WindowManager.zig
Ben Buhse 07fbe91c13
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.
2026-02-06 14:36:12 -06:00

255 lines
9.2 KiB
Zig

// SPDX-FileCopyrightText: 2025 Ben Buhse <me@benbuhse.email>
//
// SPDX-License-Identifier: GPL-3.0-or-later
const WindowManager = @This();
const MIN_RIVER_SEAT_V1_VERSION: u2 = 3;
context: *Context,
river_window_manager_v1: *river.WindowManagerV1,
seats: wl.list.Head(Seat, .link),
outputs: wl.list.Head(Output, .link),
/// Place to store windows if all Outputs have been disconnected
orphan_windows: wl.list.Head(Window, .link),
pub fn create(context: *Context, window_manager_v1: *river.WindowManagerV1) !*WindowManager {
const wm = try utils.allocator.create(WindowManager);
errdefer wm.destroy();
wm.* = .{
.context = context,
.river_window_manager_v1 = window_manager_v1,
.seats = undefined, // we will initialize these shortly
.outputs = undefined,
.orphan_windows = undefined,
};
wm.seats.init();
wm.outputs.init();
wm.orphan_windows.init();
wm.river_window_manager_v1.setListener(*WindowManager, windowManagerV1Listener, wm);
return wm;
}
pub fn destroy(wm: *WindowManager) void {
{
var it = wm.outputs.safeIterator(.forward);
while (it.next()) |output| {
output.link.remove();
output.destroy();
}
}
{
var it = wm.seats.safeIterator(.forward);
while (it.next()) |seat| {
seat.link.remove();
seat.destroy();
}
}
utils.allocator.destroy(wm);
}
/// Get the next output in the list, wrapping to first if at end
pub fn nextOutput(wm: *WindowManager, current: *Output) ?*Output {
const next_link = current.link.next orelse return wm.outputs.first();
// If we've reached the sentinel (head's link), wrap to first
if (next_link == &wm.outputs.link) return wm.outputs.first();
return @fieldParentPtr("link", next_link);
}
/// Get the previous output in the list, wrapping to last if at beginning
pub fn prevOutput(wm: *WindowManager, current: *Output) ?*Output {
const prev_link = current.link.prev orelse return wm.outputs.last();
// If we've reached the sentinel (head's link), wrap to last
if (prev_link == &wm.outputs.link) return wm.outputs.last();
return @fieldParentPtr("link", prev_link);
}
fn manage_start(wm: *WindowManager) void {
const river_window_manager_v1 = wm.river_window_manager_v1;
const context = wm.context;
// This gets used shortly, so it goes at the beginning
context.manage();
if (!context.initialized) {
// This code runs during initial startup and after config reloads.
@branchHint(.cold);
context.initialized = true;
const seat = wm.seats.first() orelse @panic("Failed to get seat");
const river_seat_v1 = seat.river_seat_v1;
// Tag bindings
for (context.config.tag_binds.items) |tag_bind| {
comptime var i: u8 = 1;
comptime var buffer: [1]u8 = undefined;
inline while (i <= 9) : (i += 1) {
const tags: u32 = 1 << (i - 1);
buffer[0] = i + '0';
const command: XkbBindings.Command = switch (tag_bind.command) {
.set_output_tags => .{ .set_output_tags = tags },
.set_window_tags => .{ .set_window_tags = tags },
.toggle_output_tags => .{ .toggle_output_tags = tags },
.toggle_window_tags => .{ .toggle_window_tags = tags },
else => unreachable,
};
context.xkb_bindings.addBinding(river_seat_v1, @field(xkbcommon.Keysym, &buffer), tag_bind.modifiers, command);
}
}
// Rest of the keybinds
for (context.config.keybinds.items) |keybind| {
// Keysyms should only be null in tag_binds (above)
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();
}
}
{
var it = wm.outputs.iterator(.forward);
while (it.next()) |output| {
output.manage();
}
}
{
var it = wm.seats.iterator(.forward);
while (it.next()) |seat| {
seat.manage();
}
}
river_window_manager_v1.manageFinish();
}
fn render_start(wm: *WindowManager) void {
const river_window_manager_v1 = wm.river_window_manager_v1;
{
var it = wm.seats.iterator(.forward);
while (it.next()) |seat| {
seat.render();
}
}
{
var it = wm.outputs.iterator(.forward);
while (it.next()) |output| {
output.render();
}
}
river_window_manager_v1.renderFinish();
}
fn windowManagerV1Listener(window_manager_v1: *river.WindowManagerV1, event: river.WindowManagerV1.Event, wm: *WindowManager) void {
assert(wm.river_window_manager_v1 == window_manager_v1);
const context = wm.context;
switch (event) {
.unavailable => {
fatal("Window manager unavailable (some other wm instance is running). Exiting", .{});
},
.manage_start => wm.manage_start(),
.render_start => wm.render_start(),
.output => |ev| {
const output = Output.create(context, ev.id) catch @panic("Out of memory");
wm.outputs.append(output);
// If there was already a seat, but no outputs, set this new output as focused
const seat = wm.seats.first() orelse return;
if (seat.focused_output == null and seat.pending_manage.output == null) {
seat.pending_manage.output = .{ .output = output };
}
// If there are orphan windows, send them to the new output
var it = wm.orphan_windows.iterator(.forward);
while (it.next()) |window| {
// We need to make sure to set up their new output
window.pending_manage.pending_output = .{ .output = output };
}
if (wm.orphan_windows.first()) |first| {
// 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| {
// TODO: Support multi-seat (maybe ?)
const river_seat_v1 = ev.id;
const river_seat_v1_version = river_seat_v1.getVersion();
if (river_seat_v1_version < MIN_RIVER_SEAT_V1_VERSION) {
@branchHint(.cold); // If we're in here, the program is exiting anyways
utils.versionNotSupported(river.SeatV1, river_seat_v1_version, MIN_RIVER_SEAT_V1_VERSION);
}
const seat = Seat.create(context, river_seat_v1) catch @panic("Out of memory");
wm.seats.append(seat);
// If there was already an output, but no seats, set the first output as focused
seat.pending_manage.output = .{ .output = wm.outputs.first() orelse return };
},
.window => |ev| {
// TODO: Support multiple seats
const seat = wm.seats.first() orelse @panic("Failed to get seat");
const focused_output = seat.focused_output;
const window_list = if (focused_output) |output|
&output.windows
else
&wm.orphan_windows;
const window = Window.create(context, ev.id, focused_output) catch @panic("Out of memory");
switch (context.config.attach_mode) {
.top => window_list.prepend(window),
.bottom => window_list.append(window),
}
seat.pending_manage.window = .{ .window = window };
seat.pending_manage.should_warp_pointer = true;
},
else => |ev| {
log.debug("unhandled event: {s}", .{@tagName(ev)});
},
}
}
const std = @import("std");
const assert = std.debug.assert;
const fatal = std.process.fatal;
const wayland = @import("wayland");
const wl = wayland.client.wl;
const river = wayland.client.river;
const xkbcommon = @import("xkbcommon");
const utils = @import("utils.zig");
const Context = @import("Context.zig");
const Output = @import("Output.zig");
const Seat = @import("Seat.zig");
const Window = @import("Window.zig");
const XkbBindings = @import("XkbBindings.zig");
const log = std.log.scoped(.WindowManager);