htui 0.2.0

A terminal HTML/CSS renderer with an embedded JavaScript engine — build interactive TUIs from HTML, CSS and JS
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
use std::sync::Arc;

use super::length::Length;

// ============================================================
// CSS Grid values
//
// A deliberately small, typed subset of CSS Grid.  Track lists are
// parsed eagerly (integer `repeat()` counts expand here, while
// `repeat(auto-fill | auto-fit, …)` is kept for the layout to resolve
// against the definite container size), and line placements remain raw
// line numbers/spans until the grid algorithm knows the final track
// count (needed for negative line numbers).  A bare custom-ident names
// a `grid-template-areas` area and resolves to its edge line at layout
// time; `grid-template-areas` itself is validated into a rectangular
// cell matrix right here.
// ============================================================

/// One track size in `grid-template-columns` / `grid-template-rows`.
#[derive(Debug, Clone, PartialEq)]
pub enum GridTrack {
    /// `auto`: size to the track's content.
    Auto,
    /// A resolved CSS length or percentage.
    Length(Length),
    /// `<flex>`: share the remaining space after fixed and auto tracks.
    Fr(f32),
    /// `minmax(min, max)`: the base size comes from the `min` side (a
    /// content hint or length), free space then grows the track up to the
    /// `max` side before `fr` distribution. `fit-content(x)` parses as
    /// `minmax(auto, x)`.
    MinMax {
        min: Box<GridTrack>,
        max: Box<GridTrack>,
    },
    /// `repeat(auto-fill | auto-fit, tracks)`: the repetition count is
    /// resolved at layout time from the definite container size, counting
    /// each track by its definite width (its max if definite, else its min).
    /// Only legal in the explicit `grid-template-*` lists; nested
    /// auto-repeats are rejected.
    AutoRepeat {
        /// `true` for `auto-fit` (empty trailing tracks collapse), `false`
        /// for `auto-fill` (every repetition is kept).
        fit: bool,
        tracks: Vec<GridTrack>,
    },
}

impl GridTrack {
    /// Parse a single track size. `min-content` and `max-content` are
    /// accepted as content-sized aliases for `auto` on the terminal model.
    pub fn parse(value: &str) -> Option<Self> {
        let s = value.trim();
        if s.eq_ignore_ascii_case("auto")
            || s.eq_ignore_ascii_case("min-content")
            || s.eq_ignore_ascii_case("max-content")
        {
            return Some(GridTrack::Auto);
        }
        if let Some(raw) = s.strip_suffix("fr") {
            let n = raw.trim().parse::<f32>().ok()?;
            return (!n.is_nan() && n >= 0.0).then_some(GridTrack::Fr(n));
        }
        if let Some(inner) = function_args(s, "minmax") {
            let mut parts = split_top_level(inner, ',')?;
            if parts.len() != 2 {
                return None;
            }
            let max = parts.pop()?;
            let min = parts.pop()?;
            let min = GridTrack::parse(&min)?;
            let max = GridTrack::parse(&max)?;
            // A track breadth is never flexible on the min side and never a
            // nested function on either side.
            if matches!(min, GridTrack::Fr(_) | GridTrack::MinMax { .. })
                || matches!(max, GridTrack::MinMax { .. })
            {
                return None;
            }
            return Some(GridTrack::MinMax {
                min: Box::new(min),
                max: Box::new(max),
            });
        }
        if let Some(inner) = function_args(s, "fit-content") {
            let length = Length::parse(inner.trim())?;
            return Some(GridTrack::MinMax {
                min: Box::new(GridTrack::Auto),
                max: Box::new(GridTrack::Length(length)),
            });
        }
        Length::parse(s).map(GridTrack::Length)
    }

    /// The flexible (`fr`) weight this track contributes to distribution: a
    /// bare `Fr(n)`, or `minmax(_, Fr(n))`'s max side. Zero means the track
    /// takes no part in the fr pass.
    pub fn fr_weight(&self) -> f32 {
        match self {
            GridTrack::Fr(n) => n.max(0.0),
            GridTrack::MinMax { max, .. } => match &**max {
                GridTrack::Fr(n) => n.max(0.0),
                _ => 0.0,
            },
            _ => 0.0,
        }
    }

    /// Whether this track contributes to flex (fr) distribution, i.e. its
    /// [`GridTrack::fr_weight`] is positive.
    pub fn is_fr(&self) -> bool {
        self.fr_weight() > 0.0
    }
}

