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

src/cells.rs (12.3K)

  1 // Chess-style addressing for desktop-grid squares.
  2 //
  3 // Coordinates in and out are the same virtual-surface CONTENT coordinates
  4 // snap.rs works in, and the grid geometry matches it exactly: cells of
  5 // `cell_w` x `cell_h` every `cell + gap_width` on each axis, each cell
  6 // fading inward by `cell_inset`, so square k's content span on an axis is
  7 // [k*period + inset, k*period + cell - inset]. A window snapped to a
  8 // square therefore has its virtual position equal to that square's origin —
  9 // the two modules must agree or a "tiled" window would not land on a named
 10 // square.
 11 //
 12 // The naming, with the origin square (the one containing canvas 0,0) as A1:
 13 //
 14 //     column:  … -B   -A    A    B    C  …      (letters right, '-' left)
 15 //     row:     … -2   -1    1    2    3  …      (numbers DOWN, '-' up)
 16 //
 17 // There is no row 0 and no bare-letter-less column: the axes step straight
 18 // from -1 to 1, the way chess files/ranks are 1-based. Columns past Z carry
 19 // on Excel-style (AA, AB, …). So A1 is the origin square, -A1 sits left of
 20 // it, A-1 above it, and -A-1 diagonally up-left.
 21 
 22 fn grid_period(cell_size: f64, gap_width: f64) -> f64 {
 23     cell_size + gap_width.max(0.0)
 24 }
 25 
 26 fn grid_inset(cell_size: f64, cell_inset: f64) -> f64 {
 27     // `clamp` panics when min > max, so a sub-2px cell must not produce a
 28     // negative ceiling (see the matching guard in snap.rs).
 29     cell_inset.clamp(0.0, (cell_size / 2.0 - 1.0).max(0.0))
 30 }
 31 
 32 /// The square index containing a virtual coordinate, on either axis.
 33 pub fn cell_index(v: f64, cell_size: f64, gap_width: f64) -> i32 {
 34     let p = grid_period(cell_size, gap_width);
 35     if p <= 0.0 || !v.is_finite() {
 36         return 0;
 37     }
 38     (v / p).floor() as i32
 39 }
 40 
 41 /// Bijective base-26 (1 -> A, 26 -> Z, 27 -> AA). `n` must be >= 1.
 42 fn letters(mut n: i32) -> String {
 43     let mut out = Vec::new();
 44     while n > 0 {
 45         let rem = (n - 1) % 26;
 46         out.push((b'A' + rem as u8) as char);
 47         n = (n - 1) / 26;
 48     }
 49     out.iter().rev().collect()
 50 }
 51 
 52 fn letters_to_index(s: &str) -> Option<i32> {
 53     if s.is_empty() {
 54         return None;
 55     }
 56     let mut n: i32 = 0;
 57     for c in s.chars() {
 58         let d = match c {
 59             'A'..='Z' => c as i32 - 'A' as i32 + 1,
 60             'a'..='z' => c as i32 - 'a' as i32 + 1,
 61             _ => return None,
 62         };
 63         n = n.checked_mul(26)?.checked_add(d)?;
 64     }
 65     Some(n)
 66 }
 67 
 68 /// Column label: 0 -> "A", 25 -> "Z", 26 -> "AA"; -1 -> "-A", -2 -> "-B".
 69 pub fn column_label(col: i32) -> String {
 70     if col >= 0 {
 71         letters(col + 1)
 72     } else {
 73         format!("-{}", letters(-col))
 74     }
 75 }
 76 
 77 /// Row label: 0 -> "1", 1 -> "2"; -1 -> "-1", -9 -> "-9". No row 0.
 78 pub fn row_label(row: i32) -> String {
 79     if row >= 0 {
 80         (row + 1).to_string()
 81     } else {
 82         row.to_string()
 83     }
 84 }
 85 
 86 /// Full square name, e.g. (2, -9) -> "C-9".
 87 pub fn square_label(col: i32, row: i32) -> String {
 88     format!("{}{}", column_label(col), row_label(row))
 89 }
 90 
 91 /// Parse a square name back to (col, row). Case-insensitive; the leading '-'
 92 /// belongs to the column, the inner '-' to the row ("-A-9" = col -1, row -9).
 93 pub fn parse_square(s: &str) -> Option<(i32, i32)> {
 94     let s = s.trim();
 95     if s.is_empty() {
 96         return None;
 97     }
 98     let (col_neg, rest) = match s.strip_prefix('-') {
 99         Some(r) => (true, r),
100         None => (false, s),
101     };
102     let split = rest.find(|c: char| c == '-' || c.is_ascii_digit())?;
103     let (letters_part, row_part) = rest.split_at(split);
104     let mag = letters_to_index(letters_part)?;
105     let col = if col_neg { -mag } else { mag - 1 };
106 
107     let row_val: i32 = row_part.parse().ok()?;
108     if row_val == 0 {
109         return None; // there is no row 0
110     }
111     let row = if row_val > 0 { row_val - 1 } else { row_val };
112     Some((col, row))
113 }
114 
115 /// Content rect (x, y, w, h) of one square — what a window snapped to it fills.
116 pub fn square_rect(
117     col: i32,
118     row: i32,
119     cell_w: f64,
120     cell_h: f64,
121     gap_width: f64,
122     cell_inset: f64,
123 ) -> (f64, f64, f64, f64) {
124     block_rect(col, row, col, row, cell_w, cell_h, gap_width, cell_inset)
125 }
126 
127 /// Content rect of a whole block of squares, inclusive of both corners.
128 /// A Tiled window covering the block fills exactly this.
129 pub fn block_rect(
130     col0: i32,
131     row0: i32,
132     col1: i32,
133     row1: i32,
134     cell_w: f64,
135     cell_h: f64,
136     gap_width: f64,
137     cell_inset: f64,
138 ) -> (f64, f64, f64, f64) {
139     let px = grid_period(cell_w, gap_width);
140     let py = grid_period(cell_h, gap_width);
141     let inset_x = grid_inset(cell_w, cell_inset);
142     let inset_y = grid_inset(cell_h, cell_inset);
143     let (cl, cr) = (col0.min(col1), col0.max(col1));
144     let (rt, rb) = (row0.min(row1), row0.max(row1));
145     let x = cl as f64 * px + inset_x;
146     let y = rt as f64 * py + inset_y;
147     let w = (cr - cl) as f64 * px + cell_w - 2.0 * inset_x;
148     let h = (rb - rt) as f64 * py + cell_h - 2.0 * inset_y;
149     (x, y, w, h)
150 }
151 
152 /// The block of squares a window's content box covers, as
153 /// (col_min, row_min, col_max, row_max), corners inclusive. The high edges
154 /// are taken just inside the box so a window flush against a cell's right
155 /// edge does not claim the next column.
156 pub fn window_span(
157     x: f64,
158     y: f64,
159     w: f64,
160     h: f64,
161     cell_w: f64,
162     cell_h: f64,
163     gap_width: f64,
164 ) -> (i32, i32, i32, i32) {
165     let col0 = cell_index(x, cell_w, gap_width);
166     let row0 = cell_index(y, cell_h, gap_width);
167     let col1 = cell_index(x + w.max(1.0) - 1.0, cell_w, gap_width).max(col0);
168     let row1 = cell_index(y + h.max(1.0) - 1.0, cell_h, gap_width).max(row0);
169     (col0, row0, col1, row1)
170 }
171 
172 /// Re-tile a block-covering box across a grid-geometry change: the block of
173 /// squares the box covers under `old` is re-derived as a content rect under
174 /// `new`. A tiled window keeps ITS SQUARES when the grid changes — C-9:D-8
175 /// stays C-9:D-8 at the new cell dimensions, so the window resizes with the
176 /// grid — rather than keeping its pixel box and later spanning whatever new
177 /// cells that box happens to touch.
178 pub fn remap_block(
179     x: f64,
180     y: f64,
181     w: f64,
182     h: f64,
183     old: &crate::snap::SnapParams,
184     new: &crate::snap::SnapParams,
185 ) -> (f64, f64, f64, f64) {
186     let (c0, r0, c1, r1) = window_span(x, y, w, h, old.cell_w, old.cell_h, old.gap_width);
187     block_rect(c0, r0, c1, r1, new.cell_w, new.cell_h, new.gap_width, new.cell_inset)
188 }
189 
190 /// Human-readable span: one square ("C-9") or a block ("C-9:D-8").
191 pub fn span_label(col0: i32, row0: i32, col1: i32, row1: i32) -> String {
192     if col0 == col1 && row0 == row1 {
193         square_label(col0, row0)
194     } else {
195         format!(
196             "{}:{}",
197             square_label(col0.min(col1), row0.min(row1)),
198             square_label(col0.max(col1), row0.max(row1))
199         )
200     }
201 }
202 
203 /// The square span of a window, already labelled.
204 pub fn window_span_label(
205     x: f64,
206     y: f64,
207     w: f64,
208     h: f64,
209     cell_w: f64,
210     cell_h: f64,
211     gap_width: f64,
212 ) -> String {
213     let (c0, r0, c1, r1) = window_span(x, y, w, h, cell_w, cell_h, gap_width);
214     span_label(c0, r0, c1, r1)
215 }
216 
217 #[cfg(test)]
218 mod tests {
219     use super::*;
220 
221     // The live desktop's geometry: 512px cells, 16px gaps, 4px fade inset.
222     const CELL: f64 = 512.0;
223     const GAP: f64 = 16.0;
224     const INSET: f64 = 4.0;
225     // period = 528
226 
227     #[test]
228     fn origin_square_is_a1() {
229         assert_eq!(square_label(0, 0), "A1");
230         // Its content origin is the inset corner, not the raw grid line.
231         let (x, y, w, h) = square_rect(0, 0, CELL, CELL, GAP, INSET);
232         assert_eq!((x, y), (4.0, 4.0));
233         assert_eq!((w, h), (504.0, 504.0));
234     }
235 
236     #[test]
237     fn neighbours_of_the_origin_skip_zero() {
238         assert_eq!(square_label(-1, 0), "-A1"); // left
239         assert_eq!(square_label(0, -1), "A-1"); // above
240         assert_eq!(square_label(-1, -1), "-A-1"); // up-left
241         assert_eq!(square_label(1, 1), "B2"); // down-right
242     }
243 
244     #[test]
245     fn columns_run_past_z() {
246         assert_eq!(column_label(25), "Z");
247         assert_eq!(column_label(26), "AA");
248         assert_eq!(column_label(27), "AB");
249         assert_eq!(column_label(-26), "-Z");
250         assert_eq!(column_label(-27), "-AA");
251     }
252 
253     #[test]
254     fn live_desktop_window_lands_on_c_minus_9() {
255         // claude-desktop sits at virtual (1060, -4748) — exactly the content
256         // origin of column 2, row -9 (2*528+4, -9*528+4).
257         let col = cell_index(1060.0, CELL, GAP);
258         let row = cell_index(-4748.0, CELL, GAP);
259         assert_eq!((col, row), (2, -9));
260         assert_eq!(square_label(col, row), "C-9");
261         let (x, y, _, _) = square_rect(col, row, CELL, CELL, GAP, INSET);
262         assert_eq!((x, y), (1060.0, -4748.0));
263     }
264 
265     #[test]
266     fn label_parse_roundtrip() {
267         for &(c, r) in &[
268             (0, 0),
269             (2, -9),
270             (-1, 0),
271             (0, -1),
272             (-1, -1),
273             (25, 41),
274             (26, -100),
275             (-27, 7),
276         ] {
277             let s = square_label(c, r);
278             assert_eq!(parse_square(&s), Some((c, r)), "roundtrip failed for {s}");
279         }
280     }
281 
282     #[test]
283     fn parse_is_lenient_but_rejects_nonsense() {
284         assert_eq!(parse_square("c-9"), Some((2, -9)));
285         assert_eq!(parse_square("  B2 "), Some((1, 1)));
286         assert_eq!(parse_square("A0"), None); // no row 0
287         assert_eq!(parse_square("A"), None); // no row at all
288         assert_eq!(parse_square("9"), None); // no column
289         assert_eq!(parse_square(""), None);
290         assert_eq!(parse_square("A1B"), None);
291     }
292 
293     #[test]
294     fn window_span_covers_only_the_squares_it_fills() {
295         // A window filling exactly one square claims one square.
296         let (x, y, w, h) = square_rect(2, -9, CELL, CELL, GAP, INSET);
297         assert_eq!(window_span(x, y, w, h, CELL, CELL, GAP), (2, -9, 2, -9));
298         assert_eq!(window_span_label(x, y, w, h, CELL, CELL, GAP), "C-9");
299 
300         // A 2x1 block claims exactly two columns, not three.
301         let (x, y, w, h) = block_rect(2, -9, 3, -9, CELL, CELL, GAP, INSET);
302         assert_eq!(window_span(x, y, w, h, CELL, CELL, GAP), (2, -9, 3, -9));
303         assert_eq!(window_span_label(x, y, w, h, CELL, CELL, GAP), "C-9:D-9");
304     }
305 
306     #[test]
307     fn block_rect_matches_the_tiled_snap_footprint() {
308         // cells.rs and snap.rs must agree: a block's rect is what tiled_span
309         // returns for a box spanning those cells.
310         let (x, y, w, h) = block_rect(2, -9, 3, -8, CELL, CELL, GAP, INSET);
311         let (lo_x, hi_x) = crate::snap::tiled_span(x, x + w, CELL, GAP, INSET);
312         let (lo_y, hi_y) = crate::snap::tiled_span(y, y + h, CELL, GAP, INSET);
313         assert_eq!((lo_x, hi_x - lo_x), (x, w));
314         assert_eq!((lo_y, hi_y - lo_y), (y, h));
315     }
316 
317     #[test]
318     fn rectangular_cells_use_per_axis_periods() {
319         // 512-wide, 256-tall cells: columns step 528, rows step 272.
320         let (x, y, w, h) = square_rect(1, 2, CELL, 256.0, GAP, INSET);
321         assert_eq!((x, y), (532.0, 548.0));
322         assert_eq!((w, h), (504.0, 248.0));
323         assert_eq!(window_span(x, y, w, h, CELL, 256.0, GAP), (1, 2, 1, 2));
324         assert_eq!(cell_index(547.0, 256.0, GAP), 2);
325     }
326 
327     #[test]
328     fn remap_block_keeps_the_squares_across_a_grid_change() {
329         let old = crate::snap::SnapParams {
330             cell_w: 512.0,
331             cell_h: 512.0,
332             gap_width: 16.0,
333             cell_inset: 4.0,
334             threshold: 24.0,
335         };
336         let new = crate::snap::SnapParams { cell_h: 256.0, ..old };
337         // A window tiled on C-9 (one square, old grid) lands on C-9 of the
338         // new grid: same column, row -9 now at -9*272+4, height 248.
339         let (x, y, w, h) = square_rect(2, -9, 512.0, 512.0, 16.0, 4.0);
340         assert_eq!(remap_block(x, y, w, h, &old, &new), (1060.0, -2444.0, 504.0, 248.0));
341         // A 2x2 block keeps all four squares: height spans rows -9..-8 on
342         // the 272 period (2*272 + 256 - 8 = 792... via block_rect).
343         let (x, y, w, h) = block_rect(2, -9, 3, -8, 512.0, 512.0, 16.0, 4.0);
344         assert_eq!(
345             remap_block(x, y, w, h, &old, &new),
346             block_rect(2, -9, 3, -8, 512.0, 256.0, 16.0, 4.0)
347         );
348         // Identity when the grid is unchanged.
349         assert_eq!(remap_block(x, y, w, h, &old, &old), (x, y, w, h));
350     }
351 
352     #[test]
353     fn degenerate_geometry_does_not_panic() {
354         assert_eq!(cell_index(f64::NAN, CELL, GAP), 0);
355         assert_eq!(cell_index(10.0, 0.0, 0.0), 0);
356         let _ = square_rect(0, 0, 0.0, 0.0, 0.0, 0.0);
357     }
358 }