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

commit003171d8c596928bd8d225bd63b89dbe04a1404d
parent4182e9cf0c
authorLucas Galante <[email protected]>
date2026-07-28 10:22
feat: edge auto-pan during interactive move/resize

Dragging or resizing against a screen edge now scrolls the infinite
desktop underneath the pinned cursor. The op tracks the cursor in
virtual space as start + cursor_delta/zoom + (pan − start_pan): SeatOp
records the desk pan at op start, and the new pan-delta term (both in
the seat op arms and in get_active_resize_dimensions, which must not
diverge) turns desktop scroll into window/edge motion, so the grab
sticks to the cursor while the desk slides. Seat::update_edge_pan runs
on every op motion: inside a band at the output edge it sets a velocity
ramping from 0 at the band's inner rim to full speed at the screen edge
(screen px/s, zoom-compensated), arming a 16ms tick that pans and
re-runs the op at its last cursor position — the tick's own op_update
re-derives the velocity and re-arms, so the scroll sustains itself
while the cursor rests at the edge and stops when it leaves the band or
the op ends. Status-bar drags are exempt. Config:
desktop { edge_pan=#true edge_pan_band=32 edge_pan_speed=1000 }.

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

 src/server/config.rs         | 53 +++++++++++++++++++++++++++++++
 src/server/cursor.rs         | 14 +++++++--
 src/server/seat.rs           | 59 ++++++++++++++++++++++++++++++++---
 src/server/window_manager.rs | 74 ++++++++++++++++++++++++++++++++++++++++++--
 src/server/xdg_toplevel.rs   |  4 +++
 5 files changed, 195 insertions(+), 9 deletions(-)

diff --git a/src/server/config.rs b/src/server/config.rs
index 2518142..8da85b4 100644
--- a/src/server/config.rs
+++ b/src/server/config.rs
@@ -71,6 +71,14 @@ pub struct Layout {
     pub desktop_snap: bool,
     /// Snap radius in virtual units.
     pub desktop_snap_threshold: f64,
+    /// Edge auto-pan: dragging/resizing against a screen edge scrolls the
+    /// desktop underneath the pinned cursor.
+    pub desktop_edge_pan: bool,
+    /// Width of the trigger band inside each output edge, in layout px.
+    pub desktop_edge_pan_band: f64,
+    /// Full-tilt pan speed at the screen edge, in screen px/s (the tick
+    /// divides by zoom; speed ramps linearly across the band).
+    pub desktop_edge_pan_speed: f64,
     pub scenefx_optimized_blur: bool,
     pub status_backdrop_blur_ignore_transparent: bool,
     pub window_backdrop_blur_ignore_transparent: bool,
@@ -144,6 +152,9 @@ impl Default for Layout {
             shadow_offset_y: 7,
             desktop_snap: true,
             desktop_snap_threshold: 24.0,
+            desktop_edge_pan: true,
+            desktop_edge_pan_band: 32.0,
+            desktop_edge_pan_speed: 1000.0,
             scenefx_optimized_blur: true,
             status_backdrop_blur_ignore_transparent: true,
             window_backdrop_blur_ignore_transparent: true,
@@ -327,6 +338,12 @@ pub struct SurfaceConfig {
     pub desktop_snap: bool,
     #[serde(default = "default_desktop_snap_threshold")]
     pub desktop_snap_threshold: i64,
+    #[serde(default = "default_desktop_edge_pan")]
+    pub desktop_edge_pan: bool,
+    #[serde(default = "default_desktop_edge_pan_band")]
+    pub desktop_edge_pan_band: i64,
+    #[serde(default = "default_desktop_edge_pan_speed")]
+    pub desktop_edge_pan_speed: i64,
     #[serde(default = "default_backplate_color")]
     pub backplate_color: String,
     #[serde(default = "default_backplate_blur")]
@@ -382,6 +399,9 @@ impl Default for SurfaceConfig {
             desktop_grid_fade_mode: default_desktop_grid_fade_mode(),
             desktop_snap: default_desktop_snap(),
             desktop_snap_threshold: default_desktop_snap_threshold(),
+            desktop_edge_pan: default_desktop_edge_pan(),
+            desktop_edge_pan_band: default_desktop_edge_pan_band(),
+            desktop_edge_pan_speed: default_desktop_edge_pan_speed(),
             backplate_color: default_backplate_color(),
             backplate_blur: default_backplate_blur(),
             backplate_corner_radius: default_backplate_corner_radius(),
@@ -443,6 +463,18 @@ fn default_desktop_snap_threshold() -> i64 {
     24
 }
 
+fn default_desktop_edge_pan() -> bool {
+    true
+}
+
+fn default_desktop_edge_pan_band() -> i64 {
+    32
+}
+
+fn default_desktop_edge_pan_speed() -> i64 {
+    1000
+}
+
 fn default_backplate_color() -> String {
     "#151520e6".to_string()
 }
@@ -1562,6 +1594,21 @@ fn parse_kdl_config(content: &str) -> Result<Config, String> {
                                             surface.desktop_snap_threshold = val;
                                         }
                                     }
+                                    "edge_pan" => {
+                                        if let Some(val) = entry.value().as_bool() {
+                                            surface.desktop_edge_pan = val;
+                                        }
+                                    }
+                                    "edge_pan_band" => {
+                                        if let Some(val) = entry.value().as_i64() {
+                                            surface.desktop_edge_pan_band = val;
+                                        }
+                                    }
+                                    "edge_pan_speed" => {
+                                        if let Some(val) = entry.value().as_i64() {
+                                            surface.desktop_edge_pan_speed = val;
+                                        }
+                                    }
                                     _ => {}
                                 }
                             }
@@ -1709,6 +1756,9 @@ fn parse_kdl_config(content: &str) -> Result<Config, String> {
             surface.desktop_grid_fade_mode = get_child_arg_string(node, "grid_fade_mode", &default_desktop_grid_fade_mode());
             surface.desktop_snap = get_child_arg_bool(node, "desktop_snap", default_desktop_snap());
             surface.desktop_snap_threshold = get_child_arg_i64(node, "desktop_snap_threshold", default_desktop_snap_threshold());
+            surface.desktop_edge_pan = get_child_arg_bool(node, "desktop_edge_pan", default_desktop_edge_pan());
+            surface.desktop_edge_pan_band = get_child_arg_i64(node, "desktop_edge_pan_band", default_desktop_edge_pan_band());
+            surface.desktop_edge_pan_speed = get_child_arg_i64(node, "desktop_edge_pan_speed", default_desktop_edge_pan_speed());
             surface.backplate_color = get_child_arg_string(node, "backplate_color", &default_backplate_color());
             surface.backplate_blur = get_child_arg_f64(node, "backplate_blur", default_backplate_blur());
             surface.backplate_corner_radius = get_child_arg_i64(node, "backplate_corner_radius", default_backplate_corner_radius());
@@ -1857,6 +1907,9 @@ pub fn parse_config(path: &str, state: &mut crate::window_manager::WindowManager
     state.layout.desktop_grid_scale = config.surface.desktop_grid_scale as f64;
     state.layout.desktop_snap = config.surface.desktop_snap;
     state.layout.desktop_snap_threshold = config.surface.desktop_snap_threshold.max(0) as f64;
+    state.layout.desktop_edge_pan = config.surface.desktop_edge_pan;
+    state.layout.desktop_edge_pan_band = config.surface.desktop_edge_pan_band.max(1) as f64;
+    state.layout.desktop_edge_pan_speed = config.surface.desktop_edge_pan_speed.max(0) as f64;
     state.layout.desktop_gap_width = config.surface.desktop_gap_width as i32;
     state.layout.desktop_cell_corner_radius = config.surface.desktop_cell_corner_radius as i32;
     state.layout.desktop_cell_fade_inset = config.surface.desktop_cell_fade_inset;
diff --git a/src/server/cursor.rs b/src/server/cursor.rs
index 956a648..ade4be9 100644
--- a/src/server/cursor.rs
+++ b/src/server/cursor.rs
@@ -828,6 +828,8 @@ unsafe extern "C" fn handle_button(listener: *mut ffi::wl_listener, data: *mut s
                     start_win_h: (*clicked_status).box_geom.height as u32,
                     start_win_virtual_x: (*clicked_status).virtual_x,
                     start_win_virtual_y: (*clicked_status).virtual_y,
+                    start_pan_x: (*server).wm.desk_pan_x,
+                    start_pan_y: (*server).wm.desk_pan_y,
                     start_tiling_mode: (*clicked_status).tiling_mode,
                     start_mode_locked: (*clicked_status).mode_locked,
                     started_in_overview: false,
@@ -867,6 +869,8 @@ unsafe extern "C" fn handle_button(listener: *mut ffi::wl_listener, data: *mut s
                     start_win_h: (*clicked_win).box_geom.height as u32,
                     start_win_virtual_x: (*clicked_win).virtual_x,
                     start_win_virtual_y: (*clicked_win).virtual_y,
+                    start_pan_x: (*server).wm.desk_pan_x,
+                    start_pan_y: (*server).wm.desk_pan_y,
                     start_tiling_mode: (*clicked_win).tiling_mode,
                     start_mode_locked: (*clicked_win).mode_locked,
                     started_in_overview: true,
@@ -995,6 +999,8 @@ unsafe extern "C" fn handle_button(listener: *mut ffi::wl_listener, data: *mut s
                         start_win_virtual_y: (*target_win).virtual_y,
                         start_tiling_mode: (*target_win).tiling_mode,
                         start_mode_locked: (*target_win).mode_locked,
+                        start_pan_x: (*(*seat).server).wm.desk_pan_x,
+                        start_pan_y: (*(*seat).server).wm.desk_pan_y,
                         started_in_overview: (*(*seat).server).wm.mode == crate::window_manager::WindowManagerMode::Overview,
                     });
                     cursor.op_start_pointer();
@@ -1088,7 +1094,9 @@ unsafe extern "C" fn handle_button(listener: *mut ffi::wl_listener, data: *mut s
                             start_win_virtual_y: (*border_target_win).virtual_y,
                             start_tiling_mode: (*border_target_win).tiling_mode,
                             start_mode_locked: (*border_target_win).mode_locked,
-                            started_in_overview: (*(*seat).server).wm.mode == crate::window_manager::WindowManagerMode::Overview,
+                            start_pan_x: (*(*seat).server).wm.desk_pan_x,
+                        start_pan_y: (*(*seat).server).wm.desk_pan_y,
+                        started_in_overview: (*(*seat).server).wm.mode == crate::window_manager::WindowManagerMode::Overview,
                         });
                         cursor.op_start_pointer();
                         cursor.pressed.insert((*event).button, None);
@@ -1168,7 +1176,9 @@ unsafe extern "C" fn handle_button(listener: *mut ffi::wl_listener, data: *mut s
                             start_win_virtual_y: (*border_target_win).virtual_y,
                             start_tiling_mode: (*border_target_win).tiling_mode,
                             start_mode_locked: (*border_target_win).mode_locked,
-                            started_in_overview: (*(*seat).server).wm.mode == crate::window_manager::WindowManagerMode::Overview,
+                            start_pan_x: (*(*seat).server).wm.desk_pan_x,
+                        start_pan_y: (*(*seat).server).wm.desk_pan_y,
+                        started_in_overview: (*(*seat).server).wm.mode == crate::window_manager::WindowManagerMode::Overview,
                         });
                         cursor.op_start_pointer();
                         cursor.pressed.insert((*event).button, None);
diff --git a/src/server/seat.rs b/src/server/seat.rs
index 0465fb4..1b4bc5c 100644
--- a/src/server/seat.rs
+++ b/src/server/seat.rs
@@ -29,6 +29,12 @@ pub struct SeatOp {
     pub start_win_h: u32,
     pub start_win_virtual_x: f64,
     pub start_win_virtual_y: f64,
+    /// Desk pan at op start. The op tracks the cursor in virtual space as
+    /// `start + cursor_delta/zoom + (pan - start_pan)`: the pan-delta term
+    /// keeps the dragged window/edge pinned to the cursor when edge auto-pan
+    /// (or anything else) scrolls the desktop mid-drag.
+    pub start_pan_x: f64,
+    pub start_pan_y: f64,
     pub start_tiling_mode: crate::tiling::TilingMode,
     pub start_mode_locked: bool,
     pub started_in_overview: bool,
@@ -1130,9 +1136,9 @@ impl Seat {
                             let scale = (*(*self.server).wm.server).wm.desk_zoom;
                             let pan_x = (*(*self.server).wm.server).wm.desk_pan_x;
                             let pan_y = (*(*self.server).wm.server).wm.desk_pan_y;
-                            let virtual_dx = dx as f64 / scale;
-                            let virtual_dy = dy as f64 / scale;
-                            
+                            let virtual_dx = dx as f64 / scale + (pan_x - op.start_pan_x);
+                            let virtual_dy = dy as f64 / scale + (pan_y - op.start_pan_y);
+
                             let vx = op.start_win_virtual_x + virtual_dx;
                             let vy = op.start_win_virtual_y + virtual_dy;
                             let (vx, vy) = crate::policy::snap::snap_move(
@@ -1172,8 +1178,8 @@ impl Seat {
                         let scale = (*(*self.server).wm.server).wm.desk_zoom;
                         let pan_x = (*(*self.server).wm.server).wm.desk_pan_x;
                         let pan_y = (*(*self.server).wm.server).wm.desk_pan_y;
-                        let virtual_dx = dx as f64 / scale;
-                        let virtual_dy = dy as f64 / scale;
+                        let virtual_dx = dx as f64 / scale + (pan_x - op.start_pan_x);
+                        let virtual_dy = dy as f64 / scale + (pan_y - op.start_pan_y);
 
                         let mut vx = op.start_win_virtual_x;
                         let mut vy = op.start_win_virtual_y;
@@ -1249,11 +1255,52 @@ impl Seat {
             }
             (*self.server).wm.dirty_windowing();
         }
+        self.update_edge_pan(x as f64, y as f64);
+    }
+
+    /// Edge auto-pan eligibility + velocity for the current op: while an
+    /// interactive move/resize holds the cursor inside the band at an output
+    /// edge, the desktop scrolls that way, ramping from 0 at the band's inner
+    /// rim to full speed at the screen edge. Called on every op motion AND
+    /// from the edge-pan tick's op_update, which is what re-arms the timer —
+    /// so the scroll continues while the cursor rests pinned at the edge.
+    unsafe fn update_edge_pan(&mut self, lx: f64, ly: f64) {
+        let wm = &mut (*self.server).wm;
+        let mut vx = 0.0;
+        let mut vy = 0.0;
+        let eligible = wm.layout.desktop_edge_pan
+            && match self.op {
+                Some(ref op) => {
+                    !op.window_ptr.is_null()
+                        && !(*op.window_ptr).closed
+                        && !(*op.window_ptr).is_status_bar()
+                }
+                None => false,
+            };
+        if eligible {
+            let wlr_output = (*self.server).om.output_at(lx, ly);
+            if !wlr_output.is_null() {
+                let mut ob = ffi::wlr_box { x: 0, y: 0, width: 0, height: 0 };
+                ffi::wlr_output_layout_get_box((*self.server).om.output_layout, wlr_output, &mut ob);
+                let band = wm.layout.desktop_edge_pan_band.max(1.0);
+                let speed = wm.layout.desktop_edge_pan_speed.max(0.0);
+                // 0 outside the band, 1 at (or past) the screen edge.
+                let ramp = |dist_to_edge: f64| ((band - dist_to_edge) / band).clamp(0.0, 1.0);
+                vx = speed
+                    * (ramp((ob.x + ob.width) as f64 - lx) - ramp(lx - ob.x as f64));
+                vy = speed
+                    * (ramp((ob.y + ob.height) as f64 - ly) - ramp(ly - ob.y as f64));
+            }
+        }
+        wm.set_edge_pan_velocity(vx, vy);
     }
 
     pub unsafe fn op_end(&mut self) {
         if let Some(op) = self.op.take() {
             log::debug!("end seat op");
+            let wm = &mut (*self.server).wm;
+            wm.edge_pan_vx = 0.0;
+            wm.edge_pan_vy = 0.0;
             let win = op.window_ptr;
             if !win.is_null() && !(*win).closed {
                 if let PointerOpType::Resize { .. } = op.op_type {
@@ -1498,6 +1545,8 @@ unsafe extern "C" fn seat_op_start_pointer(
             start_win_h: 0,
             start_win_virtual_x: 0.0,
             start_win_virtual_y: 0.0,
+            start_pan_x: (*(*seat).server).wm.desk_pan_x,
+            start_pan_y: (*(*seat).server).wm.desk_pan_y,
             start_tiling_mode: crate::tiling::TilingMode::Floating,
             start_mode_locked: false,
             started_in_overview: false,
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index 610c7f5..c590a44 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -116,6 +116,12 @@ pub struct WindowManager {
     pub target_desk_pan_x: Option<f64>,
     pub target_desk_pan_y: Option<f64>,
     pub animation_timer: *mut ffi::wl_event_source,
+    /// Edge auto-pan velocity during an interactive move/resize, in SCREEN
+    /// px/s (the tick divides by zoom). Written by `Seat::update_edge_pan`
+    /// on every op motion; both zero when the cursor is outside the bands.
+    pub edge_pan_vx: f64,
+    pub edge_pan_vy: f64,
+    pub edge_pan_timer: *mut ffi::wl_event_source,
     pub has_restored_focused_window: bool,
     pub restored_focused_window_mapped: bool,
     pub last_viewport_zoom: f64,
@@ -185,6 +191,9 @@ impl WindowManager {
         self.target_desk_pan_x = None;
         self.target_desk_pan_y = None;
         self.animation_timer = std::ptr::null_mut();
+        self.edge_pan_vx = 0.0;
+        self.edge_pan_vy = 0.0;
+        self.edge_pan_timer = std::ptr::null_mut();
         self.viewport_is_active = false;
         self.viewport_settle_timer = std::ptr::null_mut();
         self.desk_zoom = 1.0;
@@ -639,6 +648,10 @@ impl WindowManager {
             ffi::wl_event_source_remove(self.animation_timer);
             self.animation_timer = std::ptr::null_mut();
         }
+        if !self.edge_pan_timer.is_null() {
+            ffi::wl_event_source_remove(self.edge_pan_timer);
+            self.edge_pan_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();
@@ -651,6 +664,28 @@ impl WindowManager {
         self.target_desk_pan_y = None;
     }
 
+    /// Record the edge auto-pan velocity (screen px/s) and arm its 16ms tick
+    /// when nonzero. A zero velocity just parks: the armed tick sees it and
+    /// stops itself without re-arming.
+    pub unsafe fn set_edge_pan_velocity(&mut self, vx: f64, vy: f64) {
+        self.edge_pan_vx = vx;
+        self.edge_pan_vy = vy;
+        if vx == 0.0 && vy == 0.0 {
+            return;
+        }
+        if self.edge_pan_timer.is_null() {
+            let event_loop = ffi::wl_display_get_event_loop((*self.server).wl_server);
+            self.edge_pan_timer = ffi::wl_event_loop_add_timer(
+                event_loop,
+                Some(handle_edge_pan_tick),
+                self as *mut WindowManager as *mut _,
+            );
+        }
+        if !self.edge_pan_timer.is_null() {
+            ffi::wl_event_source_timer_update(self.edge_pan_timer, 16);
+        }
+    }
+
     /// 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.
@@ -1152,8 +1187,8 @@ impl WindowManager {
                         let scale = self.desk_zoom;
                         let dx = op.x - op.start_x;
                         let dy = op.y - op.start_y;
-                        let virtual_dx = dx as f64 / scale;
-                        let virtual_dy = dy as f64 / scale;
+                        let virtual_dx = dx as f64 / scale + (self.desk_pan_x - op.start_pan_x);
+                        let virtual_dy = dy as f64 / scale + (self.desk_pan_y - op.start_pan_y);
                         // Same math (and snapping) as the seat op's Resize
                         // arm — this recomputation feeds the arrange
                         // snapshot and must not diverge from it.
@@ -3966,6 +4001,41 @@ pub(crate) unsafe extern "C" fn handle_viewport_settle_tick(data: *mut std::ffi:
     0
 }
 
+/// 16ms edge auto-pan tick: scroll the desktop by the current velocity, then
+/// re-run the seat op at its last cursor position so the dragged window keeps
+/// tracking the (pinned) cursor — the op's pan-delta term turns the scroll
+/// into window motion. op_update re-derives the velocity and re-arms this
+/// timer, so the loop sustains itself until the op ends or the cursor leaves
+/// the edge bands; then it stops without re-arming.
+pub(crate) unsafe extern "C" fn handle_edge_pan_tick(data: *mut std::ffi::c_void) -> std::os::raw::c_int {
+    let wm = data as *mut WindowManager;
+    if wm.is_null() {
+        return 0;
+    }
+    let (vx, vy) = ((*wm).edge_pan_vx, (*wm).edge_pan_vy);
+    if vx == 0.0 && vy == 0.0 {
+        return 0;
+    }
+    let seat = match (*wm).first_seat() {
+        Some(seat) if (*seat).op.is_some() => seat,
+        _ => {
+            (*wm).edge_pan_vx = 0.0;
+            (*wm).edge_pan_vy = 0.0;
+            return 0;
+        }
+    };
+    let (ox, oy) = {
+        let op = (*seat).op.as_ref().unwrap();
+        (op.x, op.y)
+    };
+    let dt = 0.016;
+    let zoom = (*wm).desk_zoom.max(0.01);
+    (*wm).desk_pan_x += vx * dt / zoom;
+    (*wm).desk_pan_y += vy * dt / zoom;
+    (*seat).op_update(ox, oy);
+    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() {
diff --git a/src/server/xdg_toplevel.rs b/src/server/xdg_toplevel.rs
index 56a085f..0782a7f 100644
--- a/src/server/xdg_toplevel.rs
+++ b/src/server/xdg_toplevel.rs
@@ -826,6 +826,8 @@ unsafe extern "C" fn handle_request_move(
             start_win_virtual_y: (*window).virtual_y,
             start_tiling_mode: (*window).tiling_mode,
             start_mode_locked: (*window).mode_locked,
+            start_pan_x: (*(*window).server).wm.desk_pan_x,
+            start_pan_y: (*(*window).server).wm.desk_pan_y,
             started_in_overview: (*(*window).server).wm.mode == crate::window_manager::WindowManagerMode::Overview,
         });
         cursor.op_start_pointer();
@@ -881,6 +883,8 @@ unsafe extern "C" fn handle_request_resize(
             start_win_virtual_y: (*window).virtual_y,
             start_tiling_mode: (*window).tiling_mode,
             start_mode_locked: (*window).mode_locked,
+            start_pan_x: (*(*window).server).wm.desk_pan_x,
+            start_pan_y: (*(*window).server).wm.desk_pan_y,
             started_in_overview: (*(*window).server).wm.mode == crate::window_manager::WindowManagerMode::Overview,
         });
         cursor.op_start_pointer();