1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
use rosace_core::types::{Point, Rect, Size};
use rosace_layout::Constraints;
use super::{Widget, Children, LayoutCtx, PaintCtx, BoxedWidget, avail_w};
/// Placement algorithm for a [`Grid`] (D115/Phase 32 Step 1).
///
/// Internal — selected through the [`Grid::staggered`] / [`Grid::bento`]
/// builders; the default (`Uniform`) is the original Grid behavior,
/// unchanged (Phase 32 Migration Rule: all additive).
#[derive(Clone, Copy, PartialEq, Eq, Default)]
enum GridMode {
/// Equal-width cells, row height = tallest child of that row (the
/// original, default behavior).
#[default]
Uniform,
/// Masonry: each child keeps its OWN measured height at the column
/// width and drops into the currently-shortest column.
Staggered,
/// Fixed lattice: items span whole columns/rows (see
/// [`Grid::child_span`]); every lattice row is [`Grid::row_height`]
/// tall.
Bento,
}
/// Default lattice row height for [`Grid::bento`] mode, in logical px.
const DEFAULT_BENTO_ROW_HEIGHT: f32 = 96.0;
/// A fixed-column grid. Children flow left→right, top→bottom into `columns`
/// equal-width cells; each row's height is its tallest child. Lays out
/// something new (not a Column/Row) — see D095.
///
/// Two additional placement modes (D115/Phase 32 Step 1):
/// - [`Grid::staggered`] — masonry packing (Pinterest-style): children keep
/// their own measured heights and fill the shortest column first.
/// - [`Grid::bento`] — a fixed lattice where children added via
/// [`Grid::child_span`] cover multiple columns/rows (dashboard tiles).
pub struct Grid {
columns: usize,
spacing: f32,
run_spacing: f32,
children: Vec<BoxedWidget>,
/// Per-child `(col_span, row_span)`, parallel to `children`. Only
/// consulted in [`GridMode::Bento`]; `(1, 1)` everywhere else.
spans: Vec<(u16, u16)>,
mode: GridMode,
/// Lattice row height for bento mode (logical px).
row_height: f32,
}
impl Grid {
/// A uniform grid with `columns` equal-width columns.
pub fn new(columns: usize) -> Self {
Self {
columns: columns.max(1),
spacing: 8.0,
run_spacing: 8.0,
children: Vec::new(),
spans: Vec::new(),
mode: GridMode::default(),
row_height: DEFAULT_BENTO_ROW_HEIGHT,
}
}
/// Horizontal gap between columns (logical px).
pub fn spacing(mut self, s: f32) -> Self { self.spacing = s; self }
/// Vertical gap between rows (logical px).
pub fn run_spacing(mut self, s: f32) -> Self { self.run_spacing = s; self }
/// Append a child (span `1×1` in bento mode).
pub fn child(mut self, w: impl Widget + 'static) -> Self {
self.children.push(Box::new(w));
self.spans.push((1, 1));
self
}
/// Append several children (each span `1×1` in bento mode).
pub fn children(mut self, ws: Vec<BoxedWidget>) -> Self {
self.spans.extend(std::iter::repeat_n((1, 1), ws.len()));
self.children.extend(ws);
self
}
/// A uniform `columns`-wide grid of `count` items, each built by calling
/// `builder(i)` — a convenience constructor for the common "N items from
/// a data source" case, so callers don't have to hand-build a `Vec`
/// first. Eager, not virtualized (all `count` children build up front;
/// for large counts where that matters, `ListView::builder` is the
/// virtualized one).
pub fn builder(columns: usize, count: usize, builder: impl Fn(usize) -> BoxedWidget) -> Self {
Self::new(columns).children((0..count).map(builder).collect())
}
/// Switch to masonry placement: each child keeps its own measured
/// height at the column width and is placed into the currently-shortest
/// column (leftmost wins ties). Layout height = the tallest column.
pub fn staggered(mut self) -> Self { self.mode = GridMode::Staggered; self }
/// Switch to bento placement: children occupy whole cells of a fixed
/// lattice (`columns` wide, rows of [`Grid::row_height`]), spanning
/// multiple columns/rows per [`Grid::child_span`]. Items are placed
/// first-fit: top-to-bottom, left-to-right, into the first free block
/// that fits their span.
pub fn bento(mut self) -> Self { self.mode = GridMode::Bento; self }
/// Append a child spanning `col_span × row_span` lattice cells and
/// switch to bento mode.
///
/// A per-child span (rather than a parallel `.bento(Vec<(u16, u16)>)`
/// span list) was chosen deliberately: the span lives at the same call
/// site as the child it describes, so conditionally-added children can
/// never silently desynchronize an index-aligned spans vector.
pub fn child_span(mut self, w: impl Widget + 'static, col_span: u16, row_span: u16) -> Self {
self.mode = GridMode::Bento;
self.children.push(Box::new(w));
self.spans.push((col_span.max(1), row_span.max(1)));
self
}
/// Lattice row height for bento mode (logical px, default `96.0`).
pub fn row_height(mut self, h: f32) -> Self { self.row_height = h.max(1.0); self }
fn cell_width(&self, total: f32) -> f32 {
let gaps = self.spacing * (self.columns.saturating_sub(1)) as f32;
((total - gaps) / self.columns as f32).max(0.0)
}
/// Measured cell sizes + total height for a given available width
/// (uniform mode).
fn measure(&self, ctx: &LayoutCtx, width: f32) -> (Vec<Size>, f32) {
let cw = self.cell_width(width);
let sizes: Vec<Size> = self.children.iter()
.map(|c| c.layout(&ctx.with_constraints(Constraints::loose(cw, f32::INFINITY))))
.collect();
let mut y = 0.0;
let mut i = 0;
while i < sizes.len() {
let row_h = sizes[i..(i + self.columns).min(sizes.len())]
.iter().map(|s| s.height).fold(0.0_f32, f32::max);
y += row_h;
if i + self.columns < sizes.len() { y += self.run_spacing; }
i += self.columns;
}
(sizes, y)
}
/// Masonry placement: per-child rects (relative to the grid origin) +
/// total content height. Each child is measured at the column width,
/// keeps its own height, and goes into the currently-shortest column.
fn arrange_staggered(&self, ctx: &LayoutCtx, width: f32) -> (Vec<Rect>, f32) {
let cw = self.cell_width(width);
let mut col_h = vec![0.0f32; self.columns];
let mut rects = Vec::with_capacity(self.children.len());
for c in &self.children {
let s = c.layout(&ctx.with_constraints(Constraints::loose(cw, f32::INFINITY)));
// Shortest column; leftmost wins ties (the masonry convention).
let mut col = 0;
for (i, h) in col_h.iter().enumerate().skip(1) {
if *h < col_h[col] { col = i; }
}
let x = col as f32 * (cw + self.spacing);
rects.push(Rect {
origin: Point { x, y: col_h[col] },
size: Size { width: cw, height: s.height },
});
col_h[col] += s.height + self.run_spacing;
}
let tallest = col_h.iter().fold(0.0_f32, |a, &h| a.max(h));
(rects, (tallest - self.run_spacing).max(0.0))
}
/// Bento placement: per-child rects (relative) + total content height.
/// First-fit on a `columns`-wide lattice of `row_height`-tall rows.
fn arrange_bento(&self, width: f32) -> (Vec<Rect>, f32) {
let cw = self.cell_width(width);
// Occupancy lattice — grown row-by-row as placements demand.
let mut occ: Vec<Vec<bool>> = Vec::new();
let mut rects = Vec::with_capacity(self.children.len());
let mut rows_used = 0usize;
for i in 0..self.children.len() {
let (cs, rs) = self.spans.get(i).copied().unwrap_or((1, 1));
let cs = (cs as usize).clamp(1, self.columns);
let rs = (rs as usize).max(1);
let (row, col) = Self::first_fit(&occ, self.columns, cs, rs);
// Grow the lattice and mark the block occupied.
while occ.len() < row + rs { occ.push(vec![false; self.columns]); }
for cells in occ.iter_mut().take(row + rs).skip(row) {
for cell in cells.iter_mut().take(col + cs).skip(col) { *cell = true; }
}
rows_used = rows_used.max(row + rs);
rects.push(Rect {
origin: Point {
x: col as f32 * (cw + self.spacing),
y: row as f32 * (self.row_height + self.run_spacing),
},
size: Size {
width: cs as f32 * cw + (cs - 1) as f32 * self.spacing,
height: rs as f32 * self.row_height + (rs - 1) as f32 * self.run_spacing,
},
});
}
let total = if rows_used == 0 {
0.0
} else {
rows_used as f32 * self.row_height + (rows_used - 1) as f32 * self.run_spacing
};
(rects, total)
}
/// First lattice position `(row, col)` where a `cs × rs` block fits.
/// Always terminates: every row at/after `occ.len()` is empty.
fn first_fit(occ: &[Vec<bool>], columns: usize, cs: usize, rs: usize) -> (usize, usize) {
for row in 0..=occ.len() {
for col in 0..=(columns - cs) {
let fits = (row..row + rs).all(|r| {
occ.get(r).is_none_or(|cells| !cells[col..col + cs].iter().any(|&o| o))
});
if fits { return (row, col); }
}
}
(occ.len(), 0) // unreachable — the all-empty `occ.len()` row always fits
}
/// Relative child rects + content height for the non-uniform modes.
fn arrange(&self, ctx: &LayoutCtx, width: f32) -> (Vec<Rect>, f32) {
match self.mode {
GridMode::Staggered => self.arrange_staggered(ctx, width),
// Uniform never routes here (kept on its original row-based
// path in layout/paint); bento is the only other arm.
_ => self.arrange_bento(width),
}
}
}
impl Widget for Grid {
fn children(&self) -> Children<'_> { Children::Many(&self.children) }
fn layout(&self, ctx: &LayoutCtx) -> Size {
let w = avail_w(ctx.constraints);
let h = match self.mode {
GridMode::Uniform => self.measure(ctx, w).1,
_ => self.arrange(ctx, w).1,
};
ctx.constraints.constrain(Size { width: w, height: h })
}
fn paint(&self, ctx: &mut PaintCtx) {
let r = ctx.rect;
if self.mode != GridMode::Uniform {
let (rects, _) = self.arrange(
&ctx.layout_ctx(Constraints::loose(r.size.width, f32::INFINITY)),
r.size.width,
);
for (child, rel) in self.children.iter().zip(rects) {
let rect = Rect {
origin: Point { x: r.origin.x + rel.origin.x, y: r.origin.y + rel.origin.y },
size: rel.size,
};
child.paint(&mut ctx.child(rect));
}
return;
}
let cw = self.cell_width(r.size.width);
let (sizes, _) = self.measure(&ctx.layout_ctx(Constraints::loose(r.size.width, r.size.height)), r.size.width);
let mut y = r.origin.y;
let mut i = 0;
while i < self.children.len() {
let end = (i + self.columns).min(self.children.len());
let row_h = sizes[i..end].iter().map(|s| s.height).fold(0.0_f32, f32::max);
for (col, idx) in (i..end).enumerate() {
let x = r.origin.x + col as f32 * (cw + self.spacing);
let rect = Rect { origin: Point { x, y }, size: Size { width: cw, height: row_h } };
self.children[idx].paint(&mut ctx.child(rect));
}
y += row_h + self.run_spacing;
i += self.columns;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A leaf reporting a fixed size regardless of constraints.
struct Fixed(f32, f32);
impl Widget for Fixed {
fn layout(&self, _ctx: &LayoutCtx) -> Size {
Size { width: self.0, height: self.1 }
}
fn paint(&self, _ctx: &mut PaintCtx) {}
}
fn test_env() -> (rosace_render::FontCache, rosace_theme::ThemeData) {
(rosace_render::FontCache::embedded(), rosace_theme::built_in::dark_theme())
}
#[test]
fn staggered_packs_items_into_the_shortest_column() {
// 2 columns, no gaps, 300px wide → 150px cells. Heights 40/100/20/30:
// item0 → col0 (y 0), item1 → col1 (y 0), item2 → col0 (y 40,
// shortest), item3 → col0 again (y 60; col0 = 60 < col1 = 100).
let grid = Grid::new(2)
.spacing(0.0)
.run_spacing(0.0)
.staggered()
.child(Fixed(150.0, 40.0))
.child(Fixed(150.0, 100.0))
.child(Fixed(150.0, 20.0))
.child(Fixed(150.0, 30.0));
let (font, theme) = test_env();
let ctx = LayoutCtx::new(Constraints::loose(300.0, 1000.0), &font, &theme);
let (rects, height) = grid.arrange_staggered(&ctx, 300.0);
assert_eq!((rects[0].origin.x, rects[0].origin.y), (0.0, 0.0));
assert_eq!((rects[1].origin.x, rects[1].origin.y), (150.0, 0.0));
assert_eq!((rects[2].origin.x, rects[2].origin.y), (0.0, 40.0));
assert_eq!((rects[3].origin.x, rects[3].origin.y), (0.0, 60.0));
// Tallest column: col1 at 100 (col0 ends at 90).
assert_eq!(height, 100.0);
assert_eq!(grid.layout(&ctx).height, 100.0);
}
#[test]
fn staggered_children_keep_their_own_heights() {
let grid = Grid::new(2)
.spacing(0.0)
.run_spacing(0.0)
.staggered()
.child(Fixed(150.0, 40.0))
.child(Fixed(150.0, 100.0));
let (font, theme) = test_env();
let ctx = LayoutCtx::new(Constraints::loose(300.0, 1000.0), &font, &theme);
let (rects, _) = grid.arrange_staggered(&ctx, 300.0);
assert_eq!(rects[0].size.height, 40.0);
assert_eq!(rects[1].size.height, 100.0);
}
#[test]
fn bento_honors_column_and_row_spans() {
// 2 columns, no gaps, 200px wide → 100px cells, 50px lattice rows.
// item0 spans 2×1 (full first row), item1/item2 fill row 1,
// item3 spans 1×2 (rows 2-3, col 0).
let grid = Grid::new(2)
.spacing(0.0)
.run_spacing(0.0)
.row_height(50.0)
.child_span(Fixed(1.0, 1.0), 2, 1)
.child_span(Fixed(1.0, 1.0), 1, 1)
.child_span(Fixed(1.0, 1.0), 1, 1)
.child_span(Fixed(1.0, 1.0), 1, 2);
let (rects, height) = grid.arrange_bento(200.0);
assert_eq!((rects[0].origin.x, rects[0].origin.y), (0.0, 0.0));
assert_eq!((rects[0].size.width, rects[0].size.height), (200.0, 50.0));
assert_eq!((rects[1].origin.x, rects[1].origin.y), (0.0, 50.0));
assert_eq!((rects[2].origin.x, rects[2].origin.y), (100.0, 50.0));
assert_eq!((rects[3].origin.x, rects[3].origin.y), (0.0, 100.0));
assert_eq!((rects[3].size.width, rects[3].size.height), (100.0, 100.0));
// 4 lattice rows × 50px.
assert_eq!(height, 200.0);
}
#[test]
fn bento_first_fit_backfills_gaps_beside_tall_items() {
// 2 columns: item0 is 1×2 (col 0, rows 0-1); item1 (1×1) must land
// beside it at (row 0, col 1), not below it.
let grid = Grid::new(2)
.spacing(0.0)
.run_spacing(0.0)
.row_height(50.0)
.child_span(Fixed(1.0, 1.0), 1, 2)
.child_span(Fixed(1.0, 1.0), 1, 1);
let (rects, height) = grid.arrange_bento(200.0);
assert_eq!((rects[1].origin.x, rects[1].origin.y), (100.0, 0.0));
assert_eq!(height, 100.0);
}
#[test]
fn uniform_default_behavior_is_unchanged() {
// Regression guard for the Migration Rule: a plain Grid::new still
// lays out row-by-row with row height = tallest child.
let grid = Grid::new(2)
.spacing(0.0)
.run_spacing(0.0)
.child(Fixed(150.0, 40.0))
.child(Fixed(150.0, 100.0))
.child(Fixed(150.0, 20.0));
let (font, theme) = test_env();
let ctx = LayoutCtx::new(Constraints::loose(300.0, 1000.0), &font, &theme);
// Row 0 = max(40, 100) = 100; row 1 = 20 → 120 total.
assert_eq!(grid.layout(&ctx).height, 120.0);
}
}