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

commited664832e4f22d4af038e0b5014e64afcce0518e
parentcdcaffd3d6
authorLucas Galante <[email protected]>
date2026-08-28 08:13
feat: the keyed overview exit lands on the focused window

Exiting overview has always centered on whatever the pointer was hovering,
which is right when the pointer is what asked — a click, a swipe. A key
press carries no cursor position at all, so the pointer's resting place is
not evidence of anything, and super+i would drop you wherever the mouse
happened to be left.

Give Action::OverviewExit its own picker: the focused window if there is
one, otherwise the cursor-driven exit unchanged. The toggle keeps the old
behavior, since its callers (cursor.rs, the swipe handler) really are
pointer-driven.

The two exits now share the tail that centers on a window, so they can
only differ in the choice itself.

Co-Authored-By: Claude Opus 5 <[email protected]>

 CLAUDE.md      |  5 ++-
 src/actions.rs | 97 +++++++++++++++++++++++++++++++++++++++++++++++++---------
 2 files changed, 87 insertions(+), 15 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 5844a86..b8428bd 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -128,7 +128,10 @@ The crate owns what a binding *means*; the compositor owns the physical half
   halves, for users who want a key per direction. Asking for the mode you
   are already in returns no commands — a deliberate no-op, since the
   mechanism has no legacy arm for either action to fall through to. Like
