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, ¶ms().x()), 516.0);
334 // Far from an edge: unchanged.
335 assert_eq!(snap_low_edge(300.0, ¶ms().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, ¶ms().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, ¶ms().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, ¶ms());
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, ¶ms());
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, ¶ms());
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, ¶ms().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, ¶ms().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, ¶ms().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, ¶ms().x()), 300.0);
404 // Minimum clamps.
405 assert_eq!(resize_axis(600.0, 300.0, 290.0, true, false, 50.0, ¶ms().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, ¶ms(), 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, ¶ms(), 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 }