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

commit8c63ea8ad8ebb0b7e0f131f270f38a7268292741
parente55210076a
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-03 03:55
Leave a stepped-aside fullscreen window on the desk

A fullscreen window that yielded to another focused window dropped
behind every window but stayed pinned to its output, so a focus chord's
pan (Super+hjkl) left it fixed behind the new window like a backdrop.

While fullscreen, virtual_x/y is now the desk spot the window covers:
set on the enter transition and kept in step with the camera while it
is on top. place_fullscreen_windows (arrange_views) draws a yielded one
at that spot, at output size scaled with the zoom, so the camera pans
away from it. Focusing it again pans the camera to exactly that spot
(focus_follow_pan, via Window::fullscreen_anchor_pan) and it rides the
desk until the ease lands, then pins, with no jump at either end.
save_state keeps recording the pre-fullscreen position.

The window.rs half of this change (fs_on_desk, fullscreen_anchor_pan,
the render-path and manage_finish edits) landed in e5521007, committed
by a concurrent session along with its own changes to that file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 CLAUDE.md                    | 13 +++++++++
 src/server/seat.rs           | 25 ++++++++++++++--
 src/server/window_manager.rs | 69 +++++++++++++++++++++++++++++++++++++++-----
 3 files changed, 97 insertions(+), 10 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 76f553f3..0761fcee 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -944,6 +944,19 @@ over the window you switched to. `Seat::focus` dirties windowing whenever a
 fullscreen window exists, because the stacking pass that applies this only
 runs on a transaction.
 
+**Stepped aside, it stays on the desk** (since 2026-10-03; before, it stayed
+pinned to the output, so a focus chord's pan left it fixed behind the new
+window like a backdrop). While fullscreen, `virtual_x/y` is the desk spot
+the window covers — set on the enter transition in `manage_finish` (after
+`saved_virtual_x/y` takes the restore position) and kept in step with the
+camera while the window is on top — and `place_fullscreen_windows`
+(`arrange_views`) draws a yielded one there at output size, scaled with the
+zoom, so the camera pans away from it. Focusing it again pans the camera to
+exactly that spot (`Window::fullscreen_anchor_pan`, from `focus_follow_pan`)
+and it rides the desk until the ease lands, then pins — no jump at either
+end. `save_state` records `saved_virtual_x/y` for a fullscreen window, as it
+did before the spot existed.
+
 ### xdg-activation
 
 `handle_request_activate` (`server.rs`) runs for every activation wlroots
