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

commitcdcaffd3d6a7837b782e396381fff6f3b92d5dfd
parent7e69f95135
authorLucas Galante <[email protected]>
date2026-08-27 15:31
feat: overview_enter / overview_exit — one key per direction

Action::Overview toggles, which means one chord and no way to say "put me
in overview" without first knowing whether you already are. Split the
toggle's two halves into their own functions and give each an action of
its own, so a user can bind a key per direction instead.

Asking for the mode you are already in returns no commands. That normally
means "not mine, fall through to the legacy arms" — here the mechanism
has no arm for either action, so the fall-through is the no-op we want.
Recorded in a comment at the dispatch site, because the convention makes
it look like an omission.

The chords themselves stay out of DEFAULT_BINDINGS, following the zoom
actions: user-preference chords belong in input.kdl, the crate only owns
the vocabulary.

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

 CLAUDE.md       |   5 ++
 src/actions.rs  | 166 ++++++++++++++++++++++++++++++++++++++------------------
 src/api.rs      |   6 ++
 src/bindings.rs |   3 +-
 4 files changed, 126 insertions(+), 54 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index a9a8160..5844a86 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -124,6 +124,11 @@ The crate owns what a binding *means*; the compositor owns the physical half
 - `Action::name()` / `Action::from_name()` (in `api.rs`) — the canonical
   snake_case action names users write in the `cce-window-manager` domain of
   `input.kdl`, plus legacy aliases (`close`, `fullscreen`, `expose`, `toggle_overview`).