/// Slice the argument list out of a case-insensitive `name(...)` call.
fn function_args<'a>(token: &'a str, name: &str) -> Option<&'a str> {
    let token = token.trim();
    let lower = token.to_ascii_lowercase();
    let open = format!("{name}(");
    let inner = lower.strip_prefix(&open)?.strip_suffix(')')?;
    // ASCII-only lowercasing keeps byte offsets valid in the original.
    Some(&token[open.len()..open.len() + inner.len()])
}

/// Explicit template tracks and the track used for implicit tracks.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct GridTemplate {
    /// Explicit `grid-template-columns` tracks.
    pub columns: Vec<GridTrack>,
    /// Explicit `grid-template-rows` tracks.
    pub rows: Vec<GridTrack>,
}

/// Parse a whitespace-separated track list, expanding `repeat(N, ...)`.
///
/// The terminal grid model does not accept named lines or named-area
/// strings; returning `None` makes the CSS parser drop the declaration
/// instead of silently approximating it.
pub fn parse_grid_track_list(value: &str) -> Option<Vec<GridTrack>> {
    let value = value.trim();
    if value.is_empty() {
        return Some(Vec::new());
    }
    let mut out = Vec::new();
    for token in split_top_level_whitespace(value)? {
        match parse_repeat(&token) {
            Some(tracks) => out.extend(tracks),
            None => out.push(GridTrack::parse(&token)?),
        }
        if out.len() > 4096 {
            // Guard against pathological repeated track lists.
            return None;
        }
    }
    Some(out)
}

/// Parse a `grid-template` shorthand into column and row track lists.
///
/// The supported grammar is `<track-list> [/ <track-list>]`. Named-line
/// brackets and named-area strings are intentionally rejected here — the
/// areas form expands at declaration-parse time via
/// [`expand_grid_template`].
pub fn parse_grid_template(value: &str) -> Option<GridTemplate> {
    let parts = split_top_level(value, '/')?;
    if parts.len() > 2 {
        return None;
    }
    let columns = parse_grid_track_list(parts.first()?)?;
    let rows = match parts.get(1) {
        Some(rows) => parse_grid_track_list(rows)?,
        None => Vec::new(),
    };
    Some(GridTemplate { columns, rows })
}

/// Expand an areas-form `grid-template` shorthand into its longhand
/// declarations: `grid-template: "a a" 1fr "b b" 2ch / 2ch 1fr` becomes
/// `grid-template-areas`, `grid-template-rows` and `grid-template-columns`
/// values. Row sizes default to `auto` per area row. A value without
/// quoted strings is the plain `<track-list>` form and keeps the raw
/// shorthand path ([`parse_grid_template`]); malformed area forms also
/// return `None` so the raw path rejects them.
pub(crate) fn expand_grid_template(value: &str) -> Option<Vec<(&'static str, Arc<str>)>> {
    let value = value.trim();
    if value.is_empty() {
        return None;
    }
    let mut parts = split_top_level(value, '/')?;
    if parts.len() > 2 {
        return None;
    }
    let columns = if parts.len() == 2 {
        let columns = parts.pop()?.trim().to_string();
        Some(columns)
    } else {
        None
    };
    let before = parts.pop()?;
    let (strings, runs) = scan_template_rows(&before)?;
    if strings.is_empty() {
        // Plain track-list form: the raw shorthand path owns it.
        return None;
    }
    if !runs[0].trim().is_empty() {
        return None;
    }
    let areas = strings.join(" ");
    let rows = (1..=strings.len())
        .map(|i| {
            let size = runs.get(i).map(|run| run.trim()).unwrap_or("");
            if size.is_empty() { "auto" } else { size }
        })
        .collect::<Vec<_>>()
        .join(" ");
    let mut pairs = vec![
        ("grid-template-areas", Arc::from(areas.as_str())),
        ("grid-template-rows", Arc::from(rows.as_str())),
    ];
    if let Some(columns) = columns.filter(|columns| !columns.is_empty()) {
        pairs.push(("grid-template-columns", Arc::from(columns.as_str())));
    }
    Some(pairs)
}

