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

commite23823d1febdc77666a6639d530bfcc8a67f191f
parent881aebd417
authorLucas Galante <[email protected]>
date2026-08-13 17:23
feat: chess-style grid-square coordinates + hard snap for Tiled windows

cells.rs addresses desktop-grid squares the way a chessboard does, with the
square containing canvas (0,0) as A1: columns A..Z,AA.. to the right and
-A,-B.. to the left; rows 1,2,3.. downward and -1,-2.. upward, with no row
0 and no letter-less column (the axes step straight from -1 to 1). It shares
snap.rs's geometry exactly — square k spans [k*period + inset, k*period +
cell_size - inset] — so a window snapped to a square has its virtual
position equal to that square's origin; a test pins the two together.
Provides label/parse/rect/window-span helpers, the last two answering "which
squares does this window cover" for both single cells and blocks.

snap_move_tiled() is the move-time counterpart to tiled_span: a Tiled window
snaps hard to the nearest square with no threshold. Previously a drag that
ended mid-cell left the window looking aligned (the arrange pass re-snaps a
Tiled window's rendered box every frame) while its virtual position sat
off-grid, so is_cell_aligned failed at op_end and the window silently
demoted to Floating and jumped to the off-grid spot. Tiled windows now only
ever come to rest on whole squares; ModeNext remains the way to leave Tiled.
Deliberately not gated on the snap threshold — that is a magnetic grab
distance, while filling whole cells is what being Tiled means.

Also fixes a latent panic shared by both modules: f64::clamp panics when
min > max, so a grid_cell_size under 2 (a config typo) would abort the
compositor from inside the arrange pass.

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

 src/cells.rs | 299 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 src/lib.rs   |   1 +
 src/snap.rs  |  64 ++++++++++++-
 3 files changed, 363 insertions(+), 1 deletion(-)

diff --git a/src/cells.rs b/src/cells.rs
new file mode 100644
index 0000000..2f20490
--- /dev/null
+++ b/src/cells.rs
@@ -0,0 +1,299 @@
+// Chess-style addressing for desktop-grid squares.
+//
+// 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
+// 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.
+//
+// The naming, with the origin square (the one containing canvas 0,0) as A1:
+//
+//     column:  … -B   -A    A    B    C  …      (letters right, '-' left)
+//     row:     … -2   -1    1    2    3  …      (numbers DOWN, '-' up)
+//
+// There is no row 0 and no bare-letter-less column: the axes step straight
+// from -1 to 1, the way chess files/ranks are 1-based. Columns past Z carry
+// on Excel-style (AA, AB, …). So A1 is the origin square, -A1 sits left of
+// it, A-1 above it, and -A-1 diagonally up-left.
+
+fn grid_period(cell_size: f64, gap_width: f64) -> f64 {
+    cell_size + gap_width.max(0.0)
+}
+
+fn grid_inset(cell_size: f64, cell_inset: f64) -> f64 {
+    // `clamp` panics when min > max, so a sub-2px cell must not produce a
+    // negative ceiling (see the matching guard in snap.rs).
+    cell_inset.clamp(0.0, (cell_size / 2.0 - 1.0).max(0.0))
+}
+
+/// The square index containing a virtual coordinate, on either axis.
+pub fn cell_index(v: f64, cell_size: f64, gap_width: f64) -> i32 {
+    let p = grid_period(cell_size, gap_width);
+    if p <= 0.0 || !v.is_finite() {
+        return 0;
+    }
+    (v / p).floor() as i32
+}
+
+/// Bijective base-26 (1 -> A, 26 -> Z, 27 -> AA). `n` must be >= 1.
+fn letters(mut n: i32) -> String {
+    let mut out = Vec::new();
+    while n > 0 {
+        let rem = (n - 1) % 26;
+        out.push((b'A' + rem as u8) as char);
+        n = (n - 1) / 26;
+    }
+    out.iter().rev().collect()
+}
+
+fn letters_to_index(s: &str) -> Option<i32> {
+    if s.is_empty() {
+        return None;
+    }
+    let mut n: i32 = 0;
+    for c in s.chars() {
+        let d = match c {
+            'A'..='Z' => c as i32 - 'A' as i32 + 1,
+            'a'..='z' => c as i32 - 'a' as i32 + 1,
+            _ => return None,
+        };
+        n = n.checked_mul(26)?.checked_add(d)?;
+    }
+    Some(n)
+}
+
+/// Column label: 0 -> "A", 25 -> "Z", 26 -> "AA"; -1 -> "-A", -2 -> "-B".
+pub fn column_label(col: i32) -> String {
+    if col >= 0 {
+        letters(col + 1)
+    } else {
+        format!("-{}", letters(-col))
+    }
+}
+
+/// Row label: 0 -> "1", 1 -> "2"; -1 -> "-1", -9 -> "-9". No row 0.
+pub fn row_label(row: i32) -> String {
+    if row >= 0 {
+        (row + 1).to_string()
+    } else {
+        row.to_string()
+    }
+}
+
+/// Full square name, e.g. (2, -9) -> "C-9".
+pub fn square_label(col: i32, row: i32) -> String {
+    format!("{}{}", column_label(col), row_label(row))
+}
+
+/// Parse a square name back to (col, row). Case-insensitive; the leading '-'
+/// belongs to the column, the inner '-' to the row ("-A-9" = col -1, row -9).
+pub fn parse_square(s: &str) -> Option<(i32, i32)> {
+    let s = s.trim();
+    if s.is_empty() {
+        return None;
+    }
+    let (col_neg, rest) = match s.strip_prefix('-') {
+        Some(r) => (true, r),
+        None => (false, s),
+    };
+    let split = rest.find(|c: char| c == '-' || c.is_ascii_digit())?;
+    let (letters_part, row_part) = rest.split_at(split);
+    let mag = letters_to_index(letters_part)?;
+    let col = if col_neg { -mag } else { mag - 1 };
+
+    let row_val: i32 = row_part.parse().ok()?;
+    if row_val == 0 {
+        return None; // there is no row 0
+    }
+    let row = if row_val > 0 { row_val - 1 } else { row_val };
+    Some((col, row))
+}
+
+/// Content rect (x, y, w, h) of one square — what a window snapped to it fills.
+pub fn square_rect(
+    col: i32,
+    row: i32,
+    cell_size: f64,
+    gap_width: f64,
+    cell_inset: f64,
+) -> (f64, f64, f64, f64) {
+    block_rect(col, row, col, row, cell_size, gap_width, cell_inset)
+}
+
+/// Content rect of a whole block of squares, inclusive of both corners.
+/// A Tiled window covering the block fills exactly this.
+pub fn block_rect(
+    col0: i32,
+    row0: i32,
+    col1: i32,
+    row1: i32,
+    cell_size: 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 (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;
+    (x, y, w, h)
+}
+
+/// The block of squares a window's content box covers, as
+/// (col_min, row_min, col_max, row_max), corners inclusive. The high edges
+/// are taken just inside the box so a window flush against a cell's right
+/// edge does not claim the next column.
+pub fn window_span(
+    x: f64,
+    y: f64,
+    w: f64,
+    h: f64,
+    cell_size: 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);
+    (col0, row0, col1, row1)
+}
+
+/// Human-readable span: one square ("C-9") or a block ("C-9:D-8").
+pub fn span_label(col0: i32, row0: i32, col1: i32, row1: i32) -> String {
+    if col0 == col1 && row0 == row1 {
+        square_label(col0, row0)
+    } else {
+        format!(
+            "{}:{}",
+            square_label(col0.min(col1), row0.min(row1)),
+            square_label(col0.max(col1), row0.max(row1))
+        )
+    }
+}
+
+/// The square span of a window, already labelled.
+pub fn window_span_label(
+    x: f64,
+    y: f64,
+    w: f64,
+    h: f64,
+    cell_size: f64,
+    gap_width: f64,
+) -> String {
+    let (c0, r0, c1, r1) = window_span(x, y, w, h, cell_size, gap_width);
+    span_label(c0, r0, c1, r1)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    // The live desktop's geometry: 512px cells, 16px gaps, 4px fade inset.
+    const CELL: f64 = 512.0;
+    const GAP: f64 = 16.0;
+    const INSET: f64 = 4.0;
+    // period = 528
+
+    #[test]
+    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);
+        assert_eq!((x, y), (4.0, 4.0));
+        assert_eq!((w, h), (504.0, 504.0));
+    }
+
+    #[test]
+    fn neighbours_of_the_origin_skip_zero() {
+        assert_eq!(square_label(-1, 0), "-A1"); // left
+        assert_eq!(square_label(0, -1), "A-1"); // above
+        assert_eq!(square_label(-1, -1), "-A-1"); // up-left
+        assert_eq!(square_label(1, 1), "B2"); // down-right
+    }
+
+    #[test]
+    fn columns_run_past_z() {
+        assert_eq!(column_label(25), "Z");
+        assert_eq!(column_label(26), "AA");
+        assert_eq!(column_label(27), "AB");
+        assert_eq!(column_label(-26), "-Z");
+        assert_eq!(column_label(-27), "-AA");
+    }
+
+    #[test]
+    fn live_desktop_window_lands_on_c_minus_9() {
+        // claude-desktop sits at virtual (1060, -4748) — exactly the content
+        // origin of column 2, row -9 (2*528+4, -9*528+4).
+        let col = cell_index(1060.0, CELL, GAP);
+        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);
+        assert_eq!((x, y), (1060.0, -4748.0));
+    }
+
+    #[test]
+    fn label_parse_roundtrip() {
+        for &(c, r) in &[
+            (0, 0),
+            (2, -9),
+            (-1, 0),
+            (0, -1),
+            (-1, -1),
+            (25, 41),
+            (26, -100),
+            (-27, 7),
+        ] {
+            let s = square_label(c, r);
+            assert_eq!(parse_square(&s), Some((c, r)), "roundtrip failed for {s}");
+        }
+    }
+
+    #[test]
+    fn parse_is_lenient_but_rejects_nonsense() {
+        assert_eq!(parse_square("c-9"), Some((2, -9)));
+        assert_eq!(parse_square("  B2 "), Some((1, 1)));
+        assert_eq!(parse_square("A0"), None); // no row 0
+        assert_eq!(parse_square("A"), None); // no row at all
+        assert_eq!(parse_square("9"), None); // no column
+        assert_eq!(parse_square(""), None);
+        assert_eq!(parse_square("A1B"), None);
+    }
+
+    #[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");
+
+        // 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");
+    }
+
+    #[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 (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 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);
+    }
+}
diff --git a/src/lib.rs b/src/lib.rs
index 10fe52d..a65a902 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -42,6 +42,7 @@ pub mod arrange;
 pub mod background;
 pub mod bindings;
 pub mod camera;
