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

commitb03702dec1f023ac6ca84d48df9653178c0137db
parentf65f08b6d8
authorIsaac Freund <[email protected]>
date2025-07-12 10:42
rwm: implement fullscreen

Also tweak the protocol and split off inform_fullscreen and
inform_not_fullscreen requests.

 protocol/river-window-management-v1.xml | 57 ++++++++++++++++++++--
 river/Output.zig                        | 71 +++++++++++++---------------
 river/OutputManager.zig                 |  4 +-
 river/Window.zig                        | 83 +++++++++++++++++++++++----------
 river/WindowManager.zig                 | 14 ++++--
 river/XdgToplevel.zig                   | 16 +++++--
 river/XwaylandWindow.zig                |  7 ++-
 7 files changed, 176 insertions(+), 76 deletions(-)

diff --git a/protocol/river-window-management-v1.xml b/protocol/river-window-management-v1.xml
index 80eb7b3..8d1ede2 100644
--- a/protocol/river-window-management-v1.xml
+++ b/protocol/river-window-management-v1.xml
@@ -806,7 +806,7 @@
     </event>
 
     <request name="inform_maximized">
-      <description summary="inform the window it is maximized">
+      <description summary="inform the window that it is maximized">
         Inform the window that it is maximized. The window might use this
         information to adapt the style of its client-side window decorations for
         example.
@@ -820,7 +820,7 @@
     </request>
 
     <request name="inform_unmaximized">
-      <description summary="inform the window it is unmaximized">
+      <description summary="inform the window that it is unmaximized">
         Inform the window that it is unmaximized. The window might use this
         information to adapt the style of its client-side window decorations for
         example.
@@ -858,17 +858,57 @@
       </description>
     </event>
 
+    <request name="inform_fullscreen">
+      <description summary="inform the window that it is fullscreen">
+        Inform the window that it is fullscreen. The window might use this
+        information to adapt the style of its client-side window decorations for
+        example.
+
+        This request does not affect the size/position of the window or cause it
+        to become the only window rendered, see the river_window_v1.fullscreen
+        and exit_fullscreen requests for that.
+
+        This request modifies window management state and may only be made as
+        part of a manage sequence, see the river_window_manager_v1 description.
+      </description>
+    </request>
+
+    <request name="inform_not_fullscreen">
+      <description summary="inform the window that it is not fullscreen">
+        Inform the window that it is not fullscreen. The window might use this
+        information to adapt the style of its client-side window decorations for
+        example.
+
+        This request does not affect the size/position of the window or cause it
+        to become the only window rendered, see the river_window_v1.fullscreen
+        and exit_fullscreen requests for that.
+
+        This request modifies window management state and may only be made as
+        part of a manage sequence, see the river_window_manager_v1 description.
+      </description>
+    </request>
+
     <request name="fullscreen">
       <description summary="make the window fullscreen">
         Make the window fullscreen on the given output. If multiple windows are
         fullscreen on the same output at the same time only the "top" window in
         rendering order shall be displayed.
 
+        All river_shell_surface_v1 objects above the top fullscreen window in
+        the rendering order will continue to be rendered.
+
         The compositor will handle the position and dimensions of the window
         while it is fullscreen. The set_position and propose_dimensions requests
         shall not affect the current position and dimensions of a fullscreen
         window.
 
+        If the output on which a window is currently fullscreen is removed, the
+        windowing state is modified as if there were an exit_fullscreen request
+        made in the same manage sequence as the river_output_v1.removed event.
+
+        This request does not inform the window that it is fullscreen, see the
+        river_window_v1.inform_fullscreen and inform_not_fullscreen requests.
+
         This request modifies window management state and may only be made as
         part of a manage sequence, see the river_window_manager_v1 description.
       </description>
@@ -879,9 +919,16 @@
       <description summary="make the window not fullscreen">
         Make the window not fullscreen.
 
