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
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
//! Per-item preparation: build a [`FlexItemPrep`] from CSS properties and
//! measure each item's natural cross-axis size.

use unicode_segmentation::UnicodeSegmentation;

use crate::{
    graphics::{char_width, grapheme_width},
    html::css_parse::{
        AlignItems, AlignSelf, CssDisplay, FlexDirection, Length, LengthContext, Overflow,
        WhiteSpace,
    },
    layout::{
        InlineRun, LayoutContext,
        layoutbox::generation::{GeneratedBoxKind, generate_node},
        layoutbox::geometry::select_options,
        layoutbox::inline_tree_has_forced_break,
    },
    tdom::node::{NodeArena, NodeId, NodeType, effective_font_size_cells},
};

use super::container::{AnonymousTextItem, FLEX_ITEM_MIN_CROSS, FlexItemPrep, FlexItemSource};

/// Build a [`FlexItemPrep`] for one block child: resolve `flex-basis`,
/// read `flex-grow` / `flex-shrink` / `align-self` / `order`, and capture
/// the resolved main-axis size.
///
/// `containing_main` is the flex container's content width — the axis
/// percentage margins / paddings resolve against (CSS resolves percentage
/// margins and paddings against the containing block's *inline size* in both
/// directions), as well as the row-direction main-axis percentages and the
/// column-direction cross-size percentages.
/// `containing_height` is the container's definite content *height* — the
/// axis percentage heights resolve against — or `None` when indefinite.
pub(super) fn prepare_flex_item(
    arena: &NodeArena,
    item_id: NodeId,
    direction: FlexDirection,
    containing_main: usize,
    containing_height: Option<usize>,
    context: LayoutContext,
    align_items: AlignItems,
) -> FlexItemPrep {
    let node = &arena[item_id];
    let css = &node.css_attr;
    // The item's containing block (the flex container) has a definite inline
    // size here, so percentage margins / paddings resolve against it even in
    // the intrinsic measurement (CSS Flexbox §9.9.1: the container's main
    // size is definite for base-size calculation).
    let natural = natural_intrinsic_size(arena, item_id, context, Some(containing_main));
    // Percentage margins / paddings resolve against the container's content
    // width (CSS inline-size axis, both directions).
    let edges = css.get_box_edges(containing_main, context.font, context.viewport);
    let margin = edges.margin;
    // Declared lengths (`width` / `height` / `flex-basis` / `min-*` /
    // `max-*`) are content-box under the default `box-sizing`, while the
    // flex algorithm distributes border-box extents — the distributed main
    // size becomes the item's border-box rect and `css_cross` becomes its
    // border-box cross size. Convert each declared size by the item's own
    // edges along the axis it sizes; otherwise a bordered item laid out
    // from its CSS `height` (e.g. a sidebar card's `height: 25vh`) is
    // allocated `inner` rows too few and its content paints past the frame.
    let sizing = css.get_box_sizing();
    let (inner_main, inner_cross) = match direction.is_row() {
        true => (edges.inner_horizontal(), edges.inner_vertical()),
        false => (edges.inner_vertical(), edges.inner_horizontal()),
    };
    let to_border = |declared: usize, inner: usize| match sizing {
        crate::html::css_parse::BoxSizing::BorderBox => declared,
        crate::html::css_parse::BoxSizing::ContentBox => declared.saturating_add(inner),
    };
    // Percentage bases resolve against the axis they are declared on: the
    // main axis — definite width for rows, definite height (when known) for
    // columns.
    let main_containing = if direction.is_row() {
        Some(containing_main)
    } else {
        containing_height
    };
    let basis_resolved = css
        .get_flex_basis_resolved(main_containing, context.font, context.viewport)
        .map(|basis| to_border(basis, inner_main))
        .or_else(|| {
            // `auto` fallback chain depends on the direction:
            // - `row` direction: main axis is horizontal, so an explicit
            //   `width` wins, and the natural fallback is the text
            //   content width (1 row of text typically has 1 cell per
            //   grapheme).
            // - `column` direction: main axis is vertical, so an
            //   explicit `height` wins. With no explicit height, the
            //   item is one row of text by default — `flex-grow` will
            //   pick up any remaining vertical space if needed.
            //
            // When the item is itself a flex container, the recursive
            // intrinsic measurement replaces `measure_text_content_width`
            // so a flex column nested inside a flex row (or any deeper
            // nesting) reports its real content size — not 0 — which
            // keeps the parent from collapsing the nested container to
            // 1 cell.
            let explicit = match direction.is_row() {
                true => css.get_width(containing_main, context.font, context.viewport),
                false => css.get_height(containing_height, context.font, context.viewport),
            };
            match direction.is_row() {
                true => explicit
                    .map(|w| to_border(w, inner_main))
                    .or(Some(natural.width)),
                false => explicit
                    .map(|h| to_border(h, inner_main))
                    .or(Some(natural.height)),
            }
        })
        .unwrap_or(FLEX_ITEM_MIN_CROSS);

    // `align-self: auto` defers to the container's `align-items` (CSS
    // default for `align-items` is `stretch`, so without an explicit
    // `align-items` this still ends up as `Stretch`).
    let css_min_main = match direction.is_row() {
        true => css
            .get_min_width(containing_main, context.font, context.viewport)
            .map(|v| to_border(v, inner_main)),
        false => css
            .get_min_height(containing_height, context.font, context.viewport)
            .map(|v| to_border(v, inner_main)),
    };
    let css_max_main = match direction.is_row() {
        true => css
            .get_max_width(containing_main, context.font, context.viewport)
            .map(|v| to_border(v, inner_main)),
        false => css
            .get_max_height(containing_height, context.font, context.viewport)
            .map(|v| to_border(v, inner_main)),
    };
    // Automatic minimum main size (`min-width` / `min-height: auto`). It is
    // the min-content floor only while the computed minimum remains `auto`;
    // an explicit `min-*` replaces it through `css_min_main`. Scroll/clipping
    // containers also use a zero automatic minimum, allowing a bounded flex
    // child to shrink and scroll instead of extending past its parent.
    let min_main = if css_min_main.is_some() || css.get_overflow() == Overflow::Clip {
        0
    } else {
        match direction.is_row() {
            true => longest_breakable_width(arena, item_id),
            false => natural.height,
        }
    };
    // Explicit cross-axis size (row: `height`; column: `width`). A definite
    // cross size opts the item out of `align-items: stretch` (CSS Flexbox
    // §9.4: only items whose cross size property computes to `auto`
    // stretch). Percentages resolve along their own axis: against the
    // container's definite height for `height`, its content width for
    // `width`.
    let css_cross = match direction.is_row() {
        true => css
            .get_height(containing_height, context.font, context.viewport)
            .map(|v| to_border(v, inner_cross)),
        false => css
            .get_width(containing_main, context.font, context.viewport)
            .map(|v| to_border(v, inner_cross)),
    };
    // Baseline (rows from the box top to the first text row), for
    // `align-items: baseline`: border-top + padding-top place the content
    // box, and the glyph row sits at the line-box's vertical centring offset.
    let own_font_size_cells =
        effective_font_size_cells(arena, item_id, context.font, context.viewport);
    let line_height_cells = crate::html::css_parse::resolve_line_height_cells(
        css.get_line_height(),
        own_font_size_cells,
        context.font,
        context.viewport,
    );
    let glyph_row =
        crate::html::css_parse::glyph_row_within_line(own_font_size_cells, line_height_cells);
    // Flex-item margins are clamped to 0 when negative: the item-sizing /
    // line-placement algorithm keeps its non-negative space model, so a
    // negative margin on a flex item degenerates to 0 (the horizontal
    // block / table / flex-*container* negative margin expansion above is
    // where negative margins take effect).
    let (margin_main_before, margin_main_after, margin_cross_before, margin_cross_after) =
        match direction.is_row() {
            true => (
                margin.left.max(0) as usize,
                margin.right.max(0) as usize,
                margin.top,
                margin.bottom,
            ),
            false => (
                margin.top,
                margin.bottom,
                margin.left.max(0) as usize,
                margin.right.max(0) as usize,
            ),
        };
    // Main-axis `margin: auto` flags (row: left/right; column: top/bottom).
    // The resolved margins above already treat auto as 0; these flags let
    // the line placement absorb the free space into the auto sides.
    let margin_auto = css.get_margin_auto();
    let margin_auto_main = match direction.is_row() {
        true => (margin_auto.left, margin_auto.right),
        false => (margin_auto.top, margin_auto.bottom),
    };
    let baseline = edges
        .border
        .top
        .thickness()
        .saturating_add(edges.padding.top)
        .saturating_add(glyph_row);
    FlexItemPrep {
        source: FlexItemSource::Node(item_id),
        basis: basis_resolved,
        min_main,
        css_min_main,
        css_max_main,
        css_cross,
        grow: css.get_flex_grow(),
        shrink: css.get_flex_shrink(),
        align_self: match css.get_align_self() {
            AlignSelf::Auto => align_items,
            AlignSelf::Value(v) => v,
        },
        margin_main_before,
        margin_main_after,
        margin_cross_before,
        margin_cross_after,
        margin_auto_main,
        baseline,
        order: css.get_order(),
        natural_width: natural.width,
        natural_height: natural.height,
    }
}

