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 }