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

commit05bff2e060513b4b16133a5469ae0ef58057cfb1
parentb8ea70058a
authorLucas Galante <[email protected]>
date2026-07-16 15:43
fix: debounce backdrop blur toggling during viewport pan/zoom

Blur was re-decided every frame from a one-frame pan/zoom delta:
render_viewport_update disabled it on motion frames and render_finish
re-enabled it on any non-moving frame. During a real gesture (discrete
motion updates with ~16ms gaps, plus the inertial snap-to-target tail)
that flag oscillated, flipping blur on/off repeatedly. Behind a
transparent unfocused window the blurred desktop grid visibly flickered.

Treat the viewport as "active" for the whole gesture and restore blur
once via a 120ms settle timer that re-arms on each motion frame, so it
only fires after motion truly stops. Also fix the stationary branch,
which previously disabled blur instead of holding the finished state.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

 src/server/window_manager.rs | 93 ++++++++++++++++++++++++++++++++++++++++----
 1 file changed, 85 insertions(+), 8 deletions(-)

diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index adc9fd8..315007f 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -103,7 +103,11 @@ pub struct WindowManager {
     pub last_viewport_zoom: f64,
     pub last_viewport_pan_x: f64,
     pub last_viewport_pan_y: f64,
+    /// True while a viewport zoom/pan gesture is in progress (blur suppressed).
+    /// Cleared by `viewport_settle_timer` a short debounce after the last motion,
+    /// so blur restores exactly once when the gesture truly stops.
     pub viewport_is_active: bool,
+    pub viewport_settle_timer: *mut ffi::wl_event_source,
     pub clean_exit_in_progress: bool,
     pub clean_exit_timer: *mut ffi::wl_event_source,
 }
@@ -154,6 +158,8 @@ impl WindowManager {
         self.target_desk_pan_x = None;
         self.target_desk_pan_y = None;
         self.animation_timer = std::ptr::null_mut();
+        self.viewport_is_active = false;
+        self.viewport_settle_timer = std::ptr::null_mut();
         self.desk_zoom = 1.0;
         self.pending_screenshot = None;
         self.mode = WindowManagerMode::Normal;
@@ -550,6 +556,10 @@ impl WindowManager {
             ffi::wl_event_source_remove(self.animation_timer);
             self.animation_timer = std::ptr::null_mut();
         }
+        if !self.viewport_settle_timer.is_null() {
+            ffi::wl_event_source_remove(self.viewport_settle_timer);
+            self.viewport_settle_timer = std::ptr::null_mut();
+        }
         wl_listener_remove(&mut self.server_destroy);
     }
 
