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

commita0934a937da8539d7fa118fd215c4d1ebf95c292
parent8aa212f138
authorLucas Galante <[email protected]>
date2026-07-18 17:03
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::*;