diff --git a/src/server/seat.rs b/src/server/seat.rs
index 9fa2d932..cfcbca15 100644
--- a/src/server/seat.rs
+++ b/src/server/seat.rs
@@ -1038,9 +1038,9 @@ impl Seat {
 
     /// Focus-follow: pan the camera to a focused Floating/Maximized window —
     /// centering when it is mostly hidden, nudging a clipped edge into view
-    /// otherwise. Fullscreen is pinned to an output and popups/overlays are
-    /// not desk citizens, so other modes no-op, as do cce-cloud and windows
-    /// already fully visible.
+    /// otherwise. Fullscreen pans back onto the desk spot it covers (see
+    /// below); popups/overlays are not desk citizens, so other modes no-op,
+    /// as do cce-cloud and windows already fully visible.
     pub unsafe fn focus_follow_pan(&mut self, window: *mut crate::window::Window) {
         if self.suppress_focus_pan {
             return;
@@ -1052,6 +1052,25 @@ impl Seat {
         if (*self.server).wm.camera_ramp_anim.is_some() {
             return;
         }
+        // A fullscreen window that stepped aside was left on the desk
+        // (`WindowManager::place_fullscreen_windows`); focusing it brings
+        // the camera back to exactly the spot it covers, so it slides in
+        // and lands on its output. Not in overview, where the desk is the
+        // point and the camera stays put.
+        if !window.is_null() && (*window).is_fullscreen() {
+            let wm = &mut (*self.server).wm;
+            if wm.mode == crate::window_manager::WindowManagerMode::Overview {
+                return;
+            }
+            if let Some((px, py)) = (*window).fullscreen_anchor_pan() {
+                if (wm.desk_pan_x - px).abs() >= 0.5 || (wm.desk_pan_y - py).abs() >= 0.5 {
+                    wm.target_desk_pan_x = Some(px);
+                    wm.target_desk_pan_y = Some(py);
+                    wm.start_panning_animation();
+                }
+            }
+            return;
+        }
         if window.is_null()
             || !matches!(
                 (*window).tiling_mode,
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index dd3ae583..b479e087 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -1375,8 +1375,10 @@ impl WindowManager {
                 title: title.clone(),
                 tiling_mode: (*w).tiling_mode,
                 minimized: (*w).minimized,
-                virtual_x: (*w).virtual_x,
-                virtual_y: (*w).virtual_y,
+                // Fullscreen, virtual_x/y is the desk spot the window
+                // covers (`place_fullscreen_windows`), not where it lives.
+                virtual_x: if (*w).was_fullscreen { (*w).saved_virtual_x } else { (*w).virtual_x },
+                virtual_y: if (*w).was_fullscreen { (*w).saved_virtual_y } else { (*w).virtual_y },
                 scale: (*w).scale,
                 width: (*w).box_geom.width as u32,
                 height: (*w).box_geom.height as u32,
@@ -2998,11 +3000,10 @@ impl WindowManager {
                             } else if rendered_fullscreen(window) {
                                 // Stepped aside for a focused window (alt-tab
                                 // out): behind every window but above the
-                                // grid, which the pass below re-asserts.
-                                // Not layers.wm, which carries the camera's
-                                // sub-pixel desk offset — the window is
-                                // pinned to its output, like the layer it
-                                // left.
+                                // grid, which the pass below re-asserts. It
+                                // sits on the desk there
+                                // (`place_fullscreen_windows`), like the grid
+                                // beneath it.
                                 (*self.server).scene.layers.bottom
                             } else if (*window).tiling_mode == crate::tiling::TilingMode::Popup {
                                 (*self.server).scene.layers.popups
@@ -4193,6 +4194,8 @@ impl WindowManager {
             }
         }
 
+        self.place_fullscreen_windows();
+
         // Force configure for all status bar windows so they receive the new
         // geometry immediately, and apply the planned position DIRECTLY.
         // Positions normally land in render_finish, which only reaches
@@ -4255,6 +4258,58 @@ impl WindowManager {
         }
     }
 
+    /// A fullscreen window is pinned to its output only while it owns the
+    /// top of the stack. Stepped aside (`Window::fullscreen_yields`) it
+    /// sits on the desk at the spot it covered — `virtual_x/y`, at output
+    /// size, scaled with the zoom — so a focus chord's pan leaves it behind
+    /// like any other window. Until 2026-10-03 it stayed pinned behind the
+    /// windows the camera panned to, as if it were the backdrop. It also
+    /// rides the desk while the camera eases back onto it after a refocus
+    /// (`Seat::focus_follow_pan` aims at `fullscreen_anchor_pan`), so it
+    /// slides in and lands pinned with nothing to jump.
+    ///
+    /// While pinned, its desk spot follows the camera: whatever the camera
+    /// shows is what the window covers, so a pan made while it is on top
+    /// moves where it will be left. The policy reads the same spot, so
+    /// directional focus measures from it. Not before `was_fullscreen`:
+    /// `manage_finish` saves `virtual_x/y` as the restore position on the
+    /// entering transition (and sets the first desk spot there), which
+    /// this would overwrite.
+    unsafe fn place_fullscreen_windows(&mut self) {
+        let zoom = self.desk_zoom;
+        for &w in self.windows.iter() {
+            if w.is_null() || (*w).closed {
+                continue;
+            }
+            let fullscreen = rendered_fullscreen(w)
+                && (*w).was_fullscreen
+                && matches!((*w).state, crate::window::WindowState::Mapped);
+            if !fullscreen {
+                (*w).fs_on_desk = false;
+                continue;
+            }
+            let returning = match ((*w).fullscreen_anchor_pan(), self.target_desk_pan_x, self.target_desk_pan_y) {
+                (Some((ax, ay)), Some(tx), Some(ty)) => (tx - ax).abs() < 0.5 && (ty - ay).abs() < 0.5,
+                _ => false,
+            };
+            let on_desk = returning || (*w).fullscreen_yields();
+            if on_desk {
+                let (sx, sy) = (*w).virtual_to_screen((*w).virtual_x, (*w).virtual_y);
+                (*w).rendering_requested.x = sx;
+                (*w).rendering_requested.y = sy;
+                (*w).scale = zoom;
+            } else {
+                let output = (*w).fullscreen_output();
+                if !output.is_null() {
+                    let (vx, vy) = (*w).screen_to_virtual((*output).sent.x, (*output).sent.y);
+                    (*w).virtual_x = vx;
+                    (*w).virtual_y = vy;
+                }
+            }
+            (*w).fs_on_desk = on_desk;
+        }
+    }
+
     pub unsafe fn update_viewport_local(&mut self) {
         let zoom_changed = self.desk_zoom != self.last_viewport_zoom;
         // A pan counts as motion only once it moves a DEVICE pixel: the