/// Split an areas+sizes prefix into its quoted strings (quotes kept, the
/// way `grid-template-areas` expects them) and the raw text runs between
/// them, leading run first. An unterminated string fails.
fn scan_template_rows(value: &str) -> Option<(Vec<String>, Vec<String>)> {
    let mut strings: Vec<String> = Vec::new();
    let mut runs: Vec<String> = vec![String::new()];
    let mut quote: Option<char> = None;
    let mut current = String::new();
    for c in value.chars() {
        match quote {
            Some(q) => {
                if c == q {
                    current.push(c);
                    quote = None;
                    strings.push(std::mem::take(&mut current));
                    runs.push(String::new());
                } else {
                    current.push(c);
                }
            }
            None if c == '"' || c == '\'' => {
                quote = Some(c);
                current.push(c);
            }
            None => {
                runs.last_mut()?.push(c);
            }
        }
    }
    (quote.is_none() && current.is_empty()).then_some((strings, runs))
}

/// A parsed `grid-template-areas` matrix: one entry per quoted row string,
/// each cell an area name or a null (`.`) cell.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct GridAreas {
    pub cells: Vec<Vec<Option<Arc<str>>>>,
}

impl GridAreas {
    /// Number of area rows (one per quoted string).
    pub fn row_count(&self) -> usize {
        self.cells.len()
    }

    /// Number of area columns (every row is validated to share this width).
    pub fn column_count(&self) -> usize {
        self.cells.first().map(|row| row.len()).unwrap_or(0)
    }
}

/// Parse `grid-template-areas`.
///
/// The value is a list of quoted strings, each one row of whitespace-
/// separated cells. A cell of only `.` characters is a null cell. Every row
/// must have the same column count and each name must occupy a single
/// rectangle; otherwise the whole declaration is invalid (`None`). `none`
/// clears the areas.
pub fn parse_grid_template_areas(value: &str) -> Option<GridAreas> {
    let value = value.trim();
    if value.is_empty() {
        return None;
    }
    if value.eq_ignore_ascii_case("none") {
        return Some(GridAreas { cells: Vec::new() });
    }
    let mut rows = Vec::new();
    for token in split_quoted_area_strings(value)? {
        let inner = &token[1..token.len() - 1];
        let mut row = Vec::new();
        for cell in inner.split_whitespace() {
            if cell.chars().all(|c| c == '.') {
                row.push(None);
            } else if is_css_ident(cell) {
                row.push(Some(Arc::from(cell)));
            } else {
                return None;
            }
        }
        if row.is_empty() {
            return None;
        }
        rows.push(row);
    }
    let width = rows.first()?.len();
    if rows.iter().any(|row| row.len() != width) {
        return None;
    }
    // Each name must fill exactly one rectangle of the matrix.
    let mut areas: Vec<(&str, usize, usize, usize, usize, usize)> = Vec::new();
    for (r, row) in rows.iter().enumerate() {
        for (c, cell) in row.iter().enumerate() {
            let Some(name) = cell.as_deref() else {
                continue;
            };
            if let Some(entry) = areas.iter_mut().find(|a| a.0 == name) {
                entry.1 = entry.1.min(r);
                entry.2 = entry.2.max(r);
                entry.3 = entry.3.min(c);
                entry.4 = entry.4.max(c);
                entry.5 += 1;
            } else {
                areas.push((name, r, r, c, c, 1));
            }
        }
    }
    let rectangular = areas
        .iter()
        .all(|(_, r0, r1, c0, c1, cells)| (r1 - r0 + 1) * (c1 - c0 + 1) == *cells);
    rectangular.then_some(GridAreas { cells: rows })
}

/// Split the value into quoted strings. Whitespace between strings is
/// skipped; anything else unquoted makes the declaration invalid.
fn split_quoted_area_strings(value: &str) -> Option<Vec<String>> {
    let mut out = Vec::new();
    let mut current = String::new();
    let mut quote: Option<char> = None;
    for c in value.chars() {
        match quote {
            Some(q) => {
                if c == q {
                    current.push(c);
                    out.push(std::mem::take(&mut current));
                    quote = None;
                } else {
                    current.push(c);
                }
            }
            None if c == '"' || c == '\'' => {
                quote = Some(c);
                current.push(c);
            }
            None if c.is_whitespace() => {}
            None => return None,
        }
    }
    (quote.is_none() && current.is_empty() && !out.is_empty()).then_some(out)
}

/// A raw `<grid-line>` before the final track count is known.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GridLine {
    Auto,
    /// CSS line number (1-based positive, negative counted from the end).
    Line(isize),
    Span(usize),
    /// `span <n> <area-name>`: an N-track span anchored at the named area's
    /// edge — a start edge ends at the area's first line, an end edge starts
    /// at the line after the area. Resolved against `grid-template-areas` at
    /// collection time; an unknown name degrades to [`GridLine::Span`].
    SpanName {
        span: usize,
        name: Arc<str>,
    },
    /// A bare custom-ident naming a `grid-template-areas` area; the layout
    /// resolves it to the area's start/end edge line per axis.
    Name(Arc<str>),
}

