window management library
git clone https://git.lucas.co/cce-window-manager.git
src/overview.rs (12.4K)
1 // Overview-mode move rules: when a dragged window covers another window, the
2 // covered window automatically relocates to the vacated side.
3 //
4 // The rule is swap-like: dragging the browser rightward onto the system
5 // interface displaces the system interface to the browser's LEFT — the
6 // covered window exits toward the side the drag vacated, i.e. opposite the
7 // dominant axis/sign of the drag delta, abutting the dragged window with
8 // the desktop-grid gap. Called by the mechanism on every motion event of an
9 // overview move, so windows scoot out of the way live; it is naturally
10 // convergent because a displaced window no longer overlaps the dragged one.
11 //
12 // Displacement is single-level on purpose: a displaced window may itself
13 // land on a third window without cascading further. Coordinates are
14 // virtual-surface content coordinates throughout.
15 //
16 // It never crosses the two modes. Floating and tiled windows are
17 // independent planes — floating ones stack in front and overlap by nature,
18 // tiled ones share out the grid — so a dragged window only pushes windows
19 // of its own kind: a floating drag leaves the grid exactly as it found it,
20 // and a tiled drag leaves the floating windows where they were put.
21
22 use crate::snap::{self, SnapParams};
23
24 /// A window the dragged window may displace, as the mechanism snapshots it.
25 #[derive(Debug, Clone, Copy)]
26 pub struct DisplaceCandidate {
27 pub x: f64,
28 pub y: f64,
29 pub w: f64,
30 pub h: f64,
31 /// Tiled candidates get their displaced position snapped back onto the
32 /// grid so they stay tiled.
33 pub tiled: bool,
34 }
35
36 /// Covered fraction (of the smaller window) that triggers displacement.
37 pub const DISPLACE_THRESHOLD: f64 = 0.5;
38
39 fn overlap_1d(a0: f64, a1: f64, b0: f64, b1: f64) -> f64 {
40 (a1.min(b1) - a0.max(b0)).max(0.0)
41 }
42
43 /// Decide displacements for one motion step of an overview drag. `moved` is
44 /// the dragged window's current content box (x, y, w, h); `drag_delta` is
45 /// the cumulative virtual-space delta since the grab started. Returns
46 /// `(candidate index, new position)` for every candidate the drag covers.
47 ///
48 /// A candidate is covered when the overlap area exceeds
49 /// [`DISPLACE_THRESHOLD`] of the smaller of the two windows. It exits toward
50 /// the side the drag vacated — opposite the dominant axis/sign of
51 /// `drag_delta` (a rightward drag sends it to the dragged window's left) —
52 /// abutting the dragged window's content box with `gap` between them. With
53 /// no meaningful drag delta it falls back to flipping the candidate across
54 /// the dragged window along their center offset.
55 ///
56 /// `moved_tiled` is what the dragged window was when it was GRABBED, not
57 /// what it reads as now: the mechanism un-tiles a tiled window on the first
58 /// motion event so the drag can follow the pointer, so its live mode is
59 /// Floating for the whole drag. Candidates of the other kind are skipped —
60 /// see the note at the top of this file.
61 pub fn displace(
62 moved: (f64, f64, f64, f64),
63 moved_tiled: bool,
64 drag_delta: (f64, f64),
65 candidates: &[DisplaceCandidate],
66 p: &SnapParams,
67 gap: f64,
68 ) -> Vec<(usize, (f64, f64))> {
69 let (mx, my, mw, mh) = moved;
70 if mw <= 0.0 || mh <= 0.0 {
71 return Vec::new();
72 }
73 let mut out = Vec::new();
74 for (i, c) in candidates.iter().enumerate() {
75 if c.w <= 0.0 || c.h <= 0.0 {
76 continue;
77 }
78 // The other plane is not this drag's to rearrange.
79 if c.tiled != moved_tiled {
80 continue;
81 }
82 let overlap = overlap_1d(mx, mx + mw, c.x, c.x + c.w)
83 * overlap_1d(my, my + mh, c.y, c.y + c.h);
84 let smaller = (mw * mh).min(c.w * c.h);
85 if overlap <= smaller * DISPLACE_THRESHOLD {
86 continue;
87 }
88
89 // Exit toward the vacated side: opposite the drag direction on its
90 // dominant axis. A grab with no travel yet (or a degenerate delta)
91 // falls back to flipping the candidate across the dragged window
92 // along their center offset.
93 let (dx, dy) = if drag_delta.0.abs() >= 1.0 || drag_delta.1.abs() >= 1.0 {
94 drag_delta
95 } else {
96 (
97 (c.x + c.w / 2.0) - (mx + mw / 2.0),
98 (c.y + c.h / 2.0) - (my + mh / 2.0),
99 )
100 };
101 let (mut nx, mut ny) = (c.x, c.y);
102 if dx.abs() >= dy.abs() {
103 nx = if dx >= 0.0 { mx - gap - c.w } else { mx + mw + gap };
104 } else {
105 ny = if dy >= 0.0 { my - gap - c.h } else { my + mh + gap };
106 }
107
108 // A tiled candidate stays tiled: hard-snap the landing spot to the
109 // nearest cell edges, no threshold (its size is already
110 // cell-quantized, so aligning the low edges aligns the whole box).
111 // Magnetic snapping cannot be widened into a guarantee — targets
112 // are one PERIOD apart, so any threshold below (cell + gap)/2
113 // leaves a dead band around the midpoint where the exit spot rests
114 // mid-cell, and the arrange pass then expands the "tiled" window to
115 // every cell the off-grid box touches.
116 if c.tiled {
117 let (sx, sy) = snap::snap_move_tiled(nx, ny, p);
118 nx = sx;
119 ny = sy;
120 }
121
122 if (nx - c.x).abs() > f64::EPSILON || (ny - c.y).abs() > f64::EPSILON {
123 out.push((i, (nx, ny)));
124 }
125 }
126 out
127 }
128
129 #[cfg(test)]
130 mod tests {
131 use super::*;
132
133 fn params() -> SnapParams {
134 // cell 512, no gap, fade inset 4: visible cell k spans
135 // [512k + 4, 512k + 508].
136 SnapParams { cell_w: 512.0, cell_h: 512.0, gap_width: 0.0, cell_inset: 4.0, threshold: 24.0 }
137 }
138
139 fn cand(x: f64, y: f64, w: f64, h: f64) -> DisplaceCandidate {
140 DisplaceCandidate { x, y, w, h, tiled: false }
141 }
142
143 #[test]
144 fn browser_dragged_right_displaces_neighbor_to_its_left() {
145 // Browser (800x600) starts left of the system interface (600x600)
146 // and is dragged rightward until it covers most of it.
147 let system_interface = cand(1000.0, 100.0, 600.0, 600.0);
148 // Browser now at x=900: overlap x [1000, 1600] = 600... fully
149 // covering the system interface horizontally is not needed; 60%
150 // coverage of the smaller window triggers.
151 let moved = (900.0, 100.0, 800.0, 600.0);
152 let d = displace(moved, false, (500.0, 0.0), &[system_interface], ¶ms(), 16.0);
153 assert_eq!(d.len(), 1);
154 let (idx, (nx, ny)) = d[0];
155 assert_eq!(idx, 0);
156 // Rightward drag: the system interface exits to the browser's
157 // left, abutting with the gap: 900 - 16 - 600.
158 assert_eq!((nx, ny), (284.0, 100.0));
159 }
160
161 #[test]
162 fn approach_from_the_right_displaces_rightward() {
163 // Dragged window comes from the right; the covered window exits
164 // right — into the vacated space.
165 let covered = cand(1000.0, 100.0, 600.0, 600.0);
166 // Overlap x [1150, 1600] = 450 of 600 → 75% of the smaller window.
167 let moved = (1150.0, 100.0, 800.0, 600.0);
168 let d = displace(moved, false, (-450.0, 0.0), &[covered], ¶ms(), 16.0);
169 assert_eq!(d.len(), 1);
170 // Leftward drag: the candidate goes to the moved window's RIGHT:
171 // 1150 + 800 + 16.
172 assert_eq!(d[0].1, (1966.0, 100.0));
173 }
174
175 #[test]
176 fn vertical_approach_displaces_vertically() {
177 let covered = cand(100.0, 800.0, 600.0, 500.0);
178 // Dragged from above, covering the top 60% of the candidate.
179 let moved = (100.0, 500.0, 600.0, 600.0);
180 let d = displace(moved, false, (0.0, 300.0), &[covered], ¶ms(), 16.0);
181 assert_eq!(d.len(), 1);
182 // Downward drag: the candidate exits above, into the vacated space:
183 // y = 500 - 16 - 500.
184 assert_eq!(d[0].1, (100.0, -16.0));
185 }
186
187 #[test]
188 fn below_threshold_is_untouched() {
189 // 40% horizontal overlap of the smaller window: no displacement.
190 let covered = cand(1000.0, 100.0, 600.0, 600.0);
191 let moved = (640.0, 100.0, 600.0, 600.0); // overlap x = 240 → 40%
192 assert!(displace(moved, false, (300.0, 0.0), &[covered], ¶ms(), 16.0).is_empty());
193 }
194
195 #[test]
196 fn tiled_candidate_lands_on_cell_edges() {
197 // A tiled candidate filling cell 2 exactly: visible content box
198 // [1028, 1532] → x=1028, w=504.
199 let covered = DisplaceCandidate { x: 1028.0, y: 4.0, w: 504.0, h: 504.0, tiled: true };
200 // Dragged window covers it, approaching from the left; it is a TILED
201 // window mid-drag, so its own box is not grid-aligned right now.
202 let moved = (700.0, 10.0, 700.0, 500.0);
203 let d = displace(moved, true, (400.0, 6.0), &[covered], ¶ms(), 16.0);
204 assert_eq!(d.len(), 1);
205 // Raw exit spot 700 - 16 - 504 = 180 snaps onto cell 0's visible
206 // box: left edge 180 → 4 (within the widened threshold), y 10 → 4.
207 // The candidate stays cell-aligned.
208 assert_eq!(d[0].1, (4.0, 4.0));
209 }
210
211 #[test]
212 fn tiled_exit_in_magnetic_dead_band_still_snaps() {
213 // With a gap the grid period is 528, so snap targets are 528 apart
214 // and the old widened magnetic snap (threshold 0.45 * cell = 230.4)
215 // had a ~67px dead band around the midpoint: a landing spot ~256px
216 // from the nearest edge stayed mid-cell, and the arrange pass then
217 // grew the "tiled" window to every cell it touched.
218 let p = SnapParams { cell_w: 512.0, cell_h: 512.0, gap_width: 16.0, cell_inset: 4.0, threshold: 24.0 };
219 // Tiled candidate filling cell row 1 exactly: visible box
220 // y [532, 1036] → y=532, h=504.
221 let covered = DisplaceCandidate { x: 4.0, y: 532.0, w: 504.0, h: 504.0, tiled: true };
222 // Dragged DOWN onto it; the mover's mid-drag y is not grid-aligned.
223 // Overlap y [780, 1036] = 256 of 504 → ~51% of the smaller window.
224 let moved = (4.0, 780.0, 600.0, 600.0);
225 let d = displace(moved, true, (0.0, 300.0), &[covered], &p, 16.0);
226 assert_eq!(d.len(), 1);
227 // Downward drag: the candidate exits above, abutting the mover:
228 // raw y = 780 - 16 - 504 = 260 — 256 from the nearest low target
229 // (4), squarely in the old dead band. The hard snap lands it there
230 // anyway; x is untouched and already aligned.
231 assert_eq!(d[0].1, (4.0, 4.0));
232 }
233
234 #[test]
235 fn a_floating_drag_leaves_a_tiled_window_alone() {
236 // The two modes are independent planes: a floating window covering a
237 // tiled one stacks in front of it and the grid does not rearrange.
238 let tiled = DisplaceCandidate { x: 1028.0, y: 4.0, w: 504.0, h: 504.0, tiled: true };
239 let moved = (900.0, 4.0, 700.0, 500.0);
240 assert!(displace(moved, false, (400.0, 0.0), &[tiled], ¶ms(), 16.0).is_empty());
241 }
242
243 #[test]
244 fn a_tiled_drag_leaves_a_floating_window_alone() {
245 // And the other way: a tiled window swept across the desktop pushes
246 // the tiled windows it covers, never the floating ones.
247 let floating = cand(1000.0, 100.0, 600.0, 600.0);
248 let moved = (900.0, 100.0, 800.0, 600.0);
249 assert!(displace(moved, true, (500.0, 0.0), &[floating], ¶ms(), 16.0).is_empty());
250 }
251
252 #[test]
253 fn a_mixed_desktop_displaces_only_the_matching_plane() {
254 // One drag, both kinds under it: only the mover's own plane moves,
255 // and the returned index still names the right candidate.
256 let floating = cand(1000.0, 100.0, 400.0, 400.0);
257 let tiled = DisplaceCandidate { x: 1028.0, y: 600.0, w: 504.0, h: 504.0, tiled: true };
258 let moved = (900.0, 50.0, 700.0, 1000.0);
259 let d = displace(moved, false, (400.0, 0.0), &[floating, tiled], ¶ms(), 16.0);
260 assert_eq!(d.len(), 1);
261 assert_eq!(d[0].0, 0, "the floating candidate is the one that moved");
262 }
263
264 #[test]
265 fn multiple_covered_windows_each_displace() {
266 let a = cand(1000.0, 100.0, 400.0, 400.0);
267 let b = cand(1000.0, 600.0, 400.0, 400.0);
268 // A tall dragged window covering both.
269 // Dragged rightward: both exit to the dragged window's left even
270 // though their centers are offset vertically from the mover's.
271 let moved = (900.0, 50.0, 600.0, 1000.0);
272 let d = displace(moved, false, (400.0, 0.0), &[a, b], ¶ms(), 16.0);
273 assert_eq!(d.len(), 2);
274 // Both exit left — opposite the rightward drag.
275 assert_eq!(d[0], (0, (484.0, 100.0)));
276 assert_eq!(d[1], (1, (484.0, 600.0)));
277 }
278 }