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