+  `overview` toggles; `overview_enter` / `overview_exit` are its one-way
+  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.
 - `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 d504557..783a6f7 100644
--- a/src/actions.rs
+++ b/src/actions.rs
@@ -21,6 +21,17 @@ impl Policy for DefaultPolicy {
                 pan_step(ctx, action)
             }
             Action::Overview => toggle_overview(ctx),
+            // The one-way halves of Overview, for a key per direction
+            // instead of one key that toggles. Asking for the mode you are
+            // already in is a no-op: the empty list falls through to the
+            // mechanism's legacy match, which has no arm for either action,
+            // so nothing happens — which is the intent, not an oversight.
+            Action::OverviewEnter => {
+                if ctx.overview { Vec::new() } else { enter_overview(ctx) }
+            }
+            Action::OverviewExit => {
+                if ctx.overview { exit_overview(ctx) } else { Vec::new() }
+            }
             Action::Close => close(ctx),
             Action::Minimize => minimize(ctx),
             Action::FocusNext | Action::FocusPrev => focus_cycle(ctx, action),
@@ -96,67 +107,82 @@ fn pan_step(ctx: &ActionCtx, action: Action) -> Vec<Command> {
     vec![Command::PanTo { x, y }]
 }
 
-/// Toggle overview. Exit re-centers at zoom 1 — on the hovered window
-/// (focusing it) when there is one, else on the virtual point under the
-/// cursor — in the cursor's output. Enter fits the bounding box of all
-/// eligible windows into the first enabled output.
+/// Toggle overview: whichever of `enter_overview` / `exit_overview` the
+/// current mode calls for.
 fn toggle_overview(ctx: &ActionCtx) -> Vec<Command> {
     if ctx.overview {
-        let out = ctx.cursor_viewport;
-        let (ow, oh) = (out.width as f64, out.height as f64);
-        if !ctx.has_cursor {
-            // No seat: fall back to the origin at zoom 1.
+        exit_overview(ctx)
+    } else {
+        enter_overview(ctx)
+    }
+}
+
+/// Leave overview, re-centering at zoom 1 — on the hovered window (focusing
+/// 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.
+fn exit_overview(ctx: &ActionCtx) -> Vec<Command> {
+    let out = ctx.cursor_viewport;
+    let (ow, oh) = (out.width as f64, out.height as f64);
+    if !ctx.has_cursor {
+        // No seat: fall back to the origin at zoom 1.
+        return vec![
+            Command::StopPanAnimation,
+            Command::SetCamera {
+                camera: Camera { pan_x: 0.0, pan_y: 0.0, zoom: 1.0 },
+                overview: Some(false),
+                animate: true,
+            },
+            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: Camera { pan_x: 0.0, pan_y: 0.0, zoom: 1.0 },
-                    overview: Some(false),
-                    animate: true,
-                },
+                Command::SetCamera { camera: cam, overview: Some(false), animate: true },
                 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,
-                ];
-            }
-        }
-        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;
-        let cam = camera::center_on(vx, vy, ow, oh, 1.0);
-        vec![
-            Command::StopPanAnimation,
-            Command::SetCamera { camera: cam, overview: Some(false), animate: true },
-            Command::RefreshCamera,
-        ]
-    } else {
-        let mut bounds: Option<(f64, f64, f64, f64)> = None;
-        for w in ctx.windows.iter().filter(|w| w.overview_eligible) {
-            let (min_x, min_y, max_x, max_y) =
-                bounds.unwrap_or((f64::MAX, f64::MAX, f64::MIN, f64::MIN));
-            bounds = Some((
-                min_x.min(w.x),
-                min_y.min(w.y),
-                max_x.max(w.x + w.w),
-                max_y.max(w.y + w.h),
-            ));
-        }
-        let Some((min_x, min_y, max_x, max_y)) = bounds else { return Vec::new() };
-        let cam = camera::fit_bounds(min_x, min_y, max_x, max_y, ctx.viewport_w, ctx.viewport_h);
-        // Overview by fiat even when the fit lands at zoom 1 (a desktop
-        // smaller than the screen): the next Overview must exit, not re-enter.
-        vec![
-            Command::SetCamera { camera: cam, overview: Some(true), animate: true },
-            Command::RefreshCamera,
-        ]
     }
+    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;
+    let cam = camera::center_on(vx, vy, ow, oh, 1.0);
+    vec![
+        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.
+fn enter_overview(ctx: &ActionCtx) -> Vec<Command> {
+    let mut bounds: Option<(f64, f64, f64, f64)> = None;
+    for w in ctx.windows.iter().filter(|w| w.overview_eligible) {
+        let (min_x, min_y, max_x, max_y) =
+            bounds.unwrap_or((f64::MAX, f64::MAX, f64::MIN, f64::MIN));
+        bounds = Some((
+            min_x.min(w.x),
+            min_y.min(w.y),
+            max_x.max(w.x + w.w),
+            max_y.max(w.y + w.h),
+        ));
+    }
+    let Some((min_x, min_y, max_x, max_y)) = bounds else { return Vec::new() };
+    let cam = camera::fit_bounds(min_x, min_y, max_x, max_y, ctx.viewport_w, ctx.viewport_h);
+    // Overview by fiat even when the fit lands at zoom 1 (a desktop
+    // smaller than the screen): the next Overview must exit, not re-enter.
+    vec![
+        Command::SetCamera { camera: cam, overview: Some(true), animate: true },
+        Command::RefreshCamera,
+    ]
 }
 
 /// The bare program name of a command line: first token, basename only.
@@ -628,6 +654,40 @@ mod tests {
         assert_eq!(camera.pan_x, 2020.0 - 960.0);
     }
 
+    #[test]
+    fn one_way_overview_actions_only_fire_in_the_other_mode() {
+        let mut c = ctx();
+        c.windows.push(win(1, 0.0, 0.0, 400.0, 300.0));
+
+        // Normal mode: Enter does the same thing the toggle would, Exit is
+        // a no-op (you are already where it would take you).
+        assert_eq!(dispatch(&c, Action::OverviewEnter), dispatch(&c, Action::Overview));
+        let Command::SetCamera { overview, .. } = dispatch(&c, Action::OverviewEnter)[0] else {
+            panic!()
+        };
+        assert_eq!(overview, Some(true));
+        assert!(dispatch(&c, Action::OverviewExit).is_empty());
+
+        // Overview mode: exactly the reverse.
+        c.overview = true;
+        c.camera.zoom = 0.5;
+        assert_eq!(dispatch(&c, Action::OverviewExit), dispatch(&c, Action::Overview));
+        let Command::SetCamera { overview, .. } = dispatch(&c, Action::OverviewExit)[1] else {
+            panic!()
+        };
+        assert_eq!(overview, Some(false));
+        assert!(dispatch(&c, Action::OverviewEnter).is_empty());
+    }
+
+    #[test]
+    fn overview_enter_on_an_empty_desktop_is_not_claimed() {
+        // Nothing to fit, so no camera to compute — same empty list the
+        // toggle returns, and for the same reason.
+        let c = ctx();
+        assert!(c.windows.is_empty());
+        assert!(dispatch(&c, Action::OverviewEnter).is_empty());
+    }
+
     #[test]
     fn media_keys_spawn_stock_or_overridden_command() {
         let c = ctx();
diff --git a/src/api.rs b/src/api.rs
index 9ebd187..470adbe 100644
--- a/src/api.rs
+++ b/src/api.rs
@@ -79,6 +79,8 @@ pub enum Action {
     ModeNext,
     ModeNextShared,
     Overview,
+    OverviewEnter,
+    OverviewExit,
     Minimize,
     OverlayLeft,
     OverlayRight,
@@ -123,6 +125,8 @@ impl Action {
             Action::ModeNext => "mode_next",
             Action::ModeNextShared => "mode_next_shared",
             Action::Overview => "overview",
+            Action::OverviewEnter => "overview_enter",
+            Action::OverviewExit => "overview_exit",
             Action::Minimize => "minimize",
             Action::OverlayLeft => "overlay_left",
             Action::OverlayRight => "overlay_right",
@@ -167,6 +171,8 @@ impl Action {
             "mode_next" => Action::ModeNext,
             "mode_next_shared" => Action::ModeNextShared,
             "overview" | "expose" | "toggle_overview" => Action::Overview,
+            "overview_enter" => Action::OverviewEnter,
+            "overview_exit" => Action::OverviewExit,
             "minimize" => Action::Minimize,
             "overlay_left" => Action::OverlayLeft,
             "overlay_right" => Action::OverlayRight,
diff --git a/src/bindings.rs b/src/bindings.rs
index 8ed9dcc..8941a89 100644
--- a/src/bindings.rs
+++ b/src/bindings.rs
@@ -199,7 +199,8 @@ mod tests {
             Action::Move, Action::Resize, Action::Exit, Action::Reload,
             Action::Fullscreen, Action::ModeNext,
             Action::ModeNextShared,
-            Action::Overview, Action::Minimize, Action::OverlayLeft,
+            Action::Overview, Action::OverviewEnter, Action::OverviewExit,
+            Action::Minimize, Action::OverlayLeft,
             Action::OverlayRight, Action::ZoomIn, Action::ZoomOut,
             Action::ZoomReset, Action::PanLeft, Action::PanRight,
             Action::PanUp, Action::PanDown, Action::VolumeUp,