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 child(mut self, widget: impl teksilo_core::IntoTeksiChild) -> Self {
85 self.pending
86 .push(teksilo_core::IntoTeksiChild::into_pending(widget));
87 self
88 }
89
90 pub fn children(
92 mut self,
93 iter: impl IntoIterator<Item = impl teksilo_core::IntoTeksiChild>,
94 ) -> Self {
95 for widget in iter {
96 self.pending
97 .push(teksilo_core::IntoTeksiChild::into_pending(widget));
98 }
99 self
100 }
101
102 pub fn child_opt(mut self, widget: Option<impl teksilo_core::IntoTeksiChild>) -> Self {
104 if let Some(w) = widget {
105 self.pending
106 .push(teksilo_core::IntoTeksiChild::into_pending(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 .child(items[0])
269 .child(items[1])
270 .child(items[2])
271 .child(items[3])
272 .child(items[4])
273 .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(MasonryLayout::new(3).child(a).child(b).child(c).child(d));
297 tree.layout(SizeProposal::exact(300.0, 400.0));
298
299 assert!((tree.bounds(d).x - 100.0).abs() < 0.01); assert!((tree.bounds(d).y - 30.0).abs() < 0.01); }
304
305 #[test]
306 fn column_spacing_applied() {
307 let mut tree = WidgetTree::new();
308 let a = tree.add(FixedLeaf(50.0, 40.0));
309 let b = tree.add(FixedLeaf(50.0, 40.0));
310 let c = tree.add(FixedLeaf(50.0, 40.0));
311 let _m = tree.add(
312 MasonryLayout::new(3)
313 .column_spacing(10.0)
314 .child(a)
315 .child(b)
316 .child(c),
317 );
318 tree.layout(SizeProposal::exact(320.0, 200.0));
320
321 assert!((tree.bounds(a).x - 0.0).abs() < 0.01);
322 assert!((tree.bounds(b).x - 110.0).abs() < 0.01); assert!((tree.bounds(c).x - 220.0).abs() < 0.01); }
325
326 #[test]
327 fn item_spacing_applied() {
328 let mut tree = WidgetTree::new();
329 let a = tree.add(FixedLeaf(50.0, 40.0));
330 let b = tree.add(FixedLeaf(50.0, 50.0));
331 let c = tree.add(FixedLeaf(50.0, 20.0));
332 let d = tree.add(FixedLeaf(50.0, 20.0));
333 let _m = tree.add(
334 MasonryLayout::new(2)
335 .item_spacing(8.0)
336 .child(a)
337 .child(b)
338 .child(c)
339 .child(d),
340 );
341 tree.layout(SizeProposal::exact(200.0, 400.0));
342
343 assert!((tree.bounds(c).y - 48.0).abs() < 0.01);
346 assert!((tree.bounds(d).y - 58.0).abs() < 0.01);
348 }
349
350 #[test]
351 fn intrinsic_height_is_tallest_column() {
352 let mut tree = WidgetTree::new();
353 let a = tree.add(FixedLeaf(50.0, 100.0));
354 let b = tree.add(FixedLeaf(50.0, 30.0));
355 let c = tree.add(FixedLeaf(50.0, 30.0));
356 let m = tree.add(MasonryLayout::new(2).child(a).child(b).child(c));
357 tree.layout(SizeProposal {
358 width: Some(200.0),
359 height: None,
360 });
361
362 assert!((tree.bounds(m).height - 100.0).abs() < 0.01);
365 }
366
367 #[test]
368 fn single_child_goes_to_first_column() {
369 let mut tree = WidgetTree::new();
370 let a = tree.add(FixedLeaf(50.0, 40.0));
371 let _m = tree.add(MasonryLayout::new(3).child(a));
372 tree.layout(SizeProposal::exact(300.0, 200.0));
373
374 assert!((tree.bounds(a).x - 0.0).abs() < 0.01);
375 assert!((tree.bounds(a).y - 0.0).abs() < 0.01);
376 }
377
378 #[test]
379 fn empty_masonry_has_zero_size() {
380 let mut tree = WidgetTree::new();
381 let m = tree.add(MasonryLayout::new(3));
382 tree.layout(SizeProposal {
383 width: Some(300.0),
384 height: None,
385 });
386
387 assert!((tree.bounds(m).height - 0.0).abs() < 0.01);
388 }
389
390 #[test]
391 fn dormant_child_excluded_from_layout() {
392 let mut tree = WidgetTree::new();
393 let a = tree.add(FixedLeaf(50.0, 40.0));
394 let b = tree.add(FixedLeaf(50.0, 30.0));
395 let c = tree.add(FixedLeaf(50.0, 20.0));
396 let _m = tree.add(MasonryLayout::new(2).child(a).child(b).child(c));
397 tree.layout(SizeProposal::exact(200.0, 200.0));
398
399 assert!((tree.bounds(c).x - 100.0).abs() < 0.01); tree.set_dormant(b);
404 tree.layout(SizeProposal::exact(200.0, 200.0));
405
406 assert!((tree.bounds(a).x - 0.0).abs() < 0.01);
408 assert!((tree.bounds(c).x - 100.0).abs() < 0.01);
409 assert!((tree.bounds(c).y - 0.0).abs() < 0.01);
410 }
411
412 #[test]
413 fn children_receive_column_width() {
414 let mut tree = WidgetTree::new();
415 let a = tree.add(FixedLeaf(50.0, 40.0));
416 let b = tree.add(FixedLeaf(50.0, 40.0));
417 let _m = tree.add(MasonryLayout::new(2).child(a).child(b));
418 tree.layout(SizeProposal::exact(200.0, 200.0));
420
421 assert!((tree.bounds(a).width - 100.0).abs() < 0.01);
423 assert!((tree.bounds(b).width - 100.0).abs() < 0.01);
424 }
425
426 #[test]
427 fn fewer_children_than_columns() {
428 let mut tree = WidgetTree::new();
429 let a = tree.add(FixedLeaf(50.0, 40.0));
430 let b = tree.add(FixedLeaf(50.0, 30.0));
431 let _m = tree.add(MasonryLayout::new(4).child(a).child(b));
432 tree.layout(SizeProposal::exact(400.0, 200.0));
433
434 assert!((tree.bounds(a).x - 0.0).abs() < 0.01);
436 assert!((tree.bounds(b).x - 100.0).abs() < 0.01);
437 }
438
439 #[test]
440 fn rtl_mirrors_column_order() {
441 let mut tree = WidgetTree::new();
442 tree.set_layout_direction(teksilo_core::environment::LayoutDirection::RightToLeft);
443 let a = tree.add(FixedLeaf(50.0, 40.0));
444 let b = tree.add(FixedLeaf(50.0, 30.0));
445 let c = tree.add(FixedLeaf(50.0, 20.0));
446 let _m = tree.add(MasonryLayout::new(3).child(a).child(b).child(c));
447 tree.layout(SizeProposal::exact(300.0, 200.0));
449
450 assert!((tree.bounds(a).x - 200.0).abs() < 0.01);
455 assert!((tree.bounds(b).x - 100.0).abs() < 0.01);
456 assert!((tree.bounds(c).x - 0.0).abs() < 0.01);
457 }
458
459 #[test]
460 fn unbounded_width_uses_intrinsic() {
461 let mut tree = WidgetTree::new();
462 let a = tree.add(FixedLeaf(80.0, 40.0));
463 let b = tree.add(FixedLeaf(60.0, 30.0));
464 let m = tree.add(MasonryLayout::new(3).child(a).child(b));
465 tree.layout(SizeProposal {
466 width: None,
467 height: Some(200.0),
468 });
469
470 assert!((tree.bounds(m).width - 240.0).abs() < 0.01);
472 }
473}