git.lucas.co / cce-window-manager
window management library
git clone https://git.lucas.co/cce-window-manager.git

commit32922dc5f352ccd57787471baf112ff8b5820e9a
parentf1a8dd09d2
authorLucas Galante <[email protected]>
date2026-07-15 13:24
feat: Maximized windows snap to visible cell edges

snap::maximized_span computes the visible outer edges of every cell a
span touches (period = cell_size + gap_width, edges inset by the cell
fade inset); the arrange Maximized arm uses it instead of bare
grid-line multiples, so Maximized windows abut cells exactly like
interactive grid snapping. NormalParams gains desktop_gap_width and
desktop_cell_inset. Zero gap/inset reproduces the old behavior.

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

 src/arrange.rs | 88 +++++++++++++++++++++++++++++++++++++++-------------------
 src/snap.rs    | 33 ++++++++++++++++++++--
 2 files changed, 90 insertions(+), 31 deletions(-)

diff --git a/src/arrange.rs b/src/arrange.rs
index f7ec534..e429c38 100644
--- a/src/arrange.rs
+++ b/src/arrange.rs
@@ -347,6 +347,11 @@ pub struct NormalParams {
     pub border_width: i32,
     pub cloud_position_default: Option<[i32; 2]>,
     pub desktop_grid_scale: f64,
+    /// Desktop grid gap between cells (the grid period is scale + gap).
+    pub desktop_gap_width: f64,
+    /// Visual inset of a cell's edge (the fade inset); Maximized windows
+    /// snap to the visible cell edges like interactive snapping does.
+    pub desktop_cell_inset: f64,
 }
 
 pub struct NormalPlacement {
@@ -417,35 +422,29 @@ pub fn place_normal_window(
             virtual_write: None,
         },
         TilingMode::Maximized => {
-            // Resize to fully fill all desktop-grid cells the saved geometry
-            // is fully/partially inside of.
-            let scale = p.desktop_grid_scale;
-
+            // Cover the VISIBLE edges of every desktop-grid cell the saved
+            // geometry touches — the same cell-edge geometry as interactive
+            // grid snapping (snap::maximized_span).
             let x1 = snap.saved_maximized_virtual.0;
             let y1 = snap.saved_maximized_virtual.1;
-            let w = snap.saved_maximized_size.0 as f64;
-            let h = snap.saved_maximized_size.1 as f64;
-            let x2 = x1 + w;
-            let y2 = y1 + h;
-
-            let col_min = (x1 / scale).floor() as i32;
-            let col_max = ((x2 / scale).ceil() as i32 - 1).max(col_min);
-            let row_min = (y1 / scale).floor() as i32;
-            let row_max = ((y2 / scale).ceil() as i32 - 1).max(row_min);
-
-            let snapped_x1 = col_min as f64 * scale;
-            let snapped_x2 = (col_max + 1) as f64 * scale;
-            let snapped_y1 = row_min as f64 * scale;
-            let snapped_y2 = (row_max + 1) as f64 * scale;
-
-            // The snapped cell span is border-inclusive: the content is inset
-            // so the border stays inside the covered cells. Idempotent across
+            let x2 = x1 + snap.saved_maximized_size.0 as f64;
+            let y2 = y1 + snap.saved_maximized_size.1 as f64;
+
+            let (low_x, high_x) = crate::snap::maximized_span(
+                x1, x2, p.desktop_grid_scale, p.desktop_gap_width, p.desktop_cell_inset,
+            );
+            let (low_y, high_y) = crate::snap::maximized_span(
+                y1, y2, p.desktop_grid_scale, p.desktop_gap_width, p.desktop_cell_inset,
+            );
+
+            // The span is border-inclusive: the content is inset so the
+            // border stays inside the covered cells. Idempotent across
             // frames because it re-derives from the saved geometry.
             let bw = p.border_width.max(0) as f64;
-            let content_x = snapped_x1 + bw;
-            let content_y = snapped_y1 + bw;
-            let fw = (snapped_x2 - snapped_x1 - 2.0 * bw).max(1.0);
-            let fh = (snapped_y2 - snapped_y1 - 2.0 * bw).max(1.0);
+            let content_x = low_x + bw;
+            let content_y = low_y + bw;
+            let fw = (high_x - low_x - 2.0 * bw).max(1.0);
+            let fh = (high_y - low_y - 2.0 * bw).max(1.0);
 
             let (final_x, final_y) = ctx.virtual_to_screen(content_x, content_y);
 
@@ -1367,7 +1366,7 @@ mod tests {
             saved_maximized_size: (100, 50),
             saved_maximized_virtual: (150.0, 120.0),
         };
-        let p = NormalParams { gap_right: 10, gap_top: 6, border_width: 0, cloud_position_default: None, desktop_grid_scale: 100.0 };
+        let p = NormalParams { gap_right: 10, gap_top: 6, border_width: 0, cloud_position_default: None, desktop_grid_scale: 100.0, desktop_gap_width: 0.0, desktop_cell_inset: 0.0 };
         let placement = place_normal_window(&snap, &p, &ctx());
         // Saved geometry spans grid columns 1-2 and row 1 → snapped to
         // (100,100) with size 200x100.
@@ -1390,7 +1389,7 @@ mod tests {
             saved_maximized_size: (100, 50),
             saved_maximized_virtual: (150.0, 120.0),
         };
-        let p = NormalParams { gap_right: 10, gap_top: 6, border_width: 4, cloud_position_default: None, desktop_grid_scale: 100.0 };
+        let p = NormalParams { gap_right: 10, gap_top: 6, border_width: 4, cloud_position_default: None, desktop_grid_scale: 100.0, desktop_gap_width: 0.0, desktop_cell_inset: 0.0 };
         let placement = place_normal_window(&snap, &p, &ctx());
         // Same cell span as without borders (100,100)+200x100, with the
         // content inset so the border stays inside the covered cells.
@@ -1399,6 +1398,35 @@ mod tests {
         assert_eq!(placement.size, (192, 92));
     }
 
+    #[test]
+    fn maximized_snaps_to_visible_cell_edges() {
+        let snap = NormalSnapshot {
+            mode: TilingMode::Maximized,
+            box_geom: Rect { x: 0, y: 0, width: 100, height: 50 },
+            min_size: (0, 0),
+            virtual_pos: (150.0, 120.0),
+            active_resize: None,
+            is_cloud: false,
+            saved_maximized_size: (100, 50),
+            saved_maximized_virtual: (150.0, 120.0),
+        };
+        // period 110 (gap 10), inset 5, border 4: cells x 1-2 visibly span
+        // [115, 315], row y 1 spans [115, 205]; content insets by the border.
+        let p = NormalParams {
+            gap_right: 10,
+            gap_top: 6,
+            border_width: 4,
+            cloud_position_default: None,
+            desktop_grid_scale: 100.0,
+            desktop_gap_width: 10.0,
+            desktop_cell_inset: 5.0,
+        };
+        let placement = place_normal_window(&snap, &p, &ctx());
+        assert_eq!(placement.virtual_write, Some((119.0, 119.0)));
+        assert_eq!(placement.pos, (119, 119));
+        assert_eq!(placement.size, (192, 82));
+    }
+
     #[test]
     fn popup_docks_top_right_of_usable_area() {
         let snap = NormalSnapshot {
@@ -1411,7 +1439,7 @@ mod tests {
             saved_maximized_size: (0, 0),
             saved_maximized_virtual: (0.0, 0.0),
         };
-        let p = NormalParams { gap_right: 10, gap_top: 6, border_width: 0, cloud_position_default: None, desktop_grid_scale: 100.0 };
+        let p = NormalParams { gap_right: 10, gap_top: 6, border_width: 0, cloud_position_default: None, desktop_grid_scale: 100.0, desktop_gap_width: 0.0, desktop_cell_inset: 0.0 };
         let placement = place_normal_window(&snap, &p, &ctx());
         // Defaults to 360x100, docked inside the usable area (below the bar).
         assert_eq!(placement.pos, (1920 - 360 - 10, 30 + 6));
@@ -1438,7 +1466,7 @@ mod tests {
             saved_maximized_size: (0, 0),
             saved_maximized_virtual: (0.0, 0.0),
         };
-        let p = NormalParams { gap_right: 10, gap_top: 6, border_width: 0, cloud_position_default: None, desktop_grid_scale: 100.0 };
+        let p = NormalParams { gap_right: 10, gap_top: 6, border_width: 0, cloud_position_default: None, desktop_grid_scale: 100.0, desktop_gap_width: 0.0, desktop_cell_inset: 0.0 };
         let mut c = ctx();
         c.pan_x = 50.0;
         c.pan_y = 100.0;
@@ -1520,6 +1548,8 @@ mod tests {
                 border_width: 0,
                 cloud_position_default: None,
                 desktop_grid_scale: 100.0,
+                desktop_gap_width: 0.0,
+                desktop_cell_inset: 0.0,
             },
             pan_x: 0.0,
             pan_y: 0.0,
diff --git a/src/snap.rs b/src/snap.rs
index 52d66f0..8dd2390 100644
--- a/src/snap.rs
+++ b/src/snap.rs
@@ -12,6 +12,25 @@
 // to the former, a right/bottom edge to the latter, letting windows abut
 // the cells instead of floating mid-gap.
 
+fn grid_period(cell_size: f64, gap_width: f64) -> f64 {
+    cell_size + gap_width.max(0.0)
+}
+
+fn grid_inset(cell_size: f64, cell_inset: f64) -> f64 {
+    cell_inset.clamp(0.0, cell_size / 2.0 - 1.0)
+}
+
+/// Hard grid snap for Maximized windows: the visible outer edges of every
+/// cell the span [x1, x2) touches. Returns (outer_low, outer_high) — the
+/// border-inclusive footprint; content insets by the border width from it.
+pub fn maximized_span(x1: f64, x2: f64, cell_size: f64, gap_width: f64, cell_inset: f64) -> (f64, f64) {
+    let p = grid_period(cell_size, gap_width);
+    let inset = grid_inset(cell_size, cell_inset);
+    let col_min = (x1 / p).floor();
+    let col_max = ((x2 / p).ceil() - 1.0).max(col_min);
+    (col_min * p + inset, col_max * p + cell_size - inset)
+}
+
 #[derive(Debug, Clone, Copy)]
 pub struct SnapParams {
     /// Desktop grid cell size in virtual units.
@@ -36,12 +55,12 @@ impl SnapParams {
     }
 
     fn period(&self) -> f64 {
-        self.cell_size + self.gap_width.max(0.0)
+        grid_period(self.cell_size, self.gap_width)
     }
 
     /// Inset clamped so the two visible edges of a cell can't cross.
     fn inset(&self) -> f64 {
-        self.cell_inset.clamp(0.0, self.cell_size / 2.0 - 1.0)
+        grid_inset(self.cell_size, self.cell_inset)
     }
 
     /// Nearest visible LEFT/TOP cell edge (k*period + inset) to `v`.
@@ -175,6 +194,16 @@ mod tests {
         assert_eq!((x, y), (100.0, 200.0));
     }
 
+    #[test]
+    fn maximized_span_covers_touched_visible_cells() {
+        // period 100 (no gap), inset 0: legacy behavior — bare cell lines.
+        assert_eq!(maximized_span(150.0, 250.0, 100.0, 0.0, 0.0), (100.0, 300.0));
+        // period 110 (gap 10), inset 5: cells 1-2 visibly span [115, 315].
+        assert_eq!(maximized_span(150.0, 250.0, 100.0, 10.0, 5.0), (115.0, 315.0));
+        // Span ending exactly on a period boundary doesn't touch the next cell.
+        assert_eq!(maximized_span(150.0, 220.0, 100.0, 10.0, 5.0), (115.0, 205.0));
+    }
+
     #[test]
     fn zero_threshold_disables() {
         let p = SnapParams { threshold: 0.0, ..params() };