Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git
fix: read the plate root block in both KDL spellings; one radius source
The compositor read `style.surface.plate.root` from properties on the
`root` line only. The live config spells the block as child nodes
(`root { blur (f64)0.1; color (rgba)"…"; corner_radius (i64)24 }`), the
form cce-data-editor writes and cce-ui's kdl_to_json reads, so every
cce-ui app drew its root plate at a widened 24 while the compositor
clipped the window at a widened 12 and coloured and blurred the root
plate from its defaults. Visible the moment the main config's radius
moved off the default and the compositor restarted. node_keyed_values
reads a node's keys in both spellings, child form winning, and the
canonical-spelling test now covers the child form with the live
config's own lines.
The radius decision (fullscreen / circular / status / decorated → the
root radius) lived in four copies "mirrored" by comment:
set_rendering_state, render_viewport_update, the toplevel commit path
and root_plate_radius_base. The first three call the fourth now. The
restore placeholders' rects took the raw radius, unwidened, and read
squarer than the windows they stand in for and the cells they sit on;
they widen like both.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
src/server/config.rs | 104 ++++++++++++++++++++++++++++++++-----------
src/server/window.rs | 62 ++++++++------------------
src/server/window_manager.rs | 12 ++---
src/server/xdg_toplevel.rs | 23 +++-------
4 files changed, 110 insertions(+), 91 deletions(-)
diff --git a/src/server/config.rs b/src/server/config.rs
index 2663dd14..770face0 100644
--- a/src/server/config.rs
+++ b/src/server/config.rs
@@ -1265,6 +1265,33 @@ fn expand_env_vars(s: &str) -> String {
result
}
+/// A node's keyed values in BOTH KDL spellings, as `(key, entry)`: its
+/// properties (`root color="…" blur=0.5 corner_radius=24`) and its child
+/// nodes' first positional value (`root { color "…"; blur 0.5;
+/// corner_radius 24 }`). The child form is what cce-data-editor writes and
+/// what cce-ui's `kdl_to_json` reads; a block parsed from properties alone
+/// silently keeps its defaults under it, which is how the compositor ran on
+/// a root radius of 12 while every cce-ui app read the config's 24
+/// (2026-09-28). Properties first, then children, so a key given both ways
+/// takes the child's.
+fn node_keyed_values(node: &kdl::KdlNode) -> Vec<(String, kdl::KdlEntry)> {
+ let mut out: Vec<(String, kdl::KdlEntry)> = node
+ .entries()
+ .iter()
+ .filter_map(|e| e.name().map(|n| (n.value().to_string(), e.clone())))
+ .collect();
+ if let Some(children) = node.children() {
+ for child in children.nodes() {
+ if let Some(e) = child.entries().first() {
+ if e.name().is_none() {
+ out.push((child.name().value().to_string(), e.clone()));
+ }
+ }
+ }
+ }
+ out
+}
+
fn get_child_arg_i64(node: &kdl::KdlNode, child_name: &str, default: i64) -> i64 {
if let Some(children) = node.children() {
for child in children.nodes() {
@@ -2154,39 +2181,44 @@ fn parse_kdl_config(content: &str) -> Result<Config, String> {
.and_then(|c| c.nodes().iter().find(|n| n.name().value() == "root"));
if let Some(root_node) = root_plate_node {
found_nested = true;
- for entry in root_node.entries() {
- if let Some(id) = entry.name() {
- match id.value() {
- "color" => {
- if let Some(val) = entry.value().as_string() {
- surface.root_plate_color = val.to_string();
- }
+ // Both spellings — properties on the `root` line and
+ // child nodes inside `root { … }` — see
+ // `node_keyed_values`. This block is the one the
+ // window silhouette comes from, and cce-ui reads the
+ // same keys either way; reading one spelling here put
+ // the compositor's clip and the apps' root plates on
+ // different radii.
+ for (key, entry) in node_keyed_values(root_node) {
+ match key.as_str() {
+ "color" => {
+ if let Some(val) = entry.value().as_string() {
+ surface.root_plate_color = val.to_string();
}
- "blur" => {
- if let Some(mut val) = entry.value().as_f64() {
- if let Some(ty) = entry.ty() {
- let ty_str = ty.value();
- if ty_str.starts_with("f64:") {
- let range_str = ty_str.trim_start_matches("f64:");
- if let Some(dash_idx) = range_str.find('-') {
- let min_str = &range_str[..dash_idx].trim();
- let max_str = &range_str[dash_idx + 1..].trim();
- if let (Ok(min_f), Ok(max_f)) = (min_str.parse::<f64>(), max_str.parse::<f64>()) {
- val = val.clamp(min_f, max_f);
- }
+ }
+ "blur" => {
+ if let Some(mut val) = entry.value().as_f64() {
+ if let Some(ty) = entry.ty() {
+ let ty_str = ty.value();
+ if ty_str.starts_with("f64:") {
+ let range_str = ty_str.trim_start_matches("f64:");
+ if let Some(dash_idx) = range_str.find('-') {
+ let min_str = &range_str[..dash_idx].trim();
+ let max_str = &range_str[dash_idx + 1..].trim();
+ if let (Ok(min_f), Ok(max_f)) = (min_str.parse::<f64>(), max_str.parse::<f64>()) {
+ val = val.clamp(min_f, max_f);
}
}
}
- surface.root_plate_blur = val;
}
+ surface.root_plate_blur = val;
}
- "corner_radius" => {
- if let Some(val) = entry.value().as_i64() {
- surface.root_plate_corner_radius = val;
- }
+ }
+ "corner_radius" => {
+ if let Some(val) = entry.value().as_i64() {
+ surface.root_plate_corner_radius = val;
}
- _ => {}
}
+ _ => {}
}
}
}
@@ -3013,6 +3045,28 @@ style {
assert_eq!(config.surface.root_plate_corner_radius, 21, "canonical read; legacy ignored");
assert_eq!(config.surface.root_plate_color, "#11223344");
+ // The child-node spelling of the same block — what cce-data-editor
+ // writes and what a hand-kept config tends to grow into — reads the
+ // same. Until 2026-09-28 it did not, and the compositor sat on its
+ // default radius under root plates the apps drew at the config's.
+ let children = r##"
+style {
+ surface {
+ plate {
+ root {
+ blur (f64)0.1
+ color (rgba)"#5e657acf"
+ corner_radius (i64)24
+ }
+ }
+ }
+}
+"##;
+ let config = parse_kdl_config(children).unwrap();
+ assert_eq!(config.surface.root_plate_corner_radius, 24, "child-node spelling");
+ assert_eq!(config.surface.root_plate_color, "#5e657acf");
+ assert!((config.surface.root_plate_blur - 0.1).abs() < 1e-9);
+
// The legacy spelling alone is not read: the defaults stand.
let legacy = r##"
style {
diff --git a/src/server/window.rs b/src/server/window.rs
index 61c294ea..b2aeac30 100644
--- a/src/server/window.rs
+++ b/src/server/window.rs
@@ -3086,25 +3086,11 @@ impl Window {
}
// 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 is_status {
- // Status segments draw their own module-box corners. The
- // root plate clip is invisible on a bar-thin segment (the
- // half-extent cap keeps it inside the transparent band) but
- // carves visible sweeps into an EXPANDED segment's in-surface
- // menu box once the cap stops binding.
- 0
- } else if self.wm_requested.ssd || is_decorated {
- (*self.server).wm.layout.root_plate_corner_radius
- } else {
- 0
- };
+ // below. ONE source — `root_plate_radius_base` — for this path,
+ // `render_viewport_update`, the toplevel commit path and
+ // `draw_borders`: until 2026-09-28 each carried its own copy of
+ // the fullscreen / circular / status / decorated decision.
+ let radius = self.root_plate_radius_base();
// Rounded corners do NOT require live blur: the corner shape is applied by the
// standard blur node's sampler (wlr_scene_blur_set_corner_radius) in both modes;
// the optimized node only re-bakes the shared offscreen cache
@@ -3573,24 +3559,12 @@ impl Window {
if is_status {
ignore_transparent = (*self.server).wm.layout.status_backdrop_blur_ignore_transparent;
}
- // 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 is_status {
- // Same status exemption as set_rendering_state — the two
- // paths drive the same nodes and must agree.
- 0
- } else if self.wm_requested.ssd || is_decorated {
- (*self.server).wm.layout.root_plate_corner_radius
- } else {
- 0
- };
+ // Same radius/optimized reasoning as set_rendering_state — the
+ // one `root_plate_radius_base`, so the two paths, which drive
+ // the same nodes, cannot disagree. 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 = self.root_plate_radius_base();
// Rounded corners do NOT require live blur: the corner shape is applied by the
// standard blur node's sampler (wlr_scene_blur_set_corner_radius) in both modes;
// the optimized node only re-bakes the shared offscreen cache
@@ -3667,12 +3641,14 @@ impl Window {
}
/// The root plate / content-clip corner radius in logical px, before span
- /// widening. Single source for every writer of that radius: the two render
- /// paths clip the surface with it, and `draw_borders` shapes the root plate
- /// rect with it. Those disagreed — draw_borders applied the BORDER ring's
- /// radius to the root plate node and, running last, silently overrode the
- /// value set_rendering_state had just written, making
- /// `root_plate_corner_radius` dead config.
+ /// widening (`widen_corner_radius`). THE source for every writer of that
+ /// radius — `set_rendering_state`, `render_viewport_update`, the toplevel
+ /// commit path in `xdg_toplevel.rs` and `draw_borders` — where until
+ /// 2026-09-28 the first three each kept an inline copy of this decision
+ /// "mirrored" by comment. They disagreed once before: draw_borders applied
+ /// the BORDER ring's radius to the root plate node and, running last,
+ /// silently overrode the value set_rendering_state had just written,
+ /// making `root_plate_corner_radius` dead config.
pub unsafe fn root_plate_radius_base(&self) -> i32 {
if self.is_fullscreen() {
return 0;
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index ed9e9329..a42cc3cf 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -1149,15 +1149,17 @@ impl WindowManager {
curr_out = (*curr_out).next;
}
let zoom = self.desk_zoom;
- let radius = (self.layout.root_plate_corner_radius as f64 * zoom) as i32;
for p in &self.restore_placeholders {
let x = out_x + ((p.vx - self.desk_pan_x) * zoom).round() as i32;
let y = out_y + ((p.vy - self.desk_pan_y) * zoom).round() as i32;
+ let (w, h) = ((p.w as f64 * zoom) as i32, (p.h as f64 * zoom) as i32);
ffi::river_scene_node_set_position_if_changed(p.rect as *mut ffi::wlr_scene_node, x, y);
- ffi::river_scene_rect_set_size_if_changed(
- p.rect,
- (p.w as f64 * zoom) as i32,
- (p.h as f64 * zoom) as i32,
+ ffi::river_scene_rect_set_size_if_changed(p.rect, w, h);
+ // Span-widened like the window the placeholder stands in for
+ // and the grid cell it sits on; the raw radius read visibly
+ // squarer than both at corner_shape > 2.
+ let radius = crate::window::widen_corner_radius(
+ (self.layout.root_plate_corner_radius as f64 * zoom) as i32, w, h,
);
ffi::river_scene_rect_set_corner_radius(p.rect, radius);
}
diff --git a/src/server/xdg_toplevel.rs b/src/server/xdg_toplevel.rs
index 4955705b..a1a94f23 100644
--- a/src/server/xdg_toplevel.rs
+++ b/src/server/xdg_toplevel.rs
@@ -705,24 +705,11 @@ unsafe extern "C" fn handle_commit(listener: *mut ffi::wl_listener, _data: *mut
};
let geom_w = (actual_w as f64 * scale) as i32;
let geom_h = (actual_h as f64 * scale) as i32;
- // 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 is_status {
- // Same status exemption as Window::set_rendering_state (part of the
- // mirror): status segments draw their own module-box corners.
- 0
- } else if (*window).wm_requested.ssd || is_decorated {
- (*(*window).server).wm.layout.root_plate_corner_radius
- } else {
- 0
- };
+ // The same radius Window::set_rendering_state applies — the one
+ // `root_plate_radius_base`, not a mirrored copy: both paths drive the same
+ // blur node, and when two copies disagreed the corners flipped between
+ // rounded and square depending on which one ran last.
+ let radius = (*window).root_plate_radius_base();
// Same span widening as Window::set_rendering_state (part of the mirror).
let radius = if (*window).rendering_requested.circular {
radius