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

commitd31226fe0ff3e58b5702c5119900ac9df6412c0f
parent1a309e828e
authorLucas Galante <[email protected]>
date2026-07-15 11:45
feat: borders as the interactive grab surface with hover highlight

The border band now splits by direction instead of depth: the top edge
moves the window, the side and bottom edges resize, and corner squares
(max(2*width, 16px) from each outer corner) resize on both adjacent
edges — replacing the old outer-resize/inner-move split in
get_border_zone. Existing seat ops, cursor shapes, and double-click
handling are unchanged.

Hovering the border highlights it: cursor passthrough tracks the
hovered window and flips a border_hovered flag, repainting via
draw_borders so relayouts re-emit the highlight instead of stomping
it. Hover state is mechanism-side on purpose — arrange never runs on
pointer motion. New config key 'border { color_hover= }' (flat
border_color_hover), defaulting to a lightened focused color; virtual
(width-0) borders stay invisible on hover.

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

 src/server/config.rs         | 41 ++++++++++++++++++++++--
 src/server/cursor.rs         | 74 +++++++++++++++++++++++++++++++++++++-------
 src/server/window.rs         | 29 ++++++++++++-----
 src/server/window_manager.rs |  1 +
 4 files changed, 124 insertions(+), 21 deletions(-)

