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

src/focus.rs (11.5K)

  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 /// A candidate for the next-visible-focus rule. `eligible` is the
138 /// mechanism's judgment (mapped, not minimized, not a status bar or
139 /// background surface).
140 #[derive(Debug, Clone, Copy)]
141 pub struct FocusCandidate {
142     pub id: super::api::WindowId,
143     pub eligible: bool,
144 }
145 
146 /// Which window takes focus when the focused one goes away (close,
147 /// minimize, unmap): the most recently focused eligible window, else — a
148 /// preserved mechanism quirk — the LAST eligible window in window order,
149 /// else nothing (focus clears). `history` is most-recent-first.
150 pub fn next_visible_focus(
151     history: &[FocusCandidate],
152     windows: &[FocusCandidate],
153 ) -> Option<super::api::WindowId> {
154     history
155         .iter()
156         .find(|c| c.eligible)
157         .or_else(|| windows.iter().filter(|c| c.eligible).last())
158         .map(|c| c.id)
159 }
160 
161 #[cfg(test)]
162 mod tests {
163     use super::*;
164     use crate::api::WindowId;
165     use crate::slotmap::Key;
166 
167     fn fc(index: u32, eligible: bool) -> FocusCandidate {
168         FocusCandidate { id: WindowId(Key { generation: 0, index }), eligible }
169     }
170 
171     #[test]
172     fn next_visible_prefers_history_then_last_in_window_order() {
173         let history = [fc(3, false), fc(7, true), fc(1, true)];
174         let windows = [fc(7, true), fc(3, false), fc(1, true)];
175         // Most recent eligible history entry wins.
176         assert_eq!(next_visible_focus(&history, &windows), Some(fc(7, true).id));
177         // No eligible history: LAST eligible window in window order.
178         let history = [fc(3, false)];
179         assert_eq!(next_visible_focus(&history, &windows), Some(fc(1, true).id));
180         // Nothing eligible anywhere: focus clears.
181         let none = [fc(1, false)];
182         assert_eq!(next_visible_focus(&history, &none), None);
183         assert_eq!(next_visible_focus(&[], &[]), None);
184     }
185 
186     fn r(x: f64, y: f64, w: f64, h: f64) -> Rect {
187         Rect::new(x, y, w, h)
188     }
189 
190     // A 2x2-ish layout of 200x200 windows (y grows downward):
191     //   0:(0,0)     1:(400,0)
192     //   2:(0,400)   3:(420,380)
193     fn grid() -> [Rect; 4] {
194         [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)]
195     }
196 
197     #[test]
198     fn moves_along_each_axis() {
199         let g = grid();
200         assert_eq!(directional_focus(&g, Some(0), Direction::Right), Some(1));
201         assert_eq!(directional_focus(&g, Some(0), Direction::Down), Some(2));
202         assert_eq!(directional_focus(&g, Some(3), Direction::Left), Some(2));
203         assert_eq!(directional_focus(&g, Some(3), Direction::Up), Some(1));
204     }
205 
206     #[test]
207     fn no_candidate_means_no_move() {
208         let g = grid();
209         // Nothing is left of column 0 or above row 0.
210         assert_eq!(directional_focus(&g, Some(0), Direction::Left), None);
211         assert_eq!(directional_focus(&g, Some(0), Direction::Up), None);
212         assert_eq!(directional_focus(&[r(0.0, 0.0, 10.0, 10.0)], Some(0), Direction::Right), None);
213         assert_eq!(directional_focus(&[], None, Direction::Right), None);
214     }
215 
216     #[test]
217     fn same_row_beats_nearer_off_row() {
218         // From 0 going right: 1 shares its row (gap 200); 3 is nearly as
219         // close (gap 220) and its center is only 380 down, but it does not
220         // overlap row 0 and so loses to any in-line candidate.
221         let g = grid();
222         assert_eq!(directional_focus(&g, Some(0), Direction::Right), Some(1));
223         // With 1 gone, 3 is the only thing ahead and wins.
224         let g = [g[0], g[2], g[3]];
225         assert_eq!(directional_focus(&g, Some(0), Direction::Right), Some(2));
226     }
227 
228     #[test]
229     fn a_wider_window_above_is_not_to_the_right() {
230         // The live layout that motivated edges over centers (virtual px):
231         // a one-cell list with a two-cell calendar directly above it and
232         // a two-by-two mail window in the next column. The calendar's
233         // CENTER is right of the list's (its left edges coincide, it is
234         // twice as wide) and nearer than mail's, so a center rule picked
235         // it; it is above, not to the right.
236         let list = r(0.0, 544.0, 460.0, 532.0);
237         let calendar = r(0.0, 0.0, 932.0, 532.0);
238         let mail = r(944.0, 0.0, 932.0, 1076.0);
239         let wins = [list, calendar, mail];
240         assert_eq!(directional_focus(&wins, Some(0), Direction::Right), Some(2));
241         assert_eq!(directional_focus(&wins, Some(0), Direction::Up), Some(1));
242         assert_eq!(directional_focus(&wins, Some(0), Direction::Left), None);
243         assert_eq!(directional_focus(&wins, Some(0), Direction::Down), None);
244         // From mail, left: both are ahead and in line; the calendar's edge
245         // is 12 px away, the list's 484.
246         assert_eq!(directional_focus(&wins, Some(2), Direction::Left), Some(1));
247         // From the calendar, right: mail (in line); down: the list.
248         assert_eq!(directional_focus(&wins, Some(1), Direction::Right), Some(2));
249         assert_eq!(directional_focus(&wins, Some(1), Direction::Down), Some(0));
250     }
251 
252     #[test]
253     fn overlapping_floats_count_only_past_the_midpoint() {
254         // A float whose near edge is past the focused window's midpoint is
255         // ahead (edge gap 0); one that starts before the midpoint is a
256         // stacked window, reachable by FocusNext but not by direction.
257         let f = r(0.0, 0.0, 300.0, 300.0);
258         let ahead = r(200.0, 50.0, 300.0, 100.0);
259         let stacked = r(100.0, 0.0, 300.0, 300.0);
260         assert_eq!(directional_focus(&[f, ahead, stacked], Some(0), Direction::Right), Some(1));
261         assert_eq!(directional_focus(&[f, stacked], Some(0), Direction::Right), None);
262     }
263 
264     #[test]
265     fn same_column_ties_break_on_the_nearer_center() {
266         // Two windows in the next column, both in line with a tall focused
267         // window and both at edge gap 0: the one centered nearer wins.
268         let f = r(0.0, 0.0, 100.0, 1000.0);
269         let far = r(110.0, 0.0, 100.0, 100.0);
270         let near = r(110.0, 450.0, 100.0, 100.0);
271         assert_eq!(directional_focus(&[f, far, near], Some(0), Direction::Right), Some(2));
272     }
273 
274     #[test]
275     fn unfocused_enters_from_the_opposite_side() {
276         let g = grid();
277         assert_eq!(directional_focus(&g, None, Direction::Right), Some(0)); // leftmost-ish
278         assert_eq!(directional_focus(&g, None, Direction::Left), Some(3)); // rightmost
279         assert_eq!(directional_focus(&g, None, Direction::Down), Some(0)); // topmost
280         assert_eq!(directional_focus(&g, None, Direction::Up), Some(2)); // bottommost
281     }
282 
283     #[test]
284     fn direction_from_action() {
285         assert_eq!(Direction::from_action(Action::FocusUp), Some(Direction::Up));
286         assert_eq!(Direction::from_action(Action::FocusDown), Some(Direction::Down));
287         assert_eq!(Direction::from_action(Action::FocusLeft), Some(Direction::Left));
288         assert_eq!(Direction::from_action(Action::FocusRight), Some(Direction::Right));
289         assert_eq!(Direction::from_action(Action::FocusNext), None);
290     }
291 }