/// Build a [`FlexItemPrep`] for an anonymous text item: contiguous raw text
/// children of a flex container. Anonymous items carry the initial flex
/// properties (`flex: 0 1 auto`, `order: 0`, `align-self: auto`).
pub(super) fn prepare_anonymous_text_item(
    item: AnonymousTextItem,
    direction: FlexDirection,
    align_items: AlignItems,
) -> FlexItemPrep {
    let text_w = runs_total_width(&item.runs);
    // Capture before `item` is moved into the prep's source below.
    let natural_height = item.line_height_cells.max(FLEX_ITEM_MIN_CROSS);
    let (basis, min_main) = match direction.is_row() {
        true => (
            text_w.max(FLEX_ITEM_MIN_CROSS),
            runs_longest_word(&item.runs),
        ),
        false => (FLEX_ITEM_MIN_CROSS, FLEX_ITEM_MIN_CROSS),
    };
    FlexItemPrep {
        source: FlexItemSource::AnonymousText(item),
        basis,
        min_main,
        css_min_main: None,
        css_max_main: None,
        css_cross: None,
        grow: 0.0,
        shrink: 1.0,
        align_self: align_items,
        margin_main_before: 0,
        margin_main_after: 0,
        margin_cross_before: 0,
        margin_cross_after: 0,
        margin_auto_main: (false, false),
        baseline: 0,
        order: 0,
        // Mirrors the intrinsic semantics of the DOM-item path: a text row's
        // natural height is its line-height, its natural width the total run
        // width (the direction-dependent selection happens at read time).
        natural_width: text_w.max(FLEX_ITEM_MIN_CROSS),
        natural_height,
    }
}

