Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git
Let a fullscreen window step aside when focus moves away
A fullscreen window lived in layers.fullscreen above every desk window,
so switching away from it (Super+Tab, focus-window, a focus chord)
focused a window that stayed hidden underneath. Now a fullscreen window
that a more recently focused desk window has displaced drops to
layers.bottom, behind every window and above the grid, while staying
fullscreen. Focusing it again puts it back on top.
The test reads focus_history rather than live seat focus, so overlay UI
such as the launcher or the switcher does not pop it back. Seat::focus
dirties windowing while a fullscreen window exists, since the restack
only runs on a transaction.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md | 16 +++++++++++++++
src/server/seat.rs | 6 ++++++
src/server/window.rs | 48 ++++++++++++++++++++++++++++++++++++++++++++
src/server/window_manager.rs | 37 ++++++++++++++++++++++++++++++++--
4 files changed, 105 insertions(+), 2 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 37e7677f..bd3c9eb4 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -897,6 +897,22 @@ motion and a hold do not count. Swipes were added on 2026-09-26: a login
navigated only by three-finger swipes left each restored window's first
focus wherever it sat, often half off screen, while a second focus panned.
+### Fullscreen steps aside for focus
+
+A fullscreen window lives in `layers.fullscreen`, above every desk window, so
+until 2026-10-01 focusing another window (the Super+Tab switcher,
+`focus-window`, a focus chord) moved the keyboard to a window nobody could
+see. Now `Window::fullscreen_yields` drops it to `layers.bottom` — behind
+every window, above the grid — whenever a desk window (Floating, Tiled,
+Utility, another Fullscreen; not its own dialogs, a popup or a status
+segment) is ahead of it in `focus_history`. It stays fullscreen: the client
+is not resized or told anything. Focusing it again puts it back on top. The
+predicate reads the MRU history, not live seat focus, so a launcher or the
+switcher opening (overlay UI never enters the history) does not pop it back
+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.
+
### 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 38705cfe..9fa2d932 100644
--- a/src/server/seat.rs
+++ b/src/server/seat.rs
@@ -536,6 +536,12 @@ impl Seat {
if let Focus::Window(window) = new_focus {
if !window.is_null() {
(*self.server).wm.record_focus(window);
+ // Focus decides whether a fullscreen window stays on top
+ // (`Window::fullscreen_yields`); restack whenever one is up,
+ // since not every path to here dirties on its own.
+ if (*self.server).wm.windows.iter().any(|&w| !w.is_null() && !(*w).closed && (*w).is_fullscreen()) {
+ (*self.server).wm.dirty_windowing();
+ }
}
}
(*self.server).wm.update_status();
diff --git a/src/server/window.rs b/src/server/window.rs
index a68e360b..731192cf 100644
--- a/src/server/window.rs
+++ b/src/server/window.rs
@@ -636,6 +636,54 @@ impl Window {
|| !self.wm_requested.fullscreen.is_null()
}
+ /// A fullscreen window has stepped aside for another: a window focused
+ /// more recently than it is still up (the switcher, `focus-window`, a
+ /// focus chord). It stays fullscreen — the client keeps its size and
+ /// mode — but the stacking pass drops it out of `layers.fullscreen` to
+ /// behind every window, or the window just focused would be drawn under
+ /// it. Focusing it again brings it back on top.
+ ///
+ /// Read from the focus history, not the seat's live focus: overlay UI
+ /// (a launcher, the switcher itself) never enters the history, so
+ /// opening one over the window you switched to does not pop the
+ /// fullscreen one back over it. Only a desk window displaces it — not
+ /// its own popups or dialogs, a status segment, or a window that has
+ /// since been minimized or unmapped.
+ pub unsafe fn fullscreen_yields(&self) -> bool {
+ let me = self as *const Window as *mut Window;
+ for &w in (*self.server).wm.focus_history.iter() {
+ if w == me {
+ return false;
+ }
+ if w.is_null()
+ || (*w).closed
+ || (*w).minimized
+ || !matches!((*w).state, WindowState::Mapped)
+ || !matches!(
+ (*w).tiling_mode,
+ crate::tiling::TilingMode::Floating
+ | crate::tiling::TilingMode::Tiled
+ | crate::tiling::TilingMode::Utility
+ | crate::tiling::TilingMode::Fullscreen
+ )
+ {
+ continue;
+ }
+ // A dialog of this window opens over it, fullscreen or not.
+ let mut p = (*w).get_parent();
+ let mut depth = 0;
+ while !p.is_null() && p != me && depth < 16 {
+ p = (*p).get_parent();
+ depth += 1;
+ }
+ if p == me {
+ continue;
+ }
+ return true;
+ }
+ false
+ }
+
pub unsafe fn role(&self) -> crate::policy::api::WindowRole {
if self.grid_declared {
return crate::policy::api::WindowRole::Grid;
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index 8809643d..d413a4d5 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -2848,6 +2848,7 @@ impl WindowManager {
crate::wm_node::WmNodeType::Window(window) => {
(*window).ref_key.hash(&mut hasher);
rendered_fullscreen(window).hash(&mut hasher);
+ fullscreen_on_top(window).hash(&mut hasher);
(*window).rendering_requested.circular.hash(&mut hasher);
(*window).rendering_requested.hidden.hash(&mut hasher);
(*window).tiling_mode.hash(&mut hasher);
@@ -2908,8 +2909,17 @@ impl WindowManager {
// render-list order, burying whichever windows
// happened to map before it.
(*self.server).scene.layers.bottom
- } else if rendered_fullscreen(window) {
+ } else if fullscreen_on_top(window) {
(*self.server).scene.layers.fullscreen
+ } 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.
+ (*self.server).scene.layers.bottom
} else if (*window).tiling_mode == crate::tiling::TilingMode::Popup {
(*self.server).scene.layers.popups
} else if (*window).tiling_mode == crate::tiling::TilingMode::Status {
@@ -2947,7 +2957,7 @@ impl WindowManager {
} else {
ffi::wlr_scene_node_raise_to_top((*window).tree as *mut _);
}
- if !(*window).rendering_requested.hidden && rendered_fullscreen(window) {
+ if fullscreen_on_top(window) {
found_fullscreen = true;
}
@@ -3029,6 +3039,23 @@ impl WindowManager {
}
}
+ // A fullscreen window that stepped aside shares layers.bottom with the
+ // grid, which the loop raises in render-list order; keep the window
+ // over it — the grid is the desk, and the window sits on the desk.
+ if reorder {
+ for &w in self.windows.iter() {
+ if !w.is_null()
+ && !(*w).closed
+ && matches!((*w).state, crate::window::WindowState::Mapped)
+ && rendered_fullscreen(w)
+ && !fullscreen_on_top(w)
+ {
+ ffi::wlr_scene_node_raise_to_top((*w).tree as *mut ffi::wlr_scene_node);
+ ffi::wlr_scene_node_place_above((*w).popup_tree as *mut _, (*w).tree as *mut _);
+ }
+ }
+ }
+
// The traveling light_source segment crosses over its sibling
// segments; raise it after the loop so it stacks in front of them
// within its layer regardless of render-list order. Re-applied on
@@ -7011,6 +7038,12 @@ unsafe fn rendered_fullscreen(window: *mut Window) -> bool {
(*window).is_fullscreen() && !(*window).rendering_requested.hidden
}
+/// A fullscreen window that owns the top of the stack: rendered fullscreen
+/// and not stepped aside for another focused window (`fullscreen_yields`).
+unsafe fn fullscreen_on_top(window: *mut Window) -> bool {
+ rendered_fullscreen(window) && !(*window).fullscreen_yields()
+}
+
/// Prefer the installed `~/.local/bin/cce-cloud`, falling back to PATH lookup.
fn cce_cloud_cmd() -> String {
if let Ok(home) = std::env::var("HOME") {