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

commit6075bd8c13de13e4ec77ecc481f143ae6c1211cf
parenta43b0b66ec
authorLucas Galante <[email protected]>
date2026-08-21 12:49
fix: hard-snap displaced tiled windows — the magnetic snap had a dead band

The overview displace path snapped a tiled candidate's exit spot with
snap_move at a threshold widened to 0.45 * cell_size, claiming the
abutting position 'always finds its cell'. It cannot: snap targets are
one PERIOD apart, so the farthest landing spot is (cell + gap)/2 from
any target — always more than 0.45 * cell. In the resulting dead band
(gap + 0.1 * cell wide per period, ~67px at cell 512 / gap 16, ~13% of
positions per axis) the snap silently returned the spot unchanged, the
tiled window came to rest mid-cell, and the arrange pass's tiled_span
then expanded it to every cell the off-grid box touched — displaced
windows visibly grew a row or column, and kept the size even when the
ledger returned them home.

Use snap_move_tiled instead: the thresholdless hard snap that exists
precisely because a tiled window resting between squares is not a
reachable state. Regression test pins a landing spot 256px off-target,
squarely in the old dead band.

Shadow-verified: an overview drag covering a tiled 1032x504 window
displaces it to the abutting cell-aligned spot with its size unchanged.

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

 src/overview.rs | 38 ++++++++++++++++++++++++++++++++------
 1 file changed, 32 insertions(+), 6 deletions(-)

diff --git a/src/overview.rs b/src/overview.rs
index b001257..83d992f 100644
--- a/src/overview.rs
+++ b/src/overview.rs
@@ -88,13 +88,16 @@ pub fn displace(
             ny = if dy >= 0.0 { my - gap - c.h } else { my + mh + gap };
         }
 
-        // A tiled candidate stays tiled: snap the landing spot to the cell
-        // edges (its size is already cell-quantized, so a one-edge snap
-        // aligns the whole box). Threshold is widened to half a period so
-        // the abutting position always finds its cell.
+        // A tiled candidate stays tiled: hard-snap the landing spot to the
+        // nearest cell edges, no threshold (its size is already
+        // cell-quantized, so aligning the low edges aligns the whole box).
+        // Magnetic snapping cannot be widened into a guarantee — targets
+        // are one PERIOD apart, so any threshold below (cell + gap)/2
+        // leaves a dead band around the midpoint where the exit spot rests
+        // mid-cell, and the arrange pass then expands the "tiled" window to
+        // every cell the off-grid box touches.
         if c.tiled {
-            let wide = SnapParams { threshold: p.cell_size * 0.45, ..*p };
-            let (sx, sy) = snap::snap_move(nx, ny, c.w, c.h, &wide);
+            let (sx, sy) = snap::snap_move_tiled(nx, ny, p);
             nx = sx;
             ny = sy;
         }
@@ -188,6 +191,29 @@ mod tests {
         assert_eq!(d[0].1, (4.0, 4.0));
     }
 
+    #[test]
+    fn tiled_exit_in_magnetic_dead_band_still_snaps() {
+        // With a gap the grid period is 528, so snap targets are 528 apart
+        // and the old widened magnetic snap (threshold 0.45 * cell = 230.4)
+        // had a ~67px dead band around the midpoint: a landing spot ~256px
+        // from the nearest edge stayed mid-cell, and the arrange pass then
+        // grew the "tiled" window to every cell it touched.
+        let p = SnapParams { cell_size: 512.0, gap_width: 16.0, cell_inset: 4.0, threshold: 24.0 };
+        // Tiled candidate filling cell row 1 exactly: visible box
+        // y [532, 1036] → y=532, h=504.
+        let covered = DisplaceCandidate { x: 4.0, y: 532.0, w: 504.0, h: 504.0, tiled: true };
+        // Dragged DOWN onto it; the mover's mid-drag y is not grid-aligned.
+        // Overlap y [780, 1036] = 256 of 504 → ~51% of the smaller window.
+        let moved = (4.0, 780.0, 600.0, 600.0);
+        let d = displace(moved, (0.0, 300.0), &[covered], &p, 16.0);
+        assert_eq!(d.len(), 1);
+        // Downward drag: the candidate exits above, abutting the mover:
+        // raw y = 780 - 16 - 504 = 260 — 256 from the nearest low target
+        // (4), squarely in the old dead band. The hard snap lands it there
+        // anyway; x is untouched and already aligned.
+        assert_eq!(d[0].1, (4.0, 4.0));
+    }
+
     #[test]
     fn multiple_covered_windows_each_displace() {
         let a = cand(1000.0, 100.0, 400.0, 400.0);