Add multi-output support

Primary ratio is per-output.

If an output is disconnected/disabled, its windows get sent to the
previous output in the output list. If all outputs are disconnected,
windows are added to an orphan list in the WM. Once an output is
re-added, the orphans are all given to that output.

When a window is sent to a new output, it keeps the same tags as it
had before. I may add an option to take the new output's tags.

- Rename focus_next/focus_prev to focus_next_window/focus_prev_window
- Add focus_next_output/focus_prev_output
- Add send_to_next_output/send_to_prev_output commands to move windows
    between outputs

Split Seat.PendingManage.PendingFocus into separate pending output
and pending window structs

Fix window outputs when closing outputs
This commit is contained in:
Ben Buhse 2026-02-03 17:17:29 -06:00
commit 0f85278aea
No known key found for this signature in database
GPG key ID: 7916ACFCD38FD0B4
8 changed files with 478 additions and 269 deletions

View file

@ -12,18 +12,9 @@ river_window_manager_v1: *river.WindowManagerV1,
seats: wl.list.Head(Seat, .link),
outputs: wl.list.Head(Output, .link),
windows: wl.list.Head(Window, .link),
window_count: u8 = 0,
primary_ratio: f32 = 0.55,
/// State consumed in manage phase, reset at end of manage_start().
pending_manage: PendingManage = .{},
pub const PendingManage = struct {
primary_ratio: ?f32 = null,
};
/// 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);
@ -34,12 +25,12 @@ pub fn create(context: *Context, window_manager_v1: *river.WindowManagerV1) !*Wi
.river_window_manager_v1 = window_manager_v1,
.seats = undefined, // we will initialize these shortly
.outputs = undefined,
.windows = undefined,
.orphan_windows = undefined,
};
wm.seats.init();
wm.outputs.init();
wm.windows.init();
wm.orphan_windows.init();
wm.river_window_manager_v1.setListener(*WindowManager, windowManagerV1Listener, wm);
@ -47,13 +38,6 @@ pub fn create(context: *Context, window_manager_v1: *river.WindowManagerV1) !*Wi
}
pub fn destroy(wm: *WindowManager) void {
{
var it = wm.windows.safeIterator(.forward);
while (it.next()) |window| {
window.link.remove();
window.destroy();
}
}
{
var it = wm.outputs.safeIterator(.forward);
while (it.next()) |output| {
@ -72,118 +56,23 @@ pub fn destroy(wm: *WindowManager) void {
utils.allocator.destroy(wm);
}
/// Get the next window in the list, wrapping to first if at end
pub fn getNextWindow(wm: *WindowManager, current: *Window) ?*Window {
var it = wm.windows.iterator(.forward);
while (it.next()) |window| {
if (window == current) {
return it.next() orelse wm.windows.first();
}
}
return wm.windows.first();
/// 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 window in the list, wrapping to last if at beginning
pub fn getPrevWindow(wm: *WindowManager, current: *Window) ?*Window {
var prev: ?*Window = null;
var it = wm.windows.iterator(.forward);
while (it.next()) |window| {
if (window == current) {
return prev orelse wm.windows.last();
}
prev = window;
}
return wm.windows.last();
}
/// Calculate primary/stack layout positions for all windows.
/// - Single window: maximized
/// - Multiple windows: stack (45% left, vertically tiled), primary (55% right)
fn calculatePrimaryStackLayout(wm: *WindowManager) void {
// TODO: Support multiple outputs
const output = wm.outputs.first() orelse return;
// Get a list of active windows
var active_list: DoublyLinkedList = .{};
var active_count: u31 = 0;
var it = wm.windows.iterator(.forward);
while (it.next()) |window| {
if (output.tags & window.tags != 0x0000) {
active_list.append(&window.active_list_node);
active_count += 1;
window.pending_render.show = true;
} else {
window.pending_render.show = false;
}
}
if (active_count == 0) return;
// Output dimensions come as i32 from the protocol, convert to u31 for window dimensions
// since they can't be negative.
const output_width: u31 = @intCast(output.width);
const output_height: u31 = @intCast(output.height);
const output_x = output.x;
const output_y = output.y;
// Iterate through the active windows and apply the tags
var i: u31 = 0;
while (active_list.popFirst()) |node| : (i += 1) {
const window: *Window = @fieldParentPtr("active_list_node", node);
if (active_count == 1) {
// Single window: maximize
window.pending_render.x = output_x;
window.pending_render.y = output_y;
window.pending_manage.width = output_width;
window.pending_manage.height = output_height;
window.pending_manage.maximized = true;
} else {
// Multiple windows: primary/stack layout
// TODO: Support multiple windows in primary stack
const primary_width: u31 = @intFromFloat(@as(f32, @floatFromInt(output_width)) * wm.primary_ratio);
const stack_width: u31 = output_width - primary_width;
const stack_count = active_count - 1;
const stack_height: u31 = @divFloor(output_height, stack_count);
window.pending_manage.maximized = false;
if (i == 0) {
// Primary window (first window) - right side
window.pending_render.x = output_x + @as(i32, stack_width);
window.pending_render.y = output_y;
window.pending_manage.width = primary_width;
window.pending_manage.height = output_height;
} else {
// Stack window(s) - left side
const stack_index = i - 1;
window.pending_render.x = output_x;
window.pending_render.y = output_y + @as(i32, stack_index) * @as(i32, stack_height);
window.pending_manage.width = stack_width;
// Last stack window gets remaining height to avoid gaps from integer division
if (i == active_count - 1) {
window.pending_manage.height = output_height - stack_index * stack_height;
} else {
window.pending_manage.height = stack_height;
}
}
}
// Make space for borders; this is the same for all windows.
// Borders are automatically disabled when a window is fullscreened so we don't
// have to worry about that.
const border_width = wm.context.config.border_width;
// We use .? because we know we set the windows height, width, x, and y above
window.pending_manage.height.? -= 2 * border_width;
window.pending_manage.width.? -= 2 * border_width;
window.pending_render.x.? += border_width;
window.pending_render.y.? += border_width;
}
// Make sure we went through the whole list
assert(active_list.first == null);
/// 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 {
defer wm.pending_manage = .{};
const river_window_manager_v1 = wm.river_window_manager_v1;
const context = wm.context;
@ -223,27 +112,12 @@ fn manage_start(wm: *WindowManager) void {
}
}
// Manage the WM itself before outputs/windows/seats
if (wm.pending_manage.primary_ratio) |primary_ratio| {
// Ratios outside of this range can cause crashes anyways (when doing the layout calulcation)
std.debug.assert(primary_ratio >= 0.10 and primary_ratio <= 0.90);
wm.primary_ratio = primary_ratio;
}
{
var it = wm.outputs.iterator(.forward);
while (it.next()) |output| {
output.manage();
}
}
{
// Calculate layout before managing windows
wm.calculatePrimaryStackLayout();
var it = wm.windows.iterator(.forward);
while (it.next()) |window| {
window.manage();
}
}
{
var it = wm.seats.iterator(.forward);
while (it.next()) |seat| {
@ -267,12 +141,6 @@ fn render_start(wm: *WindowManager) void {
output.render();
}
}
{
var it = wm.windows.iterator(.forward);
while (it.next()) |window| {
window.render();
}
}
river_window_manager_v1.renderFinish();
}
@ -286,10 +154,25 @@ fn windowManagerV1Listener(window_manager_v1: *river.WindowManagerV1, event: riv
.manage_start => wm.manage_start(),
.render_start => wm.render_start(),
.output => |ev| {
log.debug("initializing new wl_output: {d}", .{ev.id.getId()});
// TODO: Support multi-output
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;
}
output.windows.appendList(&wm.orphan_windows);
},
.seat => |ev| {
// TODO: Support multi-seat (maybe ?)
@ -302,22 +185,26 @@ fn windowManagerV1Listener(window_manager_v1: *river.WindowManagerV1, event: riv
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");
// TODO: Support multiple outputs
const output = wm.outputs.first() orelse @panic("Failed to get output");
const window = Window.create(context, ev.id, output) catch @panic("Out of memory");
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 => wm.windows.prepend(window),
.bottom => wm.windows.append(window),
.top => window_list.prepend(window),
.bottom => window_list.append(window),
}
seat.pending_manage.pending_focus = .{ .window = window };
seat.pending_manage.window = .{ .window = window };
seat.pending_manage.should_warp_pointer = true;
wm.window_count += 1;
},
else => |ev| {
log.debug("unhandled event: {s}", .{@tagName(ev)});
@ -328,7 +215,6 @@ fn windowManagerV1Listener(window_manager_v1: *river.WindowManagerV1, event: riv
const std = @import("std");
const assert = std.debug.assert;
const fatal = std.process.fatal;
const DoublyLinkedList = std.DoublyLinkedList;
const wayland = @import("wayland");
const wl = wayland.client.wl;