Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git
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.