Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git
feat: hide window borders until the pointer hovers them
Borders now rest invisible and the hovered zone fades in, and windows take
the policy layer's content-aligned grid snapping so a surface fills its
grid cell exactly with the border overhanging into the gap.
Window::border_reveal holds a per-zone alpha that draw_borders multiplies
into each segment's premultiplied colour; a fully faded-out zone is
disabled outright so resting borders cost nothing. It is kept off
rendering_requested.border, which the arrange pass rewrites wholesale every
pass and would clobber. A 16ms timer on WindowManager eases each zone
toward its target, mirroring the viewport panning animation. Hover
detection needed nothing new — passthrough already resolved the zone via
get_border_zone, so set_border_hover now starts a fade instead of
repainting.
The visible segments move out of the window tree into a new border_overlay
scene layer, one border_tree per window tracking box_geom, so a revealed
edge draws over a neighbour it overhangs when gap_width is narrower than
twice the border width. The invisible hit catchers stay in the window tree,
leaving pointer hit-testing and z-order untouched. Since border_tree is no
longer a child of the window tree it has to be torn down explicitly on
close (it would otherwise leak and leave a SceneNodeData pointing at a
freed window) and disabled by hand on the fullscreen and hide paths, which
never call draw_borders.
Also fixes get_border_zone ignoring Window::scale while scene::at applies
it, which put the band in the wrong place at any zoom but 1.0, and floors
the grab band at 8px: the border is invisible, so the band is the only
thing to aim at and a 2px target would be unusable.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
src/server/config.rs | 1 -
src/server/cursor.rs | 40 +++++++++---
src/server/scene.rs | 11 +++-
src/server/window.rs | 148 ++++++++++++++++++++++++++++++++++++++++++-
src/server/window_manager.rs | 61 +++++++++++++++++-
5 files changed, 246 insertions(+), 15 deletions(-)
diff --git a/src/server/config.rs b/src/server/config.rs
index 9a4a9bc..0c29ea2 100644
--- a/src/server/config.rs
+++ b/src/server/config.rs
@@ -77,7 +77,6 @@ impl Layout {
gap_width: self.desktop_gap_width as f64,
cell_inset: self.desktop_cell_fade_inset as f64,
threshold: if self.desktop_snap { self.desktop_snap_threshold } else { 0.0 },
- border_width: self.border_width as f64,
}
}
}
diff --git a/src/server/cursor.rs b/src/server/cursor.rs
index 3a2c519..1a70a21 100644
--- a/src/server/cursor.rs
+++ b/src/server/cursor.rs
@@ -480,15 +480,17 @@ impl Cursor {
let wm = &(*(*self.seat).server).wm;
if wm.windows.iter().any(|&w| w == old) && !(*old).closed {
(*old).hovered_border_element = None;
- (*old).draw_borders();
}
}
self.hovered_border_window = target;
self.hovered_border_element = element;
if !target.is_null() {
(*target).hovered_border_element = element;
- (*target).draw_borders();
}
+ // Borders rest invisible and fade in, so the change in hover target is
+ // the start of an animation rather than a repaint: the fade timer
+ // repaints every affected window as it steps.
+ (*(*self.seat).server).wm.arm_border_fade();
}
pub unsafe fn passthrough(&mut self, time_msec: u32) {
@@ -2266,6 +2268,11 @@ pub enum BorderZone {
Resize(crate::window::Edges),
}
+/// Floor on the border grab/reveal band, in layout pixels. Borders are
+/// invisible until hovered, so the band is the only thing to aim at; a 2px
+/// target would be unusable.
+pub const HOVER_BAND_MIN: f64 = 8.0;
+
pub unsafe fn get_border_zone(window: *mut crate::window::Window, lx: f64, ly: f64) -> BorderZone {
if (*(*window).server).wm.mode == crate::window_manager::WindowManagerMode::Overview {
return BorderZone::None;
@@ -2283,22 +2290,32 @@ pub unsafe fn get_border_zone(window: *mut crate::window::Window, lx: f64, ly: f
// Width-only, matching draw_borders: visible borders are the zones at
// their real width regardless of the ssd flag.
+ //
+ // Borders rest invisible and are revealed by hovering this band, so it
+ // doubles as the reveal target and must stay comfortable to hit: a
+ // narrow configured border still gets a HOVER_BAND_MIN-wide grab zone.
let is_virtual_border = (*window).rendering_requested.border.width == 0;
- let bw = if is_virtual_border {
- 8.0
+ let bw_unscaled = if is_virtual_border {
+ HOVER_BAND_MIN
} else {
- (*window).rendering_requested.border.width as f64
+ ((*window).rendering_requested.border.width as f64).max(HOVER_BAND_MIN)
};
- if bw <= 0.0 {
+ if bw_unscaled <= 0.0 {
return BorderZone::None;
}
+ // box_geom holds the UNSCALED content size; on screen the window covers
+ // `size * scale` (as scene::at accounts for). Without this the band sits
+ // in the wrong place at any zoom other than 1.0.
+ let scale = if (*window).scale > 0.0 { (*window).scale } else { 1.0 };
+ let bw = bw_unscaled * scale;
+
let geom = (*window).box_geom;
let rx = lx - geom.x as f64;
let ry = ly - geom.y as f64;
- let content_w = geom.width as f64;
- let content_h = geom.height as f64;
+ let content_w = geom.width as f64 * scale;
+ let content_h = geom.height as f64 * scale;
if rx >= 0.0 && rx < content_w && ry >= 0.0 && ry < content_h {
return BorderZone::None;
@@ -2309,7 +2326,12 @@ pub unsafe fn get_border_zone(window: *mut crate::window::Window, lx: f64, ly: f
// 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 = crate::window::border_corner_len(bw, (*(*window).server).wm.layout.border_corner_length);
+ // Derived in unscaled units (both inputs are unscaled), then brought
+ // into screen space alongside the band.
+ let corner_len = crate::window::border_corner_len(
+ bw_unscaled,
+ (*(*window).server).wm.layout.border_corner_length,
+ ) * scale;
let dist_left = rx + bw;
let dist_right = (content_w + bw) - rx;
diff --git a/src/server/scene.rs b/src/server/scene.rs
index 1f7872d..a56315a 100644
--- a/src/server/scene.rs
+++ b/src/server/scene.rs
@@ -13,6 +13,12 @@ pub struct SceneLayers {
pub overlay: *mut ffi::wlr_scene_tree,
pub popups: *mut ffi::wlr_scene_tree,
pub override_redirect: *mut ffi::wlr_scene_tree,
+ /// Hover-revealed window borders. Borders draw outside the content box, so
+ /// with the content filling its grid cell they overhang into the gap and
+ /// over the neighbouring window. Hosting them above every other layer
+ /// keeps a revealed edge visible instead of letting the neighbour occlude
+ /// it. Each window parents its own `border_tree` here.
+ pub border_overlay: *mut ffi::wlr_scene_tree,
}
pub struct Scene {
@@ -43,6 +49,7 @@ impl Scene {
overlay: std::ptr::null_mut(),
popups: std::ptr::null_mut(),
override_redirect: std::ptr::null_mut(),
+ border_overlay: std::ptr::null_mut(),
},
}
}
@@ -98,8 +105,10 @@ impl Scene {
self.layers.overlay = ffi::wlr_scene_tree_create(normal_tree);
self.layers.popups = ffi::wlr_scene_tree_create(normal_tree);
self.layers.override_redirect = ffi::wlr_scene_tree_create(normal_tree);
+ self.layers.border_overlay = ffi::wlr_scene_tree_create(normal_tree);
- if self.layers.background.is_null()
+ if self.layers.border_overlay.is_null()
+ || self.layers.background.is_null()
|| self.layers.bottom.is_null()
|| self.layers.wm.is_null()
|| self.layers.top.is_null()
diff --git a/src/server/window.rs b/src/server/window.rs
index bcc4a89..5d4401a 100644
--- a/src/server/window.rs
+++ b/src/server/window.rs
@@ -103,6 +103,41 @@ pub enum BorderElement {
BottomRight,
}
+impl BorderElement {
+ /// Every zone, in `index()` order.
+ pub const ALL: [BorderElement; 8] = [
+ BorderElement::Top,
+ BorderElement::Bottom,
+ BorderElement::Left,
+ BorderElement::Right,
+ BorderElement::TopLeft,
+ BorderElement::TopRight,
+ BorderElement::BottomLeft,
+ BorderElement::BottomRight,
+ ];
+
+ /// Index into `Window::border_reveal`. Declaration order; kept in one
+ /// place so the reveal array and the enum can't drift apart.
+ pub fn index(self) -> usize {
+ match self {
+ BorderElement::Top => 0,
+ BorderElement::Bottom => 1,
+ BorderElement::Left => 2,
+ BorderElement::Right => 3,
+ BorderElement::TopLeft => 4,
+ BorderElement::TopRight => 5,
+ BorderElement::BottomLeft => 6,
+ BorderElement::BottomRight => 7,
+ }
+ }
+}
+
+/// Per-frame step of the hover fade, as a fraction of the remaining distance
+/// to the target (the same exponential-approach shape the viewport pan uses).
+pub const BORDER_FADE_STEP: f32 = 0.25;
+/// Below this the fade is treated as finished and snapped to its target.
+pub const BORDER_FADE_EPSILON: f32 = 0.004;
+
/// Length of a corner zone, measured from the outer corner along each band.
/// Shared by the visual segments (draw_borders) and the pointer zones
/// (cursor.rs get_border_zone) so they always agree. `configured` comes from
@@ -139,6 +174,10 @@ pub struct BorderRects {
pub bottom: *mut ffi::wlr_scene_rect,
/// The visible zone segments, indexed by the SEG_* constants.
pub segments: [*mut ffi::wlr_scene_rect; 12],
+ /// Parent of `segments`, living in the global border overlay layer rather
+ /// than in the window tree. Tracks the window tree's position so the
+ /// segments keep their window-local coordinates.
+ pub tree: *mut ffi::wlr_scene_tree,
}
pub struct ShowWindowMenuRequest {
@@ -256,6 +295,12 @@ pub struct Window {
/// The border zone the pointer is over (set by cursor.rs); that segment
/// draws in `hover_color` while set.
pub hovered_border_element: Option<BorderElement>,
+ /// Per-zone reveal factor, 0.0 (fully hidden) to 1.0 (fully drawn),
+ /// indexed by `BorderElement::index`. Borders rest invisible and only the
+ /// zone under the pointer fades in. Deliberately NOT part of
+ /// `rendering_requested.border`, which the arrange pass rewrites wholesale
+ /// every pass and would otherwise clobber.
+ pub border_reveal: [f32; 8],
pub decorations_above: ffi::wl_list,
pub decorations_above_tree: *mut ffi::wlr_scene_tree,
pub popup_tree: *mut ffi::wlr_scene_tree,
@@ -413,13 +458,25 @@ impl Window {
}
};
+ // The invisible hit catchers stay in the window tree so pointer
+ // hit-testing and z-order are unchanged. The visible segments live in
+ // a sibling tree parented to the global border overlay layer, so a
+ // revealed edge draws over the neighbouring window it overhangs.
let border_left = ffi::wlr_scene_rect_create(tree, 0, 0, clear_color.as_ptr());
let border_right = ffi::wlr_scene_rect_create(tree, 0, 0, clear_color.as_ptr());
let border_top = ffi::wlr_scene_rect_create(tree, 0, 0, clear_color.as_ptr());
let border_bottom = ffi::wlr_scene_rect_create(tree, 0, 0, clear_color.as_ptr());
+
+ let border_tree = ffi::wlr_scene_tree_create((*server).scene.layers.border_overlay);
+ if border_tree.is_null() {
+ ffi::wlr_scene_node_destroy(tree as *mut ffi::wlr_scene_node);
+ ffi::wlr_scene_node_destroy(popup_tree as *mut ffi::wlr_scene_node);
+ ffi::wlr_scene_node_destroy(&mut (*capture_scene).tree as *mut ffi::wlr_scene_tree as *mut ffi::wlr_scene_node);
+ return Err("Failed to create window border tree");
+ }
let mut border_segments = [std::ptr::null_mut(); 12];
for seg in border_segments.iter_mut() {
- *seg = ffi::wlr_scene_rect_create(tree, 0, 0, clear_color.as_ptr());
+ *seg = ffi::wlr_scene_rect_create(border_tree, 0, 0, clear_color.as_ptr());
}
let decorations_above_tree = ffi::wlr_scene_tree_create(tree);
@@ -443,8 +500,10 @@ impl Window {
top: border_top,
bottom: border_bottom,
segments: border_segments,
+ tree: border_tree,
},
hovered_border_element: None,
+ border_reveal: [0.0; 8],
decorations_above: std::mem::zeroed(),
decorations_above_tree,
popup_tree,
@@ -570,6 +629,14 @@ impl Window {
popup_tree as *mut ffi::wlr_scene_node,
crate::scene_node_data::SceneNodeDataVal::Window(raw),
);
+ // The border segments sit outside the window tree; without data of
+ // their own a hit on a revealed segment would resolve to no window at
+ // all, so tag them with the window they belong to.
+ crate::scene_node_data::SceneNodeData::attach(
+ border_tree as *mut ffi::wlr_scene_node,
+ crate::scene_node_data::SceneNodeDataVal::Window(raw),
+ );
+ ffi::wlr_scene_node_set_enabled(border_tree as *mut ffi::wlr_scene_node, false);
Ok(raw)
}
@@ -1022,6 +1089,11 @@ impl Window {
wl_listener_remove_safe(&mut (*window).commit);
ffi::wlr_scene_node_destroy((*window).tree as *mut ffi::wlr_scene_node);
ffi::wlr_scene_node_destroy((*window).popup_tree as *mut ffi::wlr_scene_node);
+ // The border segments hang off the global overlay layer, not off
+ // `tree`, so destroying the window tree does not take them with it.
+ // Left behind they would both leak and keep a SceneNodeData pointing
+ // at this freed window for the next hit test to find.
+ ffi::wlr_scene_node_destroy((*window).border.tree as *mut ffi::wlr_scene_node);
ffi::wlr_scene_node_destroy(&mut (*(*window).capture_scene).tree as *mut ffi::wlr_scene_tree as *mut ffi::wlr_scene_node);
(*window).node.deinit();
@@ -1740,6 +1812,12 @@ impl Window {
ffi::wlr_scene_node_set_enabled(self.tree as *mut ffi::wlr_scene_node, enabled);
ffi::wlr_scene_node_set_enabled(self.popup_tree as *mut ffi::wlr_scene_node, enabled);
+ if !enabled {
+ // The segment tree is not a child of `tree`, so disabling the
+ // window does not hide a revealed border with it.
+ self.border_reveal = [0.0; 8];
+ ffi::wlr_scene_node_set_enabled(self.border.tree as *mut ffi::wlr_scene_node, false);
+ }
if enabled {
let app_id = self.get_app_id_string().unwrap_or_default();
@@ -1949,6 +2027,12 @@ impl Window {
ffi::wlr_scene_node_set_enabled(self.border.top as *mut ffi::wlr_scene_node, false);
ffi::wlr_scene_node_set_enabled(self.border.bottom as *mut ffi::wlr_scene_node, false);
ffi::wlr_scene_node_set_enabled(self.window_background as *mut ffi::wlr_scene_node, false);
+ // Fullscreen skips draw_borders entirely, and the segment tree
+ // lives outside this window's tree, so it has to be taken down
+ // explicitly or a revealed edge would hang over the fullscreen
+ // surface.
+ self.border_reveal = [0.0; 8];
+ ffi::wlr_scene_node_set_enabled(self.border.tree as *mut ffi::wlr_scene_node, false);
} else {
self.box_geom.x = requested.x;
self.box_geom.y = requested.y;
@@ -2110,6 +2194,10 @@ impl Window {
ffi::wlr_scene_node_set_enabled(self.tree as *mut ffi::wlr_scene_node, enabled);
ffi::wlr_scene_node_set_enabled(self.popup_tree as *mut ffi::wlr_scene_node, enabled);
+ if !enabled {
+ self.border_reveal = [0.0; 8];
+ ffi::wlr_scene_node_set_enabled(self.border.tree as *mut ffi::wlr_scene_node, false);
+ }
if enabled {
self.box_geom.x = requested.x;
@@ -2166,6 +2254,33 @@ impl Window {
}
}
+ /// Advance the hover fade one tick. Every zone eases toward 1.0 if it is
+ /// the one under the pointer and 0.0 otherwise. Returns true while any
+ /// zone is still in motion, so the caller knows to schedule another tick.
+ pub unsafe fn step_border_fade(&mut self) -> bool {
+ let mut moving = false;
+ let mut changed = false;
+ for elem in BorderElement::ALL {
+ let i = elem.index();
+ let target = if self.hovered_border_element == Some(elem) { 1.0 } else { 0.0 };
+ let delta = target - self.border_reveal[i];
+ if delta.abs() <= BORDER_FADE_EPSILON {
+ if self.border_reveal[i] != target {
+ self.border_reveal[i] = target;
+ changed = true;
+ }
+ continue;
+ }
+ self.border_reveal[i] += delta * BORDER_FADE_STEP;
+ moving = true;
+ changed = true;
+ }
+ if changed {
+ self.draw_borders();
+ }
+ moving
+ }
+
pub unsafe fn draw_borders(&mut self) {
let requested = &self.rendering_requested;
@@ -2185,7 +2300,26 @@ impl Window {
// enabled but transparent as scene hit-test catchers, so the pointer
// never falls through the gaps (and width 0 keeps the legacy
// invisible 8px virtual resize zones).
+ //
+ // Segments live in `border.tree`, parented to the global border
+ // overlay layer rather than to this window's tree, so it has to be
+ // positioned and enabled in step with the window by hand.
let is_virtual_border = border.width == 0;
+ // Deliberately NOT gated on `wm_requested.ssd`: that flag defaults to
+ // false and is only set by a client calling use_ssd, and the segments
+ // have never depended on it — only `window_background` does.
+ let borders_visible = !requested.hidden
+ && !requested.circular
+ && !is_virtual_border
+ && self.border_reveal.iter().any(|&a| a > 0.0);
+ ffi::wlr_scene_node_set_enabled(self.border.tree as *mut ffi::wlr_scene_node, borders_visible);
+ if borders_visible {
+ ffi::river_scene_node_set_position_if_changed(
+ self.border.tree as *mut ffi::wlr_scene_node,
+ self.box_geom.x,
+ self.box_geom.y,
+ );
+ }
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);
@@ -2268,12 +2402,17 @@ impl Window {
let bar_y = cl - bw + g;
let bar_h = content.height + 2 * bw - 2 * cl - 2 * g;
+ // Borders rest invisible; a zone is drawn only as far as its
+ // reveal factor has faded in. Channels are premultiplied alpha, so
+ // scaling all four by the factor is the correct fade.
let color_for = |elem: BorderElement| -> [f32; 4] {
- if self.hovered_border_element == Some(elem) {
+ let base = if self.hovered_border_element == Some(elem) {
border.hover_color
} else {
border_color
- }
+ };
+ let a = self.border_reveal[elem.index()].clamp(0.0, 1.0);
+ [base[0] * a, base[1] * a, base[2] * a, base[3] * a]
};
let e = &border.edges;
use BorderElement::*;
@@ -2293,6 +2432,9 @@ impl Window {
(SEG_BR_V, ffi::wlr_box { x: content.width, y: content.height - arm, width: bw, height: arm }, BottomRight, e.bottom && e.right),
];
for (idx, bx, elem, enabled) in segs {
+ // A fully-faded-out zone is disabled outright rather than
+ // drawn transparent, so it costs nothing while at rest.
+ let enabled = enabled && self.border_reveal[elem.index()] > 0.0;
apply(self.border.segments[idx], bx, &color_for(elem), enabled);
}
}
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index 8c8ea70..79c5b8b 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -124,6 +124,11 @@ pub struct WindowManager {
pub viewport_settle_timer: *mut ffi::wl_event_source,
pub clean_exit_in_progress: bool,
pub clean_exit_timer: *mut ffi::wl_event_source,
+ /// Drives the hover fade on window borders (see `Window::border_reveal`).
+ pub border_fade_timer: *mut ffi::wl_event_source,
+ /// Whether the fade timer is currently armed, so re-arming while a fade is
+ /// already running doesn't restart it and double the step rate.
+ pub border_fade_running: bool,
/// `window_manager.center_on_spawn`: whether a newly spawned window pulls the viewport
/// over to it when it takes focus. Off, the desk stays put and the window opens wherever
/// the layout placed it. Focus-follow panning between EXISTING windows is unaffected.
@@ -232,6 +237,17 @@ impl WindowManager {
return Err("Failed to create clean exit timer event source");
}
self.clean_exit_in_progress = false;
+
+ self.border_fade_timer =
+ ffi::wl_event_loop_add_timer(event_loop, Some(handle_border_fade_tick), self as *mut WindowManager as *mut _);
+ if self.border_fade_timer.is_null() {
+ ffi::wl_event_source_remove(self.timeout);
+ ffi::wl_event_source_remove(self.ipc_timer);
+ ffi::wl_event_source_remove(self.clean_exit_timer);
+ return Err("Failed to create border fade timer event source");
+ }
+ self.border_fade_running = false;
+
// Default until the config is parsed (which happens after this init).
self.center_on_spawn = true;
@@ -651,6 +667,16 @@ impl WindowManager {
}
}
+ /// Start the border hover fade if it isn't already running. Idempotent —
+ /// re-arming mid-fade would restart the timer and step it twice as fast.
+ pub unsafe fn arm_border_fade(&mut self) {
+ if self.border_fade_running || self.border_fade_timer.is_null() {
+ return;
+ }
+ self.border_fade_running = true;
+ ffi::wl_event_source_timer_update(self.border_fade_timer, 16);
+ }
+
pub unsafe fn dirty_windowing(&mut self) {
if log::log_enabled!(log::Level::Debug) {
let bt = std::backtrace::Backtrace::force_capture();
@@ -1264,7 +1290,6 @@ impl WindowManager {
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,
desktop_gap_width: self.layout.desktop_gap_width as f64,
@@ -3751,6 +3776,40 @@ pub(crate) unsafe extern "C" fn handle_panning_animation_tick(data: *mut std::ff
0
}
+/// Steps every window's border hover fade until all of them have settled.
+/// Windows at rest cost one comparison per zone and no repaint, so leaving
+/// this running for the tail of a fade is cheap.
+unsafe extern "C" fn handle_border_fade_tick(data: *mut std::ffi::c_void) -> std::os::raw::c_int {
+ let wm = data as *mut WindowManager;
+ let mut moving = false;
+ let windows: Vec<*mut crate::window::Window> = (*wm).windows.iter().copied().collect();
+ for window in windows {
+ if window.is_null() || (*window).closed {
+ continue;
+ }
+ if (*window).step_border_fade() {
+ moving = true;
+ }
+ }
+
+ if moving {
+ ffi::wl_event_source_timer_update((*wm).border_fade_timer, 16);
+ let outputs_list = &mut (*(*wm).server).om.outputs as *mut ffi::wl_list as *mut WlList;
+ let mut curr = (*outputs_list).next;
+ while curr != outputs_list {
+ let next = (*curr).next;
+ let output = crate::container_of!(curr, crate::output::Output, link);
+ if (*output).sent.state == crate::output::OutputStateValue::Enabled {
+ ffi::wlr_output_schedule_frame((*output).wlr_output);
+ }
+ curr = next;
+ }
+ } else {
+ (*wm).border_fade_running = false;
+ }
+ 0
+}
+
#[cfg(test)]
mod tests {
use super::*;