1const 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 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 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
81pub(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 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 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
122fn 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
145fn 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 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}