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 }