/// Sum the grapheme widths of every non-break run in `runs`.
fn runs_total_width(runs: &[InlineRun]) -> usize {
    runs.iter()
        .filter(|r| !r.force_break)
        .map(|r| r.text.graphemes(true).map(grapheme_width).sum::<usize>())
        .sum()
}

/// Widest whitespace-delimited token in `runs` (the min-content width of the
/// anonymous text item).
fn runs_longest_word(runs: &[InlineRun]) -> usize {
    runs.iter()
        .filter(|r| !r.force_break)
        .flat_map(|r| r.text.split_whitespace())
        .map(|token| token.graphemes(true).map(grapheme_width).sum::<usize>())
        .max()
        .unwrap_or(0)
        .max(1)
}

/// Sum the grapheme widths of every direct text leaf of `node_id`.
pub(super) fn measure_text_content_width(arena: &NodeArena, node_id: NodeId) -> Option<usize> {
    let white_space = arena[node_id]
        .css_attr
        .get_white_space(&arena[node_id].node_type);
    let mut w = measure_text_content_width_ws(arena, node_id, white_space)?;
    // `<q>` generates open + close quote marks (one cell each) that are not
    // part of its text children but still contribute to its intrinsic width.
    if arena[node_id].node_type == NodeType::Q {
        w = w.saturating_add(2);
    }
    Some(w)
}

fn measure_text_content_width_ws(
    arena: &NodeArena,
    node_id: NodeId,
    white_space: WhiteSpace,
) -> Option<usize> {
    let node = &arena[node_id];
    let mut total: Option<usize> = Some(0);
    for &child_id in &node.children {
        let child = &arena[child_id];
        match &child.node_type {
            NodeType::Text => {
                if child.text_is_painted(white_space)
                    && let Some(s) = child.data.as_deref()
                {
                    let w: usize = s.graphemes(true).map(grapheme_width).sum();
                    total = Some(total.unwrap_or(0).saturating_add(w));
                }
            }
            // Inline text-level wrappers hold the text that belongs to this
            // block's inline content; recurse so a block whose text lives
            // entirely inside inline elements still reports its intrinsic
            // width (flex-basis: auto relies on it).
            _ if child.node_type.ua_default_display() == CssDisplay::Inline => {
                let ws = child.white_space_or(white_space);
                let w = measure_text_content_width_ws(arena, child_id, ws).unwrap_or(0);
                let w = if child.node_type == NodeType::Q {
                    w.saturating_add(2)
                } else {
                    w
                };
                total = Some(total.unwrap_or(0).saturating_add(w));
            }
            _ => {}
        }
    }
    total
}

