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

commit8f4e6f8c6dce1e3010cad95f64d896585d4c5a37
parent991fba370b
authorLucas Galante <[email protected]>
date2026-07-16 12:29
feat: directional focus — FocusUp/Down/Left/Right actions and selection

New focus module: directional_focus() picks the window to focus from
center points in virtual coordinates. Candidates must lie strictly in
the direction of travel, off-axis distance is penalized 2x, no
wraparound; with nothing focused, focus enters from the opposite side.

Action gains FocusUp/FocusDown/FocusLeft/FocusRight (focus_up/_down/
_left/_right in input.kdl); DEFAULT_BINDINGS maps them to super+k/j/h/l,
rebindable and never shadowing user chords.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PXSsCppeDFmSRRM5NAug5a

 CLAUDE.md       |   4 ++
 src/api.rs      |  12 +++++
 src/bindings.rs |   8 +++-
 src/focus.rs    | 139 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 src/lib.rs      |   3 ++
 5 files changed, 165 insertions(+), 1 deletion(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 70626de..918ee6b 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -123,6 +123,10 @@ The crate owns what a binding *means*; the compositor owns the physical half
 
 ### Supporting modules
 
+- `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
+  arm; default chords are super+k/j/h/l via `bindings::DEFAULT_BINDINGS`.
 - `tiling.rs` — `TilingMode` enum (serialized into saved state — renaming
   variants breaks `state.json` compatibility) and the cascade/grid/fullscreen
   tiling formulas.
diff --git a/src/api.rs b/src/api.rs
index 31b9358..4adf21b 100644
--- a/src/api.rs
+++ b/src/api.rs
@@ -51,6 +51,10 @@ pub enum Action {
     Close,
     FocusNext,
     FocusPrev,
+    FocusUp,
+    FocusDown,
+    FocusLeft,
+    FocusRight,
     WindowSwitcher,
     Move,
     Resize,
@@ -92,6 +96,10 @@ impl Action {
             Action::Close => "close_window",
             Action::FocusNext => "focus_next",
             Action::FocusPrev => "focus_prev",
+            Action::FocusUp => "focus_up",
+            Action::FocusDown => "focus_down",
+            Action::FocusLeft => "focus_left",
+            Action::FocusRight => "focus_right",
             Action::WindowSwitcher => "window_switcher",
             Action::Move => "move",
             Action::Resize => "resize",
@@ -133,6 +141,10 @@ impl Action {
             "close_window" | "close" => Action::Close,
             "focus_next" => Action::FocusNext,
             "focus_prev" => Action::FocusPrev,
+            "focus_up" => Action::FocusUp,
+            "focus_down" => Action::FocusDown,
+            "focus_left" => Action::FocusLeft,
+            "focus_right" => Action::FocusRight,
             "window_switcher" => Action::WindowSwitcher,
             "move" => Action::Move,
             "resize" => Action::Resize,
diff --git a/src/bindings.rs b/src/bindings.rs
index 7fb7826..c348c1e 100644
--- a/src/bindings.rs
+++ b/src/bindings.rs
@@ -134,6 +134,10 @@ pub struct DefaultBinding {
 /// config loader). Applied with `add_default` after every configured source,
 /// so any of these chords can be rebound in `input.kdl`.
 pub const DEFAULT_BINDINGS: &[DefaultBinding] = &[
+    DefaultBinding { mods: mods::SUPER, key: "k", action: Action::FocusUp },
+    DefaultBinding { mods: mods::SUPER, key: "j", action: Action::FocusDown },
+    DefaultBinding { mods: mods::SUPER, key: "h", action: Action::FocusLeft },
+    DefaultBinding { mods: mods::SUPER, key: "l", action: Action::FocusRight },
     DefaultBinding { mods: mods::SUPER, key: "Left", action: Action::OverlayLeft },
     DefaultBinding { mods: mods::SUPER, key: "Right", action: Action::OverlayRight },
     DefaultBinding { mods: mods::SUPER | mods::CTRL, key: "Up", action: Action::PanUp },
@@ -176,7 +180,9 @@ mod tests {
         // Every variant's canonical name resolves back to the variant.
         for action in [
             Action::None, Action::Spawn, Action::Toggle, Action::Close,
-            Action::FocusNext, Action::FocusPrev, Action::WindowSwitcher,
+            Action::FocusNext, Action::FocusPrev, Action::FocusUp,
+            Action::FocusDown, Action::FocusLeft, Action::FocusRight,
+            Action::WindowSwitcher,
             Action::Move, Action::Resize, Action::Exit, Action::Reload,
             Action::Fullscreen, Action::LayoutNext, Action::ModeNext,
             Action::ModeNextShared, Action::View1, Action::View2,
diff --git a/src/focus.rs b/src/focus.rs
new file mode 100644
index 0000000..ccd87b2
--- /dev/null
+++ b/src/focus.rs
@@ -0,0 +1,139 @@
+// 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
+// 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.
+
+use super::api::Action;
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Direction {
+    Up,
+    Down,
+    Left,
+    Right,
+}
+
+impl Direction {
+    /// The direction a focus action moves in, `None` for non-directional
+    /// actions.
+    pub fn from_action(action: Action) -> Option<Direction> {
+        match action {
+            Action::FocusUp => Some(Direction::Up),
+            Action::FocusDown => Some(Direction::Down),
+            Action::FocusLeft => Some(Direction::Left),
+            Action::FocusRight => Some(Direction::Right),
+            _ => None,
+        }
+    }
+}
+
+/// Weight of off-axis distance in the candidate score: a window slightly
+/// ahead but far off to the side loses to one 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`).
+///
+/// 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() {
+        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,
+        };
+        return centers
+            .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() {
+        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 {
+            continue;
+        }
+        let score = primary + ORTHOGONAL_PENALTY * orthogonal.abs();
+        if best.is_none_or(|(_, s)| score < s) {
+            best = Some((i, score));
+        }
+    }
+    best.map(|(i, _)| i)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    // 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)];
+
+    #[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));
+    }
+
+    #[test]
+    fn no_candidate_means_no_move() {
+        // 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(&[], 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));
+    }
+
+    #[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
+    }
+
+    #[test]
+    fn direction_from_action() {
+        assert_eq!(Direction::from_action(Action::FocusUp), Some(Direction::Up));
+        assert_eq!(Direction::from_action(Action::FocusDown), Some(Direction::Down));
+        assert_eq!(Direction::from_action(Action::FocusLeft), Some(Direction::Left));
+        assert_eq!(Direction::from_action(Action::FocusRight), Some(Direction::Right));
+        assert_eq!(Direction::from_action(Action::FocusNext), None);
+    }
+}
diff --git a/src/lib.rs b/src/lib.rs
index 150c188..0c74fad 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -15,6 +15,8 @@
 //   - `bindings`: keybinding vocabulary — action names, chord grammar,
 //     `BindingTable`, stock defaults. The compositor feeds it plain data
 //     parsed from `input.kdl`; keysym name→code lookup stays mechanism-side.
+//   - `focus`: directional focus selection (which window is "up/left/…"
+//     of the focused one) over virtual-surface center points.
 //   - `tiling`: `TilingMode` and pure layout formulas.
 //   - `snap`: magnetic grid snapping for interactive move/resize.
 //   - `state`: persisted session state (serialization/matching only; the
@@ -25,6 +27,7 @@
 pub mod api;
 pub mod arrange;
 pub mod bindings;
+pub mod focus;
 pub mod slotmap;
 pub mod snap;
 pub mod state;