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

src/snap.rs (23.4K)

  1 // Magnetic grid snapping for interactive move/resize.
  2 //
  3 // All coordinates are virtual-surface CONTENT coordinates, and snapping acts
  4 // directly on them: the window's own edge lands on the snap target. Borders
  5 // draw OUTSIDE the content box, so a snapped border overhangs its cell into
  6 // the gap rather than being inset to stay within it. This matches the
  7 // Tiled grid-snap convention, where the content fills the covered cells
  8 // edge to edge.
  9 //
 10 // The pull is continuous (see `pull`): an edge within half the threshold
 11 // sits on its target, one in the outer half is drawn toward it by a ramp
 12 // that vanishes at the threshold, so nothing jumps when an edge comes into
 13 // range. Only the hard Tiled snaps (`snap_move_tiled`, `resize_axis_tiled`)
 14 // are steps: a Tiled window covers whole cells and nothing else, so its
 15 // move and resize both land edge-on-cell from any distance.
 16 //
 17 // Targets are the VISIBLE cell edges, not the raw grid lines. The desktop
 18 // grid draws cells of `cell_size` every `cell_size + gap_width`, and each
 19 // cell fades inward by `cell_inset` — so cell k's visible span is
 20 // [k*period + inset, k*period + cell_size - inset]. A left/top edge snaps
 21 // to the former, a right/bottom edge to the latter, letting windows abut
 22 // the cells instead of floating mid-gap.
 23 
 24 fn grid_period(cell_size: f64, gap_width: f64) -> f64 {
 25     cell_size + gap_width.max(0.0)
 26 }
 27 
 28 fn grid_inset(cell_size: f64, cell_inset: f64) -> f64 {
 29     // `f64::clamp` panics when min > max, which a `grid_cell_size` under 2
 30     // (a config typo) produces — and this runs on every arrange pass, so the
 31     // panic would take the whole session down.
 32     cell_inset.clamp(0.0, (cell_size / 2.0 - 1.0).max(0.0))
 33 }
 34 
 35 /// Hard grid snap for Tiled windows: the visible outer edges of every
 36 /// cell the span [x1, x2) touches. Returns (low, high) — the content
 37 /// footprint, which fills the covered cells exactly.
 38 pub fn tiled_span(x1: f64, x2: f64, cell_size: f64, gap_width: f64, cell_inset: f64) -> (f64, f64) {
 39     let p = grid_period(cell_size, gap_width);
 40     let inset = grid_inset(cell_size, cell_inset);
 41     let col_min = (x1 / p).floor();
 42     let col_max = ((x2 / p).ceil() - 1.0).max(col_min);
 43     (col_min * p + inset, col_max * p + cell_size - inset)
 44 }
 45 
 46 #[derive(Debug, Clone, Copy)]
 47 pub struct SnapParams {
 48     /// Desktop grid cell WIDTH in virtual units (the x-axis cell size).
 49     pub cell_w: f64,
 50     /// Desktop grid cell HEIGHT in virtual units (the y-axis cell size).
 51     pub cell_h: f64,
 52     /// Gap between cells; each axis's grid period is its cell size + gap.
 53     pub gap_width: f64,
 54     /// Visual inset of a cell's edge (the fade inset).
 55     pub cell_inset: f64,
 56     /// Snap radius in virtual units; <= 0 disables snapping.
 57     pub threshold: f64,
 58 }
 59 
 60 /// One axis's view of the grid: the cell size along that axis plus the
 61 /// shared gap/inset/threshold. All the target math lives here; x/y code
 62 /// paths differ only in which cell size they carry.
 63 #[derive(Debug, Clone, Copy)]
 64 pub struct AxisSnapParams {
 65     pub cell_size: f64,
 66     pub gap_width: f64,
 67     pub cell_inset: f64,
 68     pub threshold: f64,
 69 }
 70 
 71 impl SnapParams {
 72     /// The horizontal axis: columns of width `cell_w`.
 73     pub fn x(&self) -> AxisSnapParams {
 74         AxisSnapParams {
 75             cell_size: self.cell_w,
 76             gap_width: self.gap_width,
 77             cell_inset: self.cell_inset,
 78             threshold: self.threshold,
 79         }
 80     }
 81 
 82     /// The vertical axis: rows of height `cell_h`.
 83     pub fn y(&self) -> AxisSnapParams {
 84         AxisSnapParams {
 85             cell_size: self.cell_h,
 86             gap_width: self.gap_width,
 87             cell_inset: self.cell_inset,
 88             threshold: self.threshold,
 89         }
 90     }
 91 
 92     /// Snapping is aimed in SCREEN space: the configured threshold is the
 93     /// grab distance at zoom 1, and zooming out must not shrink the felt
 94     /// target — so the virtual-space threshold grows by 1/zoom. Capped at
 95     /// 45% of the SMALLER cell dimension so a deep zoom-out can't snap from
 96     /// half a cell away (targets are one period apart; past the midpoint
 97     /// snapping would thrash between neighbors).
 98     pub fn for_zoom(mut self, zoom: f64) -> Self {
 99         if self.threshold > 0.0 && zoom > 0.0 && zoom.is_finite() {
100             self.threshold =
101                 (self.threshold / zoom).min(self.cell_w.min(self.cell_h) * 0.45);
102         }
103         self
104     }
105 }
106 
107 impl AxisSnapParams {
108     fn enabled(&self) -> bool {
109         self.threshold > 0.0 && self.cell_size > 0.5
110     }
111 
112     fn period(&self) -> f64 {
113         grid_period(self.cell_size, self.gap_width)
114     }
115 
116     /// Inset clamped so the two visible edges of a cell can't cross.
117     fn inset(&self) -> f64 {
118         grid_inset(self.cell_size, self.cell_inset)
119     }
120 
121     /// Nearest visible LEFT/TOP cell edge (k*period + inset) to `v`.
122     fn nearest_low_target(&self, v: f64) -> f64 {
123         let p = self.period();
124         let inset = self.inset();
125         ((v - inset) / p).round() * p + inset
126     }
127 
128     /// Nearest visible RIGHT/BOTTOM cell edge (k*period + cell_size - inset).
129     fn nearest_high_target(&self, v: f64) -> f64 {
130         let p = self.period();
131         let edge = self.cell_size - self.inset();
132         ((v - edge) / p).round() * p + edge
133     }
134 }
135 
136 fn within(delta: f64, p: &AxisSnapParams) -> bool {
137     delta.abs() <= p.threshold
138 }
139 
140 /// Fraction of the threshold inside which a pulled edge sits ON its target.
141 const SNAP_HOLD_FRACTION: f64 = 0.5;
142 
143 /// Magnetic pull toward `target` as a CONTINUOUS function of the distance:
144 /// inside the hold radius (half the threshold) the edge sits on the target;
145 /// from there out to the threshold it keeps a fraction of its distance that
146 /// ramps linearly from 0 to 1, meeting the untouched position exactly at
147 /// the threshold. Outside, untouched.
148 ///
149 /// The old rule was a step — anything within the threshold jumped onto the
150 /// target — and at overview zoom, where the threshold is scaled by 1/zoom
151 /// to keep its screen size, the jump was up to 24 screen px: an edge being
152 /// dragged toward a cell edge lurched the moment it came into range. The
153 /// ramp trades the outer half of the landing zone for a pull that
154 /// decelerates the edge into the target instead.
155 fn pull(pos: f64, target: f64, p: &AxisSnapParams) -> f64 {
156     let d = pos - target;
157     let r_out = p.threshold;
158     let r_in = r_out * SNAP_HOLD_FRACTION;
159     let a = d.abs();
160     if a >= r_out {
161         pos
162     } else if a <= r_in {
163         target
164     } else {
165         target + d.signum() * (a - r_in) * (r_out / (r_out - r_in))
166     }
167 }
168 
169 /// True when every content edge of the box lies on a visible cell edge —
170 /// the geometric definition of `TilingMode::Tiled`. Left/top edges must sit
171 /// on a low target (`k*period + inset`), right/bottom edges on a high target
172 /// (`k*period + cell_size - inset`), each within `eps`. Independent of the
173 /// snap `threshold`: this classifies a resting geometry, it doesn't attract
174 /// one.
175 pub fn is_cell_aligned(x: f64, y: f64, w: f64, h: f64, p: &SnapParams, eps: f64) -> bool {
176     if p.cell_w <= 0.5 || p.cell_h <= 0.5 || w <= 0.0 || h <= 0.0 {
177         return false;
178     }
179     let (px, py) = (p.x(), p.y());
180     (px.nearest_low_target(x) - x).abs() <= eps
181         && (px.nearest_high_target(x + w) - (x + w)).abs() <= eps
182         && (py.nearest_low_target(y) - y).abs() <= eps
183         && (py.nearest_high_target(y + h) - (y + h)).abs() <= eps
184 }
185 
186 /// Snap a window position during a move. On each axis the two content edges
187 /// compete for their nearest visible cell edge; the closer candidate within
188 /// the threshold wins. `w`/`h` are content sizes.
189 pub fn snap_move(x: f64, y: f64, w: f64, h: f64, p: &SnapParams) -> (f64, f64) {
190     (snap_move_axis(x, w, &p.x()), snap_move_axis(y, h, &p.y()))
191 }
192 
193 fn snap_move_axis(pos: f64, len: f64, p: &AxisSnapParams) -> f64 {
194     if !p.enabled() {
195         return pos;
196     }
197     let lo = pos;
198     let hi = pos + len;
199     let lo_delta = p.nearest_low_target(lo) - lo;
200     let hi_delta = p.nearest_high_target(hi) - hi;
201     if lo_delta.abs() <= hi_delta.abs() && within(lo_delta, p) {
202         pull(pos, pos + lo_delta, p)
203     } else if within(hi_delta, p) {
204         pull(pos, pos + hi_delta, p)
205     } else {
206         pos
207     }
208 }
209 
210 /// Snap the dragged left/top CONTENT edge during a resize onto the nearest
211 /// visible left/top cell edge. Takes the axis view: `p.x()` when dragging a
212 /// left edge, `p.y()` for a top edge.
213 pub fn snap_low_edge(pos: f64, p: &AxisSnapParams) -> f64 {
214     if !p.enabled() {
215         return pos;
216     }
217     pull(pos, p.nearest_low_target(pos), p)
218 }
219 
220 /// Snap the dragged right/bottom CONTENT edge during a resize onto the
221 /// nearest visible right/bottom cell edge. Takes the axis view like
222 /// [`snap_low_edge`].
223 pub fn snap_high_edge(pos: f64, p: &AxisSnapParams) -> f64 {
224     if !p.enabled() {
225         return pos;
226     }
227     pull(pos, p.nearest_high_target(pos), p)
228 }
229 
230 /// Hard grid snap for MOVING a `Tiled` window: both low edges land on the
231 /// nearest cell start, with no threshold, so the window can only ever come to
232 /// rest covering whole squares. `snap_move`'s magnetic pull is for Floating
233 /// windows deciding whether to tile; once a window IS tiled, sitting between
234 /// squares is not a state it is allowed to reach — a drag that ended mid-cell
235 /// used to leave the window aligned on screen (the arrange pass re-snaps a
236 /// Tiled window's rendered box every frame) while its virtual position was
237 /// off-grid, so `is_cell_aligned` failed at op_end and the window silently
238 /// demoted to Floating and jumped.
239 ///
240 /// Deliberately not gated on `enabled()`: the snap threshold is a grab
241 /// distance for magnetic snapping, while a Tiled window fills whole cells by
242 /// definition (that is what `tiled_span` renders), so disabling magnetic
243 /// snapping must not strand it off-grid.
244 pub fn snap_move_tiled(x: f64, y: f64, p: &SnapParams) -> (f64, f64) {
245     let nx = if p.cell_w > 0.5 { p.x().nearest_low_target(x) } else { x };
246     let ny = if p.cell_h > 0.5 { p.y().nearest_low_target(y) } else { y };
247     (nx, ny)
248 }
249 
250 /// One axis of an interactive resize: the dragged content edge (low =
251 /// left/top, high = right/bottom) follows the pointer delta and snaps to the
252 /// visible cell edges; the opposite edge stays anchored. Returns the new
253 /// content length, at least `min_len`. Takes the axis view (`p.x()` for
254 /// width, `p.y()` for height). The single source of this math — both the
255 /// seat op and the arrange snapshot derive sizes from it, so the snapped
256 /// result can't be overridden by an unsnapped recomputation.
257 pub fn resize_axis(
258     start_pos: f64,
259     start_len: f64,
260     delta: f64,
261     dragging_low: bool,
262     dragging_high: bool,
263     min_len: f64,
264     p: &AxisSnapParams,
265 ) -> f64 {
266     if dragging_low {
267         let low = snap_low_edge(start_pos + delta, p);
268         ((start_pos + start_len) - low).max(min_len)
269     } else if dragging_high {
270         let high = snap_high_edge(start_pos + start_len + delta, p);
271         (high - start_pos).max(min_len)
272     } else {
273         start_len
274     }
275 }
276 
277 /// Hard grid snap for RESIZING a `Tiled` window: the dragged content edge
278 /// lands on the nearest visible cell edge of its kind (a left/top edge on a
279 /// cell start, a right/bottom edge on a cell end) from any distance, the
280 /// opposite edge stays anchored, and the result spans at least one whole
281 /// cell — so the window only ever covers whole squares, the way
282 /// `snap_move_tiled` guarantees for a move, and is still Tiled at op_end
283 /// instead of demoting to Floating on the first free resize. Not gated on
284 /// `enabled()` for the same reason as the move. A degenerate cell size
285 /// falls back to the magnetic resize rather than dividing by ~zero.
286 pub fn resize_axis_tiled(
287     start_pos: f64,
288     start_len: f64,
289     delta: f64,
290     dragging_low: bool,
291     dragging_high: bool,
292     p: &AxisSnapParams,
293 ) -> f64 {
294     if p.cell_size <= 0.5 {
295         return resize_axis(start_pos, start_len, delta, dragging_low, dragging_high, 50.0, p);
296     }
297     // One visible cell: the anchored edge is on a cell edge, so this floor
298     // is exactly "the dragged edge stops at the anchor's own cell".
299     let one_cell = (p.cell_size - 2.0 * p.inset()).max(1.0);
300     if dragging_low {
301         let anchor = start_pos + start_len;
302         let low = p.nearest_low_target(start_pos + delta);
303         (anchor - low).max(one_cell)
304     } else if dragging_high {
305         let high = p.nearest_high_target(start_pos + start_len + delta);
306         (high - start_pos).max(one_cell)
307     } else {
308         start_len
309     }
310 }
311 
312 #[cfg(test)]
313 mod tests {
314     use super::*;
315 
316     /// Square 512 cells, no gap, fade inset 4: visible cell k spans
317     /// `[512k + 4, 512k + 508]`. Border width is irrelevant to snapping now —
318     /// content edges land on the targets and the border overhangs outward.
319     fn params() -> SnapParams {
320         SnapParams {
321             cell_w: 512.0,
322             cell_h: 512.0,
323             gap_width: 0.0,
324             cell_inset: 4.0,
325             threshold: 24.0,
326         }
327     }
328 
329     #[test]
330     fn resize_low_edge_abuts_visible_cell_edge() {
331         // Content left 510 → visible edge 516 (dist 6, inside the 12 hold
332         // radius) → content 516.
333         assert_eq!(snap_low_edge(510.0, &params().x()), 516.0);
334         // Far from an edge: unchanged.
335         assert_eq!(snap_low_edge(300.0, &params().x()), 300.0);
336     }
337 
338     #[test]
339     fn resize_high_edge_abuts_visible_cell_edge() {
340         // Content right 1010 → visible edge 1020 (2*512 - 4, dist 10) → 1020.
341         assert_eq!(snap_high_edge(1010.0, &params().x()), 1020.0);
342         // At dist 20 the edge is in the ramp: it keeps (20 - 12) * 2 = 16 of
343         // its distance → 1004.
344         assert_eq!(snap_high_edge(1000.0, &params().x()), 1004.0);
345     }
346 
347     #[test]
348     fn gap_width_shifts_the_period() {
349         // cell 500 + gap 12 → period 512; cell 1's rect spans [512, 1012],
350         // visibly [516, 1008].
351         let p = SnapParams { cell_w: 500.0, cell_h: 500.0, gap_width: 12.0, ..params() };
352         assert_eq!(snap_low_edge(520.0, &p.x()), 516.0);
353         assert_eq!(snap_high_edge(996.0, &p.x()), 1008.0);
354     }
355 
356     #[test]
357     fn rectangular_cells_snap_each_axis_to_its_own_size() {
358         // 512-wide, 256-tall cells, no gap, inset 4: x targets every 512,
359         // y targets every 256 — row 1's visible top edge is 260.
360         let p = SnapParams { cell_h: 256.0, ..params() };
361         let (x, y) = snap_move(510.0, 250.0, 300.0, 100.0, &p);
362         assert_eq!((x, y), (516.0, 260.0));
363         // The hard tiled snap uses per-axis periods the same way.
364         assert_eq!(snap_move_tiled(300.0, 300.0, &p), (516.0, 260.0));
365         // A box filling one 504x248 visible cell is aligned, as is a
366         // two-row 504-tall box (2*256 - 8); a height off the row grid is
367         // not.
368         assert!(is_cell_aligned(4.0, 4.0, 504.0, 248.0, &p, 1.0));
369         assert!(is_cell_aligned(4.0, 4.0, 504.0, 504.0, &p, 1.0));
370         assert!(!is_cell_aligned(4.0, 4.0, 504.0, 400.0, &p, 1.0));
371     }
372 
373     #[test]
374     fn move_snaps_the_closer_edge() {
375         // Window content [506, 806]: left 506 → low target 516 (dist 10);
376         // right 806 → high target 1020 (dist 214). Left wins: x = 516.
377         let (x, y) = snap_move(506.0, 300.0, 300.0, 100.0, &params());
378         assert_eq!(x, 516.0);
379         assert_eq!(y, 300.0);
380 
381         // Right content edge 4 past the visible edge 508 beats left.
382         // Content [212, 512]: right 512 → 508 (dist 4) → x = 208.
383         let (x, _) = snap_move(212.0, 300.0, 300.0, 100.0, &params());
384         assert_eq!(x, 208.0);
385     }
386 
387     #[test]
388     fn move_beyond_threshold_is_untouched() {
389         let (x, y) = snap_move(100.0, 200.0, 300.0, 100.0, &params());
390         assert_eq!((x, y), (100.0, 200.0));
391     }
392 
393     #[test]
394     fn resize_axis_snaps_the_dragged_edge_only() {
395         // Window [600, 900), dragging the left edge to 510: visible edge 516
396         // → content 516; anchored right edge 900 keeps the width at 384.
397         assert_eq!(resize_axis(600.0, 300.0, -90.0, true, false, 50.0, &params().x()), 384.0);
398         // Dragging the right edge to 1010: visible edge 1020 → width 420.
399         assert_eq!(resize_axis(600.0, 300.0, 110.0, false, true, 50.0, &params().x()), 420.0);
400         // To 1000 (dist 20, in the ramp): the edge is pulled to 1004 → 404.
401         assert_eq!(resize_axis(600.0, 300.0, 100.0, false, true, 50.0, &params().x()), 404.0);
402         // Not dragging this axis: length unchanged.
403         assert_eq!(resize_axis(600.0, 300.0, 100.0, false, false, 50.0, &params().x()), 300.0);
404         // Minimum clamps.
405         assert_eq!(resize_axis(600.0, 300.0, 290.0, true, false, 50.0, &params().x()), 50.0);
406     }
407 
408     #[test]
409     fn tiled_span_covers_touched_visible_cells() {
410         // period 100 (no gap), inset 0: legacy behavior — bare cell lines.
411         assert_eq!(tiled_span(150.0, 250.0, 100.0, 0.0, 0.0), (100.0, 300.0));
412         // period 110 (gap 10), inset 5: cells 1-2 visibly span [115, 315].
413         assert_eq!(tiled_span(150.0, 250.0, 100.0, 10.0, 5.0), (115.0, 315.0));
414         // Span ending exactly on a period boundary doesn't touch the next cell.
415         assert_eq!(tiled_span(150.0, 220.0, 100.0, 10.0, 5.0), (115.0, 205.0));
416     }
417 
418     #[test]
419     fn zoomed_out_threshold_holds_screen_size() {
420         // Threshold 24 at zoom 0.5 → 48 virtual = the same 24 screen px.
421         let p = params().for_zoom(0.5);
422         assert_eq!(p.threshold, 48.0);
423         // Deep zoom-out caps at 45% of the cell (512 → 230.4).
424         let p = params().for_zoom(0.05);
425         assert!((p.threshold - 230.4).abs() < 1e-9);
426         // Zoom 1 unchanged; zoomed in shrinks (still 24 screen px).
427         assert_eq!(params().for_zoom(1.0).threshold, 24.0);
428         assert_eq!(params().for_zoom(2.0).threshold, 12.0);
429         // Disabled stays disabled.
430         let p = SnapParams { threshold: 0.0, ..params() }.for_zoom(0.5);
431         assert_eq!(p.threshold, 0.0);
432     }
433 
434     #[test]
435     fn cell_aligned_needs_all_four_edges() {
436         // cell 512, inset 4: cell 0 visibly spans [4, 508], cells 0-1 [4, 1020].
437         let p = params();
438         assert!(is_cell_aligned(4.0, 4.0, 504.0, 504.0, &p, 1.0));
439         // Two-cell-wide span.
440         assert!(is_cell_aligned(4.0, 4.0, 1016.0, 504.0, &p, 1.0));
441         // One edge off-grid fails.
442         assert!(!is_cell_aligned(10.0, 4.0, 504.0, 504.0, &p, 1.0)); // left off
443         assert!(!is_cell_aligned(4.0, 4.0, 500.0, 504.0, &p, 1.0)); // right off
444         assert!(!is_cell_aligned(4.0, 4.0, 504.0, 512.0, &p, 1.0)); // bottom off
445         // Alignment ignores the snap threshold.
446         let p = SnapParams { threshold: 0.0, ..params() };
447         assert!(is_cell_aligned(4.0, 4.0, 504.0, 504.0, &p, 1.0));
448         // Degenerate boxes are never tiled.
449         assert!(!is_cell_aligned(4.0, 4.0, 0.0, 504.0, &params(), 1.0));
450     }
451 
452     #[test]
453     fn pull_is_continuous_and_monotonic() {
454         // Target 516, threshold 24, hold radius 12. Approaching from the
455         // left: untouched at the threshold, then drawn in without a jump.
456         let p = params().x();
457         assert_eq!(snap_low_edge(492.0, &p), 492.0); // dist 24: at the threshold
458         assert_eq!(snap_low_edge(504.0, &p), 516.0); // dist 12: on target
459         assert_eq!(snap_low_edge(498.0, &p), 504.0); // dist 18: halfway in
460         let mut prev = snap_low_edge(490.0, &p);
461         let mut max_step: f64 = 0.0;
462         for i in 1..=60 {
463             let pos = 490.0 + i as f64 * 0.5;
464             let out = snap_low_edge(pos, &p);
465             assert!(out >= prev, "pull went backwards at {pos}");
466             max_step = max_step.max(out - prev);
467             prev = out;
468         }
469         // Half-unit pointer steps never move the edge more than a unit —
470         // the ramp's gain is 2 — where the old step rule jumped 24 at once.
471         assert!(max_step <= 1.0 + 1e-9, "max step {max_step}");
472         // Symmetric from the right.
473         assert_eq!(snap_low_edge(540.0, &p), 540.0);
474         assert_eq!(snap_low_edge(534.0, &p), 528.0);
475         assert_eq!(snap_low_edge(528.0, &p), 516.0);
476     }
477 
478     #[test]
479     fn zero_threshold_disables() {
480         let p = SnapParams { threshold: 0.0, ..params() };
481         assert_eq!(snap_move(510.0, 300.0, 300.0, 100.0, &p), (510.0, 300.0));
482         assert_eq!(snap_low_edge(510.0, &p.x()), 510.0);
483     }
484 
485     #[test]
486     fn tiled_move_snaps_hard_from_any_distance() {
487         // cell 512, no gap, inset 4: cell starts are 4, 516, 1028 …
488         let p = params();
489         // Well beyond the 24px magnetic threshold, where snap_move gives up.
490         assert_eq!(snap_move(200.0, 200.0, 504.0, 504.0, &p), (200.0, 200.0));
491         // The tiled snap still lands on the nearest cell start (cell 0 at 4).
492         assert_eq!(snap_move_tiled(200.0, 200.0, &p), (4.0, 4.0));
493         // Past the midpoint it commits to the next cell instead (cell 1 at 516).
494         assert_eq!(snap_move_tiled(300.0, 300.0, &p), (516.0, 516.0));
495         // Negative canvas coordinates snap the same way (cell -1 at -508).
496         assert_eq!(snap_move_tiled(-400.0, -400.0, &p), (-508.0, -508.0));
497     }
498 
499     #[test]
500     fn tiled_move_result_is_always_cell_aligned() {
501         // The point of the hard snap: whatever the drag ends on, the window
502         // is still Tiled at op_end instead of silently demoting to Floating.
503         let p = params();
504         for start in [0.0, 37.0, 260.0, 700.0, -13.0, -900.0] {
505             let (x, y) = snap_move_tiled(start, start, &p);
506             assert!(
507                 is_cell_aligned(x, y, 504.0, 504.0, &p, 1.0),
508                 "drag ending at {start} left the window off-grid at ({x}, {y})"
509             );
510         }
511     }
512 
513     #[test]
514     fn tiled_resize_snaps_hard_to_whole_cells() {
515         // cell 512, no gap, inset 4: cell k visibly spans [512k+4, 512k+508].
516         let p = params().x();
517         // Two-cell window [4, 1020): dragging the right edge in by 400 puts
518         // it at 620, nearest cell end 508 → one cell wide (504).
519         assert_eq!(resize_axis_tiled(4.0, 1016.0, -400.0, false, true, &p), 504.0);
520         // Out by 300 → 1320, nearest cell end 1532 → three cells (1528).
521         assert_eq!(resize_axis_tiled(4.0, 1016.0, 300.0, false, true, &p), 1528.0);
522         // Dragging the left edge to 304: nearest cell start 516 → one cell,
523         // the right edge anchored at 1020.
524         assert_eq!(resize_axis_tiled(4.0, 1016.0, 300.0, true, false, &p), 504.0);
525         // Past the anchor's own cell the size floors at one cell.
526         assert_eq!(resize_axis_tiled(4.0, 1016.0, -900.0, false, true, &p), 504.0);
527         assert_eq!(resize_axis_tiled(4.0, 1016.0, 1000.0, true, false, &p), 504.0);
528         // Not dragging this axis: unchanged.
529         assert_eq!(resize_axis_tiled(4.0, 1016.0, 300.0, false, false, &p), 1016.0);
530         // Every result keeps the window cell-aligned.
531         for d in [-900.0, -400.0, -10.0, 0.0, 130.0, 300.0, 700.0] {
532             let w = resize_axis_tiled(4.0, 1016.0, d, false, true, &p);
533             assert!(is_cell_aligned(4.0, 4.0, w, 504.0, &params(), 1e-9), "delta {d} → width {w}");
534         }
535         // The threshold plays no part.
536         let off = SnapParams { threshold: 0.0, ..params() }.x();
537         assert_eq!(resize_axis_tiled(4.0, 1016.0, -400.0, false, true, &off), 504.0);
538     }
539 
540     #[test]
541     fn tiled_move_ignores_a_disabled_threshold() {
542         // Magnetic snapping off must not strand a tiled window between cells.
543         let p = SnapParams { threshold: 0.0, ..params() };
544         assert_eq!(snap_move_tiled(300.0, 300.0, &p), (516.0, 516.0));
545         // A degenerate cell size is left alone rather than dividing by ~zero.
546         let p = SnapParams { cell_w: 0.0, cell_h: 0.0, ..params() };
547         assert_eq!(snap_move_tiled(300.0, 300.0, &p), (300.0, 300.0));
548     }
549 }