/// Widest unbreakable segment in `node_id`'s inline text — the item's
/// min-content main-axis size for `row` direction (CSS `min-width: auto`).
///
/// Latin words break at whitespace; CJK characters each offer their own
/// break opportunity, so a single CJK grapheme is the practical floor. If
/// the node has no inline text (e.g. a pure block container) the floor is
/// the [`FLEX_ITEM_MIN_CROSS`] single cell.
pub(super) fn longest_breakable_width(arena: &NodeArena, node_id: NodeId) -> usize {
    fn walk(arena: &NodeArena, node_id: NodeId, white_space: WhiteSpace, max_w: &mut usize) {
        let node = &arena[node_id];
        for &child_id in &node.children {
            let child = &arena[child_id];
            match &child.node_type {
                NodeType::Text => {
                    if child.text_is_painted(white_space)
                        && let Some(s) = child.data.as_deref()
                    {
                        for token in s.split_whitespace() {
                            let w: usize = token.graphemes(true).map(grapheme_width).sum();
                            if w > *max_w {
                                *max_w = w;
                            }
                        }
                    }
                }
                _ if child.node_type.ua_default_display() == CssDisplay::Inline => {
                    let ws = child.white_space_or(white_space);
                    walk(arena, child_id, ws, max_w);
                }
                _ => {}
            }
        }
    }
    let white_space = arena[node_id]
        .css_attr
        .get_white_space(&arena[node_id].node_type);
    let mut max_w: usize = FLEX_ITEM_MIN_CROSS;
    walk(arena, node_id, white_space, &mut max_w);
    max_w
}

/// Resolve an explicit `width` / `height` for intrinsic measurement.
///
/// A percentage against an unknown (indefinite) containing block resolves to
/// `auto` (`None`), matching CSS — resolving `100%` against the `usize::MAX`
/// sentinel would otherwise produce a huge size and overflow the canvas later.
/// Any other length resolves normally against a zero containing width (unused
/// by non-percentage lengths).
fn intrinsic_len(raw: Option<&str>, context: LayoutContext) -> Option<usize> {
    let len = Length::parse(raw?)?;
    match len {
        Length::Auto | Length::Pct(_) => None,
        len => Some(
            len.resolve(LengthContext {
                containing: 0,
                font: context.font,
                viewport: context.viewport,
            })
            .max(1),
        ),
    }
}

/// Intrinsic outer (border-box) size of one block node, computed
/// recursively so nested flex containers report the real dimensions of
/// their children rather than collapsing to a font-size placeholder.
///
/// Returns `width` / `height` in terminal cells. Padding and border are
/// added on top of the inner content size, mirroring how
/// [`crate::html::css_parse::properties::CssAttr::get_box_edges`] is
/// applied in the layout functions.
///
/// `containing_width` is the measured node's own containing-block width when
/// it is definite (`Some` — the direct flex-item case, where the container's
/// content width drives percentage margins / paddings) and `None` for the
/// recursive descent (a nested node's containing block is indefinite during
/// pre-layout measurement, so percentage edges contribute 0).
///
/// Behaviour:
///
/// - For a non-flex block leaf, falls back to the existing logic: an
///   explicit `width` / `height` wins, otherwise the text content width
///   (for width) or font-size (for height).
/// - For a flex container, recursively measures each block child. The
///   flex container's own `flex-direction` decides whether the main-axis
///   size is a sum (single line / column) or a max (cross axis). Gaps
///   between children are added in the appropriate direction. Then
///   padding and border are added to give the border-box size.
/// Public intrinsic-size bridge for the CSS Grid item preparation pass.
///
/// Grid items use the same replaced/block/flex intrinsic model as flex items.
/// A nested grid container is measured by the conservative generic block
/// branch in this first implementation.
pub(crate) fn natural_grid_item_size(
    arena: &NodeArena,
    node_id: NodeId,
    context: LayoutContext,
    containing_width: Option<usize>,
) -> (usize, usize) {
    let natural = natural_intrinsic_size(arena, node_id, context, containing_width);
    (natural.width, natural.height)
}

