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