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

commit2c1010add3dd54f036b254016e225a93bd866346
parent7e8ef468c3
authorLucas Galante <[email protected]>
date2026-08-12 15:03
feat: Tiled/Floating window modes, overview displacement, expose→overview

- TilingMode: retire Cascade/Grid (serde-aliased to Floating), rename
  Maximized→Tiled (alias kept, old state.json loads); a window is Tiled
  when every content edge lies on a visible desktop-grid cell edge, and
  it then reports the xdg maximized state to its client. New
  snap::is_cell_aligned is the geometric test; maximized_span→tiled_span.
  The Tiled arrange arm derives its span from CURRENT geometry (so tiled
  windows move cell-to-cell); the save/restore machine (was_tiled /
  saved_floating_*) now only serves client (un)maximize requests.
- New overview module: displace() relocates windows a drag covers past a
  50% overlap threshold to the side the drag vacated (opposite the
  dominant drag-delta axis), snapping tiled candidates back onto the
  grid. Consumed by the mechanism on every motion event of an overview
  move. Single-level on purpose.
- Rename expose→overview: Action::Expose→Overview with canonical name
  "overview" ("expose"/"toggle_overview" still parse),
  expose_eligible→overview_eligible, EXPOSE_* camera consts→OVERVIEW_*.
- Retire Action::LayoutNext and SavedState.global_layout (unknown JSON
  fields are ignored, old files still load).

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

 CLAUDE.md       |  26 ++++---
 Cargo.toml      |   3 +
 src/actions.rs  |  38 +++++------
 src/api.rs      |  17 ++---
 src/arrange.rs  | 203 ++++++++++++++++++++++++++-----------------------------
 src/bindings.rs |   7 +-
 src/camera.rs   |  14 ++--
 src/lib.rs      |   5 +-
 src/overview.rs | 205 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 src/snap.rs     |  48 +++++++++++--
 src/state.rs    |   1 -
 src/tiling.rs   | 123 +++++++++-------------------------
 12 files changed, 436 insertions(+), 254 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 0d15d20..687a9f1 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -37,7 +37,7 @@ Commit here, not at the workspace root. The crate must **build standalone** —
 
 ```sh
 cargo build                      # standalone build (fast; no compositor deps)
-cargo test                       # run all tests (38 unit tests, all in-crate)
+cargo test                       # run all tests (~109 unit tests, all in-crate)
 cargo test snap::                # tests in one module
 cargo test -p cce-window-manager # same, from the workspace root
 ```