impl GridLine {
    /// Parse one start/end component. A missing component is `auto`.
    pub fn parse(value: &str) -> Option<Self> {
        let s = value.trim();
        if s.is_empty() || s.eq_ignore_ascii_case("auto") {
            return Some(GridLine::Auto);
        }
        if let Some(rest) = s.strip_prefix("span").map(str::trim_start) {
            // `span N`, `span N name`, `span name` — a bare `span` is invalid.
            let mut parts = rest.split_whitespace();
            let first = parts.next()?;
            if let Ok(n) = first.parse::<usize>() {
                if n == 0 {
                    return None;
                }
                return match parts.next() {
                    None => Some(GridLine::Span(n)),
                    Some(name)
                        if parts.next().is_none()
                            && is_css_ident(name)
                            && !name.eq_ignore_ascii_case("span") =>
                    {
                        Some(GridLine::SpanName {
                            span: n,
                            name: Arc::from(name),
                        })
                    }
                    _ => None,
                };
            }
            return (parts.next().is_none() && is_css_ident(first)).then_some(GridLine::SpanName {
                span: 1,
                name: Arc::from(first),
            });
        }
        // A bare `span` keyword is not a valid grid-line component.
        if s.eq_ignore_ascii_case("span") {
            return None;
        }
        if let Ok(n) = s.parse::<isize>()
            && n != 0
        {
            return Some(GridLine::Line(n));
        }
        is_css_ident(s).then(|| GridLine::Name(Arc::from(s)))
    }
}

/// A restricted CSS custom-ident: letters/`_`/non-ASCII to start (or `-`
/// followed by one of those), then also digits and `-`. Escaped characters
/// are not accepted.
fn is_css_ident(s: &str) -> bool {
    let mut chars = s.chars();
    let Some(first) = chars.next() else {
        return false;
    };
    let ident_start = |c: char| c.is_alphabetic() || c == '_' || !c.is_ascii();
    if first == '-' {
        match chars.clone().next() {
            Some(c) if ident_start(c) || c == '-' => {}
            _ => return false,
        }
    } else if !ident_start(first) {
        return false;
    }
    chars.all(|c| ident_start(c) || c.is_ascii_digit() || c == '-')
}

/// Raw start/end placements for one grid item.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct GridPlacements {
    pub row_start: Option<GridLine>,
    pub column_start: Option<GridLine>,
    pub row_end: Option<GridLine>,
    pub column_end: Option<GridLine>,
}

/// Parse `grid-column` / `grid-row`.
pub fn parse_grid_axis_shorthand(value: &str) -> Option<(GridLine, GridLine)> {
    let parts = split_top_level(value, '/')?;
    let start = GridLine::parse(parts.first()?)?;
    let end = match parts.get(1) {
        Some(v) => GridLine::parse(v)?,
        // CSS fills an omitted end line with the start line when that is a
        // custom-ident, and with `auto` otherwise.
        None => match &start {
            GridLine::Name(_) => start.clone(),
            _ => GridLine::Auto,
        },
    };
    Some((start, end))
}

/// Parse the four-value `grid-area` shorthand.
pub fn parse_grid_area(value: &str) -> Option<GridPlacements> {
    let parts = split_top_level(value, '/')?;
    if parts.len() > 4 {
        return None;
    }
    let mut lines: Vec<GridLine> = Vec::with_capacity(4);
    for part in &parts {
        lines.push(GridLine::parse(part)?);
    }
    while lines.len() < 4 {
        lines.push(GridLine::Auto);
    }
    // CSS expands missing values: an end edge copies its start edge when the
    // start is a custom-ident (so `grid-area: head` addresses the whole area)
    // and is `auto` otherwise.
    let same = |line: &GridLine| -> Option<GridLine> {
        matches!(line, GridLine::Name(_)).then(|| line.clone())
    };
    let row_start = lines[0].clone();
    let column_start = match parts.len() {
        1 => same(&row_start).unwrap_or(GridLine::Auto),
        _ => lines[1].clone(),
    };
    let row_end = match parts.len() {
        1 | 2 => same(&row_start).unwrap_or(GridLine::Auto),
        _ => lines[2].clone(),
    };
    let column_end = match parts.len() {
        1..=3 => same(&column_start).unwrap_or(GridLine::Auto),
        _ => lines[3].clone(),
    };
    Some(GridPlacements {
        row_start: Some(row_start),
        column_start: Some(column_start),
        row_end: Some(row_end),
        column_end: Some(column_end),
    })
}

