git.lucas.co / cce-compositor
Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git

commit4f71a256da1fdd191cae69b8cf7fdb17c10abc46
parente576d48bfb
authorIsaac Freund <[email protected]>
date2024-12-20 20:55
Output: rework for rwm protocol

All state is now applied through the transaction system and modesets are
done backend-wide.

 build.zig.zon                           |   4 +-
 protocol/river-window-management-v1.xml |   4 +
 river/InputConfig.zig                   |   8 +-
 river/LockManager.zig                   |   4 +-
 river/LockSurface.zig                   |   2 +-
 river/Output.zig                        | 356 +++++++++++++++----------
 river/Root.zig                          | 445 +++++++++++++++++++-------------
 river/Window.zig                        |   2 +-
 river/WindowManager.zig                 |  67 ++++-
 9 files changed, 564 insertions(+), 328 deletions(-)

diff --git a/build.zig.zon b/build.zig.zon
index 892558c..9237e55 100644
--- a/build.zig.zon
+++ b/build.zig.zon
@@ -12,8 +12,8 @@
             .hash = "12206015b1750462788de094b8154eab733a36523639124ace437d4631ca01b5ba18",
         },
         .@"zig-wlroots" = .{
-            .url = "https://codeberg.org/ifreund/zig-wlroots/archive/ae6151f22ceb4ccd7efb1291dea573785918a7ec.tar.gz",
-            .hash = "12204d99aebfbf88f1ff3ab197362937b3d4bef4f45fde9c4ee0d569e095a2a25889",
+            .url = "https://codeberg.org/ifreund/zig-wlroots/archive/a2372a124d9b8568775849a2ea60da36c4f5b0dd.tar.gz",
+            .hash = "122019ca283db70a0f4932e54b5195e2392b9220c7f97808d0e74c14be30aea4a458",
         },
         .@"zig-xkbcommon" = .{
             .url = "https://codeberg.org/ifreund/zig-xkbcommon/archive/v0.2.0.tar.gz",
diff --git a/protocol/river-window-management-v1.xml b/protocol/river-window-management-v1.xml
index b3f0ea6..af697b0 100644
--- a/protocol/river-window-management-v1.xml
+++ b/protocol/river-window-management-v1.xml
@@ -165,6 +165,10 @@
       <description summary="new logical output">
         A new logical output has been created, perhaps due to a new physical
         monitor being plugged in or perhaps due to a change in configuration.
+
+        This event is double-buffered state and will be followed by
+        river_output_v1.position and dimensions events as well as a
+        river_window_manager_v1.update event.
       </description>
       <arg name="id" type="new_id" interface="river_output_v1"/>
     </event>
diff --git a/river/InputConfig.zig b/river/InputConfig.zig
index 39fc47d..a0288cf 100644
--- a/river/InputConfig.zig
+++ b/river/InputConfig.zig
@@ -221,10 +221,10 @@ pub const MapToOutput = struct {
     fn apply(map_to_output: MapToOutput, device: *InputDevice) void {
         const wlr_output = blk: {
             if (map_to_output.output_name) |name| {
-                var it = server.root.active_outputs.iterator(.forward);
-                while (it.next()) |output| {
-                    if (mem.eql(u8, mem.span(output.wlr_output.name), name)) {
-                        break :blk output.wlr_output;
+                var it = server.root.output_layout.outputs.iterator(.forward);
+                while (it.next()) |layout_output| {
+                    if (mem.eql(u8, mem.span(layout_output.output.name), name)) {
+                        break :blk layout_output.output;
                     }
                 }
             }
diff --git a/river/LockManager.zig b/river/LockManager.zig
index 5c50b10..c52e8b0 100644
--- a/river/LockManager.zig
+++ b/river/LockManager.zig
@@ -147,8 +147,10 @@ pub fn maybeLock(manager: *LockManager) void {
     var all_outputs_blanked = true;
     var all_outputs_rendered_lock_surface = true;
     {
-        var it = server.root.active_outputs.iterator(.forward);
+        var it = server.root.outputs.iterator(.forward);
         while (it.next()) |output| {
+            if (!output.wlr_output.?.enabled) continue;
+
             switch (output.lock_render_state) {
                 .pending_unlock, .unlocked, .pending_blank, .pending_lock_surface => {
                     all_outputs_blanked = false;
diff --git a/river/LockSurface.zig b/river/LockSurface.zig
index fb6e14c..af355b3 100644
--- a/river/LockSurface.zig
+++ b/river/LockSurface.zig
@@ -97,7 +97,7 @@ pub fn getOutput(lock_surface: *LockSurface) *Output {
 pub fn configure(lock_surface: *LockSurface) void {
     var output_width: i32 = undefined;
     var output_height: i32 = undefined;
-    lock_surface.getOutput().wlr_output.effectiveResolution(&output_width, &output_height);
+    lock_surface.wlr_lock_surface.output.effectiveResolution(&output_width, &output_height);
     _ = lock_surface.wlr_lock_surface.configure(@intCast(output_width), @intCast(output_height));
 }
 
diff --git a/river/Output.zig b/river/Output.zig
index a34c1fe..a99eab2 100644
--- a/river/Output.zig
+++ b/river/Output.zig
@@ -1,6 +1,6 @@
 // This file is part of river, a dynamic tiling wayland compositor.
 //
-// Copyright 2020 The River Developers
+// Copyright 2020-2024 The River Developers
 //
 // This program is free software: you can redistribute it and/or modify
 // it under the terms of the GNU General Public License as published by
@@ -26,6 +26,7 @@ const wlr = @import("wlroots");
 const wayland = @import("wayland");
 const wl = wayland.server.wl;
 const zwlr = wayland.server.zwlr;
+const river = wayland.server.river;
 
 const server = &@import("main.zig").server;
 const util = @import("util.zig");
@@ -37,19 +38,102 @@ const Config = @import("Config.zig");
 
 const log = std.log.scoped(.output);
 
-wlr_output: *wlr.Output,
-scene_output: *wlr.SceneOutput,
+pub const State = struct {
+    state: enum {
+        /// Powered on and exposed to the window manager
+        enabled,
+        /// Powered off and exposed to the window manager
+        disabled_soft,
+        /// Powered off and hidden from the window manager
+        disabled_hard,
+        /// Corresponding hardware no longer present
+        destroying,
+    } = .disabled_hard,
+    /// Logical coordinate space
+    x: i32 = 0,
+    /// Logical coordinate space
+    y: i32 = 0,
+    /// The width/height of modes is in physical pixels, not in the
+    /// compositors logical coordinate space.
+    mode: union(enum) {
+        standard: *wlr.Output.Mode,
+        custom: struct {
+            width: i32 = 0,
+            height: i32 = 0,
+            refresh: i32 = 0,
+        },
+        /// Used before the initial modeset and after the wlr_output is destroyed.
+        none,
+    } = .none,
+    scale: f32 = 1,
+    transform: wl.Output.Transform = .normal,
+    adaptive_sync: bool = false,
+    auto_layout: bool = true,
+
+    /// Width in the logical coordinate space
+    pub fn width(state: *const State) i32 {
+        const physical: f32 = blk: {
+            if (@mod(@intFromEnum(state.transform), 2) == 0) {
+                break :blk @floatFromInt(switch (state.mode) {
+                    .standard => |mode| mode.width,
+                    .custom => |mode| mode.width,
+                    .none => 0,
+                });
+            } else {
+                break :blk @floatFromInt(switch (state.mode) {
+                    .standard => |mode| mode.height,
+                    .custom => |mode| mode.height,
+                    .none => 0,
+                });
+            }
+        };
+        return @intFromFloat(physical / state.scale);
+    }
+
+    /// Height in the logical coordinate space
+    pub fn height(state: *const State) i32 {
+        const physical: f32 = blk: {
+            if (@mod(@intFromEnum(state.transform), 2) == 0) {
+                break :blk @floatFromInt(switch (state.mode) {
+                    .standard => |mode| mode.height,
+                    .custom => |mode| mode.height,
+                    .none => 0,
+                });
+            } else {
+                break :blk @floatFromInt(switch (state.mode) {
+                    .standard => |mode| mode.width,
+                    .custom => |mode| mode.width,
+                    .none => 0,
+                });
+            }
+        };
+        return @intFromFloat(physical / state.scale);
+    }
+
+    pub fn apply(state: *const State, wlr_state: *wlr.Output.State) void {
+        wlr_state.setEnabled(state.state == .enabled);
+        switch (state.mode) {
+            .standard => |mode| wlr_state.setMode(mode),
+            .custom => |mode| wlr_state.setCustomMode(mode.width, mode.height, mode.refresh),
+            .none => {},
+        }
+        wlr_state.setScale(state.scale);
+        wlr_state.setTransform(state.transform);
+        wlr_state.setAdaptiveSyncEnabled(state.adaptive_sync);
+    }
+};
 
-/// For Root.all_outputs
-all_link: wl.list.Link,
+/// Set to null when the wlr_output is destroyed.
+wlr_output: ?*wlr.Output,
+scene_output: ?*wlr.SceneOutput,
 
-/// For Root.active_outputs
-active_link: wl.list.Link,
+object: ?*river.OutputV1 = null,
 
 /// Tracks the currently presented frame on the output as it pertains to ext-session-lock.
 /// The output is initially considered blanked:
 /// If using the DRM backend it will be blanked with the initial modeset.
 /// If using the Wayland or X11 backend nothing will be visible until the first frame is rendered.
+/// XXX set this to blanked on enabled->disabled transition
 lock_render_state: enum {
     /// Submitted an unlocked buffer but the buffer has not yet been presented.
     pending_unlock,
@@ -71,6 +155,18 @@ lock_render_state: enum {
 /// This request is handled while rendering the next frame in handleFrame().
 gamma_dirty: bool = false,
 
+/// Root.outputs
+link: wl.list.Link,
+
+/// Pending state to be sent to the wm in the next update sequence.
+pending: State = .{},
+link_pending: wl.list.Link,
+/// State sent to the wm in the latest update sequence.
+sent: State = .{},
+link_sent: wl.list.Link,
+/// State applied to the wlr_output and rendered.
+current: State = .{},
+
 destroy: wl.Listener(*wlr.Output) = wl.Listener(*wlr.Output).init(handleDestroy),
 request_state: wl.Listener(*wlr.Output.event.RequestState) = wl.Listener(*wlr.Output.event.RequestState).init(handleRequestState),
 frame: wl.Listener(*wlr.Output) = wl.Listener(*wlr.Output).init(handleFrame),
@@ -80,181 +176,189 @@ pub fn create(wlr_output: *wlr.Output) !void {
     const output = try util.gpa.create(Output);
     errdefer util.gpa.destroy(output);
 
-    if (!wlr_output.initRender(server.allocator, server.renderer)) return error.InitRenderFailed;
-
-    // If no standard mode for the output works we can't enable the output automatically.
-    // It will stay disabled unless the user configures a custom mode which works.
-    //
-    // For the Wayland backend, the list of modes will be empty and it is possible to
-    // enable the output without setting a mode.
     {
-        var state = wlr.Output.State.init();
-        defer state.finish();
-
-        state.setEnabled(true);
-
-        if (wlr_output.preferredMode()) |preferred_mode| {
-            state.setMode(preferred_mode);
-        }
-
-        if (!wlr_output.commitState(&state)) {
-            log.err("initial output commit with preferred mode failed, trying all modes", .{});
-
-            // It is important to try other modes if the preferred mode fails
-            // which is reported to be helpful in practice with e.g. multiple
-            // high resolution monitors connected through a usb dock.
-            var it = wlr_output.modes.iterator(.forward);
-            while (it.next()) |mode| {
-                state.setMode(mode);
-                if (wlr_output.commitState(&state)) {
-                    log.info("initial output commit succeeded with mode {}x{}@{}mHz", .{
-                        mode.width,
-                        mode.height,
-                        mode.refresh,
-                    });
-                    break;
-                } else {
-                    log.err("initial output commit failed with mode {}x{}@{}mHz", .{
-                        mode.width,
-                        mode.height,
-                        mode.refresh,
-                    });
-                }
-            }
+        const title = try fmt.allocPrintZ(util.gpa, "river - {s}", .{wlr_output.name});
+        defer util.gpa.free(title);
+        if (wlr_output.isWl()) {
+            wlr_output.wlSetTitle(title);
+        } else if (wlr.config.has_x11_backend and wlr_output.isX11()) {
+            wlr_output.x11SetTitle(title);
         }
     }
 
-    var width: c_int = undefined;
-    var height: c_int = undefined;
-    wlr_output.effectiveResolution(&width, &height);
+    if (!wlr_output.initRender(server.allocator, server.renderer)) return error.InitRenderFailed;
 
     const scene_output = try server.root.scene.createSceneOutput(wlr_output);
 
+    errdefer comptime unreachable;
+
     output.* = .{
         .wlr_output = wlr_output,
         .scene_output = scene_output,
-        .all_link = undefined,
-        .active_link = undefined,
+        .link = undefined,
+        .link_pending = undefined,
+        .link_sent = undefined,
     };
     wlr_output.data = @intFromPtr(output);
 
+    server.root.outputs.append(output);
+    server.wm.pending.outputs.append(output);
+    output.link_sent.init();
+
     wlr_output.events.destroy.add(&output.destroy);
     wlr_output.events.request_state.add(&output.request_state);
     wlr_output.events.frame.add(&output.frame);
     wlr_output.events.present.add(&output.present);
 
-    output.setTitle();
-
-    output.active_link.init();
-    server.root.all_outputs.append(output);
+    output.pending.state = .enabled;
+    if (wlr_output.preferredMode()) |preferred_mode| {
+        output.pending.mode = .{ .standard = preferred_mode };
+    }
 
-    output.handleEnableDisable();
+    server.wm.dirtyPending();
 }
 
-fn handleDestroy(listener: *wl.Listener(*wlr.Output), _: *wlr.Output) void {
+fn handleDestroy(listener: *wl.Listener(*wlr.Output), wlr_output: *wlr.Output) void {
     const output: *Output = @fieldParentPtr("destroy", listener);
 
-    log.debug("output '{s}' destroyed", .{output.wlr_output.name});
-
-    // Remove the destroyed output from root if it wasn't already removed
-    server.root.deactivateOutput(output);
+    log.debug("output '{s}' destroyed", .{wlr_output.name});
 
-    output.all_link.remove();
+    output.link.remove();
 
     output.destroy.link.remove();
     output.request_state.link.remove();
     output.frame.link.remove();
     output.present.link.remove();
 
-    output.wlr_output.data = 0;
+    wlr_output.data = 0;
 
-    util.gpa.destroy(output);
-
-    server.root.handleOutputConfigChange() catch std.log.err("out of memory", .{});
+    output.wlr_output = null;
+    output.scene_output = null;
+    output.pending.state = .destroying;
 
     server.wm.dirtyPending();
 }
 
-fn handleRequestState(listener: *wl.Listener(*wlr.Output.event.RequestState), event: *wlr.Output.event.RequestState) void {
-    const output: *Output = @fieldParentPtr("request_state", listener);
+pub fn sendDirty(output: *Output) !void {
+    switch (output.pending.state) {
+        .enabled, .disabled_soft => {
+            const wm_v1 = server.wm.object.?;
+            const new = output.object == null;
+            const output_v1 = output.object orelse blk: {
+                const output_v1 = try river.OutputV1.create(wm_v1.getClient(), wm_v1.getVersion(), 0);
+                output.object = output_v1;
+
+                output_v1.setHandler(*Output, handleRequest, null, output);
+                wm_v1.sendOutput(output_v1);
+                output.link_sent.remove();
+                server.wm.sent.outputs.append(output);
+
+                break :blk output_v1;
+            };
+            errdefer comptime unreachable;
 
-    output.applyState(event.state) catch {
-        log.err("failed to commit requested state", .{});
-        return;
-    };
+            const pending = &output.pending;
+            const sent = &output.sent;
 
-    server.wm.dirtyPending();
-}
+            if (new or pending.width() != sent.width() or pending.height() != sent.height()) {
+                output_v1.sendDimensions(pending.width(), pending.height());
+            }
+            if (new or pending.x != sent.x or pending.y != sent.y) {
+                output_v1.sendPosition(pending.x, pending.y);
+            }
 
-// TODO double buffer output state changes for frame perfection and cleaner code.
-// Schedule a frame and commit in the frame handler.
-// Get rid of this function.
-pub fn applyState(output: *Output, state: *wlr.Output.State) error{CommitFailed}!void {
+            output.sent = output.pending;
+        },
+        .disabled_hard, .destroying => {
+            if (output.object) |output_v1| {
+                output_v1.sendRemoved();
+                output_v1.setHandler(?*anyopaque, handleRequestInert, null, null);
+                output.object = null;
+            }
 
-    // We need to be precise about this state change to make assertions
-    // in updateLockRenderStateOnEnableDisable() possible.
-    const enable_state_change = state.committed.enabled and
-        (state.enabled != output.wlr_output.enabled);
+            output.link_pending.remove();
+            output.link_sent.remove();
+            output.link_pending.init();
+            output.link_sent.init();
 
-    if (!output.wlr_output.commitState(state)) {
-        return error.CommitFailed;
+            if (output.pending.state == .destroying) {
+                util.gpa.destroy(output);
+            }
+        },
     }
+}
 
-    if (enable_state_change) {
-        output.handleEnableDisable();
-    }
+fn handleRequestInert(
+    output_v1: *river.OutputV1,
+    request: river.OutputV1.Request,
+    _: ?*anyopaque,
+) void {
+    if (request == .destroy) output_v1.destroy();
+}
 
-    if (state.committed.mode) {
-        if (server.lock_manager.lockSurfaceFromOutput(output)) |s| s.configure();
+fn handleRequest(
+    _: *river.OutputV1,
+    request: river.OutputV1.Request,
+    _: ?*Output,
+) void {
+    switch (request) {
+        .destroy => {}, // XXX send protocol error
     }
 }
 
-fn handleEnableDisable(output: *Output) void {
-    output.updateLockRenderStateOnEnableDisable();
-    output.gamma_dirty = true;
+fn handleRequestState(listener: *wl.Listener(*wlr.Output.event.RequestState), event: *wlr.Output.event.RequestState) void {
+    const output: *Output = @fieldParentPtr("request_state", listener);
 
-    if (output.wlr_output.enabled) {
-        // Add the output to root.active_outputs and the output layout if it has not
-        // already been added.
-        server.root.activateOutput(output);
-    } else {
-        server.root.deactivateOutput(output);
+    // The only state currently requested by a wlroots backend is a
+    // custom mode as the Wayland/X11 backend window is resized.
+    const committed: u32 = @bitCast(event.state.committed);
+    const supported: u32 = @bitCast(wlr.Output.State.Fields{ .mode = true });
+
+    if (committed != supported) {
+        log.err("backend requested unsupported state {}", .{committed});
+        return;
     }
-}
 
-pub fn updateLockRenderStateOnEnableDisable(output: *Output) void {
-    if (output.wlr_output.enabled) {
-        assert(output.lock_render_state == .blanked);
+    if (event.state.mode) |mode| {
+        output.pending.mode = .{ .standard = mode };
     } else {
-        // Disabling and re-enabling an output always blanks it.
-        output.lock_render_state = .blanked;
+        output.pending.mode = .{ .custom = .{
+            .width = event.state.custom_mode.width,
+            .height = event.state.custom_mode.height,
+            .refresh = event.state.custom_mode.refresh,
+        } };
     }
+
+    server.wm.dirtyPending();
 }
 
-fn handleFrame(listener: *wl.Listener(*wlr.Output), _: *wlr.Output) void {
+fn handleFrame(listener: *wl.Listener(*wlr.Output), wlr_output: *wlr.Output) void {
     const output: *Output = @fieldParentPtr("frame", listener);
-    const scene_output = server.root.scene.getSceneOutput(output.wlr_output).?;
 
     // TODO this should probably be retried on failure
-    output.renderAndCommit(scene_output) catch |err| switch (err) {
+    output.renderAndCommit() catch |err| switch (err) {
         error.OutOfMemory => log.err("out of memory", .{}),
-        error.CommitFailed => log.err("output commit failed for {s}", .{output.wlr_output.name}),
+        error.CommitFailed => log.err("output commit failed for {s}", .{wlr_output.name}),
     };
 
     var now: posix.timespec = undefined;
     posix.clock_gettime(posix.CLOCK.MONOTONIC, &now) catch @panic("CLOCK_MONOTONIC not supported");
-    scene_output.sendFrameDone(&now);
+    output.scene_output.?.sendFrameDone(&now);
 }
 
-fn renderAndCommit(output: *Output, scene_output: *wlr.SceneOutput) !void {
-    if (output.gamma_dirty) {
-        var state = wlr.Output.State.init();
-        defer state.finish();
+fn renderAndCommit(output: *Output) !void {
+    const wlr_output = output.wlr_output.?;
+
+    var state = wlr.Output.State.init();
+    defer state.finish();
+
+    output.current.apply(&state);
 
-        const control = server.root.gamma_control_manager.getControl(output.wlr_output);
+    if (output.gamma_dirty) {
+        const control = server.root.gamma_control_manager.getControl(wlr_output);
         if (!wlr.GammaControlV1.apply(control, &state)) return error.OutOfMemory;
 
-        if (!output.wlr_output.testState(&state)) {
+        if (!wlr_output.testState(&state)) {
             wlr.GammaControlV1.sendFailedAndDestroy(control);
             state.clearGammaLut();
             // If the backend does not support gamma LUTs it will reject any
@@ -262,15 +366,13 @@ fn renderAndCommit(output: *Output, scene_output: *wlr.SceneOutput) !void {
             // has a null LUT. The wayland backend for example has this behavior.
             state.committed.gamma_lut = false;
         }
+    }
 
-        if (!scene_output.buildState(&state, null)) return error.CommitFailed;
+    if (!output.scene_output.?.buildState(&state, null)) return error.CommitFailed;
 
-        if (!output.wlr_output.commitState(&state)) return error.CommitFailed;
+    if (!wlr_output.commitState(&state)) return error.CommitFailed;
 
-        output.gamma_dirty = false;
-    } else {
-        if (!scene_output.commit(null)) return error.CommitFailed;
-    }
+    output.gamma_dirty = false;
 
     const lock_surface_mapped = blk: {
         if (server.lock_manager.lockSurfaceFromOutput(output)) |lock_surface| {
@@ -341,13 +443,3 @@ fn handlePresent(
         .blanked, .lock_surface => {},
     }
 }
-
-fn setTitle(output: Output) void {
-    const title = fmt.allocPrintZ(util.gpa, "river - {s}", .{output.wlr_output.name}) catch return;
-    defer util.gpa.free(title);
-    if (output.wlr_output.isWl()) {
-        output.wlr_output.wlSetTitle(title);
-    } else if (wlr.config.has_x11_backend and output.wlr_output.isX11()) {
-        output.wlr_output.x11SetTitle(title);
-    }
-}
diff --git a/river/Root.zig b/river/Root.zig
index c892ece..914a046 100644
--- a/river/Root.zig
+++ b/river/Root.zig
@@ -71,7 +71,6 @@ layers: struct {
 new_output: wl.Listener(*wlr.Output) = wl.Listener(*wlr.Output).init(handleNewOutput),
 
 output_layout: *wlr.OutputLayout,
-layout_change: wl.Listener(*wlr.OutputLayout) = wl.Listener(*wlr.OutputLayout).init(handleLayoutChange),
 
 presentation: *wlr.Presentation,
 xdg_output_manager: *wlr.XdgOutputManagerV1,
@@ -90,12 +89,8 @@ gamma_control_manager: *wlr.GammaControlManagerV1,
 gamma_control_set_gamma: wl.Listener(*wlr.GammaControlManagerV1.event.SetGamma) =
     wl.Listener(*wlr.GammaControlManagerV1.event.SetGamma).init(handleSetGamma),
 
-/// A list of all outputs
-all_outputs: wl.list.Head(Output, .all_link),
-
-/// A list of all active outputs (any one that can be interacted with, even if
-/// it's turned off by dpms)
-active_outputs: wl.list.Head(Output, .active_link),
+/// All Outputs that have a corresponding wlr_output.
+outputs: wl.list.Head(Output, .link),
 
 pub fn init(root: *Root) !void {
     const output_layout = try wlr.OutputLayout.create(server.wl_server);
@@ -129,8 +124,7 @@ pub fn init(root: *Root) !void {
             .override_redirect = if (build_options.xwayland) try normal_tree.createSceneTree(),
         },
         .output_layout = output_layout,
-        .all_outputs = undefined,
-        .active_outputs = undefined,
+        .outputs = undefined,
 
         .presentation = try wlr.Presentation.create(server.wl_server, server.backend),
         .xdg_output_manager = try wlr.XdgOutputManagerV1.create(server.wl_server, output_layout),
@@ -139,13 +133,11 @@ pub fn init(root: *Root) !void {
         .gamma_control_manager = try wlr.GammaControlManagerV1.create(server.wl_server),
     };
 
-    root.all_outputs.init();
-    root.active_outputs.init();
+    root.outputs.init();
 
     server.backend.events.new_output.add(&root.new_output);
     root.output_manager.events.apply.add(&root.manager_apply);
     root.output_manager.events.@"test".add(&root.manager_test);
-    root.output_layout.events.change.add(&root.layout_change);
     root.power_manager.events.set_mode.add(&root.power_manager_set_mode);
     root.gamma_control_manager.events.set_gamma.add(&root.gamma_control_set_gamma);
 }
@@ -192,16 +184,6 @@ pub fn at(root: Root, lx: f64, ly: f64) ?AtResult {
     }
 }
 
-pub fn layerSurfaceTree(root: Root, layer: zwlr.LayerShellV1.Layer) *wlr.SceneTree {
-    const trees = [_]*wlr.SceneTree{
-        root.layers.background,
-        root.layers.bottom,
-        root.layers.top,
-        root.layers.overlay,
-    };
-    return trees[@intCast(@intFromEnum(layer))];
-}
-
 fn handleNewOutput(_: *wl.Listener(*wlr.Output), wlr_output: *wlr.Output) void {
     const log = std.log.scoped(.output_manager);
 
@@ -215,137 +197,86 @@ fn handleNewOutput(_: *wl.Listener(*wlr.Output), wlr_output: *wlr.Output) void {
         wlr_output.destroy();
         return;
     };
-
-    server.root.handleOutputConfigChange() catch log.err("out of memory", .{});
-
-    server.input_manager.reconfigureDevices();
 }
 
-/// Remove the output from root.active_outputs and the output layout.
-/// Evacuate windows if necessary.
-pub fn deactivateOutput(root: *Root, output: *Output) void {
-    {
-        // If the output has already been removed, do nothing
-        var it = root.active_outputs.iterator(.forward);
-        while (it.next()) |o| {
-            if (o == output) break;
-        } else return;
-    }
-
-    root.output_layout.remove(output.wlr_output);
-
-    output.active_link.remove();
-    output.active_link.init();
-
-    // XXX Close all layer surfaces on the removed output
-
-    // We must call reconfigureDevices here to unmap devices that might be mapped to this output
-    // in order to prevent a segfault in wlroots.
-    server.input_manager.reconfigureDevices();
-}
+fn handleManagerTest(_: *wl.Listener(*wlr.OutputConfigurationV1), config: *wlr.OutputConfigurationV1) void {
+    defer config.destroy();
 
-/// Add the output to root.active_outputs and the output layout if it has not
-/// already been added.
-pub fn activateOutput(root: *Root, output: *Output) void {
-    {
-        // If we have already added the output, do nothing and return
-        var it = root.active_outputs.iterator(.forward);
-        while (it.next()) |o| if (o == output) return;
+    if (!validateConfigCoordinates(config)) {
+        config.sendFailed();
+        return;
     }
 
-    root.active_outputs.append(output);
-
-    // This arranges outputs from left-to-right in the order they appear. The
-    // wlr-output-management protocol may be used to modify this arrangement.
-    // This also creates a wl_output global which is advertised to clients.
-    _ = root.output_layout.addAuto(output.wlr_output) catch {
-        // This would currently be very awkward to handle well and this output
-        // handling code needs to be heavily refactored soon anyways for double
-        // buffered state application as part of the transaction system.
-        // In any case, wlroots 0.16 would have crashed here, the error is only
-        // possible to handle after updating to 0.17.
-        @panic("TODO handle allocation failure here");
+    const states = config.buildState() catch {
+        std.log.err("out of memory", .{});
+        config.sendFailed();
+        return;
     };
-}
-
-// We need this listener to deal with outputs that have their position auto-configured
-// by the wlr_output_layout.
-fn handleLayoutChange(listener: *wl.Listener(*wlr.OutputLayout), _: *wlr.OutputLayout) void {
-    const root: *Root = @fieldParentPtr("layout_change", listener);
-
-    root.handleOutputConfigChange() catch std.log.err("out of memory", .{});
-}
+    defer std.c.free(states.ptr);
 
-/// Sync up the output scene node state with the output_layout and
-/// send the current output configuration to all wlr-output-manager clients.
-pub fn handleOutputConfigChange(root: *Root) !void {
-    const config = try wlr.OutputConfigurationV1.create();
-    // this destroys all associated config heads as well
-    errdefer config.destroy();
+    var swapchain_manager: wlr.OutputSwapchainManager = undefined;
+    swapchain_manager.init(server.backend);
+    defer swapchain_manager.finish();
 
-    var it = root.all_outputs.iterator(.forward);
-    while (it.next()) |output| {
-        // If the output is not part of the layout (and thus disabled)
-        // the box will be zeroed out.
-        var box: wlr.Box = undefined;
-        root.output_layout.getBox(output.wlr_output, &box);
-
-        // XXX
-        //output.tree.node.setEnabled(!box.empty());
-        //output.tree.node.setPosition(box.x, box.y);
-        //output.scene_output.setPosition(box.x, box.y);
-
-        const head = try wlr.OutputConfigurationV1.Head.create(config, output.wlr_output);
-        head.state.x = box.x;
-        head.state.y = box.y;
+    if (swapchain_manager.prepare(states)) {
+        config.sendSucceeded();
+    } else {
+        config.sendFailed();
     }
-
-    root.output_manager.setConfiguration(config);
 }
 
-fn handleManagerApply(
-    listener: *wl.Listener(*wlr.OutputConfigurationV1),
-    config: *wlr.OutputConfigurationV1,
-) void {
-    const root: *Root = @fieldParentPtr("manager_apply", listener);
-    defer config.destroy();
-
+fn handleManagerApply(_: *wl.Listener(*wlr.OutputConfigurationV1), config: *wlr.OutputConfigurationV1) void {
     std.log.scoped(.output_manager).info("applying output configuration", .{});
 
-    root.processOutputConfig(config, .apply);
-
-    root.handleOutputConfigChange() catch std.log.err("out of memory", .{});
-}
+    if (!validateConfigCoordinates(config)) {
+        config.sendFailed();
+        return;
+    }
 
-fn handleManagerTest(
-    listener: *wl.Listener(*wlr.OutputConfigurationV1),
-    config: *wlr.OutputConfigurationV1,
-) void {
-    const root: *Root = @fieldParentPtr("manager_test", listener);
-    defer config.destroy();
+    var it = config.heads.iterator(.forward);
+    while (it.next()) |head| {
+        const output: *Output = @ptrFromInt(head.state.output.data);
+
+        const prev_state = output.pending.state;
+
+        output.pending = .{
+            .state = if (head.state.enabled) .enabled else .disabled_hard,
+            .mode = blk: {
+                if (head.state.mode) |mode| {
+                    break :blk .{ .standard = mode };
+                } else {
+                    break :blk .{ .custom = .{
+                        .width = head.state.custom_mode.width,
+                        .height = head.state.custom_mode.height,
+                        .refresh = head.state.custom_mode.refresh,
+                    } };
+                }
+            },
+            .x = head.state.x,
+            .y = head.state.y,
+            .transform = head.state.transform,
+            .adaptive_sync = head.state.adaptive_sync_enabled,
+            .auto_layout = false,
+        };
 
-    root.processOutputConfig(config, .test_only);
-}
+        if (output.pending.state == .enabled and prev_state != .enabled) {
+            output.link_pending.remove();
+            server.wm.pending.outputs.append(output);
+        }
+    }
 
-fn processOutputConfig(
-    root: *Root,
-    config: *wlr.OutputConfigurationV1,
-    action: enum { test_only, apply },
-) void {
-    // Ignore layout change events this function generates while applying the config
-    root.layout_change.link.remove();
-    defer root.output_layout.events.change.add(&root.layout_change);
+    if (server.wm.pending.output_config) |old| {
+        old.sendFailed();
+        old.destroy();
+    }
+    server.wm.pending.output_config = config;
 
-    var success = true;
+    server.wm.dirtyPending();
+}
 
+fn validateConfigCoordinates(config: *wlr.OutputConfigurationV1) bool {
     var it = config.heads.iterator(.forward);
     while (it.next()) |head| {
-        const wlr_output = head.state.output;
-        const output: *Output = @ptrFromInt(wlr_output.data);
-
-        var proposed_state = wlr.Output.State.init();
-        head.state.apply(&proposed_state);
-
         // Negative output coordinates currently cause Xwayland clients to not receive click events.
         // See: https://gitlab.freedesktop.org/xorg/xserver/-/issues/899
         if (build_options.xwayland and server.xwayland != null and
@@ -354,38 +285,11 @@ fn processOutputConfig(
             std.log.scoped(.output_manager).err(
                 \\Attempted to set negative coordinates for output {s}.
                 \\Negative output coordinates are disallowed if Xwayland is enabled due to a limitation of Xwayland.
-            , .{output.wlr_output.name});
-            success = false;
-            continue;
-        }
-
-        switch (action) {
-            .test_only => {
-                if (!wlr_output.testState(&proposed_state)) success = false;
-            },
-            .apply => {
-                output.applyState(&proposed_state) catch {
-                    std.log.scoped(.output_manager).err("failed to apply config to output {s}", .{
-                        output.wlr_output.name,
-                    });
-                    success = false;
-                };
-                if (output.wlr_output.enabled) {
-                    // applyState() will always add the output to the layout on success, which means
-                    // that this function cannot fail as it does not need to allocate a new layout output.
-                    _ = root.output_layout.add(output.wlr_output, head.state.x, head.state.y) catch unreachable;
-                }
-            },
+            , .{head.state.output.name});
+            return false;
         }
     }
-
-    if (action == .apply) server.wm.dirtyPending();
-
-    if (success) {
-        config.sendSucceeded();
-    } else {
-        config.sendFailed();
-    }
+    return true;
 }
 
 fn handlePowerManagerSetMode(
@@ -400,30 +304,17 @@ fn handlePowerManagerSetMode(
         event.output.name,
     });
 
-    const requested = event.mode == .on;
-
-    if (output.wlr_output.enabled == requested) {
-        std.log.debug("output {s} dpms is already {s}, ignoring request", .{
-            event.output.name,
-            @tagName(event.mode),
-        });
-        return;
-    }
-
-    {
-        var state = wlr.Output.State.init();
-        defer state.finish();
-
-        state.setEnabled(requested);
-
-        if (!output.wlr_output.commitState(&state)) {
-            std.log.scoped(.server).err("output commit failed for {s}", .{output.wlr_output.name});
-            return;
-        }
+    switch (output.pending.state) {
+        .enabled => {
+            if (event.mode == .off) output.pending.state = .disabled_soft else return;
+        },
+        .disabled_soft => {
+            if (event.mode == .on) output.pending.state = .enabled else return;
+        },
+        .disabled_hard, .destroying => unreachable,
     }
 
-    output.updateLockRenderStateOnEnableDisable();
-    output.gamma_dirty = true;
+    server.wm.dirtyPending();
 }
 
 fn handleSetGamma(
@@ -436,5 +327,187 @@ fn handleSetGamma(
     std.log.debug("client requested to set gamma", .{});
 
     output.gamma_dirty = true;
-    output.wlr_output.scheduleFrame();
+    event.output.scheduleFrame();
+}
+
+pub fn commitOutputState(root: *Root) void {
+    const wm = &server.wm;
+
+    {
+        var it = wm.sent.outputs.iterator(.forward);
+        while (it.next()) |output| {
+            const wlr_output = output.wlr_output orelse continue;
+            switch (output.sent.state) {
+                .enabled, .disabled_soft => {
+                    output.scene_output.?.setPosition(output.sent.x, output.sent.y);
+                    _ = root.output_layout.add(wlr_output, output.sent.x, output.sent.y) catch {
+                        std.log.err("out of memory", .{});
+                        continue; // Try again next time
+                    };
+                },
+                .disabled_hard, .destroying => {
+                    root.output_layout.remove(wlr_output);
+                },
+            }
+        }
+    }
+
+    server.input_manager.reconfigureDevices();
+
+    const need_modeset = blk: {
+        var it = wm.sent.outputs.iterator(.forward);
+        while (it.next()) |output| {
+            const wlr_output = output.wlr_output orelse continue;
+
+            switch (output.sent.state) {
+                .enabled => if (!wlr_output.enabled) break :blk true,
+                .disabled_soft, .disabled_hard, .destroying => continue,
+            }
+
+            switch (output.sent.mode) {
+                .standard => |mode| {
+                    if (mode != wlr_output.current_mode) break :blk true;
+                },
+                .custom => |mode| {
+                    if (mode.width != wlr_output.width) break :blk true;
+                    if (mode.height != wlr_output.height) break :blk true;
+                    if (mode.refresh != wlr_output.refresh) break :blk true;
+                },
+                .none => {},
+            }
+            if (output.sent.adaptive_sync != (wlr_output.adaptive_sync_status == .enabled)) {
+                break :blk true;
+            }
+        }
+
+        break :blk false;
+    };
+
+    if (need_modeset) {
+        var states = std.ArrayList(wlr.Backend.OutputState).init(util.gpa);
+        defer states.deinit();
+        defer for (states.items) |*s| s.base.finish();
+
+        {
+            var it = wm.sent.outputs.iterator(.forward);
+            while (it.next()) |output| {
+                const wlr_output = output.wlr_output orelse continue;
+                const state = states.addOne() catch {
+                    std.log.err("out of memory", .{});
+                    return;
+                };
+
+                state.output = wlr_output;
+                state.base = wlr.Output.State.init();
+
+                output.sent.apply(&state.base);
+            }
+        }
+
+        var swapchain_manager: wlr.OutputSwapchainManager = undefined;
+        swapchain_manager.init(server.backend);
+        defer swapchain_manager.finish();
+
+        if (!swapchain_manager.prepare(states.items)) {
+            std.log.err("failed to prepare new output configuration", .{});
+            // TODO search for a working fallback
+
+            if (wm.sent.output_config) |config| {
+                config.sendFailed();
+                config.destroy();
+                wm.sent.output_config = null;
+            }
+
+            {
+                // Revert to last working state on failure
+                var it = wm.sent.outputs.iterator(.forward);
+                while (it.next()) |output| {
+                    output.pending = output.current;
+                    output.sent = output.current;
+                }
+                wm.dirtyPending();
+            }
+            return;
+        }
+
+        for (states.items) |*state| {
+            const output: *Output = @ptrFromInt(state.output.data);
+            if (!output.scene_output.?.buildState(&state.base, &.{
+                .swapchain = swapchain_manager.getSwapchain(state.output),
+            })) {
+                std.log.err("failed to render scene for {s}", .{state.output.name});
+            }
+        }
+
+        if (!server.backend.commit(states.items)) {
+            std.log.err("failed to commit new output configuration", .{});
+
+            if (wm.sent.output_config) |config| {
+                config.sendFailed();
+                config.destroy();
+                wm.sent.output_config = null;
+            }
+
+            {
+                // Revert to last working state on failure
+                var it = wm.sent.outputs.iterator(.forward);
+                while (it.next()) |output| {
+                    output.pending = output.current;
+                    output.sent = output.current;
+                }
+                wm.dirtyPending();
+            }
+            return;
+        }
+
+        swapchain_manager.apply();
+    }
+
+    if (wm.sent.output_config) |config| {
+        config.sendSucceeded();
+        config.destroy();
+        wm.sent.output_config = null;
+    }
+
+    {
+        var it = wm.sent.outputs.iterator(.forward);
+        while (it.next()) |output| {
+            output.current = output.sent;
+
+            if (output.wlr_output) |wlr_output| {
+                wlr_output.scheduleFrame();
+            }
+        }
+    }
+
+    // XXX sending this every transaction is too noisy
+    root.sendManagerConfig() catch {
+        std.log.err("out of memory", .{});
+    };
+}
+
+/// Send the current output state to all wlr-output-manager clients.
+fn sendManagerConfig(root: *Root) !void {
+    const config = try wlr.OutputConfigurationV1.create();
+    // this destroys all associated config heads as well
+    errdefer config.destroy();
+
+    var it = root.outputs.iterator(.forward);
+    while (it.next()) |output| {
+        const head = try wlr.OutputConfigurationV1.Head.create(config, output.wlr_output.?);
+
+        // It's only necessary to overwrite the state that does not require a modeset.
+        // All state that requires a modeset will have already been committed to the wlr_output.
+        head.state.enabled = switch (output.current.state) {
+            .enabled, .disabled_soft => true,
+            .disabled_hard => false,
+            .destroying => unreachable,
+        };
+        head.state.scale = output.current.scale;
+        head.state.transform = output.current.transform;
+        head.state.x = output.current.x;
+        head.state.y = output.current.y;
+    }
+
+    root.output_manager.setConfiguration(config);
 }
diff --git a/river/Window.zig b/river/Window.zig
index 1c10342..c7635b2 100644
--- a/river/Window.zig
+++ b/river/Window.zig
@@ -141,7 +141,7 @@ pending: struct {
         /// object.
         init,
         /// Indicates that the window is ready to be configured.
-        /// Create a river_window_v1 object if needed an send events.
+        /// Create a river_window_v1 object if needed and send events.
         ready,
         /// Indicates that the closed event will be sent in the next update sequence.
         closing,
diff --git a/river/WindowManager.zig b/river/WindowManager.zig
index acb2326..8b8dda4 100644
--- a/river/WindowManager.zig
+++ b/river/WindowManager.zig
@@ -19,11 +19,13 @@ const WindowManager = @This();
 const std = @import("std");
 const assert = std.debug.assert;
 const wl = @import("wayland").server.wl;
+const wlr = @import("wlroots");
 const river = @import("wayland").server.river;
 
 const server = &@import("main.zig").server;
 const util = @import("util.zig");
 
+const Output = @import("Output.zig");
 const Window = @import("Window.zig");
 const WmNode = @import("WmNode.zig");
 
@@ -48,11 +50,21 @@ state: union(enum) {
     inflight_configures: u32,
 } = .idle,
 
-/// Pending state from windows to be sent to the wm in the next update sequence.
+/// Pending state to be sent to the wm in the next update sequence.
 pending: struct {
     /// Pending state has been modified since the last update event sent to the wm.
     dirty: bool = false,
+
     dirty_windows: wl.list.Head(Window, .link_dirty),
+
+    outputs: wl.list.Head(Output, .link_pending),
+    output_config: ?*wlr.OutputConfigurationV1 = null,
+},
+
+/// State sent to the wm in the latest update sequence.
+sent: struct {
+    outputs: wl.list.Head(Output, .link_sent),
+    output_config: ?*wlr.OutputConfigurationV1 = null,
 },
 
 /// State sent by the wm but not yet committed with a commit request.
@@ -86,6 +98,10 @@ pub fn init(wm: *WindowManager) !void {
         .windows = undefined,
         .pending = .{
             .dirty_windows = undefined,
+            .outputs = undefined,
+        },
+        .sent = .{
+            .outputs = undefined,
         },
         .uncommitted = .{
             .render_list = undefined,
@@ -100,6 +116,8 @@ pub fn init(wm: *WindowManager) !void {
     };
     wm.windows.init();
     wm.pending.dirty_windows.init();
+    wm.pending.outputs.init();
+    wm.sent.outputs.init();
     wm.uncommitted.render_list.init();
     wm.committed.render_list.init();
     wm.inflight.render_list.init();
@@ -219,6 +237,22 @@ fn sendUpdate(wm: *WindowManager) void {
 
     // XXX send all dirty pending state
 
+    wm.autoLayoutOutputs();
+    {
+        var it = wm.pending.outputs.safeIterator(.forward);
+        while (it.next()) |output| {
+            output.sendDirty() catch {
+                log.err("out of memory", .{});
+                continue; // Try again next update
+            };
+            output.link_pending.remove();
+        }
+    }
+
+    assert(wm.sent.output_config == null);
+    wm.sent.output_config = wm.pending.output_config;
+    wm.pending.output_config = null;
+
     {
         var it = wm.pending.dirty_windows.safeIterator(.forward);
         while (it.next()) |window| {
@@ -238,6 +272,35 @@ fn sendUpdate(wm: *WindowManager) void {
     wm.startTimeoutTimer();
 }
 
+fn autoLayoutOutputs(wm: *WindowManager) void {
+    // Find the right most edge of any non-autolayout output.
+    var rightmost_edge: i32 = 0;
+    var row_y: i32 = 0;
+    {
+        var it = wm.pending.outputs.iterator(.forward);
+        while (it.next()) |output| {
+            if (output.pending.auto_layout) continue;
+
+            const x = output.pending.x + output.pending.width();
+            if (x > rightmost_edge) {
+                rightmost_edge = x;
+                row_y = output.pending.y;
+            }
+        }
+    }
+    // Place autolayout outputs in a row starting at the rightmost edge.
+    {
+        var it = wm.pending.outputs.iterator(.forward);
+        while (it.next()) |output| {
+            if (!output.pending.auto_layout) continue;
+
+            output.pending.x = rightmost_edge;
+            output.pending.y = row_y;
+            rightmost_edge += output.pending.width();
+        }
+    }
+}
+
 fn sendConfigures(wm: *WindowManager) void {
     switch (wm.state) {
         .idle, .update_acked => {},
@@ -326,6 +389,8 @@ fn commitTransaction(wm: *WindowManager) void {
         }
     }
 
+    server.root.commitOutputState();
+
     {
         var it = server.input_manager.seats.first;
         while (it) |node| : (it = node.next) node.data.cursor.updateState();