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

commit416f42c78fb63131121d8f2a86a607c31c829fc5
parent054f6f053f
authorLucas Galante <[email protected]>
date2026-09-20 22:36
focus: pick directional targets by window footprint, not center

FocusRight from a one-cell list focused the two-cell calendar directly
above it instead of the mail window in the next column. The calendar's
left edge coincides with the list's and it is twice as wide, so its
center is right of the list's center and, with the 2x off-axis penalty,
scored nearer than mail's. A center rule cannot distinguish "sticks out
past me" from "is beside me".

`directional_focus` now takes `Rect` footprints (extent already scaled).
A candidate is ahead when its near edge is past the focused window's
midpoint on the axis of travel; candidates overlapping the focused
window's extent across that axis (same row for left/right, same column
for up/down) win over any that do not; then the smallest edge gap, with
the off-axis gap weighted as before, then the nearest off-axis center.
Stacked floats that start before the midpoint are not ahead — they stay
reachable by FocusNext.

Tests cover the live layout that motivated this, in-line beating a
nearer off-row window, stacked floats, same-column ties, and the scaled
footprint in the action arm.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

 CLAUDE.md      |   8 ++-
 src/actions.rs |  18 +++--
 src/focus.rs   | 209 +++++++++++++++++++++++++++++++++++++++++++--------------
 3 files changed, 177 insertions(+), 58 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index e0baf7d..45bfbb0 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -210,8 +210,12 @@ The crate owns what a binding *means*; the compositor owns the physical half
   interpolation are deliberately MIRRORED from cce-ui rather than shared —
   this crate stays dependency-minimal — so the two must be kept in step.
 - `focus.rs` — directional focus selection (`directional_focus` over window
-  center points in virtual coordinates; no wraparound, off-axis distance is
-  penalized). Consumed by the compositor's `FocusUp/Down/Left/Right` action
+  footprints (`Rect`) in virtual coordinates: a candidate's near edge must be
+  past the focused window's midpoint, same-row/column candidates win over
+  off-axis ones, then the smallest edge gap; no wraparound). Edges, not
+  centers: a wide window directly above a narrow one has a center to its
+  right, and a center rule used to focus it on "right". Consumed by the
+  compositor's `FocusUp/Down/Left/Right` action
   arm; default chords are super+k/j/h/l via `bindings::DEFAULT_BINDINGS`.
 - `pan.rs` — cell-aligned viewport panning: `aligned_step` gives the keyed
   PanLeft/… actions their animation targets (pan offsets that are multiples
diff --git a/src/actions.rs b/src/actions.rs
index ffe719d..12f59ce 100644
--- a/src/actions.rs
+++ b/src/actions.rs
@@ -396,17 +396,17 @@ fn focus_cycle(ctx: &ActionCtx, action: Action) -> Vec<Command> {
     vec![Command::Focus(id), Command::Raise(id), Command::Relayout]
 }
 
-/// Directional focus over the ring's window centers on the virtual surface.
+/// Directional focus over the ring's window footprints on the virtual surface.
 fn focus_directional(ctx: &ActionCtx, action: Action) -> Vec<Command> {
     let ring = focus_ring(ctx);
-    let centers: Vec<(f64, f64)> = ring
+    let rects: Vec<focus::Rect> = ring
         .iter()
-        .map(|w| (w.x + w.w * w.scale / 2.0, w.y + w.h * w.scale / 2.0))
+        .map(|w| focus::Rect::new(w.x, w.y, w.w * w.scale, w.h * w.scale))
         .collect();
     let focused_idx = ctx.focused.and_then(|f| ring.iter().position(|w| w.id == f));
     let dir = focus::Direction::from_action(action)
         .expect("arm only matches directional focus actions");
-    let Some(target) = focus::directional_focus(&centers, focused_idx, dir) else {
+    let Some(target) = focus::directional_focus(&rects, focused_idx, dir) else {
         return Vec::new();
     };
     let id = ring[target].id;
@@ -645,7 +645,7 @@ mod tests {
     }
 
     #[test]
-    fn focus_directional_picks_by_center() {
+    fn focus_directional_picks_by_footprint() {
         let mut c = ctx();
         c.windows.push(win(1, 0.0, 0.0, 100.0, 100.0));
         c.windows.push(win(2, 500.0, 0.0, 100.0, 100.0));
@@ -655,6 +655,14 @@ mod tests {
         assert_eq!(cmds[1], Command::Raise(wid(2)));
         // Nothing to the left of window 1.
         assert!(dispatch(&c, Action::FocusLeft).is_empty());
+        // The footprint is the scaled extent. A window starting at 80 is
+        // past window 1's midpoint (50) at scale 1 and wins as the nearer
+        // edge; at scale 2 window 1 spans 0..200, so it is stacked, not
+        // ahead, and window 2 wins again.
+        c.windows.push(win(3, 80.0, 0.0, 100.0, 100.0));
+        assert_eq!(dispatch(&c, Action::FocusRight)[0], Command::Focus(wid(3)));
+        c.windows[0].scale = 2.0;
+        assert_eq!(dispatch(&c, Action::FocusRight)[0], Command::Focus(wid(2)));
     }
 
     #[test]
diff --git a/src/focus.rs b/src/focus.rs
index e27f4c3..ab4f540 100644
--- a/src/focus.rs
+++ b/src/focus.rs
@@ -1,7 +1,7 @@
 // Directional focus selection: which window receives focus when the user
 // moves focus up/down/left/right of the current one.
 //
-// Inputs are window CENTER points in virtual-surface coordinates (the same
+// Inputs are window FOOTPRINTS in virtual-surface coordinates (the same
 // space as `WindowSnapshot.virtual_x/y`; y grows downward). The mechanism
 // side builds the candidate list (visible, non-status windows) and applies
 // the returned index.
@@ -30,57 +30,105 @@ impl Direction {
     }
 }
 
-/// Weight of off-axis distance in the candidate score: a window slightly
-/// ahead but far off to the side loses to one straight ahead.
+/// A window's footprint on the virtual surface: top-left corner and extent,
+/// the extent already multiplied by the window's output scale.
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub struct Rect {
+    pub x: f64,
+    pub y: f64,
+    pub w: f64,
+    pub h: f64,
+}
+
+impl Rect {
+    pub fn new(x: f64, y: f64, w: f64, h: f64) -> Rect {
+        Rect { x, y, w, h }
+    }
+
+    fn center(&self) -> (f64, f64) {
+        (self.x + self.w / 2.0, self.y + self.h / 2.0)
+    }
+
+    /// The rect seen along `dir`: `(lo, hi)` is its extent on the axis of
+    /// travel, increasing in the direction of travel, and `(olo, ohi)` its
+    /// extent across it.
+    fn along(&self, dir: Direction) -> (f64, f64, f64, f64) {
+        let (x0, x1, y0, y1) = (self.x, self.x + self.w, self.y, self.y + self.h);
+        match dir {
+            Direction::Right => (x0, x1, y0, y1),
+            Direction::Left => (-x1, -x0, y0, y1),
+            Direction::Down => (y0, y1, x0, x1),
+            Direction::Up => (-y1, -y0, x0, x1),
+        }
+    }
+}
+
+/// Weight of off-axis distance in the candidate score, for candidates that
+/// do not share a row/column with the focused window: a window slightly
+/// ahead but far off to the side loses to one nearly straight ahead.
 const ORTHOGONAL_PENALTY: f64 = 2.0;
 
 /// Pick the window to focus when moving in `dir` from `focused`.
 ///
-/// A candidate must lie strictly in the direction of travel (its center's
-/// primary-axis delta > 0); among candidates the lowest
-/// `primary + ORTHOGONAL_PENALTY * |orthogonal|` wins. No wraparound: with
-/// no candidate in that direction the focus stays put (`None`).
+/// Edges decide, not centers. A candidate must lie ahead: its near edge
+/// past the focused window's midpoint on the axis of travel. That excludes
+/// a window that merely sticks out past the focused one — a wide window
+/// directly above a narrow one has a center to the right of it, but is not
+/// "to the right" of it. Candidates whose extent across the axis of travel
+/// overlaps the focused window's (same row for left/right, same column for
+/// up/down) win over any that do not; within a group the smallest edge gap
+/// wins, off-axis gap weighted by `ORTHOGONAL_PENALTY`, then the nearest
+/// off-axis center. No wraparound: with no candidate in that direction the
+/// focus stays put (`None`).
 ///
-/// With nothing focused, the entry window is the one furthest on the
-/// opposite side (moving right enters at the leftmost window), matching the
-/// "focus is entering the surface from off-screen" intuition.
-pub fn directional_focus(centers: &[(f64, f64)], focused: Option<usize>, dir: Direction) -> Option<usize> {
-    if centers.is_empty() {
+/// With nothing focused, the entry window is the one whose center is
+/// furthest on the opposite side (moving right enters at the leftmost
+/// window), matching the "focus is entering the surface from off-screen"
+/// intuition.
+pub fn directional_focus(rects: &[Rect], focused: Option<usize>, dir: Direction) -> Option<usize> {
+    if rects.is_empty() {
         return None;
     }
 
     let Some(focused) = focused else {
-        let entry_key = |&(x, y): &(f64, f64)| match dir {
-            Direction::Right => x,
-            Direction::Left => -x,
-            Direction::Down => y,
-            Direction::Up => -y,
+        let entry_key = |r: &Rect| {
+            let (x, y) = r.center();
+            match dir {
+                Direction::Right => x,
+                Direction::Left => -x,
+                Direction::Down => y,
+                Direction::Up => -y,
+            }
         };
-        return centers
+        return rects
             .iter()
             .enumerate()
             .min_by(|(_, a), (_, b)| entry_key(a).total_cmp(&entry_key(b)))
             .map(|(i, _)| i);
     };
 
-    let (fx, fy) = centers[focused];
-    let mut best: Option<(usize, f64)> = None;
-    for (i, &(x, y)) in centers.iter().enumerate() {
+    let (flo, fhi, folo, fohi) = rects[focused].along(dir);
+    let fmid = (flo + fhi) / 2.0;
+    let fomid = (folo + fohi) / 2.0;
+    let mut best: Option<(usize, (bool, f64, f64))> = None;
+    for (i, r) in rects.iter().enumerate() {
         if i == focused {
             continue;
         }
-        let (primary, orthogonal) = match dir {
-            Direction::Right => (x - fx, y - fy),
-            Direction::Left => (fx - x, y - fy),
-            Direction::Down => (y - fy, x - fx),
-            Direction::Up => (fy - y, x - fx),
-        };
-        if primary <= 0.0 {
+        let (lo, hi, olo, ohi) = r.along(dir);
+        if lo <= fmid {
             continue;
         }
-        let score = primary + ORTHOGONAL_PENALTY * orthogonal.abs();
-        if best.is_none_or(|(_, s)| score < s) {
-            best = Some((i, score));
+        let in_line = olo < fohi && ohi > folo;
+        let primary = (lo - fhi).max(0.0);
+        let orthogonal = (olo - fohi).max(folo - ohi).max(0.0);
+        let key = (
+            !in_line,
+            primary + ORTHOGONAL_PENALTY * orthogonal,
+            ((olo + ohi) / 2.0 - fomid).abs(),
+        );
+        if best.is_none_or(|(_, b)| key < b) {
+            best = Some((i, key));
         }
     }
     best.map(|(i, _)| i)
@@ -135,42 +183,101 @@ mod tests {
         assert_eq!(next_visible_focus(&[], &[]), None);
     }
 
-    // A 2x2-ish layout (y grows downward):
-    //   0:(100,100)   1:(500,100)
-    //   2:(100,500)   3:(520,480)
-    const GRID: [(f64, f64); 4] = [(100.0, 100.0), (500.0, 100.0), (100.0, 500.0), (520.0, 480.0)];
+    fn r(x: f64, y: f64, w: f64, h: f64) -> Rect {
+        Rect::new(x, y, w, h)
+    }
+
+    // A 2x2-ish layout of 200x200 windows (y grows downward):
+    //   0:(0,0)     1:(400,0)
+    //   2:(0,400)   3:(420,380)
+    fn grid() -> [Rect; 4] {
+        [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)]
+    }
 
     #[test]
     fn moves_along_each_axis() {
-        assert_eq!(directional_focus(&GRID, Some(0), Direction::Right), Some(1));
-        assert_eq!(directional_focus(&GRID, Some(0), Direction::Down), Some(2));
-        assert_eq!(directional_focus(&GRID, Some(3), Direction::Left), Some(2));
-        assert_eq!(directional_focus(&GRID, Some(3), Direction::Up), Some(1));
+        let g = grid();
+        assert_eq!(directional_focus(&g, Some(0), Direction::Right), Some(1));
+        assert_eq!(directional_focus(&g, Some(0), Direction::Down), Some(2));
+        assert_eq!(directional_focus(&g, Some(3), Direction::Left), Some(2));
+        assert_eq!(directional_focus(&g, Some(3), Direction::Up), Some(1));
     }
 
     #[test]
     fn no_candidate_means_no_move() {
+        let g = grid();
         // Nothing is left of column 0 or above row 0.
-        assert_eq!(directional_focus(&GRID, Some(0), Direction::Left), None);
-        assert_eq!(directional_focus(&GRID, Some(0), Direction::Up), None);
-        assert_eq!(directional_focus(&[(0.0, 0.0)], Some(0), Direction::Right), None);
+        assert_eq!(directional_focus(&g, Some(0), Direction::Left), None);
+        assert_eq!(directional_focus(&g, Some(0), Direction::Up), None);
+        assert_eq!(directional_focus(&[r(0.0, 0.0, 10.0, 10.0)], Some(0), Direction::Right), None);
         assert_eq!(directional_focus(&[], None, Direction::Right), None);
     }
 
     #[test]
-    fn orthogonal_penalty_prefers_straight_ahead() {
-        // From 0 going right: 1 is straight ahead (400 away); 3 is closer on
-        // the diagonal-ish (420, 380) but pays the off-axis penalty:
-        // score(1) = 400, score(3) = 420 + 2*380 = 1180.
-        assert_eq!(directional_focus(&GRID, Some(0), Direction::Right), Some(1));
+    fn same_row_beats_nearer_off_row() {
+        // From 0 going right: 1 shares its row (gap 200); 3 is nearly as
+        // close (gap 220) and its center is only 380 down, but it does not
+        // overlap row 0 and so loses to any in-line candidate.
+        let g = grid();
+        assert_eq!(directional_focus(&g, Some(0), Direction::Right), Some(1));
+        // With 1 gone, 3 is the only thing ahead and wins.
+        let g = [g[0], g[2], g[3]];
+        assert_eq!(directional_focus(&g, Some(0), Direction::Right), Some(2));
+    }
+
+    #[test]
+    fn a_wider_window_above_is_not_to_the_right() {
+        // The live layout that motivated edges over centers (virtual px):
+        // a one-cell list with a two-cell calendar directly above it and
+        // a two-by-two mail window in the next column. The calendar's
+        // CENTER is right of the list's (its left edges coincide, it is
+        // twice as wide) and nearer than mail's, so a center rule picked
+        // it; it is above, not to the right.
+        let list = r(0.0, 544.0, 460.0, 532.0);
+        let calendar = r(0.0, 0.0, 932.0, 532.0);
+        let mail = r(944.0, 0.0, 932.0, 1076.0);
+        let wins = [list, calendar, mail];
+        assert_eq!(directional_focus(&wins, Some(0), Direction::Right), Some(2));
+        assert_eq!(directional_focus(&wins, Some(0), Direction::Up), Some(1));
+        assert_eq!(directional_focus(&wins, Some(0), Direction::Left), None);
+        assert_eq!(directional_focus(&wins, Some(0), Direction::Down), None);
+        // From mail, left: both are ahead and in line; the calendar's edge
+        // is 12 px away, the list's 484.
+        assert_eq!(directional_focus(&wins, Some(2), Direction::Left), Some(1));
+        // From the calendar, right: mail (in line); down: the list.
+        assert_eq!(directional_focus(&wins, Some(1), Direction::Right), Some(2));
+        assert_eq!(directional_focus(&wins, Some(1), Direction::Down), Some(0));
+    }
+
+    #[test]
+    fn overlapping_floats_count_only_past_the_midpoint() {
+        // A float whose near edge is past the focused window's midpoint is
+        // ahead (edge gap 0); one that starts before the midpoint is a
+        // stacked window, reachable by FocusNext but not by direction.
+        let f = r(0.0, 0.0, 300.0, 300.0);
+        let ahead = r(200.0, 50.0, 300.0, 100.0);
+        let stacked = r(100.0, 0.0, 300.0, 300.0);
+        assert_eq!(directional_focus(&[f, ahead, stacked], Some(0), Direction::Right), Some(1));
+        assert_eq!(directional_focus(&[f, stacked], Some(0), Direction::Right), None);
+    }
+
+    #[test]
+    fn same_column_ties_break_on_the_nearer_center() {
+        // Two windows in the next column, both in line with a tall focused
+        // window and both at edge gap 0: the one centered nearer wins.
+        let f = r(0.0, 0.0, 100.0, 1000.0);
+        let far = r(110.0, 0.0, 100.0, 100.0);
+        let near = r(110.0, 450.0, 100.0, 100.0);
+        assert_eq!(directional_focus(&[f, far, near], Some(0), Direction::Right), Some(2));
     }
 
     #[test]
     fn unfocused_enters_from_the_opposite_side() {
-        assert_eq!(directional_focus(&GRID, None, Direction::Right), Some(0)); // leftmost-ish
-        assert_eq!(directional_focus(&GRID, None, Direction::Left), Some(3)); // rightmost
-        assert_eq!(directional_focus(&GRID, None, Direction::Down), Some(0)); // topmost
-        assert_eq!(directional_focus(&GRID, None, Direction::Up), Some(2)); // bottommost
+        let g = grid();
+        assert_eq!(directional_focus(&g, None, Direction::Right), Some(0)); // leftmost-ish
+        assert_eq!(directional_focus(&g, None, Direction::Left), Some(3)); // rightmost
+        assert_eq!(directional_focus(&g, None, Direction::Down), Some(0)); // topmost
+        assert_eq!(directional_focus(&g, None, Direction::Up), Some(2)); // bottommost
     }
 
     #[test]