+pub mod cells;
 pub mod focus;
 pub mod overview;
 pub mod pan;
diff --git a/src/snap.rs b/src/snap.rs
index 7820aa6..8e7140d 100644
--- a/src/snap.rs
+++ b/src/snap.rs
@@ -19,7 +19,10 @@ fn grid_period(cell_size: f64, gap_width: f64) -> f64 {
 }
 
 fn grid_inset(cell_size: f64, cell_inset: f64) -> f64 {
-    cell_inset.clamp(0.0, cell_size / 2.0 - 1.0)
+    // `f64::clamp` panics when min > max, which a `grid_cell_size` under 2
+    // (a config typo) produces — and this runs on every arrange pass, so the
+    // panic would take the whole session down.
+    cell_inset.clamp(0.0, (cell_size / 2.0 - 1.0).max(0.0))
 }
 
 /// Hard grid snap for Tiled windows: the visible outer edges of every
@@ -159,6 +162,27 @@ pub fn snap_high_edge(pos: f64, p: &SnapParams) -> f64 {
     }
 }
 
+/// Hard grid snap for MOVING a `Tiled` window: both low edges land on the
+/// nearest cell start, with no threshold, so the window can only ever come to
+/// rest covering whole squares. `snap_move`'s magnetic pull is for Floating
+/// windows deciding whether to tile; once a window IS tiled, sitting between
+/// squares is not a state it is allowed to reach — a drag that ended mid-cell
+/// used to leave the window aligned on screen (the arrange pass re-snaps a
+/// Tiled window's rendered box every frame) while its virtual position was
+/// off-grid, so `is_cell_aligned` failed at op_end and the window silently
+/// demoted to Floating and jumped.
+///
+/// Deliberately not gated on `enabled()`: the snap threshold is a grab
+/// distance for magnetic snapping, while a Tiled window fills whole cells by
+/// 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))
+}
+
 /// 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
