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

commit653389ec52e19ee1752d426abe052ea8de84d729
parent609627ca9f
authorLucas Galante <[email protected]>
date2026-09-11 15:08
feat(stacking): floating windows are a plane in front of the tiled ones

A tiled window could bury a floating one. The reorder pass stacks every
ordinary window in layers.wm by render-list order alone, and a focus click
moves a window to the tail of that list — so clicking a tiled window put it
over every floating window it overlapped, and a floating window fully inside
a screen-filling tiled one vanished, taking its clicks with it (scene order
is what wlr_scene_node_at reads).

Floating and tiled are meant to be independent: the floating plane now
stacks in front of the tiled one, whichever was focused last. The pass walks
the render list a second time and re-raises the floating windows, so each
plane keeps its OWN relative order — raise order within the plane is
untouched, and a click still raises a window among its own kind.

It belongs here, in the stacking authority, for the reason the light_source
raise beside it does: raise_window cannot own the rule, because every other
path that reorders the list would then have to know it too. Only the windows
the loop actually parked in layers.wm take part, tested through the parent it
just set, so fullscreen/popup/status/circular windows keep their own layers.
Utility rides with floating (a client-declared tool window floats and moves
like any other); Overlay keeps its own overlay_behavior rule.

The matching half is in cce-window-manager 70cbee6: an overview drag no
longer displaces windows of the other mode. Together they make the two modes
independent — neither covers nor shifts the other.

Consequence worth knowing: a tiled window's xdg popups ride in its own
popup_tree, so a menu opened in a tiled app can now be covered by a floating
window. That follows from the rule; a focused-window exception can be added
if it reads wrong in use.

Verified headless with a tiled cce-files covering the output and a floating
cce-text-editor inside it: before, clicking the tiled window hid the editor
completely and a click at the editor's own coordinates reached cce-files;
after, the editor stays in front and takes its own clicks. Two floating
windows still restack on focus as they did.

Co-Authored-By: Claude Opus 5 <[email protected]>

 src/server/seat.rs           | 12 ++++++++++--
 src/server/window_manager.rs | 46 ++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 56 insertions(+), 2 deletions(-)

diff --git a/src/server/seat.rs b/src/server/seat.rs
index cf433ef..bf25138 100644
--- a/src/server/seat.rs
+++ b/src/server/seat.rs
@@ -1440,6 +1440,7 @@ impl Seat {
                                 displace_covered(
                                     self.server,
                                     win,
+                                    op.start_was_tiled,
                                     (virtual_dx, virtual_dy),
                                     &sp,
                                     &mut self.overview_displaced,
@@ -1665,9 +1666,15 @@ impl Seat {
 /// re-evaluates the window AT THAT SPOT — a drag that stops covering it
 /// releases it back home. The ledger drops with the op on release, which
 /// finalizes wherever everything currently sits.
+///
+/// `moved_tiled` is what the dragged window was when it was GRABBED (the op's
+/// `start_was_tiled`), for the reason the snap call above gives: the drag
+/// un-tiles it on the first motion event, so its live mode reads Floating
+/// however it started. The policy skips candidates of the other kind.
 unsafe fn displace_covered(
     server: *mut Server,
     win: *mut crate::window::Window,
+    moved_tiled: bool,
     drag_delta: (f64, f64),
     sp: &crate::policy::snap::SnapParams,
     ledger: &mut Vec<(*mut crate::window::Window, f64, f64)>,
@@ -1715,8 +1722,9 @@ unsafe fn displace_covered(
             tiled: mode == crate::tiling::TilingMode::Tiled,
         });
     }
-    let moves =
-        crate::policy::overview::displace(moved, drag_delta, &cands, sp, sp.gap_width);
+    let moves = crate::policy::overview::displace(
+        moved, moved_tiled, drag_delta, &cands, sp, sp.gap_width,
+    );
 
     let mut changed = false;
     let mut displaced_now: Vec<*mut crate::window::Window> = Vec::new();
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index 141c454..610100b 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -2503,6 +2503,52 @@ impl WindowManager {
             curr = next;
         }
 
+        // Floating windows are a plane IN FRONT of the tiled ones: a tiled
+        // window never covers a floating one, however recently it was raised.
+        // The loop above stacks layers.wm in render-list order alone, so
+        // clicking a tiled window buried every floating window it overlaps —
+        // and the two modes are meant to be independent, not interleaved.
+        //
+        // Re-applied on every reorder pass, walking the render list again so
+        // each plane keeps its OWN relative stacking: the floating windows
+        // come out in the order they were raised, above the tiled ones in the
+        // order they were raised. A rule in the stacking authority, like the
+        // light_source raise below — `raise_window` cannot own it, because
+        // every other path that reorders the list would then have to know it.
+        //
+        // Only the windows the loop actually parked in layers.wm take part,
+        // tested through the parent it just set: a fullscreen, popup, status
+        // or circular window lives in a layer of its own, where raising it
+        // would reshuffle that layer's members for no reason. `Utility` is
+        // floating furniture too (a client-declared tool window — it floats
+        // and moves like any other), so it rides in the same plane; `Overlay`
+        // keeps its own `overlay_behavior` rule and stays out of this.
+        if reorder {
+            let wm_layer = (*self.server).scene.layers.wm;
+            curr = (*render_list).next;
+            while curr != render_list {
+                let next = (*curr).next;
+                let node = crate::container_of!(curr, crate::wm_node::WmNode, link);
+                if let crate::wm_node::WmNodeType::Window(window) = (*node).get() {
+                    let floats = matches!(
+                        (*window).tiling_mode,
+                        crate::tiling::TilingMode::Floating | crate::tiling::TilingMode::Utility
+                    );
+                    let in_wm_layer = !(*window).tree.is_null()
+                        && !wm_layer.is_null()
+                        && ffi::river_scene_node_get_parent((*window).tree as *mut _) == wm_layer;
+                    if floats && in_wm_layer {
+                        ffi::wlr_scene_node_raise_to_top((*window).tree as *mut _);
+                        ffi::wlr_scene_node_place_above(
+                            (*window).popup_tree as *mut _,
+                            (*window).tree as *mut _,
+                        );
+                    }
+                }
+                curr = next;
+            }
+        }
+
         // 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