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

commit314f369e3a51fa5632b228bac89ee20491909936
parentb6af11a243
authorLucas Galante <[email protected]>
date2026-07-15 13:02
feat: grid-snap interactive move/resize via policy snap module

Move and resize ops now magnetically snap to the desktop grid:
op_update runs the pointer-derived position/edge through
cce-window-manager's snap module (border-inclusive, nearest grid line
within a threshold). Config: 'snap' and 'snap_threshold' on the
desktop node (defaults on, 24 virtual px); snap=false or threshold 0
disables.

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

 src/server/config.rs | 34 ++++++++++++++++++++++++++++++++++
 src/server/seat.rs   | 43 +++++++++++++++++++++++++++++++++++++------
 2 files changed, 71 insertions(+), 6 deletions(-)

diff --git a/src/server/config.rs b/src/server/config.rs
index 6442d39..b182916 100644
--- a/src/server/config.rs
+++ b/src/server/config.rs
@@ -59,6 +59,10 @@ pub struct Layout {
     pub desktop_grid_fade_mode: String,
     pub desktop_enable_solid_color: bool,
     pub desktop_solid_color: [f32; 4],
+    /// Magnetic grid snap for interactive move/resize.
+    pub desktop_snap: bool,
+    /// Snap radius in virtual units.
+    pub desktop_snap_threshold: f64,
     pub scenefx_optimized_blur: bool,
     pub status_backdrop_blur_ignore_transparent: bool,
     pub window_backdrop_blur_ignore_transparent: bool,
@@ -114,6 +118,8 @@ impl Default for Layout {
             desktop_grid_fade_mode: "linear".to_string(),
             desktop_enable_solid_color: false,
             desktop_solid_color: [0.0, 0.0, 0.0, 1.0],
+            desktop_snap: true,
+            desktop_snap_threshold: 24.0,
             scenefx_optimized_blur: true,
             status_backdrop_blur_ignore_transparent: true,
             window_backdrop_blur_ignore_transparent: true,
@@ -275,6 +281,10 @@ pub struct SurfaceConfig {
     pub desktop_grid_fade_mode: String,
     #[serde(default = "default_desktop_solid_color")]
     pub desktop_solid_color: String,
+    #[serde(default = "default_desktop_snap")]
+    pub desktop_snap: bool,
+    #[serde(default = "default_desktop_snap_threshold")]
+    pub desktop_snap_threshold: i64,
     #[serde(default = "default_backplate_color")]
     pub backplate_color: String,
     #[serde(default = "default_backplate_blur")]
@@ -314,6 +324,8 @@ impl Default for SurfaceConfig {
             desktop_mode: default_desktop_mode(),
             desktop_grid_fade_mode: default_desktop_grid_fade_mode(),
             desktop_solid_color: default_desktop_solid_color(),
+            desktop_snap: default_desktop_snap(),
+            desktop_snap_threshold: default_desktop_snap_threshold(),
             backplate_color: default_backplate_color(),
             backplate_blur: default_backplate_blur(),
             backplate_corner_radius: default_backplate_corner_radius(),
@@ -370,6 +382,14 @@ fn default_desktop_solid_color() -> String {
     "#000000".to_string()
 }
 
+fn default_desktop_snap() -> bool {
+    true
+}
+
+fn default_desktop_snap_threshold() -> i64 {
+    24
+}
+
 fn default_backplate_color() -> String {
     "#151520e6".to_string()
 }
@@ -1431,6 +1451,16 @@ fn parse_kdl_config(content: &str) -> Result<Config, String> {
                                             surface.desktop_grid_scale = val;
                                         }
                                     }
+                                    "snap" => {
+                                        if let Some(val) = entry.value().as_bool() {
+                                            surface.desktop_snap = val;
+                                        }
+                                    }
+                                    "snap_threshold" => {
+                                        if let Some(val) = entry.value().as_i64() {
+                                            surface.desktop_snap_threshold = val;
+                                        }
+                                    }
                                     _ => {}
                                 }
                             }
@@ -1540,6 +1570,8 @@ 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_mode = get_child_arg_string(node, "desktop_mode", &default_desktop_mode());
             surface.desktop_solid_color = get_child_arg_string(node, "desktop_solid_color", &default_desktop_solid_color());
+            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.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());
@@ -1654,6 +1686,8 @@ pub fn parse_config(path: &str, state: &mut crate::window_manager::WindowManager
 
     state.layout.desktop_cell_color = parse_hex_color_rgba(&config.surface.desktop_cell_color);
     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_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/seat.rs b/src/server/seat.rs
index 401a95d..84e1d11 100644
--- a/src/server/seat.rs
+++ b/src/server/seat.rs
@@ -943,7 +943,19 @@ impl Seat {
         found
     }
 
+    /// Snap parameters for interactive ops, from the current layout config.
+    /// A zero threshold (snap disabled) makes every snap function a no-op.
+    unsafe fn snap_params(&self) -> crate::policy::snap::SnapParams {
+        let layout = &(*self.server).wm.layout;
+        crate::policy::snap::SnapParams {
+            grid_scale: layout.desktop_grid_scale,
+            threshold: if layout.desktop_snap { layout.desktop_snap_threshold } else { 0.0 },
+            border_width: layout.border_width as f64,
+        }
+    }
+
     pub unsafe fn op_update(&mut self, x: i32, y: i32) {
+        let sp = self.snap_params();
         if let Some(ref mut op) = self.op {
             op.x = x;
             op.y = y;
@@ -1121,6 +1133,13 @@ impl Seat {
                             
                             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(
+                                vx,
+                                vy,
+                                (*win).box_geom.width as f64,
+                                (*win).box_geom.height as f64,
+                                &sp,
+                            );
                             (*win).virtual_x = vx;
                             (*win).virtual_y = vy;
 
@@ -1175,18 +1194,30 @@ impl Seat {
                             (*win).resize_edges = Some(edges);
                         }
 
+                        // Magnetic grid snap pulls the dragged edge onto grid
+                        // lines; the anchored edge is untouched.
                         if edges.left {
-                            let w = std::cmp::max(50, (op.start_win_w as f64 - virtual_dx) as i32) as u32;
-                            new_w = w;
+                            let left = crate::policy::snap::snap_low_edge(op.start_win_virtual_x + virtual_dx, &sp);
+                            let anchor_right = op.start_win_virtual_x + op.start_win_w as f64;
+                            new_w = std::cmp::max(50, (anchor_right - left) as i32) as u32;
                         } else if edges.right {
-                            new_w = std::cmp::max(50, (op.start_win_w as f64 + virtual_dx) as i32) as u32;
+                            let right = crate::policy::snap::snap_high_edge(
+                                op.start_win_virtual_x + op.start_win_w as f64 + virtual_dx,
+                                &sp,
+                            );
+                            new_w = std::cmp::max(50, (right - op.start_win_virtual_x) as i32) as u32;
                         }
 
                         if edges.top {
-                            let h = std::cmp::max(50, (op.start_win_h as f64 - virtual_dy) as i32) as u32;
-                            new_h = h;
+                            let top = crate::policy::snap::snap_low_edge(op.start_win_virtual_y + virtual_dy, &sp);
+                            let anchor_bottom = op.start_win_virtual_y + op.start_win_h as f64;
+                            new_h = std::cmp::max(50, (anchor_bottom - top) as i32) as u32;
                         } else if edges.bottom {
-                            new_h = std::cmp::max(50, (op.start_win_h as f64 + virtual_dy) as i32) as u32;
+                            let bottom = crate::policy::snap::snap_high_edge(
+                                op.start_win_virtual_y + op.start_win_h as f64 + virtual_dy,
+                                &sp,
+                            );
+                            new_h = std::cmp::max(50, (bottom - op.start_win_virtual_y) as i32) as u32;
                         }
 
                         (*win).virtual_x = vx;