git.lucas.co / cce-window-manager
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], &params(), 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], &params(), 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], &params(), 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], &params(), 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], &params(), 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], &params(), 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], &params(), 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], &params(), 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], &params(), 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 }