window management library
git clone https://git.lucas.co/cce-window-manager.git
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;