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 }