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

commit8ba4945b8737cb3e1ebb381800b8bd494834c86b
parent90fa82d540
authorLucas Galante <[email protected]>
date2026-09-06 13:06
fix(fullscreen, corners): restore the pre-fullscreen mode; crop CSD toplevels to their geometry

Toggling fullscreen on and off left Claude desktop with square corners.
Two things stacked:

1. The toggle's exit unlocked the window to its resolved mode, so a
   hand-tiled window came back Floating (cce-window-manager@ pairs with
   this: ActionWindow.pre_fullscreen). The SetWindowMode arm now records
   the (mode, locked) a Fullscreen request replaces and hands it to the
   policy; on the way back to Tiled it also restores `was_tiled`, since
   the arrange pass's Enter transition would otherwise snapshot the still
   output-sized box as the window's floating geometry and a later un-tile
   popped the window to screen size (seen in a headless run).

2. Floating Chromium/Electron windows draw a translucent shadow band
   outside their xdg geometry. Corners are rounded per scene buffer at the
   buffer's edge, so the rounding landed in that band and the visible
   window stayed square. apply_surface_clip now crops a CSD toplevel to
   its geometry (skipped for SSD, while a cce-ui popover overhangs the
   geometry, and mid fullscreen animation). The clip had been set before
   and was nulled in 34b3ae64 because the scaling passes rewrote every
   buffer's dest size and position from the full surface on each commit
   and stretched the crop back out; they now derive both from the clip
   (river_scene_buffer_get_surface_clip, surface_buffer_extent), and the
   scaled opaque region is brought into clipped buffer space first.

Verified headless at output scale 2: a GTK4 CSD dialog (760x404 buffer,
716x360 geometry) is shown at exactly its geometry at zoom 1 and at
geometry x zoom in overview, positioned at the window box; a tiled cce-ui
window survives the round trip Tiled, a floating one Floating, and its
saved floating size is intact afterwards.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

 src/server/window.rs             | 78 ++++++++++++++++++++++++++++++++++------
 src/server/window_manager.rs     | 28 +++++++++++++++
 src/server/wlroots_log_wrapper.c | 28 +++++++++++++++
 wrapper.h                        |  1 +
 4 files changed, 124 insertions(+), 11 deletions(-)

