1use teksilo_canvas::{Point, Rect, Size, SizeProposal};
32use teksilo_core::accessibility::AccessNodeBuilder;
33use teksilo_core::signal::Prop;
34use teksilo_core::widget::{LayoutContext, PaintContext, PendingChild, Widget, WidgetPlacement};
35use teksilo_core::widget_id::WidgetId;
36
37#[derive(Debug, Clone, Copy, PartialEq)]
39pub enum TrackSize {
40 Fixed(f32),
42 Fractional(f32),
46 Auto,
49}
50
51#[derive(Debug)]
53pub struct Grid {
54 columns: Vec<TrackSize>,
55 rows: Vec<TrackSize>,
56 column_gap: Prop<f32>,
57 row_gap: Prop<f32>,
58 child_ids: Vec<WidgetId>,
59 pending: Vec<PendingChild>,
60}
61
62impl Grid {
63 pub fn new() -> Self {
67 Self {
68 columns: vec![TrackSize::Auto],
69 rows: vec![TrackSize::Auto],
70 column_gap: Prop::Static(0.0),
71 row_gap: Prop::Static(0.0),
72 child_ids: Vec::new(),
73 pending: Vec::new(),
74 }
75 }
76
77 pub fn columns(mut self, columns: Vec<TrackSize>) -> Self {
80 self.columns = columns;
81 self
82 }
83
84 pub fn rows(mut self, rows: Vec<TrackSize>) -> Self {
87 self.rows = rows;
88 self
89 }
90
91 pub fn column_gap(mut self, gap: impl Into<Prop<f32>>) -> Self {
93 self.column_gap = gap.into();
94 self
95 }
96
97 pub fn row_gap(mut self, gap: impl Into<Prop<f32>>) -> Self {
99 self.row_gap = gap.into();
100 self
101 }
102
103 pub fn add_child(mut self, id: WidgetId) -> Self {
106 self.pending.push(PendingChild::Id(id));
107 self
108 }
109
110 pub fn child(mut self, widget: impl Widget + 'static) -> Self {
112 self.pending.push(PendingChild::Deferred(Box::new(widget)));
113 self
114 }
115
116 pub fn children(mut self, iter: impl IntoIterator<Item = impl Widget + 'static>) -> Self {
119 for widget in iter {
120 self.pending.push(PendingChild::Deferred(Box::new(widget)));
121 }
122 self
123 }
124
125 pub fn child_opt(mut self, widget: Option<impl Widget + 'static>) -> Self {
128 if let Some(w) = widget {
129 self.pending.push(PendingChild::Deferred(Box::new(w)));
130 }
131 self
132 }
133
134 fn resolve_tracks(
136 tracks: &[TrackSize],
137 gap: f32,
138 available: Option<f32>,
139 child_sizes: &[f32], child_mins: &[f32], ) -> Vec<f32> {
142 let n = tracks.len();
143 let total_gap = gap * (n as f32 - 1.0).max(0.0);
144
145 let mut resolved = vec![0.0_f32; n];
147 let mut used = 0.0_f32;
148 let mut total_fr = 0.0_f32;
149
150 for (i, track) in tracks.iter().enumerate() {
151 match *track {
152 TrackSize::Fixed(px) => {
153 resolved[i] = px;
154 used += px;
155 }
156 TrackSize::Auto => {
157 let size = if i < child_sizes.len() {
158 child_sizes[i]
159 } else {
160 0.0
161 };
162 resolved[i] = size;
163 used += size;
164 }
165 TrackSize::Fractional(fr) => {
166 total_fr += fr;
167 }
168 }
169 }
170
171 if let Some(a) = available {
190 let remaining = (a - total_gap - used).max(0.0);
191 if total_fr > 0.0 {
192 for (i, track) in tracks.iter().enumerate() {
193 if let TrackSize::Fractional(fr) = *track {
194 let share = remaining * fr / total_fr;
202 resolved[i] = share.max(child_mins.get(i).copied().unwrap_or(0.0));
203 }
204 }
205 }
206 } else if total_fr > 0.0 {
207 for (i, track) in tracks.iter().enumerate() {
208 if matches!(*track, TrackSize::Fractional(_)) {
209 resolved[i] = child_sizes.get(i).copied().unwrap_or(0.0);
210 }
211 }
212 }
213
214 resolved
215 }
216
217 fn cell_for(&self, child_index: usize) -> (usize, usize) {
219 let cols = self.columns.len().max(1);
220 (child_index / cols, child_index % cols)
221 }
222}
223
224impl Default for Grid {
225 fn default() -> Self {
226 Self::new()
227 }
228}
229
230impl Widget for Grid {
231 fn layout_response(
232 &self,
233 proposal: SizeProposal,
234 ctx: &LayoutContext,
235 ) -> teksilo_core::widget::LayoutResponse {
236 let num_cols = self.columns.len().max(1);
237 let num_rows = self.rows.len().max(1);
238
239 let intrinsic_proposal = SizeProposal::unspecified();
245 let mut col_max = vec![0.0_f32; num_cols];
246 let mut row_max = vec![0.0_f32; num_rows];
247 let mut col_min = vec![0.0_f32; num_cols];
251
252 for (i, &child_id) in self.child_ids.iter().enumerate() {
253 let (row, col) = self.cell_for(i);
254 if row >= num_rows || col >= num_cols {
255 continue;
256 }
257 if let Some(r) = ctx.child_layout_response(child_id, intrinsic_proposal) {
258 col_max[col] = col_max[col].max(r.size.width);
259 row_max[row] = row_max[row].max(r.size.height);
260 if r.shrink > 0.0 {
261 col_min[col] = col_min[col].max(r.min.width);
262 }
263 }
264 }
265
266 let col_gap = self.column_gap.get();
268 let row_gap = self.row_gap.get();
269 let col_sizes =
270 Self::resolve_tracks(&self.columns, col_gap, proposal.width, &col_max, &col_min);
271
272 for (i, &child_id) in self.child_ids.iter().enumerate() {
281 let (row, col) = self.cell_for(i);
282 if row >= num_rows || col >= num_cols {
283 continue;
284 }
285 let needs_remeasure = matches!(self.columns[col], TrackSize::Fractional(_))
286 && col_sizes[col] + 0.5 < col_max[col];
287 if needs_remeasure {
288 let constrained = SizeProposal {
289 width: Some(col_sizes[col]),
290 height: None,
291 };
292 if let Some(s) = ctx.child_size(child_id, constrained) {
293 row_max[row] = row_max[row].max(s.height);
294 }
295 }
296 }
297
298 let row_sizes = Self::resolve_tracks(
301 &self.rows,
302 row_gap,
303 proposal.height,
304 &row_max,
305 &vec![0.0_f32; num_rows],
306 );
307
308 let total_col_gap = col_gap * (num_cols as f32 - 1.0).max(0.0);
309 let total_row_gap = row_gap * (num_rows as f32 - 1.0).max(0.0);
310 let width = col_sizes.iter().sum::<f32>() + total_col_gap;
311 let height = row_sizes.iter().sum::<f32>() + total_row_gap;
312
313 Size::new(width, height).into()
314 }
315
316 fn place_children(
317 &self,
318 bounds: Rect,
319 _proposal: SizeProposal,
320 children: &mut [WidgetPlacement],
321 ctx: &LayoutContext,
322 ) {
323 let num_cols = self.columns.len().max(1);
324 let num_rows = self.rows.len().max(1);
325
326 let original_indices: Vec<Option<usize>> = children
335 .iter()
336 .map(|c| self.child_ids.iter().position(|&id| id == c.id))
337 .collect();
338
339 let intrinsic_proposal = SizeProposal::unspecified();
341 let mut col_max = vec![0.0_f32; num_cols];
342 let mut row_max = vec![0.0_f32; num_rows];
343 let mut col_min = vec![0.0_f32; num_cols];
344
345 for (i, child) in children.iter().enumerate() {
346 let Some(orig) = original_indices[i] else {
347 continue;
348 };
349 let (row, col) = self.cell_for(orig);
350 if row >= num_rows || col >= num_cols {
351 continue;
352 }
353 if let Some(r) = ctx.child_layout_response(child.id, intrinsic_proposal) {
354 col_max[col] = col_max[col].max(r.size.width);
355 row_max[row] = row_max[row].max(r.size.height);
356 if r.shrink > 0.0 {
357 col_min[col] = col_min[col].max(r.min.width);
358 }
359 }
360 }
361
362 let col_gap = self.column_gap.get();
363 let row_gap = self.row_gap.get();
364 let col_sizes = Self::resolve_tracks(
365 &self.columns,
366 col_gap,
367 Some(bounds.width),
368 &col_max,
369 &col_min,
370 );
371
372 for (i, child) in children.iter().enumerate() {
375 let Some(orig) = original_indices[i] else {
376 continue;
377 };
378 let (row, col) = self.cell_for(orig);
379 if row >= num_rows || col >= num_cols {
380 continue;
381 }
382 let needs_remeasure = matches!(self.columns[col], TrackSize::Fractional(_))
383 && col_sizes[col] + 0.5 < col_max[col];
384 if needs_remeasure {
385 let constrained = SizeProposal {
386 width: Some(col_sizes[col]),
387 height: None,
388 };
389 if let Some(s) = ctx.child_size(child.id, constrained) {
390 row_max[row] = row_max[row].max(s.height);
391 }
392 }
393 }
394
395 let row_sizes = Self::resolve_tracks(
396 &self.rows,
397 row_gap,
398 Some(bounds.height),
399 &row_max,
400 &vec![0.0_f32; num_rows],
401 );
402
403 let mut col_origins = Vec::with_capacity(num_cols);
405 let mut x = bounds.x;
406 for (i, &w) in col_sizes.iter().enumerate() {
407 col_origins.push(x);
408 x += w;
409 if i < num_cols - 1 {
410 x += col_gap;
411 }
412 }
413
414 let mut row_origins = Vec::with_capacity(num_rows);
415 let mut y = bounds.y;
416 for (i, &h) in row_sizes.iter().enumerate() {
417 row_origins.push(y);
418 y += h;
419 if i < num_rows - 1 {
420 y += row_gap;
421 }
422 }
423
424 for (i, child) in children.iter_mut().enumerate() {
426 let Some(orig) = original_indices[i] else {
427 child.size = Size::ZERO;
428 continue;
429 };
430 let (row, col) = self.cell_for(orig);
431 if row >= num_rows || col >= num_cols {
432 child.size = Size::ZERO;
433 continue;
434 }
435 child.origin = Point::new(col_origins[col], row_origins[row]);
436 child.size = Size::new(col_sizes[col], row_sizes[row]);
437 }
438 }
439
440 fn paint(&self, _bounds: Rect, _canvas: &mut teksilo_canvas::Canvas, _ctx: &PaintContext) {}
441
442 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
443 builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
444 }
445
446 fn children(&self) -> Vec<WidgetId> {
447 self.child_ids.clone()
448 }
449
450 fn build(&mut self, ctx: &mut teksilo_core::build_context::BuildContext) -> Vec<WidgetId> {
451 let pending = std::mem::take(&mut self.pending);
452 if !pending.is_empty() {
453 self.child_ids = pending
454 .into_iter()
455 .map(|child| match child {
456 PendingChild::Id(id) => id,
457 PendingChild::Deferred(w) => ctx.add_boxed(w),
458 })
459 .collect();
460 }
461 let self_id = ctx.self_id();
462 let registry = ctx.binding_registry();
463 self.column_gap.register_if_bound(
464 self_id,
465 registry,
466 teksilo_core::binding::BindingLevel::Relayout,
467 );
468 self.row_gap.register_if_bound(
469 self_id,
470 registry,
471 teksilo_core::binding::BindingLevel::Relayout,
472 );
473 self.child_ids.clone()
474 }
475}
476
477#[cfg(test)]
478mod tests {
479 use super::*;
480 use teksilo_core::widget_tree::WidgetTree;
481
482 #[derive(Debug)]
483 struct FixedLeaf(f32, f32);
484 impl Widget for FixedLeaf {
485 fn layout_response(
486 &self,
487 _proposal: SizeProposal,
488 _ctx: &LayoutContext,
489 ) -> teksilo_core::widget::LayoutResponse {
490 Size::new(self.0, self.1).into()
491 }
492 }
493
494 #[derive(Debug)]
496 struct ShrinkLeaf {
497 w: f32,
498 h: f32,
499 min: f32,
500 }
501 impl Widget for ShrinkLeaf {
502 fn layout_response(
503 &self,
504 _proposal: SizeProposal,
505 _ctx: &LayoutContext,
506 ) -> teksilo_core::widget::LayoutResponse {
507 teksilo_core::widget::LayoutResponse::shrinkable(
508 Size::new(self.w, self.h),
509 Size::new(self.min, self.h),
510 1.0,
511 )
512 }
513 }
514
515 #[test]
516 fn fractional_column_floors_shrinkable_child_at_min() {
517 let mut tree = WidgetTree::new();
520 let a = tree.add(FixedLeaf(50.0, 20.0));
521 let b = tree.add(ShrinkLeaf {
522 w: 200.0,
523 h: 20.0,
524 min: 80.0,
525 });
526 let _grid = tree.add(
527 Grid::new()
528 .columns(vec![TrackSize::Fixed(50.0), TrackSize::Fractional(1.0)])
529 .rows(vec![TrackSize::Fixed(40.0)])
530 .add_child(a)
531 .add_child(b),
532 );
533 tree.layout(SizeProposal::exact(100.0, 60.0));
536 assert!(
537 (tree.bounds(b).width - 80.0).abs() < 0.01,
538 "fractional column should floor at min 80, got {}",
539 tree.bounds(b).width
540 );
541 }
542
543 #[test]
544 fn fractional_column_still_shrinks_rigid_child_below_intrinsic() {
545 let mut tree = WidgetTree::new();
548 let a = tree.add(FixedLeaf(50.0, 20.0));
549 let b = tree.add(FixedLeaf(200.0, 20.0)); let _grid = tree.add(
551 Grid::new()
552 .columns(vec![TrackSize::Fixed(50.0), TrackSize::Fractional(1.0)])
553 .rows(vec![TrackSize::Fixed(40.0)])
554 .add_child(a)
555 .add_child(b),
556 );
557 tree.layout(SizeProposal::exact(100.0, 60.0));
558 assert!(
560 (tree.bounds(b).width - 50.0).abs() < 0.01,
561 "rigid child's fractional column should shrink to 50, got {}",
562 tree.bounds(b).width
563 );
564 }
565
566 #[test]
567 fn fixed_tracks_place_children_correctly() {
568 let mut tree = WidgetTree::new();
569 let a = tree.add(FixedLeaf(50.0, 30.0));
570 let b = tree.add(FixedLeaf(50.0, 30.0));
571 let c = tree.add(FixedLeaf(50.0, 30.0));
572 let d = tree.add(FixedLeaf(50.0, 30.0));
573 let _grid = tree.add(
574 Grid::new()
575 .columns(vec![TrackSize::Fixed(100.0), TrackSize::Fixed(100.0)])
576 .rows(vec![TrackSize::Fixed(50.0), TrackSize::Fixed(50.0)])
577 .add_child(a)
578 .add_child(b)
579 .add_child(c)
580 .add_child(d),
581 );
582 tree.layout(SizeProposal::exact(300.0, 200.0));
583
584 assert!((tree.bounds(a).x - 0.0).abs() < 0.01);
586 assert!((tree.bounds(a).y - 0.0).abs() < 0.01);
587 assert!((tree.bounds(b).x - 100.0).abs() < 0.01);
588 assert!((tree.bounds(b).y - 0.0).abs() < 0.01);
589 assert!((tree.bounds(c).x - 0.0).abs() < 0.01);
590 assert!((tree.bounds(c).y - 50.0).abs() < 0.01);
591 assert!((tree.bounds(d).x - 100.0).abs() < 0.01);
592 assert!((tree.bounds(d).y - 50.0).abs() < 0.01);
593 }
594
595 #[test]
596 fn auto_tracks_size_to_content() {
597 let mut tree = WidgetTree::new();
598 let a = tree.add(FixedLeaf(60.0, 25.0));
599 let b = tree.add(FixedLeaf(40.0, 35.0));
600 let _grid = tree.add(
601 Grid::new()
602 .columns(vec![TrackSize::Auto, TrackSize::Auto])
603 .rows(vec![TrackSize::Auto])
604 .add_child(a)
605 .add_child(b),
606 );
607 tree.layout(SizeProposal::exact(300.0, 200.0));
608
609 assert!((tree.bounds(a).width - 60.0).abs() < 0.01);
611 assert!((tree.bounds(b).width - 40.0).abs() < 0.01);
612 assert!((tree.bounds(b).x - 60.0).abs() < 0.01);
613 }
614
615 #[test]
616 fn fractional_tracks_distribute_remaining_space() {
617 let mut tree = WidgetTree::new();
618 let a = tree.add(FixedLeaf(10.0, 10.0));
619 let b = tree.add(FixedLeaf(10.0, 10.0));
620 let c = tree.add(FixedLeaf(10.0, 10.0));
621 let _grid = tree.add(
622 Grid::new()
623 .columns(vec![
624 TrackSize::Fixed(50.0),
625 TrackSize::Fractional(1.0),
626 TrackSize::Fractional(2.0),
627 ])
628 .rows(vec![TrackSize::Fixed(40.0)])
629 .add_child(a)
630 .add_child(b)
631 .add_child(c),
632 );
633 tree.layout(SizeProposal::exact(350.0, 100.0));
634
635 assert!((tree.bounds(a).width - 50.0).abs() < 0.01);
637 assert!((tree.bounds(b).width - 100.0).abs() < 0.01);
638 assert!((tree.bounds(c).width - 200.0).abs() < 0.01);
639 }
640
641 #[test]
642 fn gaps_between_tracks() {
643 let mut tree = WidgetTree::new();
644 let a = tree.add(FixedLeaf(10.0, 10.0));
645 let b = tree.add(FixedLeaf(10.0, 10.0));
646 let c = tree.add(FixedLeaf(10.0, 10.0));
647 let d = tree.add(FixedLeaf(10.0, 10.0));
648 let _grid = tree.add(
649 Grid::new()
650 .columns(vec![TrackSize::Fixed(50.0), TrackSize::Fixed(50.0)])
651 .rows(vec![TrackSize::Fixed(30.0), TrackSize::Fixed(30.0)])
652 .column_gap(10.0)
653 .row_gap(5.0)
654 .add_child(a)
655 .add_child(b)
656 .add_child(c)
657 .add_child(d),
658 );
659 tree.layout(SizeProposal::exact(200.0, 100.0));
660
661 assert!((tree.bounds(b).x - 60.0).abs() < 0.01); assert!((tree.bounds(c).y - 35.0).abs() < 0.01); }
664
665 #[test]
666 fn grid_intrinsic_size() {
667 let mut tree = WidgetTree::new();
668 let a = tree.add(FixedLeaf(40.0, 20.0));
669 let b = tree.add(FixedLeaf(60.0, 30.0));
670 let grid = tree.add(
671 Grid::new()
672 .columns(vec![TrackSize::Auto, TrackSize::Auto])
673 .rows(vec![TrackSize::Auto])
674 .column_gap(10.0)
675 .add_child(a)
676 .add_child(b),
677 );
678 tree.layout(SizeProposal::unspecified());
679
680 let gb = tree.bounds(grid);
681 assert!((gb.width - 110.0).abs() < 0.01);
683 assert!((gb.height - 30.0).abs() < 0.01);
685 }
686
687 #[test]
697 fn fractional_tracks_under_unspecified_use_intrinsic() {
698 let mut tree = WidgetTree::new();
699 let a = tree.add(FixedLeaf(50.0, 20.0));
703 let b = tree.add(FixedLeaf(50.0, 20.0));
704 let grid = tree.add(
705 Grid::new()
706 .columns(vec![TrackSize::Fractional(1.0), TrackSize::Fractional(1.0)])
707 .rows(vec![TrackSize::Auto])
708 .column_gap(8.0)
709 .add_child(a)
710 .add_child(b),
711 );
712 tree.layout(SizeProposal::unspecified());
713
714 let gb = tree.bounds(grid);
715 assert!(
717 (gb.width - 108.0).abs() < 0.01,
718 "expected 108, got {}",
719 gb.width,
720 );
721 assert!(
723 (gb.height - 20.0).abs() < 0.01,
724 "expected 20, got {}",
725 gb.height,
726 );
727 }
728
729 #[test]
733 fn fractional_tracks_constrained_still_share_remainder() {
734 let mut tree = WidgetTree::new();
735 let a = tree.add(FixedLeaf(10.0, 10.0));
736 let b = tree.add(FixedLeaf(10.0, 10.0));
737 let _grid = tree.add(
738 Grid::new()
739 .columns(vec![TrackSize::Fractional(1.0), TrackSize::Fractional(3.0)])
740 .rows(vec![TrackSize::Fixed(20.0)])
741 .add_child(a)
742 .add_child(b),
743 );
744 tree.layout(SizeProposal::exact(400.0, 100.0));
745
746 assert!((tree.bounds(a).width - 100.0).abs() < 0.01);
748 assert!((tree.bounds(b).width - 300.0).abs() < 0.01);
749 }
750
751 #[test]
752 fn dormant_child_preserves_cell_positions() {
753 let mut tree = WidgetTree::new();
756 let a = tree.add(FixedLeaf(50.0, 30.0));
757 let b = tree.add(FixedLeaf(50.0, 30.0));
758 let c = tree.add(FixedLeaf(50.0, 30.0));
759 let d = tree.add(FixedLeaf(50.0, 30.0));
760 let _grid = tree.add(
761 Grid::new()
762 .columns(vec![TrackSize::Fixed(80.0), TrackSize::Fixed(80.0)])
763 .rows(vec![TrackSize::Fixed(40.0), TrackSize::Fixed(40.0)])
764 .column_gap(10.0)
765 .row_gap(5.0)
766 .add_child(a)
767 .add_child(b)
768 .add_child(c)
769 .add_child(d),
770 );
771 tree.layout(SizeProposal::exact(300.0, 200.0));
772
773 assert!((tree.bounds(b).x - 90.0).abs() < 0.01);
775 assert!((tree.bounds(c).y - 45.0).abs() < 0.01);
776 assert!((tree.bounds(d).x - 90.0).abs() < 0.01);
777 assert!((tree.bounds(d).y - 45.0).abs() < 0.01);
778
779 tree.set_dormant(a);
781 tree.layout(SizeProposal::exact(300.0, 200.0));
782
783 assert!(
785 (tree.bounds(b).x - 90.0).abs() < 0.01,
786 "b.x should stay at 90, got {}",
787 tree.bounds(b).x
788 );
789 assert!(
790 (tree.bounds(b).y - 0.0).abs() < 0.01,
791 "b.y should stay at 0, got {}",
792 tree.bounds(b).y
793 );
794 assert!(
796 (tree.bounds(c).x - 0.0).abs() < 0.01,
797 "c.x should stay at 0, got {}",
798 tree.bounds(c).x
799 );
800 assert!(
801 (tree.bounds(c).y - 45.0).abs() < 0.01,
802 "c.y should stay at 45, got {}",
803 tree.bounds(c).y
804 );
805 assert!(
807 (tree.bounds(d).x - 90.0).abs() < 0.01,
808 "d.x should stay at 90, got {}",
809 tree.bounds(d).x
810 );
811 assert!(
812 (tree.bounds(d).y - 45.0).abs() < 0.01,
813 "d.y should stay at 45, got {}",
814 tree.bounds(d).y
815 );
816 }
817}