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

commit7fd57da0e1ce0fa9ae2e868c49ff77bc44331d2d
parenta0934a937d
authorLucas Galante <[email protected]>
date2026-07-18 18:28
fix: round the backdrop blur to match the window's corner radius

river_scene_node_enable_blur now takes a corner_radius and applies it to the
standard blur node on both the create and the reuse path, so a node recreated
after a blur toggle no longer comes back square. Applying it there rather than
via river_scene_node_set_corner_radius means a blur node can never exist
without a radius — that helper scans a tree's direct children, so it silently
no-ops on the wrong tree, and was never called on the viewport-update path.

The optimized blur node sits under the standard one and cannot be rounded
(wlr_scene_optimized_blur has no radius field), so it is disabled exactly for
windows with rounded corners. All three call sites that drive a toplevel's
blur — set_rendering_state, the viewport update, and xdg_toplevel's commit —
now compute the same radius; disagreement there flipped corners between
rounded and square depending on which ran last.

CCE_BLUR_LEGACY=1 restores the previous behaviour for A/B testing from one
build; it is a temporary diagnostic.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

 src/server/layer_shell.rs        |  4 +++
 src/server/window.rs             | 72 +++++++++++++++++++++++++++++++---------
 src/server/wlroots_log_wrapper.c | 15 ++++++++-
 src/server/xdg_toplevel.rs       | 23 ++++++++++++-
 wrapper.h                        |  2 +-
 5 files changed, 97 insertions(+), 19 deletions(-)

diff --git a/src/server/layer_shell.rs b/src/server/layer_shell.rs
index 85fcaf4..b2980db 100644
--- a/src/server/layer_shell.rs
+++ b/src/server/layer_shell.rs
@@ -629,6 +629,10 @@ unsafe extern "C" fn handle_layer_surface_commit(listener: *mut ffi::wl_listener
             0,
             geom_w,
             geom_h,
+            // 0 preserves existing behaviour: layer surfaces (status bar, etc.) never had a
+            // blur radius applied, and their corner rounding is handled separately. Left
+            // deliberately unchanged so this fix stays scoped to toplevels.
+            0,
         );
     }
 
