teksilo_widgets/primitives/
masonry.rs1use teksilo_canvas::{Point, Rect, Size, SizeProposal};
27use teksilo_core::accessibility::AccessNodeBuilder;
28use teksilo_core::widget::{LayoutContext, PaintContext, PendingChild, Widget, WidgetPlacement};
29use teksilo_core::widget_id::WidgetId;
30
31#[derive(Debug)]
49pub struct MasonryLayout {
50 column_count: usize,
51 column_spacing: f32,
52 item_spacing: f32,
53 child_ids: Vec<WidgetId>,
54 pending: Vec<PendingChild>,
55}
56
57impl MasonryLayout {
58 pub fn new(column_count: usize) -> Self {
62 Self {
63 column_count: column_count.max(1),
64 column_spacing: 0.0,
65 item_spacing: 0.0,
66 child_ids: Vec::new(),
67 pending: Vec::new(),
68 }
69 }
70
71 pub fn column_spacing(mut self, spacing: f32) -> Self {
73 self.column_spacing = spacing;
74 self
75 }
76
77 pub fn item_spacing(mut self, spacing: f32) -> Self {
79 self.item_spacing = spacing;
80 self
81 }
82
83 pub fn add_child(mut self, id: WidgetId) -> Self {
85 self.pending.push(PendingChild::Id(id));
86 self
87 }
88
89 pub fn child(mut self, widget: impl Widget + 'static) -> Self {
91 self.pending.push(PendingChild::Deferred(Box::new(widget)));
92 self
93 }
94
95 pub fn children(mut self, iter: impl IntoIterator<Item = impl Widget + 'static>) -> Self {
97 for widget in iter {
98 self.pending.push(PendingChild::Deferred(Box::new(widget)));
99 }
100 self
101 }
102
103 pub fn child_opt(mut self, widget: Option<impl Widget + 'static>) -> Self {
105 if let Some(w) = widget {
106 self.pending.push(PendingChild::Deferred(Box::new(w)));
107 }
108 self
109 }
110
111 fn column_width(&self, available_width: f32) -> f32 {
113 let gaps = self.column_spacing * (self.column_count as f32 - 1.0).max(0.0);
114 ((available_width - gaps) / self.column_count as f32).max(0.0)
115 }
116
117 fn shortest_column(col_heights: &[f32]) -> usize {
120 col_heights
121 .iter()
122 .enumerate()
123 .min_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
124 .map(|(i, _)| i)
125 .unwrap_or(0)
126 }
127}
128
129impl Default for MasonryLayout {
130 fn default() -> Self {
131 Self::new(2)
132 }
133}
134
135impl Widget for MasonryLayout {
136 fn build(&mut self, ctx: &mut teksilo_core::build_context::BuildContext) -> Vec<WidgetId> {
137 let pending = std::mem::take(&mut self.pending);
138 if !pending.is_empty() {
139 self.child_ids = pending
140 .into_iter()
141 .map(|child| match child {
142 PendingChild::Id(id) => id,
143 PendingChild::Deferred(w) => ctx.add_boxed(w),
144 })
145 .collect();
146 }
147 self.child_ids.clone()
148 }
149
150 fn layout_response(
151 &self,
152 proposal: SizeProposal,
153 ctx: &LayoutContext,
154 ) -> teksilo_core::widget::LayoutResponse {
155 if self.child_ids.is_empty() {
156 return (proposal.resolve(0.0, 0.0)).into();
157 }
158
159 let (total_width, col_width) = if let Some(w) = proposal.width {
160 (w, self.column_width(w))
161 } else {
162 let mut max_w = 0.0_f32;
164 for &child_id in &self.child_ids {
165 if let Some(s) = ctx.child_size(child_id, SizeProposal::unspecified()) {
166 max_w = max_w.max(s.width);
167 }
168 }
169 let gaps = self.column_spacing * (self.column_count as f32 - 1.0).max(0.0);
170 let total = max_w * self.column_count as f32 + gaps;
171 (total, max_w)
172 };
173
174 let child_proposal = SizeProposal::with_width(col_width);
176 let mut col_heights = vec![0.0_f32; self.column_count];
177
178 for &child_id in &self.child_ids {
179 if let Some(child_size) = ctx.child_size(child_id, child_proposal) {
180 let col = Self::shortest_column(&col_heights);
181 if col_heights[col] > 0.0 {
182 col_heights[col] += self.item_spacing;
183 }
184 col_heights[col] += child_size.height;
185 }
186 }
187
188 let total_height = col_heights.iter().copied().fold(0.0_f32, f32::max);
189 Size::new(total_width, total_height).into()
190 }
191
192 fn place_children(
193 &self,
194 bounds: Rect,
195 _proposal: SizeProposal,
196 children: &mut [WidgetPlacement],
197 ctx: &LayoutContext,
198 ) {
199 if children.is_empty() {
200 return;
201 }
202
203 let col_width = self.column_width(bounds.width);
204 let rtl = ctx.is_rtl();
205
206 let col_x: Vec<f32> = (0..self.column_count)
208 .map(|i| {
209 let physical_col = if rtl { self.column_count - 1 - i } else { i };
210 bounds.x + physical_col as f32 * (col_width + self.column_spacing)
211 })
212 .collect();
213
214 let mut col_y = vec![bounds.y; self.column_count];
215
216 let child_proposal = SizeProposal::with_width(col_width);
217 for child in children.iter_mut() {
218 let child_size = ctx
219 .child_size(child.id, child_proposal)
220 .unwrap_or(Size::ZERO);
221
222 let col = Self::shortest_column(&col_y);
223
224 if col_y[col] > bounds.y {
225 col_y[col] += self.item_spacing;
226 }
227
228 child.origin = Point::new(col_x[col], col_y[col]);
229 child.size = Size::new(col_width, child_size.height);
230 col_y[col] += child_size.height;
231 }
232 }
233
234 fn paint(&self, _bounds: Rect, _canvas: &mut teksilo_canvas::Canvas, _ctx: &PaintContext) {}
235
236 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
237 builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
238 }
239
240 fn children(&self) -> Vec<WidgetId> {
241 self.child_ids.clone()
242 }
243}
244
245#[cfg(test)]
246mod tests {
247 use super::*;
248 use teksilo_core::widget_tree::WidgetTree;
249
250 #[derive(Debug)]
251 struct FixedLeaf(f32, f32);
252 impl Widget for FixedLeaf {
253 fn layout_response(
254 &self,
255 _proposal: SizeProposal,
256 _ctx: &LayoutContext,
257 ) -> teksilo_core::widget::LayoutResponse {
258 Size::new(self.0, self.1).into()
259 }
260 }
261
262 #[test]
263 fn equal_height_items_fill_columns_evenly() {
264 let mut tree = WidgetTree::new();
265 let items: Vec<_> = (0..6).map(|_| tree.add(FixedLeaf(50.0, 40.0))).collect();
266 let _m = tree.add(
267 MasonryLayout::new(3)
268 .add_child(items[0])
269 .add_child(items[1])
270 .add_child(items[2])
271 .add_child(items[3])
272 .add_child(items[4])
273 .add_child(items[5]),
274 );
275 tree.layout(SizeProposal::exact(300.0, 400.0));
277
278 assert!((tree.bounds(items[0]).y - 0.0).abs() < 0.01);
280 assert!((tree.bounds(items[1]).y - 0.0).abs() < 0.01);
281 assert!((tree.bounds(items[2]).y - 0.0).abs() < 0.01);
282 assert!((tree.bounds(items[3]).y - 40.0).abs() < 0.01);
284 assert!((tree.bounds(items[4]).y - 40.0).abs() < 0.01);
285 assert!((tree.bounds(items[5]).y - 40.0).abs() < 0.01);
286 }
287
288 #[test]
289 fn variable_height_items_go_to_shortest_column() {
290 let mut tree = WidgetTree::new();
291 let a = tree.add(FixedLeaf(50.0, 100.0));
293 let b = tree.add(FixedLeaf(50.0, 30.0));
294 let c = tree.add(FixedLeaf(50.0, 30.0));
295 let d = tree.add(FixedLeaf(50.0, 20.0));
296 let _m = tree.add(
297 MasonryLayout::new(3)
298 .add_child(a)
299 .add_child(b)
300 .add_child(c)
301 .add_child(d),
302 );
303 tree.layout(SizeProposal::exact(300.0, 400.0));
304
305 assert!((tree.bounds(d).x - 100.0).abs() < 0.01); assert!((tree.bounds(d).y - 30.0).abs() < 0.01); }
310
311 #[test]
312 fn column_spacing_applied() {
313 let mut tree = WidgetTree::new();
314 let a = tree.add(FixedLeaf(50.0, 40.0));
315 let b = tree.add(FixedLeaf(50.0, 40.0));
316 let c = tree.add(FixedLeaf(50.0, 40.0));
317 let _m = tree.add(
318 MasonryLayout::new(3)
319 .column_spacing(10.0)
320 .add_child(a)
321 .add_child(b)
322 .add_child(c),
323 );
324 tree.layout(SizeProposal::exact(320.0, 200.0));
326
327 assert!((tree.bounds(a).x - 0.0).abs() < 0.01);
328 assert!((tree.bounds(b).x - 110.0).abs() < 0.01); assert!((tree.bounds(c).x - 220.0).abs() < 0.01); }
331
332 #[test]
333 fn item_spacing_applied() {
334 let mut tree = WidgetTree::new();
335 let a = tree.add(FixedLeaf(50.0, 40.0));
336 let b = tree.add(FixedLeaf(50.0, 50.0));
337 let c = tree.add(FixedLeaf(50.0, 20.0));
338 let d = tree.add(FixedLeaf(50.0, 20.0));
339 let _m = tree.add(
340 MasonryLayout::new(2)
341 .item_spacing(8.0)
342 .add_child(a)
343 .add_child(b)
344 .add_child(c)
345 .add_child(d),
346 );
347 tree.layout(SizeProposal::exact(200.0, 400.0));
348
349 assert!((tree.bounds(c).y - 48.0).abs() < 0.01);
352 assert!((tree.bounds(d).y - 58.0).abs() < 0.01);
354 }
355
356 #[test]
357 fn intrinsic_height_is_tallest_column() {
358 let mut tree = WidgetTree::new();
359 let a = tree.add(FixedLeaf(50.0, 100.0));
360 let b = tree.add(FixedLeaf(50.0, 30.0));
361 let c = tree.add(FixedLeaf(50.0, 30.0));
362 let m = tree.add(MasonryLayout::new(2).add_child(a).add_child(b).add_child(c));
363 tree.layout(SizeProposal {
364 width: Some(200.0),
365 height: None,
366 });
367
368 assert!((tree.bounds(m).height - 100.0).abs() < 0.01);
371 }
372
373 #[test]
374 fn single_child_goes_to_first_column() {
375 let mut tree = WidgetTree::new();
376 let a = tree.add(FixedLeaf(50.0, 40.0));
377 let _m = tree.add(MasonryLayout::new(3).add_child(a));
378 tree.layout(SizeProposal::exact(300.0, 200.0));
379
380 assert!((tree.bounds(a).x - 0.0).abs() < 0.01);
381 assert!((tree.bounds(a).y - 0.0).abs() < 0.01);
382 }
383
384 #[test]
385 fn empty_masonry_has_zero_size() {
386 let mut tree = WidgetTree::new();
387 let m = tree.add(MasonryLayout::new(3));
388 tree.layout(SizeProposal {
389 width: Some(300.0),
390 height: None,
391 });
392
393 assert!((tree.bounds(m).height - 0.0).abs() < 0.01);
394 }
395
396 #[test]
397 fn dormant_child_excluded_from_layout() {
398 let mut tree = WidgetTree::new();
399 let a = tree.add(FixedLeaf(50.0, 40.0));
400 let b = tree.add(FixedLeaf(50.0, 30.0));
401 let c = tree.add(FixedLeaf(50.0, 20.0));
402 let _m = tree.add(MasonryLayout::new(2).add_child(a).add_child(b).add_child(c));
403 tree.layout(SizeProposal::exact(200.0, 200.0));
404
405 assert!((tree.bounds(c).x - 100.0).abs() < 0.01); tree.set_dormant(b);
410 tree.layout(SizeProposal::exact(200.0, 200.0));
411
412 assert!((tree.bounds(a).x - 0.0).abs() < 0.01);
414 assert!((tree.bounds(c).x - 100.0).abs() < 0.01);
415 assert!((tree.bounds(c).y - 0.0).abs() < 0.01);
416 }
417
418 #[test]
419 fn children_receive_column_width() {
420 let mut tree = WidgetTree::new();
421 let a = tree.add(FixedLeaf(50.0, 40.0));
422 let b = tree.add(FixedLeaf(50.0, 40.0));
423 let _m = tree.add(MasonryLayout::new(2).add_child(a).add_child(b));
424 tree.layout(SizeProposal::exact(200.0, 200.0));
426
427 assert!((tree.bounds(a).width - 100.0).abs() < 0.01);
429 assert!((tree.bounds(b).width - 100.0).abs() < 0.01);
430 }
431
432 #[test]
433 fn fewer_children_than_columns() {
434 let mut tree = WidgetTree::new();
435 let a = tree.add(FixedLeaf(50.0, 40.0));
436 let b = tree.add(FixedLeaf(50.0, 30.0));
437 let _m = tree.add(MasonryLayout::new(4).add_child(a).add_child(b));
438 tree.layout(SizeProposal::exact(400.0, 200.0));
439
440 assert!((tree.bounds(a).x - 0.0).abs() < 0.01);
442 assert!((tree.bounds(b).x - 100.0).abs() < 0.01);
443 }
444
445 #[test]
446 fn rtl_mirrors_column_order() {
447 let mut tree = WidgetTree::new();
448 tree.set_layout_direction(teksilo_core::environment::LayoutDirection::RightToLeft);
449 let a = tree.add(FixedLeaf(50.0, 40.0));
450 let b = tree.add(FixedLeaf(50.0, 30.0));
451 let c = tree.add(FixedLeaf(50.0, 20.0));
452 let _m = tree.add(MasonryLayout::new(3).add_child(a).add_child(b).add_child(c));
453 tree.layout(SizeProposal::exact(300.0, 200.0));
455
456 assert!((tree.bounds(a).x - 200.0).abs() < 0.01);
461 assert!((tree.bounds(b).x - 100.0).abs() < 0.01);
462 assert!((tree.bounds(c).x - 0.0).abs() < 0.01);
463 }
464
465 #[test]
466 fn unbounded_width_uses_intrinsic() {
467 let mut tree = WidgetTree::new();
468 let a = tree.add(FixedLeaf(80.0, 40.0));
469 let b = tree.add(FixedLeaf(60.0, 30.0));
470 let m = tree.add(MasonryLayout::new(3).add_child(a).add_child(b));
471 tree.layout(SizeProposal {
472 width: None,
473 height: Some(200.0),
474 });
475
476 assert!((tree.bounds(m).width - 240.0).abs() < 0.01);
478 }
479}