-        The new, post-fullscreen position and dimensions of the window will be
-        determined by the most recent set_position and propose_dimensions
-        requests.
+        The position and dimensions are undefined after this request is made
+        until a manage sequence in which the window manager makes the
+        propose_dimensions and set_position requests is completed.
+
+        The window manager should make propose_dimensions and set_position
+        requests in the same manage sequence as the exit_fullscreen request for
+        frame perfection.
+
+        This request does not inform the window that it is fullscreen, see the
+        river_window_v1.inform_fullscreen and inform_not_fullscreen requests.
 
         This request modifies window management state and may only be made as
         part of a manage sequence, see the river_window_manager_v1 description.
diff --git a/river/Output.zig b/river/Output.zig
index 18be1e5..eb222db 100644
--- a/river/Output.zig
+++ b/river/Output.zig
@@ -70,44 +70,20 @@ pub const State = struct {
     adaptive_sync: bool,
     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,
-                });
-            }
+    /// Width/height in the logical coordinate space
+    pub fn dimensions(state: *const State) struct { u31, u31 } {
+        var w: i32, var h: i32 = switch (state.mode) {
+            .standard => |mode| .{ mode.width, mode.height },
+            .custom => |mode| .{ mode.width, mode.height },
+            .none => .{ 0, 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,
-                });
-            }
+        if (@mod(@intFromEnum(state.transform), 2) != 0) {
+            mem.swap(i32, &w, &h);
+        }
+        return .{
+            @intFromFloat(@as(f32, @floatFromInt(w)) / state.scale),
+            @intFromFloat(@as(f32, @floatFromInt(h)) / state.scale),
         };
