Skip to main content

teksilo_widgets/primitives/
grid.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Grid — a 2D layout container with explicit row and column tracks.
5//!
6//! Columns and rows are declared as [`TrackSize`] slices supporting three
7//! sizing modes: `Fixed(px)` (exact logical pixels), `Auto` (sized to the
8//! largest child in that track), and `Fractional(fr)` (share of the remaining
9//! space after fixed and auto tracks are allocated — the CSS `fr` unit).
10//! Children are placed in **row-major order**: child 0 occupies cell
11//! `(row=0, col=0)`, child 1 `(row=0, col=1)`, and so on. Dormant children
12//! are excluded from placement while keeping their siblings at their original
13//! cell positions, so toggling a cell visible/dormant does not shift other
14//! cells.
15//!
16//! Fractional columns fall back to the child's natural width when the parent
17//! provides no width constraint (intrinsic-measurement pass), preventing
18//! wrap-aware children from reporting inflated heights.
19//!
20//! ```rust
21//! # use teksilo_widgets::primitives::{Grid, TrackSize, RectWidget};
22//! // Two equal columns with a fixed 40 dp row, separated by an 8 dp gap
23//! let _grid = Grid::new()
24//!     .columns(vec![TrackSize::Fractional(1.0), TrackSize::Fractional(1.0)])
25//!     .rows(vec![TrackSize::Fixed(40.0)])
26//!     .column_gap(8.0)
27//!     .child(RectWidget::new())
28//!     .child(RectWidget::new());
29//! ```
30
31use 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/// Sizing mode for a single row or column track in a [`Grid`].
38#[derive(Debug, Clone, Copy, PartialEq)]
39pub enum TrackSize {
40    /// Fixed size in logical pixels regardless of available space.
41    Fixed(f32),
42    /// Share of the remaining space after `Fixed` and `Auto` tracks are
43    /// resolved; equivalent to the CSS `fr` unit.  Multiple `Fractional`
44    /// tracks divide the remainder proportionally to their weights.
45    Fractional(f32),
46    /// Sized to the largest intrinsic dimension among all children in the
47    /// track; expands to fill content, never clips.
48    Auto,
49}
50
51/// A 2D grid layout container with explicit track declarations.
52#[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    /// Create a new `Grid` with a single `Auto` column and a single `Auto`
64    /// row; configure track definitions with [`columns`](Self::columns) and
65    /// [`rows`](Self::rows).
66    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    /// Set the column track definitions; each entry describes one column's
78    /// sizing mode.
79    pub fn columns(mut self, columns: Vec<TrackSize>) -> Self {
80        self.columns = columns;
81        self
82    }
83
84    /// Set the row track definitions; each entry describes one row's sizing
85    /// mode.
86    pub fn rows(mut self, rows: Vec<TrackSize>) -> Self {
87        self.rows = rows;
88        self
89    }
90
91    /// Set the inter-column gap. Accepts static `f32` or `Signal<f32>`.
92    pub fn column_gap(mut self, gap: impl Into<Prop<f32>>) -> Self {
93        self.column_gap = gap.into();
94        self
95    }
96
97    /// Set the inter-row gap. Accepts static `f32` or `Signal<f32>`.
98    pub fn row_gap(mut self, gap: impl Into<Prop<f32>>) -> Self {
99        self.row_gap = gap.into();
100        self
101    }
102
103    /// Append a pre-registered child by ID; children are placed in row-major
104    /// order starting at `(row=0, col=0)`.
105    pub fn add_child(mut self, id: WidgetId) -> Self {
106        self.pending.push(PendingChild::Id(id));
107        self
108    }
109
110    /// Append an inline child widget in the next cell (row-major order).
111    pub fn child(mut self, widget: impl Widget + 'static) -> Self {
112        self.pending.push(PendingChild::Deferred(Box::new(widget)));
113        self
114    }
115
116    /// Append multiple inline children from an iterator, each occupying the
117    /// next cell in row-major order.
118    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    /// Append an optional inline child; a `None` value is a no-op, keeping
126    /// subsequent children at their original cell positions.
127    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    /// Resolve track sizes given available space, child sizes, and track definitions.
135    fn resolve_tracks(
136        tracks: &[TrackSize],
137        gap: f32,
138        available: Option<f32>,
139        child_sizes: &[f32], // max child size per track
140        child_mins: &[f32],  // compression floor per track (0 if no shrinkable child)
141    ) -> Vec<f32> {
142        let n = tracks.len();
143        let total_gap = gap * (n as f32 - 1.0).max(0.0);
144
145        // Phase 1: resolve Fixed and Auto tracks
146        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        // Phase 2: distribute remaining space to Fractional tracks.
172        //
173        // When the parent gives us an explicit `available` constraint
174        // we share the remainder by flex weight. When it's `None`
175        // (intrinsic-measurement pass — Switcher / ZStack / ScrollArea
176        // ask their children with an unspecified proposal), there *is*
177        // no remainder to share. We can't return zero for every
178        // Fractional track: any child that re-measures against that
179        // zero width will report a wildly inflated height (a TextWidget
180        // with `proposal.width = Some(0)` wraps one glyph per line),
181        // and that height bubbles up as the Grid's intrinsic height —
182        // which is what callers like ScrollArea use to size their
183        // scrollable content.
184        //
185        // Fall back to the child's natural width per track instead, so
186        // an unconstrained Fractional column behaves like Auto. The
187        // shared-remainder behavior still applies whenever a parent
188        // *does* offer a width.
189        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                        // Share the remainder by flex weight, but never below a
195                        // track's compression floor. The floor is non-zero only
196                        // when a child in that track opted into shrink with a
197                        // `min < size`; rigid children contribute no floor, so
198                        // their fractional columns still shrink (CSS-`fr`
199                        // semantics). A track held at its floor may push the row
200                        // past `available` — the intended residual overflow.
201                        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    /// Get the (row, col) cell index for a given child index.
218    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        // Pass 1: measure children with unspecified width so Auto
240        // columns can size to content. Wrapping children (TextWidget
241        // with markup, etc.) report their single-line width — that's
242        // fine for column resolution because Auto picks the largest
243        // single-line width, and Fractional gets re-measured in pass 2.
244        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        // Per-column compression floor: the max `min.width` of *shrinkable*
248        // children in the column. Rigid children contribute nothing, so their
249        // fractional columns still shrink freely.
250        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        // Resolve column tracks against the parent's width proposal.
267        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        // Pass 2: for every child whose column is *narrower* than its
273        // intrinsic width (typical of Fractional columns receiving the
274        // remainder after Auto columns claim their natural sizes),
275        // re-measure with the resolved column width as the proposal so
276        // wrapping content reports its real wrapped height. Without
277        // this, a TextWidget-with-markup inside a Fractional column
278        // reports a 1-line height in pass 1 but actually paints
279        // multi-line, bleeding outside its assigned cell.
280        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        // Rows are not floored by `min` (the compression model targets the
299        // main/horizontal axis); pass zeros.
300        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        // Map each active child to its original cell index. The
327        // framework filters dormant children out of `children`, so we
328        // look up each child's position in `self.child_ids` (which
329        // retains all children) to keep cell assignments stable.
330        // `None` for a child not found in `child_ids` (an orphan from a
331        // transient desync). Such a child is skipped entirely below rather than
332        // mapped to cell 0 — placing it at (0,0) would overlap the real
333        // cell-0 child and pollute that track's measured size.
334        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        // Pass 1: intrinsic sizes — same as size_that_fits.
340        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        // Pass 2: re-measure Fractional cells whose column shrank, so
373        // their row height reflects wrap-induced growth.
374        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        // Compute cell origins
404        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        // Place each child in its cell
425        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    /// A shrinkable leaf with an explicit `min` width floor.
495    #[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        // A Fractional column whose only child is shrinkable (min 80) must not
518        // shrink the column below 80, even when the fr share would be smaller.
519        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        // Total 100: col0 Fixed 50 leaves 50 for the fr column — below the 80
534        // floor, so it clamps to 80 (residual overflow).
535        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        // Regression guard: a *rigid* child (min == size) must NOT floor the
546        // fractional column — CSS-`fr` semantics keep shrinking it.
547        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)); // rigid, intrinsic 200
550        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        // fr column gets 100 - 50 = 50, below the rigid child's 200 intrinsic.
559        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        // a at (0,0), b at (0,1), c at (1,0), d at (1,1)
585        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        // Column 0 sized to 60 (widest child), column 1 to 40
610        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        // Remaining: 350 - 50 = 300, split 1:2 → 100, 200
636        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); // 50 + 10 gap
662        assert!((tree.bounds(c).y - 35.0).abs() < 0.01); // 30 + 5 gap
663    }
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        // Width: 40 + 10 + 60 = 110
682        assert!((gb.width - 110.0).abs() < 0.01);
683        // Height: max(20, 30) = 30
684        assert!((gb.height - 30.0).abs() < 0.01);
685    }
686
687    /// Regression: under an unspecified-width proposal (intrinsic
688    /// measurement — Switcher / ZStack / ScrollArea ask their children
689    /// like this), a Fractional column has no parent constraint to
690    /// share. Returning zero for those tracks would force a re-measure
691    /// of every child against `width = 0`, which makes wrap-aware
692    /// widgets like TextWidget report inflated heights (one glyph per
693    /// line). The Grid must fall back to the child's natural width
694    /// instead, so the measurement matches what the paint pass would
695    /// produce at a normal width.
696    #[test]
697    fn fractional_tracks_under_unspecified_use_intrinsic() {
698        let mut tree = WidgetTree::new();
699        // Two Fractional columns with one child each. With the bug,
700        // both columns would resolve to 0 width; with the fix, each
701        // resolves to its child's natural width (50).
702        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        // Width: 50 + 8 + 50 = 108. With the bug this would be 8 (just the gap).
716        assert!(
717            (gb.width - 108.0).abs() < 0.01,
718            "expected 108, got {}",
719            gb.width,
720        );
721        // Height should be the natural row height, not inflated.
722        assert!(
723            (gb.height - 20.0).abs() < 0.01,
724            "expected 20, got {}",
725            gb.height,
726        );
727    }
728
729    /// Fractional tracks still distribute parent-offered slack when a
730    /// width *is* provided — the unspecified-fallback must not change
731    /// the constrained behavior.
732    #[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        // 1:3 split of 400 → a=100, b=300.
747        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        // 2x2 grid: a(0,0) b(0,1) c(1,0) d(1,1)
754        // Making a dormant should leave b at (0,1), c at (1,0), d at (1,1).
755        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        // Before dormant: b at col 1 → x=90 (80+10), c at row 1 → y=45 (40+5)
774        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        // Make a dormant — b, c, d keep their original cells
780        tree.set_dormant(a);
781        tree.layout(SizeProposal::exact(300.0, 200.0));
782
783        // b stays at cell (0,1)
784        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        // c stays at cell (1,0)
795        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        // d stays at cell (1,1)
806        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}