git.lucas.co / cce-window-manager
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 }