window management library
git clone https://git.lucas.co/cce-window-manager.git
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], ¶ms(), 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], ¶ms(), 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], ¶ms(), 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], ¶ms(), 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], ¶ms(), 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], ¶ms(), 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, ¶ms(), 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}");
+ }
+ }
}
-