@@ -1327,36 +1337,45 @@ impl WindowManager {
     pub unsafe fn update_viewport_local(&mut self) {
         let zoom_changed = self.desk_zoom != self.last_viewport_zoom;
         let pan_changed = self.desk_pan_x != self.last_viewport_pan_x || self.desk_pan_y != self.last_viewport_pan_y;
-        let active = zoom_changed || pan_changed;
+        let moved = zoom_changed || pan_changed;
 
         self.last_viewport_zoom = self.desk_zoom;
         self.last_viewport_pan_x = self.desk_pan_x;
         self.last_viewport_pan_y = self.desk_pan_y;
 
-        let was_active = self.viewport_is_active;
-        self.viewport_is_active = active;
-
         self.arrange_views();
         // Clear rendering dirty flag so we don't trigger the idle callback's IPC handshake
         self.rendering_scheduled.dirty = false;
         self.remove_dirty_idle();
 
-        if active {
+        // Blur is toggled at most twice per gesture: off the moment real motion
+        // starts, on once the debounce timer confirms motion has stopped. During
+        // motion (and the brief gaps between discrete motion updates) the viewport
+        // stays "active" so blur is not re-enabled mid-gesture — that on/off churn
+        // was the flicker of the blurred desktop grid behind transparent windows.
+        if moved {
+            self.viewport_is_active = true;
             for &window in self.windows.iter() {
                 if !window.is_null() {
                     (*window).render_viewport_update();
                 }
             }
-        } else if was_active {
+            // (Re)arm the settle debounce: while motion keeps arriving this pushes
+            // the settle out, so it only fires once the gesture truly ends.
+            self.arm_viewport_settle_timer();
+        } else if self.viewport_is_active {
+            // A gap between motion updates within an ongoing gesture: keep blur
+            // suppressed and let the settle timer decide when the gesture ended.
             for &window in self.windows.iter() {
                 if !window.is_null() {
-                    (*window).render_finish();
+                    (*window).render_viewport_update();
                 }
             }
         } else {
+            // Stationary viewport: hold the finished, blurred state.
             for &window in self.windows.iter() {
                 if !window.is_null() {
-                    (*window).render_viewport_update();
+                    (*window).render_finish();
                 }
             }
         }
@@ -1384,6 +1403,48 @@ impl WindowManager {
         }
     }
 
+    /// (Re)arm the debounce that restores backdrop blur once viewport motion
+    /// stops. Called on every motion frame, so continuous panning keeps pushing
+    /// the settle out; it only fires `VIEWPORT_SETTLE_MS` after the last motion.
+    unsafe fn arm_viewport_settle_timer(&mut self) {
+        if self.viewport_settle_timer.is_null() {
+            let event_loop = ffi::wl_display_get_event_loop((*self.server).wl_server);
+            self.viewport_settle_timer = ffi::wl_event_loop_add_timer(
+                event_loop,
+                Some(handle_viewport_settle_tick),
+                self as *mut WindowManager as *mut _,
+            );
+        }
+        if !self.viewport_settle_timer.is_null() {
+            ffi::wl_event_source_timer_update(self.viewport_settle_timer, VIEWPORT_SETTLE_MS);
+        }
+    }
+
+    /// Restore the settled (blurred) render state for every window and repaint.
+    /// Runs once the settle debounce confirms the gesture has ended. Window
+    /// positions are already final from the last motion frame's arrange pass, so
+    /// this only flips each window back to its finished (blur-on) render.
+    unsafe fn finish_viewport_settle(&mut self) {
+        if !self.viewport_is_active {
+            return;
+        }
+        self.viewport_is_active = false;
+        for &window in self.windows.iter() {
+            if !window.is_null() {
+                (*window).render_finish();
+            }
+        }
+        let outputs_list = &mut (*self.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;
+        }
+    }
 
     pub unsafe fn focused_window(&self) -> *mut crate::window::Window {
         let seats_list = &(*self.server).input_manager.seats as *const ffi::wl_list as *const WlList as *mut WlList;
@@ -3578,6 +3639,22 @@ unsafe extern "C" fn handle_destroy_wm_resource(resource: *mut ffi::wl_resource)
     }
 }
 
+/// Debounce, in ms, between the last viewport motion and blur being restored.
+/// Long enough to outlast the ~16ms gaps between discrete pan/zoom updates (so
+/// blur is not restored mid-gesture), short enough that blur returns promptly.
+const VIEWPORT_SETTLE_MS: i32 = 120;
+
+/// One-shot timer callback: the viewport has been still for `VIEWPORT_SETTLE_MS`,
+/// so restore the blurred render state.
+pub(crate) unsafe extern "C" fn handle_viewport_settle_tick(data: *mut std::ffi::c_void) -> std::os::raw::c_int {
+    let wm = data as *mut WindowManager;
+    if wm.is_null() {
+        return 0;
+    }
+    (*wm).finish_viewport_settle();
+    0
+}
+
 pub(crate) unsafe extern "C" fn handle_panning_animation_tick(data: *mut std::ffi::c_void) -> std::os::raw::c_int {
     let wm = data as *mut WindowManager;
     if wm.is_null() {