fn natural_intrinsic_size(
    arena: &NodeArena,
    node_id: NodeId,
    context: LayoutContext,
    containing_width: Option<usize>,
) -> NaturalIntrinsicSize {
    let node = &arena[node_id];
    let css = &node.css_attr;
    let own_font_size_cells =
        effective_font_size_cells(arena, node_id, context.font, context.viewport);
    let child_context = context.child(own_font_size_cells);

    // Replaced elements have an intrinsic box independent of their (empty)
    // children. `<input>` takes its HTML `width` attribute (default 8 cells)
    // and one row; `<select>` sizes to the longest option label plus the
    // frame / chevron — without these, flex measurement reports the 1x1
    // floor and a control inside a flex row collapses to a sliver. A CSS
    // `border:` adds its thickness on both axes, matching the geometry phase.
    if matches!(node.node_type, NodeType::Input) {
        // Checkbox / radio are two-cell toggles (glyph + spacing); other
        // kinds take the HTML `width` attribute (default 8 cells). Toggles
        // paint no chrome of their own, so they reserve no border cells.
        let is_toggle = node
            .get_type()
            .is_some_and(|t| matches!(t.to_ascii_lowercase().as_str(), "checkbox" | "radio"));
        // Only border thicknesses are read here and `border-width` cannot be a
        // percentage, so the containing width is irrelevant — 0 is exact.
        let edges = css.get_box_edges(0, child_context.font, child_context.viewport);
        let (border_h, border_v) = if is_toggle {
            (0, 0)
        } else {
            (
                edges.border.horizontal_thickness(),
                edges.border.vertical_thickness(),
            )
        };
        let w = if is_toggle {
            2
        } else {
            node.get_width().map(|w| w as usize).unwrap_or(8)
        };
        return NaturalIntrinsicSize {
            width: w.saturating_add(border_h).max(FLEX_ITEM_MIN_CROSS),
            height: 1usize.saturating_add(border_v).max(FLEX_ITEM_MIN_CROSS),
        };
    }
    if matches!(node.node_type, NodeType::Select) {
        let text_w: usize = select_options(arena, node_id)
            .iter()
            .map(|opt| opt.label.chars().map(char_width).sum::<usize>())
            .max()
            .unwrap_or(0);
        let edges = css.get_box_edges(0, child_context.font, child_context.viewport);
        let w = node
            .get_width()
            .map(|w| w as usize)
            .unwrap_or(text_w.saturating_add(4));
        return NaturalIntrinsicSize {
            width: w
                .saturating_add(edges.border.horizontal_thickness())
                .max(FLEX_ITEM_MIN_CROSS),
            height: 1usize
                .saturating_add(edges.border.vertical_thickness())
                .max(FLEX_ITEM_MIN_CROSS),
        };
    }

    // Intrinsic measurement resolves percentage margins / paddings against
    // the containing-block width when it is definite (the direct flex-item
    // case) and to 0 when it is indefinite (the recursive descent — CSS
    // resolves a cyclic percentage to zero).
    // Non-percentage edges are unaffected by the containing width.
    let edges = css.get_box_edges(
        containing_width.unwrap_or(0),
        child_context.font,
        child_context.viewport,
    );

    let generated = generate_node(arena, node_id);
    let is_flex_box = generated
        .as_ref()
        .map(|g| {
            g.kind == GeneratedBoxKind::Normal
                && g.inner_formatting_context == crate::layout::FormattingContext::Flex
        })
        .unwrap_or(false);

    let res = if css.display().is_some_and(|d| d.is_flex()) && is_flex_box {
        let inner_direction = css.get_flex_direction();
        let gap = css.get_gap(child_context.font, child_context.viewport);

        let mut child_sizes: Vec<NaturalIntrinsicSize> = Vec::new();
        for &child_id in &node.children {
            let Some(child_generated) = generate_node(arena, child_id) else {
                continue;
            };
            if child_generated.kind == GeneratedBoxKind::Normal {
                // A nested node's containing block is indefinite during the
                // measurement: percentage edges contribute 0.
                child_sizes.push(natural_intrinsic_size(arena, child_id, child_context, None));
            }
        }

        let (inner_w, inner_h) = match inner_direction.is_row() {
            true => {
                // Row: main axis = horizontal, items flow left→right.
                // Main size is sum of widths + column gaps; cross size
                // is the max of heights.
                let total_w: usize = child_sizes
                    .iter()
                    .map(|s| s.width)
                    .fold(0usize, usize::saturating_add);
                let gaps_w = if child_sizes.len() > 1 {
                    gap.column.saturating_mul(child_sizes.len() - 1)
                } else {
                    0
                };
                let max_h = child_sizes
                    .iter()
                    .map(|s| s.height)
                    .max()
                    .unwrap_or(FLEX_ITEM_MIN_CROSS);
                (
                    total_w.saturating_add(gaps_w).max(FLEX_ITEM_MIN_CROSS),
                    max_h.max(FLEX_ITEM_MIN_CROSS),
                )
            }
            false => {
                // Column: main axis = vertical, items stack top→bottom.
                // Main size is sum of heights + row gaps; cross size
                // is the max of widths.
                let max_w = child_sizes
                    .iter()
                    .map(|s| s.width)
                    .max()
                    .unwrap_or(FLEX_ITEM_MIN_CROSS);
                let total_h: usize = child_sizes
                    .iter()
                    .map(|s| s.height)
                    .fold(0usize, usize::saturating_add);
                let gaps_h = if child_sizes.len() > 1 {
                    gap.row.saturating_mul(child_sizes.len() - 1)
                } else {
                    0
                };
                (
                    max_w.max(FLEX_ITEM_MIN_CROSS),
                    total_h.saturating_add(gaps_h).max(FLEX_ITEM_MIN_CROSS),
                )
            }
        };

        let outer_w = inner_w
            .saturating_add(edges.padding.horizontal())
            .saturating_add(edges.border.horizontal_thickness());
        let outer_h = inner_h
            .saturating_add(edges.padding.vertical())
            .saturating_add(edges.border.vertical_thickness());
        NaturalIntrinsicSize {
            width: outer_w.max(FLEX_ITEM_MIN_CROSS),
            height: outer_h.max(FLEX_ITEM_MIN_CROSS),
        }
    } else {
        // Non-flex block: measure content by recursing into block children
        // (sum their heights, take the max width) and adding one
        // max-content row for any inline / text content. Explicit CSS
        // `width` / `height` always win; the recursive `auto` fallback gives
        // nested content its content-based intrinsic size, matching the
        // flex-container branch above.
        //
        // We deliberately ignore the containing block here — that value is
        // meant for resolving percentages during layout, not for the
        // recursive pre-layout measurement pass.
        let mut content_w: usize = 0;
        let mut content_h: usize = 0;
        for &child_id in &node.children {
            let Some(child_gen) = generate_node(arena, child_id) else {
                continue;
            };
            let is_block =
                child_gen.kind == GeneratedBoxKind::Normal && !child_gen.outer_display.is_inline();
            if is_block {
                let cs = natural_intrinsic_size(arena, child_id, child_context, None);
                content_w = content_w.max(cs.width);
                content_h = content_h.saturating_add(cs.height);
            }
            // Non-block children (text / inline elements / anonymous inline
            // groups) fold into the block's own inline content, measured by
            // `collect_runs` / `measure_text_content_width` below.
        }
        let text_w = measure_text_content_width(arena, node_id).unwrap_or(0);
        content_w = content_w.max(text_w);
        // Inline content adds one intrinsic row, but a run made purely of
        // collapsible whitespace must not (it collapses away at a line edge).
        // NBSP is intentionally *not* collapsible, so it still counts — and
        // it is painted, so it already made `text_w > 0`. With `text_w == 0`
        // the subtree has no painted text at all, so the only thing that can
        // still force a row is a `<br>`: a cheap boolean scan replaces
        // building the full run list here.
        let has_inline = text_w > 0 || inline_tree_has_forced_break(arena, node_id);
        if has_inline {
            // Intrinsic (max-content) measurement does not wrap, so a
            // single line of inline content contributes one row scaled by
            // the block's effective font size.
            content_h = content_h.saturating_add(own_font_size_cells.max(1));
        }

        let content_box_w = intrinsic_len(css.0.get("width").map(|s| s.as_ref()), child_context)
            .unwrap_or(content_w.max(FLEX_ITEM_MIN_CROSS))
            .max(FLEX_ITEM_MIN_CROSS);
        // Some elements reserve trailing blank rows after their border-box
        // (the browser UA stylesheet's `spacing_after`): `<hr>` leaves one
        // row. The block layout adds this on top of `content_height`, so the
        // intrinsic measurement must too — otherwise a flex item sized from
        // the natural measure under-estimates its real height and the item's
        // last row bleeds into the parent's paint clip (silently dropped).
        let ua_spacing_after = match node.node_type {
            NodeType::Hr => 1,
            _ => 0,
        };
        let content_box_h = intrinsic_len(css.0.get("height").map(|s| s.as_ref()), child_context)
            .unwrap_or(content_h.max(FLEX_ITEM_MIN_CROSS))
            .max(FLEX_ITEM_MIN_CROSS)
            .saturating_add(ua_spacing_after);
        // Report the border-box (outer) size, like the flex-container branch
        // above: padding + border sit on top of the content-box measurement
        // so the flex algorithm sizes the item's full border-box.
        let width = content_box_w
            .saturating_add(edges.padding.horizontal())
            .saturating_add(edges.border.horizontal_thickness());
        let height = content_box_h
            .saturating_add(edges.padding.vertical())
            .saturating_add(edges.border.vertical_thickness());
        NaturalIntrinsicSize {
            width: width.max(FLEX_ITEM_MIN_CROSS),
            height: height.max(FLEX_ITEM_MIN_CROSS),
        }
    };

    log::debug!(
        target: "htui::intrinsic",
        "natural_intrinsic_size node={:?} type={:?} class={:?} flex={} -> {}x{}",
        node_id,
        node.node_type,
        node.attributes.get("class").map(|s| s.as_str()),
        is_flex_box,
        res.width,
        res.height
    );
    res
}

