window management library
git clone https://git.lucas.co/cce-window-manager.git
src/background.rs (15K)
1 // Background drawing policy: the per-frame geometry of the infinite desktop
2 // grid. `grid_frame` maps a GridSpec + camera + output extent to a concrete
3 // drawing plan — where the grid tree sits (the modulo shift that makes the
4 // grid infinite), how big the backdrop is, and whether/how the cells draw
5 // (density fade, safety caps, zoom-scaled extents). The mechanism owns the
6 // scene rects, the reuse pool, and scenefx's fade-inset wire encoding.
7
8 use crate::api::{GridSpec, Rgba};
9 use crate::camera::Camera;
10
11 /// A camera zoom as the background math consumes it: NaN and non-positive
12 /// values fall back to 1.
13 pub fn sanitized_zoom(zoom: f64) -> f64 {
14 if zoom.is_nan() || zoom <= 0.0 { 1.0 } else { zoom }
15 }
16
17 /// One frame's grid drawing plan, in output-local px unless noted. The two
18 /// axes carry independent periods (cell_w + gap and cell_h + gap).
19 #[derive(Debug, Clone, PartialEq)]
20 pub struct GridFrame {
21 /// Grid-tree translation in layout px (includes the output's own
22 /// offset): the pan shift folded modulo one EXACT period per axis,
23 /// minus one period so the tree always overhangs the top-left edge.
24 /// None when either zoomed period rounds to zero — the tree keeps its
25 /// last position.
26 pub tree_pos: Option<(i32, i32)>,
27 /// The zoomed grid periods (cell + gap per axis), rounded to px.
28 pub period_px_x: i32,
29 pub period_px_y: i32,
30 /// The exact (unrounded) zoomed periods. Cell (col, row) must be placed
31 /// at `(round(col * period_px_exact_x), round(row * period_px_exact_y))`
32 /// tree-local — NOT multiples of the rounded periods: at fractional
33 /// zooms the rounded period drifts from the world-true cell positions
34 /// by its rounding error per period, so the grid slides relative to the
35 /// (world-anchored) windows as the camera pans, and anything meant to
36 /// hug a window edge (a client shadow, a snap) visibly jitters against
37 /// the grid.
38 pub period_px_exact_x: f64,
39 pub period_px_exact_y: f64,
40 /// Backdrop (gap color) extent: viewport plus one period per axis, so
41 /// pan shifts never expose the edge.
42 pub backdrop_w: i32,
43 pub backdrop_h: i32,
44 /// None when the cells are fully density-faded, a period degenerates,
45 /// or the cell count exceeds the safety caps — backdrop only.
46 pub cells: Option<GridCells>,
47 /// World square indices of the cell drawn at tree-local (0, 0). The grid
48 /// tree is folded modulo one period for infinite scrolling, so tree-local
49 /// column `c` is world column `first_col + c` — the only way to put a
50 /// name (see `cells.rs`) on a drawn cell.
51 pub first_col: i32,
52 pub first_row: i32,
53 }
54
55 /// The repeated cell lattice: draw a cell at (round(col * period_px_exact_x),
56 /// round(row * period_px_exact_y)) for col in 0..=cols, row in 0..=rows,
57 /// tree-local.
58 #[derive(Debug, Clone, PartialEq)]
59 pub struct GridCells {
60 pub cell_w_px: i32,
61 pub cell_h_px: i32,
62 pub cols: i32,
63 pub rows: i32,
64 /// Cell color with the density fade already applied to alpha.
65 pub color: Rgba,
66 pub corner_radius_px: i32,
67 /// Zoom-scaled fade inset, capped at 45% of the smaller cell dimension
68 /// so cells can't blur out entirely when far zoomed out; 0 disables the
69 /// fade.
70 pub fade_inset_px: i32,
71 }
72
73 pub fn grid_frame(
74 spec: &GridSpec,
75 cam: Camera,
76 viewport_w: i32,
77 viewport_h: i32,
78 output_x: i32,
79 output_y: i32,
80 ) -> GridFrame {
81 let zoom = sanitized_zoom(cam.zoom);
82 let cell_w = spec.cell_w.max(5.0);
83 let cell_h = spec.cell_h.max(5.0);
84 let gap = spec.gap_width.max(0.0);
85 let period_x = cell_w + gap;
86 let period_y = cell_h + gap;
87
88 // Fade the cells out as they shrink (smaller period under 30 screen px)
89 // to prevent visual noise and pathological cell counts when zooming.
90 let period_pixels_x = period_x * zoom;
91 let period_pixels_y = period_y * zoom;
92 let min_period_pixels = period_pixels_x.min(period_pixels_y);
93 let density_fade = if min_period_pixels < 15.0 {
94 0.0
95 } else if min_period_pixels < 30.0 {
96 (min_period_pixels - 15.0) / 15.0
97 } else {
98 1.0
99 };
100 let period_px_x = period_pixels_x.round() as i32;
101 let period_px_y = period_pixels_y.round() as i32;
102
103 // Modulo shift for infinite scrolling, in EXACT period units: the phase
104 // is folded over the true zoomed period and only rounded once at the
105 // end, so the tree lands within half a pixel of the world-true cell
106 // boundary at any pan. (Folding over the ROUNDED period accumulated its
107 // rounding error into the phase and made the whole grid jump relative
108 // to the windows whenever the fold wrapped.)
109 let tree_pos = if period_px_x > 0 && period_px_y > 0 {
110 let phase_x = ((-cam.pan_x) * zoom).rem_euclid(period_pixels_x);
111 let phase_y = ((-cam.pan_y) * zoom).rem_euclid(period_pixels_y);
112 Some((
113 output_x + (phase_x - period_pixels_x).round() as i32,
114 output_y + (phase_y - period_pixels_y).round() as i32,
115 ))
116 } else {
117 None
118 };
119
120 let cells = if period_px_x > 0 && period_px_y > 0 && density_fade > 0.0 {
121 let cols = (viewport_w as f64 / period_px_x as f64).ceil() as i32 + 1;
122 let rows = (viewport_h as f64 / period_px_y as f64).ceil() as i32 + 1;
123 let cell_w_px = (cell_w * zoom).round() as i32;
124 let cell_h_px = (cell_h * zoom).round() as i32;
125 if cols > 0
126 && rows > 0
127 && cols <= 1000
128 && rows <= 1000
129 && cols * rows <= 20000
130 && cell_w_px > 0
131 && cell_h_px > 0
132 {
133 let mut color = spec.cell_color;
134 color.0[3] *= density_fade as f32;
135 let max_inset = (cell_w_px.min(cell_h_px) as f64 * 0.45).floor() as i32;
136 let fade_inset_px = ((spec.cell_fade_inset as f64 * zoom).round() as i32)
137 .min(max_inset)
138 .max(0);
139 Some(GridCells {
140 cell_w_px,
141 cell_h_px,
142 cols,
143 rows,
144 color,
145 corner_radius_px: (spec.cell_corner_radius as f64 * zoom).round() as i32,
146 fade_inset_px,
147 })
148 } else {
149 None
150 }
151 } else {
152 None
153 };
154
155 // Which world square the tree-local (0,0) cell is. Invert the screen
156 // mapping (screen = (world - pan) * zoom + output) at the tree origin and
157 // divide by the period; the tree's px rounding is a fraction of a cell,
158 // so rounding here is exact in practice.
159 let (first_col, first_row) = match tree_pos {
160 Some((tx, ty)) => (
161 ((((tx - output_x) as f64) / zoom + cam.pan_x) / period_x).round() as i32,
162 ((((ty - output_y) as f64) / zoom + cam.pan_y) / period_y).round() as i32,
163 ),
164 None => (0, 0),
165 };
166
167 GridFrame {
168 tree_pos,
169 period_px_x,
170 period_px_y,
171 period_px_exact_x: period_pixels_x,
172 period_px_exact_y: period_pixels_y,
173 backdrop_w: viewport_w + period_px_x,
174 backdrop_h: viewport_h + period_px_y,
175 cells,
176 first_col,
177 first_row,
178 }
179 }
180
181 #[cfg(test)]
182 mod tests {
183 use super::*;
184 use crate::api::GridFadeMode;
185
186 /// A drawn cell must sit exactly where the square it is named after sits.
187 /// This is the contract between the lattice (drawn tree-local, folded
188 /// modulo one period) and cells.rs (world-indexed).
189 #[test]
190 fn first_cell_indices_name_the_drawn_lattice() {
191 let spec = GridSpec {
192 cell_w: 512.0,
193 cell_h: 512.0,
194 gap_width: 16.0,
195 cell_fade_inset: 4,
196 cell_corner_radius: 0,
197 fade_mode: GridFadeMode::Quadratic,
198 cell_color: Rgba([0.0, 0.0, 0.0, 1.0]),
199 gap_color: Rgba([0.7, 0.8, 0.9, 1.0]),
200 };
201 let period = spec.cell_w + spec.gap_width;
202 // A few cameras, including the live desktop's overview zoom and a
203 // deeply negative pan (where the modulo fold wraps).
204 for &(pan_x, pan_y, zoom) in &[
205 (0.0, 0.0, 1.0),
206 (1060.0, -4748.0, 1.0),
207 (1060.0, -4748.0, 0.382),
208 (-3000.0, 2500.0, 0.75),
209 ] {
210 let cam = Camera { pan_x, pan_y, zoom };
211 let f = grid_frame(&spec, cam, 1920, 1080, 0, 0);
212 let (tx, ty) = f.tree_pos.expect("period is drawable here");
213 for &(col, row) in &[(0, 0), (1, 2), (3, 1)] {
214 // Where the mechanism draws this cell.
215 let drawn_x = tx as f64 + (col as f64 * f.period_px_exact_x).round();
216 let drawn_y = ty as f64 + (row as f64 * f.period_px_exact_y).round();
217 // Where the square it is named after actually is.
218 let (wx, wy, _, _) = crate::cells::square_rect(
219 f.first_col + col,
220 f.first_row + row,
221 spec.cell_w,
222 spec.cell_h,
223 spec.gap_width,
224 0.0, // raw grid line: the lattice rect is not inset
225 );
226 let want_x = (wx - pan_x) * zoom;
227 let want_y = (wy - pan_y) * zoom;
228 assert!(
229 (drawn_x - want_x).abs() <= 1.5 && (drawn_y - want_y).abs() <= 1.5,
230 "cell ({col},{row}) at cam ({pan_x},{pan_y},{zoom}) drawn at \
231 ({drawn_x},{drawn_y}) but square {}{} is at ({want_x},{want_y})",
232 crate::cells::column_label(f.first_col + col),
233 crate::cells::row_label(f.first_row + row),
234 );
235 }
236 // Sanity: the period is what we divided by.
237 assert!((f.period_px_exact_x - period * zoom).abs() < 1e-9);
238 }
239 }
240
241 const VW: i32 = 1920;
242 const VH: i32 = 1080;
243
244 fn spec() -> GridSpec {
245 GridSpec {
246 gap_color: Rgba([0.0, 0.0, 0.0, 1.0]),
247 cell_color: Rgba([0.05, 0.05, 0.05, 0.6]),
248 cell_w: 100.0,
249 cell_h: 100.0,
250 gap_width: 10.0,
251 cell_corner_radius: 8,
252 cell_fade_inset: 4,
253 fade_mode: GridFadeMode::Linear,
254 }
255 }
256
257 fn cam(pan_x: f64, pan_y: f64, zoom: f64) -> Camera {
258 Camera { pan_x, pan_y, zoom }
259 }
260
261 #[test]
262 fn unpanned_grid_overhangs_one_period() {
263 let f = grid_frame(&spec(), cam(0.0, 0.0, 1.0), VW, VH, 100, 50);
264 assert_eq!((f.period_px_x, f.period_px_y), (110, 110));
265 assert_eq!(f.tree_pos, Some((100 - 110, 50 - 110)));
266 assert_eq!((f.backdrop_w, f.backdrop_h), (VW + 110, VH + 110));
267 let cells = f.cells.unwrap();
268 // ceil(1920/110)+1 = 19, ceil(1080/110)+1 = 11.
269 assert_eq!((cells.cols, cells.rows), (19, 11));
270 assert_eq!((cells.cell_w_px, cells.cell_h_px), (100, 100));
271 assert_eq!(cells.fade_inset_px, 4);
272 assert_eq!(cells.corner_radius_px, 8);
273 // Full density: color untouched.
274 assert_eq!(cells.color, Rgba([0.05, 0.05, 0.05, 0.6]));
275 }
276
277 #[test]
278 fn rectangular_cells_get_per_axis_periods_and_counts() {
279 // 100-wide, 50-tall cells, gap 10: periods 110 x 60.
280 let mut sp = spec();
281 sp.cell_h = 50.0;
282 let f = grid_frame(&sp, cam(0.0, 0.0, 1.0), VW, VH, 0, 0);
283 assert_eq!((f.period_px_x, f.period_px_y), (110, 60));
284 assert_eq!(f.tree_pos, Some((-110, -60)));
285 assert_eq!((f.backdrop_w, f.backdrop_h), (VW + 110, VH + 60));
286 let cells = f.cells.unwrap();
287 assert_eq!((cells.cell_w_px, cells.cell_h_px), (100, 50));
288 // ceil(1920/110)+1 = 19, ceil(1080/60)+1 = 19.
289 assert_eq!((cells.cols, cells.rows), (19, 19));
290 // The fade inset caps on the SMALLER dimension (45% of 50 = 22).
291 sp.cell_fade_inset = 60;
292 let f = grid_frame(&sp, cam(0.0, 0.0, 1.0), VW, VH, 0, 0);
293 assert_eq!(f.cells.unwrap().fade_inset_px, 22);
294 }
295
296 #[test]
297 fn pan_folds_modulo_one_period() {
298 // Pan 30 right: origin -30, rem_euclid(110) = 80, minus a period.
299 let f = grid_frame(&spec(), cam(30.0, 0.0, 1.0), VW, VH, 0, 0);
300 assert_eq!(f.tree_pos, Some((-30, -110)));
301 // A full period of pan lands back where it started.
302 let g = grid_frame(&spec(), cam(140.0, 0.0, 1.0), VW, VH, 0, 0);
303 assert_eq!(g.tree_pos, Some((-30, -110)));
304 }
305
306 #[test]
307 fn density_fade_scales_alpha_then_kills_cells() {
308 // Zoom 0.2: period 110 * 0.2 = 22 px — mid-ramp (22-15)/15.
309 let f = grid_frame(&spec(), cam(0.0, 0.0, 0.2), VW, VH, 0, 0);
310 let cells = f.cells.unwrap();
311 assert!((cells.color.0[3] - 0.6 * (7.0 / 15.0) as f32).abs() < 1e-6);
312 // Zoom 0.1: period 11 px — fully faded, backdrop only.
313 let f = grid_frame(&spec(), cam(0.0, 0.0, 0.1), VW, VH, 0, 0);
314 assert!(f.cells.is_none());
315 assert_eq!(f.period_px_x, 11);
316 assert!(f.tree_pos.is_some());
317 }
318
319 #[test]
320 fn fade_inset_caps_at_45_percent_of_cell() {
321 let mut s = spec();
322 s.cell_fade_inset = 60;
323 let f = grid_frame(&s, cam(0.0, 0.0, 1.0), VW, VH, 0, 0);
324 assert_eq!(f.cells.unwrap().fade_inset_px, 45);
325 }
326
327 #[test]
328 fn fractional_zoom_keeps_grid_world_true() {
329 // cell 512 + gap 16 = period 528; zoom 0.8 → exact period 422.4
330 // (rounds to 422). The renderer places cell k at round(k * exact):
331 // spacing alternates 422/423 so cells never drift from the
332 // world-anchored windows.
333 let mut s = spec();
334 s.cell_w = 512.0;
335 s.cell_h = 512.0;
336 s.gap_width = 16.0;
337 let f = grid_frame(&s, cam(0.0, 0.0, 0.8), 3840, 2400, 0, 0);
338 assert_eq!(f.period_px_x, 422);
339 assert!((f.period_px_exact_x - 422.4).abs() < 1e-9);
340 let pos: Vec<i32> = (0..5).map(|k| (k as f64 * f.period_px_exact_x).round() as i32).collect();
341 assert_eq!(pos, vec![0, 422, 845, 1267, 1690]);
342 // Tree phase folds over the EXACT period: pan 100 → phase
343 // (-80).rem_euclid(422.4) = 342.4 → tree at round(342.4 - 422.4).
344 let f = grid_frame(&s, cam(100.0, 0.0, 0.8), 3840, 2400, 0, 0);
345 assert_eq!(f.tree_pos.unwrap().0, -80);
346 // A pan that wraps the fold still lands world-true: pan 600 →
347 // -480 px screen shift; phase (-480).rem_euclid(422.4) = 364.8 →
348 // tree at round(-57.6) = -58 (= -480 + one 422.4 period, rounded).
349 let f = grid_frame(&s, cam(600.0, 0.0, 0.8), 3840, 2400, 0, 0);
350 assert_eq!(f.tree_pos.unwrap().0, -58);
351 }
352
353 #[test]
354 fn degenerate_zoom_and_period_are_safe() {
355 // NaN zoom falls back to 1.
356 let f = grid_frame(&spec(), cam(0.0, 0.0, f64::NAN), VW, VH, 0, 0);
357 assert_eq!(f.period_px_x, 110);
358 assert!(f.cells.is_some());
359 // A period that rounds to zero: no tree move, no cells, backdrop
360 // stays viewport-sized.
361 let f = grid_frame(&spec(), cam(0.0, 0.0, 0.001), VW, VH, 0, 0);
362 assert_eq!(f.period_px_x, 0);
363 assert!(f.tree_pos.is_none());
364 assert!(f.cells.is_none());
365 assert_eq!((f.backdrop_w, f.backdrop_h), (VW, VH));
366 }
367 }