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

commita3dd4cf63ec6695b6c2e49352ed19e70463fe708
parent50a6036fcf
authorLucas Galante <[email protected]>
date2026-07-28 10:14
feat: keyed pans step cell-by-cell and animate to an aligned viewport

PanLeft/Right/Up/Down now target the adjacent grid-period-aligned pan
offset (policy: pan::aligned_step — one full period from an aligned
start, the remaining fraction from an unaligned one) and ease there via
the pan animation instead of jumping 100px. Rapid presses queue one cell
apiece by stepping from the pending target. The focus-pan's inline timer
setup moves into WindowManager::start_panning_animation(), shared by
both call sites.

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

 src/server/seat.rs           | 14 +------------
 src/server/window_manager.rs | 50 +++++++++++++++++++++++++++++++++-----------
 2 files changed, 39 insertions(+), 25 deletions(-)

diff --git a/src/server/seat.rs b/src/server/seat.rs
index cc5ec30..0465fb4 100644
--- a/src/server/seat.rs
+++ b/src/server/seat.rs
@@ -520,19 +520,7 @@ impl Seat {
 
                                 wm.target_desk_pan_x = Some(target_x);
                                 wm.target_desk_pan_y = Some(target_y);
-
-                                if wm.animation_timer.is_null() {
-                                    let event_loop = ffi::wl_display_get_event_loop((*self.server).wl_server);
-                                    wm.animation_timer = ffi::wl_event_loop_add_timer(
-                                        event_loop,
-                                        Some(crate::window_manager::handle_panning_animation_tick),
-                                        wm as *mut crate::window_manager::WindowManager as *mut _,
-                                    );
-                                }
-
-                                if !wm.animation_timer.is_null() {
-                                    ffi::wl_event_source_timer_update(wm.animation_timer, 16);
-                                }
+                                wm.start_panning_animation();
                             }
                         }
                     }
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index 6bae8f9..610c7f5 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -651,6 +651,23 @@ impl WindowManager {
         self.target_desk_pan_y = None;
     }
 
+    /// Arm the pan animation timer (creating it on first use): every 16ms
+    /// `handle_panning_animation_tick` eases `desk_pan_x/y` toward
+    /// `target_desk_pan_x/y`. Callers set the targets first.
+    pub unsafe fn start_panning_animation(&mut self) {
+        if self.animation_timer.is_null() {
+            let event_loop = ffi::wl_display_get_event_loop((*self.server).wl_server);
+            self.animation_timer = ffi::wl_event_loop_add_timer(
+                event_loop,
+                Some(handle_panning_animation_tick),
+                self as *mut WindowManager as *mut _,
+            );
+        }
+        if !self.animation_timer.is_null() {
+            ffi::wl_event_source_timer_update(self.animation_timer, 16);
+        }
+    }
+
     pub unsafe fn ensure_windowing(&self) -> bool {
         match self.state {
             WindowManagerState::Manage => true,
@@ -2223,19 +2240,28 @@ impl WindowManager {
                 self.dirty_windowing();
             }
             Action::PanLeft | Action::PanRight | Action::PanUp | Action::PanDown => {
-                let step = 100.0 / self.desk_zoom;
-                match action {
-                    Action::PanLeft => self.desk_pan_x -= step,
-                    Action::PanRight => self.desk_pan_x += step,
-                    Action::PanUp => self.desk_pan_y -= step,
-                    Action::PanDown => self.desk_pan_y += step,
-                    _ => {}
-                }
-                if matches!(self.state, WindowManagerState::Idle) {
-                    self.update_viewport_local();
-                } else {
-                    self.dirty_windowing();
+                // Keyed pans move cell-by-cell and land aligned: the target
+                // is the adjacent pan offset that puts the viewport origin on
+                // a grid-period boundary, eased in by the pan animation.
+                // Basing each step on the pending target (not the current
+                // offset) lets rapid presses queue one cell apiece.
+                let period = self.layout.desktop_grid_scale.max(5.0)
+                    + self.layout.desktop_gap_width.max(0) as f64;
+                let (dx, dy) = match action {
+                    Action::PanLeft => (-1.0, 0.0),
+                    Action::PanRight => (1.0, 0.0),
+                    Action::PanUp => (0.0, -1.0),
+                    _ => (0.0, 1.0),
+                };
+                if dx != 0.0 {
+                    let base = self.target_desk_pan_x.unwrap_or(self.desk_pan_x);
+                    self.target_desk_pan_x = Some(crate::policy::pan::aligned_step(base, period, dx));
+                }
+                if dy != 0.0 {
+                    let base = self.target_desk_pan_y.unwrap_or(self.desk_pan_y);
+                    self.target_desk_pan_y = Some(crate::policy::pan::aligned_step(base, period, dy));
                 }
+                self.start_panning_animation();
             }
             Action::OverlayLeft => {
                 self.layout.overlay_position = "left".to_string();