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

commit9ec4e6d9c53e1d7487e42a69a2cc880863d2b07c
parent7fd57da0e1
authorLucas Galante <[email protected]>
date2026-07-19 01:23
feat: cast a drop shadow under cce/ssd windows

A raised slab announces its height mostly through what it does to the scene
behind it; without a cast shadow the client-side bevel says "raised" while
the compositing says "coplanar sticker", and the sticker wins. Wire scenefx's
wlr_scene_shadow as the bottom-most child of each window tree: node padded by
sigma on all sides (the box-shadow shader draws the shadow of a box inset by
sigma), displaced by a configurable offset that should point away from the
DE light, with the window's own rounded box punched out via the clipped
region so the shadow darkens only the desktop around a translucent window,
never the window itself.

Synced from both geometry paths (set_rendering_state and the viewport
update), matching the blur radius criteria; disabled for fullscreen and
status windows.

Config, mirroring the border block:
  style.surface.shadow enabled=(bool) blur=(f64,sigma) color=(rgba) \
      offset_x=(i64) offset_y=(i64)
Defaults: enabled, sigma 22, #0000008c, offset (7,7) for the default
top-left light.

Verified in a nested compositor instance (cce-fx -c true --no-xwayland) with
cce-data-editor as the client.

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

 src/server/config.rs | 81 +++++++++++++++++++++++++++++++++++++++++++++++++++-
 src/server/window.rs | 58 +++++++++++++++++++++++++++++++++++++
 2 files changed, 138 insertions(+), 1 deletion(-)

diff --git a/src/server/config.rs b/src/server/config.rs
index 0c29ea2..db764b9 100644
--- a/src/server/config.rs
+++ b/src/server/config.rs
@@ -57,6 +57,16 @@ pub struct Layout {
     pub desktop_cell_corner_radius: i32,
     pub desktop_cell_fade_inset: i64,
     pub desktop_grid_fade_mode: String,
+    /// Drop shadow under cce/ssd windows (scenefx box-shadow node).
+    pub shadow_enabled: bool,
+    /// Gaussian spread in logical px.
+    pub shadow_sigma: f32,
+    /// Premultiplied-alpha RGBA, like `border_color`.
+    pub shadow_color: [f32; 4],
+    /// Displacement of the cast shadow in logical px — should point away from
+    /// `light_source_position` (down-right for the default top-left light).
+    pub shadow_offset_x: i32,
+    pub shadow_offset_y: i32,
     /// Magnetic grid snap for interactive move/resize.
     pub desktop_snap: bool,
     /// Snap radius in virtual units.
@@ -127,6 +137,11 @@ impl Default for Layout {
             desktop_cell_corner_radius: 0,
             desktop_cell_fade_inset: 0,
             desktop_grid_fade_mode: "linear".to_string(),
+            shadow_enabled: true,
+            shadow_sigma: 22.0,
+            shadow_color: [0.0, 0.0, 0.0, 0.55],
+            shadow_offset_x: 7,
+            shadow_offset_y: 7,
             desktop_snap: true,
             desktop_snap_threshold: 24.0,
             scenefx_optimized_blur: true,
@@ -331,7 +346,23 @@ pub struct SurfaceConfig {
     pub border_corner_length: i64,
     #[serde(default = "default_cloud_position_default")]
     pub cloud_position_default: Option<[i32; 2]>,
-}
+    #[serde(default = "default_shadow_enabled")]
+    pub shadow_enabled: bool,
+    #[serde(default = "default_shadow_sigma")]
+    pub shadow_sigma: f64,
+    #[serde(default = "default_shadow_color")]
+    pub shadow_color: String,
+    #[serde(default = "default_shadow_offset_x")]
+    pub shadow_offset_x: i64,
+    #[serde(default = "default_shadow_offset_y")]
+    pub shadow_offset_y: i64,
+}
+
+fn default_shadow_enabled() -> bool { true }
+fn default_shadow_sigma() -> f64 { 22.0 }
+fn default_shadow_color() -> String { "#0000008c".to_string() }
+fn default_shadow_offset_x() -> i64 { 7 }
+fn default_shadow_offset_y() -> i64 { 7 }
 
 impl Default for SurfaceConfig {
     fn default() -> Self {
@@ -356,6 +387,11 @@ impl Default for SurfaceConfig {
             border_segment_gap: default_border_segment_gap(),
             border_corner_length: 0,
             cloud_position_default: default_cloud_position_default(),
+            shadow_enabled: default_shadow_enabled(),
+            shadow_sigma: default_shadow_sigma(),
+            shadow_color: default_shadow_color(),
+            shadow_offset_x: default_shadow_offset_x(),
+            shadow_offset_y: default_shadow_offset_y(),
         }
      }
 }
@@ -1591,6 +1627,44 @@ fn parse_kdl_config(content: &str) -> Result<Config, String> {
                             }
                         }
                     }
