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

commitbb2cefd883fab8eea535cf6e17aefcd3ef3b4f28
parentcfcf2cbd8a
authorIsaac Freund <[email protected]>
date2024-07-19 13:59
river: move transaction code to WindowManager

 river/Cursor.zig                   |  16 +--
 river/ForeignToplevelHandle.zig    |   4 +-
 river/InputPopup.zig               |   6 +-
 river/LayerSurface.zig             |   8 +-
 river/LockManager.zig              |   2 +-
 river/Output.zig                   |   6 +-
 river/Root.zig                     | 220 +------------------------------------
 river/Seat.zig                     |   2 +-
 river/Server.zig                   |   2 +-
 river/Window.zig                   |  22 ++--
 river/WindowManager.zig            | 175 +++++++++++++++++++++++++++++
 river/XdgDecoration.zig            |   2 +-
 river/XdgToplevel.zig              |   4 +-
 river/XwaylandOverrideRedirect.zig |   2 +-
 river/XwaylandWindow.zig           |   6 +-
 15 files changed, 219 insertions(+), 258 deletions(-)

diff --git a/river/Cursor.zig b/river/Cursor.zig
index 786fb94..5d66fea 100644
--- a/river/Cursor.zig
+++ b/river/Cursor.zig
@@ -338,7 +338,7 @@ fn handleButton(listener: *wl.Listener(*wlr.Pointer.event.Button), event: *wlr.P
             cursor.mode = .passthrough;
             cursor.passthrough(event.time_msec);
 
-            server.root.applyPending();
+            server.wm.applyPending();
         } else {
             _ = cursor.seat.wlr_seat.pointerNotifyButton(event.time_msec, event.button, event.state);
         }
@@ -375,10 +375,10 @@ fn handleButton(listener: *wl.Listener(*wlr.Pointer.event.Button), event: *wlr.P
         }
     }
 
-    server.root.applyPending();
+    server.wm.applyPending();
 }
 
