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

commitdfde58422441b56e4219147a6deea1ee1b099bea
parent416f42c78f
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-24 22:46
focus: pick by a free direction for the focus swipe

vector_focus picks the window to focus along a ray from the focused
window's center, where directional_focus picks one of four sides. Only
window centers within a cone of the ray are candidates, so a direction
with nothing in it changes nothing. Candidates score by center distance
divided by cos² of their angle off the ray, so a precise diagonal lands
on the diagonal window over a slightly nearer straight neighbour.

actions::focus_toward wraps it for the compositor's three-finger focus
swipe. Keyed focus stays four-way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 CLAUDE.md      |   9 +++++
 src/actions.rs |  21 +++++++++++
 src/focus.rs   | 112 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 142 insertions(+)

diff --git a/CLAUDE.md b/CLAUDE.md
index 45bfbb0..010d7f0 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -217,6 +217,15 @@ The crate owns what a binding *means*; the compositor owns the physical half
   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`.
+  Beside it, `vector_focus` picks by a free direction instead of one of
+  four: a ray from the focused window's CENTER, candidates only within a
+  cone of it (so a direction with nothing in it returns `None`), scored
+  by center distance / cos²θ so a precise diagonal lands on the diagonal
+  window over a slightly nearer straight neighbour. Centers are right
+  here where edges were wrong above, because the ray carries its own
+  angle rather than asking which side a window is on. `actions::focus_toward`
+  wraps it for the compositor's three-finger focus swipe; keys stay
+  four-way.
 - `pan.rs` — cell-aligned viewport panning: `aligned_step` gives the keyed
   PanLeft/… actions their animation targets (pan offsets that are multiples
   of the grid period).
diff --git a/src/actions.rs b/src/actions.rs
index 12f59ce..080fa64 100644
--- a/src/actions.rs
+++ b/src/actions.rs
@@ -413,6 +413,27 @@ fn focus_directional(ctx: &ActionCtx, action: Action) -> Vec<Command> {
     vec![Command::Focus(id), Command::Raise(id), Command::Relayout]
 }
 
+/// Focus toward a free direction `v` (virtual units, y down) — the
+/// three-finger swipe's path, where the fingers give a vector rather than
+/// one of four directions. See `focus::vector_focus`; `cone_deg` is how far
+/// off the ray a window center may lie. Empty when there is no ray (nothing
+/// focused) or nothing within the cone, which the caller reads as "no
+/// focus change" — or, with nothing focused, falls back to the four-way
+/// action for its entry rule.
+pub fn focus_toward(ctx: &ActionCtx, v: (f64, f64), cone_deg: f64) -> Vec<Command> {
+    let ring = focus_ring(ctx);
+    let rects: Vec<focus::Rect> = ring
+        .iter()
+        .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 Some(target) = focus::vector_focus(&rects, focused_idx, v, cone_deg) else {
+        return Vec::new();
+    };
+    let id = ring[target].id;
+    vec![Command::Focus(id), Command::Raise(id), Command::Relayout]
+}
+
 /// Toggle fullscreen on the focused window. Leaving fullscreen puts the
 /// window back the way the toggle found it — mode AND lock, so a window
 /// tiled by hand stays tiled (the mechanism records both when it applies
diff --git a/src/focus.rs b/src/focus.rs
index ab4f540..63e5bec 100644
--- a/src/focus.rs
+++ b/src/focus.rs
@@ -134,6 +134,52 @@ pub fn directional_focus(rects: &[Rect], focused: Option<usize>, dir: Direction)
     best.map(|(i, _)| i)
 }
 
+/// Pick the window to focus by a free direction rather than one of four:
+/// a ray from the focused window's center along `v` (virtual-surface
+/// units, y down), and the window whose center lies nearest along it.
+///
+/// Only centers within `cone_deg` of the ray are candidates, so a swipe
+/// toward empty space changes nothing (`None`) rather than reaching for
+/// whatever is closest. Among candidates the score is the center's
+/// distance divided by cos²θ, θ its angle off the ray: nearer wins, and
+/// off-ray costs more the further off it is, so a precise diagonal lands
+/// on the diagonal window even when a straight neighbour is a little
+/// closer. A candidate centered on the focused window's own center has no
+/// direction and is skipped. With nothing focused, or a zero `v`, there
+/// is no ray and the answer is `None`; the caller falls back to
+/// `directional_focus`, whose entry rule covers the unfocused case.
+pub fn vector_focus(rects: &[Rect], focused: Option<usize>, v: (f64, f64), cone_deg: f64) -> Option<usize> {
+    let focused = focused?;
+    let len = v.0.hypot(v.1);
+    if !(len > 0.0) {
+        return None;
+    }
+    let (ux, uy) = (v.0 / len, v.1 / len);
+    let cos_cone = cone_deg.to_radians().cos();
+    let (fx, fy) = rects[focused].center();
+    let mut best: Option<(usize, f64)> = None;
+    for (i, r) in rects.iter().enumerate() {
+        if i == focused {
+            continue;
+        }
+        let (cx, cy) = r.center();
+        let (dx, dy) = (cx - fx, cy - fy);
+        let dist = dx.hypot(dy);
+        if !(dist > 0.0) {
+            continue;
+        }
+        let cos = (dx * ux + dy * uy) / dist;
+        if cos < cos_cone || cos <= 0.0 {
+            continue;
+        }
+        let score = dist / (cos * cos);
+        if best.is_none_or(|(_, b)| score < b) {
+            best = Some((i, score));
+        }
+    }
+    best.map(|(i, _)| i)
+}
+
 /// A candidate for the next-visible-focus rule. `eligible` is the
 /// mechanism's judgment (mapped, not minimized, not a status bar or
 /// background surface).
@@ -288,4 +334,70 @@ mod tests {
         assert_eq!(Direction::from_action(Action::FocusRight), Some(Direction::Right));
         assert_eq!(Direction::from_action(Action::FocusNext), None);
     }
+
+    // Vector focus. A 3x2 grid of 900x800 windows with 40px gaps, focus on
+    // the top-left one (center 450,400).
+    fn wide_grid() -> Vec<Rect> {
+        vec![
+            r(0.0, 0.0, 900.0, 800.0),
+            r(940.0, 0.0, 900.0, 800.0),
+            r(1880.0, 0.0, 900.0, 800.0),
+            r(0.0, 840.0, 900.0, 800.0),
+            r(940.0, 840.0, 900.0, 800.0),
+            r(1880.0, 840.0, 900.0, 800.0),
+        ]
+    }
+
+    #[test]
+    fn vector_straight_takes_the_neighbour() {
+        let g = wide_grid();
+        assert_eq!(vector_focus(&g, Some(0), (1.0, 0.0), 45.0), Some(1));
+        assert_eq!(vector_focus(&g, Some(0), (0.0, 1.0), 45.0), Some(3));
+    }
+
+    #[test]
+    fn vector_diagonal_takes_the_diagonal_window() {
+        // The diagonal neighbour sits about 42° down-right; a swipe that
+        // way lands on it, not on the nearer straight neighbours.
+        let g = wide_grid();
+        assert_eq!(vector_focus(&g, Some(0), (940.0, 840.0), 45.0), Some(4));
+        assert_eq!(vector_focus(&g, Some(0), (1.0, 1.0), 45.0), Some(4));
+    }
+
+    #[test]
+    fn vector_shallow_angle_prefers_the_straight_neighbour() {
+        let g = wide_grid();
+        assert_eq!(vector_focus(&g, Some(0), (1.0, 0.3), 45.0), Some(1));
+    }
+
+    #[test]
+    fn vector_toward_nothing_changes_nothing() {
+        let g = wide_grid();
+        // Up and left of the top-left window there is nothing.
+        assert_eq!(vector_focus(&g, Some(0), (-1.0, 0.0), 45.0), None);
+        assert_eq!(vector_focus(&g, Some(0), (-1.0, -1.0), 45.0), None);
+        // A narrow cone still reaches a window a little off the ray: from
+        // the top-right window, a swipe 11° left of straight down finds
+        // the one below, while the diagonal one, 37° off, is outside.
+        assert_eq!(vector_focus(&g, Some(5), (0.0, -1.0), 45.0), Some(2));
+        assert_eq!(vector_focus(&g, Some(2), (-0.2, 1.0), 20.0), Some(5));
+        // Aimed into the gap between that diagonal window (42° below
+        // left) and the one straight below (90°), a 20° cone reaches
+        // neither: both lie about 24° off the ray.
+        assert_eq!(vector_focus(&g, Some(2), (-0.4, 0.9), 20.0), None);
+    }
+
+    #[test]
+    fn vector_needs_a_focus_and_a_direction() {
+        let g = wide_grid();
+        assert_eq!(vector_focus(&g, None, (1.0, 0.0), 45.0), None);
+        assert_eq!(vector_focus(&g, Some(0), (0.0, 0.0), 45.0), None);
+    }
+
+    #[test]
+    fn vector_skips_a_window_centered_on_the_focus() {
+        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)];
+        assert_eq!(vector_focus(&rects, Some(0), (1.0, 0.0), 45.0), Some(2));
+    }
+
 }