diff --git a/src/server/window.rs b/src/server/window.rs
index 5d4401a..57439d4 100644
--- a/src/server/window.rs
+++ b/src/server/window.rs
@@ -1829,7 +1829,33 @@ impl Window {
             if is_status {
                 ignore_transparent = (*self.server).wm.layout.status_backdrop_blur_ignore_transparent;
             }
-            let use_optimized = if is_status { false } else { (*self.server).wm.layout.scenefx_optimized_blur };
+            // Hoisted above the blur setup: the blur node needs this radius, and whether
+            // the window wants rounded corners at all decides the optimized-blur question
+            // below.
+            let radius = if self.is_fullscreen() {
+                0
+            } else if requested.circular {
+                let w = self.rendering_sent.width as i32;
+                let h = self.rendering_sent.height as i32;
+                w.min(h) / 2
+            } else if self.wm_requested.ssd || is_cce_app {
+                (*self.server).wm.layout.backplate_corner_radius
+            } else {
+                0
+            };
+            // The optimized blur node sits UNDER the standard one and cannot be rounded
+            // (wlr_scene_optimized_blur has no radius field in the vendored scenefx), so
+            // for a window with rounded corners it would keep painting square corners
+            // underneath a correctly rounded standard blur. Trade the optimization away
+            // exactly where it would be visible, and keep it everywhere else.
+            // TEMP DIAGNOSTIC: CCE_BLUR_LEGACY=1 restores pre-fix behaviour (optimized blur
+            // always on, no radius) so both can be A/B'd from one build.
+            let legacy_blur = std::env::var_os("CCE_BLUR_LEGACY").is_some();
+            let use_optimized = if is_status || (radius > 0 && !legacy_blur) {
+                false
+            } else {
+                (*self.server).wm.layout.scenefx_optimized_blur
+            };
             let toplevel_w = match self.impl_type {
                 WindowImpl::Toplevel(toplevel) => {
                     if toplevel.is_null() { 0 } else { (*toplevel).geometry.width }
@@ -1855,21 +1881,12 @@ impl Window {
                 0,
                 width,
                 height,
+                // width/height above are scaled to device pixels, so the radius must be too
+                // (cf. the window_background rect, which scales it the same way).
+                if legacy_blur { 0 } else { (radius as f64 * self.scale) as i32 },
             );
             ffi::river_scene_node_set_opacity(self.tree as *mut ffi::wlr_scene_node, requested.opacity);
 
-            let radius = if self.is_fullscreen() {
-                0
-            } else if requested.circular {
-                let w = self.rendering_sent.width as i32;
-                let h = self.rendering_sent.height as i32;
-                w.min(h) / 2
-            } else if self.wm_requested.ssd || is_cce_app {
-                (*self.server).wm.layout.backplate_corner_radius
-            } else {
-                0
-            };
-
             ffi::river_scene_node_set_corner_radius(
                 self.surfaces.tree as *mut ffi::wlr_scene_node,
                 radius,
@@ -2218,7 +2235,26 @@ impl Window {
                 if is_status {
                     ignore_transparent = (*self.server).wm.layout.status_backdrop_blur_ignore_transparent;
                 }
-                let use_optimized = if is_status { false } else { (*self.server).wm.layout.scenefx_optimized_blur };
+                // Same radius/optimized reasoning as set_rendering_state. Before, this path
+                // set no radius at all, so a blur node recreated during a pan came back
+                // square and stayed that way.
+                let radius = if self.is_fullscreen() {
+                    0
+                } else if requested.circular {
+                    let w = self.rendering_sent.width as i32;
+                    let h = self.rendering_sent.height as i32;
+                    w.min(h) / 2
+                } else if self.wm_requested.ssd || is_cce_app {
+                    (*self.server).wm.layout.backplate_corner_radius
+                } else {
+                    0
+                };
+                let legacy_blur = std::env::var_os("CCE_BLUR_LEGACY").is_some(); // TEMP DIAGNOSTIC
+                let use_optimized = if is_status || (radius > 0 && !legacy_blur) {
+                    false
+                } else {
+                    (*self.server).wm.layout.scenefx_optimized_blur
+                };
                 let toplevel_w = match self.impl_type {
                     WindowImpl::Toplevel(toplevel) => {
                         if toplevel.is_null() { 0 } else { (*toplevel).geometry.width }
@@ -2244,9 +2280,11 @@ impl Window {
                     0,
                     width,
                     height,
+                    if legacy_blur { 0 } else { (radius as f64 * self.scale) as i32 },
                 );
             } else {
-                ffi::river_scene_node_enable_blur(self.tree as *mut ffi::wlr_scene_node, false, (*self.server).wm.layout.scenefx_optimized_blur, true, 0, 0, 0, 0);
+                // Tearing the blur down: radius is irrelevant, the nodes are destroyed.
+                ffi::river_scene_node_enable_blur(self.tree as *mut ffi::wlr_scene_node, false, (*self.server).wm.layout.scenefx_optimized_blur, true, 0, 0, 0, 0, 0);
             }
 
             self.scale_only_render_finish();
@@ -3265,7 +3303,9 @@ impl Decoration {
         }
         let is_cce_app = app_id.starts_with("cce-");
         let blur_enabled = self.rendering_requested.blur && ((*self.window).wm_requested.ssd || is_cce_app || is_status);
-        ffi::river_scene_node_enable_blur(self.surfaces.tree as *mut ffi::wlr_scene_node, blur_enabled, (*server).wm.layout.scenefx_optimized_blur, ignore_transparent, 0, 0, 0, 0);
+        // Radius 0 preserves existing behaviour on the layer-surface path (see layer_shell.rs)
+        // — it never had a blur radius applied, and this fix is scoped to toplevels.
+        ffi::river_scene_node_enable_blur(self.surfaces.tree as *mut ffi::wlr_scene_node, blur_enabled, (*server).wm.layout.scenefx_optimized_blur, ignore_transparent, 0, 0, 0, 0, 0);
 
         let scale = (*self.window).scale;
         let scaled_x = (self.rendering_requested.offset_x as f64 * scale) as i32;
diff --git a/src/server/wlroots_log_wrapper.c b/src/server/wlroots_log_wrapper.c
index 65b36e9..27ae412 100644
--- a/src/server/wlroots_log_wrapper.c
+++ b/src/server/wlroots_log_wrapper.c
@@ -694,7 +694,14 @@ static void find_buffer_iterator(struct wlr_scene_buffer *buffer, int sx, int sy
 	}
 }
 
-void river_scene_node_enable_blur(struct wlr_scene_node *node, bool enabled, bool optimized, bool ignore_transparent, int x, int y, int width, int height) {
+// `corner_radius` is in the same (scaled, device) pixels as width/height. It is applied
+// here rather than through river_scene_node_set_corner_radius so that a blur node can
+// never exist without it: that helper looks the blur up by scanning a tree's direct
+// children, so aiming it at the wrong tree silently no-ops, and it was never called at
+// all on the viewport-update path. Note the optimized blur node cannot be rounded --
+// wlr_scene_optimized_blur has no radius field -- so callers that need rounded corners
+// must pass optimized = false.
+void river_scene_node_enable_blur(struct wlr_scene_node *node, bool enabled, bool optimized, bool ignore_transparent, int x, int y, int width, int height, int corner_radius) {
 	if (node->type != WLR_SCENE_NODE_TREE) {
 		return;
 	}
@@ -761,6 +768,12 @@ void river_scene_node_enable_blur(struct wlr_scene_node *node, bool enabled, boo
 		wlr_scene_blur_set_size((struct wlr_scene_blur *)std_blur_node, width, height);
 	}
 
+	// Set on both the create and the reuse path: a node reused across a resize keeps its
+	// radius, but one recreated after a blur toggle would otherwise come back square.
+	if (std_blur_node) {
+		wlr_scene_blur_set_corner_radius((struct wlr_scene_blur *)std_blur_node, corner_radius);
+	}
+
 	if (std_blur_node) {
 		wlr_scene_node_set_position(std_blur_node, x, y);
 		struct wlr_scene_buffer *source_buffer = NULL;
diff --git a/src/server/xdg_toplevel.rs b/src/server/xdg_toplevel.rs
index ca39871..98c5eb1 100644
--- a/src/server/xdg_toplevel.rs
+++ b/src/server/xdg_toplevel.rs
@@ -449,8 +449,27 @@ unsafe extern "C" fn handle_commit(listener: *mut ffi::wl_listener, _data: *mut
     let geom_h = (actual_h as f64 * scale) as i32;
     let is_status = (*window).tiling_mode == crate::tiling::TilingMode::Status || 
                     app_id.starts_with("cce-status");
-    let use_optimized = if is_status { false } else { (*(*window).server).wm.layout.scenefx_optimized_blur };
     let is_cce_app = app_id.starts_with("cce-");
+    // Must mirror Window::set_rendering_state's radius exactly: both paths drive the same
+    // blur node, so if they disagree the corners flip between rounded and square depending
+    // on which one ran last.
+    let radius = if (*window).is_fullscreen() {
+        0
+    } else if (*window).rendering_requested.circular {
+        let w = (*window).rendering_sent.width as i32;
+        let h = (*window).rendering_sent.height as i32;
+        w.min(h) / 2
+    } else if (*window).wm_requested.ssd || is_cce_app {
+        (*(*window).server).wm.layout.backplate_corner_radius
+    } else {
+        0
+    };
+    let legacy_blur = std::env::var_os("CCE_BLUR_LEGACY").is_some(); // TEMP DIAGNOSTIC
+    let use_optimized = if is_status || (radius > 0 && !legacy_blur) {
+        false
+    } else {
+        (*(*window).server).wm.layout.scenefx_optimized_blur
+    };
     let blur_enabled = (*window).rendering_requested.blur && ((*window).wm_requested.ssd || is_cce_app || is_status);
     ffi::river_scene_node_enable_blur(
         (*window).tree as *mut ffi::wlr_scene_node,
@@ -461,6 +480,8 @@ unsafe extern "C" fn handle_commit(listener: *mut ffi::wl_listener, _data: *mut
         0,
         geom_w,
         geom_h,
+        // geom_w/h are already scaled to device px; the radius must match.
+        if legacy_blur { 0 } else { (radius as f64 * scale) as i32 },
     );
 
     let capture_node = &mut (*(*window).capture_scene).tree as *mut ffi::wlr_scene_tree as *mut ffi::wlr_scene_node;
diff --git a/wrapper.h b/wrapper.h
index 10300f8..f239a86 100644
--- a/wrapper.h
+++ b/wrapper.h
@@ -258,7 +258,7 @@ struct wlr_seat_client *river_wlr_drag_get_seat_client(struct wlr_drag *drag);
 
 void river_wlr_keyboard_init(struct wlr_keyboard *keyboard, void (*led_update)(struct wlr_keyboard *keyboard, uint32_t leds), const char *name);
 
-void river_scene_node_enable_blur(struct wlr_scene_node *node, bool enabled, bool optimized, bool ignore_transparent, int x, int y, int width, int height);
+void river_scene_node_enable_blur(struct wlr_scene_node *node, bool enabled, bool optimized, bool ignore_transparent, int x, int y, int width, int height, int corner_radius);
 
 void river_scene_node_set_opacity(struct wlr_scene_node *node, float opacity);