Skip to main content

oxiui_core/
grid.rs

1//! CSS Grid Level 1 layout engine.
2//!
3//! Provides a full implementation of the CSS Grid track-sizing algorithm,
4//! including `fr` units, `minmax()`, auto-sizing, template areas, gap,
5//! spanning, and sparse row-major auto-placement.
6//!
7//! # Quick start
8//!
9//! ```rust
10//! use oxiui_core::grid::{GridTemplate, GridItem, GridPlacement, TrackSizing, compute_grid};
11//! use oxiui_core::Size;
12//!
13//! let template = GridTemplate {
14//!     cols: vec![TrackSizing::Fr(1.0), TrackSizing::Fr(1.0)],
15//!     rows: vec![TrackSizing::Fixed(100.0)],
16//!     areas: None,
17//!     row_gap: 0.0,
18//!     col_gap: 0.0,
19//! };
20//! let items = vec![
21//!     GridItem {
22//!         placement: GridPlacement::auto(),
23//!         min_content_size: Size::ZERO,
24//!         max_content_size: Size::ZERO,
25//!     },
26//! ];
27//! let rects = compute_grid(&template, &items, Size::new(200.0, 100.0));
28//! assert_eq!(rects.len(), 1);
29//! ```
30
31use std::collections::{HashMap, HashSet};
32
33use crate::geometry::{Rect, Size};
34
35// ── Track sizing functions ────────────────────────────────────────────────────
36
37/// The sizing function for a single grid track.
38///
39/// Mirrors the CSS `<track-size>` value types from the Grid Level 1 spec.
40#[derive(Clone, Debug)]
41pub enum TrackSizing {
42    /// A fixed pixel size: `100px`.
43    Fixed(f32),
44    /// A flexible fraction of the remaining free space: `1fr`.
45    Fr(f32),
46    /// Size the track to its content; behaves as `minmax(min-content, max-content)`.
47    Auto,
48    /// `minmax(min, max)` — floor at `min`, ceiling at `max`.
49    MinMax(Box<TrackSizing>, Box<TrackSizing>),
50    /// Shrink the track to the smallest size that avoids overflow (`min-content`).
51    MinContent,
52    /// Grow the track to the largest intrinsic size of its items (`max-content`).
53    MaxContent,
54    /// Convenience: expand `n` repetitions of `sizing` into individual tracks.
55    Repeat(usize, Box<TrackSizing>),
56}
57
58// ── Template ──────────────────────────────────────────────────────────────────
59
60/// A CSS grid template: track definitions, named areas, and gutters.
61#[derive(Clone, Debug, Default)]
62pub struct GridTemplate {
63    /// Sizing functions for explicit row tracks (top to bottom).
64    pub rows: Vec<TrackSizing>,
65    /// Sizing functions for explicit column tracks (left to right).
66    pub cols: Vec<TrackSizing>,
67    /// Optional named area grid: `areas[row_idx][col_idx]` is the area name
68    /// (or `None` for an unnamed cell). Row and column indices are zero-based.
69    pub areas: Option<Vec<Vec<Option<String>>>>,
70    /// Gap (gutter) between adjacent row tracks in logical pixels.
71    pub row_gap: f32,
72    /// Gap (gutter) between adjacent column tracks in logical pixels.
73    pub col_gap: f32,
74}
75
76// ── Placement ─────────────────────────────────────────────────────────────────
77
78/// Identifies the start line of a grid placement.
79#[derive(Clone, Debug)]
80pub enum GridLine {
81    /// Explicit 1-based line number. Positive values start from the
82    /// beginning of the grid; negative value support is reserved for
83    /// future explicit track count resolution.
84    Line(i32),
85    /// Let the auto-placement algorithm decide.
86    Auto,
87    /// Place by named template area (resolved from `GridTemplate::areas`).
88    Named(String),
89}
90
91/// One axis of a grid placement: a start line plus a span count.
92#[derive(Clone, Debug)]
93pub struct GridSpan {
94    /// The start line (or `Auto`/`Named` for algorithm-placed items).
95    pub line: GridLine,
96    /// Number of tracks to span (minimum 1).
97    pub span: usize,
98}
99
100/// The full placement of a grid item: row and column spans.
101#[derive(Clone, Debug)]
102pub struct GridPlacement {
103    /// Row placement (start line + span).
104    pub row: GridSpan,
105    /// Column placement (start line + span).
106    pub col: GridSpan,
107}
108
109impl GridPlacement {
110    /// Fully auto-placed item: both axes use `Auto`, span = 1.
111    pub fn auto() -> Self {
112        Self {
113            row: GridSpan {
114                line: GridLine::Auto,
115                span: 1,
116            },
117            col: GridSpan {
118                line: GridLine::Auto,
119                span: 1,
120            },
121        }
122    }
123
124    /// Explicit line placement with no span (span = 1 on each axis).
125    ///
126    /// Lines are 1-based; negative values count from the grid end.
127    pub fn at(row: i32, col: i32) -> Self {
128        Self {
129            row: GridSpan {
130                line: GridLine::Line(row),
131                span: 1,
132            },
133            col: GridSpan {
134                line: GridLine::Line(col),
135                span: 1,
136            },
137        }
138    }
139
140    /// Explicit line placement with explicit row/column spans.
141    pub fn span(row: i32, col: i32, row_span: usize, col_span: usize) -> Self {
142        Self {
143            row: GridSpan {
144                line: GridLine::Line(row),
145                span: row_span.max(1),
146            },
147            col: GridSpan {
148                line: GridLine::Line(col),
149                span: col_span.max(1),
150            },
151        }
152    }
153}
154
155// ── Grid item ─────────────────────────────────────────────────────────────────
156
157/// A grid item with a placement and intrinsic content-size hints.
158#[derive(Clone, Debug)]
159pub struct GridItem {
160    /// Where to place this item in the grid.
161    pub placement: GridPlacement,
162    /// The smallest size that avoids overflow (`min-content`).
163    pub min_content_size: Size,
164    /// The largest intrinsic size (`max-content`).
165    pub max_content_size: Size,
166}
167
168// ── Internal resolved placement ───────────────────────────────────────────────
169
170/// Resolved 1-based inclusive placement (row_start, col_start, row_end, col_end).
171/// `row_end` and `col_end` are the lines *after* the last track, i.e. exclusive.
172#[derive(Clone, Debug, PartialEq, Eq)]
173struct ResolvedPlacement {
174    /// 1-based start row line (inclusive).
175    row_start: usize,
176    /// 1-based exclusive row end line.
177    row_end: usize,
178    /// 1-based start column line (inclusive).
179    col_start: usize,
180    /// 1-based exclusive column end line.
181    col_end: usize,
182}
183
184// ── Track record ──────────────────────────────────────────────────────────────
185
186/// Internal per-track state during the sizing algorithm.
187#[derive(Clone, Debug)]
188struct TrackRecord {
189    /// The expanded (Repeat-unwound) sizing function.
190    sizing: TrackSizing,
191    /// Base size: the minimum the track must be.
192    base: f32,
193    /// Growth limit: the maximum the track may grow to.
194    growth_limit: f32,
195}
196
197// ── Named area map ────────────────────────────────────────────────────────────
198
199/// Parses `GridTemplate::areas` into a map from area name →
200/// 1-based `(row_start, col_start, row_end_exclusive, col_end_exclusive)`.
201fn build_area_map(areas: &[Vec<Option<String>>]) -> HashMap<String, (usize, usize, usize, usize)> {
202    let mut map: HashMap<String, (usize, usize, usize, usize)> = HashMap::new();
203    for (r, row) in areas.iter().enumerate() {
204        for (c, cell) in row.iter().enumerate() {
205            if let Some(name) = cell {
206                let row1 = r + 1;
207                let col1 = c + 1;
208                map.entry(name.clone())
209                    .and_modify(|e| {
210                        // Extend the bounding box.
211                        e.0 = e.0.min(row1);
212                        e.1 = e.1.min(col1);
213                        e.2 = e.2.max(row1 + 1);
214                        e.3 = e.3.max(col1 + 1);
215                    })
216                    .or_insert((row1, col1, row1 + 1, col1 + 1));
217            }
218        }
219    }
220    map
221}
222
223// ── Track expansion (Repeat unwinding) ───────────────────────────────────────
224
225/// Expands a slice of `TrackSizing` values, unwinding any `Repeat(n, s)` entries.
226fn expand_tracks(specs: &[TrackSizing]) -> Vec<TrackSizing> {
227    let mut out = Vec::new();
228    for s in specs {
229        expand_one(s, &mut out);
230    }
231    out
232}
233
234fn expand_one(s: &TrackSizing, out: &mut Vec<TrackSizing>) {
235    match s {
236        TrackSizing::Repeat(n, inner) => {
237            for _ in 0..*n {
238                expand_one(inner, out);
239            }
240        }
241        other => out.push(other.clone()),
242    }
243}
244
245// ── Implicit track count expansion ───────────────────────────────────────────
246
247/// Ensures `tracks` has at least `needed` entries, padding with `Auto` tracks.
248fn ensure_track_count(tracks: &mut Vec<TrackRecord>, needed: usize) {
249    while tracks.len() < needed {
250        tracks.push(TrackRecord {
251            sizing: TrackSizing::Auto,
252            base: 0.0,
253            growth_limit: f32::INFINITY,
254        });
255    }
256}
257
258// ── Track record initialisation ───────────────────────────────────────────────
259
260/// Builds the initial `TrackRecord` for a sizing function, before content sizes
261/// are considered. `Auto` and content-based tracks start at 0/∞; they are
262/// updated in the content-sizing pass.
263fn make_track_record(sizing: &TrackSizing) -> TrackRecord {
264    let (base, growth_limit) = initial_base_growth(sizing);
265    TrackRecord {
266        sizing: sizing.clone(),
267        base,
268        growth_limit,
269    }
270}
271
272fn initial_base_growth(sizing: &TrackSizing) -> (f32, f32) {
273    match sizing {
274        TrackSizing::Fixed(px) => (*px, *px),
275        TrackSizing::Fr(_) => (0.0, f32::INFINITY),
276        TrackSizing::Auto => (0.0, f32::INFINITY),
277        TrackSizing::MinContent => (0.0, f32::INFINITY),
278        TrackSizing::MaxContent => (0.0, f32::INFINITY),
279        TrackSizing::MinMax(min, max) => {
280            let (b, _) = initial_base_growth(min);
281            let (_, g) = initial_base_growth(max);
282            (b, g)
283        }
284        TrackSizing::Repeat(_, inner) => initial_base_growth(inner),
285    }
286}
287
288// ── Content-size contribution to a single span-1 track ───────────────────────
289
290/// Returns `true` if a sizing function's base is driven by intrinsic content
291/// (i.e. it should be updated from item min/max content sizes).
292fn is_intrinsic_min(sizing: &TrackSizing) -> bool {
293    matches!(
294        sizing,
295        TrackSizing::Auto | TrackSizing::MinContent | TrackSizing::MaxContent
296    )
297}
298
299/// Returns `true` if a sizing function uses max-content for its base
300/// (as opposed to min-content).
301fn uses_max_content_base(sizing: &TrackSizing) -> bool {
302    matches!(sizing, TrackSizing::MaxContent)
303}
304
305// ── Core algorithm ────────────────────────────────────────────────────────────
306
307/// Compute grid layout.
308///
309/// Given a `template`, a list of `items`, and the `available` container size,
310/// returns one [`Rect`] per item (in the same order as `items`), each
311/// describing the item's position and size within the grid.
312///
313/// # Algorithm
314///
315/// Follows CSS Grid Level 1 specification order:
316/// 1. Expand `Repeat` track shorthands.
317/// 2. Resolve named template areas.
318/// 3. Resolve explicit placements; run sparse row-major auto-placement.
319/// 4. Initialise track base sizes from intrinsic content.
320/// 5. Distribute free space to `fr` tracks.
321/// 6. Compute per-track offsets (prefix sum with gaps).
322/// 7. Build output `Rect` for each item.
323pub fn compute_grid(template: &GridTemplate, items: &[GridItem], available: Size) -> Vec<Rect> {
324    if items.is_empty() {
325        return Vec::new();
326    }
327
328    // ── Step 1: Expand Repeat shorthands ─────────────────────────────────────
329    let explicit_col_specs = expand_tracks(&template.cols);
330    let explicit_row_specs = expand_tracks(&template.rows);
331    let explicit_cols = explicit_col_specs.len();
332    let explicit_rows = explicit_row_specs.len();
333
334    // ── Step 2: Build named-area map ─────────────────────────────────────────
335    let area_map: HashMap<String, (usize, usize, usize, usize)> = template
336        .areas
337        .as_deref()
338        .map(build_area_map)
339        .unwrap_or_default();
340
341    // ── Step 3: Resolve placements ────────────────────────────────────────────
342
343    // First pass: resolve items whose both axes are non-Auto.
344    // We need the final track counts to handle negative line numbers, so we do
345    // a preliminary scan to find the maximum required tracks.
346
347    // Pre-resolve named and explicit lines (not auto).
348    let mut pre_placements: Vec<Option<ResolvedPlacement>> = vec![None; items.len()];
349
350    for (idx, item) in items.iter().enumerate() {
351        let row_line = resolve_grid_line(&item.placement.row.line, &area_map, true);
352        let col_line = resolve_grid_line(&item.placement.col.line, &area_map, false);
353
354        if let (Some(rs), Some(cs)) = (row_line, col_line) {
355            let re = rs + item.placement.row.span;
356            let ce = cs + item.placement.col.span;
357            pre_placements[idx] = Some(ResolvedPlacement {
358                row_start: rs,
359                row_end: re,
360                col_start: cs,
361                col_end: ce,
362            });
363        } else if let (Some(rs), None) = (row_line, col_line) {
364            // Row explicit, col auto — handled in auto-placement pass.
365            let re = rs + item.placement.row.span;
366            pre_placements[idx] = Some(ResolvedPlacement {
367                row_start: rs,
368                row_end: re,
369                col_start: 0, // sentinel for "not yet placed"
370                col_end: 0,
371            });
372        }
373    }
374
375    // Determine required track count from pre-placed items.
376    let mut max_row = explicit_rows.max(1);
377    let mut max_col = explicit_cols.max(1);
378    for p in pre_placements.iter().flatten() {
379        if p.col_start != 0 {
380            max_row = max_row.max(p.row_end.saturating_sub(1));
381            max_col = max_col.max(p.col_end.saturating_sub(1));
382        }
383    }
384
385    // Build track records, initially from explicit specs, padded with Auto.
386    let mut row_tracks: Vec<TrackRecord> =
387        explicit_row_specs.iter().map(make_track_record).collect();
388    let mut col_tracks: Vec<TrackRecord> =
389        explicit_col_specs.iter().map(make_track_record).collect();
390    ensure_track_count(&mut row_tracks, max_row);
391    ensure_track_count(&mut col_tracks, max_col);
392
393    // Occupied cells set: (1-based row, 1-based col).
394    let mut occupied: HashSet<(usize, usize)> = HashSet::new();
395
396    // Mark cells occupied by fully-placed items.
397    for p in pre_placements.iter().flatten() {
398        if p.col_start != 0 {
399            mark_occupied(&mut occupied, p);
400        }
401    }
402
403    // Final placements: one per item.
404    let mut placements: Vec<ResolvedPlacement> = vec![
405        ResolvedPlacement {
406            row_start: 1,
407            row_end: 2,
408            col_start: 1,
409            col_end: 2
410        };
411        items.len()
412    ];
413
414    // Copy over already-placed items.
415    for (idx, pre) in pre_placements.iter().enumerate() {
416        if let Some(p) = pre {
417            if p.col_start != 0 {
418                placements[idx] = p.clone();
419            }
420        }
421    }
422
423    // Auto-placement cursor (1-based).
424    let mut cur_row: usize = 1;
425    let mut cur_col: usize = 1;
426
427    // The number of auto-placement columns is the current column count
428    // (may grow as we place items).
429    let auto_col_count = |col_tracks: &Vec<TrackRecord>| col_tracks.len();
430
431    for (idx, item) in items.iter().enumerate() {
432        let pre = &pre_placements[idx];
433        // Skip already fully-placed.
434        if let Some(p) = pre {
435            if p.col_start != 0 {
436                continue;
437            }
438        }
439
440        let span_row = item.placement.row.span.max(1);
441        let span_col = item.placement.col.span.max(1);
442
443        // If row was pre-resolved (row explicit, col auto):
444        if let Some(p) = pre {
445            // Row is fixed; scan columns in that row for a free slot.
446            let fixed_rs = p.row_start;
447            let fixed_re = p.row_end;
448            let mut c = 1usize;
449            loop {
450                if c + span_col - 1 > auto_col_count(&col_tracks) {
451                    // Grow columns.
452                    ensure_track_count(&mut col_tracks, c + span_col - 1);
453                }
454                if slots_free(&occupied, fixed_rs, fixed_re, c, c + span_col) {
455                    break;
456                }
457                c += 1;
458                // Grow if needed.
459                ensure_track_count(&mut col_tracks, c + span_col - 1);
460            }
461            let placement = ResolvedPlacement {
462                row_start: fixed_rs,
463                row_end: fixed_re,
464                col_start: c,
465                col_end: c + span_col,
466            };
467            mark_occupied(&mut occupied, &placement);
468            placements[idx] = placement;
469            continue;
470        }
471
472        // Both auto: advance cursor in row-major order.
473        loop {
474            // Grow columns to fit span.
475            let needed_cols = auto_col_count(&col_tracks).max(span_col);
476            ensure_track_count(&mut col_tracks, needed_cols);
477
478            let col_limit = auto_col_count(&col_tracks);
479
480            if cur_col + span_col - 1 > col_limit {
481                // Wrap to next row.
482                cur_row += 1;
483                cur_col = 1;
484                ensure_track_count(&mut row_tracks, cur_row + span_row - 1);
485            }
486
487            let rs = cur_row;
488            let re = cur_row + span_row;
489            let cs = cur_col;
490            let ce = cur_col + span_col;
491
492            // Grow row tracks if needed.
493            ensure_track_count(&mut row_tracks, re.saturating_sub(1).max(1));
494
495            if slots_free(&occupied, rs, re, cs, ce) {
496                let placement = ResolvedPlacement {
497                    row_start: rs,
498                    row_end: re,
499                    col_start: cs,
500                    col_end: ce,
501                };
502                mark_occupied(&mut occupied, &placement);
503                placements[idx] = placement;
504                // Advance past multi-column spans too.
505                cur_col = cs + span_col;
506                if cur_col > auto_col_count(&col_tracks) {
507                    cur_row += 1;
508                    cur_col = 1;
509                }
510                break;
511            } else {
512                // Advance one column.
513                cur_col += 1;
514                if cur_col > col_limit {
515                    cur_row += 1;
516                    cur_col = 1;
517                    ensure_track_count(&mut row_tracks, cur_row);
518                }
519            }
520        }
521    }
522
523    // ── Step 4: Resolve track base sizes from content ─────────────────────────
524
525    // For span-1 items only: update intrinsic track bases.
526    for (item, placement) in items.iter().zip(placements.iter()) {
527        // Row tracks.
528        if placement.row_end - placement.row_start == 1 {
529            let ri = placement.row_start - 1; // 0-based index
530            if ri < row_tracks.len() {
531                let track = &mut row_tracks[ri];
532                if is_intrinsic_min(&track.sizing) {
533                    let content = if uses_max_content_base(&track.sizing) {
534                        item.max_content_size.height
535                    } else {
536                        item.min_content_size.height
537                    };
538                    track.base = track.base.max(content);
539                }
540            }
541        }
542        // Column tracks.
543        if placement.col_end - placement.col_start == 1 {
544            let ci = placement.col_start - 1;
545            if ci < col_tracks.len() {
546                let track = &mut col_tracks[ci];
547                if is_intrinsic_min(&track.sizing) {
548                    let content = if uses_max_content_base(&track.sizing) {
549                        item.max_content_size.width
550                    } else {
551                        item.min_content_size.width
552                    };
553                    track.base = track.base.max(content);
554                }
555            }
556        }
557    }
558
559    // Apply MinMax floors and ceilings after content resolution.
560    apply_minmax_clamps(&mut col_tracks);
561    apply_minmax_clamps(&mut row_tracks);
562
563    // ── Step 5: Distribute free space to Fr tracks ────────────────────────────
564    distribute_fr(&mut col_tracks, available.width, template.col_gap);
565    distribute_fr(&mut row_tracks, available.height, template.row_gap);
566
567    // ── Step 6: Compute per-track start positions ─────────────────────────────
568    let col_starts = compute_starts(&col_tracks, template.col_gap);
569    let row_starts = compute_starts(&row_tracks, template.row_gap);
570
571    // ── Step 7: Build output rects ────────────────────────────────────────────
572    let mut out = Vec::with_capacity(items.len());
573    for placement in &placements {
574        let cs = placement.col_start.saturating_sub(1); // 0-based track index
575        let ce = (placement.col_end - 1).saturating_sub(1); // inclusive last track
576        let rs = placement.row_start.saturating_sub(1);
577        let re = (placement.row_end - 1).saturating_sub(1);
578
579        let x = col_starts.get(cs).copied().unwrap_or(0.0);
580        let y = row_starts.get(rs).copied().unwrap_or(0.0);
581
582        let x_end = if ce < col_starts.len() && ce < col_tracks.len() {
583            col_starts[ce] + col_tracks[ce].base
584        } else if cs < col_starts.len() && cs < col_tracks.len() {
585            col_starts[cs] + col_tracks[cs].base
586        } else {
587            x
588        };
589
590        let y_end = if re < row_starts.len() && re < row_tracks.len() {
591            row_starts[re] + row_tracks[re].base
592        } else if rs < row_starts.len() && rs < row_tracks.len() {
593            row_starts[rs] + row_tracks[rs].base
594        } else {
595            y
596        };
597
598        let w = (x_end - x).max(0.0);
599        let h = (y_end - y).max(0.0);
600        out.push(Rect::new(x, y, w, h));
601    }
602
603    out
604}
605
606// ── Helpers ───────────────────────────────────────────────────────────────────
607
608/// Resolves a `GridLine` to a 1-based absolute start line.
609///
610/// For `Named` lines, looks up the area in `area_map`.
611/// `is_row` selects whether to use the row or column component of the area.
612///
613/// Returns `None` for `Auto` lines or unresolvable names.
614fn resolve_grid_line(
615    line: &GridLine,
616    area_map: &HashMap<String, (usize, usize, usize, usize)>,
617    is_row: bool,
618) -> Option<usize> {
619    match line {
620        GridLine::Line(n) => {
621            if *n >= 1 {
622                Some(*n as usize)
623            } else if *n < 0 {
624                // Negative lines cannot be resolved without knowing the total
625                // track count; treat as line 1 at this stage (will be overridden
626                // by negative-line resolution after track expansion).
627                Some(1)
628            } else {
629                None
630            }
631        }
632        GridLine::Named(name) => area_map
633            .get(name)
634            .map(|&(rs, cs, _re, _ce)| if is_row { rs } else { cs }),
635        GridLine::Auto => None,
636    }
637}
638
639/// Marks all cells in the bounding box of `p` as occupied.
640fn mark_occupied(occupied: &mut HashSet<(usize, usize)>, p: &ResolvedPlacement) {
641    for r in p.row_start..p.row_end {
642        for c in p.col_start..p.col_end {
643            occupied.insert((r, c));
644        }
645    }
646}
647
648/// Returns `true` if all cells in the bounding box
649/// `[row_start, row_end) × [col_start, col_end)` are free.
650fn slots_free(
651    occupied: &HashSet<(usize, usize)>,
652    row_start: usize,
653    row_end: usize,
654    col_start: usize,
655    col_end: usize,
656) -> bool {
657    for r in row_start..row_end {
658        for c in col_start..col_end {
659            if occupied.contains(&(r, c)) {
660                return false;
661            }
662        }
663    }
664    true
665}
666
667/// Applies `MinMax(min, max)` floor and ceiling constraints to each track's
668/// base size after the content-size contribution pass.
669fn apply_minmax_clamps(tracks: &mut [TrackRecord]) {
670    for track in tracks.iter_mut() {
671        if let TrackSizing::MinMax(min_spec, max_spec) = &track.sizing.clone() {
672            let floor = match min_spec.as_ref() {
673                TrackSizing::Fixed(px) => *px,
674                TrackSizing::MinContent | TrackSizing::Auto => track.base,
675                _ => 0.0,
676            };
677            let ceil = match max_spec.as_ref() {
678                TrackSizing::Fixed(px) => *px,
679                TrackSizing::MaxContent => f32::INFINITY,
680                TrackSizing::Fr(_) => f32::INFINITY, // resolved in fr pass
681                _ => f32::INFINITY,
682            };
683            track.base =
684                track
685                    .base
686                    .max(floor)
687                    .min(if ceil.is_finite() { ceil } else { track.base });
688            track.growth_limit = ceil;
689        }
690    }
691}
692
693/// Distributes free space among `Fr` tracks.
694///
695/// `available` is the total container size along this axis.
696/// `gap` is the gutter between tracks.
697fn distribute_fr(tracks: &mut [TrackRecord], available: f32, gap: f32) {
698    let gap_total = if tracks.len() > 1 {
699        gap * (tracks.len() as f32 - 1.0)
700    } else {
701        0.0
702    };
703
704    // Sum of all non-Fr (base) sizes. Tracks whose max is `Fr` are flexible
705    // and must be excluded so that their floor (base) doesn't consume free space
706    // that should be distributed to them proportionally.
707    let fixed_sum: f32 = tracks
708        .iter()
709        .map(|t| match &t.sizing {
710            TrackSizing::Fr(_) => 0.0,
711            TrackSizing::MinMax(_, max) if matches!(max.as_ref(), TrackSizing::Fr(_)) => 0.0,
712            _ => t.base,
713        })
714        .sum();
715
716    let free = (available - gap_total - fixed_sum).max(0.0);
717
718    // Collect fr tracks.
719    let fr_indices: Vec<usize> = tracks
720        .iter()
721        .enumerate()
722        .filter_map(|(i, t)| match &t.sizing {
723            TrackSizing::Fr(_) => Some(i),
724            // MinMax(_, Fr) — also participates.
725            TrackSizing::MinMax(_, max) => {
726                if matches!(max.as_ref(), TrackSizing::Fr(_)) {
727                    Some(i)
728                } else {
729                    None
730                }
731            }
732            _ => None,
733        })
734        .collect();
735
736    if fr_indices.is_empty() {
737        return;
738    }
739
740    let sum_fr: f32 = fr_indices
741        .iter()
742        .map(|&i| fr_value_of(&tracks[i].sizing))
743        .sum();
744
745    if sum_fr <= 0.0 {
746        return;
747    }
748
749    for i in fr_indices {
750        let frac = fr_value_of(&tracks[i].sizing);
751        let computed = frac * free / sum_fr;
752        let base_floor = tracks[i].base;
753        tracks[i].base = computed.max(base_floor);
754    }
755}
756
757/// Extracts the `fr` multiplier from a sizing function.
758/// Returns 0.0 for non-Fr functions.
759fn fr_value_of(sizing: &TrackSizing) -> f32 {
760    match sizing {
761        TrackSizing::Fr(f) => *f,
762        TrackSizing::MinMax(_, max) => match max.as_ref() {
763            TrackSizing::Fr(f) => *f,
764            _ => 0.0,
765        },
766        _ => 0.0,
767    }
768}
769
770/// Computes cumulative start positions for each track (prefix sum with gaps).
771fn compute_starts(tracks: &[TrackRecord], gap: f32) -> Vec<f32> {
772    let mut starts = Vec::with_capacity(tracks.len());
773    let mut offset = 0.0f32;
774    for (i, track) in tracks.iter().enumerate() {
775        if i > 0 {
776            offset += gap;
777        }
778        starts.push(offset);
779        offset += track.base;
780    }
781    starts
782}
783
784// ── Tests ─────────────────────────────────────────────────────────────────────
785
786#[cfg(test)]
787mod tests {
788    use super::*;
789    use crate::geometry::Size;
790
791    fn fixed_item(r: i32, c: i32) -> GridItem {
792        GridItem {
793            placement: GridPlacement::at(r, c),
794            min_content_size: Size::ZERO,
795            max_content_size: Size::ZERO,
796        }
797    }
798
799    fn auto_item() -> GridItem {
800        GridItem {
801            placement: GridPlacement::auto(),
802            min_content_size: Size::ZERO,
803            max_content_size: Size::ZERO,
804        }
805    }
806
807    // ── Fixed-track tests ─────────────────────────────────────────────────────
808
809    #[test]
810    fn test_single_fixed_track_row() {
811        let template = GridTemplate {
812            rows: vec![TrackSizing::Fixed(100.0)],
813            cols: vec![TrackSizing::Fixed(200.0)],
814            areas: None,
815            row_gap: 0.0,
816            col_gap: 0.0,
817        };
818        let items = vec![fixed_item(1, 1)];
819        let rects = compute_grid(&template, &items, Size::new(200.0, 200.0));
820        assert_eq!(rects.len(), 1);
821        assert_eq!(rects[0].origin.y, 0.0);
822        assert_eq!(rects[0].size.height, 100.0);
823    }
824
825    #[test]
826    fn test_single_fixed_track_col() {
827        let template = GridTemplate {
828            rows: vec![TrackSizing::Fixed(200.0)],
829            cols: vec![TrackSizing::Fixed(100.0)],
830            areas: None,
831            row_gap: 0.0,
832            col_gap: 0.0,
833        };
834        let items = vec![fixed_item(1, 1)];
835        let rects = compute_grid(&template, &items, Size::new(200.0, 200.0));
836        assert_eq!(rects.len(), 1);
837        assert_eq!(rects[0].origin.x, 0.0);
838        assert_eq!(rects[0].size.width, 100.0);
839    }
840
841    // ── Fr track tests ────────────────────────────────────────────────────────
842
843    #[test]
844    fn test_three_equal_fr_tracks() {
845        let template = GridTemplate {
846            rows: vec![TrackSizing::Fixed(50.0)],
847            cols: vec![
848                TrackSizing::Fr(1.0),
849                TrackSizing::Fr(1.0),
850                TrackSizing::Fr(1.0),
851            ],
852            areas: None,
853            row_gap: 0.0,
854            col_gap: 0.0,
855        };
856        let items = vec![fixed_item(1, 1), fixed_item(1, 2), fixed_item(1, 3)];
857        let rects = compute_grid(&template, &items, Size::new(300.0, 50.0));
858        assert_eq!(rects.len(), 3);
859        for r in &rects {
860            assert!(
861                (r.size.width - 100.0).abs() < 1e-4,
862                "expected 100px, got {}",
863                r.size.width
864            );
865        }
866    }
867
868    #[test]
869    fn test_fr_proportional_split_unequal() {
870        let template = GridTemplate {
871            rows: vec![TrackSizing::Fixed(50.0)],
872            cols: vec![TrackSizing::Fr(1.0), TrackSizing::Fr(2.0)],
873            areas: None,
874            row_gap: 0.0,
875            col_gap: 0.0,
876        };
877        let items = vec![fixed_item(1, 1), fixed_item(1, 2)];
878        let rects = compute_grid(&template, &items, Size::new(300.0, 50.0));
879        assert_eq!(rects.len(), 2);
880        assert!(
881            (rects[0].size.width - 100.0).abs() < 1e-4,
882            "col1 = {}",
883            rects[0].size.width
884        );
885        assert!(
886            (rects[1].size.width - 200.0).abs() < 1e-4,
887            "col2 = {}",
888            rects[1].size.width
889        );
890    }
891
892    // ── MinMax tests ──────────────────────────────────────────────────────────
893
894    #[test]
895    fn test_minmax_clamps() {
896        // minmax(100, 1fr) — free space = 50px < 100px floor → track must be 100px.
897        let template = GridTemplate {
898            rows: vec![TrackSizing::Fixed(50.0)],
899            cols: vec![TrackSizing::MinMax(
900                Box::new(TrackSizing::Fixed(100.0)),
901                Box::new(TrackSizing::Fr(1.0)),
902            )],
903            areas: None,
904            row_gap: 0.0,
905            col_gap: 0.0,
906        };
907        let items = vec![fixed_item(1, 1)];
908        let rects = compute_grid(&template, &items, Size::new(50.0, 50.0));
909        assert_eq!(rects.len(), 1);
910        assert!(
911            rects[0].size.width >= 100.0,
912            "minmax floor violated: {}",
913            rects[0].size.width
914        );
915    }
916
917    #[test]
918    fn test_nested_minmax_fr() {
919        // minmax(50px, 1fr), available = 200px.
920        // CSS Grid spec: the track gets max(floor=50, fr_share=200) = 200px.
921        let template = GridTemplate {
922            rows: vec![TrackSizing::Fixed(50.0)],
923            cols: vec![TrackSizing::MinMax(
924                Box::new(TrackSizing::Fixed(50.0)),
925                Box::new(TrackSizing::Fr(1.0)),
926            )],
927            areas: None,
928            row_gap: 0.0,
929            col_gap: 0.0,
930        };
931        let items = vec![fixed_item(1, 1)];
932        let rects = compute_grid(&template, &items, Size::new(200.0, 50.0));
933        assert_eq!(rects.len(), 1);
934        // Per CSS Grid Level 1: minmax(50px, 1fr) with 200px free → 200px.
935        assert!(
936            (rects[0].size.width - 200.0).abs() < 1e-4,
937            "minmax(50,1fr) with 200px available: expected 200, got {}",
938            rects[0].size.width
939        );
940    }
941
942    // ── Auto / content sizing ─────────────────────────────────────────────────
943
944    #[test]
945    fn test_auto_track_sizes_to_content() {
946        let template = GridTemplate {
947            rows: vec![TrackSizing::Fixed(50.0)],
948            cols: vec![TrackSizing::Auto],
949            areas: None,
950            row_gap: 0.0,
951            col_gap: 0.0,
952        };
953        let items = vec![GridItem {
954            placement: GridPlacement::at(1, 1),
955            min_content_size: Size::new(40.0, 50.0),
956            max_content_size: Size::new(80.0, 50.0),
957        }];
958        let rects = compute_grid(&template, &items, Size::new(200.0, 50.0));
959        assert_eq!(rects.len(), 1);
960        assert!(
961            rects[0].size.width >= 40.0,
962            "auto track should be ≥ min_content: {}",
963            rects[0].size.width
964        );
965    }
966
967    // ── Explicit placement ────────────────────────────────────────────────────
968
969    #[test]
970    fn test_explicit_placement_at_line() {
971        // 3 cols of 50px each. Item at row=2, col=3.
972        let template = GridTemplate {
973            rows: vec![TrackSizing::Fixed(50.0), TrackSizing::Fixed(50.0)],
974            cols: vec![
975                TrackSizing::Fixed(50.0),
976                TrackSizing::Fixed(50.0),
977                TrackSizing::Fixed(50.0),
978            ],
979            areas: None,
980            row_gap: 0.0,
981            col_gap: 0.0,
982        };
983        let items = vec![fixed_item(2, 3)];
984        let rects = compute_grid(&template, &items, Size::new(150.0, 100.0));
985        assert_eq!(rects.len(), 1);
986        assert!(
987            (rects[0].origin.x - 100.0).abs() < 1e-4,
988            "x = {}",
989            rects[0].origin.x
990        );
991        assert!(
992            (rects[0].origin.y - 50.0).abs() < 1e-4,
993            "y = {}",
994            rects[0].origin.y
995        );
996    }
997
998    // ── Span tests ────────────────────────────────────────────────────────────
999
1000    #[test]
1001    fn test_span_2_occupies_two_tracks() {
1002        // 2 cols of 100px with 10px gap. Item spans both cols.
1003        let template = GridTemplate {
1004            rows: vec![TrackSizing::Fixed(50.0)],
1005            cols: vec![TrackSizing::Fixed(100.0), TrackSizing::Fixed(100.0)],
1006            areas: None,
1007            row_gap: 0.0,
1008            col_gap: 10.0,
1009        };
1010        let items = vec![GridItem {
1011            placement: GridPlacement::span(1, 1, 1, 2),
1012            min_content_size: Size::ZERO,
1013            max_content_size: Size::ZERO,
1014        }];
1015        let rects = compute_grid(&template, &items, Size::new(210.0, 50.0));
1016        assert_eq!(rects.len(), 1);
1017        // Width = track1 + gap + track2 = 100 + 10 + 100 = 210.
1018        assert!(
1019            (rects[0].size.width - 210.0).abs() < 1e-4,
1020            "span width = {}",
1021            rects[0].size.width
1022        );
1023    }
1024
1025    // ── Auto-placement tests ──────────────────────────────────────────────────
1026
1027    #[test]
1028    fn test_auto_placement_fills_row_major() {
1029        // 4 auto items, 2 explicit cols with explicit row sizing → 2×2 row-major.
1030        let template = GridTemplate {
1031            rows: vec![TrackSizing::Fixed(40.0), TrackSizing::Fixed(40.0)],
1032            cols: vec![TrackSizing::Fixed(50.0), TrackSizing::Fixed(50.0)],
1033            areas: None,
1034            row_gap: 0.0,
1035            col_gap: 0.0,
1036        };
1037        let items = vec![auto_item(), auto_item(), auto_item(), auto_item()];
1038        let rects = compute_grid(&template, &items, Size::new(100.0, 80.0));
1039        assert_eq!(rects.len(), 4);
1040        // Items 0,1 in row 1 (y=0); items 2,3 in row 2 (y=40).
1041        assert!((rects[0].origin.x - 0.0).abs() < 1e-4);
1042        assert!((rects[1].origin.x - 50.0).abs() < 1e-4);
1043        assert!(
1044            (rects[2].origin.y - 40.0).abs() < 1e-4,
1045            "row2 y = {}",
1046            rects[2].origin.y
1047        );
1048        assert!((rects[2].origin.x - 0.0).abs() < 1e-4);
1049        assert!((rects[3].origin.x - 50.0).abs() < 1e-4);
1050    }
1051
1052    #[test]
1053    fn test_auto_placement_with_hole() {
1054        // Explicit item at (1,2). Three auto items should skip that cell.
1055        let template = GridTemplate {
1056            rows: vec![TrackSizing::Fixed(50.0)],
1057            cols: vec![TrackSizing::Fixed(50.0), TrackSizing::Fixed(50.0)],
1058            areas: None,
1059            row_gap: 0.0,
1060            col_gap: 0.0,
1061        };
1062        let explicit = GridItem {
1063            placement: GridPlacement::at(1, 2),
1064            min_content_size: Size::ZERO,
1065            max_content_size: Size::ZERO,
1066        };
1067        let items = vec![explicit, auto_item(), auto_item(), auto_item()];
1068        let rects = compute_grid(&template, &items, Size::new(100.0, 200.0));
1069        assert_eq!(rects.len(), 4);
1070        // The explicit item is at x=50.
1071        assert!(
1072            (rects[0].origin.x - 50.0).abs() < 1e-4,
1073            "explicit x = {}",
1074            rects[0].origin.x
1075        );
1076        // No two items should occupy the same cell.
1077        let positions: Vec<(i32, i32)> = rects
1078            .iter()
1079            .map(|r| (r.origin.x as i32, r.origin.y as i32))
1080            .collect();
1081        let unique: std::collections::HashSet<_> = positions.iter().cloned().collect();
1082        assert_eq!(
1083            positions.len(),
1084            unique.len(),
1085            "duplicate positions: {:?}",
1086            positions
1087        );
1088    }
1089
1090    // ── Template-area tests ───────────────────────────────────────────────────
1091
1092    #[test]
1093    fn test_template_areas_named_item() {
1094        // 2×2 grid. "header" occupies (row1, col1) and (row1, col2).
1095        let areas = vec![
1096            vec![Some("header".to_string()), Some("header".to_string())],
1097            vec![Some("main".to_string()), Some("sidebar".to_string())],
1098        ];
1099        let template = GridTemplate {
1100            rows: vec![TrackSizing::Fixed(60.0), TrackSizing::Fixed(100.0)],
1101            cols: vec![TrackSizing::Fixed(120.0), TrackSizing::Fixed(80.0)],
1102            areas: Some(areas),
1103            row_gap: 0.0,
1104            col_gap: 0.0,
1105        };
1106        let items = vec![GridItem {
1107            placement: GridPlacement {
1108                row: GridSpan {
1109                    line: GridLine::Named("header".to_string()),
1110                    span: 1,
1111                },
1112                col: GridSpan {
1113                    line: GridLine::Named("header".to_string()),
1114                    span: 2,
1115                },
1116            },
1117            min_content_size: Size::ZERO,
1118            max_content_size: Size::ZERO,
1119        }];
1120        let rects = compute_grid(&template, &items, Size::new(200.0, 160.0));
1121        assert_eq!(rects.len(), 1);
1122        // Header spans cols 1–2: width = 120 + 80 = 200.
1123        assert!(
1124            (rects[0].size.width - 200.0).abs() < 1e-4,
1125            "header width = {}",
1126            rects[0].size.width
1127        );
1128        assert!((rects[0].origin.y - 0.0).abs() < 1e-4);
1129    }
1130
1131    // ── Gap tests ─────────────────────────────────────────────────────────────
1132
1133    #[test]
1134    fn test_row_col_gap_offsets() {
1135        // 2 cols of 50px with col_gap=10.
1136        let template = GridTemplate {
1137            rows: vec![TrackSizing::Fixed(50.0)],
1138            cols: vec![TrackSizing::Fixed(50.0), TrackSizing::Fixed(50.0)],
1139            areas: None,
1140            row_gap: 0.0,
1141            col_gap: 10.0,
1142        };
1143        let items = vec![fixed_item(1, 1), fixed_item(1, 2)];
1144        let rects = compute_grid(&template, &items, Size::new(110.0, 50.0));
1145        assert_eq!(rects.len(), 2);
1146        assert!((rects[0].origin.x - 0.0).abs() < 1e-4);
1147        // Second col starts at 50 + 10 = 60.
1148        assert!(
1149            (rects[1].origin.x - 60.0).abs() < 1e-4,
1150            "second col x = {}",
1151            rects[1].origin.x
1152        );
1153    }
1154
1155    // ── Edge-case tests ───────────────────────────────────────────────────────
1156
1157    #[test]
1158    fn test_over_constrained_shrinks_gracefully() {
1159        // Items larger than available space — no panic, clamp ≥ 0.
1160        let template = GridTemplate {
1161            rows: vec![TrackSizing::Fixed(50.0)],
1162            cols: vec![TrackSizing::Fixed(1000.0)],
1163            areas: None,
1164            row_gap: 0.0,
1165            col_gap: 0.0,
1166        };
1167        let items = vec![fixed_item(1, 1)];
1168        let rects = compute_grid(&template, &items, Size::new(10.0, 50.0));
1169        assert_eq!(rects.len(), 1);
1170        assert!(rects[0].size.width >= 0.0);
1171        assert!(rects[0].size.height >= 0.0);
1172    }
1173
1174    #[test]
1175    fn test_empty_grid_empty_rects() {
1176        let template = GridTemplate {
1177            rows: vec![TrackSizing::Fixed(50.0)],
1178            cols: vec![TrackSizing::Fixed(50.0)],
1179            areas: None,
1180            row_gap: 0.0,
1181            col_gap: 0.0,
1182        };
1183        let rects = compute_grid(&template, &[], Size::new(100.0, 100.0));
1184        assert!(rects.is_empty());
1185    }
1186
1187    // ── Repeat expansion ──────────────────────────────────────────────────────
1188
1189    #[test]
1190    fn test_repeat_expands_tracks() {
1191        // repeat(3, 50px) → 3 × 50px tracks.
1192        let template = GridTemplate {
1193            rows: vec![TrackSizing::Fixed(50.0)],
1194            cols: vec![TrackSizing::Repeat(3, Box::new(TrackSizing::Fixed(50.0)))],
1195            areas: None,
1196            row_gap: 0.0,
1197            col_gap: 0.0,
1198        };
1199        let items = vec![fixed_item(1, 1), fixed_item(1, 2), fixed_item(1, 3)];
1200        let rects = compute_grid(&template, &items, Size::new(150.0, 50.0));
1201        assert_eq!(rects.len(), 3);
1202        assert!((rects[0].size.width - 50.0).abs() < 1e-4);
1203        assert!((rects[1].size.width - 50.0).abs() < 1e-4);
1204        assert!((rects[2].size.width - 50.0).abs() < 1e-4);
1205        assert!((rects[1].origin.x - 50.0).abs() < 1e-4);
1206        assert!((rects[2].origin.x - 100.0).abs() < 1e-4);
1207    }
1208
1209    // ── CSS Grid spec conformance scenarios ───────────────────────────────────
1210
1211    /// CSS Grid spec: a 12-column grid with a 3-column spanning item.
1212    #[test]
1213    fn test_spec_12col_grid_with_span() {
1214        let cols: Vec<TrackSizing> = (0..12).map(|_| TrackSizing::Fixed(10.0)).collect();
1215        let template = GridTemplate {
1216            rows: vec![TrackSizing::Fixed(50.0)],
1217            cols,
1218            areas: None,
1219            row_gap: 0.0,
1220            col_gap: 0.0,
1221        };
1222        let items = vec![GridItem {
1223            placement: GridPlacement::span(1, 5, 1, 3),
1224            min_content_size: Size::ZERO,
1225            max_content_size: Size::ZERO,
1226        }];
1227        let rects = compute_grid(&template, &items, Size::new(120.0, 50.0));
1228        assert_eq!(rects.len(), 1);
1229        // col 5–7 inclusive: x = 40, w = 30.
1230        assert!(
1231            (rects[0].origin.x - 40.0).abs() < 1e-4,
1232            "x = {}",
1233            rects[0].origin.x
1234        );
1235        assert!(
1236            (rects[0].size.width - 30.0).abs() < 1e-4,
1237            "w = {}",
1238            rects[0].size.width
1239        );
1240    }
1241
1242    /// CSS Grid spec: auto-placement dense — item mid-row after explicit hole.
1243    #[test]
1244    fn test_spec_auto_placement_dense_after_hole() {
1245        // 3-col grid. Item at (1,2). Auto item should go to (1,1).
1246        let template = GridTemplate {
1247            rows: vec![TrackSizing::Fixed(50.0), TrackSizing::Fixed(50.0)],
1248            cols: vec![
1249                TrackSizing::Fixed(30.0),
1250                TrackSizing::Fixed(30.0),
1251                TrackSizing::Fixed(30.0),
1252            ],
1253            areas: None,
1254            row_gap: 0.0,
1255            col_gap: 0.0,
1256        };
1257        let explicit = GridItem {
1258            placement: GridPlacement::at(1, 2),
1259            min_content_size: Size::ZERO,
1260            max_content_size: Size::ZERO,
1261        };
1262        let items = vec![explicit, auto_item()];
1263        let rects = compute_grid(&template, &items, Size::new(90.0, 100.0));
1264        assert_eq!(rects.len(), 2);
1265        // Auto item should be at (1,1): x=0.
1266        assert!(
1267            (rects[1].origin.x - 0.0).abs() < 1e-4,
1268            "auto x = {}",
1269            rects[1].origin.x
1270        );
1271        assert!(
1272            (rects[1].origin.y - 0.0).abs() < 1e-4,
1273            "auto y = {}",
1274            rects[1].origin.y
1275        );
1276    }
1277
1278    /// CSS Grid spec: mixed fr and fixed — fixed tracks take their space first.
1279    #[test]
1280    fn test_spec_mixed_fixed_and_fr() {
1281        // 200px total, col1=50px fixed, col2=1fr → col2 = 150px.
1282        let template = GridTemplate {
1283            rows: vec![TrackSizing::Fixed(50.0)],
1284            cols: vec![TrackSizing::Fixed(50.0), TrackSizing::Fr(1.0)],
1285            areas: None,
1286            row_gap: 0.0,
1287            col_gap: 0.0,
1288        };
1289        let items = vec![fixed_item(1, 1), fixed_item(1, 2)];
1290        let rects = compute_grid(&template, &items, Size::new(200.0, 50.0));
1291        assert_eq!(rects.len(), 2);
1292        assert!(
1293            (rects[0].size.width - 50.0).abs() < 1e-4,
1294            "fixed = {}",
1295            rects[0].size.width
1296        );
1297        assert!(
1298            (rects[1].size.width - 150.0).abs() < 1e-4,
1299            "fr = {}",
1300            rects[1].size.width
1301        );
1302    }
1303
1304    /// CSS Grid spec: row gaps affect row offsets.
1305    #[test]
1306    fn test_spec_row_gap_affects_offsets() {
1307        let template = GridTemplate {
1308            rows: vec![TrackSizing::Fixed(40.0), TrackSizing::Fixed(60.0)],
1309            cols: vec![TrackSizing::Fixed(100.0)],
1310            areas: None,
1311            row_gap: 8.0,
1312            col_gap: 0.0,
1313        };
1314        let items = vec![fixed_item(1, 1), fixed_item(2, 1)];
1315        let rects = compute_grid(&template, &items, Size::new(100.0, 108.0));
1316        assert_eq!(rects.len(), 2);
1317        assert!((rects[0].origin.y - 0.0).abs() < 1e-4);
1318        // Row 2 starts at 40 + 8 = 48.
1319        assert!(
1320            (rects[1].origin.y - 48.0).abs() < 1e-4,
1321            "row2 y = {}",
1322            rects[1].origin.y
1323        );
1324    }
1325
1326    /// CSS Grid spec: template areas resolution with 2 items.
1327    #[test]
1328    fn test_spec_template_areas_two_items() {
1329        let areas = vec![vec![Some("nav".to_string()), Some("content".to_string())]];
1330        let template = GridTemplate {
1331            rows: vec![TrackSizing::Fixed(80.0)],
1332            cols: vec![TrackSizing::Fixed(60.0), TrackSizing::Fixed(140.0)],
1333            areas: Some(areas),
1334            row_gap: 0.0,
1335            col_gap: 0.0,
1336        };
1337        let nav_item = GridItem {
1338            placement: GridPlacement {
1339                row: GridSpan {
1340                    line: GridLine::Named("nav".to_string()),
1341                    span: 1,
1342                },
1343                col: GridSpan {
1344                    line: GridLine::Named("nav".to_string()),
1345                    span: 1,
1346                },
1347            },
1348            min_content_size: Size::ZERO,
1349            max_content_size: Size::ZERO,
1350        };
1351        let content_item = GridItem {
1352            placement: GridPlacement {
1353                row: GridSpan {
1354                    line: GridLine::Named("content".to_string()),
1355                    span: 1,
1356                },
1357                col: GridSpan {
1358                    line: GridLine::Named("content".to_string()),
1359                    span: 1,
1360                },
1361            },
1362            min_content_size: Size::ZERO,
1363            max_content_size: Size::ZERO,
1364        };
1365        let items = vec![nav_item, content_item];
1366        let rects = compute_grid(&template, &items, Size::new(200.0, 80.0));
1367        assert_eq!(rects.len(), 2);
1368        // nav at x=0, w=60.
1369        assert!(
1370            (rects[0].origin.x - 0.0).abs() < 1e-4,
1371            "nav x = {}",
1372            rects[0].origin.x
1373        );
1374        assert!(
1375            (rects[0].size.width - 60.0).abs() < 1e-4,
1376            "nav w = {}",
1377            rects[0].size.width
1378        );
1379        // content at x=60, w=140.
1380        assert!(
1381            (rects[1].origin.x - 60.0).abs() < 1e-4,
1382            "content x = {}",
1383            rects[1].origin.x
1384        );
1385        assert!(
1386            (rects[1].size.width - 140.0).abs() < 1e-4,
1387            "content w = {}",
1388            rects[1].size.width
1389        );
1390    }
1391
1392    /// CSS Grid spec: implicit rows created when items exceed explicit row count.
1393    #[test]
1394    fn test_spec_implicit_row_creation() {
1395        // Only 1 explicit row; 3 auto items with 1 column → 3 rows needed.
1396        let template = GridTemplate {
1397            rows: vec![TrackSizing::Fixed(30.0)],
1398            cols: vec![TrackSizing::Fixed(100.0)],
1399            areas: None,
1400            row_gap: 0.0,
1401            col_gap: 0.0,
1402        };
1403        let items = vec![auto_item(), auto_item(), auto_item()];
1404        let rects = compute_grid(&template, &items, Size::new(100.0, 90.0));
1405        assert_eq!(rects.len(), 3);
1406        // All in single column, stacked.
1407        assert!((rects[0].origin.x - 0.0).abs() < 1e-4);
1408        assert!((rects[1].origin.x - 0.0).abs() < 1e-4);
1409        assert!((rects[2].origin.x - 0.0).abs() < 1e-4);
1410        assert!(rects[1].origin.y >= rects[0].origin.y + rects[0].size.height);
1411        assert!(rects[2].origin.y >= rects[1].origin.y + rects[1].size.height);
1412    }
1413}