@@ -88,7 +88,7 @@ Vec<Command>` decides against a mechanism-built `ActionCtx` snapshot, and
 `Compositor::apply(cmd)` (implemented by the compositor's `WindowManager`)
 executes one command at a time. `actions.rs` holds `DefaultPolicy`, the live
 `Policy` impl: the camera actions (keyed zoom, cell-aligned pans, View jumps,
-SetViewport sends, Expose both directions) are decided there; an **empty
+SetViewport sends, Overview both directions) are decided there; an **empty
 command list means "not mine"** and the compositor falls through to its legacy
 arms. New flows grow snapshot methods here only alongside a real mechanism
 caller — no speculative signatures. Effects are declarative on purpose: new
@@ -98,8 +98,10 @@ scenefx capabilities extend `EffectSpec` without changing either trait.
 
 ### Grid snapping (`snap.rs`)
 
-Magnetic snapping math for interactive move/resize, plus the hard grid snap for
-`Maximized` windows. Conventions that everything here assumes:
+Magnetic snapping math for interactive move/resize, the hard grid snap for
+`Tiled` windows (`tiled_span`), and `is_cell_aligned` — the geometric test
+that decides whether a window IS tiled (every content edge on a visible cell
+edge). Conventions that everything here assumes:
 
 - Coordinates are **virtual-surface content coordinates**.
 - Snapping is **border-inclusive**: the border's *outer* edge lands on the snap
@@ -120,7 +122,7 @@ 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`, `toggle_overview`).
+  `input.kdl`, plus legacy aliases (`close`, `fullscreen`, `expose`, `toggle_overview`).
 - `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,
@@ -138,7 +140,7 @@ The crate owns what a binding *means*; the compositor owns the physical half
   `Layout::background_spec()` builds the `api::GridSpec`.
 - `camera.rs` — viewport pan/zoom math (`Camera` = pan_x/pan_y/zoom):
   `zoom_about_anchor` (wheel zoom at cursor, keyed zoom at viewport center),
-  `center_on`, `fit_bounds` (overview/Expose fit), `visible_fraction` +
+  `center_on`, `fit_bounds` (overview fit), `visible_fraction` +
   `FOCUS_VISIBLE_THRESHOLD` (focus-follow panning), `is_overview`. The
   mechanism owns the actual fields and animation; these are pure maps.
 - `focus.rs` — directional focus selection (`directional_focus` over window
@@ -148,9 +150,15 @@ The crate owns what a binding *means*; the compositor owns the physical half
 - `pan.rs` — cell-aligned viewport panning: `aligned_step` gives the keyed
   PanLeft/… actions their animation targets (pan offsets that are multiples
   of the grid period).
-- `tiling.rs` — `TilingMode` enum (serialized into saved state — renaming
-  variants breaks `state.json` compatibility) and the cascade/grid/fullscreen
-  tiling formulas.
+- `tiling.rs` — `TilingMode` enum: a window is `Floating` or `Tiled` (all
+  content edges on visible desktop-grid cell edges; tiled windows report the
+  xdg maximized state), plus `Fullscreen` and the internal `Popup` /
+  `Overlay` / `Status` roles. Serialized into saved state — serde aliases
+  map the retired names (`Cascade`/`Grid` → `Floating`, `Maximized` →
+  `Tiled`); keep aliases when renaming variants.
+- `overview.rs` — overview-mode move rules: `displace` relocates windows a
+  drag covers (past an overlap threshold) to the side the drag vacated,
+  called by the mechanism on every motion event of an overview move.
 - `state.rs` — `SavedState` / `SavedWindowState` serde types. New fields need
   `#[serde(default)]` to keep old state files loadable.
 - `slotmap.rs` — generational-index map (river-derived, 0BSD-licensed — keep the
diff --git a/Cargo.toml b/Cargo.toml
index 5e572a2..83b01da 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -10,3 +10,6 @@ path = "src/lib.rs"
 [dependencies]
 log = "0.4"
 serde = { version = "1", features = ["derive"] }
+
+[dev-dependencies]
+serde_json = "1"
diff --git a/src/actions.rs b/src/actions.rs
index 5aec9c8..360a73a 100644
--- a/src/actions.rs
+++ b/src/actions.rs
@@ -20,7 +20,7 @@ impl Policy for DefaultPolicy {
             Action::PanLeft | Action::PanRight | Action::PanUp | Action::PanDown => {
                 pan_step(ctx, action)
             }
-            Action::Expose => expose(ctx),
+            Action::Overview => toggle_overview(ctx),
             Action::Close => close(ctx),
             Action::Minimize => minimize(ctx),
             Action::FocusNext | Action::FocusPrev => focus_cycle(ctx, action),
@@ -100,7 +100,7 @@ fn pan_step(ctx: &ActionCtx, action: Action) -> Vec<Command> {
 /// (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.
-fn expose(ctx: &ActionCtx) -> Vec<Command> {
+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);
@@ -138,7 +138,7 @@ fn expose(ctx: &ActionCtx) -> Vec<Command> {
         ]
     } else {
         let mut bounds: Option<(f64, f64, f64, f64)> = None;
-        for w in ctx.windows.iter().filter(|w| w.expose_eligible) {
+        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((
@@ -151,7 +151,7 @@ fn expose(ctx: &ActionCtx) -> Vec<Command> {
         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 Expose must exit, not re-enter.
+        // smaller than the screen): the next Overview must exit, not re-enter.
         vec![
             Command::SetCamera { camera: cam, overview: Some(true), animate: true },
             Command::RefreshCamera,
@@ -274,14 +274,14 @@ fn focus_directional(ctx: &ActionCtx, action: Action) -> Vec<Command> {
 }
 
 /// Toggle fullscreen on the focused window. Leaving fullscreen unlocks the
-/// window back to its viewport-resolved mode (Cascade if that resolution is
+/// window back to its viewport-resolved mode (Floating if that resolution is
 /// itself Fullscreen).
 fn fullscreen(ctx: &ActionCtx) -> Vec<Command> {
     let Some(id) = ctx.focused else { return Vec::new() };
     let Some(win) = window(ctx, id) else { return Vec::new() };
     let cmd = if win.mode == TilingMode::Fullscreen {
         let target = if win.resolved_mode == TilingMode::Fullscreen {
-            TilingMode::Cascade
+            TilingMode::Floating
         } else {
             win.resolved_mode
         };
@@ -354,7 +354,7 @@ mod tests {
         WindowId(Key { generation: 0, index })
     }
 
-    /// A plain visible, cyclable, expose-eligible Floating window.
+    /// A plain visible, cyclable, overview-eligible Floating window.
     fn win(index: u32, x: f64, y: f64, w: f64, h: f64) -> ActionWindow {
         ActionWindow {
             id: wid(index),
@@ -370,7 +370,7 @@ mod tests {
             resolved_mode: TilingMode::Floating,
             visible: true,
             focus_cyclable: true,
-            expose_eligible: true,
+            overview_eligible: true,
         }
     }
 
@@ -516,16 +516,16 @@ mod tests {
         );
         // Exit: unlock back to the resolved mode.
         c.windows[0].mode = TilingMode::Fullscreen;
-        c.windows[0].resolved_mode = TilingMode::Grid;
+        c.windows[0].resolved_mode = TilingMode::Tiled;
         assert_eq!(
             dispatch(&c, Action::Fullscreen)[0],
-            Command::SetWindowMode { id: wid(1), mode: TilingMode::Grid, locked: false }
+            Command::SetWindowMode { id: wid(1), mode: TilingMode::Tiled, locked: false }
         );
-        // Exit when the viewport itself resolves Fullscreen: fall to Cascade.
+        // Exit when the viewport itself resolves Fullscreen: fall to Floating.
         c.windows[0].resolved_mode = TilingMode::Fullscreen;
         assert_eq!(
             dispatch(&c, Action::Fullscreen)[0],
-            Command::SetWindowMode { id: wid(1), mode: TilingMode::Cascade, locked: false }
+            Command::SetWindowMode { id: wid(1), mode: TilingMode::Floating, locked: false }
         );
     }
 
@@ -586,13 +586,13 @@ mod tests {
     }
 
     #[test]
-    fn expose_enter_fits_eligible_windows_only() {
+    fn overview_enter_fits_eligible_windows_only() {
         let mut c = ctx();
         c.windows.push(win(1, 0.0, 0.0, 400.0, 300.0));
         let mut ineligible = win(2, 5000.0, 0.0, 400.0, 300.0);
-        ineligible.expose_eligible = false;
+        ineligible.overview_eligible = false;
         c.windows.push(ineligible);
-        let cmds = dispatch(&c, Action::Expose);
+        let cmds = dispatch(&c, Action::Overview);
         let Command::SetCamera { camera, overview, .. } = cmds[0] else { panic!() };
         assert_eq!(overview, Some(true));
         // Only window 1 counts: 400x300 fits without zooming out.
@@ -600,17 +600,17 @@ mod tests {
         assert_eq!(cmds[1], Command::RefreshCamera);
         // No eligible windows: not claimed, nothing happens.
         c.windows.clear();
-        assert!(dispatch(&c, Action::Expose).is_empty());
+        assert!(dispatch(&c, Action::Overview).is_empty());
     }
 
     #[test]
-    fn expose_exit_prefers_the_hovered_window() {
+    fn overview_exit_prefers_the_hovered_window() {
         let mut c = ctx();
         c.overview = true;
         c.camera.zoom = 0.5;
         c.windows.push(win(3, 1000.0, 2000.0, 400.0, 300.0));
         c.hovered = Some(wid(3));
-        let cmds = dispatch(&c, Action::Expose);
+        let cmds = dispatch(&c, Action::Overview);
         assert_eq!(cmds[0], Command::Focus(wid(3)));
         assert_eq!(cmds[1], Command::StopPanAnimation);
         let Command::SetCamera { camera, overview, .. } = cmds[2] else { panic!() };
@@ -622,7 +622,7 @@ mod tests {
         // Without a hovered window, exit centers the point under the cursor.
         c.hovered = None;
         c.camera.pan_x = 100.0;
-        let Command::SetCamera { camera, .. } = dispatch(&c, Action::Expose)[1] else { panic!() };
+        let Command::SetCamera { camera, .. } = dispatch(&c, Action::Overview)[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);
     }
diff --git a/src/api.rs b/src/api.rs
index c4f0584..cb128c7 100644
--- a/src/api.rs
+++ b/src/api.rs
@@ -66,10 +66,9 @@ pub enum Action {
     Exit,
     Reload,
     Fullscreen,
-    LayoutNext,
     ModeNext,
     ModeNextShared,
-    Expose,
+    Overview,
     Minimize,
     OverlayLeft,
     OverlayRight,
@@ -111,10 +110,9 @@ impl Action {
             Action::Exit => "exit",
             Action::Reload => "reload",
             Action::Fullscreen => "toggle_fullscreen",
-            Action::LayoutNext => "layout_next",
             Action::ModeNext => "mode_next",
             Action::ModeNextShared => "mode_next_shared",
-            Action::Expose => "expose",
+            Action::Overview => "overview",
             Action::Minimize => "minimize",
             Action::OverlayLeft => "overlay_left",
             Action::OverlayRight => "overlay_right",
@@ -136,7 +134,7 @@ impl Action {
     }
 
     /// Inverse of `name()`, plus aliases from the old `config.kdl`
-    /// vocabulary (`close`, `fullscreen`, `toggle_overview`).
+    /// vocabulary (`close`, `fullscreen`, `expose`, `toggle_overview`).
     pub fn from_name(name: &str) -> Option<Action> {
         Some(match name.trim() {
             "none" => Action::None,
@@ -156,10 +154,9 @@ impl Action {
             "exit" => Action::Exit,
             "reload" => Action::Reload,
             "toggle_fullscreen" | "fullscreen" => Action::Fullscreen,
-            "layout_next" => Action::LayoutNext,
             "mode_next" => Action::ModeNext,
             "mode_next_shared" => Action::ModeNextShared,
-            "expose" | "toggle_overview" => Action::Expose,
+            "overview" | "expose" | "toggle_overview" => Action::Overview,
             "minimize" => Action::Minimize,
             "overlay_left" => Action::OverlayLeft,
             "overlay_right" => Action::OverlayRight,
@@ -277,7 +274,7 @@ impl GridFadeMode {
 #[derive(Debug, Clone)]
 pub struct ActionCtx {
     pub camera: Camera,
-    /// The mechanism is in overview mode. Set by fiat on Expose enter, so
+    /// The mechanism is in overview mode. Set by fiat on Overview enter, so
     /// this is NOT always `camera::is_overview(zoom)` — an overview fit can
     /// land at zoom 1.
     pub overview: bool,
@@ -289,7 +286,7 @@ pub struct ActionCtx {
     pub viewport_w: f64,
     pub viewport_h: f64,
     /// Output box under the cursor, falling back to the first enabled
-    /// output: the viewport Expose enters/exits in.
+    /// output: the viewport Overview enters/exits in.
     pub cursor_viewport: Rect,
     /// False when there is no seat; the cursor fields then hold zeros.
     pub has_cursor: bool,
@@ -332,7 +329,7 @@ pub struct ActionWindow {
     pub focus_cyclable: bool,
     /// Participates in the overview fit: mapped, not minimized, not
     /// status/background, not popup/overlay.
-    pub expose_eligible: bool,
+    pub overview_eligible: bool,
 }
 
 /// One mechanism write, returned by policy decisions and applied in order —
diff --git a/src/arrange.rs b/src/arrange.rs
index 21316b6..c28dabe 100644
--- a/src/arrange.rs
+++ b/src/arrange.rs
@@ -306,25 +306,28 @@ pub fn place_overlay_window(
     }
 }
 
-/// State-machine step for entering/leaving Maximized mode. `Enter` tells the
+/// State-machine step for entering/leaving Tiled mode. `Enter` tells the
 /// mechanism to save the given restore size (plus the window's current virtual
-/// position) and set `was_maximized`; `Exit` restores the saved geometry.
-/// Leaving with an invalid saved size does nothing (`was_maximized` stays set).
+/// position) and set `was_tiled`; `Exit` restores the saved geometry — the
+/// client-unmaximize path. (A geometric demotion — dragging a tiled window
+/// off-grid — clears `was_tiled` mechanism-side without an Exit, so the
+/// window stays where it was dropped.) Leaving with an invalid saved size
+/// does nothing (`was_tiled` stays set).
 #[derive(Debug, Clone, Copy, PartialEq)]
-pub enum MaximizedTransition {
+pub enum TiledTransition {
     Enter { width: i32, height: i32 },
     Exit { width: i32, height: i32, virtual_x: f64, virtual_y: f64 },
 }
 
-pub fn maximized_transition(
-    is_maximized_mode: bool,
-    was_maximized: bool,
+pub fn tiled_transition(
+    is_tiled_mode: bool,
+    was_tiled: bool,
     box_size: (i32, i32),
     min_size: (i32, i32),
     saved_size: (i32, i32),
     saved_virtual: (f64, f64),
-) -> Option<MaximizedTransition> {
-    if is_maximized_mode && !was_maximized {
+) -> Option<TiledTransition> {
+    if is_tiled_mode && !was_tiled {
         let mut w = box_size.0;
         let mut h = box_size.1;
         if w <= 0 {
@@ -333,10 +336,10 @@ pub fn maximized_transition(
         if h <= 0 {
             h = if min_size.1 > 32 { min_size.1 } else { 600 };
         }
-        Some(MaximizedTransition::Enter { width: w, height: h })
-    } else if !is_maximized_mode && was_maximized {
+        Some(TiledTransition::Enter { width: w, height: h })
+    } else if !is_tiled_mode && was_tiled {
         if saved_size.0 > 0 && saved_size.1 > 0 {
-            Some(MaximizedTransition::Exit {
+            Some(TiledTransition::Exit {
                 width: saved_size.0,
                 height: saved_size.1,
                 virtual_x: saved_virtual.0,
@@ -358,8 +361,6 @@ pub struct NormalSnapshot {
     /// Live interactive-resize dimensions, if a resize op is in progress.
     pub active_resize: Option<(u32, u32)>,
     pub is_cloud: bool,
-    pub saved_maximized_size: (i32, i32),
-    pub saved_maximized_virtual: (f64, f64),
 }
 
 pub struct NormalParams {
@@ -369,7 +370,7 @@ pub struct NormalParams {
     pub desktop_grid_scale: f64,
     /// Desktop grid gap between cells (the grid period is scale + gap).
     pub desktop_gap_width: f64,
-    /// Visual inset of a cell's edge (the fade inset); Maximized windows
+    /// Visual inset of a cell's edge (the fade inset); Tiled windows
     /// snap to the visible cell edges like interactive snapping does.
     pub desktop_cell_inset: f64,
 }
@@ -382,14 +383,14 @@ pub struct NormalPlacement {
     pub tiled_all_edges: bool,
     /// Offscreen-culling result; `None` leaves the window's flag untouched.
     pub hidden: Option<bool>,
-    /// Maximized grid-snap moves the window's virtual position.
+    /// Tiled grid-snap moves the window's virtual position.
     pub virtual_write: Option<(f64, f64)>,
 }
 
 /// Place a non-overlay window according to its tiling mode: `Popup` docks to
 /// the usable area's top-right (or the cloud default position), `Fullscreen`
-/// covers the physical output, `Maximized` snaps to cover every desktop-grid
-/// cell its saved geometry touches, and everything else pans on the virtual
+/// covers the physical output, `Tiled` snaps to cover every desktop-grid
+/// cell its current geometry touches, and everything else pans on the virtual
 /// surface under the current viewport.
 pub fn place_normal_window(
     snap: &NormalSnapshot,
@@ -440,25 +441,27 @@ pub fn place_normal_window(
             hidden: None,
             virtual_write: None,
         },
-        TilingMode::Maximized => {
-            // Cover the VISIBLE edges of every desktop-grid cell the saved
+        TilingMode::Tiled => {
+            // Cover the VISIBLE edges of every desktop-grid cell the current
             // geometry touches — the same cell-edge geometry as interactive
-            // grid snapping (snap::maximized_span).
-            let x1 = snap.saved_maximized_virtual.0;
-            let y1 = snap.saved_maximized_virtual.1;
-            let x2 = x1 + snap.saved_maximized_size.0 as f64;
-            let y2 = y1 + snap.saved_maximized_size.1 as f64;
-
-            let (low_x, high_x) = crate::snap::maximized_span(
+            // grid snapping (snap::tiled_span). A tiled window's geometry
+            // is already cell-aligned (that's what makes it tiled), so this
+            // is normally the identity; it aligns a client-requested maximize
+            // and absorbs drift.
+            let x1 = snap.virtual_pos.0;
+            let y1 = snap.virtual_pos.1;
+            let x2 = x1 + snap.box_geom.width.max(1) as f64;
+            let y2 = y1 + snap.box_geom.height.max(1) as f64;
+
+            let (low_x, high_x) = crate::snap::tiled_span(
                 x1, x2, p.desktop_grid_scale, p.desktop_gap_width, p.desktop_cell_inset,
             );
-            let (low_y, high_y) = crate::snap::maximized_span(
+            let (low_y, high_y) = crate::snap::tiled_span(
                 y1, y2, p.desktop_grid_scale, p.desktop_gap_width, p.desktop_cell_inset,
             );
 
             // The content fills the covered cells edge to edge; the border
-            // draws outside it and overhangs into the grid gap. Idempotent
-            // across frames because it re-derives from the saved geometry.
+            // draws outside it and overhangs into the grid gap.
             let content_x = low_x;
             let content_y = low_y;
             let fw = (high_x - low_x).max(1.0);
@@ -886,9 +889,9 @@ pub struct WindowSnapshot {
     /// Raw decoration measurement (`measure_decorations`), not gated on
     /// `ssd` — placement applies it only when the effective SSD is off.
     pub decorations_size: (i32, i32),
-    pub was_maximized: bool,
-    pub saved_maximized_size: (i32, i32),
-    pub saved_maximized_virtual: (f64, f64),
+    pub was_tiled: bool,
+    pub saved_floating_size: (i32, i32),
+    pub saved_floating_virtual: (f64, f64),
 }
 
 /// Frame-wide inputs: config knobs plus the desktop viewport.
@@ -940,9 +943,9 @@ pub struct WindowPlan {
     pub blur: Option<bool>,
     pub decoration: Option<DecorationSpec>,
     pub opacity: Option<f32>,
-    pub was_maximized: Option<bool>,
-    /// Maximized-enter save: (restore size, restore virtual position).
-    pub saved_maximized: Option<((i32, i32), (f64, f64))>,
+    pub was_tiled: Option<bool>,
+    /// Tiled-enter save: (restore size, restore virtual position).
+    pub saved_floating: Option<((i32, i32), (f64, f64))>,
 }
 
 pub struct ArrangePlan {
@@ -984,7 +987,7 @@ fn decoration_for(p: &ArrangeParams, is_focused: bool, mode: TilingMode) -> Deco
 /// The output loop is last-wins, like the mechanism loop it replaces: every
 /// output pass re-plans every window, so with several outputs the final plan
 /// reflects the last one. State that arranging itself evolves (box geometry,
-/// virtual position, SSD overrides, the maximized save/restore machine) is
+/// virtual position, SSD overrides, the tiled save/restore machine) is
 /// tracked on a working copy of the snapshots so later sections and later
 /// output passes read what earlier ones wrote — exactly as the mutating
 /// original did.
@@ -1112,44 +1115,44 @@ pub fn arrange(
             }
         }
 
-        // Manage entering/exiting Maximized state for normal windows.
+        // Manage entering/exiting Tiled state for normal windows.
         for &i in &normal_windows {
             let w = &state[i];
-            let transition = maximized_transition(
-                w.mode == TilingMode::Maximized,
-                w.was_maximized,
+            let transition = tiled_transition(
+                w.mode == TilingMode::Tiled,
+                w.was_tiled,
                 (w.box_geom.width, w.box_geom.height),
                 w.min_size,
-                w.saved_maximized_size,
-                w.saved_maximized_virtual,
+                w.saved_floating_size,
+                w.saved_floating_virtual,
             );
             match transition {
-                Some(MaximizedTransition::Enter { width, height }) => {
+                Some(TiledTransition::Enter { width, height }) => {
                     let w = &mut state[i];
-                    w.saved_maximized_size = (width, height);
-                    w.saved_maximized_virtual = w.virtual_pos;
-                    w.was_maximized = true;
+                    w.saved_floating_size = (width, height);
+                    w.saved_floating_virtual = w.virtual_pos;
+                    w.was_tiled = true;
                     let wp = &mut plan[i];
-                    wp.saved_maximized = Some(((width, height), w.saved_maximized_virtual));
-                    wp.was_maximized = Some(true);
-                    log::info!("[Maximized] Saved window {:?} geometry: {}x{} at ({}, {})",
+                    wp.saved_floating = Some(((width, height), w.saved_floating_virtual));
+                    wp.was_tiled = Some(true);
+                    log::info!("[Tiled] Saved window {:?} geometry: {}x{} at ({}, {})",
                         w.title.as_deref().unwrap_or(""),
                         width, height,
-                        w.saved_maximized_virtual.0, w.saved_maximized_virtual.1
+                        w.saved_floating_virtual.0, w.saved_floating_virtual.1
                     );
                 }
-                Some(MaximizedTransition::Exit { width, height, virtual_x, virtual_y }) => {
+                Some(TiledTransition::Exit { width, height, virtual_x, virtual_y }) => {
                     let w = &mut state[i];
                     w.box_geom.width = width;
                     w.box_geom.height = height;
                     w.virtual_pos = (virtual_x, virtual_y);
-                    w.was_maximized = false;
+                    w.was_tiled = false;
                     let wp = &mut plan[i];
                     wp.box_geom = Some(w.box_geom);
                     wp.virtual_pos = Some((virtual_x, virtual_y));
-                    wp.was_maximized = Some(false);
+                    wp.was_tiled = Some(false);
                     wp.size = Some((width as u32, height as u32));
-                    log::info!("[Maximized] Restored window {:?} geometry: {}x{} at ({}, {})",
+                    log::info!("[Tiled] Restored window {:?} geometry: {}x{} at ({}, {})",
                         w.title.as_deref().unwrap_or(""),
                         width, height, virtual_x, virtual_y
                     );
@@ -1170,8 +1173,6 @@ pub fn arrange(
                     virtual_pos: w.virtual_pos,
                     active_resize: w.active_resize,
                     is_cloud: is_cloud_app(w.app_id.as_deref()),
-                    saved_maximized_size: w.saved_maximized_size,
-                    saved_maximized_virtual: w.saved_maximized_virtual,
                 },
                 &p.normal,
                 &ctx,
@@ -1429,7 +1430,7 @@ mod tests {
         );
         // Minimized or closing/init normal windows are hidden.
         assert_eq!(
-            classify_window(WindowRole::Normal, true, false, TilingMode::Grid, false),
+            classify_window(WindowRole::Normal, true, false, TilingMode::Floating, false),
             WindowClass::Hidden
         );
         // Overlay mode gets the overlay slot — unless mid-drag.
@@ -1442,7 +1443,7 @@ mod tests {
             WindowClass::Normal
         );
         assert_eq!(
-            classify_window(WindowRole::Normal, false, false, TilingMode::Cascade, false),
+            classify_window(WindowRole::Normal, false, false, TilingMode::Tiled, false),
             WindowClass::Normal
         );
     }
@@ -1531,43 +1532,41 @@ mod tests {
     }
 
     #[test]
-    fn maximized_transitions() {
+    fn tiled_transitions() {
         // Entering with no usable geometry falls back to 800x600.
         assert_eq!(
-            maximized_transition(true, false, (0, 0), (0, 0), (0, 0), (0.0, 0.0)),
-            Some(MaximizedTransition::Enter { width: 800, height: 600 })
+            tiled_transition(true, false, (0, 0), (0, 0), (0, 0), (0.0, 0.0)),
+            Some(TiledTransition::Enter { width: 800, height: 600 })
         );
         // Entering keeps real geometry.
         assert_eq!(
-            maximized_transition(true, false, (640, 480), (0, 0), (0, 0), (0.0, 0.0)),
-            Some(MaximizedTransition::Enter { width: 640, height: 480 })
+            tiled_transition(true, false, (640, 480), (0, 0), (0, 0), (0.0, 0.0)),
+            Some(TiledTransition::Enter { width: 640, height: 480 })
         );
         // Steady states do nothing.
-        assert_eq!(maximized_transition(true, true, (640, 480), (0, 0), (640, 480), (0.0, 0.0)), None);
-        assert_eq!(maximized_transition(false, false, (640, 480), (0, 0), (0, 0), (0.0, 0.0)), None);
+        assert_eq!(tiled_transition(true, true, (640, 480), (0, 0), (640, 480), (0.0, 0.0)), None);
+        assert_eq!(tiled_transition(false, false, (640, 480), (0, 0), (0, 0), (0.0, 0.0)), None);
         // Exit restores the saved geometry; invalid saved size is a no-op.
         assert_eq!(
-            maximized_transition(false, true, (0, 0), (0, 0), (640, 480), (10.0, 20.0)),
-            Some(MaximizedTransition::Exit { width: 640, height: 480, virtual_x: 10.0, virtual_y: 20.0 })
+            tiled_transition(false, true, (0, 0), (0, 0), (640, 480), (10.0, 20.0)),
+            Some(TiledTransition::Exit { width: 640, height: 480, virtual_x: 10.0, virtual_y: 20.0 })
         );
-        assert_eq!(maximized_transition(false, true, (0, 0), (0, 0), (0, 480), (10.0, 20.0)), None);
+        assert_eq!(tiled_transition(false, true, (0, 0), (0, 0), (0, 480), (10.0, 20.0)), None);
     }
 
     #[test]
-    fn maximized_snaps_to_grid_cells() {
+    fn tiled_snaps_to_grid_cells() {
         let snap = NormalSnapshot {
-            mode: TilingMode::Maximized,
+            mode: TilingMode::Tiled,
             box_geom: Rect { x: 0, y: 0, width: 100, height: 50 },
             min_size: (0, 0),
             virtual_pos: (150.0, 120.0),
             active_resize: None,
             is_cloud: false,
-            saved_maximized_size: (100, 50),
-            saved_maximized_virtual: (150.0, 120.0),
         };
         let p = NormalParams { gap_right: 10, gap_top: 6, cloud_position_default: None, desktop_grid_scale: 100.0, desktop_gap_width: 0.0, desktop_cell_inset: 0.0 };
         let placement = place_normal_window(&snap, &p, &ctx());
-        // Saved geometry spans grid columns 1-2 and row 1 → snapped to
+        // Current geometry spans grid columns 1-2 and row 1 → snapped to
         // (100,100) with size 200x100.
         assert_eq!(placement.virtual_write, Some((100.0, 100.0)));
         assert_eq!(placement.pos, (100, 100));
@@ -1577,16 +1576,14 @@ mod tests {
     }
 
     #[test]
-    fn maximized_ignores_border_width() {
+    fn tiled_ignores_border_width() {
         let snap = NormalSnapshot {
-            mode: TilingMode::Maximized,
+            mode: TilingMode::Tiled,
             box_geom: Rect { x: 0, y: 0, width: 100, height: 50 },
             min_size: (0, 0),
             virtual_pos: (150.0, 120.0),
             active_resize: None,
             is_cloud: false,
-            saved_maximized_size: (100, 50),
-            saved_maximized_virtual: (150.0, 120.0),
         };
         let p = NormalParams { gap_right: 10, gap_top: 6, cloud_position_default: None, desktop_grid_scale: 100.0, desktop_gap_width: 0.0, desktop_cell_inset: 0.0 };
         let placement = place_normal_window(&snap, &p, &ctx());
@@ -1598,16 +1595,14 @@ mod tests {
     }
 
     #[test]
-    fn maximized_snaps_to_visible_cell_edges() {
+    fn tiled_snaps_to_visible_cell_edges() {
         let snap = NormalSnapshot {
-            mode: TilingMode::Maximized,
+            mode: TilingMode::Tiled,
             box_geom: Rect { x: 0, y: 0, width: 100, height: 50 },
             min_size: (0, 0),
             virtual_pos: (150.0, 120.0),
             active_resize: None,
             is_cloud: false,
-            saved_maximized_size: (100, 50),
-            saved_maximized_virtual: (150.0, 120.0),
         };
         // period 110 (gap 10), inset 5: cells x 1-2 visibly span [115, 315],
         // row y 1 spans [115, 205]; the content fills them edge to edge.
@@ -1634,8 +1629,6 @@ mod tests {
             virtual_pos: (0.0, 0.0),
             active_resize: None,
             is_cloud: false,
-            saved_maximized_size: (0, 0),
-            saved_maximized_virtual: (0.0, 0.0),
         };
         let p = NormalParams { gap_right: 10, gap_top: 6, cloud_position_default: None, desktop_grid_scale: 100.0, desktop_gap_width: 0.0, desktop_cell_inset: 0.0 };
         let placement = place_normal_window(&snap, &p, &ctx());
@@ -1658,8 +1651,6 @@ mod tests {
             virtual_pos: (100.0, 200.0),
             active_resize: None,
             is_cloud: false,
-            saved_maximized_size: (0, 0),
-            saved_maximized_virtual: (0.0, 0.0),
         };
         let p = NormalParams { gap_right: 10, gap_top: 6, cloud_position_default: None, desktop_grid_scale: 100.0, desktop_gap_width: 0.0, desktop_cell_inset: 0.0 };
         let placement = place_normal_window(&snap, &p, &ctx());
@@ -1677,23 +1668,21 @@ mod tests {
 
         // Non-floating modes keep the min-size fallback: their sizes are
         // dictated by tiling, not chosen by the client.
-        let tiled = NormalSnapshot { mode: TilingMode::Cascade, ..established };
-        let tiled = NormalSnapshot { box_geom: Rect { x: 0, y: 0, width: 0, height: 0 }, ..tiled };
-        let placement = place_normal_window(&tiled, &p, &ctx());
+        let other = NormalSnapshot { mode: TilingMode::Overlay, ..established };
+        let other = NormalSnapshot { box_geom: Rect { x: 0, y: 0, width: 0, height: 0 }, ..other };
+        let placement = place_normal_window(&other, &p, &ctx());
         assert_eq!(placement.size, (320, 240));
     }
 
     #[test]
     fn pannable_window_follows_viewport() {
         let snap = NormalSnapshot {
-            mode: TilingMode::Cascade,
+            mode: TilingMode::Overlay,
             box_geom: Rect { x: 0, y: 0, width: 640, height: 480 },
             min_size: (0, 0),
             virtual_pos: (100.0, 200.0),
             active_resize: None,
             is_cloud: false,
-            saved_maximized_size: (0, 0),
-            saved_maximized_virtual: (0.0, 0.0),
         };
         let p = NormalParams { gap_right: 10, gap_top: 6, cloud_position_default: None, desktop_grid_scale: 100.0, desktop_gap_width: 0.0, desktop_cell_inset: 0.0 };
         let mut c = ctx();
@@ -1745,9 +1734,9 @@ mod tests {
             active_resize: None,
             ssd: true,
             decorations_size: (0, 16),
-            was_maximized: false,
-            saved_maximized_size: (0, 0),
-            saved_maximized_virtual: (0.0, 0.0),
+            was_tiled: false,
+            saved_floating_size: (0, 0),
+            saved_floating_virtual: (0.0, 0.0),
         }
     }
 
@@ -1918,16 +1907,16 @@ mod tests {
     }
 
     #[test]
-    fn arrange_maximized_enter_saves_geometry() {
+    fn arrange_tiled_enter_saves_geometry() {
         let mut w = snap("firefox");
-        w.mode = TilingMode::Maximized;
+        w.mode = TilingMode::Tiled;
         w.box_geom = Rect { x: 0, y: 0, width: 150, height: 50 };
         w.virtual_pos = (150.0, 120.0);
 
         let plan = arrange(&[w], &one_output(), &arrange_params());
         let wp = &plan.windows[0];
-        assert_eq!(wp.was_maximized, Some(true));
-        assert_eq!(wp.saved_maximized, Some(((150, 50), (150.0, 120.0))));
+        assert_eq!(wp.was_tiled, Some(true));
+        assert_eq!(wp.saved_floating, Some(((150, 50), (150.0, 120.0))));
         // Grid snap: spans columns 1-2, row 1 of the 100px grid.
         assert_eq!(wp.virtual_pos, Some((100.0, 100.0)));
         assert_eq!(wp.pos, Some((100, 100)));
@@ -1935,16 +1924,16 @@ mod tests {
     }
 
     #[test]
-    fn arrange_maximized_exit_restores_saved_geometry() {
+    fn arrange_tiled_exit_restores_saved_geometry() {
         let mut w = snap("firefox");
         w.mode = TilingMode::Floating;
-        w.was_maximized = true;
-        w.saved_maximized_size = (500, 400);
-        w.saved_maximized_virtual = (10.0, 20.0);
+        w.was_tiled = true;
+        w.saved_floating_size = (500, 400);
+        w.saved_floating_virtual = (10.0, 20.0);
 
         let plan = arrange(&[w], &one_output(), &arrange_params());
         let wp = &plan.windows[0];
-        assert_eq!(wp.was_maximized, Some(false));
+        assert_eq!(wp.was_tiled, Some(false));
         assert_eq!(wp.box_geom, Some(Rect { x: 0, y: 0, width: 500, height: 400 }));
         // The restored geometry flows into the pannable placement.
         assert_eq!(wp.virtual_pos, Some((10.0, 20.0)));
@@ -1992,15 +1981,15 @@ mod tests {
         assert_eq!(plan.windows[0].pos, Some((1920, 0)));
         assert_eq!(plan.windows[0].size, Some((1280, 720)));
 
-        // Maximize state machine only fires once across passes: entering on
+        // Tiled state machine only fires once across passes: entering on
         // pass one must not re-enter (and re-save) on pass two.
         let mut w = snap("firefox");
-        w.mode = TilingMode::Maximized;
+        w.mode = TilingMode::Tiled;
         w.box_geom = Rect { x: 0, y: 0, width: 150, height: 50 };
         w.virtual_pos = (150.0, 120.0);
         let plan = arrange(&[w], &outputs, &arrange_params());
         // Saved from the original geometry, not the pass-one grid snap.
-        assert_eq!(plan.windows[0].saved_maximized, Some(((150, 50), (150.0, 120.0))));
+        assert_eq!(plan.windows[0].saved_floating, Some(((150, 50), (150.0, 120.0))));
     }
 
     #[test]
diff --git a/src/bindings.rs b/src/bindings.rs
index ff52e6a..8ed9dcc 100644
--- a/src/bindings.rs
+++ b/src/bindings.rs
@@ -197,9 +197,9 @@ mod tests {
             Action::FocusDown, Action::FocusLeft, Action::FocusRight,
             Action::WindowSwitcher, Action::WindowSwitcherPrev,
             Action::Move, Action::Resize, Action::Exit, Action::Reload,
-            Action::Fullscreen, Action::LayoutNext, Action::ModeNext,
+            Action::Fullscreen, Action::ModeNext,
             Action::ModeNextShared,
-            Action::Expose, Action::Minimize, Action::OverlayLeft,
+            Action::Overview, Action::Minimize, Action::OverlayLeft,
             Action::OverlayRight, Action::ZoomIn, Action::ZoomOut,
             Action::ZoomReset, Action::PanLeft, Action::PanRight,
             Action::PanUp, Action::PanDown, Action::VolumeUp,
@@ -211,7 +211,8 @@ mod tests {
         // Legacy aliases from the old config.kdl vocabulary.
         assert_eq!(Action::from_name("close"), Some(Action::Close));
         assert_eq!(Action::from_name("fullscreen"), Some(Action::Fullscreen));
-        assert_eq!(Action::from_name("toggle_overview"), Some(Action::Expose));
+        assert_eq!(Action::from_name("toggle_overview"), Some(Action::Overview));
+        assert_eq!(Action::from_name("expose"), Some(Action::Overview));
         assert_eq!(Action::from_name("no_such_action"), None);
     }
 
diff --git a/src/camera.rs b/src/camera.rs
index c8b9d8e..b57b843 100644
--- a/src/camera.rs
+++ b/src/camera.rs
@@ -1,5 +1,5 @@
 // Viewport camera policy: the pan/zoom math behind zoom actions, wheel
-// zoom, viewport jumps, overview (Expose) fit, and focus-follow panning.
+// zoom, viewport jumps, overview fit, and focus-follow panning.
 //
 // The desktop camera is (pan_x, pan_y, zoom): a virtual point v appears on
 // an output at `(v - pan) * zoom` output-local px, so the viewport shows the
@@ -183,11 +183,11 @@ pub fn nudge_into_view(
 }
 
 /// Margin kept around the fitted bounds when entering overview, output px.
-const EXPOSE_MARGIN: f64 = 100.0;
+const OVERVIEW_MARGIN: f64 = 100.0;
 /// The margin never shrinks the usable viewport below this, output px.
-const EXPOSE_MIN_AVAIL: f64 = 200.0;
+const OVERVIEW_MIN_AVAIL: f64 = 200.0;
 /// Overview fit only zooms OUT (cap 1.0), and never further than this.
-const EXPOSE_ZOOM_MIN: f64 = 0.05;
+const OVERVIEW_ZOOM_MIN: f64 = 0.05;
 
 /// Entering overview: fit the virtual bounding box [min_x, max_x] x
 /// [min_y, max_y] into the viewport with a margin, centered. Zoom is capped
@@ -202,12 +202,12 @@ pub fn fit_bounds(
 ) -> Camera {
     let box_w = max_x - min_x;
     let box_h = max_y - min_y;
-    let avail_w = (vw - 2.0 * EXPOSE_MARGIN).max(EXPOSE_MIN_AVAIL);
-    let avail_h = (vh - 2.0 * EXPOSE_MARGIN).max(EXPOSE_MIN_AVAIL);
+    let avail_w = (vw - 2.0 * OVERVIEW_MARGIN).max(OVERVIEW_MIN_AVAIL);
+    let avail_h = (vh - 2.0 * OVERVIEW_MARGIN).max(OVERVIEW_MIN_AVAIL);
     let zoom = (avail_w / box_w.max(1.0))
         .min(avail_h / box_h.max(1.0))
         .min(1.0)
-        .max(EXPOSE_ZOOM_MIN);
+        .max(OVERVIEW_ZOOM_MIN);
     center_on(min_x + box_w / 2.0, min_y + box_h / 2.0, vw, vh, zoom)
 }
 
diff --git a/src/lib.rs b/src/lib.rs
index 0bc4b6b..10fe52d 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -24,7 +24,9 @@
 //     centering, overview fit, focus-follow visibility.
 //   - `focus`: directional focus selection (which window is "up/left/…"
 //     of the focused one) over virtual-surface center points.
-//   - `tiling`: `TilingMode` and pure layout formulas.
+//   - `tiling`: `TilingMode` — `Tiled`/`Floating` plus the internal roles.
+//   - `overview`: overview-mode move rules — displacing windows a drag
+//     covers to the vacated side.
 //   - `pan`: cell-aligned viewport panning (the PanLeft/… step targets).
 //   - `query`: window-query resolution (ccectl focus-window etc.) — numeric
 //     id first, then app_id with exact-beats-substring.
@@ -41,6 +43,7 @@ pub mod background;
 pub mod bindings;
 pub mod camera;
 pub mod focus;
+pub mod overview;
 pub mod pan;
 pub mod query;
 pub mod ramp;
diff --git a/src/overview.rs b/src/overview.rs
new file mode 100644
index 0000000..b001257
--- /dev/null
+++ b/src/overview.rs
@@ -0,0 +1,205 @@
+// Overview-mode move rules: when a dragged window covers another window, the
+// covered window automatically relocates to the vacated side.
+//
+// The rule is swap-like: dragging the browser rightward onto the system
+// interface displaces the system interface to the browser's LEFT — the
+// covered window exits toward the side the drag vacated, i.e. opposite the
+// dominant axis/sign of the drag delta, abutting the dragged window with
+// the desktop-grid gap. Called by the mechanism on every motion event of an
+// overview move, so windows scoot out of the way live; it is naturally
+// convergent because a displaced window no longer overlaps the dragged one.
+//
+// Displacement is single-level on purpose: a displaced window may itself
+// land on a third window without cascading further. Coordinates are
+// virtual-surface content coordinates throughout.
+
+use crate::snap::{self, SnapParams};
+
+/// A window the dragged window may displace, as the mechanism snapshots it.
+#[derive(Debug, Clone, Copy)]
+pub struct DisplaceCandidate {
+    pub x: f64,
+    pub y: f64,
+    pub w: f64,
+    pub h: f64,
+    /// Tiled candidates get their displaced position snapped back onto the
+    /// grid so they stay tiled.
+    pub tiled: bool,
+}
+
+/// Covered fraction (of the smaller window) that triggers displacement.
+pub const DISPLACE_THRESHOLD: f64 = 0.5;
+
+fn overlap_1d(a0: f64, a1: f64, b0: f64, b1: f64) -> f64 {
+    (a1.min(b1) - a0.max(b0)).max(0.0)
+}
+
+/// Decide displacements for one motion step of an overview drag. `moved` is
+/// the dragged window's current content box (x, y, w, h); `drag_delta` is
+/// the cumulative virtual-space delta since the grab started. Returns
+/// `(candidate index, new position)` for every candidate the drag covers.
+///
+/// A candidate is covered when the overlap area exceeds
+/// [`DISPLACE_THRESHOLD`] of the smaller of the two windows. It exits toward
+/// the side the drag vacated — opposite the dominant axis/sign of
+/// `drag_delta` (a rightward drag sends it to the dragged window's left) —
+/// abutting the dragged window's content box with `gap` between them. With
+/// no meaningful drag delta it falls back to flipping the candidate across
+/// the dragged window along their center offset.
+pub fn displace(
+    moved: (f64, f64, f64, f64),
+    drag_delta: (f64, f64),
+    candidates: &[DisplaceCandidate],
+    p: &SnapParams,
+    gap: f64,
+) -> Vec<(usize, (f64, f64))> {
+    let (mx, my, mw, mh) = moved;
+    if mw <= 0.0 || mh <= 0.0 {
+        return Vec::new();
+    }
+    let mut out = Vec::new();
+    for (i, c) in candidates.iter().enumerate() {
+        if c.w <= 0.0 || c.h <= 0.0 {
+            continue;
+        }
+        let overlap = overlap_1d(mx, mx + mw, c.x, c.x + c.w)
+            * overlap_1d(my, my + mh, c.y, c.y + c.h);
+        let smaller = (mw * mh).min(c.w * c.h);
+        if overlap <= smaller * DISPLACE_THRESHOLD {
+            continue;
+        }
+
+        // Exit toward the vacated side: opposite the drag direction on its
+        // dominant axis. A grab with no travel yet (or a degenerate delta)
+        // falls back to flipping the candidate across the dragged window
+        // along their center offset.
+        let (dx, dy) = if drag_delta.0.abs() >= 1.0 || drag_delta.1.abs() >= 1.0 {
+            drag_delta
+        } else {
+            (
+                (c.x + c.w / 2.0) - (mx + mw / 2.0),
+                (c.y + c.h / 2.0) - (my + mh / 2.0),
+            )
+        };
+        let (mut nx, mut ny) = (c.x, c.y);
+        if dx.abs() >= dy.abs() {
+            nx = if dx >= 0.0 { mx - gap - c.w } else { mx + mw + gap };
+        } else {
+            ny = if dy >= 0.0 { my - gap - c.h } else { my + mh + gap };
+        }
+
+        // A tiled candidate stays tiled: snap the landing spot to the cell
+        // edges (its size is already cell-quantized, so a one-edge snap
+        // aligns the whole box). Threshold is widened to half a period so
+        // the abutting position always finds its cell.
+        if c.tiled {
+            let wide = SnapParams { threshold: p.cell_size * 0.45, ..*p };
+            let (sx, sy) = snap::snap_move(nx, ny, c.w, c.h, &wide);
+            nx = sx;
+            ny = sy;
+        }
+
+        if (nx - c.x).abs() > f64::EPSILON || (ny - c.y).abs() > f64::EPSILON {
+            out.push((i, (nx, ny)));
+        }
+    }
+    out
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn params() -> SnapParams {
+        // cell 512, no gap, fade inset 4: visible cell k spans
+        // [512k + 4, 512k + 508].
+        SnapParams { cell_size: 512.0, gap_width: 0.0, cell_inset: 4.0, threshold: 24.0 }
+    }
+
+    fn cand(x: f64, y: f64, w: f64, h: f64) -> DisplaceCandidate {
+        DisplaceCandidate { x, y, w, h, tiled: false }
+    }
+
+    #[test]
+    fn browser_dragged_right_displaces_neighbor_to_its_left() {
+        // Browser (800x600) starts left of the system interface (600x600)
+        // and is dragged rightward until it covers most of it.
+        let system_interface = cand(1000.0, 100.0, 600.0, 600.0);
+        // Browser now at x=900: overlap x [1000, 1600] = 600... fully
+        // covering the system interface horizontally is not needed; 60%
+        // coverage of the smaller window triggers.
+        let moved = (900.0, 100.0, 800.0, 600.0);
+        let d = displace(moved, (500.0, 0.0), &[system_interface], &params(), 16.0);
+        assert_eq!(d.len(), 1);
+        let (idx, (nx, ny)) = d[0];
+        assert_eq!(idx, 0);
+        // Rightward drag: the system interface exits to the browser's
+        // left, abutting with the gap: 900 - 16 - 600.
+        assert_eq!((nx, ny), (284.0, 100.0));
+    }
+
+    #[test]
+    fn approach_from_the_right_displaces_rightward() {
+        // Dragged window comes from the right; the covered window exits
+        // right — into the vacated space.
+        let covered = cand(1000.0, 100.0, 600.0, 600.0);
+        // Overlap x [1150, 1600] = 450 of 600 → 75% of the smaller window.
+        let moved = (1150.0, 100.0, 800.0, 600.0);
+        let d = displace(moved, (-450.0, 0.0), &[covered], &params(), 16.0);
+        assert_eq!(d.len(), 1);
+        // Leftward drag: the candidate goes to the moved window's RIGHT:
+        // 1150 + 800 + 16.
+        assert_eq!(d[0].1, (1966.0, 100.0));
+    }
+
+    #[test]
+    fn vertical_approach_displaces_vertically() {
+        let covered = cand(100.0, 800.0, 600.0, 500.0);
+        // Dragged from above, covering the top 60% of the candidate.
+        let moved = (100.0, 500.0, 600.0, 600.0);
+        let d = displace(moved, (0.0, 300.0), &[covered], &params(), 16.0);
+        assert_eq!(d.len(), 1);
+        // Downward drag: the candidate exits above, into the vacated space:
+        // y = 500 - 16 - 500.
+        assert_eq!(d[0].1, (100.0, -16.0));
+    }
+
+    #[test]
+    fn below_threshold_is_untouched() {
+        // 40% horizontal overlap of the smaller window: no displacement.
+        let covered = cand(1000.0, 100.0, 600.0, 600.0);
+        let moved = (640.0, 100.0, 600.0, 600.0); // overlap x = 240 → 40%
+        assert!(displace(moved, (300.0, 0.0), &[covered], &params(), 16.0).is_empty());
+    }
+
+    #[test]
+    fn tiled_candidate_lands_on_cell_edges() {
+        // A tiled candidate filling cell 2 exactly: visible content box
+        // [1028, 1532] → x=1028, w=504.
+        let covered = DisplaceCandidate { x: 1028.0, y: 4.0, w: 504.0, h: 504.0, tiled: true };
+        // Dragged window covers it, approaching from the left; its own box
+        // is NOT grid-aligned (mid-drag).
+        let moved = (700.0, 10.0, 700.0, 500.0);
+        let d = displace(moved, (400.0, 6.0), &[covered], &params(), 16.0);
+        assert_eq!(d.len(), 1);
+        // Raw exit spot 700 - 16 - 504 = 180 snaps onto cell 0's visible
+        // box: left edge 180 → 4 (within the widened threshold), y 10 → 4.
+        // The candidate stays cell-aligned.
+        assert_eq!(d[0].1, (4.0, 4.0));
+    }
+
+    #[test]
+    fn multiple_covered_windows_each_displace() {
+        let a = cand(1000.0, 100.0, 400.0, 400.0);
+        let b = cand(1000.0, 600.0, 400.0, 400.0);
+        // A tall dragged window covering both.
+        // Dragged rightward: both exit to the dragged window's left even
+        // though their centers are offset vertically from the mover's.
+        let moved = (900.0, 50.0, 600.0, 1000.0);
+        let d = displace(moved, (400.0, 0.0), &[a, b], &params(), 16.0);
+        assert_eq!(d.len(), 2);
+        // Both exit left — opposite the rightward drag.
+        assert_eq!(d[0], (0, (484.0, 100.0)));
+        assert_eq!(d[1], (1, (484.0, 600.0)));
+    }
+}
diff --git a/src/snap.rs b/src/snap.rs
index fc0587c..7820aa6 100644
--- a/src/snap.rs
+++ b/src/snap.rs
@@ -4,7 +4,7 @@
 // directly on them: the window's own edge lands on the snap target. Borders
 // draw OUTSIDE the content box, so a snapped border overhangs its cell into
 // the gap rather than being inset to stay within it. This matches the
-// Maximized grid-snap convention, where the content fills the covered cells
+// Tiled grid-snap convention, where the content fills the covered cells
 // edge to edge.
 //
 // Targets are the VISIBLE cell edges, not the raw grid lines. The desktop
@@ -22,10 +22,10 @@ fn grid_inset(cell_size: f64, cell_inset: f64) -> f64 {
     cell_inset.clamp(0.0, cell_size / 2.0 - 1.0)
 }
 
-/// Hard grid snap for Maximized windows: the visible outer edges of every
+/// Hard grid snap for Tiled windows: the visible outer edges of every
 /// cell the span [x1, x2) touches. Returns (low, high) — the content
 /// footprint, which fills the covered cells exactly.
-pub fn maximized_span(x1: f64, x2: f64, cell_size: f64, gap_width: f64, cell_inset: f64) -> (f64, f64) {
+pub fn tiled_span(x1: f64, x2: f64, cell_size: f64, gap_width: f64, cell_inset: f64) -> (f64, f64) {
     let p = grid_period(cell_size, gap_width);
     let inset = grid_inset(cell_size, cell_inset);
     let col_min = (x1 / p).floor();
@@ -91,6 +91,22 @@ fn within(delta: f64, p: &SnapParams) -> bool {
     delta.abs() <= p.threshold
 }
 
+/// True when every content edge of the box lies on a visible cell edge —
+/// the geometric definition of `TilingMode::Tiled`. Left/top edges must sit
+/// on a low target (`k*period + inset`), right/bottom edges on a high target
+/// (`k*period + cell_size - inset`), each within `eps`. Independent of the
+/// snap `threshold`: this classifies a resting geometry, it doesn't attract
+/// one.
+pub fn is_cell_aligned(x: f64, y: f64, w: f64, h: f64, p: &SnapParams, eps: f64) -> bool {
+    if p.cell_size <= 0.5 || w <= 0.0 || h <= 0.0 {
+        return false;
+    }
+    (p.nearest_low_target(x) - x).abs() <= eps
+        && (p.nearest_high_target(x + w) - (x + w)).abs() <= eps
+        && (p.nearest_low_target(y) - y).abs() <= eps
+        && (p.nearest_high_target(y + h) - (y + h)).abs() <= eps
+}
+
 /// Snap a window position during a move. On each axis the two content edges
 /// compete for their nearest visible cell edge; the closer candidate within
 /// the threshold wins. `w`/`h` are content sizes.
@@ -237,13 +253,13 @@ mod tests {
     }
 
     #[test]
-    fn maximized_span_covers_touched_visible_cells() {
+    fn tiled_span_covers_touched_visible_cells() {
         // period 100 (no gap), inset 0: legacy behavior — bare cell lines.
-        assert_eq!(maximized_span(150.0, 250.0, 100.0, 0.0, 0.0), (100.0, 300.0));
+        assert_eq!(tiled_span(150.0, 250.0, 100.0, 0.0, 0.0), (100.0, 300.0));
         // period 110 (gap 10), inset 5: cells 1-2 visibly span [115, 315].
-        assert_eq!(maximized_span(150.0, 250.0, 100.0, 10.0, 5.0), (115.0, 315.0));
+        assert_eq!(tiled_span(150.0, 250.0, 100.0, 10.0, 5.0), (115.0, 315.0));
         // Span ending exactly on a period boundary doesn't touch the next cell.
-        assert_eq!(maximized_span(150.0, 220.0, 100.0, 10.0, 5.0), (115.0, 205.0));
+        assert_eq!(tiled_span(150.0, 220.0, 100.0, 10.0, 5.0), (115.0, 205.0));
     }
 
     #[test]
@@ -262,6 +278,24 @@ mod tests {
         assert_eq!(p.threshold, 0.0);
     }
 
+    #[test]
+    fn cell_aligned_needs_all_four_edges() {
+        // cell 512, inset 4: cell 0 visibly spans [4, 508], cells 0-1 [4, 1020].
+        let p = params();
+        assert!(is_cell_aligned(4.0, 4.0, 504.0, 504.0, &p, 1.0));
+        // Two-cell-wide span.
+        assert!(is_cell_aligned(4.0, 4.0, 1016.0, 504.0, &p, 1.0));
+        // One edge off-grid fails.
+        assert!(!is_cell_aligned(10.0, 4.0, 504.0, 504.0, &p, 1.0)); // left off
+        assert!(!is_cell_aligned(4.0, 4.0, 500.0, 504.0, &p, 1.0)); // right off
+        assert!(!is_cell_aligned(4.0, 4.0, 504.0, 512.0, &p, 1.0)); // bottom off
+        // Alignment ignores the snap threshold.
+        let p = SnapParams { threshold: 0.0, ..params() };
+        assert!(is_cell_aligned(4.0, 4.0, 504.0, 504.0, &p, 1.0));
+        // Degenerate boxes are never tiled.
+        assert!(!is_cell_aligned(4.0, 4.0, 0.0, 504.0, &params(), 1.0));
+    }
+
     #[test]
     fn zero_threshold_disables() {
         let p = SnapParams { threshold: 0.0, ..params() };
diff --git a/src/state.rs b/src/state.rs
index 7f677cd..57dfc73 100644
--- a/src/state.rs
+++ b/src/state.rs
@@ -25,7 +25,6 @@ pub struct SavedState {
     pub desk_pan_x: f64,
     pub desk_pan_y: f64,
     pub desk_zoom: f64,
-    pub global_layout: TilingMode,
     pub windows: Vec<SavedWindowState>,
     #[serde(default)]
     pub last_window_states: Vec<SavedWindowState>,
diff --git a/src/tiling.rs b/src/tiling.rs
index 043e6f8..f2220df 100644
--- a/src/tiling.rs
+++ b/src/tiling.rs
@@ -1,113 +1,56 @@
-// Tiling formulas ported from cce-client
+// Window modes.
+//
+// A window is either `Floating` (positioned freely on the virtual surface) or
+// `Tiled` (every content edge lies on a visible desktop-grid cell edge). Tiled
+// windows report the xdg maximized state to their client. The remaining
+// variants are internal roles (`Popup`, `Overlay`, `Status`) or the orthogonal
+// `Fullscreen` toggle.
+//
+// Serde aliases keep old `state.json` files loading: the retired `Cascade` /
+// `Grid` layout modes collapse to `Floating`, and `Maximized` (the old name
+// for grid-locked windows) maps to `Tiled`.
 
 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
 pub enum TilingMode {
+    #[serde(alias = "Cascade", alias = "Grid")]
     Floating,
-    Cascade,
-    Grid,
+    #[serde(alias = "Maximized")]
+    Tiled,
     Fullscreen,
     Popup,
     Overlay,
     Status,
-    Maximized,
 }
 
 impl TilingMode {
     pub fn as_str(&self) -> &'static str {
         match self {
             TilingMode::Floating => "Floating",
-            TilingMode::Cascade => "Cascade",
-            TilingMode::Grid => "Grid",
+            TilingMode::Tiled => "Tiled",
             TilingMode::Fullscreen => "Fullscreen",
             TilingMode::Popup => "Popup",
             TilingMode::Overlay => "Overlay",
             TilingMode::Status => "Status",
-            TilingMode::Maximized => "Maximized",
         }
     }
 }
 
-/// Cascade depth factor: each depth step multiplies channels by this
-pub const CASCADE_DEPTH_FACTOR: f64 = 0.80;
-
-/// Full alpha, byte-replicated for River's color format
-pub const CASCADE_ALPHA: u32 = 0xFFFFFFFFu32;
-
-/// Tile a window in cascade mode.
-pub fn tile_cascade(
-    screen_w: i32,
-    screen_h: i32,
-    _gap: i32,
-    gap_top: i32,
-    gap_left: i32,
-    gap_right: i32,
-    gap_bottom: i32,
-    bw: i32,
-    dec_h: i32,
-    cascade_offset: i32,
-    bar_height: i32,
-    n_cascade: i32,
-    idx: i32,
-) -> (i32, i32, i32, i32) {
-    let max_offsets = 5;
-    let eff_cascade = n_cascade.min(max_offsets);
-    let width = screen_w - gap_left - gap_right - bw * 2 - cascade_offset * (eff_cascade - 1);
-    let height = screen_h - bar_height - gap_top - gap_bottom - (dec_h + bw) - cascade_offset * (eff_cascade - 1);
-    let width = if width < 1 { 1 } else { width };
-    let height = if height < 1 { 1 } else { height };
-    let pos_idx = idx.min(max_offsets - 1);
-    let x = gap_left + bw + pos_idx * cascade_offset;
-    let y = bar_height + gap_top + dec_h + pos_idx * cascade_offset;
-    (x, y, width, height)
-}
-
-/// Tile a window in grid mode.
-pub fn tile_grid(
-    screen_w: i32,
-    screen_h: i32,
-    gap: i32,
-    gap_top: i32,
-    gap_left: i32,
-    gap_right: i32,
-    gap_bottom: i32,
-    bw: i32,
-    dec_h: i32,
-    bar_height: i32,
-    n_grid: i32,
-    idx: i32,
-) -> (i32, i32, i32, i32) {
-    let cols = if n_grid == 1 { 1i32 } else { 2i32 };
-    let row = idx / cols;
-    let col = idx % cols;
-    let rows = (n_grid + cols - 1) / cols;
-    let width = (screen_w - gap_left - gap_right - (cols - 1) * gap) / cols - 2 * bw;
-    let height = (screen_h - bar_height - gap_top - gap_bottom - (rows - 1) * gap) / rows - (dec_h + bw);
-    let width = if width < 1 { 1 } else { width };
-    let height = if height < 1 { 1 } else { height };
-    let x = gap_left + bw + col * (width + 2 * bw + gap);
-    let y = bar_height + gap_top + dec_h + row * (height + (dec_h + bw) + gap);
-    (x, y, width, height)
-}
-
-/// Tile a window in fullscreen mode.
-pub fn tile_fullscreen(
-    screen_w: i32,
-    screen_h: i32,
-    _gap_top: i32,
-    _gap_left: i32,
-    _gap_right: i32,
-    _gap_bottom: i32,
-    _bw: i32,
-    _bar_height: i32,
-) -> (i32, i32, i32, i32) {
-    (0, 0, screen_w, screen_h)
-}
-
-/// Interpolate a byte-replicated 32-bit channel (0xVVVVVVVV) by factor^depth.
-pub fn interp_channel(fp_channel: u32, factor: f64, depth: i32) -> u32 {
-    let base = (fp_channel & 0xFF) as u8;
-    let f = factor.powi(depth);
-    let val = ((base as f64) * f) as u8;
-    val as u32 * 0x01010101
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn legacy_state_names_still_deserialize() {
+        // Old state.json files carry the retired mode names.
+        for (json, expected) in [
+            ("\"Cascade\"", TilingMode::Floating),
+            ("\"Grid\"", TilingMode::Floating),
+            ("\"Maximized\"", TilingMode::Tiled),
+            ("\"Floating\"", TilingMode::Floating),
+            ("\"Tiled\"", TilingMode::Tiled),
+        ] {
+            let mode: TilingMode = serde_json::from_str(json).unwrap();
+            assert_eq!(mode, expected, "{json}");
+        }
+    }
 }
-