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 an inline child widget in the next cell (row-major order).
104    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    /// Append multiple inline children from an iterator, each occupying the
111    /// next cell in row-major order.
112    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    /// Append an optional inline child; a `None` value is a no-op, keeping
124    /// subsequent children at their original cell positions.
125    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    /// Resolve track sizes given available space, child sizes, and track definitions.
134    fn resolve_tracks(
135        tracks: &[TrackSize],
136        gap: f32,
137        available: Option<f32>,
138        child_sizes: &[f32], // max child size per track
139        child_mins: &[f32],  // compression floor per track (0 if no shrinkable child)
140    ) -> Vec<f32> {
141        let n = tracks.len();
142        let total_gap = gap * (n as f32 - 1.0).max(0.0);
143
144        // Phase 1: resolve Fixed and Auto tracks
145        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        // Phase 2: distribute remaining space to Fractional tracks.
171        //
172        // When the parent gives us an explicit `available` constraint
173        // we share the remainder by flex weight. When it's `None`
174        // (intrinsic-measurement pass — Switcher / ZStack / ScrollArea
175        // ask their children with an unspecified proposal), there *is*
176        // no remainder to share. We can't return zero for every
177        // Fractional track: any child that re-measures against that
178        // zero width will report a wildly inflated height (a TextWidget
179        // with `proposal.width = Some(0)` wraps one glyph per line),
180        // and that height bubbles up as the Grid's intrinsic height —
181        // which is what callers like ScrollArea use to size their
182        // scrollable content.
183        //
184        // Fall back to the child's natural width per track instead, so
185        // an unconstrained Fractional column behaves like Auto. The
186        // shared-remainder behavior still applies whenever a parent
187        // *does* offer a width.
188        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                        // Share the remainder by flex weight, but never below a
194                        // track's compression floor. The floor is non-zero only
195                        // when a child in that track opted into shrink with a
196                        // `min < size`; rigid children contribute no floor, so
197                        // their fractional columns still shrink (CSS-`fr`
198                        // semantics). A track held at its floor may push the row
199                        // past `available` — the intended residual overflow.
200                        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    /// Get the (row, col) cell index for a given child index.
217    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        // Pass 1: measure children with unspecified width so Auto
239        // columns can size to content. Wrapping children (TextWidget
240        // with markup, etc.) report their single-line width — that's
241        // fine for column resolution because Auto picks the largest
242        // single-line width, and Fractional gets re-measured in pass 2.
243        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        // Per-column compression floor: the max `min.width` of *shrinkable*
247        // children in the column. Rigid children contribute nothing, so their
248        // fractional columns still shrink freely.
249        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        // Resolve column tracks against the parent's width proposal.
266        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        // Pass 2: for every child whose column is *narrower* than its
272        // intrinsic width (typical of Fractional columns receiving the
273        // remainder after Auto columns claim their natural sizes),
274        // re-measure with the resolved column width as the proposal so
275        // wrapping content reports its real wrapped height. Without
276        // this, a TextWidget-with-markup inside a Fractional column
277        // reports a 1-line height in pass 1 but actually paints
278        // multi-line, bleeding outside its assigned cell.
279        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        // Rows are not floored by `min` (the compression model targets the
298        // main/horizontal axis); pass zeros.
299        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        // Map each active child to its original cell index. The
326        // framework filters dormant children out of `children`, so we
327        // look up each child's position in `self.child_ids` (which
328        // retains all children) to keep cell assignments stable.
329        // `None` for a child not found in `child_ids` (an orphan from a
330        // transient desync). Such a child is skipped entirely below rather than
331        // mapped to cell 0 — placing it at (0,0) would overlap the real
332        // cell-0 child and pollute that track's measured size.
333        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        // Pass 1: intrinsic sizes — same as size_that_fits.
339        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        // Pass 2: re-measure Fractional cells whose column shrank, so
372        // their row height reflects wrap-induced growth.
373        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        // Compute cell origins
403        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        // Place each child in its cell
424        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    /// A shrinkable leaf with an explicit `min` width floor.
494    #[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        // A Fractional column whose only child is shrinkable (min 80) must not
517        // shrink the column below 80, even when the fr share would be smaller.
518        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        // Total 100: col0 Fixed 50 leaves 50 for the fr column — below the 80
533        // floor, so it clamps to 80 (residual overflow).
534        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        // Regression guard: a *rigid* child (min == size) must NOT floor the
545        // fractional column — CSS-`fr` semantics keep shrinking it.
546        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)); // rigid, intrinsic 200
549        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        // fr column gets 100 - 50 = 50, below the rigid child's 200 intrinsic.
558        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        // a at (0,0), b at (0,1), c at (1,0), d at (1,1)
584        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        // Column 0 sized to 60 (widest child), column 1 to 40
609        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        // Remaining: 350 - 50 = 300, split 1:2 → 100, 200
635        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); // 50 + 10 gap
661        assert!((tree.bounds(c).y - 35.0).abs() < 0.01); // 30 + 5 gap
662    }
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        // Width: 40 + 10 + 60 = 110
681        assert!((gb.width - 110.0).abs() < 0.01);
682        // Height: max(20, 30) = 30
683        assert!((gb.height - 30.0).abs() < 0.01);
684    }
685
686    /// Regression: under an unspecified-width proposal (intrinsic
687    /// measurement — Switcher / ZStack / ScrollArea ask their children
688    /// like this), a Fractional column has no parent constraint to
689    /// share. Returning zero for those tracks would force a re-measure
690    /// of every child against `width = 0`, which makes wrap-aware
691    /// widgets like TextWidget report inflated heights (one glyph per
692    /// line). The Grid must fall back to the child's natural width
693    /// instead, so the measurement matches what the paint pass would
694    /// produce at a normal width.
695    #[test]
696    fn fractional_tracks_under_unspecified_use_intrinsic() {
697        let mut tree = WidgetTree::new();
698        // Two Fractional columns with one child each. With the bug,
699        // both columns would resolve to 0 width; with the fix, each
700        // resolves to its child's natural width (50).
701        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        // Width: 50 + 8 + 50 = 108. With the bug this would be 8 (just the gap).
715        assert!(
716            (gb.width - 108.0).abs() < 0.01,
717            "expected 108, got {}",
718            gb.width,
719        );
720        // Height should be the natural row height, not inflated.
721        assert!(
722            (gb.height - 20.0).abs() < 0.01,
723            "expected 20, got {}",
724            gb.height,
725        );
726    }
727
728    /// Fractional tracks still distribute parent-offered slack when a
729    /// width *is* provided — the unspecified-fallback must not change
730    /// the constrained behavior.
731    #[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        // 1:3 split of 400 → a=100, b=300.
746        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        // 2x2 grid: a(0,0) b(0,1) c(1,0) d(1,1)
753        // Making a dormant should leave b at (0,1), c at (1,0), d at (1,1).
754        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        // Before dormant: b at col 1 → x=90 (80+10), c at row 1 → y=45 (40+5)
773        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        // Make a dormant — b, c, d keep their original cells
779        tree.set_dormant(a);
780        tree.layout(SizeProposal::exact(300.0, 200.0));
781
782        // b stays at cell (0,1)
783        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        // c stays at cell (1,0)
794        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        // d stays at cell (1,1)
805        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}