/// Axis and hole-filling mode of automatic grid placement.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct GridAutoFlow {
    /// `true` for `row` (also the default), `false` for `column`.
    pub row: bool,
    /// `dense`: later auto items may back-fill earlier holes.
    pub dense: bool,
}

/// Parse `grid-auto-flow`.
pub fn parse_grid_auto_flow(value: &str) -> Option<GridAutoFlow> {
    let mut row = true;
    let mut dense = false;
    let mut saw_axis = false;
    for token in value.split_whitespace() {
        match token.to_ascii_lowercase().as_str() {
            "row" if !saw_axis => {
                row = true;
                saw_axis = true;
            }
            "column" if !saw_axis => {
                row = false;
                saw_axis = true;
            }
            "dense" if !dense => dense = true,
            _ => return None,
        }
    }
    Some(GridAutoFlow { row, dense })
}

/// Split at a top-level delimiter, ignoring delimiters inside parentheses.
fn split_top_level(value: &str, delimiter: char) -> Option<Vec<String>> {
    let mut parts = Vec::new();
    let mut current = String::new();
    let mut depth = 0usize;
    let mut seen_delimiter = false;
    for c in value.chars() {
        if c == '(' {
            depth += 1;
        } else if c == ')' {
            depth = depth.checked_sub(1)?;
        }
        if c == delimiter && depth == 0 {
            parts.push(current.trim().to_string());
            current.clear();
            seen_delimiter = true;
        } else {
            current.push(c);
        }
    }
    parts.push(current.trim().to_string());
    (seen_delimiter || parts.len() > 1 || !parts[0].is_empty()).then_some(parts)
}

/// Split at top-level whitespace, preserving whitespace inside `repeat(...)`.
fn split_top_level_whitespace(value: &str) -> Option<Vec<String>> {
    let mut parts = Vec::new();
    let mut current = String::new();
    let mut depth = 0usize;
    for c in value.chars() {
        if c == '(' {
            depth += 1;
        } else if c == ')' {
            depth = depth.checked_sub(1)?;
        }
        if c.is_whitespace() && depth == 0 {
            if !current.trim().is_empty() {
                parts.push(current.trim().to_string());
                current.clear();
            }
        } else {
            current.push(c);
        }
    }
    if !current.trim().is_empty() {
        parts.push(current.trim().to_string());
    }
    (!parts.is_empty()).then_some(parts)
}

/// Expand a single `repeat(N, a b)` token. `auto-fill` / `auto-fit` become
/// one [`GridTrack::AutoRepeat`] for the layout to resolve; integer counts
/// expand eagerly. Auto-repeats cannot nest.
fn parse_repeat(token: &str) -> Option<Vec<GridTrack>> {
    let lower = token.to_ascii_lowercase();
    if !lower.starts_with("repeat(") || !token.ends_with(')') {
        return None;
    }
    let inner = &token[7..token.len() - 1];
    let mut parts = split_top_level(inner, ',')?;
    if parts.len() != 2 {
        return None;
    }
    let second = parts.pop()?;
    let count_raw = parts.pop()?;
    let count_raw = count_raw.trim();
    let auto_fit = match () {
        _ if count_raw.eq_ignore_ascii_case("auto-fill") => false,
        _ if count_raw.eq_ignore_ascii_case("auto-fit") => true,
        _ => {
            let count = count_raw.parse::<usize>().ok()?;
            if count == 0 {
                return None;
            }
            let tracks = parse_grid_track_list(&second)?;
            if tracks.iter().any(is_auto_repeat) {
                return None;
            }
            let mut out = Vec::with_capacity(tracks.len().saturating_mul(count));
            for _ in 0..count {
                out.extend(tracks.iter().cloned());
            }
            return Some(out);
        }
    };
    let tracks = parse_grid_track_list(&second)?;
    if tracks.is_empty() || tracks.iter().any(is_auto_repeat) {
        return None;
    }
    Some(vec![GridTrack::AutoRepeat {
        fit: auto_fit,
        tracks,
    }])
}

/// Whether this track is an unresolved `repeat(auto-fill | auto-fit, …)`.
fn is_auto_repeat(track: &GridTrack) -> bool {
    matches!(track, GridTrack::AutoRepeat { .. })
}