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

commitc86d9516d729d4e4ee58ae814bef21df50c19260
parent6aa3aff1e9
authorLucas Galante <[email protected]>
date2026-07-14 23:05
feat: server-side window borders driven by policy DecorationSpec

Replace BorderPlan with policy::api::DecorationSpec end to end: arrange()
plans the configured decoration per placed window, the apply loop maps it
onto rendering_requested.border (now premultiplied f32 color plus corner
radius), and draw_borders() actually renders it instead of hardcoding the
invisible virtual border. Zero width keeps the legacy transparent 8px
resize-zone rects. Rounded borders draw as a single scenefx frame rect
with the content area clipped out; the SSD backplate rounds to match.

New config (style > surface > border { width= color= corner_radius= },
flat surface keys as fallback) feeds layout.border_width/border_color/
border_corner_radius; defaults keep borders off.

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

 src/server/config.rs         |  92 +++++++++++++++++++++++++++++------
 src/server/policy/api.rs     |   3 +-
 src/server/policy/arrange.rs |  26 +++++-----
 src/server/window.rs         | 112 +++++++++++++++++++++++++++++++++----------
 src/server/window_manager.rs |  16 ++++---
 5 files changed, 188 insertions(+), 61 deletions(-)

diff --git a/src/server/config.rs b/src/server/config.rs
index e5c3a53..3eb3e10 100644
--- a/src/server/config.rs
+++ b/src/server/config.rs
@@ -19,10 +19,9 @@ pub struct Layout {
     pub cascade_border_width: i32,
     pub grid_border_width: i32,
     pub floating_border_width: i32,
-    pub border_r: u32,
-    pub border_g: u32,
-    pub border_b: u32,
-    pub border_a: u32,
+    /// Premultiplied-alpha RGBA, 0.0–1.0 per channel (scenefx convention).
+    pub border_color: [f32; 4],
+    pub border_corner_radius: i32,
     pub background_r: u32,
     pub background_g: u32,
     pub background_b: u32,
@@ -72,10 +71,8 @@ impl Default for Layout {
             cascade_border_width: 0,
             grid_border_width: 0,
             floating_border_width: 0,
-            border_r: 0x3E3E3E3Eu32,
-            border_g: 0x3E3E3E3Eu32,
-            border_b: 0x3E3E3E3Eu32,
-            border_a: 0xFFFFFFFFu32,
+            border_color: [62.0 / 255.0, 62.0 / 255.0, 62.0 / 255.0, 1.0],
+            border_corner_radius: 0,
             background_r: 0x1C1C1C1Cu32,
             background_g: 0x20202020u32,
             background_b: 0x20202020u32,
@@ -306,6 +303,12 @@ pub struct SurfaceConfig {
     pub backplate_blur: f64,
     #[serde(default = "default_backplate_corner_radius")]
     pub backplate_corner_radius: i64,
+    #[serde(default = "default_border_width")]
+    pub border_width: i64,
+    #[serde(default = "default_border_color")]
+    pub border_color: String,
+    #[serde(default = "default_border_corner_radius")]
+    pub border_corner_radius: i64,
     #[serde(default = "default_cloud_position_default")]
     pub cloud_position_default: Option<[i32; 2]>,
 }
@@ -325,6 +328,9 @@ impl Default for SurfaceConfig {
             backplate_color: default_backplate_color(),
             backplate_blur: default_backplate_blur(),
             backplate_corner_radius: default_backplate_corner_radius(),
+            border_width: default_border_width(),
+            border_color: default_border_color(),
+            border_corner_radius: default_border_corner_radius(),
             cloud_position_default: default_cloud_position_default(),
         }
      }
@@ -383,6 +389,18 @@ fn default_backplate_corner_radius() -> i64 {
     12
 }
 
+fn default_border_width() -> i64 {
+    0
+}
+
+fn default_border_color() -> String {
+    "#3e3e3e".to_string()
+}
+
+fn default_border_corner_radius() -> i64 {
+    0
+}
+
 
 #[derive(Debug, Deserialize)]
 pub struct Config {
@@ -1445,6 +1463,31 @@ fn parse_kdl_config(content: &str) -> Result<Config, String> {
                             }
                         }
                     }
