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

commit81dde083438bb7a8d272ba8178e9ee97d40ba7fe
parent6075bd8c13
authorLucas Galante <[email protected]>
date2026-08-21 18:49
feat: independent desktop cell width and height

The desktop grid cell is no longer forced square. Every module that
consumed one cell size now carries both axes:

- snap: SnapParams gains cell_w/cell_h; the per-axis target math moved
  to AxisSnapParams (SnapParams::x()/y()), which snap_low_edge /
  snap_high_edge / resize_axis now take — callers name the axis they
  drag. for_zoom caps the widened threshold at 45% of the SMALLER
  dimension.
- cells: square_rect / block_rect / window_span / window_span_label
  take cell_w + cell_h; columns and rows carry independent periods.
- background: GridFrame splits period_px / period_px_exact / cell_px
  per axis; density fade and the fade-inset cap key off the smaller
  dimension.
- api: GridSpec cell_w/cell_h; ActionCtx grid_period_x/grid_period_y —
  keyed pans step one column period horizontally, one row period
  vertically.
- arrange: NormalParams desktop_cell_w/desktop_cell_h; the Tiled arm
  spans columns by width and rows by height.

tiled_span itself was already per-axis by argument and is unchanged.

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

 src/actions.rs    |   7 ++-
 src/api.rs        |  12 +++-
 src/arrange.rs    |  28 +++++----
 src/background.rs | 177 ++++++++++++++++++++++++++++++++++--------------------
 src/cells.rs      |  74 ++++++++++++++---------
 src/overview.rs   |   4 +-
 src/snap.rs       | 144 +++++++++++++++++++++++++++++++-------------
 7 files changed, 291 insertions(+), 155 deletions(-)

diff --git a/src/actions.rs b/src/actions.rs
index 360a73a..d504557 100644
--- a/src/actions.rs
+++ b/src/actions.rs
@@ -87,11 +87,11 @@ fn pan_step(ctx: &ActionCtx, action: Action) -> Vec<Command> {
     let mut y = None;
     if dx != 0.0 {
         let base = ctx.pan_target_x.unwrap_or(ctx.camera.pan_x);
-        x = Some(pan::aligned_step(base, ctx.grid_period, dx));
+        x = Some(pan::aligned_step(base, ctx.grid_period_x, dx));
     }
     if dy != 0.0 {
         let base = ctx.pan_target_y.unwrap_or(ctx.camera.pan_y);
-        y = Some(pan::aligned_step(base, ctx.grid_period, dy));
+        y = Some(pan::aligned_step(base, ctx.grid_period_y, dy));
     }
     vec![Command::PanTo { x, y }]
 }
@@ -388,7 +388,8 @@ mod tests {
             cursor_y: 540.0,
             hovered: None,
             focused: None,
-            grid_period: 512.0,
+            grid_period_x: 512.0,
+            grid_period_y: 512.0,
             windows: Vec::new(),
         }
     }
