Skip to main content

mimick/library/masonry/
layout.rs

1//! Pure layout math for the justified-row masonry grid.
2//!
3//! `pack_rows` greedily fills rows up to `canvas_w` then scales each row to
4//! fit. Includes `O(log n)` row lookup helpers used by the snapshot + hit-test
5//! paths.
6
7const FALLBACK_W: f32 = 4.0;
8const FALLBACK_H: f32 = 3.0;
9
10pub(crate) const MIN_ROW_HEIGHT_COMPACT: f32 = 48.0;
11pub(crate) const MAX_ROW_HEIGHT_COMPACT: f32 = 80.0;
12pub(crate) const MIN_ROW_HEIGHT_NARROW: f32 = 60.0;
13pub(crate) const MAX_ROW_HEIGHT_NARROW: f32 = 120.0;
14pub(crate) const MIN_ROW_HEIGHT_WIDE: f32 = 180.0;
15pub(crate) const MAX_ROW_HEIGHT_WIDE: f32 = 360.0;
16
17pub(crate) const GAP: f32 = 0.0;
18
19#[derive(Debug, Clone, Copy, PartialEq)]
20pub struct LaidItem {
21    pub asset_index: u32,
22    pub x: f32,
23    pub w: f32,
24}
25
26#[derive(Debug, Clone, PartialEq)]
27pub struct LaidRow {
28    pub y: f32,
29    pub h: f32,
30    pub items: Vec<LaidItem>,
31}
32
33#[derive(Debug, Clone, Copy)]
34pub struct LayoutConfig {
35    pub min_row_height: f32,
36    pub max_row_height: f32,
37    pub gap: f32,
38}
39
40impl LayoutConfig {
41    /// Compact: very small tiles for sub-400px widths (phone screens).
42    pub(crate) fn compact() -> Self {
43        Self {
44            min_row_height: MIN_ROW_HEIGHT_COMPACT,
45            max_row_height: MAX_ROW_HEIGHT_COMPACT,
46            gap: GAP,
47        }
48    }
49
50    pub(crate) fn narrow() -> Self {
51        Self {
52            min_row_height: MIN_ROW_HEIGHT_NARROW,
53            max_row_height: MAX_ROW_HEIGHT_NARROW,
54            gap: GAP,
55        }
56    }
57
58    pub(crate) fn wide() -> Self {
59        Self {
60            min_row_height: MIN_ROW_HEIGHT_WIDE,
61            max_row_height: MAX_ROW_HEIGHT_WIDE,
62            gap: GAP,
63        }
64    }
65
66    /// Override the gap for this config (used for configurable border width).
67    pub(crate) fn with_gap(mut self, gap: f32) -> Self {
68        self.gap = gap;
69        self
70    }
71}
72
73fn aspect(width: u32, height: u32) -> f32 {
74    if width == 0 || height == 0 {
75        FALLBACK_W / FALLBACK_H
76    } else {
77        (width as f32) / (height as f32)
78    }
79}
80
81/// Greedy justified-row pack. `dims[i] = (w, h)` for asset i.
82pub(crate) fn pack_rows(
83    dims: &[(u32, u32)],
84    canvas_w: f32,
85    cfg: LayoutConfig,
86) -> (Vec<LaidRow>, f32) {
87    if dims.is_empty() || canvas_w <= 0.0 {
88        return (Vec::new(), 0.0);
89    }
90
91    // When a border gap is configured, inset the content area so the same
92    // gap appears between tiles AND between tiles and the window edges.
93    let edge = cfg.gap;
94    let inner_w = (canvas_w - 2.0 * edge).max(1.0);
95
96    let mut rows: Vec<LaidRow> = Vec::new();
97    let mut y_cursor = edge;
98    let mut i = 0_usize;
99
100    while i < dims.len() {
101        let (indices, next_i) = collect_row_indices(dims, i, inner_w, cfg);
102        i = next_i;
103
104        let last_row = i >= dims.len();
105        let mut row = build_row(dims, indices, inner_w, y_cursor, last_row, cfg);
106
107        // Offset each item's x position by the edge inset.
108        if edge > 0.0 {
109            for item in &mut row.items {
110                item.x += edge;
111            }
112        }
113
114        y_cursor += row.h + cfg.gap;
115        rows.push(row);
116    }
117
118    let total_height = (y_cursor - cfg.gap + edge).max(0.0);
119    (rows, total_height)
120}
121
122/// Greedily collect item indices that fit into a single row at max height.
123fn collect_row_indices(
124    dims: &[(u32, u32)],
125    start: usize,
126    canvas_w: f32,
127    cfg: LayoutConfig,
128) -> (Vec<usize>, usize) {
129    let mut indices: Vec<usize> = Vec::new();
130    let mut summed_w = 0.0_f32;
131    let mut i = start;
132    while i < dims.len() {
133        let w_at_max = aspect(dims[i].0, dims[i].1) * cfg.max_row_height;
134        let gap_before = if indices.is_empty() { 0.0 } else { cfg.gap };
135        if !indices.is_empty() && summed_w + gap_before + w_at_max > canvas_w {
136            break;
137        }
138        indices.push(i);
139        summed_w += w_at_max + gap_before;
140        i += 1;
141    }
142    (indices, i)
143}
144
145/// Scale, clamp, and place items into a single laid-out row.
146fn build_row(
147    dims: &[(u32, u32)],
148    mut indices: Vec<usize>,
149    canvas_w: f32,
150    y: f32,
151    last_row: bool,
152    cfg: LayoutConfig,
153) -> LaidRow {
154    let mut row_h = scale_to_fit(&indices, dims, canvas_w, cfg);
155
156    // Pop the trailing item if the row is too short -- it spills to the next row
157    // (handled by the caller via the returned next index).
158    if indices.len() > 1 && row_h < cfg.min_row_height {
159        indices.pop();
160        row_h = scale_to_fit(&indices, dims, canvas_w, cfg);
161    }
162
163    if last_row {
164        row_h = row_h.clamp(cfg.min_row_height, cfg.max_row_height);
165    }
166
167    let mut placed = Vec::with_capacity(indices.len());
168    let mut x_cursor = 0.0_f32;
169    for &idx in &indices {
170        let w = aspect(dims[idx].0, dims[idx].1) * row_h;
171        placed.push(LaidItem {
172            asset_index: idx as u32,
173            x: x_cursor,
174            w,
175        });
176        x_cursor += w + cfg.gap;
177    }
178
179    LaidRow {
180        y,
181        h: row_h,
182        items: placed,
183    }
184}
185
186fn scale_to_fit(indices: &[usize], dims: &[(u32, u32)], canvas_w: f32, cfg: LayoutConfig) -> f32 {
187    let total_gap = if indices.len() > 1 {
188        cfg.gap * (indices.len() as f32 - 1.0)
189    } else {
190        0.0
191    };
192    let sum: f32 = indices
193        .iter()
194        .map(|&idx| aspect(dims[idx].0, dims[idx].1) * cfg.max_row_height)
195        .sum();
196    if sum <= 0.0 {
197        return cfg.max_row_height;
198    }
199    let scale = ((canvas_w - total_gap) / sum).max(0.0);
200    cfg.max_row_height * scale
201}
202
203pub(crate) fn first_row_at_or_after(rows: &[LaidRow], y: f32) -> usize {
204    let mut lo = 0;
205    let mut hi = rows.len();
206    while lo < hi {
207        let mid = lo + (hi - lo) / 2;
208        if rows[mid].y + rows[mid].h < y {
209            lo = mid + 1;
210        } else {
211            hi = mid;
212        }
213    }
214    lo
215}
216
217pub(crate) fn row_at_y(rows: &[LaidRow], y: f32) -> Option<usize> {
218    if rows.is_empty() {
219        return None;
220    }
221    let mut lo = 0;
222    let mut hi = rows.len();
223    while lo < hi {
224        let mid = lo + (hi - lo) / 2;
225        let r = &rows[mid];
226        if y < r.y {
227            hi = mid;
228        } else if y >= r.y + r.h {
229            lo = mid + 1;
230        } else {
231            return Some(mid);
232        }
233    }
234    None
235}
236
237pub(crate) fn item_at_x(row: &LaidRow, x: f32) -> Option<&LaidItem> {
238    row.items.iter().find(|it| x >= it.x && x < it.x + it.w)
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244
245    fn cfg() -> LayoutConfig {
246        LayoutConfig {
247            min_row_height: 100.0,
248            max_row_height: 200.0,
249            gap: 0.0,
250        }
251    }
252
253    #[test]
254    fn empty_input_yields_empty_layout() {
255        let (rows, h) = pack_rows(&[], 1000.0, cfg());
256        assert!(rows.is_empty());
257        assert_eq!(h, 0.0);
258    }
259
260    #[test]
261    fn zero_canvas_width_yields_empty() {
262        let (rows, h) = pack_rows(&[(100, 100)], 0.0, cfg());
263        assert!(rows.is_empty());
264        assert_eq!(h, 0.0);
265    }
266
267    #[test]
268    fn fallback_aspect_when_dimensions_zero() {
269        let (rows, _) = pack_rows(&[(0, 0), (0, 0), (0, 0)], 1200.0, cfg());
270        assert_eq!(rows.len(), 1);
271        assert!((rows[0].h - 200.0).abs() < 0.01);
272    }
273
274    #[test]
275    fn full_row_fills_canvas_width_within_a_pixel() {
276        let dims = &[(1600, 900), (1600, 900), (1600, 900), (1600, 900)];
277        let (rows, _) = pack_rows(dims, 1200.0, cfg());
278        let r1 = &rows[0];
279        let last = r1.items.last().unwrap();
280        let fill = last.x + last.w;
281        assert!((fill - 1200.0).abs() < 1.0);
282    }
283
284    #[test]
285    fn all_items_placed_across_rows() {
286        let dims: Vec<(u32, u32)> = (0..8).map(|_| (3000, 1000)).collect();
287        let (rows, _) = pack_rows(&dims, 1200.0, cfg());
288        let total: usize = rows.iter().map(|r| r.items.len()).sum();
289        assert_eq!(total, 8);
290    }
291
292    #[test]
293    fn last_row_clamped_to_max_height() {
294        let mut dims: Vec<(u32, u32)> = (0..6).map(|_| (4000, 3000)).collect();
295        dims.push((1000, 1500));
296        let (rows, _) = pack_rows(&dims, 1200.0, cfg());
297        let last = rows.last().unwrap();
298        assert!(last.h <= 200.0 + 0.01);
299    }
300
301    #[test]
302    fn binary_search_finds_correct_row() {
303        let rows = vec![
304            LaidRow {
305                y: 0.0,
306                h: 100.0,
307                items: vec![],
308            },
309            LaidRow {
310                y: 100.0,
311                h: 150.0,
312                items: vec![],
313            },
314            LaidRow {
315                y: 250.0,
316                h: 80.0,
317                items: vec![],
318            },
319        ];
320        assert_eq!(row_at_y(&rows, 0.0), Some(0));
321        assert_eq!(row_at_y(&rows, 100.0), Some(1));
322        assert_eq!(row_at_y(&rows, 329.9), Some(2));
323        assert_eq!(row_at_y(&rows, 330.0), None);
324    }
325
326    #[test]
327    fn item_hit_test_within_row() {
328        let row = LaidRow {
329            y: 0.0,
330            h: 100.0,
331            items: vec![
332                LaidItem {
333                    asset_index: 5,
334                    x: 0.0,
335                    w: 50.0,
336                },
337                LaidItem {
338                    asset_index: 6,
339                    x: 50.0,
340                    w: 80.0,
341                },
342                LaidItem {
343                    asset_index: 7,
344                    x: 130.0,
345                    w: 40.0,
346                },
347            ],
348        };
349        assert_eq!(item_at_x(&row, 0.0).map(|i| i.asset_index), Some(5));
350        assert_eq!(item_at_x(&row, 50.0).map(|i| i.asset_index), Some(6));
351        assert_eq!(item_at_x(&row, 130.0).map(|i| i.asset_index), Some(7));
352        assert!(item_at_x(&row, 200.0).is_none());
353    }
354
355    #[test]
356    fn gap_increases_total_layout_height() {
357        let dims = &[(100, 100), (100, 100), (100, 100), (100, 100)];
358        let (_, h0) = pack_rows(dims, 1200.0, LayoutConfig { gap: 0.0, ..cfg() });
359        let (_, h1) = pack_rows(dims, 1200.0, LayoutConfig { gap: 10.0, ..cfg() });
360        assert!(h1 > h0);
361    }
362
363    #[test]
364    fn first_row_skip_lands_on_intersecting_row() {
365        let rows = vec![
366            LaidRow {
367                y: 0.0,
368                h: 100.0,
369                items: vec![],
370            },
371            LaidRow {
372                y: 100.0,
373                h: 100.0,
374                items: vec![],
375            },
376            LaidRow {
377                y: 200.0,
378                h: 100.0,
379                items: vec![],
380            },
381        ];
382        assert_eq!(first_row_at_or_after(&rows, -50.0), 0);
383        assert_eq!(first_row_at_or_after(&rows, 0.0), 0);
384        assert_eq!(first_row_at_or_after(&rows, 150.0), 1);
385        assert_eq!(first_row_at_or_after(&rows, 250.0), 2);
386        assert_eq!(first_row_at_or_after(&rows, 500.0), 3);
387    }
388}