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

commite8eaa2db2cd39c368fbcf4d80afc6b083d94b9ca
parentc86d9516d7
authorLucas Galante <[email protected]>
date2026-07-15 10:01
feat: focused border color and border-inclusive layout insets

Server-side borders draw outside the content box, so slot-filling
placements now inset the content by the border width: the fresh overlay
slot, Maximized grid-snap, and the docked Popup position all keep the
border inside their gaps/cells. Fullscreen windows get no border at all.
Free-floating windows keep borders outside their box, matching the
virtual resize-zone convention. Everything reduces to the previous
behavior at width 0.

Config grows 'color_focused' on the border node (flat fallback
'border_color_focused'); the focused window's border uses it, falling
back to the unfocused color when unset.

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

 src/server/config.rs         |  25 ++++++-
 src/server/policy/arrange.rs | 160 ++++++++++++++++++++++++++++++++++++++-----
 src/server/window_manager.rs |   3 +
 3 files changed, 168 insertions(+), 20 deletions(-)

diff --git a/src/server/config.rs b/src/server/config.rs
index 3eb3e10..81e1070 100644
--- a/src/server/config.rs
+++ b/src/server/config.rs
@@ -21,6 +21,8 @@ pub struct Layout {
     pub floating_border_width: i32,
     /// Premultiplied-alpha RGBA, 0.0–1.0 per channel (scenefx convention).
     pub border_color: [f32; 4],
+    /// Border color for the focused window; defaults to `border_color`.
+    pub border_color_focused: [f32; 4],
     pub border_corner_radius: i32,
     pub background_r: u32,
     pub background_g: u32,
@@ -72,6 +74,7 @@ impl Default for Layout {
             grid_border_width: 0,
             floating_border_width: 0,
             border_color: [62.0 / 255.0, 62.0 / 255.0, 62.0 / 255.0, 1.0],
+            border_color_focused: [62.0 / 255.0, 62.0 / 255.0, 62.0 / 255.0, 1.0],
             border_corner_radius: 0,
             background_r: 0x1C1C1C1Cu32,
             background_g: 0x20202020u32,
@@ -307,6 +310,9 @@ pub struct SurfaceConfig {
     pub border_width: i64,
     #[serde(default = "default_border_color")]
     pub border_color: String,
+    /// `None` falls back to `border_color`.
+    #[serde(default)]
+    pub border_color_focused: Option<String>,
     #[serde(default = "default_border_corner_radius")]
     pub border_corner_radius: i64,
     #[serde(default = "default_cloud_position_default")]
@@ -330,6 +336,7 @@ impl Default for SurfaceConfig {
             backplate_corner_radius: default_backplate_corner_radius(),
             border_width: default_border_width(),
             border_color: default_border_color(),
+            border_color_focused: None,
             border_corner_radius: default_border_corner_radius(),
             cloud_position_default: default_cloud_position_default(),
         }
@@ -1478,6 +1485,11 @@ fn parse_kdl_config(content: &str) -> Result<Config, String> {
                                             surface.border_color = val.to_string();
                                         }
                                     }
+                                    "color_focused" => {
+                                        if let Some(val) = entry.value().as_string() {
+                                            surface.border_color_focused = Some(val.to_string());
+                                        }
+                                    }
                                     "corner_radius" => {
                                         if let Some(val) = entry.value().as_i64() {
                                             surface.border_corner_radius = val;
@@ -1514,6 +1526,7 @@ fn parse_kdl_config(content: &str) -> Result<Config, String> {
             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_color_focused = get_child_arg_string_opt(node, "border_color_focused");
             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");
         }
@@ -1586,6 +1599,12 @@ pub fn parse_config(path: &str, state: &mut crate::window_manager::WindowManager
     state.layout.floating_border_width = 0;
 
     state.layout.border_color = parse_hex_color_rgba(&config.surface.border_color);
+    state.layout.border_color_focused = config
+        .surface
+        .border_color_focused
+        .as_deref()
+        .map(parse_hex_color_rgba)
+        .unwrap_or(state.layout.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();
@@ -2016,18 +2035,20 @@ mod tests {
         let content = r##"
             style {
                 surface {
-                    border width=2 color="#ff8800" corner_radius=10
+                    border width=2 color="#ff8800" color_focused="#00ff88" 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_color_focused, Some("#00ff88".to_string()));
         assert_eq!(config.surface.border_corner_radius, 10);
 
-        // Defaults keep borders off.
+        // Defaults keep borders off; the focused color falls back to `color`.
         let config = parse_kdl_config("").unwrap();
         assert_eq!(config.surface.border_width, 0);
         assert_eq!(config.surface.border_corner_radius, 0);
+        assert_eq!(config.surface.border_color_focused, None);
     }
 }
diff --git a/src/server/policy/arrange.rs b/src/server/policy/arrange.rs
index 81576b3..450ef49 100644
--- a/src/server/policy/arrange.rs
+++ b/src/server/policy/arrange.rs
@@ -151,9 +151,6 @@ pub fn classify_window(
     }
 }
 
-/// Legacy border width, baked into the overlay geometry formulas.
-const BW: i32 = 0;
-
 /// Overlay windows keep 16px clear above for decorations.
 const OVERLAY_DEC_H: i32 = 16;
 
@@ -206,6 +203,8 @@ pub struct OverlaySnapshot {
 pub struct OverlayParams {
     pub overlay_width: i32,
     pub border_gap: i32,
+    /// Server-side border width; the fresh overlay slot is border-inclusive.
+    pub border_width: i32,
     pub position_right: bool,
     pub cloud_position_default: Option<[i32; 2]>,
 }
@@ -221,12 +220,17 @@ pub struct OverlayPlacement {
 /// Place the primary overlay window: fresh windows get the configured overlay
 /// slot (left or right edge, full usable height); cloud windows snap to their
 /// configured default position; anything else keeps its stored geometry.
+///
+/// The fresh slot is border-inclusive: since borders draw outside the content
+/// box, the content is inset by the border width so slot + border stays inside
+/// the configured gaps. Stored geometry is treated like a floating window (the
+/// border extends beyond it).
 pub fn place_overlay_window(
     snap: &OverlaySnapshot,
     p: &OverlayParams,
     ctx: &PlacementCtx,
 ) -> OverlayPlacement {
-    let bw = BW;
+    let bw = p.border_width.max(0);
     let g = p.border_gap;
     let dec_h = std::cmp::max(bw, OVERLAY_DEC_H);
 
@@ -242,14 +246,14 @@ pub fn place_overlay_window(
         } else {
             p.overlay_width
         };
-        sp_h = (ctx.usable.height - (dec_h + bw) - 2 * g).max(1);
+        sp_h = (ctx.usable.height - dec_h - 2 * g - 2 * bw).max(1);
 
         sp_x = if p.position_right {
-            ctx.usable.x + ctx.usable.width - sp_w - g + bw
+            ctx.usable.x + ctx.usable.width - sp_w - g - bw
         } else {
             ctx.usable.x + g + bw
         };
-        sp_y = ctx.usable.y + dec_h + g;
+        sp_y = ctx.usable.y + dec_h + g + bw;
 
         box_geom_write = Some(Rect { x: sp_x, y: sp_y, width: sp_w, height: sp_h });
     } else if snap.is_cloud {
@@ -338,6 +342,9 @@ pub struct NormalSnapshot {
 pub struct NormalParams {
     pub gap_right: i32,
     pub gap_top: i32,
+    /// Server-side border width; docked and grid-snapped placements are
+    /// border-inclusive.
+    pub border_width: i32,
     pub cloud_position_default: Option<[i32; 2]>,
     pub desktop_grid_scale: f64,
 }
@@ -381,11 +388,15 @@ pub fn place_normal_window(
                 100
             };
 
+            // The dock slot is border-inclusive: inset so the border stays
+            // within the configured gaps. Explicit cloud positions are taken
+            // as content positions verbatim.
+            let bw = p.border_width.max(0);
             let (fx, fy) = if snap.is_cloud && p.cloud_position_default.is_some() {
                 let pos = p.cloud_position_default.unwrap();
                 (ctx.usable.x + pos[0], ctx.usable.y + pos[1])
             } else {
-                (ctx.usable.x + ctx.usable.width - fw - p.gap_right, ctx.usable.y + p.gap_top)
+                (ctx.usable.x + ctx.usable.width - fw - p.gap_right - bw, ctx.usable.y + p.gap_top + bw)
             };
 
             NormalPlacement {
@@ -427,10 +438,16 @@ pub fn place_normal_window(
             let snapped_y1 = row_min as f64 * scale;
             let snapped_y2 = (row_max + 1) as f64 * scale;
 
-            let fw = snapped_x2 - snapped_x1;
-            let fh = snapped_y2 - snapped_y1;
+            // The snapped cell span is border-inclusive: the content is inset
+            // so the border stays inside the covered cells. Idempotent across
+            // frames because it re-derives from the saved geometry.
+            let bw = p.border_width.max(0) as f64;
+            let content_x = snapped_x1 + bw;
+            let content_y = snapped_y1 + bw;
+            let fw = (snapped_x2 - snapped_x1 - 2.0 * bw).max(1.0);
+            let fh = (snapped_y2 - snapped_y1 - 2.0 * bw).max(1.0);
 
-            let (final_x, final_y) = ctx.virtual_to_screen(snapped_x1, snapped_y1);
+            let (final_x, final_y) = ctx.virtual_to_screen(content_x, content_y);
 
             NormalPlacement {
                 pos: (final_x, final_y),
@@ -438,7 +455,7 @@ pub fn place_normal_window(
                 size: (fw as u32, fh as u32),
                 tiled_all_edges: false,
                 hidden: Some(ctx.is_offscreen(final_x, final_y, fw * ctx.zoom, fh * ctx.zoom)),
-                virtual_write: Some((snapped_x1, snapped_y1)),
+                virtual_write: Some((content_x, content_y)),
             }
         }
         _ => {
@@ -772,6 +789,9 @@ pub struct ArrangeParams {
     pub opacity_enabled: bool,
     /// The configured server-side decoration, applied to every placed window.
     pub decoration: DecorationSpec,
+    /// Border color for the focused window (falls back to the unfocused
+    /// color in config when unset).
+    pub border_color_focused: crate::policy::api::Rgba,
     pub overlay: OverlayParams,
     pub normal: NormalParams,
     pub pan_x: f64,
@@ -818,6 +838,26 @@ fn is_cloud_app(app_id: Option<&str>) -> bool {
     app_id.map_or(false, |id| id.starts_with("cce-cloud"))
 }
 
+/// The decoration one placed window gets: the configured spec with the
+/// focused border color swapped in, and no border at all when fullscreen.
+fn decoration_for(p: &ArrangeParams, is_focused: bool, mode: TilingMode) -> DecorationSpec {
+    if mode == TilingMode::Fullscreen {
+        return DecorationSpec {
+            border_width: 0,
+            corner_radius: 0,
+            ..p.decoration
+        };
+    }
+    DecorationSpec {
+        border_color: if is_focused {
+            p.border_color_focused
+        } else {
+            p.decoration.border_color
+        },
+        ..p.decoration
+    }
+}
+
 /// The whole arrange pass as one pure function: classify every window, place
 /// overlays/normals/status bars per output, and return per-window write
 /// instructions.
@@ -941,7 +981,7 @@ pub fn arrange(
                 wp.virtual_pos = Some(placement.virtual_pos);
                 wp.size = Some(placement.size);
                 wp.tiled = Some(1 | 2 | 4 | 8);
-                wp.decoration = Some(p.decoration);
+                wp.decoration = Some(decoration_for(p, state[i].is_focused, state[i].mode));
                 wp.blur = Some(p.window_blur);
                 wp.opacity = Some(window_opacity(
                     state[i].is_focused,
@@ -1002,6 +1042,7 @@ pub fn arrange(
         // Arrange normal windows on the virtual surface.
         for &i in &normal_windows {
             let w = &state[i];
+            let mode = w.mode;
             let placement = place_normal_window(
                 &NormalSnapshot {
                     mode: w.mode,
@@ -1035,7 +1076,7 @@ pub fn arrange(
             if placement.tiled_all_edges {
                 wp.tiled = Some(1 | 2 | 4 | 8);
             }
-            wp.decoration = Some(p.decoration);
+            wp.decoration = Some(decoration_for(p, is_focused, mode));
             wp.blur = Some(p.window_blur);
             wp.opacity = Some(window_opacity(is_focused, p.opacity_enabled, NORMAL_UNFOCUSED_OPACITY));
         }
@@ -1233,6 +1274,7 @@ mod tests {
             &OverlayParams {
                 overlay_width: 400,
                 border_gap: 8,
+                border_width: 0,
                 position_right: true,
                 cloud_position_default: None,
             },
@@ -1246,6 +1288,31 @@ mod tests {
         assert_eq!(placement.virtual_pos, (1512.0, 54.0));
     }
 
+    #[test]
+    fn fresh_overlay_slot_insets_by_border_width() {
+        let placement = place_overlay_window(
+            &OverlaySnapshot {
+                box_geom: Rect { x: 0, y: 0, width: 0, height: 0 },
+                min_width: 0,
+                is_cloud: false,
+                ssd: true,
+                decorations_size: (0, 16),
+            },
+            &OverlayParams {
+                overlay_width: 400,
+                border_gap: 8,
+                border_width: 4,
+                position_right: true,
+                cloud_position_default: None,
+            },
+            &ctx(),
+        );
+        // Content sits one border width inside the zero-width slot on every
+        // side, so the border lands within the gaps.
+        assert_eq!(placement.pos, (1512 - 4, 54 + 4));
+        assert_eq!(placement.size, (400, 1018 - 8));
+    }
+
     #[test]
     fn overlay_without_ssd_shrinks_by_decorations() {
         let placement = place_overlay_window(
@@ -1256,7 +1323,7 @@ mod tests {
                 ssd: false,
                 decorations_size: (2, 18),
             },
-            &OverlayParams { overlay_width: 400, border_gap: 8, position_right: false, cloud_position_default: None },
+            &OverlayParams { overlay_width: 400, border_gap: 8, border_width: 0, position_right: false, cloud_position_default: None },
             &ctx(),
         );
         // Existing geometry is kept; the client is sized minus decorations.
@@ -1300,7 +1367,7 @@ mod tests {
             saved_maximized_size: (100, 50),
             saved_maximized_virtual: (150.0, 120.0),
         };
-        let p = NormalParams { gap_right: 10, gap_top: 6, cloud_position_default: None, desktop_grid_scale: 100.0 };
+        let p = NormalParams { gap_right: 10, gap_top: 6, border_width: 0, cloud_position_default: None, desktop_grid_scale: 100.0 };
         let placement = place_normal_window(&snap, &p, &ctx());
         // Saved geometry spans grid columns 1-2 and row 1 → snapped to
         // (100,100) with size 200x100.
@@ -1311,6 +1378,27 @@ mod tests {
         assert_eq!(placement.scale, 1.0);
     }
 
+    #[test]
+    fn maximized_insets_by_border_width() {
+        let snap = NormalSnapshot {
+            mode: TilingMode::Maximized,
+            box_geom: Rect { x: 0, y: 0, width: 100, height: 50 },
+            min_size: (0, 0),
+            virtual_pos: (150.0, 120.0),
+            active_resize: None,
+            is_cloud: false,
+            saved_maximized_size: (100, 50),
+            saved_maximized_virtual: (150.0, 120.0),
+        };
+        let p = NormalParams { gap_right: 10, gap_top: 6, border_width: 4, cloud_position_default: None, desktop_grid_scale: 100.0 };
+        let placement = place_normal_window(&snap, &p, &ctx());
+        // Same cell span as without borders (100,100)+200x100, with the
+        // content inset so the border stays inside the covered cells.
+        assert_eq!(placement.virtual_write, Some((104.0, 104.0)));
+        assert_eq!(placement.pos, (104, 104));
+        assert_eq!(placement.size, (192, 92));
+    }
+
     #[test]
     fn popup_docks_top_right_of_usable_area() {
         let snap = NormalSnapshot {
@@ -1323,12 +1411,19 @@ mod tests {
             saved_maximized_size: (0, 0),
             saved_maximized_virtual: (0.0, 0.0),
         };
-        let p = NormalParams { gap_right: 10, gap_top: 6, cloud_position_default: None, desktop_grid_scale: 100.0 };
+        let p = NormalParams { gap_right: 10, gap_top: 6, border_width: 0, cloud_position_default: None, desktop_grid_scale: 100.0 };
         let placement = place_normal_window(&snap, &p, &ctx());
         // Defaults to 360x100, docked inside the usable area (below the bar).
         assert_eq!(placement.pos, (1920 - 360 - 10, 30 + 6));
         assert_eq!(placement.size, (360, 100));
         assert_eq!(placement.hidden, None);
+
+        // With a border, the dock position insets so the border stays inside
+        // the gaps; the popup keeps its size.
+        let p = NormalParams { border_width: 4, ..p };
+        let placement = place_normal_window(&snap, &p, &ctx());
+        assert_eq!(placement.pos, (1920 - 360 - 10 - 4, 30 + 6 + 4));
+        assert_eq!(placement.size, (360, 100));
     }
 
     #[test]
@@ -1343,7 +1438,7 @@ mod tests {
             saved_maximized_size: (0, 0),
             saved_maximized_virtual: (0.0, 0.0),
         };
-        let p = NormalParams { gap_right: 10, gap_top: 6, cloud_position_default: None, desktop_grid_scale: 100.0 };
+        let p = NormalParams { gap_right: 10, gap_top: 6, border_width: 0, cloud_position_default: None, desktop_grid_scale: 100.0 };
         let mut c = ctx();
         c.pan_x = 50.0;
         c.pan_y = 100.0;
@@ -1411,15 +1506,18 @@ mod tests {
                 border_color: crate::policy::api::Rgba([0.1, 0.2, 0.3, 0.4]),
                 corner_radius: 0,
             },
+            border_color_focused: crate::policy::api::Rgba([0.9, 0.1, 0.1, 1.0]),
             overlay: OverlayParams {
                 overlay_width: 400,
                 border_gap: 8,
+                border_width: 0,
                 position_right: true,
                 cloud_position_default: None,
             },
             normal: NormalParams {
                 gap_right: 10,
                 gap_top: 6,
+                border_width: 0,
                 cloud_position_default: None,
                 desktop_grid_scale: 100.0,
             },
@@ -1476,6 +1574,32 @@ mod tests {
         assert_eq!(wp.opacity, Some(NORMAL_UNFOCUSED_OPACITY));
     }
 
+    #[test]
+    fn arrange_focused_window_gets_focused_border_color() {
+        let mut focused = snap("firefox");
+        focused.is_focused = true;
+        let unfocused = snap("terminal");
+        let p = arrange_params();
+        let plan = arrange(&[focused, unfocused], &one_output(), &p);
+
+        assert_eq!(plan.windows[0].decoration.unwrap().border_color, p.border_color_focused);
+        assert_eq!(plan.windows[1].decoration.unwrap().border_color, p.decoration.border_color);
+    }
+
+    #[test]
+    fn arrange_fullscreen_window_gets_no_border() {
+        let mut w = snap("mpv");
+        w.mode = TilingMode::Fullscreen;
+        let mut p = arrange_params();
+        p.decoration.border_width = 4;
+        p.decoration.corner_radius = 12;
+        let plan = arrange(&[w], &one_output(), &p);
+
+        let dec = plan.windows[0].decoration.unwrap();
+        assert_eq!(dec.border_width, 0);
+        assert_eq!(dec.corner_radius, 0);
+    }
+
     #[test]
     fn arrange_hidden_window_disabled_in_scene() {
         let mut w = snap("firefox");
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index 7dff8d9..5c99313 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -1190,15 +1190,18 @@ impl WindowManager {
                 border_color: crate::policy::api::Rgba(self.layout.border_color),
                 corner_radius: self.layout.border_corner_radius,
             },
+            border_color_focused: crate::policy::api::Rgba(self.layout.border_color_focused),
             overlay: crate::policy::arrange::OverlayParams {
                 overlay_width: self.layout.overlay_width,
                 border_gap: self.layout.overlay_border_gap,
+                border_width: self.layout.border_width,
                 position_right: self.layout.overlay_position == "right",
                 cloud_position_default: self.layout.cloud_position_default,
             },
             normal: crate::policy::arrange::NormalParams {
                 gap_right: self.layout.gap_right,
                 gap_top: self.layout.gap_top,
+                border_width: self.layout.border_width,
                 cloud_position_default: self.layout.cloud_position_default,
                 desktop_grid_scale: self.layout.desktop_grid_scale,
             },