@@ -302,4 +326,42 @@ mod tests {
         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);
     }
+
+    #[test]
+    fn tiled_move_snaps_hard_from_any_distance() {
+        // cell 512, no gap, inset 4: cell starts are 4, 516, 1028 …
+        let p = params();
+        // Well beyond the 24px magnetic threshold, where snap_move gives up.
+        assert_eq!(snap_move(200.0, 200.0, 504.0, 504.0, &p), (200.0, 200.0));
+        // The tiled snap still lands on the nearest cell start (cell 0 at 4).
+        assert_eq!(snap_move_tiled(200.0, 200.0, &p), (4.0, 4.0));
+        // Past the midpoint it commits to the next cell instead (cell 1 at 516).
+        assert_eq!(snap_move_tiled(300.0, 300.0, &p), (516.0, 516.0));
+        // Negative canvas coordinates snap the same way (cell -1 at -508).
+        assert_eq!(snap_move_tiled(-400.0, -400.0, &p), (-508.0, -508.0));
+    }
+
+    #[test]
+    fn tiled_move_result_is_always_cell_aligned() {
+        // The point of the hard snap: whatever the drag ends on, the window
+        // is still Tiled at op_end instead of silently demoting to Floating.
+        let p = params();
+        for start in [0.0, 37.0, 260.0, 700.0, -13.0, -900.0] {
+            let (x, y) = snap_move_tiled(start, start, &p);
+            assert!(
+                is_cell_aligned(x, y, 504.0, 504.0, &p, 1.0),
+                "drag ending at {start} left the window off-grid at ({x}, {y})"
+            );
+        }
+    }
+
+    #[test]
+    fn tiled_move_ignores_a_disabled_threshold() {
+        // Magnetic snapping off must not strand a tiled window between cells.
+        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() };
+        assert_eq!(snap_move_tiled(300.0, 300.0, &p), (300.0, 300.0));
+    }
 }