diff --git a/src/server/config.rs b/src/server/config.rs
index 96ac7e5..937e018 100644
--- a/src/server/config.rs
+++ b/src/server/config.rs
@@ -23,6 +23,9 @@ pub struct Layout {
     pub border_color: [f32; 4],
     /// Border color for the focused window; defaults to `border_color`.
     pub border_color_focused: [f32; 4],
+    /// Border color while the pointer hovers the border (the grab surface);
+    /// defaults to a lightened `border_color_focused`.
+    pub border_color_hover: [f32; 4],
     pub border_corner_radius: i32,
     pub background_r: u32,
     pub background_g: u32,
@@ -75,6 +78,7 @@ impl Default for Layout {
             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_color_hover: lighten_premultiplied([62.0 / 255.0, 62.0 / 255.0, 62.0 / 255.0, 1.0], HOVER_LIGHTEN),
             border_corner_radius: 0,
             background_r: 0x1C1C1C1Cu32,
             background_g: 0x20202020u32,
@@ -277,6 +281,9 @@ pub struct SurfaceConfig {
     /// `None` falls back to `border_color`.
     #[serde(default)]
     pub border_color_focused: Option<String>,
+    /// `None` falls back to a lightened `border_color_focused`.
+    #[serde(default)]
+    pub border_color_hover: Option<String>,
     #[serde(default = "default_border_corner_radius")]
     pub border_corner_radius: i64,
     #[serde(default = "default_cloud_position_default")]
@@ -301,6 +308,7 @@ impl Default for SurfaceConfig {
             border_width: default_border_width(),
             border_color: default_border_color(),
             border_color_focused: None,
+            border_color_hover: None,
             border_corner_radius: default_border_corner_radius(),
             cloud_position_default: default_cloud_position_default(),
         }
@@ -372,6 +380,20 @@ fn default_border_corner_radius() -> i64 {
     0
 }
 
+/// How far the default hover color moves toward white.
+const HOVER_LIGHTEN: f32 = 0.35;
+
+/// Mix a premultiplied-alpha color toward white (which is `[a, a, a, a]` in
+/// premultiplied space), keeping the alpha.
+pub fn lighten_premultiplied(c: [f32; 4], t: f32) -> [f32; 4] {
+    [
+        c[0] + (c[3] - c[0]) * t,
+        c[1] + (c[3] - c[1]) * t,
+        c[2] + (c[3] - c[2]) * t,
+        c[3],
+    ]
+}
+
 
 #[derive(Debug, Deserialize)]
 pub struct Config {
@@ -1454,6 +1476,11 @@ fn parse_kdl_config(content: &str) -> Result<Config, String> {
                                             surface.border_color_focused = Some(val.to_string());
                                         }
                                     }
+                                    "color_hover" => {
+                                        if let Some(val) = entry.value().as_string() {
+                                            surface.border_color_hover = Some(val.to_string());
+                                        }
+                                    }
                                     "corner_radius" => {
                                         if let Some(val) = entry.value().as_i64() {
                                             surface.border_corner_radius = val;
@@ -1491,6 +1518,7 @@ fn parse_kdl_config(content: &str) -> Result<Config, String> {
             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_color_hover = get_child_arg_string_opt(node, "border_color_hover");
             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");
         }
@@ -1569,6 +1597,12 @@ pub fn parse_config(path: &str, state: &mut crate::window_manager::WindowManager
         .as_deref()
         .map(parse_hex_color_rgba)
         .unwrap_or(state.layout.border_color);
+    state.layout.border_color_hover = config
+        .surface
+        .border_color_hover
+        .as_deref()
+        .map(parse_hex_color_rgba)
+        .unwrap_or_else(|| lighten_premultiplied(state.layout.border_color_focused, HOVER_LIGHTEN));
     state.layout.border_corner_radius = config.surface.border_corner_radius as i32;
 
     state.layout.desktop_gap_color = config.surface.desktop_gap_color.clone();
@@ -1999,7 +2033,7 @@ mod tests {
         let content = r##"
             style {
                 surface {
-                    border width=2 color="#ff8800" color_focused="#00ff88" corner_radius=10
+                    border width=2 color="#ff8800" color_focused="#00ff88" color_hover="#88ffcc" corner_radius=10
                 }
             }
         "##;
@@ -2007,12 +2041,15 @@ mod tests {
         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_color_hover, Some("#88ffcc".to_string()));
         assert_eq!(config.surface.border_corner_radius, 10);
 
-        // Defaults keep borders off; the focused color falls back to `color`.
+        // Defaults keep borders off; the focused color falls back to `color`
+        // and the hover color to a lightened focused 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);
+        assert_eq!(config.surface.border_color_hover, None);
     }
 }
diff --git a/src/server/cursor.rs b/src/server/cursor.rs
index 6724d28..15aa161 100644
--- a/src/server/cursor.rs
+++ b/src/server/cursor.rs
@@ -49,6 +49,10 @@ pub struct Cursor {
     pub panning_gesture_active: bool,
     pub last_click_time: u32,
     pub last_click_window: *mut crate::window::Window,
+    /// Window whose border currently draws highlighted, kept to un-highlight
+    /// on hover transitions. May dangle after a close — validate against
+    /// `wm.windows` before dereferencing.
+    pub hovered_border_window: *mut crate::window::Window,
     pub right_click_on_bg: bool,
     pub right_click_on_border: bool,
     pub left_click_on_bg_in_overview: bool,
@@ -98,6 +102,7 @@ impl Default for Cursor {
             panning_gesture_active: false,
             last_click_time: 0,
             last_click_window: std::ptr::null_mut(),
+            hovered_border_window: std::ptr::null_mut(),
             right_click_on_bg: false,
             right_click_on_border: false,
             left_click_on_bg_in_overview: false,
@@ -448,6 +453,29 @@ impl Cursor {
         }
     }
 
+    /// Move the border hover highlight to `target` (null to clear): flips the
+    /// `border_hovered` flag on the old and new windows and repaints their
+    /// borders. The stored pointer may refer to a closed window, so it is
+    /// only dereferenced after checking it is still in `wm.windows`.
+    pub unsafe fn set_border_hover(&mut self, target: *mut crate::window::Window) {
+        if self.hovered_border_window == target {
+            return;
+        }
+        let old = self.hovered_border_window;
+        if !old.is_null() {
+            let wm = &(*(*self.seat).server).wm;
+            if wm.windows.iter().any(|&w| w == old) && !(*old).closed {
+                (*old).border_hovered = false;
+                (*old).draw_borders();
+            }
+        }
+        self.hovered_border_window = target;
+        if !target.is_null() {
+            (*target).border_hovered = true;
+            (*target).draw_borders();
+        }
+    }
+
     pub unsafe fn passthrough(&mut self, time_msec: u32) {
         let lx = self.x();
         let ly = self.y();
@@ -457,11 +485,13 @@ impl Cursor {
             let lock_state = (*server).lock_manager.state;
             if lock_state != crate::lock_manager::LockState::Unlocked {
                 if !matches!(result.data, SceneNodeDataVal::LockSurface(_)) {
+                    self.set_border_hover(std::ptr::null_mut());
                     self.clear_focus();
                     return;
                 }
             } else {
                 if matches!(result.data, SceneNodeDataVal::LockSurface(_)) {
+                    self.set_border_hover(std::ptr::null_mut());
                     self.clear_focus();
                     return;
                 }
@@ -482,12 +512,14 @@ impl Cursor {
                     {
                         match get_border_zone(window, lx, ly) {
                             BorderZone::Resize(edges) => {
+                                self.set_border_hover(window);
                                 ffi::wlr_seat_pointer_notify_clear_focus((*self.seat).wlr_seat);
                                 let cursor_name = get_resize_cursor_name(edges);
                                 self.set_xcursor(cursor_name.as_ptr() as *const _);
                                 return;
                             }
                             BorderZone::Move => {
+                                self.set_border_hover(window);
                                 ffi::wlr_seat_pointer_notify_clear_focus((*self.seat).wlr_seat);
                                 self.set_xcursor(b"grab\0".as_ptr() as *const _);
                                 return;
@@ -501,6 +533,7 @@ impl Cursor {
                 }
                 _ => {}
             }
+            self.set_border_hover(std::ptr::null_mut());
 
             if is_window && (*server).wm.mode == crate::window_manager::WindowManagerMode::Overview {
                 self.clear_focus();
@@ -514,6 +547,7 @@ impl Cursor {
             }
         }
 
+        self.set_border_hover(std::ptr::null_mut());
         self.clear_focus();
     }
 
@@ -2249,26 +2283,44 @@ pub unsafe fn get_border_zone(window: *mut crate::window::Window, lx: f64, ly: f
     }
 
     if rx >= -bw && rx < content_w + bw && ry >= -bw && ry < content_h + bw {
-        let threshold = if bw <= 3.0 { bw / 2.0 } else { 3.0 };
+        // The border band splits by direction, not by depth: the top edge
+        // moves the window, everything else resizes. Corner squares of
+        // `corner_len` (measured from the outer corners along the band)
+        // resize on both adjacent edges, so the top corners still resize.
+        let corner_len = (2.0 * bw).max(16.0);
 
         let dist_left = rx + bw;
         let dist_right = (content_w + bw) - rx;
         let dist_top = ry + bw;
         let dist_bottom = (content_h + bw) - ry;
 
-        let min_dist = dist_left.min(dist_right).min(dist_top).min(dist_bottom);
-
-        if min_dist >= 0.0 && min_dist < threshold {
-            let delta = threshold + 1.0;
-            let left = dist_left < delta;
-            let right = dist_right < delta;
-            let top = dist_top < delta;
-            let bottom = dist_bottom < delta;
+        let near_left = dist_left < corner_len && dist_left <= dist_right;
+        let near_right = dist_right < corner_len && dist_right < dist_left;
+        let near_top = dist_top < corner_len && dist_top <= dist_bottom;
+        let near_bottom = dist_bottom < corner_len && dist_bottom < dist_top;
+
+        if (near_left || near_right) && (near_top || near_bottom) {
+            return BorderZone::Resize(crate::window::Edges {
+                top: near_top,
+                bottom: near_bottom,
+                left: near_left,
+                right: near_right,
+            });
+        }
 
-            return BorderZone::Resize(crate::window::Edges { top, bottom, left, right });
-        } else {
+        if ry < 0.0 {
             return BorderZone::Move;
         }
+
+        let edges = crate::window::Edges {
+            top: false,
+            bottom: ry >= content_h,
+            left: rx < 0.0,
+            right: rx >= content_w,
+        };
+        if edges.bottom || edges.left || edges.right {
+            return BorderZone::Resize(edges);
+        }
     }
 
     BorderZone::None
diff --git a/src/server/window.rs b/src/server/window.rs
index a92358b..5448e75 100644
--- a/src/server/window.rs
+++ b/src/server/window.rs
@@ -78,12 +78,14 @@ pub struct Border {
     pub width: u32,
     /// Premultiplied-alpha RGBA, 0.0–1.0 per channel (scenefx convention).
     pub color: [f32; 4],
+    /// Color while the pointer hovers the border (the grab surface).
+    pub hover_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 }
+        Self { edges: Edges::new(), width: 0, color: [0.0; 4], hover_color: [0.0; 4], corner_radius: 0 }
     }
 }
 
@@ -206,6 +208,9 @@ pub struct Window {
     pub decorations_below_tree: *mut ffi::wlr_scene_tree,
     pub surfaces: crate::scene::SaveableSurfaces,
     pub border: BorderRects,
+    /// Pointer is over the border grab surface (set by cursor.rs); the
+    /// border draws in `hover_color` while set.
+    pub border_hovered: bool,
     pub decorations_above: ffi::wl_list,
     pub decorations_above_tree: *mut ffi::wlr_scene_tree,
     pub popup_tree: *mut ffi::wlr_scene_tree,
@@ -379,6 +384,7 @@ impl Window {
                 top: border_top,
                 bottom: border_bottom,
             },
+            border_hovered: false,
             decorations_above: std::mem::zeroed(),
             decorations_above_tree,
             popup_tree,
@@ -2105,8 +2111,12 @@ impl Window {
         if clip_empty || ffi::wlr_box_intersection(&mut intersect, &content, &requested.content_clip) {
             let border = &requested.border;
             let border_width = if is_virtual_border { 8 } else { border.width };
+            // Hover highlights only real SSD borders; virtual rects stay
+            // invisible. The backplate keeps the base color either way.
             let color: [f32; 4] = if is_virtual_border {
                 [0.0, 0.0, 0.0, 0.0]
+            } else if self.border_hovered && self.wm_requested.ssd {
+                border.hover_color
             } else {
                 border_color
             };
@@ -2466,16 +2476,19 @@ unsafe extern "C" fn window_set_borders(
         return;
     }
     let alpha = (a as f64 / u32::MAX as f64) as f32;
+    // Protocol channels are straight alpha; scene colors are premultiplied.
+    let 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,
+    ];
     (*window).rendering_requested.border = Border {
         edges: Edges::from_u32(edges),
         width: width as u32,
-        // 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,
-        ],
+        color,
+        // Protocol-set borders don't participate in hover highlighting.
+        hover_color: color,
         corner_radius: 0,
     };
 }
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index 5c99313..152ed9a 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -1260,6 +1260,7 @@ impl WindowManager {
                     edges: crate::window::Edges { top: true, bottom: true, left: true, right: true },
                     width: dec.border_width.max(0) as u32,
                     color: dec.border_color.0,
+                    hover_color: self.layout.border_color_hover,
                     corner_radius: dec.corner_radius.max(0),
                 };
             }