+                    if let Some(shadow_node) = surface_children.nodes().iter().find(|n| n.name().value() == "shadow") {
+                        found_nested = true;
+                        for entry in shadow_node.entries() {
+                            if let Some(id) = entry.name() {
+                                match id.value() {
+                                    "enabled" => {
+                                        if let Some(val) = entry.value().as_bool() {
+                                            surface.shadow_enabled = val;
+                                        }
+                                    }
+                                    // Named `blur` to match the status/backplate blur keys.
+                                    "blur" => {
+                                        if let Some(val) = entry.value().as_f64() {
+                                            surface.shadow_sigma = val;
+                                        } else if let Some(val) = entry.value().as_i64() {
+                                            surface.shadow_sigma = val as f64;
+                                        }
+                                    }
+                                    "color" => {
+                                        if let Some(val) = entry.value().as_string() {
+                                            surface.shadow_color = val.to_string();
+                                        }
+                                    }
+                                    "offset_x" => {
+                                        if let Some(val) = entry.value().as_i64() {
+                                            surface.shadow_offset_x = val;
+                                        }
+                                    }
+                                    "offset_y" => {
+                                        if let Some(val) = entry.value().as_i64() {
+                                            surface.shadow_offset_y = val;
+                                        }
+                                    }
+                                    _ => {}
+                                }
+                            }
+                        }
+                    }
                     if let Some(cloud_node) = surface_children.nodes().iter().find(|n| n.name().value() == "cloud") {
                         found_nested = true;
                         if let Some(pos) = get_child_arg_vec2i_opt(cloud_node, "position_default") {
@@ -1764,6 +1838,11 @@ pub fn parse_config(path: &str, state: &mut crate::window_manager::WindowManager
     state.layout.window_backdrop_blur_ignore_transparent = config.layout.window_backdrop_blur_ignore_transparent;
     state.layout.status_module_hide_mode_preview = config.layout.status_module_hide_mode_preview;
     state.layout.cloud_position_default = config.surface.cloud_position_default;
+    state.layout.shadow_enabled = config.surface.shadow_enabled;
+    state.layout.shadow_sigma = config.surface.shadow_sigma.max(0.0) as f32;
+    state.layout.shadow_color = parse_hex_color_rgba(&config.surface.shadow_color);
+    state.layout.shadow_offset_x = config.surface.shadow_offset_x as i32;
+    state.layout.shadow_offset_y = config.surface.shadow_offset_y as i32;
 
     for (key, val) in &config.env {
         let expanded = expand_env_vars(val);
diff --git a/src/server/window.rs b/src/server/window.rs
index 57439d4..bf44382 100644
--- a/src/server/window.rs
+++ b/src/server/window.rs
@@ -288,6 +288,9 @@ pub struct Window {
     pub tree: *mut ffi::wlr_scene_tree,
     pub fullscreen_background: *mut ffi::wlr_scene_rect,
     pub window_background: *mut ffi::wlr_scene_rect,
+    /// scenefx drop shadow, first child of `tree` so it renders beneath
+    /// everything else in the window; null if creation failed (shadow skipped).
+    pub shadow: *mut ffi::wlr_scene_shadow,
     pub decorations_below: ffi::wl_list,
     pub decorations_below_tree: *mut ffi::wlr_scene_tree,
     pub surfaces: crate::scene::SaveableSurfaces,
@@ -428,6 +431,16 @@ impl Window {
         // SceneFX 0.4 does not support restack_xwayland_surfaces
         // (*capture_scene).restack_xwayland_surfaces = false;
 
+        // Created first so it is the bottom-most child: the cast shadow must render
+        // beneath the (translucent) window content and its backgrounds. Geometry and
+        // color are synced per-frame in update_shadow; a null pointer just disables
+        // the effect rather than failing window creation.
+        let shadow_color = [0.0f32, 0.0f32, 0.0f32, 0.55f32];
+        let shadow = ffi::wlr_scene_shadow_create(tree, 0, 0, 0, 22.0, shadow_color.as_ptr());
+        if !shadow.is_null() {
+            ffi::wlr_scene_node_set_enabled(&mut (*shadow).node, false);
+        }
+
         let black_color = [0.0f32, 0.0f32, 0.0f32, 1.0f32];
         let fullscreen_background = ffi::wlr_scene_rect_create(tree, 0, 0, black_color.as_ptr());
         if fullscreen_background.is_null() {
@@ -491,6 +504,7 @@ impl Window {
             tree,
             fullscreen_background,
             window_background,
+            shadow,
             decorations_below: std::mem::zeroed(),
             decorations_below_tree,
             surfaces,
@@ -1885,6 +1899,8 @@ impl Window {
                 // (cf. the window_background rect, which scales it the same way).
                 if legacy_blur { 0 } else { (radius as f64 * self.scale) as i32 },
             );
+            let want_shadow = !is_status && (self.wm_requested.ssd || is_cce_app) && !self.is_fullscreen();
+            self.update_shadow(width, height, radius, want_shadow);
             ffi::river_scene_node_set_opacity(self.tree as *mut ffi::wlr_scene_node, requested.opacity);
 
             ffi::river_scene_node_set_corner_radius(
@@ -2282,6 +2298,8 @@ impl Window {
                     height,
                     if legacy_blur { 0 } else { (radius as f64 * self.scale) as i32 },
                 );
+                let want_shadow = !is_status && (self.wm_requested.ssd || is_cce_app) && !self.is_fullscreen();
+                self.update_shadow(width, height, radius, want_shadow);
             } else {
                 // 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);
@@ -2292,6 +2310,46 @@ impl Window {
         }
     }
 
+    /// Sync the drop shadow with the current geometry. `width`/`height` are the
+    /// content size in device px, `radius` the corner radius in logical px (as
+    /// computed for the blur/rounding paths). scenefx's box-shadow shader draws
+    /// the shadow of a box inset by sigma on all sides of the node box, so the
+    /// node is padded by sigma and offset so the casting box lands exactly on
+    /// the window, displaced by the configured offset — which should point away
+    /// from the light (down-right for the DE's default top-left light). The
+    /// window's own box is punched out via the clipped region so the shadow
+    /// darkens only the desktop around the window, never the (translucent)
+    /// window itself.
+    pub unsafe fn update_shadow(&self, width: i32, height: i32, radius: i32, want: bool) {
+        if self.shadow.is_null() {
+            return;
+        }
+        let node = &mut (*self.shadow).node as *mut ffi::wlr_scene_node;
+        let layout = &(*self.server).wm.layout;
+        let enabled = want && layout.shadow_enabled && width > 0 && height > 0;
+        ffi::wlr_scene_node_set_enabled(node, enabled);
+        if !enabled {
+            return;
+        }
+        let sigma = (layout.shadow_sigma as f64 * self.scale) as f32;
+        let pad = sigma.ceil() as i32;
+        let ox = (layout.shadow_offset_x as f64 * self.scale) as i32;
+        let oy = (layout.shadow_offset_y as f64 * self.scale) as i32;
+        let radius_dev = (radius as f64 * self.scale) as i32;
+        ffi::wlr_scene_shadow_set_color(self.shadow, layout.shadow_color.as_ptr());
+        ffi::wlr_scene_shadow_set_blur_sigma(self.shadow, sigma);
+        ffi::wlr_scene_shadow_set_corner_radius(self.shadow, radius_dev);
+        ffi::wlr_scene_shadow_set_size(self.shadow, width + 2 * pad, height + 2 * pad);
+        ffi::river_scene_node_set_position_if_changed(node, -pad + ox, -pad + oy);
+        let r = radius_dev.clamp(0, u16::MAX as i32) as u16;
+        ffi::wlr_scene_shadow_set_clipped_region(self.shadow, ffi::clipped_region {
+            area: ffi::wlr_box { x: pad - ox, y: pad - oy, width, height },
+            corners: ffi::fx_corner_radii {
+                top_left: r, top_right: r, bottom_right: r, bottom_left: r,
+            },
+        });
+    }
+
     /// Advance the hover fade one tick. Every zone eases toward 1.0 if it is
     /// the one under the pointer and 0.0 otherwise. Returns true while any
     /// zone is still in motion, so the caller knows to schedule another tick.