-        return @intFromFloat(physical / state.scale);
     }
 
     pub fn applyNoModeset(state: *const State, wlr_state: *wlr.Output.State) void {
@@ -270,8 +246,11 @@ pub fn manageStart(output: *Output) void {
                     output_v1.sendWlOutput(wlr_output.global.?.getName(output_v1.getClient()));
                 }
 
-                if (new or pending.width() != sent.width() or pending.height() != sent.height()) {
-                    output_v1.sendDimensions(pending.width(), pending.height());
+                const pending_width, const pending_height = pending.dimensions();
+                const sent_width, const sent_height = sent.dimensions();
+
+                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);
@@ -294,6 +273,22 @@ pub fn manageStart(output: *Output) void {
             output.link_sent.init();
 
             if (output.scheduled.state == .destroying) {
+                {
+                    var it = server.wm.windows.iterator(.forward);
+                    while (it.next()) |window| {
+                        switch (window.wm_scheduled.fullscreen_requested) {
+                            .fullscreen => |output_hint| {
+                                if (output_hint == output) {
+                                    window.wm_scheduled.fullscreen_requested = .{ .fullscreen = null };
+                                }
+                            },
+                            .no_request, .exit => {},
+                        }
+                        if (window.wm_requested.fullscreen == output) {
+                            window.wm_requested.fullscreen = null;
+                        }
+                    }
+                }
                 util.gpa.destroy(output);
             }
         },
diff --git a/river/OutputManager.zig b/river/OutputManager.zig
index f3bee07..03175cb 100644
--- a/river/OutputManager.zig
+++ b/river/OutputManager.zig
@@ -222,7 +222,7 @@ pub fn autoLayout(om: *OutputManager) void {
         while (it.next()) |output| {
             if (output.scheduled.auto_layout) continue;
 
-            const x = output.scheduled.x + output.scheduled.width();
+            const x = output.scheduled.x + output.scheduled.dimensions()[0];
             if (x > rightmost_edge) {
                 rightmost_edge = x;
                 row_y = output.scheduled.y;
@@ -237,7 +237,7 @@ pub fn autoLayout(om: *OutputManager) void {
 
             output.scheduled.x = rightmost_edge;
             output.scheduled.y = row_y;
-            rightmost_edge += output.scheduled.width();
+            rightmost_edge += output.scheduled.dimensions()[0];
         }
     }
 }
diff --git a/river/Window.zig b/river/Window.zig
index a119787..f402618 100644
--- a/river/Window.zig
+++ b/river/Window.zig
@@ -56,6 +56,12 @@ const Impl = union(enum) {
     destroying,
 };
 
+pub const FullscreenRequest = union(enum) {
+    no_request,
+    fullscreen: ?*Output,
+    exit,
+};
+
 pub const Border = struct {
     edges: river.WindowV1.Edges = .{},
     width: u31 = 0,
@@ -75,7 +81,7 @@ pub const Configure = struct {
     tiled: river.WindowV1.Edges = .{},
     capabilities: river.WindowV1.Capabilities = .{},
     maximized: bool = false,
-    fullscreen: bool = false,
+    inform_fullscreen: bool = false,
     resizing: bool = false,
 };
 
@@ -106,6 +112,11 @@ impl: Impl,
 /// The trees in the following fields are in rendering order.
 tree: *wlr.SceneTree,
 
+/// Opaque black rectangle used as the background while this window is rendered fullscreen.
+/// TODO consider using one of these per output rather than one per window to save memory
+/// if the complexity tradeoff is worth it.
+fullscreen_background: *wlr.SceneRect,
+
 decorations_below: wl.list.Head(Decoration, .link),
 decorations_below_tree: *wlr.SceneTree,
 
@@ -131,12 +142,7 @@ wm_scheduled: struct {
     dimensions_hint: DimensionsHint = .{},
     decoration_hint: river.WindowV1.DecorationHint = .only_supports_csd,
     /// Set back to no_request at the end of each update sequence
-    fullscreen_requested: enum {
-        no_request,
-        /// TODO output hint
-        fullscreen,
-        exit,
-    } = .no_request,
+    fullscreen_requested: FullscreenRequest = .no_request,
     dirty_app_id: bool = false,
     dirty_title: bool = false,
 } = .{},
@@ -165,7 +171,8 @@ wm_requested: struct {
     },
     resizing: bool = false,
     maximized: bool = false,
-    fullscreen: bool = false, // XXX output
+    fullscreen: ?*Output = null,
+    inform_fullscreen: bool = false,
     close: bool = false,
 } = .{},
 
@@ -216,6 +223,7 @@ pub fn create(impl: Impl) error{OutOfMemory}!*Window {
         .node = undefined,
         .impl = impl,
         .tree = tree,
+        .fullscreen_background = try tree.createSceneRect(0, 0, &.{ 0, 0, 0, 1 }),
         .decorations_below = undefined,
         .decorations_below_tree = try tree.createSceneTree(),
         .surfaces = try Scene.SaveableSurfaces.init(tree),
@@ -240,6 +248,7 @@ pub fn create(impl: Impl) error{OutOfMemory}!*Window {
 
     window.tree.node.setEnabled(false);
     window.popup_tree.node.setEnabled(false);
+    window.fullscreen_background.node.setEnabled(false);
 
     try SceneNodeData.attach(&window.tree.node, .{ .window = window });
     try SceneNodeData.attach(&window.popup_tree.node, .{ .window = window });
@@ -320,15 +329,6 @@ pub fn setDecorationHint(window: *Window, hint: river.WindowV1.DecorationHint) v
     }
 }
 
-pub fn setFullscreenRequested(window: *Window, fullscreen_requested: bool) void {
-    if (fullscreen_requested) {
-        window.wm_scheduled.fullscreen_requested = .fullscreen;
-    } else {
-        window.wm_scheduled.fullscreen_requested = .exit;
-    }
-    server.wm.dirtyWindowing();
-}
-
 /// Send dirty state as part of a manage sequence.
 pub fn manageStart(window: *Window) void {
     switch (window.state) {
@@ -383,7 +383,13 @@ pub fn manageStart(window: *Window) void {
             }
             switch (pending.fullscreen_requested) {
                 .no_request => {},
-                .fullscreen => window_v1.sendFullscreenRequested(null),
+                .fullscreen => |output_hint| {
+                    if (output_hint) |output| {
+                        window_v1.sendFullscreenRequested(output.object);
+                    } else {
+                        window_v1.sendFullscreenRequested(null);
+                    }
+                },
                 .exit => window_v1.sendExitFullscreenRequested(),
             }
             pending.fullscreen_requested = .no_request;
@@ -543,13 +549,23 @@ fn handleRequest(
             if (!server.wm.ensureWindowing()) return;
             wm_requested.maximized = false;
         },
-        .fullscreen => {
+        .inform_fullscreen => {
             if (!server.wm.ensureWindowing()) return;
-            wm_requested.fullscreen = true;
+            wm_requested.inform_fullscreen = true;
+        },
+        .inform_not_fullscreen => {
+            if (!server.wm.ensureWindowing()) return;
+            wm_requested.inform_fullscreen = false;
+        },
+        .fullscreen => |args| {
+            if (!server.wm.ensureWindowing()) return;
+            const data = args.output.getUserData() orelse return;
+            const output: *Output = @ptrCast(@alignCast(data));
+            wm_requested.fullscreen = output;
         },
         .exit_fullscreen => {
             if (!server.wm.ensureWindowing()) return;
-            wm_requested.fullscreen = false;
+            wm_requested.fullscreen = null;
         },
     }
 }
@@ -584,7 +600,7 @@ pub fn manageFinish(window: *Window) bool {
     window.configure_scheduled.capabilities = wm_requested.capabilities;
     window.configure_scheduled.resizing = wm_requested.resizing;
     window.configure_scheduled.maximized = wm_requested.maximized;
-    window.configure_scheduled.fullscreen = wm_requested.fullscreen;
+    window.configure_scheduled.inform_fullscreen = wm_requested.inform_fullscreen;
 
     if (wm_requested.close) {
         window.close();
@@ -603,12 +619,21 @@ pub fn manageFinish(window: *Window) bool {
         }
     }
 
-    if (wm_requested.dimensions) |dimensions| {
+    if (wm_requested.fullscreen) |output| {
+        // XXX don't configure again if these dimensions were already sent.
+        const width, const height = output.sent.dimensions();
+        if (window.configure_sent.width != width) {
+            window.configure_scheduled.width = width;
+        }
+        if (window.configure_sent.height != height) {
+            window.configure_scheduled.height = height;
+        }
+    } else if (wm_requested.dimensions) |dimensions| {
         window.configure_scheduled.width = dimensions.width;
         window.configure_scheduled.height = dimensions.height;
-        wm_requested.dimensions = null;
         window.rendering_scheduled.resend_dimensions = true;
     }
+    wm_requested.dimensions = null;
 
     const track_configure = switch (window.impl) {
         .toplevel => |*toplevel| toplevel.configure(),
@@ -698,6 +723,16 @@ pub fn renderFinish(window: *Window) void {
         .height = window.rendering_sent.height,
     };
 
+    if (window.wm_requested.fullscreen) |output| {
+        window.box.x = output.sent.x;
+        window.box.y = output.sent.y;
+        window.fullscreen_background.node.setEnabled(true);
+        const width, const height = output.sent.dimensions();
+        window.fullscreen_background.setSize(width, height);
+    } else {
+        window.fullscreen_background.node.setEnabled(false);
+    }
+
     window.tree.node.setPosition(window.box.x, window.box.y);
     window.popup_tree.node.setPosition(window.box.x, window.box.y);
 
diff --git a/river/WindowManager.zig b/river/WindowManager.zig
index 069c635..c8abd94 100644
--- a/river/WindowManager.zig
+++ b/river/WindowManager.zig
@@ -424,18 +424,26 @@ fn renderFinish(wm: *WindowManager) void {
     }
 
     {
+        var found_fullscreen: bool = false;
         var it = wm.rendering_requested.list.iterator(.forward);
         while (it.next()) |node| {
             switch (node.get()) {
                 .window => |window| {
                     window.renderFinish();
-
                     window.tree.node.reparent(server.scene.layers.wm);
-                    window.tree.node.raiseToTop();
+                    if (window.wm_requested.fullscreen != null) {
+                        found_fullscreen = true;
+                        window.tree.node.raiseToTop();
+                    }
+                    // Rendering order for windows below the top fullscreen window
+                    // (if present) does not matter, as they will be fully obscured
+                    // by the opaque black fullscreen background rect.
+                    if (!found_fullscreen) {
+                        window.tree.node.raiseToTop();
+                    }
                 },
                 .shell_surface => |shell_surface| {
                     shell_surface.renderFinish();
-
                     shell_surface.tree.node.reparent(server.scene.layers.wm);
                     shell_surface.tree.node.raiseToTop();
                 },
diff --git a/river/XdgToplevel.zig b/river/XdgToplevel.zig
index e9ee408..a9259c8 100644
--- a/river/XdgToplevel.zig
+++ b/river/XdgToplevel.zig
@@ -159,7 +159,7 @@ pub fn configure(toplevel: *XdgToplevel) bool {
         .minimize = scheduled.capabilities.minimize,
     });
     _ = wlr_toplevel.setMaximized(scheduled.maximized);
-    _ = wlr_toplevel.setFullscreen(scheduled.fullscreen);
+    _ = wlr_toplevel.setFullscreen(scheduled.inform_fullscreen);
     _ = wlr_toplevel.setResizing(scheduled.resizing);
     if (toplevel.decoration) |decoration| {
         _ = decoration.wlr_decoration.setMode(if (scheduled.ssd) .server_side else .client_side);
@@ -201,7 +201,7 @@ fn needsConfigure(toplevel: *XdgToplevel) bool {
     if (!std.meta.eql(scheduled.tiled, sent.tiled)) return true;
     if (!std.meta.eql(scheduled.capabilities, sent.capabilities)) return true;
     if (scheduled.maximized != sent.maximized) return true;
-    if (scheduled.fullscreen != sent.fullscreen) return true;
+    if (scheduled.inform_fullscreen != sent.inform_fullscreen) return true;
     if ((scheduled.resizing) != (sent.resizing)) return true;
 
     return false;
@@ -365,7 +365,17 @@ fn handleCommit(listener: *wl.Listener(*wlr.Surface), _: *wlr.Surface) void {
 
 fn handleRequestFullscreen(listener: *wl.Listener(void)) void {
     const toplevel: *XdgToplevel = @fieldParentPtr("request_fullscreen", listener);
-    toplevel.window.setFullscreenRequested(toplevel.wlr_toplevel.requested.fullscreen);
+    if (toplevel.wlr_toplevel.requested.fullscreen) {
+        if (toplevel.wlr_toplevel.requested.fullscreen_output) |wlr_output| {
+            const output: *Output = @ptrCast(@alignCast(wlr_output.data));
+            toplevel.window.wm_scheduled.fullscreen_requested = .{ .fullscreen = output };
+        } else {
+            toplevel.window.wm_scheduled.fullscreen_requested = .{ .fullscreen = null };
+        }
+    } else {
+        toplevel.window.wm_scheduled.fullscreen_requested = .exit;
+    }
+    server.wm.dirtyWindowing();
 }
 
 fn handleRequestMove(
diff --git a/river/XwaylandWindow.zig b/river/XwaylandWindow.zig
index d60d0df..7794209 100644
--- a/river/XwaylandWindow.zig
+++ b/river/XwaylandWindow.zig
@@ -278,7 +278,12 @@ fn handleSetDecorations(listener: *wl.Listener(void)) void {
 
 fn handleRequestFullscreen(listener: *wl.Listener(void)) void {
     const xwindow: *XwaylandWindow = @fieldParentPtr("request_fullscreen", listener);
-    xwindow.window.setFullscreenRequested(xwindow.xsurface.fullscreen);
+    if (xwindow.xsurface.fullscreen) {
+        xwindow.window.wm_scheduled.fullscreen_requested = .{ .fullscreen = null };
+    } else {
+        xwindow.window.wm_scheduled.fullscreen_requested = .exit;
+    }
+    server.wm.dirtyWindowing();
 }
 
 /// Some X11 clients will minimize themselves regardless of how we respond.