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

src/spawn.rs (11K)

  1 // Where a window launched from a point on the desktop should land.
  2 //
  3 // A window normally reopens wherever it last was. That is right when you
  4 // summon it from nowhere in particular, and wrong when you asked for it AT a
  5 // place — right-clicking a grid square and picking Terminal says something
  6 // about where you want the terminal. This module answers only that second
  7 // case; the caller decides when it applies.
  8 //
  9 // The rule is: the window covers the square you invoked from, and grows AWAY
 10 // from whatever is already there. It never searches for somewhere else to be,
 11 // so the result stays predictable — the invocation square is always one of
 12 // its corners, and only WHICH corner is in question.
 13 
 14 /// A rectangular block of grid squares, inclusive on both corners.
 15 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
 16 pub struct CellBlock {
 17     pub col0: i32,
 18     pub row0: i32,
 19     pub col1: i32,
 20     pub row1: i32,
 21 }
 22 
 23 impl CellBlock {
 24     pub fn new(col0: i32, row0: i32, col1: i32, row1: i32) -> Self {
 25         Self {
 26             col0: col0.min(col1),
 27             row0: row0.min(row1),
 28             col1: col0.max(col1),
 29             row1: row0.max(row1),
 30         }
 31     }
 32 
 33     /// Cells covered by both blocks.
 34     fn overlap_cells(&self, other: &CellBlock) -> i64 {
 35         let w = (self.col1.min(other.col1) - self.col0.max(other.col0) + 1).max(0) as i64;
 36         let h = (self.row1.min(other.row1) - self.row0.max(other.row0) + 1).max(0) as i64;
 37         w * h
 38     }
 39 }
 40 
 41 /// The four ways a block of `cols` x `rows` can hang off one square, in
 42 /// preference order. Top-left first because a window growing right and down
 43 /// from where you clicked is the conventional reading; the others are only
 44 /// reached when that would land on something.
 45 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
 46 pub enum Anchor {
 47     TopLeft,
 48     TopRight,
 49     BottomLeft,
 50     BottomRight,
 51 }
 52 
 53 impl Anchor {
 54     pub const ORDER: [Anchor; 4] =
 55         [Anchor::TopLeft, Anchor::TopRight, Anchor::BottomLeft, Anchor::BottomRight];
 56 
 57     fn block(self, col: i32, row: i32, cols: i32, rows: i32) -> CellBlock {
 58         let (cols, rows) = (cols.max(1), rows.max(1));
 59         let (col0, row0) = match self {
 60             Anchor::TopLeft => (col, row),
 61             Anchor::TopRight => (col - cols + 1, row),
 62             Anchor::BottomLeft => (col, row - rows + 1),
 63             Anchor::BottomRight => (col - cols + 1, row - rows + 1),
 64         };
 65         CellBlock::new(col0, row0, col0 + cols - 1, row0 + rows - 1)
 66     }
 67 }
 68 
 69 /// Where a `cols` x `rows` window invoked at square `(col, row)` should go.
 70 ///
 71 /// `occupied` are the blocks already taken by other windows; `viewport` is the
 72 /// block of squares currently on screen, used only to break ties — of two
 73 /// placements that collide with nothing, the one you can see is the better
 74 /// answer. Returns the chosen block; the caller turns it back into
 75 /// coordinates with [`crate::cells::block_rect`].
 76 pub fn place_at_cell(
 77     col: i32,
 78     row: i32,
 79     cols: i32,
 80     rows: i32,
 81     occupied: &[CellBlock],
 82     viewport: Option<CellBlock>,
 83 ) -> CellBlock {
 84     let mut best: Option<(i64, i64, CellBlock)> = None;
 85     for anchor in Anchor::ORDER {
 86         let block = anchor.block(col, row, cols, rows);
 87         let collision: i64 = occupied.iter().map(|o| block.overlap_cells(o)).sum();
 88         // Negated so that "more visible" sorts the same direction as "less
 89         // collision" — both smaller-is-better in the comparison below.
 90         let unseen = match viewport {
 91             Some(v) => {
 92                 let total = (cols.max(1) as i64) * (rows.max(1) as i64);
 93                 total - block.overlap_cells(&v)
 94             }
 95             None => 0,
 96         };
 97         let better = match best {
 98             None => true,
 99             // Collisions dominate: a placement that lands on another window is
100             // worse than one you have to scroll to, however far off-screen.
101             Some((bc, bu, _)) => (collision, unseen) < (bc, bu),
102         };
103         if better {
104             best = Some((collision, unseen, block));
105         }
106         // Nothing beats a clean, fully-visible placement, and the order is a
107         // preference order — stop at the first one.
108         if collision == 0 && unseen == 0 {
109             break;
110         }
111     }
112     best.map(|(_, _, b)| b).unwrap_or_else(|| Anchor::TopLeft.block(col, row, cols, rows))
113 }
114 
115 /// Direction preference when stepping a block off an occupied spot: right,
116 /// down, left, up, then the diagonals. Reading order first, so a nudge goes
117 /// where the eye already expects the next window.
118 fn step_rank(dc: i32, dr: i32) -> u8 {
119     match (dc.signum(), dr.signum()) {
120         (1, 0) => 0,
121         (0, 1) => 1,
122         (-1, 0) => 2,
123         (0, -1) => 3,
124         (1, 1) => 4,
125         (-1, 1) => 5,
126         (-1, -1) => 6,
127         (1, -1) => 7,
128         _ => 8,
129     }
130 }
131 
132 /// The nearest position for `block` that lands on nothing, searched outward
133 /// from where it wanted to be.
134 ///
135 /// This is the fallback for a window opening at its REMEMBERED place: that
136 /// spot was free when it closed and may not be now, and two tiled windows
137 /// stacked on the same squares is never what anyone meant. Rings are searched
138 /// in increasing distance, so the window stays as close to its own spot as it
139 /// can while landing clear.
140 ///
141 /// Returns `block` unchanged when it is already clear, or when nothing free
142 /// turns up within `max_radius` squares — better to sit on top of something
143 /// than to fling a window half a desktop away to a place with no meaning.
144 pub fn nearest_free(block: CellBlock, occupied: &[CellBlock], max_radius: i32) -> CellBlock {
145     let hits = |b: &CellBlock| occupied.iter().any(|o| b.overlap_cells(o) > 0);
146     if !hits(&block) {
147         return block;
148     }
149     for r in 1..=max_radius.max(0) {
150         let mut ring: Vec<(i32, i32)> = Vec::new();
151         for dc in -r..=r {
152             for dr in -r..=r {
153                 if dc.abs().max(dr.abs()) == r {
154                     ring.push((dc, dr));
155                 }
156             }
157         }
158         ring.sort_by_key(|&(dc, dr)| (dc.abs() + dr.abs(), step_rank(dc, dr)));
159         for (dc, dr) in ring {
160             let moved = CellBlock {
161                 col0: block.col0 + dc,
162                 row0: block.row0 + dr,
163                 col1: block.col1 + dc,
164                 row1: block.row1 + dr,
165             };
166             if !hits(&moved) {
167                 return moved;
168             }
169         }
170     }
171     block
172 }
173 
174 #[cfg(test)]
175 mod tests {
176     use super::*;
177 
178     fn b(c0: i32, r0: i32, c1: i32, r1: i32) -> CellBlock {
179         CellBlock::new(c0, r0, c1, r1)
180     }
181 
182     #[test]
183     fn empty_desktop_grows_right_and_down() {
184         // Nothing in the way: the invocation square is the top-left corner.
185         assert_eq!(place_at_cell(1, 1, 2, 2, &[], None), b(1, 1, 2, 2));
186     }
187 
188     #[test]
189     fn grows_away_from_a_neighbour_on_the_right() {
190         // The reported case: the menu is opened one square LEFT of a 2x2
191         // window, and a 2x2 window opening there must not land on it — so the
192         // invocation square becomes its top-RIGHT corner and it grows left.
193         let claude = b(2, 1, 3, 2);
194         let placed = place_at_cell(1, 1, 2, 2, &[claude], None);
195         assert_eq!(placed, b(0, 1, 1, 2));
196         assert_eq!(placed.col1, 1, "invocation square is its right edge");
197         assert_eq!(placed.row0, 1, "invocation square is its top edge");
198         assert_eq!(placed.overlap_cells(&claude), 0);
199     }
200 
201     #[test]
202     fn boxed_in_below_and_right_it_goes_up_and_left() {
203         // Right and below taken. Growing up alone is not enough — a
204         // bottom-LEFT anchor still clips the right-hand window by a square —
205         // so the only clean corner is up AND left.
206         let right = b(2, 1, 3, 2);
207         let below = b(1, 2, 2, 3);
208         let placed = place_at_cell(1, 1, 2, 2, &[right, below], None);
209         assert_eq!(placed, b(0, 0, 1, 1));
210         assert_eq!(placed.overlap_cells(&right), 0);
211         assert_eq!(placed.overlap_cells(&below), 0);
212         assert_eq!(placed.col1, 1, "invocation square is still a corner");
213         assert_eq!(placed.row1, 1);
214     }
215 
216     #[test]
217     fn a_visible_placement_beats_an_off_screen_one() {
218         // Both anchors are collision-free, but growing left would leave the
219         // window off the visible desktop, so it grows right instead.
220         let viewport = b(0, 0, 5, 3);
221         let placed = place_at_cell(0, 0, 3, 1, &[], Some(viewport));
222         assert_eq!(placed, b(0, 0, 2, 0));
223     }
224 
225     #[test]
226     fn collisions_outrank_visibility() {
227         // The only on-screen anchor is occupied: take the off-screen one
228         // rather than open on top of another window.
229         let viewport = b(0, 0, 5, 3);
230         let blocker = b(1, 0, 2, 0);
231         let placed = place_at_cell(1, 0, 2, 1, &[blocker], Some(viewport));
232         assert_eq!(placed, b(0, 0, 1, 0));
233     }
234 
235     #[test]
236     fn a_single_square_window_lands_on_the_square_itself() {
237         // 1x1 has the same block under every anchor — the invocation square.
238         for occ in [vec![], vec![b(0, 0, 0, 0)]] {
239             assert_eq!(place_at_cell(4, -2, 1, 1, &occ, None), b(4, -2, 4, -2));
240         }
241     }
242 
243     #[test]
244     fn a_clear_block_is_left_where_it_is() {
245         let b1 = b(1, 1, 2, 2);
246         assert_eq!(nearest_free(b1, &[b(5, 5, 6, 6)], 8), b1);
247         assert_eq!(nearest_free(b1, &[], 8), b1);
248     }
249 
250     #[test]
251     fn an_occupied_spot_steps_aside_to_the_nearest_free_one() {
252         // Reopening onto exactly where another window now sits: one square
253         // right is the nearest clear spot, and right is the first direction
254         // tried.
255         let sitting = b(1, 1, 2, 2);
256         let placed = nearest_free(b(1, 1, 2, 2), &[sitting], 8);
257         assert_eq!(placed, b(3, 1, 4, 2));
258         assert_eq!(placed.overlap_cells(&sitting), 0);
259     }
260 
261     #[test]
262     fn it_keeps_stepping_until_it_is_actually_clear() {
263         // A wall of windows to the right: the search has to pass over all of
264         // them rather than stopping at the first shifted position.
265         let wall = [b(1, 1, 2, 2), b(3, 1, 4, 2), b(5, 1, 6, 2)];
266         let placed = nearest_free(b(1, 1, 2, 2), &wall, 8);
267         for w in &wall {
268             assert_eq!(placed.overlap_cells(w), 0, "{placed:?} still lands on {w:?}");
269         }
270     }
271 
272     #[test]
273     fn size_is_never_changed_by_a_nudge() {
274         let want = b(0, 0, 2, 1);
275         let placed = nearest_free(want, &[b(0, 0, 2, 1)], 8);
276         assert_eq!(placed.col1 - placed.col0, want.col1 - want.col0);
277         assert_eq!(placed.row1 - placed.row0, want.row1 - want.row0);
278     }
279 
280     #[test]
281     fn a_hopeless_search_leaves_the_window_where_it_wanted_to_be() {
282         // Boxed in everywhere within the radius: sitting on something beats
283         // being flung somewhere arbitrary.
284         let want = b(0, 0, 0, 0);
285         let mut occupied = Vec::new();
286         for c in -2..=2 {
287             for r in -2..=2 {
288                 occupied.push(b(c, r, c, r));
289             }
290         }
291         assert_eq!(nearest_free(want, &occupied, 2), want);
292     }
293 
294     #[test]
295     fn degenerate_sizes_are_clamped_to_one_square() {
296         assert_eq!(place_at_cell(2, 2, 0, -3, &[], None), b(2, 2, 2, 2));
297     }
298 }