diff --git a/src/server/window.rs b/src/server/window.rs
index dccd48a..8b98e30 100644
--- a/src/server/window.rs
+++ b/src/server/window.rs
@@ -448,6 +448,10 @@ pub struct Window {
     /// border-fade tick. `render_finish` draws at this rect (position, buffer
     /// stretch, backdrop, clip) instead of the settled geometry.
     pub fs_anim: Option<FsAnim>,
+    /// Mode and lock this window had when a `SetWindowMode` made it
+    /// Fullscreen; the policy's fullscreen toggle restores both on exit.
+    /// Cleared by any `SetWindowMode` to another mode.
+    pub pre_fullscreen: Option<(crate::tiling::TilingMode, bool)>,
     pub circular: bool,
     pub blur: bool,
     pub scale: f64,
@@ -733,6 +737,7 @@ impl Window {
             has_parent: false,
             minimized: false,
             fs_anim: None,
+            pre_fullscreen: None,
             circular: false,
             blur: false,
             scale: 1.0,
@@ -2824,19 +2829,20 @@ impl Window {
 
                 let surface = ffi::river_scene_node_get_surface(node);
                 if !surface.is_null() {
-                    let w = ffi::river_wlr_surface_get_width(surface);
-                    let h = ffi::river_wlr_surface_get_height(surface);
+                    let (w, h, ox, oy) = surface_buffer_extent(buffer, surface);
                     if data.scale_x == 1.0 && data.scale_y == 1.0 {
                         ffi::river_scene_buffer_set_dest_size_if_changed(buffer, w, h);
-                        ffi::river_scene_node_set_position_if_changed(node, 0, 0);
+                        ffi::river_scene_node_set_position_if_changed(node, ox, oy);
                     } else {
                         let dest_w = (w as f64 * data.scale_x) as i32;
                         let dest_h = (h as f64 * data.scale_y) as i32;
                         ffi::river_scene_buffer_set_dest_size_if_changed(buffer, dest_w, dest_h);
 
+                        // The parent offset scales like the content; the
+                        // clip origin rides on top of it, scaled the same.
                         let (px, py) = get_parent_position_relative_to(node, data.ancestor);
-                        let dest_x = (px as f64 * (data.scale_x - 1.0)) as i32;
-                        let dest_y = (py as f64 * (data.scale_y - 1.0)) as i32;
+                        let dest_x = (px as f64 * (data.scale_x - 1.0) + ox as f64 * data.scale_x) as i32;
+                        let dest_y = (py as f64 * (data.scale_y - 1.0) + oy as f64 * data.scale_y) as i32;
                         ffi::river_scene_node_set_position_if_changed(node, dest_x, dest_y);
                     }
                     // Keep the opaque region in step with the dest scale —
@@ -3076,19 +3082,18 @@ impl Window {
 
             let surface = ffi::river_scene_node_get_surface(node);
             if !surface.is_null() {
-                let w = ffi::river_wlr_surface_get_width(surface);
-                let h = ffi::river_wlr_surface_get_height(surface);
+                let (w, h, ox, oy) = surface_buffer_extent(buffer, surface);
                 if data.scale == 1.0 {
                     ffi::river_scene_buffer_set_dest_size_if_changed(buffer, w, h);
-                    ffi::river_scene_node_set_position_if_changed(node, 0, 0);
+                    ffi::river_scene_node_set_position_if_changed(node, ox, oy);
                 } else {
                     let dest_w = (w as f64 * data.scale) as i32;
                     let dest_h = (h as f64 * data.scale) as i32;
                     ffi::river_scene_buffer_set_dest_size_if_changed(buffer, dest_w, dest_h);
 
                     let (px, py) = get_parent_position_relative_to(node, data.ancestor);
-                    let dest_x = (px as f64 * (data.scale - 1.0)) as i32;
-                    let dest_y = (py as f64 * (data.scale - 1.0)) as i32;
+                    let dest_x = (px as f64 * (data.scale - 1.0) + ox as f64 * data.scale) as i32;
+                    let dest_y = (py as f64 * (data.scale - 1.0) + oy as f64 * data.scale) as i32;
                     ffi::river_scene_node_set_position_if_changed(node, dest_x, dest_y);
                 }
                 // Keep the opaque region in step with the dest scale —
@@ -4078,9 +4083,37 @@ impl Window {
             _ => {}
         }
 
+        // Crop a CSD toplevel to its xdg geometry. Chromium-family clients
+        // paint a translucent shadow band outside the geometry whenever they
+        // are not maximized; the compositor draws its own shadow, and it
+        // rounds corners per buffer at the buffer's edge, so uncropped the
+        // rounding fell in that band and the visible window read
+        // square-cornered (an Electron window un-tiled by a fullscreen round
+        // trip). A geometry clip was set once and nulled in 34b3ae64: the
+        // scaling passes rewrote every buffer's dest size from the full
+        // surface each commit and stretched the crop back out — they go
+        // through surface_buffer_extent now. Skipped while a cce-ui client
+        // has a popover overhanging its geometry (set_popover_region): that
+        // rim is live menu content, not a shadow. And skipped mid
+        // fullscreen-toggle, where the animation owns the buffers' stretch.
+        let mut crop = ffi::wlr_box { x: 0, y: 0, width: 0, height: 0 };
+        if let WindowImpl::Toplevel(toplevel) = self.impl_type {
+            if !toplevel.is_null()
+                && !self.wm_requested.ssd
+                && self.popover_region.is_none()
+                && self.fs_anim.is_none()
+            {
+                crop = (*toplevel).geometry;
+            }
+        }
+        let clip: *const ffi::wlr_box = if crop.width > 0 && crop.height > 0 {
+            &crop
+        } else {
+            std::ptr::null()
+        };
         let children_head = ffi::river_scene_tree_get_children(self.surfaces.tree) as *mut WlList;
         if (*children_head).next != children_head {
-            ffi::wlr_scene_subsurface_tree_set_clip(self.surfaces.tree as *mut ffi::wlr_scene_node, std::ptr::null());
+            ffi::wlr_scene_subsurface_tree_set_clip(self.surfaces.tree as *mut ffi::wlr_scene_node, clip);
         }
     }
 }
@@ -5108,6 +5141,29 @@ pub static mut DECORATION_ROLE: ffi::wlr_surface_role = ffi::wlr_surface_role {
     destroy: Some(dec_role_destroy),
 };
 
+/// A surface buffer's visible extent for the scaling passes: `(width,
+/// height, x, y)` — the subsurface clip when one is set (the xdg geometry,
+/// see `apply_surface_clip`), placed where wlroots puts the cropped content
+/// in its parent, else the whole surface at the origin. wlroots re-derives
+/// dest size and position from the clip on every commit; a pass that
+/// overrides them from the full surface size stretches the crop back out.
+unsafe fn surface_buffer_extent(
+    buffer: *mut ffi::wlr_scene_buffer,
+    surface: *mut ffi::wlr_surface,
+) -> (i32, i32, i32, i32) {
+    let mut clip = ffi::wlr_box { x: 0, y: 0, width: 0, height: 0 };
+    if ffi::river_scene_buffer_get_surface_clip(buffer, &mut clip) {
+        (clip.width, clip.height, clip.x, clip.y)
+    } else {
+        (
+            ffi::river_wlr_surface_get_width(surface),
+            ffi::river_wlr_surface_get_height(surface),
+            0,
+            0,
+        )
+    }
+}
+
 unsafe fn get_parent_position_relative_to(
     node: *mut ffi::wlr_scene_node,
     ancestor: *mut ffi::wlr_scene_node,
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index ffd1758..fdaacad 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -1420,6 +1420,7 @@ impl WindowManager {
                 scale: (*w).scale,
                 mode: (*w).tiling_mode,
                 resolved_mode,
+                pre_fullscreen: (*w).pre_fullscreen,
                 visible,
                 focus_cyclable,
                 overview_eligible,
@@ -5800,6 +5801,33 @@ impl crate::policy::api::Compositor for WindowManager {
                 Command::SetWindowMode { id, mode, locked } => {
                     if let Some(&win) = self.windows.get(id.0) {
                         if !win.is_null() && !(*win).closed {
+                            // Remember what Fullscreen replaced, so the
+                            // toggle's exit can put it back (policy
+                            // `actions::fullscreen`). A re-lock while
+                            // already Fullscreen keeps the first record.
+                            if mode == crate::tiling::TilingMode::Fullscreen {
+                                if (*win).tiling_mode != crate::tiling::TilingMode::Fullscreen {
+                                    (*win).pre_fullscreen = Some(((*win).tiling_mode, (*win).mode_locked));
+                                }
+                            } else {
+                                // Coming back Tiled from Fullscreen is a
+                                // RETURN, not a fresh entry: the arrange
+                                // pass's Enter transition would snapshot
+                                // the current box — still the output-sized
+                                // fullscreen box at this point — as the
+                                // window's floating geometry, and a later
+                                // un-tile would pop it to screen size.
+                                // Restoring `was_tiled` with the mode keeps
+                                // the floating geometry saved before the
+                                // window was tiled in the first place.
+                                if (*win).tiling_mode == crate::tiling::TilingMode::Fullscreen
+                                    && mode == crate::tiling::TilingMode::Tiled
+                                    && matches!((*win).pre_fullscreen, Some((crate::tiling::TilingMode::Tiled, _)))
+                                {
+                                    (*win).was_tiled = true;
+                                }
+                                (*win).pre_fullscreen = None;
+                            }
                             (*win).tiling_mode = mode;
                             (*win).mode_locked = locked;
                         }
diff --git a/src/server/wlroots_log_wrapper.c b/src/server/wlroots_log_wrapper.c
index c1ebb03..f704e9d 100644
--- a/src/server/wlroots_log_wrapper.c
+++ b/src/server/wlroots_log_wrapper.c
@@ -918,11 +918,39 @@ int river_scene_buffer_get_height(struct wlr_scene_buffer *scene_buffer) {
  * margins. Culling then skipped repainting behind the shadow ring: stale
  * pixels showed through the translucent shadow around focused windows when
  * zoomed (worst at the bottom, where Chromium's ring is tallest). */
+/* The subsurface clip on a surface buffer (wlr_scene_subsurface_tree_set_clip,
+ * applied by the window's apply_surface_clip as the xdg geometry): the part of
+ * the surface this buffer shows, in surface coordinates. wlroots crops the
+ * buffer's source box to it and sizes/positions the node from it on every
+ * commit, so every pass that rewrites a buffer's dest size or position must
+ * work from this extent rather than the surface's full size — or it stretches
+ * the cropped source back out to the whole surface. False (and an empty box)
+ * when the buffer is not a surface's or nothing is clipped. */
+bool river_scene_buffer_get_surface_clip(struct wlr_scene_buffer *scene_buffer,
+		struct wlr_box *out) {
+	*out = (struct wlr_box){0};
+	struct wlr_scene_surface *scene_surface = wlr_scene_surface_try_from_buffer(scene_buffer);
+	if (!scene_surface) {
+		return false;
+	}
+	*out = scene_surface->WLR_PRIVATE.clip;
+	return !wlr_box_empty(out);
+}
+
 void river_scene_buffer_set_scaled_opaque_region(struct wlr_scene_buffer *scene_buffer,
 		struct wlr_surface *surface, double scale) {
 	pixman_region32_t scaled;
 	pixman_region32_init(&scaled);
 	pixman_region32_copy(&scaled, &surface->opaque_region);
+	/* A clipped buffer shows only `clip` of the surface, at its own origin:
+	 * bring the region into buffer space before scaling, or the part of it
+	 * that lies in the cropped-away margin claims pixels the node never
+	 * paints. */
+	struct wlr_box clip;
+	if (river_scene_buffer_get_surface_clip(scene_buffer, &clip)) {
+		pixman_region32_translate(&scaled, -clip.x, -clip.y);
+		pixman_region32_intersect_rect(&scaled, &scaled, 0, 0, clip.width, clip.height);
+	}
 	wlr_region_scale(&scaled, &scaled, (float)scale);
 	wlr_scene_buffer_set_opaque_region(scene_buffer, &scaled);
 	pixman_region32_fini(&scaled);
diff --git a/wrapper.h b/wrapper.h
index 68d244c..ddac0dc 100644
--- a/wrapper.h
+++ b/wrapper.h
@@ -274,6 +274,7 @@ void river_scene_buffer_set_dest_size_if_changed(struct wlr_scene_buffer *scene_
 int river_scene_buffer_get_width(struct wlr_scene_buffer *scene_buffer);
 int river_scene_buffer_get_height(struct wlr_scene_buffer *scene_buffer);
 void river_scene_buffer_set_scaled_opaque_region(struct wlr_scene_buffer *scene_buffer, struct wlr_surface *surface, double scale);
+bool river_scene_buffer_get_surface_clip(struct wlr_scene_buffer *scene_buffer, struct wlr_box *out);
 int river_scene_buffer_get_dest_width(struct wlr_scene_buffer *scene_buffer);
 int river_scene_buffer_get_dest_height(struct wlr_scene_buffer *scene_buffer);
 bool river_scene_node_get_enabled(struct wlr_scene_node *node);