/// Intrinsic border-box dimensions of a node, in terminal cells.
struct NaturalIntrinsicSize {
    width: usize,
    height: usize,
}

/// Natural cross-axis size of a prepared flex item (its cached height for
/// `row`, its cached width for `column`).
///
/// The cross axis swaps between rows and columns:
///
/// - `row` direction: cross axis is vertical — the item's natural row count.
/// - `column` direction: cross axis is horizontal — the item's widest
///   intrinsic row.
///
/// Both values were measured once by `natural_intrinsic_size` when the prep
/// was built; re-reading them here keeps the wrap and placement passes from
/// re-running the recursive measurement with identical inputs.
pub(super) fn natural_cross_axis_size_for_prep(
    prep: &FlexItemPrep,
    direction: FlexDirection,
) -> usize {
    match direction.is_row() {
        true => prep.natural_height,
        false => prep.natural_width,
    }
    .max(FLEX_ITEM_MIN_CROSS)
}

/// Resolve an item's cross-axis size in the current line.
///
/// Honours `align-self` (item-level, already-resolved from `auto` by
/// `prepare_flex_item`). The natural cross-axis size of the item is
/// the default; `align-self: stretch` upgrades it to the line's
/// cross-axis size whenever the line's cross-axis size is already
/// known — but never over an explicit cross size (`css_cross: Some`):
/// CSS stretches only items whose cross size property computes to
/// `auto` (CSS Flexbox §9.4 step 10).
///
/// - `row` direction: cross axis is vertical. Stretching fills the
///   line height so a short sibling of a tall flex item grows to the
///   line instead of leaving blank rows.
/// - `column` direction: cross axis is horizontal. The container's
///   content width is always known (`content.width`), so stretch
///   always takes effect.
///
/// `margin_cross` is `(before, after)` along the cross axis; a stretched
/// item's border-box is the line cross minus its own cross-axis margins
/// (the margins sit outside the stretched box, like a browser).
pub(super) fn resolve_item_cross_size(
    align_self: AlignItems,
    line_cross: usize,
    natural_cross: usize,
    margin_cross: (usize, usize),
    explicit_cross: Option<usize>,
) -> usize {
    if let Some(size) = explicit_cross {
        return size.max(FLEX_ITEM_MIN_CROSS);
    }
    if matches!(align_self, AlignItems::Stretch) {
        line_cross
            .saturating_sub(margin_cross.0)
            .saturating_sub(margin_cross.1)
            .max(FLEX_ITEM_MIN_CROSS)
    } else {
        natural_cross.max(FLEX_ITEM_MIN_CROSS)
    }
}