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