git.lucas.co / cce-window-manager
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 }