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