-  the zoom chords, both ship unbound.
+  the zoom chords, both ship unbound. `overview_exit` lands on the FOCUSED
+  window, where the toggle lands on the hovered one: a key press carries no
+  cursor position, so the pointer's resting place is not evidence of where
+  the user meant to go.
 - `parse_chord("super+shift+h")` — strict chord grammar; the key stays an XKB
   keysym *name* (`Chord.key: String`) because name→code lookup needs xkbcommon.
 - `BindingTable` — insertion order is priority order (`resolve` = first match,
diff --git a/src/actions.rs b/src/actions.rs
index 783a6f7..0ab940e 100644
--- a/src/actions.rs
+++ b/src/actions.rs
@@ -30,7 +30,7 @@ impl Policy for DefaultPolicy {
                 if ctx.overview { Vec::new() } else { enter_overview(ctx) }
             }
             Action::OverviewExit => {
-                if ctx.overview { exit_overview(ctx) } else { Vec::new() }
+                if ctx.overview { exit_overview_keyed(ctx) } else { Vec::new() }
             }
             Action::Close => close(ctx),
             Action::Minimize => minimize(ctx),
@@ -121,8 +121,9 @@ fn toggle_overview(ctx: &ActionCtx) -> Vec<Command> {
 /// it) when there is one, else on the virtual point under the cursor — in
 /// the cursor's output.
 ///
-/// Callers that reach this through `Action::OverviewExit` have already
-/// checked `ctx.overview`; this assumes it.
+/// This is the cursor-driven exit: the toggle, and what the keyed exit falls
+/// back to when nothing is focused. Callers have already established that
+/// the mechanism is in overview; this assumes it.
 fn exit_overview(ctx: &ActionCtx) -> Vec<Command> {
     let out = ctx.cursor_viewport;
     let (ow, oh) = (out.width as f64, out.height as f64);
@@ -138,17 +139,8 @@ fn exit_overview(ctx: &ActionCtx) -> Vec<Command> {
             Command::RefreshCamera,
         ];
     }
-    if let Some(id) = ctx.hovered {
-        if let Some(win) = window(ctx, id) {
-            let cam =
-                camera::center_on(win.x + win.w / 2.0, win.y + win.h / 2.0, ow, oh, 1.0);
-            return vec![
-                Command::Focus(id),
-                Command::StopPanAnimation,
-                Command::SetCamera { camera: cam, overview: Some(false), animate: true },
-                Command::RefreshCamera,
-            ];
-        }
+    if let Some(win) = ctx.hovered.and_then(|id| window(ctx, id)) {
+        return exit_onto_window(ctx, win);
     }
     let vx = ctx.camera.pan_x + (ctx.cursor_x - out.x as f64) / ctx.camera.zoom;
     let vy = ctx.camera.pan_y + (ctx.cursor_y - out.y as f64) / ctx.camera.zoom;
@@ -160,6 +152,38 @@ fn exit_overview(ctx: &ActionCtx) -> Vec<Command> {
     ]
 }
 
+/// The keyed exit (`Action::OverviewExit`). A key press carries no cursor
+/// position, so the focused window — not whatever the pointer was left
+/// hovering — is what says where you meant to land. Falls back to the
+/// cursor-driven exit when nothing is focused.
+fn exit_overview_keyed(ctx: &ActionCtx) -> Vec<Command> {
+    match ctx.focused.and_then(|id| window(ctx, id)) {
+        Some(win) => exit_onto_window(ctx, win),
+        None => exit_overview(ctx),
+    }
+}
+
+/// Leave overview centered on one window at zoom 1 — the shared tail of both
+/// exits, which differ only in how they choose the window. Focusing it is
+/// redundant on the keyed path (it is already focused) and the point of the
+/// hovered one; it is idempotent either way.
+fn exit_onto_window(ctx: &ActionCtx, win: &crate::api::ActionWindow) -> Vec<Command> {
+    let out = ctx.cursor_viewport;
+    let cam = camera::center_on(
+        win.x + win.w / 2.0,
+        win.y + win.h / 2.0,
+        out.width as f64,
+        out.height as f64,
+        1.0,
+    );
+    vec![
+        Command::Focus(win.id),
+        Command::StopPanAnimation,
+        Command::SetCamera { camera: cam, overview: Some(false), animate: true },
+        Command::RefreshCamera,
+    ]
+}
+
 /// Enter overview, fitting the bounding box of all eligible windows into the
 /// viewport. An empty desktop yields no commands at all — see the note on
 /// `Action::OverviewEnter` about what the mechanism does with that.
@@ -679,6 +703,51 @@ mod tests {
         assert!(dispatch(&c, Action::OverviewEnter).is_empty());
     }
 
+    #[test]
+    fn the_keyed_exit_lands_on_the_focused_window_not_the_hovered_one() {
+        let mut c = ctx();
+        c.overview = true;
+        c.camera.zoom = 0.5;
+        c.windows.push(win(1, 1000.0, 2000.0, 400.0, 300.0)); // focused
+        c.windows.push(win(2, 5000.0, 6000.0, 400.0, 300.0)); // under the pointer
+        c.focused = Some(wid(1));
+        c.hovered = Some(wid(2));
+
+        // The pointer is over window 2, but a key press said nothing about
+        // the pointer: window 1 wins, and gets (re)focused.
+        let cmds = dispatch(&c, Action::OverviewExit);
+        assert_eq!(cmds[0], Command::Focus(wid(1)));
+        let Command::SetCamera { camera, overview, .. } = cmds[2] else { panic!() };
+        assert_eq!(overview, Some(false));
+        assert_eq!(camera.zoom, 1.0);
+        // Centered on window 1's center (1200, 2150).
+        assert_eq!(camera.pan_x, 1200.0 - 960.0);
+        assert_eq!(camera.pan_y, 2150.0 - 540.0);
+
+        // The toggle is the cursor-driven path and still prefers the hover.
+        assert_eq!(dispatch(&c, Action::Overview)[0], Command::Focus(wid(2)));
+    }
+
+    #[test]
+    fn the_keyed_exit_falls_back_to_the_cursor_with_nothing_focused() {
+        let mut c = ctx();
+        c.overview = true;
+        c.camera.zoom = 0.5;
+        c.windows.push(win(2, 5000.0, 6000.0, 400.0, 300.0));
+        c.focused = None;
+        c.hovered = Some(wid(2));
+        assert_eq!(dispatch(&c, Action::OverviewExit), dispatch(&c, Action::Overview));
+
+        // ...and with neither, onto the virtual point under the cursor.
+        c.hovered = None;
+        c.camera.pan_x = 100.0;
+        let Command::SetCamera { camera, .. } = dispatch(&c, Action::OverviewExit)[1] else {
+            panic!()
+        };
+        // Virtual point under (960, 540) at zoom 0.5: 100 + 960/0.5 = 2020.
+        assert_eq!(camera.pan_x, 2020.0 - 960.0);
+    }
+
     #[test]
     fn overview_enter_on_an_empty_desktop_is_not_claimed() {
         // Nothing to fit, so no camera to compute — same empty list the