Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git
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