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

commit08b260f7b58d2cb62be42e48d9fc9e2c775cbc8f
parentced0e022c1
authorLucas Galante <[email protected]>
date2026-08-28 12:54
feat: step a tiled window across the grid, swapping with its neighbour

Four actions — move_window_left/right/up/down — that move the focused
TILED window one grid cell, and swap positions when a tiled window already
holds the destination.

The step is one grid period, which is exactly what separates adjacent
cells, so a window that started cell-aligned lands cell-aligned and no
snapping is involved. Occupancy is a rect overlap against the destination
rather than a comparison of cell indices: the two agree, and the rect test
needs nothing from ActionCtx that is not already in it — no snapshot
change for this feature.

A swap exchanges ORIGINS, not boxes, so stepping onto a differently-shaped
neighbour stays a swap instead of becoming a resize.

Three declines, all returning no commands (a no-op, since the mechanism
has no legacy arm for these actions): a focused window that is not tiled,
since a floating window has no cell to step between; a degenerate grid;
and more than one tiled window in the destination, where "swap with it"
names no particular window and picking one would be a guess.

Verified live in a headless session as well as in unit tests, via the new
ccectl verbs: stepping into empty space moves exactly one period on both
axes, stepping onto a neighbour swaps and is its own inverse, and a
never-tiled window does not move.

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

 CLAUDE.md       |  11 ++++
 src/actions.rs  | 197 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 src/api.rs      |  12 ++++
 src/bindings.rs |   5 +-
 4 files changed, 224 insertions(+), 1 deletion(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 51fd8ac..7ba62d1 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -135,6 +135,17 @@ The crate owns what a binding *means*; the compositor owns the physical half
   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.
+- `move_window_left` / `_right` / `_up` / `_down` (`actions::move_tiled`) step
+  the focused **tiled** window one grid cell, swapping with the tiled window
+  already holding the destination. The step is one grid period, so an aligned
+  window stays aligned without snapping, and occupancy is a rect overlap
+  against the destination rather than a cell-index comparison — equivalent,
+  and it needs nothing added to `ActionCtx`. A swap exchanges origins, not
+  boxes, so each window keeps its size. Declines (no commands, hence a no-op)
+  when the focused window is not tiled, the grid is degenerate, or MORE than
+  one tiled window is in the destination — "swap with it" names no particular
+  window there. Unbound by default; reachable as `ccectl move-window-left`
+  etc., the relative counterpart to `ccectl move-window <square>`.
 - `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 0ab940e..ab0ddc0 100644
--- a/src/actions.rs
+++ b/src/actions.rs
@@ -38,6 +38,10 @@ impl Policy for DefaultPolicy {
             Action::FocusUp | Action::FocusDown | Action::FocusLeft | Action::FocusRight => {
                 focus_directional(ctx, action)
             }
+            Action::MoveWindowLeft
+            | Action::MoveWindowRight
+            | Action::MoveWindowUp
+            | Action::MoveWindowDown => move_tiled(ctx, action),
             Action::Fullscreen => fullscreen(ctx),
             Action::ModeNext => mode_next(ctx),
             Action::ModeNextShared => mode_next_shared(ctx),
@@ -209,6 +213,71 @@ fn enter_overview(ctx: &ActionCtx) -> Vec<Command> {
     ]
 }
 
+/// Do two virtual-space boxes share any area? Strictly — adjacent tiled
+/// windows merely touch (they are separated by the gap and two insets, and
+/// by nothing at all when both are zero), and touching is not occupying.
+fn boxes_overlap(ax: f64, ay: f64, aw: f64, ah: f64, b: &crate::api::ActionWindow) -> bool {
+    const EPS: f64 = 0.5;
+    ax < b.x + b.w - EPS && b.x < ax + aw - EPS && ay < b.y + b.h - EPS && b.y < ay + ah - EPS
+}
+
+/// Step the focused TILED window one grid cell, swapping with whatever tiled
+/// window already holds the destination.
+///
+/// The step is one grid period, which is what separates adjacent cells, so a
+/// window that started cell-aligned stays cell-aligned and no snapping is
+/// needed. Occupancy is decided by overlapping the DESTINATION box against
+/// the other tiled windows rather than by comparing cell indices: the two
+/// agree, and a rect test needs nothing from the snapshot that is not
+/// already there.
+///
+/// Three ways this declines, all returning no commands — which the mechanism
+/// treats as "not mine" and, having no legacy arm for these actions, turns
+/// into the no-op that is wanted:
+///   - nothing focused, or the focused window is not tiled (the feature is
+///     defined for the grid; a floating window has no cell to step between);
+///   - a degenerate grid, where a period is zero and the step goes nowhere;
+///   - MORE than one tiled window in the destination, where "swap positions
+///     with it" names no particular window. Better to refuse than to pick.
+fn move_tiled(ctx: &ActionCtx, action: Action) -> Vec<Command> {
+    let Some(win) = ctx.focused.and_then(|id| window(ctx, id)) else {
+        return Vec::new();
+    };
+    if win.resolved_mode != TilingMode::Tiled {
+        return Vec::new();
+    }
+    let (dx, dy) = match action {
+        Action::MoveWindowLeft => (-ctx.grid_period_x, 0.0),
+        Action::MoveWindowRight => (ctx.grid_period_x, 0.0),
+        Action::MoveWindowUp => (0.0, -ctx.grid_period_y),
+        _ => (0.0, ctx.grid_period_y),
+    };
+    if dx == 0.0 && dy == 0.0 {
+        return Vec::new();
+    }
+    let (nx, ny) = (win.x + dx, win.y + dy);
+
+    let mut occupants = ctx.windows.iter().filter(|w| {
+        w.id != win.id
+            && w.visible
+            && w.resolved_mode == TilingMode::Tiled
+            && boxes_overlap(nx, ny, win.w, win.h, w)
+    });
+    let Some(other) = occupants.next() else {
+        return vec![Command::MoveWindow { id: win.id, x: nx, y: ny }, Command::Relayout];
+    };
+    if occupants.next().is_some() {
+        return Vec::new();
+    }
+    // Swap origins, not boxes: each window keeps its own size, so a step onto
+    // a differently-shaped neighbour stays a swap rather than a resize.
+    vec![
+        Command::MoveWindow { id: win.id, x: other.x, y: other.y },
+        Command::MoveWindow { id: other.id, x: win.x, y: win.y },
+        Command::Relayout,
+    ]
+}
+
 /// The bare program name of a command line: first token, basename only.
 pub fn program_name(cmd: &str) -> String {
     let first_token = cmd.trim().split_whitespace().next().unwrap_or("");
@@ -757,6 +826,134 @@ mod tests {
         assert!(dispatch(&c, Action::OverviewEnter).is_empty());
     }
 
+    /// A ctx on a 100px grid period, so cell (col,row) sits at (col*100,
+    /// row*100) and the arithmetic in these tests reads directly.
+    fn grid_ctx() -> ActionCtx {
+        let mut c = ctx();
+        c.grid_period_x = 100.0;
+        c.grid_period_y = 100.0;
+        c
+    }
+
+    /// A tiled window one cell wide/high at cell (col,row) on `grid_ctx`.
+    fn tiled_at(index: u32, col: f64, row: f64) -> ActionWindow {
+        let mut w = win(index, col * 100.0, row * 100.0, 90.0, 90.0);
+        w.mode = TilingMode::Tiled;
+        w.resolved_mode = TilingMode::Tiled;
+        w
+    }
+
+    #[test]
+    fn a_tiled_window_steps_one_cell_into_empty_space() {
+        let mut c = grid_ctx();
+        c.windows.push(tiled_at(1, 2.0, 3.0));
+        c.focused = Some(wid(1));
+
+        for (action, x, y) in [
+            (Action::MoveWindowRight, 300.0, 300.0),
+            (Action::MoveWindowLeft, 100.0, 300.0),
+            (Action::MoveWindowDown, 200.0, 400.0),
+            (Action::MoveWindowUp, 200.0, 200.0),
+        ] {
+            let cmds = dispatch(&c, action);
+            assert_eq!(cmds[0], Command::MoveWindow { id: wid(1), x, y }, "{:?}", action);
+            assert_eq!(cmds[1], Command::Relayout);
+        }
+    }
+
+    #[test]
+    fn stepping_onto_a_tiled_neighbour_swaps_the_two() {
+        let mut c = grid_ctx();
+        c.windows.push(tiled_at(1, 2.0, 3.0));
+        c.windows.push(tiled_at(2, 3.0, 3.0)); // directly to the right
+        c.focused = Some(wid(1));
+
+        // Right: lands on window 2, so the two exchange origins.
+        let cmds = dispatch(&c, Action::MoveWindowRight);
+        assert_eq!(cmds[0], Command::MoveWindow { id: wid(1), x: 300.0, y: 300.0 });
+        assert_eq!(cmds[1], Command::MoveWindow { id: wid(2), x: 200.0, y: 300.0 });
+        assert_eq!(cmds[2], Command::Relayout);
+
+        // Left is still empty, so that direction is an ordinary move.
+        let cmds = dispatch(&c, Action::MoveWindowLeft);
+        assert_eq!(cmds.len(), 2);
+        assert_eq!(cmds[0], Command::MoveWindow { id: wid(1), x: 100.0, y: 300.0 });
+    }
+
+    #[test]
+    fn a_swap_keeps_each_window_its_own_size() {
+        let mut c = grid_ctx();
+        c.windows.push(tiled_at(1, 2.0, 3.0));
+        let mut wide = tiled_at(2, 3.0, 3.0);
+        wide.w = 190.0; // two cells wide
+        c.windows.push(wide);
+        c.focused = Some(wid(1));
+
+        // Only origins move; neither command carries a size.
+        let cmds = dispatch(&c, Action::MoveWindowRight);
+        assert_eq!(cmds[0], Command::MoveWindow { id: wid(1), x: 300.0, y: 300.0 });
+        assert_eq!(cmds[1], Command::MoveWindow { id: wid(2), x: 200.0, y: 300.0 });
+    }
+
+    #[test]
+    fn only_tiled_windows_step() {
+        let mut c = grid_ctx();
+        // Floating: the grid step is not defined for it.
+        c.windows.push(win(1, 200.0, 300.0, 90.0, 90.0));
+        c.focused = Some(wid(1));
+        assert!(dispatch(&c, Action::MoveWindowRight).is_empty());
+
+        // Nothing focused at all.
+        c.windows[0].mode = TilingMode::Tiled;
+        c.windows[0].resolved_mode = TilingMode::Tiled;
+        c.focused = None;
+        assert!(dispatch(&c, Action::MoveWindowRight).is_empty());
+    }
+
+    #[test]
+    fn a_floating_window_in_the_way_is_not_swapped_with() {
+        let mut c = grid_ctx();
+        c.windows.push(tiled_at(1, 2.0, 3.0));
+        c.windows.push(win(2, 300.0, 300.0, 90.0, 90.0)); // floating, in the destination
+        c.focused = Some(wid(1));
+        // The step happens anyway: only tiled windows hold cells.
+        let cmds = dispatch(&c, Action::MoveWindowRight);
+        assert_eq!(cmds.len(), 2);
+        assert_eq!(cmds[0], Command::MoveWindow { id: wid(1), x: 300.0, y: 300.0 });
+    }
+
+    #[test]
+    fn an_ambiguous_swap_is_declined() {
+        let mut c = grid_ctx();
+        // A two-cell-tall window stepping right onto TWO stacked neighbours:
+        // "swap with it" names neither, so nothing happens.
+        let mut tall = tiled_at(1, 2.0, 3.0);
+        tall.h = 190.0;
+        c.windows.push(tall);
+        c.windows.push(tiled_at(2, 3.0, 3.0));
+        c.windows.push(tiled_at(3, 3.0, 4.0));
+        c.focused = Some(wid(1));
+        assert!(dispatch(&c, Action::MoveWindowRight).is_empty());
+    }
+
+    #[test]
+    fn merely_touching_the_neighbour_is_not_occupying() {
+        // Zero gap and zero inset: cells abut exactly, so a window's right
+        // edge sits on its neighbour's left edge. Stepping AWAY from it must
+        // not read that shared edge as an overlap.
+        let mut c = grid_ctx();
+        let mut a = tiled_at(1, 2.0, 3.0);
+        a.w = 100.0;
+        let mut b = tiled_at(2, 3.0, 3.0);
+        b.w = 100.0;
+        c.windows.push(a);
+        c.windows.push(b);
+        c.focused = Some(wid(1));
+        let cmds = dispatch(&c, Action::MoveWindowLeft);
+        assert_eq!(cmds.len(), 2, "stepping left should be a plain move");
+        assert_eq!(cmds[0], Command::MoveWindow { id: wid(1), x: 100.0, y: 300.0 });
+    }
+
     #[test]
     fn media_keys_spawn_stock_or_overridden_command() {
         let c = ctx();
diff --git a/src/api.rs b/src/api.rs
index 470adbe..c9b8865 100644
--- a/src/api.rs
+++ b/src/api.rs
@@ -73,6 +73,10 @@ pub enum Action {
     WindowSwitcherPrev,
     Move,
     Resize,
+    MoveWindowLeft,
+    MoveWindowRight,
+    MoveWindowUp,
+    MoveWindowDown,
     Exit,
     Reload,
     Fullscreen,
@@ -119,6 +123,10 @@ impl Action {
             Action::WindowSwitcherPrev => "window_switcher_prev",
             Action::Move => "move",
             Action::Resize => "resize",
+            Action::MoveWindowLeft => "move_window_left",
+            Action::MoveWindowRight => "move_window_right",
+            Action::MoveWindowUp => "move_window_up",
+            Action::MoveWindowDown => "move_window_down",
             Action::Exit => "exit",
             Action::Reload => "reload",
             Action::Fullscreen => "toggle_fullscreen",
@@ -165,6 +173,10 @@ impl Action {
             "window_switcher_prev" => Action::WindowSwitcherPrev,
             "move" => Action::Move,
             "resize" => Action::Resize,
+            "move_window_left" => Action::MoveWindowLeft,
+            "move_window_right" => Action::MoveWindowRight,
+            "move_window_up" => Action::MoveWindowUp,
+            "move_window_down" => Action::MoveWindowDown,
             "exit" => Action::Exit,
             "reload" => Action::Reload,
             "toggle_fullscreen" | "fullscreen" => Action::Fullscreen,
diff --git a/src/bindings.rs b/src/bindings.rs
index 8941a89..b34093e 100644
--- a/src/bindings.rs
+++ b/src/bindings.rs
@@ -196,7 +196,10 @@ mod tests {
             Action::FocusNext, Action::FocusPrev, Action::FocusUp,
             Action::FocusDown, Action::FocusLeft, Action::FocusRight,
             Action::WindowSwitcher, Action::WindowSwitcherPrev,
-            Action::Move, Action::Resize, Action::Exit, Action::Reload,
+            Action::Move, Action::Resize,
+            Action::MoveWindowLeft, Action::MoveWindowRight,
+            Action::MoveWindowUp, Action::MoveWindowDown,
+            Action::Exit, Action::Reload,
             Action::Fullscreen, Action::ModeNext,
             Action::ModeNextShared,
             Action::Overview, Action::OverviewEnter, Action::OverviewExit,