diff --git a/src/api.rs b/src/api.rs
index 21096fb..9ebd187 100644
--- a/src/api.rs
+++ b/src/api.rs
@@ -254,7 +254,10 @@ pub struct GridSpec {
     /// Backdrop color behind and between the cells (premultiplied).
     pub gap_color: Rgba,
     pub cell_color: Rgba,
-    pub cell_size: f64,
+    /// Cell width (x axis); the column period is `cell_w + gap_width`.
+    pub cell_w: f64,
+    /// Cell height (y axis); the row period is `cell_h + gap_width`.
+    pub cell_h: f64,
     pub gap_width: f64,
     pub cell_corner_radius: i32,
     /// Cells fade inward by this many virtual px.
@@ -320,8 +323,11 @@ pub struct ActionCtx {
     /// Non-status, non-background window under the cursor.
     pub hovered: Option<WindowId>,
     pub focused: Option<WindowId>,
-    /// Desktop grid period (cell size + gap width) for cell-aligned panning.
-    pub grid_period: f64,
+    /// Desktop grid periods (cell size + gap width, per axis) for
+    /// cell-aligned panning: keyed horizontal pans step `grid_period_x`,
+    /// vertical pans `grid_period_y`.
+    pub grid_period_x: f64,
+    pub grid_period_y: f64,
     pub windows: Vec<ActionWindow>,
 }
 
diff --git a/src/arrange.rs b/src/arrange.rs
index 3da0cca..8c01564 100644
--- a/src/arrange.rs
+++ b/src/arrange.rs
@@ -386,8 +386,10 @@ pub struct NormalParams {
     pub gap_right: i32,
     pub gap_top: i32,
     pub cloud_position_default: Option<[i32; 2]>,
-    pub desktop_grid_scale: f64,
-    /// Desktop grid gap between cells (the grid period is scale + gap).
+    /// Desktop grid cell width/height (each axis's period is cell + gap).
+    pub desktop_cell_w: f64,
+    pub desktop_cell_h: f64,
+    /// Desktop grid gap between cells.
     pub desktop_gap_width: f64,
     /// Visual inset of a cell's edge (the fade inset); Tiled windows
     /// snap to the visible cell edges like interactive snapping does.
@@ -473,10 +475,10 @@ pub fn place_normal_window(
             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,
+                x1, x2, p.desktop_cell_w, p.desktop_gap_width, p.desktop_cell_inset,
             );
             let (low_y, high_y) = crate::snap::tiled_span(
-                y1, y2, p.desktop_grid_scale, p.desktop_gap_width, p.desktop_cell_inset,
+                y1, y2, p.desktop_cell_h, p.desktop_gap_width, p.desktop_cell_inset,
             );
 
             // The content fills the covered cells edge to edge; the border
@@ -1657,7 +1659,7 @@ mod tests {
             active_resize: None,
             is_cloud: false,
         };
-        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 p = NormalParams { gap_right: 10, gap_top: 6, cloud_position_default: None, desktop_cell_w: 100.0, desktop_cell_h: 100.0, desktop_gap_width: 0.0, desktop_cell_inset: 0.0 };
         let placement = place_normal_window(&snap, &p, &ctx());
         // Current geometry spans grid columns 1-2 and row 1 → snapped to
         // (100,100) with size 200x100.
@@ -1678,7 +1680,7 @@ mod tests {
             active_resize: None,
             is_cloud: false,
         };
-        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 p = NormalParams { gap_right: 10, gap_top: 6, cloud_position_default: None, desktop_cell_w: 100.0, desktop_cell_h: 100.0, desktop_gap_width: 0.0, desktop_cell_inset: 0.0 };
         let placement = place_normal_window(&snap, &p, &ctx());
         // The content fills the covered cells (100,100)+200x100 exactly; the
         // border draws outside that and overhangs into the grid gap.
@@ -1703,7 +1705,8 @@ mod tests {
             gap_right: 10,
             gap_top: 6,
             cloud_position_default: None,
-            desktop_grid_scale: 100.0,
+            desktop_cell_w: 100.0,
+            desktop_cell_h: 100.0,
             desktop_gap_width: 10.0,
             desktop_cell_inset: 5.0,
         };
@@ -1723,7 +1726,7 @@ mod tests {
             active_resize: None,
             is_cloud: false,
         };
-        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 p = NormalParams { gap_right: 10, gap_top: 6, cloud_position_default: None, desktop_cell_w: 100.0, desktop_cell_h: 100.0, desktop_gap_width: 0.0, desktop_cell_inset: 0.0 };
         let placement = place_normal_window(&snap, &p, &ctx());
         // Defaults to 360x100, docked inside the usable area (below the bar).
         assert_eq!(placement.pos, (1920 - 360 - 10, 30 + 6));
@@ -1745,7 +1748,7 @@ mod tests {
             active_resize: None,
             is_cloud: false,
         };
-        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 p = NormalParams { gap_right: 10, gap_top: 6, cloud_position_default: None, desktop_cell_w: 100.0, desktop_cell_h: 100.0, desktop_gap_width: 0.0, desktop_cell_inset: 0.0 };
         let placement = place_normal_window(&snap, &p, &ctx());
         assert_eq!(placement.size, (0, 0));
         assert_eq!(placement.hidden, None);
@@ -1773,7 +1776,7 @@ mod tests {
         // with an established box — so the compositor can never dictate a
         // size to it (a restored size, an output change). The established box
         // still drives the offscreen cull.
-        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 p = NormalParams { gap_right: 10, gap_top: 6, cloud_position_default: None, desktop_cell_w: 100.0, desktop_cell_h: 100.0, desktop_gap_width: 0.0, desktop_cell_inset: 0.0 };
         let fresh = NormalSnapshot {
             mode: TilingMode::Utility,
             box_geom: Rect { x: 0, y: 0, width: 0, height: 0 },
@@ -1806,7 +1809,7 @@ mod tests {
             active_resize: None,
             is_cloud: false,
         };
-        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 p = NormalParams { gap_right: 10, gap_top: 6, cloud_position_default: None, desktop_cell_w: 100.0, desktop_cell_h: 100.0, desktop_gap_width: 0.0, desktop_cell_inset: 0.0 };
         let mut c = ctx();
         c.pan_x = 50.0;
         c.pan_y = 100.0;
@@ -1890,7 +1893,8 @@ mod tests {
                 gap_right: 10,
                 gap_top: 6,
                 cloud_position_default: None,
-                desktop_grid_scale: 100.0,
+                desktop_cell_w: 100.0,
+            desktop_cell_h: 100.0,
                 desktop_gap_width: 0.0,
                 desktop_cell_inset: 0.0,
             },
diff --git a/src/background.rs b/src/background.rs
index 97dd710..6ede900 100644
--- a/src/background.rs
+++ b/src/background.rs
@@ -14,29 +14,34 @@ pub fn sanitized_zoom(zoom: f64) -> f64 {
     if zoom.is_nan() || zoom <= 0.0 { 1.0 } else { zoom }
 }
 
-/// One frame's grid drawing plan, in output-local px unless noted.
+/// One frame's grid drawing plan, in output-local px unless noted. The two
+/// axes carry independent periods (cell_w + gap and cell_h + gap).
 #[derive(Debug, Clone, PartialEq)]
 pub struct GridFrame {
     /// Grid-tree translation in layout px (includes the output's own
-    /// offset): the pan shift folded modulo one EXACT period, minus one
-    /// period so the tree always overhangs the top-left edge. None when the
-    /// zoomed period rounds to zero — the tree keeps its last position.
+    /// offset): the pan shift folded modulo one EXACT period per axis,
+    /// minus one period so the tree always overhangs the top-left edge.
+    /// None when either zoomed period rounds to zero — the tree keeps its
+    /// last position.
     pub tree_pos: Option<(i32, i32)>,
-    /// The zoomed grid period (cell + gap), rounded to px.
-    pub period_px: i32,
-    /// The exact (unrounded) zoomed period. Cell k must be placed at
-    /// `round(k * period_px_exact)` tree-local — NOT `k * period_px`: at
-    /// fractional zooms the rounded period drifts from the world-true cell
-    /// positions by its rounding error per period, so the grid slides
-    /// relative to the (world-anchored) windows as the camera pans, and
-    /// anything meant to hug a window edge (a client shadow, a snap)
-    /// visibly jitters against the grid.
-    pub period_px_exact: f64,
-    /// Backdrop (gap color) extent: viewport plus one period, so pan shifts
-    /// never expose the edge.
+    /// The zoomed grid periods (cell + gap per axis), rounded to px.
+    pub period_px_x: i32,
+    pub period_px_y: i32,
+    /// The exact (unrounded) zoomed periods. Cell (col, row) must be placed
+    /// at `(round(col * period_px_exact_x), round(row * period_px_exact_y))`
+    /// tree-local — NOT multiples of the rounded periods: at fractional
+    /// zooms the rounded period drifts from the world-true cell positions
+    /// by its rounding error per period, so the grid slides relative to the
+    /// (world-anchored) windows as the camera pans, and anything meant to
+    /// hug a window edge (a client shadow, a snap) visibly jitters against
+    /// the grid.
+    pub period_px_exact_x: f64,
+    pub period_px_exact_y: f64,
+    /// Backdrop (gap color) extent: viewport plus one period per axis, so
+    /// pan shifts never expose the edge.
     pub backdrop_w: i32,
     pub backdrop_h: i32,
-    /// None when the cells are fully density-faded, the period degenerates,
+    /// None when the cells are fully density-faded, a period degenerates,
     /// or the cell count exceeds the safety caps — backdrop only.
     pub cells: Option<GridCells>,
     /// World square indices of the cell drawn at tree-local (0, 0). The grid
@@ -47,18 +52,21 @@ pub struct GridFrame {
     pub first_row: i32,
 }
 
-/// The repeated cell lattice: draw a cell at (col * period_px,
-/// row * period_px) for col in 0..=cols, row in 0..=rows, tree-local.
+/// The repeated cell lattice: draw a cell at (round(col * period_px_exact_x),
+/// round(row * period_px_exact_y)) for col in 0..=cols, row in 0..=rows,
+/// tree-local.
 #[derive(Debug, Clone, PartialEq)]
 pub struct GridCells {
-    pub cell_px: i32,
+    pub cell_w_px: i32,
+    pub cell_h_px: i32,
     pub cols: i32,
     pub rows: i32,
     /// Cell color with the density fade already applied to alpha.
     pub color: Rgba,
     pub corner_radius_px: i32,
-    /// Zoom-scaled fade inset, capped at 45% of the cell so cells can't
-    /// blur out entirely when far zoomed out; 0 disables the fade.
+    /// Zoom-scaled fade inset, capped at 45% of the smaller cell dimension
+    /// so cells can't blur out entirely when far zoomed out; 0 disables the
+    /// fade.
     pub fade_inset_px: i32,
 }
 
@@ -71,21 +79,26 @@ pub fn grid_frame(
     output_y: i32,
 ) -> GridFrame {
     let zoom = sanitized_zoom(cam.zoom);
-    let cell_size = spec.cell_size.max(5.0);
+    let cell_w = spec.cell_w.max(5.0);
+    let cell_h = spec.cell_h.max(5.0);
     let gap = spec.gap_width.max(0.0);
-    let period = cell_size + gap;
+    let period_x = cell_w + gap;
+    let period_y = cell_h + gap;
 
-    // Fade the cells out as they shrink (period under 30 screen px) to
-    // prevent visual noise and pathological cell counts when zooming.
-    let period_pixels = period * zoom;
-    let density_fade = if period_pixels < 15.0 {
+    // Fade the cells out as they shrink (smaller period under 30 screen px)
+    // to prevent visual noise and pathological cell counts when zooming.
+    let period_pixels_x = period_x * zoom;
+    let period_pixels_y = period_y * zoom;
+    let min_period_pixels = period_pixels_x.min(period_pixels_y);
+    let density_fade = if min_period_pixels < 15.0 {
         0.0
-    } else if period_pixels < 30.0 {
-        (period_pixels - 15.0) / 15.0
+    } else if min_period_pixels < 30.0 {
+        (min_period_pixels - 15.0) / 15.0
     } else {
         1.0
     };
-    let period_px = period_pixels.round() as i32;
+    let period_px_x = period_pixels_x.round() as i32;
+    let period_px_y = period_pixels_y.round() as i32;
 
     // Modulo shift for infinite scrolling, in EXACT period units: the phase
     // is folded over the true zoomed period and only rounded once at the
@@ -93,30 +106,39 @@ pub fn grid_frame(
     // boundary at any pan. (Folding over the ROUNDED period accumulated its
     // rounding error into the phase and made the whole grid jump relative
     // to the windows whenever the fold wrapped.)
-    let tree_pos = if period_px > 0 {
-        let phase_x = ((-cam.pan_x) * zoom).rem_euclid(period_pixels);
-        let phase_y = ((-cam.pan_y) * zoom).rem_euclid(period_pixels);
+    let tree_pos = if period_px_x > 0 && period_px_y > 0 {
+        let phase_x = ((-cam.pan_x) * zoom).rem_euclid(period_pixels_x);
+        let phase_y = ((-cam.pan_y) * zoom).rem_euclid(period_pixels_y);
         Some((
-            output_x + (phase_x - period_pixels).round() as i32,
-            output_y + (phase_y - period_pixels).round() as i32,
+            output_x + (phase_x - period_pixels_x).round() as i32,
+            output_y + (phase_y - period_pixels_y).round() as i32,
         ))
     } else {
         None
     };
 
-    let cells = if period_px > 0 && density_fade > 0.0 {
-        let cols = (viewport_w as f64 / period_px as f64).ceil() as i32 + 1;
-        let rows = (viewport_h as f64 / period_px as f64).ceil() as i32 + 1;
-        let cell_px = (cell_size * zoom).round() as i32;
-        if cols > 0 && rows > 0 && cols <= 1000 && rows <= 1000 && cols * rows <= 20000 && cell_px > 0 {
+    let cells = if period_px_x > 0 && period_px_y > 0 && density_fade > 0.0 {
+        let cols = (viewport_w as f64 / period_px_x as f64).ceil() as i32 + 1;
+        let rows = (viewport_h as f64 / period_px_y as f64).ceil() as i32 + 1;
+        let cell_w_px = (cell_w * zoom).round() as i32;
+        let cell_h_px = (cell_h * zoom).round() as i32;
+        if cols > 0
+            && rows > 0
+            && cols <= 1000
+            && rows <= 1000
+            && cols * rows <= 20000
+            && cell_w_px > 0
+            && cell_h_px > 0
+        {
             let mut color = spec.cell_color;
             color.0[3] *= density_fade as f32;
-            let max_inset = (cell_px as f64 * 0.45).floor() as i32;
+            let max_inset = (cell_w_px.min(cell_h_px) as f64 * 0.45).floor() as i32;
             let fade_inset_px = ((spec.cell_fade_inset as f64 * zoom).round() as i32)
                 .min(max_inset)
                 .max(0);
             Some(GridCells {
-                cell_px,
+                cell_w_px,
+                cell_h_px,
                 cols,
                 rows,
                 color,
@@ -136,18 +158,20 @@ pub fn grid_frame(
     // so rounding here is exact in practice.
     let (first_col, first_row) = match tree_pos {
         Some((tx, ty)) => (
-            ((((tx - output_x) as f64) / zoom + cam.pan_x) / period).round() as i32,
-            ((((ty - output_y) as f64) / zoom + cam.pan_y) / period).round() as i32,
+            ((((tx - output_x) as f64) / zoom + cam.pan_x) / period_x).round() as i32,
+            ((((ty - output_y) as f64) / zoom + cam.pan_y) / period_y).round() as i32,
         ),
         None => (0, 0),
     };
 
     GridFrame {
         tree_pos,
-        period_px,
-        period_px_exact: period_pixels,
-        backdrop_w: viewport_w + period_px,
-        backdrop_h: viewport_h + period_px,
+        period_px_x,
+        period_px_y,
+        period_px_exact_x: period_pixels_x,
+        period_px_exact_y: period_pixels_y,
+        backdrop_w: viewport_w + period_px_x,
+        backdrop_h: viewport_h + period_px_y,
         cells,
         first_col,
         first_row,
@@ -165,7 +189,8 @@ mod tests {
     #[test]
     fn first_cell_indices_name_the_drawn_lattice() {
         let spec = GridSpec {
-            cell_size: 512.0,
+            cell_w: 512.0,
+            cell_h: 512.0,
             gap_width: 16.0,
             cell_fade_inset: 4,
             cell_corner_radius: 0,
@@ -173,7 +198,7 @@ mod tests {
             cell_color: Rgba([0.0, 0.0, 0.0, 1.0]),
             gap_color: Rgba([0.7, 0.8, 0.9, 1.0]),
         };
-        let period = spec.cell_size + spec.gap_width;
+        let period = spec.cell_w + spec.gap_width;
         // A few cameras, including the live desktop's overview zoom and a
         // deeply negative pan (where the modulo fold wraps).
         for &(pan_x, pan_y, zoom) in &[
@@ -187,13 +212,14 @@ mod tests {
             let (tx, ty) = f.tree_pos.expect("period is drawable here");
             for &(col, row) in &[(0, 0), (1, 2), (3, 1)] {
                 // Where the mechanism draws this cell.
-                let drawn_x = tx as f64 + (col as f64 * f.period_px_exact).round();
-                let drawn_y = ty as f64 + (row as f64 * f.period_px_exact).round();
+                let drawn_x = tx as f64 + (col as f64 * f.period_px_exact_x).round();
+                let drawn_y = ty as f64 + (row as f64 * f.period_px_exact_y).round();
                 // Where the square it is named after actually is.
                 let (wx, wy, _, _) = crate::cells::square_rect(
                     f.first_col + col,
                     f.first_row + row,
-                    spec.cell_size,
+                    spec.cell_w,
+                    spec.cell_h,
                     spec.gap_width,
                     0.0, // raw grid line: the lattice rect is not inset
                 );
@@ -208,7 +234,7 @@ mod tests {
                 );
             }
             // Sanity: the period is what we divided by.
-            assert!((f.period_px_exact - period * zoom).abs() < 1e-9);
+            assert!((f.period_px_exact_x - period * zoom).abs() < 1e-9);
         }
     }
 
@@ -219,7 +245,8 @@ mod tests {
         GridSpec {
             gap_color: Rgba([0.0, 0.0, 0.0, 1.0]),
             cell_color: Rgba([0.05, 0.05, 0.05, 0.6]),
-            cell_size: 100.0,
+            cell_w: 100.0,
+            cell_h: 100.0,
             gap_width: 10.0,
             cell_corner_radius: 8,
             cell_fade_inset: 4,
@@ -234,19 +261,38 @@ mod tests {
     #[test]
     fn unpanned_grid_overhangs_one_period() {
         let f = grid_frame(&spec(), cam(0.0, 0.0, 1.0), VW, VH, 100, 50);
-        assert_eq!(f.period_px, 110);
+        assert_eq!((f.period_px_x, f.period_px_y), (110, 110));
         assert_eq!(f.tree_pos, Some((100 - 110, 50 - 110)));
         assert_eq!((f.backdrop_w, f.backdrop_h), (VW + 110, VH + 110));
         let cells = f.cells.unwrap();
         // ceil(1920/110)+1 = 19, ceil(1080/110)+1 = 11.
         assert_eq!((cells.cols, cells.rows), (19, 11));
-        assert_eq!(cells.cell_px, 100);
+        assert_eq!((cells.cell_w_px, cells.cell_h_px), (100, 100));
         assert_eq!(cells.fade_inset_px, 4);
         assert_eq!(cells.corner_radius_px, 8);
         // Full density: color untouched.
         assert_eq!(cells.color, Rgba([0.05, 0.05, 0.05, 0.6]));
     }
 
+    #[test]
+    fn rectangular_cells_get_per_axis_periods_and_counts() {
+        // 100-wide, 50-tall cells, gap 10: periods 110 x 60.
+        let mut sp = spec();
+        sp.cell_h = 50.0;
+        let f = grid_frame(&sp, cam(0.0, 0.0, 1.0), VW, VH, 0, 0);
+        assert_eq!((f.period_px_x, f.period_px_y), (110, 60));
+        assert_eq!(f.tree_pos, Some((-110, -60)));
+        assert_eq!((f.backdrop_w, f.backdrop_h), (VW + 110, VH + 60));
+        let cells = f.cells.unwrap();
+        assert_eq!((cells.cell_w_px, cells.cell_h_px), (100, 50));
+        // ceil(1920/110)+1 = 19, ceil(1080/60)+1 = 19.
+        assert_eq!((cells.cols, cells.rows), (19, 19));
+        // The fade inset caps on the SMALLER dimension (45% of 50 = 22).
+        sp.cell_fade_inset = 60;
+        let f = grid_frame(&sp, cam(0.0, 0.0, 1.0), VW, VH, 0, 0);
+        assert_eq!(f.cells.unwrap().fade_inset_px, 22);
+    }
+
     #[test]
     fn pan_folds_modulo_one_period() {
         // Pan 30 right: origin -30, rem_euclid(110) = 80, minus a period.
@@ -266,7 +312,7 @@ mod tests {
         // Zoom 0.1: period 11 px — fully faded, backdrop only.
         let f = grid_frame(&spec(), cam(0.0, 0.0, 0.1), VW, VH, 0, 0);
         assert!(f.cells.is_none());
-        assert_eq!(f.period_px, 11);
+        assert_eq!(f.period_px_x, 11);
         assert!(f.tree_pos.is_some());
     }
 
@@ -285,12 +331,13 @@ mod tests {
         // spacing alternates 422/423 so cells never drift from the
         // world-anchored windows.
         let mut s = spec();
-        s.cell_size = 512.0;
+        s.cell_w = 512.0;
+        s.cell_h = 512.0;
         s.gap_width = 16.0;
         let f = grid_frame(&s, cam(0.0, 0.0, 0.8), 3840, 2400, 0, 0);
-        assert_eq!(f.period_px, 422);
-        assert!((f.period_px_exact - 422.4).abs() < 1e-9);
-        let pos: Vec<i32> = (0..5).map(|k| (k as f64 * f.period_px_exact).round() as i32).collect();
+        assert_eq!(f.period_px_x, 422);
+        assert!((f.period_px_exact_x - 422.4).abs() < 1e-9);
+        let pos: Vec<i32> = (0..5).map(|k| (k as f64 * f.period_px_exact_x).round() as i32).collect();
         assert_eq!(pos, vec![0, 422, 845, 1267, 1690]);
         // Tree phase folds over the EXACT period: pan 100 → phase
         // (-80).rem_euclid(422.4) = 342.4 → tree at round(342.4 - 422.4).
@@ -307,12 +354,12 @@ mod tests {
     fn degenerate_zoom_and_period_are_safe() {
         // NaN zoom falls back to 1.
         let f = grid_frame(&spec(), cam(0.0, 0.0, f64::NAN), VW, VH, 0, 0);
-        assert_eq!(f.period_px, 110);
+        assert_eq!(f.period_px_x, 110);
         assert!(f.cells.is_some());
         // A period that rounds to zero: no tree move, no cells, backdrop
         // stays viewport-sized.
         let f = grid_frame(&spec(), cam(0.0, 0.0, 0.001), VW, VH, 0, 0);
-        assert_eq!(f.period_px, 0);
+        assert_eq!(f.period_px_x, 0);
         assert!(f.tree_pos.is_none());
         assert!(f.cells.is_none());
         assert_eq!((f.backdrop_w, f.backdrop_h), (VW, VH));
diff --git a/src/cells.rs b/src/cells.rs
index 2f20490..9c2292b 100644
--- a/src/cells.rs
+++ b/src/cells.rs
@@ -2,9 +2,9 @@
 //
 // Coordinates in and out are the same virtual-surface CONTENT coordinates
 // snap.rs works in, and the grid geometry matches it exactly: cells of
-// `cell_size` every `cell_size + gap_width`, each cell fading inward by
-// `cell_inset`, so square k's content span is
-// [k*period + inset, k*period + cell_size - inset]. A window snapped to a
+// `cell_w` x `cell_h` every `cell + gap_width` on each axis, each cell
+// fading inward by `cell_inset`, so square k's content span on an axis is
+// [k*period + inset, k*period + cell - inset]. A window snapped to a
 // square therefore has its virtual position equal to that square's origin —
 // the two modules must agree or a "tiled" window would not land on a named
 // square.
@@ -116,11 +116,12 @@ pub fn parse_square(s: &str) -> Option<(i32, i32)> {
 pub fn square_rect(
     col: i32,
     row: i32,
-    cell_size: f64,
+    cell_w: f64,
+    cell_h: f64,
     gap_width: f64,
     cell_inset: f64,
 ) -> (f64, f64, f64, f64) {
-    block_rect(col, row, col, row, cell_size, gap_width, cell_inset)
+    block_rect(col, row, col, row, cell_w, cell_h, gap_width, cell_inset)
 }
 
 /// Content rect of a whole block of squares, inclusive of both corners.
@@ -130,18 +131,21 @@ pub fn block_rect(
     row0: i32,
     col1: i32,
     row1: i32,
-    cell_size: f64,
+    cell_w: f64,
+    cell_h: f64,
     gap_width: f64,
     cell_inset: f64,
 ) -> (f64, f64, f64, f64) {
-    let p = grid_period(cell_size, gap_width);
-    let inset = grid_inset(cell_size, cell_inset);
+    let px = grid_period(cell_w, gap_width);
+    let py = grid_period(cell_h, gap_width);
+    let inset_x = grid_inset(cell_w, cell_inset);
+    let inset_y = grid_inset(cell_h, cell_inset);
     let (cl, cr) = (col0.min(col1), col0.max(col1));
     let (rt, rb) = (row0.min(row1), row0.max(row1));
-    let x = cl as f64 * p + inset;
-    let y = rt as f64 * p + inset;
-    let w = (cr - cl) as f64 * p + cell_size - 2.0 * inset;
-    let h = (rb - rt) as f64 * p + cell_size - 2.0 * inset;
+    let x = cl as f64 * px + inset_x;
+    let y = rt as f64 * py + inset_y;
+    let w = (cr - cl) as f64 * px + cell_w - 2.0 * inset_x;
+    let h = (rb - rt) as f64 * py + cell_h - 2.0 * inset_y;
     (x, y, w, h)
 }
 
@@ -154,13 +158,14 @@ pub fn window_span(
     y: f64,
     w: f64,
     h: f64,
-    cell_size: f64,
+    cell_w: f64,
+    cell_h: f64,
     gap_width: f64,
 ) -> (i32, i32, i32, i32) {
-    let col0 = cell_index(x, cell_size, gap_width);
-    let row0 = cell_index(y, cell_size, gap_width);
-    let col1 = cell_index(x + w.max(1.0) - 1.0, cell_size, gap_width).max(col0);
-    let row1 = cell_index(y + h.max(1.0) - 1.0, cell_size, gap_width).max(row0);
+    let col0 = cell_index(x, cell_w, gap_width);
+    let row0 = cell_index(y, cell_h, gap_width);
+    let col1 = cell_index(x + w.max(1.0) - 1.0, cell_w, gap_width).max(col0);
+    let row1 = cell_index(y + h.max(1.0) - 1.0, cell_h, gap_width).max(row0);
     (col0, row0, col1, row1)
 }
 
@@ -183,10 +188,11 @@ pub fn window_span_label(
     y: f64,
     w: f64,
     h: f64,
-    cell_size: f64,
+    cell_w: f64,
+    cell_h: f64,
     gap_width: f64,
 ) -> String {
-    let (c0, r0, c1, r1) = window_span(x, y, w, h, cell_size, gap_width);
+    let (c0, r0, c1, r1) = window_span(x, y, w, h, cell_w, cell_h, gap_width);
     span_label(c0, r0, c1, r1)
 }
 
@@ -204,7 +210,7 @@ mod tests {
     fn origin_square_is_a1() {
         assert_eq!(square_label(0, 0), "A1");
         // Its content origin is the inset corner, not the raw grid line.
-        let (x, y, w, h) = square_rect(0, 0, CELL, GAP, INSET);
+        let (x, y, w, h) = square_rect(0, 0, CELL, CELL, GAP, INSET);
         assert_eq!((x, y), (4.0, 4.0));
         assert_eq!((w, h), (504.0, 504.0));
     }
@@ -234,7 +240,7 @@ mod tests {
         let row = cell_index(-4748.0, CELL, GAP);
         assert_eq!((col, row), (2, -9));
         assert_eq!(square_label(col, row), "C-9");
-        let (x, y, _, _) = square_rect(col, row, CELL, GAP, INSET);
+        let (x, y, _, _) = square_rect(col, row, CELL, CELL, GAP, INSET);
         assert_eq!((x, y), (1060.0, -4748.0));
     }
 
@@ -269,31 +275,41 @@ mod tests {
     #[test]
     fn window_span_covers_only_the_squares_it_fills() {
         // A window filling exactly one square claims one square.
-        let (x, y, w, h) = square_rect(2, -9, CELL, GAP, INSET);
-        assert_eq!(window_span(x, y, w, h, CELL, GAP), (2, -9, 2, -9));
-        assert_eq!(window_span_label(x, y, w, h, CELL, GAP), "C-9");
+        let (x, y, w, h) = square_rect(2, -9, CELL, CELL, GAP, INSET);
+        assert_eq!(window_span(x, y, w, h, CELL, CELL, GAP), (2, -9, 2, -9));
+        assert_eq!(window_span_label(x, y, w, h, CELL, CELL, GAP), "C-9");
 
         // A 2x1 block claims exactly two columns, not three.
-        let (x, y, w, h) = block_rect(2, -9, 3, -9, CELL, GAP, INSET);
-        assert_eq!(window_span(x, y, w, h, CELL, GAP), (2, -9, 3, -9));
-        assert_eq!(window_span_label(x, y, w, h, CELL, GAP), "C-9:D-9");
+        let (x, y, w, h) = block_rect(2, -9, 3, -9, CELL, CELL, GAP, INSET);
+        assert_eq!(window_span(x, y, w, h, CELL, CELL, GAP), (2, -9, 3, -9));
+        assert_eq!(window_span_label(x, y, w, h, CELL, CELL, GAP), "C-9:D-9");
     }
 
     #[test]
     fn block_rect_matches_the_tiled_snap_footprint() {
         // cells.rs and snap.rs must agree: a block's rect is what tiled_span
         // returns for a box spanning those cells.
-        let (x, y, w, h) = block_rect(2, -9, 3, -8, CELL, GAP, INSET);
+        let (x, y, w, h) = block_rect(2, -9, 3, -8, CELL, CELL, GAP, INSET);
         let (lo_x, hi_x) = crate::snap::tiled_span(x, x + w, CELL, GAP, INSET);
         let (lo_y, hi_y) = crate::snap::tiled_span(y, y + h, CELL, GAP, INSET);
         assert_eq!((lo_x, hi_x - lo_x), (x, w));
         assert_eq!((lo_y, hi_y - lo_y), (y, h));
     }
 
+    #[test]
+    fn rectangular_cells_use_per_axis_periods() {
+        // 512-wide, 256-tall cells: columns step 528, rows step 272.
+        let (x, y, w, h) = square_rect(1, 2, CELL, 256.0, GAP, INSET);
+        assert_eq!((x, y), (532.0, 548.0));
+        assert_eq!((w, h), (504.0, 248.0));
+        assert_eq!(window_span(x, y, w, h, CELL, 256.0, GAP), (1, 2, 1, 2));
+        assert_eq!(cell_index(547.0, 256.0, GAP), 2);
+    }
+
     #[test]
     fn degenerate_geometry_does_not_panic() {
         assert_eq!(cell_index(f64::NAN, CELL, GAP), 0);
         assert_eq!(cell_index(10.0, 0.0, 0.0), 0);
-        let _ = square_rect(0, 0, 0.0, 0.0, 0.0);
+        let _ = square_rect(0, 0, 0.0, 0.0, 0.0, 0.0);
     }
 }
diff --git a/src/overview.rs b/src/overview.rs
index 83d992f..9ebb930 100644
--- a/src/overview.rs
+++ b/src/overview.rs
@@ -116,7 +116,7 @@ mod tests {
     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 }
+        SnapParams { cell_w: 512.0, cell_h: 512.0, gap_width: 0.0, cell_inset: 4.0, threshold: 24.0 }
     }
 
     fn cand(x: f64, y: f64, w: f64, h: f64) -> DisplaceCandidate {
@@ -198,7 +198,7 @@ mod tests {
         // had a ~67px dead band around the midpoint: a landing spot ~256px
         // from the nearest edge stayed mid-cell, and the arrange pass then
         // grew the "tiled" window to every cell it touched.
-        let p = SnapParams { cell_size: 512.0, gap_width: 16.0, cell_inset: 4.0, threshold: 24.0 };
+        let p = SnapParams { cell_w: 512.0, cell_h: 512.0, gap_width: 16.0, cell_inset: 4.0, threshold: 24.0 };
         // Tiled candidate filling cell row 1 exactly: visible box
         // y [532, 1036] → y=532, h=504.
         let covered = DisplaceCandidate { x: 4.0, y: 532.0, w: 504.0, h: 504.0, tiled: true };
diff --git a/src/snap.rs b/src/snap.rs
index 8e7140d..7309f9e 100644
--- a/src/snap.rs
+++ b/src/snap.rs
@@ -38,9 +38,11 @@ pub fn tiled_span(x1: f64, x2: f64, cell_size: f64, gap_width: f64, cell_inset:
 
 #[derive(Debug, Clone, Copy)]
 pub struct SnapParams {
-    /// Desktop grid cell size in virtual units.
-    pub cell_size: f64,
-    /// Gap between cells; the grid period is `cell_size + gap_width`.
+    /// Desktop grid cell WIDTH in virtual units (the x-axis cell size).
+    pub cell_w: f64,
+    /// Desktop grid cell HEIGHT in virtual units (the y-axis cell size).
+    pub cell_h: f64,
+    /// Gap between cells; each axis's grid period is its cell size + gap.
     pub gap_width: f64,
     /// Visual inset of a cell's edge (the fade inset).
     pub cell_inset: f64,
@@ -48,20 +50,54 @@ pub struct SnapParams {
     pub threshold: f64,
 }
 
+/// One axis's view of the grid: the cell size along that axis plus the
+/// shared gap/inset/threshold. All the target math lives here; x/y code
+/// paths differ only in which cell size they carry.
+#[derive(Debug, Clone, Copy)]
+pub struct AxisSnapParams {
+    pub cell_size: f64,
+    pub gap_width: f64,
+    pub cell_inset: f64,
+    pub threshold: f64,
+}
+
 impl SnapParams {
+    /// The horizontal axis: columns of width `cell_w`.
+    pub fn x(&self) -> AxisSnapParams {
+        AxisSnapParams {
+            cell_size: self.cell_w,
+            gap_width: self.gap_width,
+            cell_inset: self.cell_inset,
+            threshold: self.threshold,
+        }
+    }
+
+    /// The vertical axis: rows of height `cell_h`.
+    pub fn y(&self) -> AxisSnapParams {
+        AxisSnapParams {
+            cell_size: self.cell_h,
+            gap_width: self.gap_width,
+            cell_inset: self.cell_inset,
+            threshold: self.threshold,
+        }
+    }
+
     /// Snapping is aimed in SCREEN space: the configured threshold is the
     /// grab distance at zoom 1, and zooming out must not shrink the felt
     /// target — so the virtual-space threshold grows by 1/zoom. Capped at
-    /// 45% of the cell so a deep zoom-out can't snap from half a cell away
-    /// (targets are one period apart; past the midpoint snapping would
-    /// thrash between neighbors).
+    /// 45% of the SMALLER cell dimension so a deep zoom-out can't snap from
+    /// half a cell away (targets are one period apart; past the midpoint
+    /// snapping would thrash between neighbors).
     pub fn for_zoom(mut self, zoom: f64) -> Self {
         if self.threshold > 0.0 && zoom > 0.0 && zoom.is_finite() {
-            self.threshold = (self.threshold / zoom).min(self.cell_size * 0.45);
+            self.threshold =
+                (self.threshold / zoom).min(self.cell_w.min(self.cell_h) * 0.45);
         }
         self
     }
+}
 
+impl AxisSnapParams {
     fn enabled(&self) -> bool {
         self.threshold > 0.0 && self.cell_size > 0.5
     }
@@ -90,7 +126,7 @@ impl SnapParams {
     }
 }
 
-fn within(delta: f64, p: &SnapParams) -> bool {
+fn within(delta: f64, p: &AxisSnapParams) -> bool {
     delta.abs() <= p.threshold
 }
 
@@ -101,23 +137,24 @@ fn within(delta: f64, p: &SnapParams) -> bool {
 /// 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 {
+    if p.cell_w <= 0.5 || p.cell_h <= 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
+    let (px, py) = (p.x(), p.y());
+    (px.nearest_low_target(x) - x).abs() <= eps
+        && (px.nearest_high_target(x + w) - (x + w)).abs() <= eps
+        && (py.nearest_low_target(y) - y).abs() <= eps
+        && (py.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.
 pub fn snap_move(x: f64, y: f64, w: f64, h: f64, p: &SnapParams) -> (f64, f64) {
-    (snap_move_axis(x, w, p), snap_move_axis(y, h, p))
+    (snap_move_axis(x, w, &p.x()), snap_move_axis(y, h, &p.y()))
 }
 
-fn snap_move_axis(pos: f64, len: f64, p: &SnapParams) -> f64 {
+fn snap_move_axis(pos: f64, len: f64, p: &AxisSnapParams) -> f64 {
     if !p.enabled() {
         return pos;
     }
@@ -135,8 +172,9 @@ fn snap_move_axis(pos: f64, len: f64, p: &SnapParams) -> f64 {
 }
 
 /// Snap the dragged left/top CONTENT edge during a resize onto the nearest
-/// visible left/top cell edge.
-pub fn snap_low_edge(pos: f64, p: &SnapParams) -> f64 {
+/// visible left/top cell edge. Takes the axis view: `p.x()` when dragging a
+/// left edge, `p.y()` for a top edge.
+pub fn snap_low_edge(pos: f64, p: &AxisSnapParams) -> f64 {
     if !p.enabled() {
         return pos;
     }
@@ -149,8 +187,9 @@ pub fn snap_low_edge(pos: f64, p: &SnapParams) -> f64 {
 }
 
 /// Snap the dragged right/bottom CONTENT edge during a resize onto the
-/// nearest visible right/bottom cell edge.
-pub fn snap_high_edge(pos: f64, p: &SnapParams) -> f64 {
+/// nearest visible right/bottom cell edge. Takes the axis view like
+/// [`snap_low_edge`].
+pub fn snap_high_edge(pos: f64, p: &AxisSnapParams) -> f64 {
     if !p.enabled() {
         return pos;
     }
@@ -177,18 +216,18 @@ pub fn snap_high_edge(pos: f64, p: &SnapParams) -> f64 {
 /// definition (that is what `tiled_span` renders), so disabling magnetic
 /// snapping must not strand it off-grid.
 pub fn snap_move_tiled(x: f64, y: f64, p: &SnapParams) -> (f64, f64) {
-    if p.cell_size <= 0.5 {
-        return (x, y);
-    }
-    (p.nearest_low_target(x), p.nearest_low_target(y))
+    let nx = if p.cell_w > 0.5 { p.x().nearest_low_target(x) } else { x };
+    let ny = if p.cell_h > 0.5 { p.y().nearest_low_target(y) } else { y };
+    (nx, ny)
 }
 
 /// One axis of an interactive resize: the dragged content edge (low =
 /// left/top, high = right/bottom) follows the pointer delta and snaps to the
 /// visible cell edges; the opposite edge stays anchored. Returns the new
-/// content length, at least `min_len`. The single source of this math —
-/// both the seat op and the arrange snapshot derive sizes from it, so the
-/// snapped result can't be overridden by an unsnapped recomputation.
+/// content length, at least `min_len`. Takes the axis view (`p.x()` for
+/// width, `p.y()` for height). The single source of this math — both the
+/// seat op and the arrange snapshot derive sizes from it, so the snapped
+/// result can't be overridden by an unsnapped recomputation.
 pub fn resize_axis(
     start_pos: f64,
     start_len: f64,
@@ -196,7 +235,7 @@ pub fn resize_axis(
     dragging_low: bool,
     dragging_high: bool,
     min_len: f64,
-    p: &SnapParams,
+    p: &AxisSnapParams,
 ) -> f64 {
     if dragging_low {
         let low = snap_low_edge(start_pos + delta, p);
@@ -213,34 +252,57 @@ pub fn resize_axis(
 mod tests {
     use super::*;
 
-    /// cell 512, no gap, fade inset 4: visible cell k spans
+    /// Square 512 cells, no gap, fade inset 4: visible cell k spans
     /// [512k + 4, 512k + 508]. Border width is irrelevant to snapping now —
     /// content edges land on the targets and the border overhangs outward.
     fn params() -> SnapParams {
-        SnapParams { cell_size: 512.0, gap_width: 0.0, cell_inset: 4.0, threshold: 24.0 }
+        SnapParams {
+            cell_w: 512.0,
+            cell_h: 512.0,
+            gap_width: 0.0,
+            cell_inset: 4.0,
+            threshold: 24.0,
+        }
     }
 
     #[test]
     fn resize_low_edge_abuts_visible_cell_edge() {
         // Content left 510 → visible edge 516 (dist 6) → content 516.
-        assert_eq!(snap_low_edge(510.0, &params()), 516.0);
+        assert_eq!(snap_low_edge(510.0, &params().x()), 516.0);
         // Far from an edge: unchanged.
-        assert_eq!(snap_low_edge(300.0, &params()), 300.0);
+        assert_eq!(snap_low_edge(300.0, &params().x()), 300.0);
     }
 
     #[test]
     fn resize_high_edge_abuts_visible_cell_edge() {
         // Content right 1000 → visible edge 1020 (2*512 - 4, dist 20) → 1020.
-        assert_eq!(snap_high_edge(1000.0, &params()), 1020.0);
+        assert_eq!(snap_high_edge(1000.0, &params().x()), 1020.0);
     }
 
     #[test]
     fn gap_width_shifts_the_period() {
         // cell 500 + gap 12 → period 512; cell 1's rect spans [512, 1012],
         // visibly [516, 1008].
-        let p = SnapParams { cell_size: 500.0, gap_width: 12.0, ..params() };
-        assert_eq!(snap_low_edge(520.0, &p), 516.0);
-        assert_eq!(snap_high_edge(996.0, &p), 1008.0);
+        let p = SnapParams { cell_w: 500.0, cell_h: 500.0, gap_width: 12.0, ..params() };
+        assert_eq!(snap_low_edge(520.0, &p.x()), 516.0);
+        assert_eq!(snap_high_edge(996.0, &p.x()), 1008.0);
+    }
+
+    #[test]
+    fn rectangular_cells_snap_each_axis_to_its_own_size() {
+        // 512-wide, 256-tall cells, no gap, inset 4: x targets every 512,
+        // y targets every 256 — row 1's visible top edge is 260.
+        let p = SnapParams { cell_h: 256.0, ..params() };
+        let (x, y) = snap_move(510.0, 250.0, 300.0, 100.0, &p);
+        assert_eq!((x, y), (516.0, 260.0));
+        // The hard tiled snap uses per-axis periods the same way.
+        assert_eq!(snap_move_tiled(300.0, 300.0, &p), (516.0, 260.0));
+        // A box filling one 504x248 visible cell is aligned, as is a
+        // two-row 504-tall box (2*256 - 8); a height off the row grid is
+        // not.
+        assert!(is_cell_aligned(4.0, 4.0, 504.0, 248.0, &p, 1.0));
+        assert!(is_cell_aligned(4.0, 4.0, 504.0, 504.0, &p, 1.0));
+        assert!(!is_cell_aligned(4.0, 4.0, 504.0, 400.0, &p, 1.0));
     }
 
     #[test]
@@ -267,13 +329,13 @@ mod tests {
     fn resize_axis_snaps_the_dragged_edge_only() {
         // Window [600, 900), dragging the left edge to 510: visible edge 516
         // → content 516; anchored right edge 900 keeps the width at 384.
-        assert_eq!(resize_axis(600.0, 300.0, -90.0, true, false, 50.0, &params()), 384.0);
+        assert_eq!(resize_axis(600.0, 300.0, -90.0, true, false, 50.0, &params().x()), 384.0);
         // Dragging the right edge to 1000: visible edge 1020 → width 420.
-        assert_eq!(resize_axis(600.0, 300.0, 100.0, false, true, 50.0, &params()), 420.0);
+        assert_eq!(resize_axis(600.0, 300.0, 100.0, false, true, 50.0, &params().x()), 420.0);
         // Not dragging this axis: length unchanged.
-        assert_eq!(resize_axis(600.0, 300.0, 100.0, false, false, 50.0, &params()), 300.0);
+        assert_eq!(resize_axis(600.0, 300.0, 100.0, false, false, 50.0, &params().x()), 300.0);
         // Minimum clamps.
-        assert_eq!(resize_axis(600.0, 300.0, 290.0, true, false, 50.0, &params()), 50.0);
+        assert_eq!(resize_axis(600.0, 300.0, 290.0, true, false, 50.0, &params().x()), 50.0);
     }
 
     #[test]
@@ -324,7 +386,7 @@ mod tests {
     fn zero_threshold_disables() {
         let p = SnapParams { threshold: 0.0, ..params() };
         assert_eq!(snap_move(510.0, 300.0, 300.0, 100.0, &p), (510.0, 300.0));
-        assert_eq!(snap_low_edge(510.0, &p), 510.0);
+        assert_eq!(snap_low_edge(510.0, &p.x()), 510.0);
     }
 
     #[test]
@@ -361,7 +423,7 @@ mod tests {
         let p = SnapParams { threshold: 0.0, ..params() };
         assert_eq!(snap_move_tiled(300.0, 300.0, &p), (516.0, 516.0));
         // A degenerate cell size is left alone rather than dividing by ~zero.
-        let p = SnapParams { cell_size: 0.0, ..params() };
+        let p = SnapParams { cell_w: 0.0, cell_h: 0.0, ..params() };
         assert_eq!(snap_move_tiled(300.0, 300.0, &p), (300.0, 300.0));
     }
 }