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