-/// Requires a call to Root.applyPending()
+/// Requires a call to WindowManager.applyPending()
 fn updateKeyboardFocus(cursor: Cursor, result: Root.AtResult) void {
     switch (result.data) {
         .window => |window| {
@@ -509,7 +509,7 @@ fn handleTouchDown(
         }
     }
 
-    server.root.applyPending();
+    server.wm.applyPending();
 }
 
 fn handleTouchMotion(
@@ -796,7 +796,7 @@ fn enterMode(cursor: *Cursor, mode: Mode, window: *Window, xcursor_name: [*:0]co
     cursor.seat.wlr_seat.pointerNotifyClearFocus();
     cursor.setXcursor(xcursor_name);
 
-    server.root.applyPending();
+    server.wm.applyPending();
 }
 
 fn processMotion(cursor: *Cursor, device: *wlr.InputDevice, time: u32, delta_x: f64, delta_y: f64, unaccel_dx: f64, unaccel_dy: f64) void {
@@ -853,7 +853,7 @@ fn processMotion(cursor: *Cursor, device: *wlr.InputDevice, time: u32, delta_x:
 
             data.window.pending.move(@intFromFloat(dx), @intFromFloat(dy));
 
-            server.root.applyPending();
+            server.wm.applyPending();
         },
         .resize => |*data| {
             dx += data.delta_x;
@@ -906,7 +906,7 @@ fn processMotion(cursor: *Cursor, device: *wlr.InputDevice, time: u32, delta_x:
                 data.y = box.height - data.initial_height;
             }
 
-            server.root.applyPending();
+            server.wm.applyPending();
         },
     }
 }
@@ -948,7 +948,7 @@ pub fn updateState(cursor: *Cursor) void {
                 .passthrough, .down => {},
                 inline .move, .resize => |data, mode| {
 
-                    // These conditions are checked in Root.applyPending()
+                    // These conditions are checked in WindowManager.applyPending()
                     assert(!data.window.current.fullscreen);
 
                     // Keep the cursor locked to the original offset from the edges of the window.
diff --git a/river/ForeignToplevelHandle.zig b/river/ForeignToplevelHandle.zig
index f6e0555..1bd5deb 100644
--- a/river/ForeignToplevelHandle.zig
+++ b/river/ForeignToplevelHandle.zig
@@ -86,7 +86,7 @@ fn handleForeignActivate(
     const seat: *Seat = @ptrFromInt(event.seat.data);
 
     seat.focus(window);
-    server.root.applyPending();
+    server.wm.applyPending();
 }
 
 fn handleForeignFullscreen(
@@ -97,7 +97,7 @@ fn handleForeignFullscreen(
     const window: *Window = @fieldParentPtr("foreign_toplevel_handle", handle);
 
     window.pending.fullscreen = event.fullscreen;
-    server.root.applyPending();
+    server.wm.applyPending();
 }
 
 fn handleForeignClose(
diff --git a/river/InputPopup.zig b/river/InputPopup.zig
index 19abbf8..b4cb096 100644
--- a/river/InputPopup.zig
+++ b/river/InputPopup.zig
@@ -47,7 +47,7 @@ pub fn create(wlr_popup: *wlr.InputPopupSurfaceV2, input_relay: *InputRelay) !vo
         .link = undefined,
         .input_relay = input_relay,
         .wlr_popup = wlr_popup,
-        .surface_tree = try server.root.hidden.tree.createSceneSubsurfaceTree(wlr_popup.surface),
+        .surface_tree = try server.root.hidden_tree.createSceneSubsurfaceTree(wlr_popup.surface),
     };
 
     input_relay.input_popups.append(input_popup);
@@ -82,7 +82,7 @@ fn handleMap(listener: *wl.Listener(void)) void {
 fn handleUnmap(listener: *wl.Listener(void)) void {
     const input_popup: *InputPopup = @fieldParentPtr("unmap", listener);
 
-    input_popup.surface_tree.node.reparent(server.root.hidden.tree);
+    input_popup.surface_tree.node.reparent(server.root.hidden_tree);
 }
 
 fn handleCommit(listener: *wl.Listener(*wlr.Surface), _: *wlr.Surface) void {
@@ -93,7 +93,7 @@ fn handleCommit(listener: *wl.Listener(*wlr.Surface), _: *wlr.Surface) void {
 
 pub fn update(input_popup: *InputPopup) void {
     const text_input = input_popup.input_relay.text_input orelse {
-        input_popup.surface_tree.node.reparent(server.root.hidden.tree);
+        input_popup.surface_tree.node.reparent(server.root.hidden_tree);
         return;
     };
 
diff --git a/river/LayerSurface.zig b/river/LayerSurface.zig
index af97e46..348a397 100644
--- a/river/LayerSurface.zig
+++ b/river/LayerSurface.zig
@@ -108,7 +108,7 @@ fn handleMap(listener: *wl.Listener(void)) void {
         if (consider) layer_surface else null,
     );
 
-    server.root.applyPending();
+    server.wm.applyPending();
 }
 
 fn handleUnmap(listener: *wl.Listener(void)) void {
@@ -118,7 +118,7 @@ fn handleUnmap(listener: *wl.Listener(void)) void {
 
     layer_surface.output.arrangeLayers();
     handleKeyboardInteractiveExclusive(layer_surface.output, null);
-    server.root.applyPending();
+    server.wm.applyPending();
 }
 
 fn handleCommit(listener: *wl.Listener(*wlr.Surface), _: *wlr.Surface) void {
@@ -138,13 +138,13 @@ fn handleCommit(listener: *wl.Listener(*wlr.Surface), _: *wlr.Surface) void {
     {
         layer_surface.output.arrangeLayers();
         handleKeyboardInteractiveExclusive(layer_surface.output, null);
-        server.root.applyPending();
+        server.wm.applyPending();
     }
 }
 
 /// Focus topmost keyboard-interactivity-exclusive layer surface above normal
 /// content, or if none found, focus the surface given as `consider`.
-/// Requires a call to Root.applyPending()
+/// Requires a call to WindowManager.applyPending()
 fn handleKeyboardInteractiveExclusive(output: *Output, consider: ?*LayerSurface) void {
     if (server.lock_manager.state != .unlocked) return;
 
diff --git a/river/LockManager.zig b/river/LockManager.zig
index 85332e1..1a16c66 100644
--- a/river/LockManager.zig
+++ b/river/LockManager.zig
@@ -218,7 +218,7 @@ fn handleUnlock(listener: *wl.Listener(void)) void {
 
     handleDestroy(&manager.destroy);
 
-    server.root.applyPending();
+    server.wm.applyPending();
 }
 
 fn handleDestroy(listener: *wl.Listener(void)) void {
diff --git a/river/Output.zig b/river/Output.zig
index 7a07f6d..e816404 100644
--- a/river/Output.zig
+++ b/river/Output.zig
@@ -237,7 +237,7 @@ pub fn layerSurfaceTree(output: Output, layer: zwlr.LayerShellV1.Layer) *wlr.Sce
 
 /// Arrange all layer surfaces of this output and adjust the usable area.
 /// Will arrange windows as well if the usable area changes.
-/// Requires a call to Root.applyPending()
+/// Requires a call to WindowManager.applyPending()
 pub fn arrangeLayers(output: *Output) void {
     var full_box: wlr.Box = .{
         .x = 0,
@@ -329,7 +329,7 @@ fn handleDestroy(listener: *wl.Listener(*wlr.Output), _: *wlr.Output) void {
 
     server.root.handleOutputConfigChange() catch std.log.err("out of memory", .{});
 
-    server.root.applyPending();
+    server.wm.applyPending();
 }
 
 fn handleRequestState(listener: *wl.Listener(*wlr.Output.event.RequestState), event: *wlr.Output.event.RequestState) void {
@@ -340,7 +340,7 @@ fn handleRequestState(listener: *wl.Listener(*wlr.Output.event.RequestState), ev
         return;
     };
 
-    server.root.applyPending();
+    server.wm.applyPending();
 }
 
 // TODO double buffer output state changes for frame perfection and cleaner code.
diff --git a/river/Root.zig b/river/Root.zig
index 262233a..43ef70f 100644
--- a/river/Root.zig
+++ b/river/Root.zig
@@ -52,31 +52,7 @@ layers: struct {
     override_redirect: if (build_options.xwayland) *wlr.SceneTree else void,
 },
 
-wm: struct {
-    pending: struct {
-        render_list: wl.list.Head(Window, .pending_render_list_link),
-    },
-
-    inflight: struct {
-        render_list: wl.list.Head(Window, .inflight_render_list_link),
-    },
-},
-
-/// This is kind of like an imaginary output where windows start and end their life.
-hidden: struct {
-    /// This tree is always disabled.
-    tree: *wlr.SceneTree,
-
-    pending: struct {
-        render_list: wl.list.Head(Window, .pending_render_list_link),
-    },
-
-    inflight: struct {
-        render_list: wl.list.Head(Window, .inflight_render_list_link),
-    },
-},
-
-windows: wl.list.Head(Window, .link),
+hidden_tree: *wlr.SceneTree,
 
 new_output: wl.Listener(*wlr.Output) = wl.Listener(*wlr.Output).init(handleNewOutput),
 
@@ -107,13 +83,6 @@ all_outputs: wl.list.Head(Output, .all_link),
 /// it's turned off by dpms)
 active_outputs: wl.list.Head(Output, .active_link),
 
-/// Number of inflight configures sent in the current transaction.
-inflight_configures: u32 = 0,
-transaction_timeout: *wl.EventSource,
-/// Set to true if applyPending() is called while a transaction is inflight.
-/// If true when a transaction completes, causes applyPending() to be called again.
-pending_state_dirty: bool = false,
-
 pub fn init(root: *Root) !void {
     const output_layout = try wlr.OutputLayout.create(server.wl_server);
     errdefer output_layout.destroy();
@@ -129,10 +98,6 @@ pub fn init(root: *Root) !void {
     const outputs = try interactive_content.createSceneTree();
     const override_redirect = if (build_options.xwayland) try interactive_content.createSceneTree();
 
-    const event_loop = server.wl_server.getEventLoop();
-    const transaction_timeout = try event_loop.addTimer(*Root, handleTransactionTimeout, root);
-    errdefer transaction_timeout.remove();
-
     root.* = .{
         .scene = scene,
         .interactive_content = interactive_content,
@@ -141,41 +106,19 @@ pub fn init(root: *Root) !void {
             .outputs = outputs,
             .override_redirect = override_redirect,
         },
-        .wm = .{
-            .pending = .{
-                .render_list = undefined,
-            },
-            .inflight = .{
-                .render_list = undefined,
-            },
-        },
-        .hidden = .{
-            .tree = hidden_tree,
-            .pending = .{
-                .render_list = undefined,
-            },
-            .inflight = .{
-                .render_list = undefined,
-            },
-        },
-        .windows = undefined,
         .output_layout = output_layout,
         .all_outputs = undefined,
         .active_outputs = undefined,
 
+        .hidden_tree = hidden_tree,
+
         .presentation = try wlr.Presentation.create(server.wl_server, server.backend),
         .xdg_output_manager = try wlr.XdgOutputManagerV1.create(server.wl_server, output_layout),
         .output_manager = try wlr.OutputManagerV1.create(server.wl_server),
         .power_manager = try wlr.OutputPowerManagerV1.create(server.wl_server),
         .gamma_control_manager = try wlr.GammaControlManagerV1.create(server.wl_server),
-        .transaction_timeout = transaction_timeout,
     };
-    root.wm.pending.render_list.init();
-    root.wm.inflight.render_list.init();
-    root.hidden.pending.render_list.init();
-    root.hidden.inflight.render_list.init();
 
-    root.windows.init();
     root.all_outputs.init();
     root.active_outputs.init();
 
@@ -189,7 +132,6 @@ pub fn init(root: *Root) !void {
 
 pub fn deinit(root: *Root) void {
     root.output_layout.destroy();
-    root.transaction_timeout.remove();
 }
 
 pub const AtResult = struct {
@@ -307,160 +249,6 @@ pub fn activateOutput(root: *Root, output: *Output) void {
     };
 }
 
-/// Trigger asynchronous application of pending state for all outputs and windows.
-/// Changes will not be applied to the scene graph until the layout generator
-/// generates a new layout for all outputs and all affected clients ack a
-/// configure and commit a new buffer.
-pub fn applyPending(root: *Root) void {
-    {
-        // Changes to the pending state may require a focus update to keep
-        // state consistent. Instead of having focus(null) calls spread all
-        // around the codebase and risk forgetting one, always ensure focus
-        // state is synchronized here.
-        var it = server.input_manager.seats.first;
-        while (it) |node| : (it = node.next) node.data.focus(null);
-    }
-
-    // If there is already a transaction inflight, wait until it completes.
-    if (root.inflight_configures > 0) {
-        root.pending_state_dirty = true;
-        return;
-    }
-    root.pending_state_dirty = false;
-
-    {
-        var it = root.hidden.pending.render_list.iterator(.forward);
-        while (it.next()) |window| {
-            window.inflight_render_list_link.remove();
-            root.hidden.inflight.render_list.append(window);
-        }
-    }
-
-    {
-        var it = server.input_manager.seats.first;
-        while (it) |node| : (it = node.next) {
-            const cursor = &node.data.cursor;
-
-            switch (cursor.mode) {
-                .passthrough, .down => {},
-                inline .move, .resize => |data| {
-                    if (data.window.inflight.fullscreen) {
-                        cursor.mode = .passthrough;
-                        data.window.pending.resizing = false;
-                        data.window.inflight.resizing = false;
-                    }
-                },
-            }
-
-            cursor.inflight_mode = cursor.mode;
-        }
-    }
-
-    root.sendConfigures();
-}
-
-fn sendConfigures(root: *Root) void {
-    assert(root.inflight_configures == 0);
-
-    {
-        var it = root.wm.inflight.render_list.iterator(.forward);
-        while (it.next()) |window| {
-            assert(!window.inflight_transaction);
-            window.inflight_transaction = true;
-
-            // This can happen if a window is unmapped while a layout demand including it is inflight
-            // If a window has been unmapped, don't send it a configure.
-            if (!window.mapped) continue;
-
-            if (window.configure()) {
-                root.inflight_configures += 1;
-
-                window.saveSurfaceTree();
-                window.sendFrameDone();
-            }
-        }
-    }
-
-    if (root.inflight_configures > 0) {
-        std.log.scoped(.transaction).debug("started transaction with {} pending configure(s)", .{
-            root.inflight_configures,
-        });
-
-        root.transaction_timeout.timerUpdate(100) catch {
-            std.log.scoped(.transaction).err("failed to update timer", .{});
-            root.commitTransaction();
-        };
-    } else {
-        root.commitTransaction();
-    }
-}
-
-fn handleTransactionTimeout(root: *Root) c_int {
-    std.log.scoped(.transaction).err("timeout occurred, some imperfect frames may be shown", .{});
-
-    root.inflight_configures = 0;
-    root.commitTransaction();
-
-    return 0;
-}
-
-pub fn notifyConfigured(root: *Root) void {
-    root.inflight_configures -= 1;
-    if (root.inflight_configures == 0) {
-        // Disarm the timer, as we didn't timeout
-        root.transaction_timeout.timerUpdate(0) catch std.log.scoped(.transaction).err("error disarming timer", .{});
-        root.commitTransaction();
-    }
-}
-
-/// Apply the inflight state and drop stashed buffers. This means that
-/// the next frame drawn will be the post-transaction state of the
-/// layout. Should only be called after all clients have configured for
-/// the new layout. If called early imperfect frames may be drawn.
-fn commitTransaction(root: *Root) void {
-    assert(root.inflight_configures == 0);
-
-    std.log.scoped(.transaction).debug("commiting transaction", .{});
-
-    {
-        var it = root.hidden.inflight.render_list.safeIterator(.forward);
-        while (it.next()) |window| {
-            window.tree.node.reparent(root.hidden.tree);
-            window.popup_tree.node.reparent(root.hidden.tree);
-        }
-    }
-
-    {
-        var it = root.wm.inflight.render_list.iterator(.forward);
-        while (it.next()) |window| {
-            window.commitTransaction();
-
-            window.tree.node.setEnabled(true);
-            window.popup_tree.node.setEnabled(true);
-        }
-    }
-
-    {
-        var it = server.input_manager.seats.first;
-        while (it) |node| : (it = node.next) node.data.cursor.updateState();
-    }
-
-    {
-        // This must be done after updating cursor state in case the window was the target of move/resize.
-        var it = root.hidden.inflight.render_list.safeIterator(.forward);
-        while (it.next()) |window| {
-            window.dropSavedSurfaceTree();
-            if (window.destroying) window.destroy(.assert);
-        }
-    }
-
-    server.idle_inhibit_manager.checkActive();
-
-    if (root.pending_state_dirty) {
-        root.applyPending();
-    }
-}
-
 // 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 {
@@ -571,7 +359,7 @@ fn processOutputConfig(
         }
     }
 
-    if (action == .apply) root.applyPending();
+    if (action == .apply) server.wm.applyPending();
 
     if (success) {
         config.sendSucceeded();
diff --git a/river/Seat.zig b/river/Seat.zig
index 77864e5..921a86d 100644
--- a/river/Seat.zig
+++ b/river/Seat.zig
@@ -129,7 +129,7 @@ pub fn deinit(seat: *Seat) void {
 
 /// Set the current focus. If a visible window is passed it will be focused.
 /// If null is passed, the top window in the stack of the focused output will be focused.
-/// Requires a call to Root.applyPending()
+/// Requires a call to WindowManager.applyPending()
 pub fn focus(seat: *Seat, target: ?*Window) void {
     // Views may not receive focus while locked.
     if (server.lock_manager.state != .unlocked) return;
diff --git a/river/Server.zig b/river/Server.zig
index 8ec9201..7e15659 100644
--- a/river/Server.zig
+++ b/river/Server.zig
@@ -415,7 +415,7 @@ fn handleRequestActivate(
     switch (node_data.data) {
         .window => |window| if (window.pending.focus == 0) {
             window.pending.urgent = true;
-            server.root.applyPending();
+            server.wm.applyPending();
         },
         else => |tag| {
             log.info("ignoring xdg-activation-v1 activate request of {s} surface", .{@tagName(tag)});
diff --git a/river/Window.zig b/river/Window.zig
index 779c251..3a327b7 100644
--- a/river/Window.zig
+++ b/river/Window.zig
@@ -131,7 +131,7 @@ destroying: bool = false,
 /// to be applied as a single atomic transaction across all clients as soon as any
 /// in progress transaction has been completed.
 ///
-/// Any time pending state is modified Root.applyPending() must be called
+/// Any time pending state is modified WindowManager.applyPending() must be called
 /// before yielding back to the event loop.
 pending: State = .{},
 pending_render_list_link: wl.list.Link,
@@ -153,10 +153,10 @@ pub fn create(impl: Impl) error{OutOfMemory}!*Window {
     const window = try util.gpa.create(Window);
     errdefer util.gpa.destroy(window);
 
-    const tree = try server.root.hidden.tree.createSceneTree();
+    const tree = try server.root.hidden_tree.createSceneTree();
     errdefer tree.node.destroy();
 
-    const popup_tree = try server.root.hidden.tree.createSceneTree();
+    const popup_tree = try server.root.hidden_tree.createSceneTree();
     errdefer popup_tree.node.destroy();
 
     window.* = .{
@@ -177,9 +177,9 @@ pub fn create(impl: Impl) error{OutOfMemory}!*Window {
         .inflight_render_list_link = undefined,
     };
 
-    server.root.windows.prepend(window);
-    server.root.hidden.pending.render_list.prepend(window);
-    server.root.hidden.inflight.render_list.prepend(window);
+    server.wm.windows.prepend(window);
+    server.wm.pending.render_list.prepend(window);
+    server.wm.inflight.render_list.prepend(window);
 
     window.tree.node.setEnabled(false);
     window.popup_tree.node.setEnabled(false);
@@ -492,7 +492,7 @@ pub fn map(window: *Window) !void {
 
     window.foreign_toplevel_handle.map();
 
-    server.root.applyPending();
+    server.wm.applyPending();
 }
 
 /// Called by the impl when the surface will no longer be displayed
@@ -501,17 +501,15 @@ pub fn unmap(window: *Window) void {
 
     if (!window.saved_surface_tree.node.enabled) window.saveSurfaceTree();
 
-    {
-        window.pending_render_list_link.remove();
-        server.root.hidden.pending.render_list.prepend(window);
-    }
+    //window.pending_render_list_link.remove();
+    //server.root.hidden.pending.render_list.prepend(window);
 
     assert(window.mapped and !window.destroying);
     window.mapped = false;
 
     window.foreign_toplevel_handle.unmap();
 
-    server.root.applyPending();
+    server.wm.applyPending();
 }
 
 pub fn notifyTitle(window: *const Window) void {
diff --git a/river/WindowManager.zig b/river/WindowManager.zig
index 38edd54..bc9be75 100644
--- a/river/WindowManager.zig
+++ b/river/WindowManager.zig
@@ -24,6 +24,8 @@ const river = @import("wayland").server.river;
 const server = &@import("main.zig").server;
 const util = @import("util.zig");
 
+const Window = @import("Window.zig");
+
 const log = std.log.scoped(.wm);
 
 global: *wl.Global,
@@ -32,17 +34,52 @@ server_destroy: wl.Listener(*wl.Server) = wl.Listener(*wl.Server).init(handleSer
 /// The active window manager, if any.
 wm_v1: ?*river.WindowManagerV1 = null,
 
+windows: wl.list.Head(Window, .link),
+
+/// Pending state sent by the window manager but not yet committed
+/// TODO maybe need to split this into uncommitted and committed but not yet part of transaction
+pending: struct {
+    render_list: wl.list.Head(Window, .pending_render_list_link),
+},
+
+/// Committed state from the window manager that is part of the current transaction.
+inflight: struct {
+    render_list: wl.list.Head(Window, .inflight_render_list_link),
+},
+
+/// Number of inflight configures sent to windows in the current transaction.
+inflight_configures: u32 = 0,
+transaction_timeout: *wl.EventSource,
+/// Set to true if applyPending() is called while a transaction is inflight.
+/// If true when a transaction completes, causes applyPending() to be called again.
+pending_state_dirty: bool = false,
+
 pub fn init(wm: *WindowManager) !void {
+    const event_loop = server.wl_server.getEventLoop();
+    const transaction_timeout = try event_loop.addTimer(*WindowManager, handleTransactionTimeout, wm);
+    errdefer transaction_timeout.remove();
+
     wm.* = .{
         .global = try wl.Global.create(server.wl_server, river.WindowManagerV1, 1, *WindowManager, wm, bind),
+        .windows = undefined,
+        .pending = .{
+            .render_list = undefined,
+        },
+        .inflight = .{
+            .render_list = undefined,
+        },
+        .transaction_timeout = transaction_timeout,
     };
+    wm.windows.init();
 
     server.wl_server.addDestroyListener(&wm.server_destroy);
 }
 
 fn handleServerDestroy(listener: *wl.Listener(*wl.Server), _: *wl.Server) void {
     const wm: *WindowManager = @fieldParentPtr("server_destroy", listener);
+
     wm.global.destroy();
+    wm.transaction_timeout.remove();
 }
 
 fn bind(client: *wl.Client, wm: *WindowManager, version: u32, id: u32) void {
@@ -91,3 +128,141 @@ fn handleRequest(
         .get_shell_surface => |_| {},
     }
 }
+
+/// Trigger asynchronous application of pending state for all outputs and windows.
+/// Changes will not be applied to the scene graph until the layout generator
+/// generates a new layout for all outputs and all affected clients ack a
+/// configure and commit a new buffer.
+pub fn applyPending(wm: *WindowManager) void {
+    {
+        // Changes to the pending state may require a focus update to keep
+        // state consistent. Instead of having focus(null) calls spread all
+        // around the codebase and risk forgetting one, always ensure focus
+        // state is synchronized here.
+        var it = server.input_manager.seats.first;
+        while (it) |node| : (it = node.next) node.data.focus(null);
+    }
+
+    // If there is already a transaction inflight, wait until it completes.
+    if (wm.inflight_configures > 0) {
+        wm.pending_state_dirty = true;
+        return;
+    }
+    wm.pending_state_dirty = false;
+
+    {
+        var it = server.input_manager.seats.first;
+        while (it) |node| : (it = node.next) {
+            const cursor = &node.data.cursor;
+
+            switch (cursor.mode) {
+                .passthrough, .down => {},
+                inline .move, .resize => |data| {
+                    if (data.window.inflight.fullscreen) {
+                        cursor.mode = .passthrough;
+                        data.window.pending.resizing = false;
+                        data.window.inflight.resizing = false;
+                    }
+                },
+            }
+
+            cursor.inflight_mode = cursor.mode;
+        }
+    }
+
+    wm.sendConfigures();
+}
+
+fn sendConfigures(wm: *WindowManager) void {
+    assert(wm.inflight_configures == 0);
+
+    {
+        var it = wm.inflight.render_list.iterator(.forward);
+        while (it.next()) |window| {
+            assert(!window.inflight_transaction);
+            window.inflight_transaction = true;
+
+            // This can happen if a window is unmapped while a layout demand including it is inflight
+            // If a window has been unmapped, don't send it a configure.
+            if (!window.mapped) continue;
+
+            if (window.configure()) {
+                wm.inflight_configures += 1;
+
+                window.saveSurfaceTree();
+                window.sendFrameDone();
+            }
+        }
+    }
+
+    if (wm.inflight_configures > 0) {
+        std.log.scoped(.transaction).debug("started transaction with {} pending configure(s)", .{
+            wm.inflight_configures,
+        });
+
+        wm.transaction_timeout.timerUpdate(100) catch {
+            std.log.scoped(.transaction).err("failed to update timer", .{});
+            wm.commitTransaction();
+        };
+    } else {
+        wm.commitTransaction();
+    }
+}
+
+fn handleTransactionTimeout(wm: *WindowManager) c_int {
+    std.log.scoped(.transaction).err("timeout occurred, some imperfect frames may be shown", .{});
+
+    wm.inflight_configures = 0;
+    wm.commitTransaction();
+
+    return 0;
+}
+
+pub fn notifyConfigured(wm: *WindowManager) void {
+    wm.inflight_configures -= 1;
+    if (wm.inflight_configures == 0) {
+        // Disarm the timer, as we didn't timeout
+        wm.transaction_timeout.timerUpdate(0) catch std.log.scoped(.transaction).err("error disarming timer", .{});
+        wm.commitTransaction();
+    }
+}
+
+/// Apply the inflight state and drop stashed buffers. This means that
+/// the next frame drawn will be the post-transaction state of the
+/// layout. Should only be called after all clients have configured for
+/// the new layout. If called early imperfect frames may be drawn.
+fn commitTransaction(wm: *WindowManager) void {
+    assert(wm.inflight_configures == 0);
+
+    std.log.scoped(.transaction).debug("commiting transaction", .{});
+
+    {
+        var it = wm.inflight.render_list.iterator(.forward);
+        while (it.next()) |window| {
+            window.commitTransaction();
+
+            window.tree.node.setEnabled(true);
+            window.popup_tree.node.setEnabled(true);
+        }
+    }
+
+    {
+        var it = server.input_manager.seats.first;
+        while (it) |node| : (it = node.next) node.data.cursor.updateState();
+    }
+
+    {
+        // This must be done after updating cursor state in case the window was the target of move/resize.
+        var it = wm.inflight.render_list.safeIterator(.forward);
+        while (it.next()) |window| {
+            window.dropSavedSurfaceTree();
+            if (window.destroying) window.destroy(.assert);
+        }
+    }
+
+    server.idle_inhibit_manager.checkActive();
+
+    if (wm.pending_state_dirty) {
+        wm.applyPending();
+    }
+}
diff --git a/river/XdgDecoration.zig b/river/XdgDecoration.zig
index 6545eb9..909eba1 100644
--- a/river/XdgDecoration.zig
+++ b/river/XdgDecoration.zig
@@ -79,6 +79,6 @@ fn handleRequestMode(
 
     if (window.pending.ssd != ssd) {
         window.pending.ssd = ssd;
-        server.root.applyPending();
+        server.wm.applyPending();
     }
 }
diff --git a/river/XdgToplevel.zig b/river/XdgToplevel.zig
index 2c40642..c70b130 100644
--- a/river/XdgToplevel.zig
+++ b/river/XdgToplevel.zig
@@ -381,7 +381,7 @@ fn handleCommit(listener: *wl.Listener(*wlr.Surface), _: *wlr.Surface) void {
             switch (toplevel.configure_state) {
                 .acked => {
                     toplevel.configure_state = .committed;
-                    server.root.notifyConfigured();
+                    server.wm.notifyConfigured();
                 },
                 .timed_out_acked => {
                     toplevel.configure_state = .idle;
@@ -400,7 +400,7 @@ fn handleRequestFullscreen(listener: *wl.Listener(void)) void {
     const toplevel: *XdgToplevel = @fieldParentPtr("request_fullscreen", listener);
     if (toplevel.window.pending.fullscreen != toplevel.wlr_toplevel.requested.fullscreen) {
         toplevel.window.pending.fullscreen = toplevel.wlr_toplevel.requested.fullscreen;
-        server.root.applyPending();
+        server.wm.applyPending();
     }
 }
 
diff --git a/river/XwaylandOverrideRedirect.zig b/river/XwaylandOverrideRedirect.zig
index 28f3480..4f4680e 100644
--- a/river/XwaylandOverrideRedirect.zig
+++ b/river/XwaylandOverrideRedirect.zig
@@ -176,7 +176,7 @@ fn handleUnmap(listener: *wl.Listener(void)) void {
         }
     }
 
-    server.root.applyPending();
+    server.wm.applyPending();
 }
 
 fn handleSetGeometry(listener: *wl.Listener(void)) void {
diff --git a/river/XwaylandWindow.zig b/river/XwaylandWindow.zig
index 85751d2..7e1e487 100644
--- a/river/XwaylandWindow.zig
+++ b/river/XwaylandWindow.zig
@@ -229,7 +229,7 @@ fn handleRequestConfigure(
     // Allow xwayland windows to set their own dimensions (but not position) if floating
     xwayland_window.window.pending.box.width = event.width;
     xwayland_window.window.pending.box.height = event.height;
-    server.root.applyPending();
+    server.wm.applyPending();
 }
 
 fn handleSetOverrideRedirect(listener: *wl.Listener(void)) void {
@@ -272,7 +272,7 @@ fn handleSetDecorations(listener: *wl.Listener(void)) void {
 
     if (window.pending.ssd != ssd) {
         window.pending.ssd = ssd;
-        server.root.applyPending();
+        server.wm.applyPending();
     }
 }
 
@@ -280,7 +280,7 @@ fn handleRequestFullscreen(listener: *wl.Listener(void)) void {
     const xwayland_window: *XwaylandWindow = @fieldParentPtr("request_fullscreen", listener);
     if (xwayland_window.window.pending.fullscreen != xwayland_window.xwayland_surface.fullscreen) {
         xwayland_window.window.pending.fullscreen = xwayland_window.xwayland_surface.fullscreen;
-        server.root.applyPending();
+        server.wm.applyPending();
     }
 }