window management library
git clone https://git.lucas.co/cce-window-manager.git
src/focus.rs (16K)
1 // Directional focus selection: which window receives focus when the user
2 // moves focus up/down/left/right of the current one.
3 //
4 // Inputs are window FOOTPRINTS in virtual-surface coordinates (the same
5 // space as `WindowSnapshot.virtual_x/y`; y grows downward). The mechanism
6 // side builds the candidate list (visible, non-status windows) and applies
7 // the returned index.
8
9 use super::api::Action;
10
11 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
12 pub enum Direction {
13 Up,
14 Down,
15 Left,
16 Right,
17 }
18
19 impl Direction {
20 /// The direction a focus action moves in, `None` for non-directional
21 /// actions.
22 pub fn from_action(action: Action) -> Option<Direction> {
23 match action {
24 Action::FocusUp => Some(Direction::Up),
25 Action::FocusDown => Some(Direction::Down),
26 Action::FocusLeft => Some(Direction::Left),
27 Action::FocusRight => Some(Direction::Right),
28 _ => None,
29 }
30 }
31 }
32
33 /// A window's footprint on the virtual surface: top-left corner and extent,
34 /// the extent already multiplied by the window's output scale.
35 #[derive(Debug, Clone, Copy, PartialEq)]
36 pub struct Rect {
37 pub x: f64,
38 pub y: f64,
39 pub w: f64,
40 pub h: f64,
41 }
42
43 impl Rect {
44 pub fn new(x: f64, y: f64, w: f64, h: f64) -> Rect {
45 Rect { x, y, w, h }
46 }
47
48 fn center(&self) -> (f64, f64) {
49 (self.x + self.w / 2.0, self.y + self.h / 2.0)
50 }
51
52 /// The rect seen along `dir`: `(lo, hi)` is its extent on the axis of
53 /// travel, increasing in the direction of travel, and `(olo, ohi)` its
54 /// extent across it.
55 fn along(&self, dir: Direction) -> (f64, f64, f64, f64) {
56 let (x0, x1, y0, y1) = (self.x, self.x + self.w, self.y, self.y + self.h);
57 match dir {
58 Direction::Right => (x0, x1, y0, y1),
59 Direction::Left => (-x1, -x0, y0, y1),
60 Direction::Down => (y0, y1, x0, x1),
61 Direction::Up => (-y1, -y0, x0, x1),
62 }
63 }
64 }
65
66 /// Weight of off-axis distance in the candidate score, for candidates that
67 /// do not share a row/column with the focused window: a window slightly
68 /// ahead but far off to the side loses to one nearly straight ahead.
69 const ORTHOGONAL_PENALTY: f64 = 2.0;
70
71 /// Pick the window to focus when moving in `dir` from `focused`.
72 ///
73 /// Edges decide, not centers. A candidate must lie ahead: its near edge
74 /// past the focused window's midpoint on the axis of travel. That excludes
75 /// a window that merely sticks out past the focused one — a wide window
76 /// directly above a narrow one has a center to the right of it, but is not
77 /// "to the right" of it. Candidates whose extent across the axis of travel
78 /// overlaps the focused window's (same row for left/right, same column for
79 /// up/down) win over any that do not; within a group the smallest edge gap
80 /// wins, off-axis gap weighted by `ORTHOGONAL_PENALTY`, then the nearest
81 /// off-axis center. No wraparound: with no candidate in that direction the
82 /// focus stays put (`None`).
83 ///
84 /// With nothing focused, the entry window is the one whose center is
85 /// furthest on the opposite side (moving right enters at the leftmost
86 /// window), matching the "focus is entering the surface from off-screen"
87 /// intuition.
88 pub fn directional_focus(rects: &[Rect], focused: Option<usize>, dir: Direction) -> Option<usize> {
89 if rects.is_empty() {
90 return None;
91 }
92
93 let Some(focused) = focused else {
94 let entry_key = |r: &Rect| {
95 let (x, y) = r.center();
96 match dir {
97 Direction::Right => x,
98 Direction::Left => -x,
99 Direction::Down => y,
100 Direction::Up => -y,
101 }
102 };
103 return rects
104 .iter()
105 .enumerate()
106 .min_by(|(_, a), (_, b)| entry_key(a).total_cmp(&entry_key(b)))
107 .map(|(i, _)| i);
108 };
109
110 let (flo, fhi, folo, fohi) = rects[focused].along(dir);
111 let fmid = (flo + fhi) / 2.0;
112 let fomid = (folo + fohi) / 2.0;
113 let mut best: Option<(usize, (bool, f64, f64))> = None;
114 for (i, r) in rects.iter().enumerate() {
115 if i == focused {
116 continue;
117 }
118 let (lo, hi, olo, ohi) = r.along(dir);
119 if lo <= fmid {
120 continue;
121 }
122 let in_line = olo < fohi && ohi > folo;
123 let primary = (lo - fhi).max(0.0);
124 let orthogonal = (olo - fohi).max(folo - ohi).max(0.0);
125 let key = (
126 !in_line,
127 primary + ORTHOGONAL_PENALTY * orthogonal,
128 ((olo + ohi) / 2.0 - fomid).abs(),
129 );
130 if best.is_none_or(|(_, b)| key < b) {
131 best = Some((i, key));
132 }
133 }
134 best.map(|(i, _)| i)
135 }
136
137 /// Pick the window to focus by a free direction rather than one of four:
138 /// a ray from the focused window's center along `v` (virtual-surface
139 /// units, y down), and the window whose center lies nearest along it.
140 ///
141 /// Only centers within `cone_deg` of the ray are candidates, so a swipe
142 /// toward empty space changes nothing (`None`) rather than reaching for
143 /// whatever is closest. Among candidates the score is the center's
144 /// distance divided by cos²θ, θ its angle off the ray: nearer wins, and
145 /// off-ray costs more the further off it is, so a precise diagonal lands
146 /// on the diagonal window even when a straight neighbour is a little
147 /// closer. A candidate centered on the focused window's own center has no
148 /// direction and is skipped. With nothing focused, or a zero `v`, there
149 /// is no ray and the answer is `None`; the caller falls back to
150 /// `directional_focus`, whose entry rule covers the unfocused case.
151 pub fn vector_focus(rects: &[Rect], focused: Option<usize>, v: (f64, f64), cone_deg: f64) -> Option<usize> {
152 let focused = focused?;
153 let len = v.0.hypot(v.1);
154 if !(len > 0.0) {
155 return None;
156 }
157 let (ux, uy) = (v.0 / len, v.1 / len);
158 let cos_cone = cone_deg.to_radians().cos();
159 let (fx, fy) = rects[focused].center();
160 let mut best: Option<(usize, f64)> = None;
161 for (i, r) in rects.iter().enumerate() {
162 if i == focused {
163 continue;
164 }
165 let (cx, cy) = r.center();
166 let (dx, dy) = (cx - fx, cy - fy);
167 let dist = dx.hypot(dy);
168 if !(dist > 0.0) {
169 continue;
170 }
171 let cos = (dx * ux + dy * uy) / dist;
172 if cos < cos_cone || cos <= 0.0 {
173 continue;
174 }
175 let score = dist / (cos * cos);
176 if best.is_none_or(|(_, b)| score < b) {
177 best = Some((i, score));
178 }
179 }
180 best.map(|(i, _)| i)
181 }
182
183 /// A candidate for the next-visible-focus rule. `eligible` is the
184 /// mechanism's judgment (mapped, not minimized, not a status bar or
185 /// background surface).
186 #[derive(Debug, Clone, Copy)]
187 pub struct FocusCandidate {
188 pub id: super::api::WindowId,
189 pub eligible: bool,
190 }
191
192 /// Which window takes focus when the focused one goes away (close,
193 /// minimize, unmap): the most recently focused eligible window, else — a
194 /// preserved mechanism quirk — the LAST eligible window in window order,
195 /// else nothing (focus clears). `history` is most-recent-first.
196 pub fn next_visible_focus(
197 history: &[FocusCandidate],
198 windows: &[FocusCandidate],
199 ) -> Option<super::api::WindowId> {
200 history
201 .iter()
202 .find(|c| c.eligible)
203 .or_else(|| windows.iter().filter(|c| c.eligible).last())
204 .map(|c| c.id)
205 }
206
207 #[cfg(test)]
208 mod tests {
209 use super::*;
210 use crate::api::WindowId;
211 use crate::slotmap::Key;
212
213 fn fc(index: u32, eligible: bool) -> FocusCandidate {
214 FocusCandidate { id: WindowId(Key { generation: 0, index }), eligible }
215 }
216
217 #[test]
218 fn next_visible_prefers_history_then_last_in_window_order() {
219 let history = [fc(3, false), fc(7, true), fc(1, true)];
220 let windows = [fc(7, true), fc(3, false), fc(1, true)];
221 // Most recent eligible history entry wins.
222 assert_eq!(next_visible_focus(&history, &windows), Some(fc(7, true).id));
223 // No eligible history: LAST eligible window in window order.
224 let history = [fc(3, false)];
225 assert_eq!(next_visible_focus(&history, &windows), Some(fc(1, true).id));
226 // Nothing eligible anywhere: focus clears.
227 let none = [fc(1, false)];
228 assert_eq!(next_visible_focus(&history, &none), None);
229 assert_eq!(next_visible_focus(&[], &[]), None);
230 }
231
232 fn r(x: f64, y: f64, w: f64, h: f64) -> Rect {
233 Rect::new(x, y, w, h)
234 }
235
236 // A 2x2-ish layout of 200x200 windows (y grows downward):
237 // 0:(0,0) 1:(400,0)
238 // 2:(0,400) 3:(420,380)
239 fn grid() -> [Rect; 4] {
240 [r(0.0, 0.0, 200.0, 200.0), r(400.0, 0.0, 200.0, 200.0), r(0.0, 400.0, 200.0, 200.0), r(420.0, 380.0, 200.0, 200.0)]
241 }
242
243 #[test]
244 fn moves_along_each_axis() {
245 let g = grid();
246 assert_eq!(directional_focus(&g, Some(0), Direction::Right), Some(1));
247 assert_eq!(directional_focus(&g, Some(0), Direction::Down), Some(2));
248 assert_eq!(directional_focus(&g, Some(3), Direction::Left), Some(2));
249 assert_eq!(directional_focus(&g, Some(3), Direction::Up), Some(1));
250 }
251
252 #[test]
253 fn no_candidate_means_no_move() {
254 let g = grid();
255 // Nothing is left of column 0 or above row 0.
256 assert_eq!(directional_focus(&g, Some(0), Direction::Left), None);
257 assert_eq!(directional_focus(&g, Some(0), Direction::Up), None);
258 assert_eq!(directional_focus(&[r(0.0, 0.0, 10.0, 10.0)], Some(0), Direction::Right), None);
259 assert_eq!(directional_focus(&[], None, Direction::Right), None);
260 }
261
262 #[test]
263 fn same_row_beats_nearer_off_row() {
264 // From 0 going right: 1 shares its row (gap 200); 3 is nearly as
265 // close (gap 220) and its center is only 380 down, but it does not
266 // overlap row 0 and so loses to any in-line candidate.
267 let g = grid();
268 assert_eq!(directional_focus(&g, Some(0), Direction::Right), Some(1));
269 // With 1 gone, 3 is the only thing ahead and wins.
270 let g = [g[0], g[2], g[3]];
271 assert_eq!(directional_focus(&g, Some(0), Direction::Right), Some(2));
272 }
273
274 #[test]
275 fn a_wider_window_above_is_not_to_the_right() {
276 // The live layout that motivated edges over centers (virtual px):
277 // a one-cell list with a two-cell calendar directly above it and
278 // a two-by-two mail window in the next column. The calendar's
279 // CENTER is right of the list's (its left edges coincide, it is
280 // twice as wide) and nearer than mail's, so a center rule picked
281 // it; it is above, not to the right.
282 let list = r(0.0, 544.0, 460.0, 532.0);
283 let calendar = r(0.0, 0.0, 932.0, 532.0);
284 let mail = r(944.0, 0.0, 932.0, 1076.0);
285 let wins = [list, calendar, mail];
286 assert_eq!(directional_focus(&wins, Some(0), Direction::Right), Some(2));
287 assert_eq!(directional_focus(&wins, Some(0), Direction::Up), Some(1));
288 assert_eq!(directional_focus(&wins, Some(0), Direction::Left), None);
289 assert_eq!(directional_focus(&wins, Some(0), Direction::Down), None);
290 // From mail, left: both are ahead and in line; the calendar's edge
291 // is 12 px away, the list's 484.
292 assert_eq!(directional_focus(&wins, Some(2), Direction::Left), Some(1));
293 // From the calendar, right: mail (in line); down: the list.
294 assert_eq!(directional_focus(&wins, Some(1), Direction::Right), Some(2));
295 assert_eq!(directional_focus(&wins, Some(1), Direction::Down), Some(0));
296 }
297
298 #[test]
299 fn overlapping_floats_count_only_past_the_midpoint() {
300 // A float whose near edge is past the focused window's midpoint is
301 // ahead (edge gap 0); one that starts before the midpoint is a
302 // stacked window, reachable by FocusNext but not by direction.
303 let f = r(0.0, 0.0, 300.0, 300.0);
304 let ahead = r(200.0, 50.0, 300.0, 100.0);
305 let stacked = r(100.0, 0.0, 300.0, 300.0);
306 assert_eq!(directional_focus(&[f, ahead, stacked], Some(0), Direction::Right), Some(1));
307 assert_eq!(directional_focus(&[f, stacked], Some(0), Direction::Right), None);
308 }
309
310 #[test]
311 fn same_column_ties_break_on_the_nearer_center() {
312 // Two windows in the next column, both in line with a tall focused
313 // window and both at edge gap 0: the one centered nearer wins.
314 let f = r(0.0, 0.0, 100.0, 1000.0);
315 let far = r(110.0, 0.0, 100.0, 100.0);
316 let near = r(110.0, 450.0, 100.0, 100.0);
317 assert_eq!(directional_focus(&[f, far, near], Some(0), Direction::Right), Some(2));
318 }
319
320 #[test]
321 fn unfocused_enters_from_the_opposite_side() {
322 let g = grid();
323 assert_eq!(directional_focus(&g, None, Direction::Right), Some(0)); // leftmost-ish
324 assert_eq!(directional_focus(&g, None, Direction::Left), Some(3)); // rightmost
325 assert_eq!(directional_focus(&g, None, Direction::Down), Some(0)); // topmost
326 assert_eq!(directional_focus(&g, None, Direction::Up), Some(2)); // bottommost
327 }
328
329 #[test]
330 fn direction_from_action() {
331 assert_eq!(Direction::from_action(Action::FocusUp), Some(Direction::Up));
332 assert_eq!(Direction::from_action(Action::FocusDown), Some(Direction::Down));
333 assert_eq!(Direction::from_action(Action::FocusLeft), Some(Direction::Left));
334 assert_eq!(Direction::from_action(Action::FocusRight), Some(Direction::Right));
335 assert_eq!(Direction::from_action(Action::FocusNext), None);
336 }
337
338 // Vector focus. A 3x2 grid of 900x800 windows with 40px gaps, focus on
339 // the top-left one (center 450,400).
340 fn wide_grid() -> Vec<Rect> {
341 vec![
342 r(0.0, 0.0, 900.0, 800.0),
343 r(940.0, 0.0, 900.0, 800.0),
344 r(1880.0, 0.0, 900.0, 800.0),
345 r(0.0, 840.0, 900.0, 800.0),
346 r(940.0, 840.0, 900.0, 800.0),
347 r(1880.0, 840.0, 900.0, 800.0),
348 ]
349 }
350
351 #[test]
352 fn vector_straight_takes_the_neighbour() {
353 let g = wide_grid();
354 assert_eq!(vector_focus(&g, Some(0), (1.0, 0.0), 45.0), Some(1));
355 assert_eq!(vector_focus(&g, Some(0), (0.0, 1.0), 45.0), Some(3));
356 }
357
358 #[test]
359 fn vector_diagonal_takes_the_diagonal_window() {
360 // The diagonal neighbour sits about 42° down-right; a swipe that
361 // way lands on it, not on the nearer straight neighbours.
362 let g = wide_grid();
363 assert_eq!(vector_focus(&g, Some(0), (940.0, 840.0), 45.0), Some(4));
364 assert_eq!(vector_focus(&g, Some(0), (1.0, 1.0), 45.0), Some(4));
365 }
366
367 #[test]
368 fn vector_shallow_angle_prefers_the_straight_neighbour() {
369 let g = wide_grid();
370 assert_eq!(vector_focus(&g, Some(0), (1.0, 0.3), 45.0), Some(1));
371 }
372
373 #[test]
374 fn vector_toward_nothing_changes_nothing() {
375 let g = wide_grid();
376 // Up and left of the top-left window there is nothing.
377 assert_eq!(vector_focus(&g, Some(0), (-1.0, 0.0), 45.0), None);
378 assert_eq!(vector_focus(&g, Some(0), (-1.0, -1.0), 45.0), None);
379 // A narrow cone still reaches a window a little off the ray: from
380 // the top-right window, a swipe 11° left of straight down finds
381 // the one below, while the diagonal one, 37° off, is outside.
382 assert_eq!(vector_focus(&g, Some(5), (0.0, -1.0), 45.0), Some(2));
383 assert_eq!(vector_focus(&g, Some(2), (-0.2, 1.0), 20.0), Some(5));
384 // Aimed into the gap between that diagonal window (42° below
385 // left) and the one straight below (90°), a 20° cone reaches
386 // neither: both lie about 24° off the ray.
387 assert_eq!(vector_focus(&g, Some(2), (-0.4, 0.9), 20.0), None);
388 }
389
390 #[test]
391 fn vector_needs_a_focus_and_a_direction() {
392 let g = wide_grid();
393 assert_eq!(vector_focus(&g, None, (1.0, 0.0), 45.0), None);
394 assert_eq!(vector_focus(&g, Some(0), (0.0, 0.0), 45.0), None);
395 }
396
397 #[test]
398 fn vector_skips_a_window_centered_on_the_focus() {
399 let rects = [r(0.0, 0.0, 400.0, 400.0), r(100.0, 100.0, 200.0, 200.0), r(600.0, 0.0, 400.0, 400.0)];
400 assert_eq!(vector_focus(&rects, Some(0), (1.0, 0.0), 45.0), Some(2));
401 }
402
403 }