+                    if let Some(border_node) = surface_children.nodes().iter().find(|n| n.name().value() == "border") {
+                        found_nested = true;
+                        for entry in border_node.entries() {
+                            if let Some(id) = entry.name() {
+                                match id.value() {
+                                    "width" => {
+                                        if let Some(val) = entry.value().as_i64() {
+                                            surface.border_width = val;
+                                        }
+                                    }
+                                    "color" => {
+                                        if let Some(val) = entry.value().as_string() {
+                                            surface.border_color = val.to_string();
+                                        }
+                                    }
+                                    "corner_radius" => {
+                                        if let Some(val) = entry.value().as_i64() {
+                                            surface.border_corner_radius = 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") {
@@ -1469,6 +1512,9 @@ fn parse_kdl_config(content: &str) -> Result<Config, String> {
             surface.backplate_color = get_child_arg_string(node, "backplate_color", &default_backplate_color());
             surface.backplate_blur = get_child_arg_f64(node, "backplate_blur", default_backplate_blur());
             surface.backplate_corner_radius = get_child_arg_i64(node, "backplate_corner_radius", default_backplate_corner_radius());
+            surface.border_width = get_child_arg_i64(node, "border_width", default_border_width());
+            surface.border_color = get_child_arg_string(node, "border_color", &default_border_color());
+            surface.border_corner_radius = get_child_arg_i64(node, "border_corner_radius", default_border_corner_radius());
             surface.cloud_position_default = get_child_arg_vec2i_opt(node, "cloud_position_default");
         }
     }
@@ -1533,16 +1579,14 @@ pub fn parse_config(path: &str, state: &mut crate::window_manager::WindowManager
     state.layout.gap_bottom = config.layout.gap_bottom as i32;
     state.layout.cascade_offset = config.layout.cascade_offset as i32;
     state.layout.bar_height = config.layout.bar_height as i32;
-    state.layout.border_width = 0;
+    state.layout.border_width = config.surface.border_width as i32;
     state.layout.fullscreen_border_width = 0;
     state.layout.cascade_border_width = 0;
     state.layout.grid_border_width = 0;
     state.layout.floating_border_width = 0;
-    
-    state.layout.border_r = 0x3E3E3E3E;
-    state.layout.border_g = 0x3E3E3E3E;
-    state.layout.border_b = 0x3E3E3E3E;
-    state.layout.border_a = 0xFFFFFFFF;
+
+    state.layout.border_color = parse_hex_color_rgba(&config.surface.border_color);
+    state.layout.border_corner_radius = config.surface.border_corner_radius as i32;
 
     state.layout.desktop_gap_color = config.surface.desktop_gap_color.clone();
 
@@ -1966,4 +2010,24 @@ mod tests {
         assert_eq!(wm.toggle_fullscreen, Some("super+f".to_string()));
         assert_eq!(wm.toggle_overview, Some("swipe_up".to_string()));
     }
+
+    #[test]
+    fn test_kdl_surface_border_parsing() {
+        let content = r##"
+            style {
+                surface {
+                    border width=2 color="#ff8800" corner_radius=10
+                }
+            }
+        "##;
+        let config = parse_kdl_config(content).unwrap();
+        assert_eq!(config.surface.border_width, 2);
+        assert_eq!(config.surface.border_color, "#ff8800");
+        assert_eq!(config.surface.border_corner_radius, 10);
+
+        // Defaults keep borders off.
+        let config = parse_kdl_config("").unwrap();
+        assert_eq!(config.surface.border_width, 0);
+        assert_eq!(config.surface.border_corner_radius, 0);
+    }
 }
diff --git a/src/server/policy/api.rs b/src/server/policy/api.rs
index 99e222d..718428e 100644
--- a/src/server/policy/api.rs
+++ b/src/server/policy/api.rs
@@ -57,11 +57,12 @@ pub struct Rect {
     pub height: i32,
 }
 
+/// Premultiplied-alpha RGBA, 0.0–1.0 per channel (scenefx convention).
 #[derive(Debug, Clone, Copy, PartialEq)]
 pub struct Rgba(pub [f32; 4]);
 
 /// Server-side decoration for one window: borders now, titlebars later.
-#[derive(Debug, Clone, PartialEq)]
+#[derive(Debug, Clone, Copy, PartialEq)]
 pub struct DecorationSpec {
     pub border_width: i32,
     pub border_color: Rgba,
diff --git a/src/server/policy/arrange.rs b/src/server/policy/arrange.rs
index a93fcba..81576b3 100644
--- a/src/server/policy/arrange.rs
+++ b/src/server/policy/arrange.rs
@@ -6,7 +6,7 @@
 // call — the future `Policy::arrange` entry point — composed from the
 // per-section functions below it.
 
-use super::api::{Rect, WindowRole};
+use super::api::{DecorationSpec, Rect, WindowRole};
 use super::tiling::TilingMode;
 
 /// Which screen edge/region a status-bar window docks to.
@@ -770,7 +770,8 @@ pub struct ArrangeParams {
     pub window_blur: bool,
     /// `layout.window_opacity` — unfocused windows dim when set.
     pub opacity_enabled: bool,
-    pub border_color: (u32, u32, u32, u32),
+    /// The configured server-side decoration, applied to every placed window.
+    pub decoration: DecorationSpec,
     pub overlay: OverlayParams,
     pub normal: NormalParams,
     pub pan_x: f64,
@@ -778,12 +779,6 @@ pub struct ArrangeParams {
     pub zoom: f64,
 }
 
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub struct BorderPlan {
-    pub width: u32,
-    pub color: (u32, u32, u32, u32),
-}
-
 /// Write instructions for one window. `None` leaves the field untouched, so
 /// the mechanism apply loop is a flat sequence of `if let Some` writes.
 #[derive(Debug, Clone, Default, PartialEq)]
@@ -804,7 +799,7 @@ pub struct WindowPlan {
     pub box_geom: Option<Rect>,
     pub virtual_pos: Option<(f64, f64)>,
     pub blur: Option<bool>,
-    pub border: Option<BorderPlan>,
+    pub decoration: Option<DecorationSpec>,
     pub opacity: Option<f32>,
     pub was_maximized: Option<bool>,
     /// Maximized-enter save: (restore size, restore virtual position).
@@ -843,7 +838,6 @@ pub fn arrange(
     let mut plan: Vec<WindowPlan> = vec![WindowPlan::default(); windows.len()];
 
     let has_wallpaper = state.iter().any(|w| w.role == WindowRole::Background);
-    let default_border = BorderPlan { width: BW as u32, color: p.border_color };
 
     for out in outputs {
         let phys = out.layout_box;
@@ -947,7 +941,7 @@ pub fn arrange(
                 wp.virtual_pos = Some(placement.virtual_pos);
                 wp.size = Some(placement.size);
                 wp.tiled = Some(1 | 2 | 4 | 8);
-                wp.border = Some(default_border);
+                wp.decoration = Some(p.decoration);
                 wp.blur = Some(p.window_blur);
                 wp.opacity = Some(window_opacity(
                     state[i].is_focused,
@@ -1041,7 +1035,7 @@ pub fn arrange(
             if placement.tiled_all_edges {
                 wp.tiled = Some(1 | 2 | 4 | 8);
             }
-            wp.border = Some(default_border);
+            wp.decoration = Some(p.decoration);
             wp.blur = Some(p.window_blur);
             wp.opacity = Some(window_opacity(is_focused, p.opacity_enabled, NORMAL_UNFOCUSED_OPACITY));
         }
@@ -1412,7 +1406,11 @@ mod tests {
             status_blur: true,
             window_blur: true,
             opacity_enabled: true,
-            border_color: (10, 20, 30, 40),
+            decoration: DecorationSpec {
+                border_width: 0,
+                border_color: crate::policy::api::Rgba([0.1, 0.2, 0.3, 0.4]),
+                corner_radius: 0,
+            },
             overlay: OverlayParams {
                 overlay_width: 400,
                 border_gap: 8,
@@ -1473,7 +1471,7 @@ mod tests {
         assert_eq!(wp.size, Some((640, 480)));
         assert_eq!(wp.hidden, Some(false));
         assert_eq!(wp.tiled, None);
-        assert_eq!(wp.border, Some(BorderPlan { width: 0, color: (10, 20, 30, 40) }));
+        assert_eq!(wp.decoration, Some(arrange_params().decoration));
         assert_eq!(wp.blur, Some(true));
         assert_eq!(wp.opacity, Some(NORMAL_UNFOCUSED_OPACITY));
     }
diff --git a/src/server/window.rs b/src/server/window.rs
index 1cda7d8..a92358b 100644
--- a/src/server/window.rs
+++ b/src/server/window.rs
@@ -72,14 +72,19 @@ impl Edges {
     }
 }
 
-#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+#[derive(Clone, Copy, Debug, PartialEq)]
 pub struct Border {
     pub edges: Edges,
     pub width: u32,
-    pub r: u32,
-    pub g: u32,
-    pub b: u32,
-    pub a: u32,
+    /// Premultiplied-alpha RGBA, 0.0–1.0 per channel (scenefx convention).
+    pub color: [f32; 4],
+    pub corner_radius: i32,
+}
+
+impl Border {
+    pub fn none() -> Self {
+        Self { edges: Edges::new(), width: 0, color: [0.0; 4], corner_radius: 0 }
+    }
 }
 
 pub struct BorderRects {
@@ -459,7 +464,7 @@ impl Window {
                 x: 0,
                 y: 0,
                 hidden: false,
-                border: Border { edges: Edges::new(), width: 0, r: 0, g: 0, b: 0, a: 0 },
+                border: Border::none(),
                 clip: ffi::wlr_box { x: 0, y: 0, width: 0, height: 0 },
                 content_clip: ffi::wlr_box { x: 0, y: 0, width: 0, height: 0 },
                 opacity: 1.0f32,
@@ -1086,7 +1091,7 @@ impl Window {
                     x: 0,
                     y: 0,
                     hidden: false,
-                    border: Border { edges: Edges::new(), width: 0, r: 0, g: 0, b: 0, a: 0 },
+                    border: Border::none(),
                     clip: ffi::wlr_box { x: 0, y: 0, width: 0, height: 0 },
                     content_clip: ffi::wlr_box { x: 0, y: 0, width: 0, height: 0 },
                     opacity: 1.0f32,
@@ -2069,20 +2074,18 @@ impl Window {
         let requested = &self.rendering_requested;
 
         let border = &requested.border;
-        let bg_color: [f32; 4] = [
-            (border.r as f64 / u32::MAX as f64) as f32,
-            (border.g as f64 / u32::MAX as f64) as f32,
-            (border.b as f64 / u32::MAX as f64) as f32,
-            (border.a as f64 / u32::MAX as f64) as f32,
-        ];
+        let border_color = border.color;
         ffi::river_scene_node_set_position_if_changed(self.window_background as *mut ffi::wlr_scene_node, 0, 0);
         let bg_width = (self.box_geom.width as f64 * self.scale) as i32;
         let bg_height = (self.box_geom.height as f64 * self.scale) as i32;
         ffi::river_scene_rect_set_size_if_changed(self.window_background, bg_width, bg_height);
-        ffi::wlr_scene_rect_set_color(self.window_background, bg_color.as_ptr());
+        ffi::wlr_scene_rect_set_color(self.window_background, border_color.as_ptr());
+        ffi::river_scene_rect_set_corner_radius(self.window_background, (border.corner_radius as f64 * self.scale) as i32);
         ffi::wlr_scene_node_set_enabled(self.window_background as *mut ffi::wlr_scene_node, !requested.hidden && self.wm_requested.ssd);
 
-        let is_virtual_border = true;
+        // Zero configured width keeps the legacy invisible rects; cursor.rs
+        // treats the same case as a "virtual" 8px resize zone.
+        let is_virtual_border = border.width == 0;
         if requested.circular {
             ffi::wlr_scene_node_set_enabled(self.border.left as *mut ffi::wlr_scene_node, false);
             ffi::wlr_scene_node_set_enabled(self.border.right as *mut ffi::wlr_scene_node, false);
@@ -2105,14 +2108,18 @@ impl Window {
             let color: [f32; 4] = if is_virtual_border {
                 [0.0, 0.0, 0.0, 0.0]
             } else {
-                [
-                    (border.r as f64 / u32::MAX as f64) as f32,
-                    (border.g as f64 / u32::MAX as f64) as f32,
-                    (border.b as f64 / u32::MAX as f64) as f32,
-                    (border.a as f64 / u32::MAX as f64) as f32,
-                ]
+                border_color
             };
 
+            if !is_virtual_border && border.corner_radius > 0 {
+                self.draw_rounded_border_frame(&content, border_width as i32, border.corner_radius, &color);
+                return;
+            }
+            // Coming back from the rounded path: the top rect doubles as the
+            // frame there and keeps rounded/clipped state otherwise.
+            ffi::river_scene_rect_set_corner_radius(self.border.top, 0);
+            ffi::wlr_scene_rect_set_clipped_region(self.border.top, ffi::clipped_region_get_default());
+
             let mut left = ffi::wlr_box {
                 x: -(border_width as i32),
                 y: 0,
@@ -2179,6 +2186,56 @@ impl Window {
         }
     }
 
+    /// Rounded border: a single frame rect (the `top` border node) covering
+    /// content plus border on all edges, with the content area clipped out.
+    /// Per-edge selection doesn't apply here — a rounded frame is all-edges.
+    unsafe fn draw_rounded_border_frame(&mut self, content: &ffi::wlr_box, width: i32, corner_radius: i32, color: &[f32; 4]) {
+        let requested = &self.rendering_requested;
+
+        let mut frame = ffi::wlr_box {
+            x: -width,
+            y: -width,
+            width: content.width + 2 * width,
+            height: content.height + 2 * width,
+        };
+        if requested.clip.width != 0 || requested.clip.height != 0 {
+            let mut clip_intersect = std::mem::zeroed();
+            ffi::wlr_box_intersection(&mut clip_intersect, &frame, &requested.clip);
+            frame = clip_intersect;
+        }
+
+        let scale = self.scale;
+        let s = |v: i32| (v as f64 * scale) as i32;
+        let radius_u16 = |v: i32| (v as f64 * scale).round().clamp(0.0, u16::MAX as f64) as u16;
+
+        let rect = self.border.top;
+        ffi::river_scene_node_set_position_if_changed(rect as *mut ffi::wlr_scene_node, s(frame.x), s(frame.y));
+        ffi::river_scene_rect_set_size_if_changed(rect, s(frame.width), s(frame.height));
+        ffi::wlr_scene_rect_set_color(rect, color.as_ptr());
+        ffi::river_scene_rect_set_corner_radius(rect, s(corner_radius + width));
+        // Clipped region is node-relative: the content box, shifted by
+        // wherever clipping moved the frame's origin.
+        ffi::wlr_scene_rect_set_clipped_region(rect, ffi::clipped_region {
+            area: ffi::wlr_box {
+                x: s(-frame.x),
+                y: s(-frame.y),
+                width: s(content.width),
+                height: s(content.height),
+            },
+            corners: ffi::fx_corner_radii {
+                top_left: radius_u16(corner_radius),
+                top_right: radius_u16(corner_radius),
+                bottom_right: radius_u16(corner_radius),
+                bottom_left: radius_u16(corner_radius),
+            },
+        });
+
+        ffi::wlr_scene_node_set_enabled(rect as *mut ffi::wlr_scene_node, true);
+        ffi::wlr_scene_node_set_enabled(self.border.left as *mut ffi::wlr_scene_node, false);
+        ffi::wlr_scene_node_set_enabled(self.border.right as *mut ffi::wlr_scene_node, false);
+        ffi::wlr_scene_node_set_enabled(self.border.bottom as *mut ffi::wlr_scene_node, false);
+    }
+
     #[allow(unused_assignments)]
     pub unsafe fn apply_surface_clip(&mut self, a: *const ffi::wlr_box, b: *const ffi::wlr_box) {
         let mut surface_clip = std::mem::zeroed::<ffi::wlr_box>();
@@ -2408,13 +2465,18 @@ unsafe extern "C" fn window_set_borders(
         );
         return;
     }
+    let alpha = (a as f64 / u32::MAX as f64) as f32;
     (*window).rendering_requested.border = Border {
         edges: Edges::from_u32(edges),
         width: width as u32,
-        r,
-        g,
-        b,
-        a,
+        // Protocol channels are straight alpha; scene colors are premultiplied.
+        color: [
+            (r as f64 / u32::MAX as f64) as f32 * alpha,
+            (g as f64 / u32::MAX as f64) as f32 * alpha,
+            (b as f64 / u32::MAX as f64) as f32 * alpha,
+            alpha,
+        ],
+        corner_radius: 0,
     };
 }
 
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index 3cc9e36..7dff8d9 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -1185,7 +1185,11 @@ impl WindowManager {
             status_blur: self.layout.status_background_blur > 0.001,
             window_blur: self.layout.window_blur,
             opacity_enabled: self.layout.window_opacity,
-            border_color: (self.layout.border_r, self.layout.border_g, self.layout.border_b, self.layout.border_a),
+            decoration: crate::policy::api::DecorationSpec {
+                border_width: self.layout.border_width,
+                border_color: crate::policy::api::Rgba(self.layout.border_color),
+                corner_radius: self.layout.border_corner_radius,
+            },
             overlay: crate::policy::arrange::OverlayParams {
                 overlay_width: self.layout.overlay_width,
                 border_gap: self.layout.overlay_border_gap,
@@ -1248,14 +1252,12 @@ impl WindowManager {
                 (*win_ptr).wm_requested.dimensions = Some(crate::window::Dimensions { width, height });
                 (*win_ptr).wm_requested.bounds = crate::window::Dimensions { width, height };
             }
-            if let Some(border) = wp.border {
+            if let Some(dec) = wp.decoration {
                 (*win_ptr).rendering_requested.border = crate::window::Border {
                     edges: crate::window::Edges { top: true, bottom: true, left: true, right: true },
-                    width: border.width,
-                    r: border.color.0,
-                    g: border.color.1,
-                    b: border.color.2,
-                    a: border.color.3,
+                    width: dec.border_width.max(0) as u32,
+                    color: dec.border_color.0,
+                    corner_radius: dec.corner_radius.max(0),
                 };
             }
             if let Some(blur) = wp.blur {