Private/Rendering/Measure-ElmViewTree.ps1

function Resolve-ElmDimension {
    param(
        [object]$Value,
        [int]$Available,
        [int]$Natural
    )

    if ($Value -eq 'Auto') { return $Natural }
    if ($Value -eq 'Fill') { return $Available }
    if ($Value -is [int])  { return $Value }
    if ($Value -match '^(\d+)%$') {
        return [int][Math]::Floor($Available * [int]$Matches[1] / 100)
    }
    return $Natural
}

function Invoke-ElmPass1 {
    param(
        [Parameter(Mandatory)]
        [PSCustomObject]$Node
    )

    $style         = $Node.Style
    $paddingLeft   = if ($null -ne $style) { [int]$style.PaddingLeft } else { 0 }
    $paddingRight  = if ($null -ne $style) { [int]$style.PaddingRight } else { 0 }
    $paddingTop    = if ($null -ne $style) { [int]$style.PaddingTop } else { 0 }
    $paddingBottom = if ($null -ne $style) { [int]$style.PaddingBottom } else { 0 }
    $hasBorder     = ($null -ne $style) -and ($style.Border -ne 'None') -and ($null -ne $style.Border)
    $borderExtra   = if ($hasBorder) { 2 } else { 0 }

    if ($Node.Type -eq 'Text') {
        $naturalWidth  = $Node.Content.Length + $paddingLeft + $paddingRight + $borderExtra
        $naturalHeight = 1 + $paddingTop + $paddingBottom + $borderExtra

        return [PSCustomObject]@{
            Type          = 'Text'
            Content       = $Node.Content
            Style         = $Node.Style
            Width         = $Node.Width
            Height        = $Node.Height
            NaturalWidth  = $naturalWidth
            NaturalHeight = $naturalHeight
            X             = 0
            Y             = 0
        }
    }

    if ($Node.Type -eq 'Component') {
        # Expand the component by calling its ViewFn with its SubModel.
        # The resulting subtree is measured transparently - no Component nodes
        # appear in the measured output.
        $expanded = & $Node.ViewFn $Node.SubModel
        return Invoke-ElmPass1 -Node $expanded
    }

    # Box node - recurse into all children first
    $measuredChildren = [System.Collections.ArrayList]::new()
    foreach ($child in $Node.Children) {
        [void]$measuredChildren.Add((Invoke-ElmPass1 -Node $child))
    }

    # Compute parent natural size from non-Fill children only
    $nonFill = @($measuredChildren | Where-Object { $_.Width -ne 'Fill' })

    if ($Node.Direction -eq 'Vertical') {
        $naturalWidth  = if ($nonFill.Count -gt 0) {
            ($nonFill | Measure-Object -Property NaturalWidth -Maximum).Maximum
        } else { 0 }
        $naturalHeight = if ($measuredChildren.Count -gt 0) {
            ($measuredChildren | Measure-Object -Property NaturalHeight -Sum).Sum
        } else { 0 }
    } else {
        # Horizontal
        $naturalWidth  = if ($nonFill.Count -gt 0) {
            ($nonFill | Measure-Object -Property NaturalWidth -Sum).Sum
        } else { 0 }
        $naturalHeight = if ($measuredChildren.Count -gt 0) {
            ($measuredChildren | Measure-Object -Property NaturalHeight -Maximum).Maximum
        } else { 0 }
    }

    return [PSCustomObject]@{
        Type          = 'Box'
        Direction     = $Node.Direction
        Children      = $measuredChildren.ToArray()
        Style         = $Node.Style
        Width         = $Node.Width
        Height        = $Node.Height
        NaturalWidth  = $naturalWidth
        NaturalHeight = $naturalHeight
        X             = 0
        Y             = 0
    }
}

function Invoke-ElmPass2 {
    param(
        [Parameter(Mandatory)]
        [PSCustomObject]$Node,
        [Parameter(Mandatory)]
        [int]$AvailableWidth,
        [Parameter(Mandatory)]
        [int]$AvailableHeight,
        [Parameter(Mandatory)]
        [int]$X,
        [Parameter(Mandatory)]
        [int]$Y
    )

    $resolvedWidth  = Resolve-ElmDimension -Value $Node.Width  -Available $AvailableWidth  -Natural $Node.NaturalWidth
    $resolvedHeight = Resolve-ElmDimension -Value $Node.Height -Available $AvailableHeight -Natural $Node.NaturalHeight

    if ($Node.Type -eq 'Text') {
        return [PSCustomObject]@{
            Type          = 'Text'
            Content       = $Node.Content
            Style         = $Node.Style
            Width         = $resolvedWidth
            Height        = $resolvedHeight
            NaturalWidth  = $Node.NaturalWidth
            NaturalHeight = $Node.NaturalHeight
            X             = $X
            Y             = $Y
        }
    }

    # Box node - distribute space to children
    $resolvedChildren = [System.Collections.ArrayList]::new()

    if ($Node.Direction -eq 'Horizontal') {
        # Pre-compute each non-Fill child's resolved width (against parent) for fill distribution.
        # We store the resolved value only for accounting; non-Fill children are still called with
        # the parent's resolvedWidth as AvailableWidth so they resolve % against the parent.
        $childResolvedW = @{}
        $fixedTotal     = 0
        $fillCount      = 0

        for ($i = 0; $i -lt $Node.Children.Count; $i++) {
            $child = $Node.Children[$i]
            if ($child.Width -eq 'Fill') {
                $fillCount++
                $childResolvedW[$i] = $null
            } else {
                $w = Resolve-ElmDimension -Value $child.Width -Available $resolvedWidth -Natural $child.NaturalWidth
                $childResolvedW[$i] = $w
                $fixedTotal += $w
            }
        }

        $remainingW = [Math]::Max(0, $resolvedWidth - $fixedTotal)
        $fillW      = if ($fillCount -gt 0) { [int][Math]::Floor($remainingW / $fillCount) } else { 0 }
        $lastFillW  = if ($fillCount -gt 0) { $remainingW - ($fillW * ($fillCount - 1)) } else { 0 }

        $cursorX   = $X
        $fillIndex = 0
        for ($i = 0; $i -lt $Node.Children.Count; $i++) {
            $child = $Node.Children[$i]
            if ($null -eq $childResolvedW[$i]) {
                # Fill child: pass the computed fill slice as AvailableWidth
                $fillIndex++
                $avail = if ($fillIndex -eq $fillCount) { $lastFillW } else { $fillW }
            } else {
                # Non-fill child: pass parent resolvedWidth so % resolves correctly inside
                $avail = $resolvedWidth
            }
            $rc = Invoke-ElmPass2 -Node $child -AvailableWidth $avail -AvailableHeight $resolvedHeight -X $cursorX -Y $Y
            [void]$resolvedChildren.Add($rc)
            $cursorX += $rc.Width
        }
    } else {
        # Vertical - same pattern, distributing height
        $childResolvedH = @{}
        $fixedTotal     = 0
        $fillCount      = 0

        for ($i = 0; $i -lt $Node.Children.Count; $i++) {
            $child = $Node.Children[$i]
            if ($child.Height -eq 'Fill') {
                $fillCount++
                $childResolvedH[$i] = $null
            } else {
                $h = Resolve-ElmDimension -Value $child.Height -Available $resolvedHeight -Natural $child.NaturalHeight
                $childResolvedH[$i] = $h
                $fixedTotal += $h
            }
        }

        $remainingH = [Math]::Max(0, $resolvedHeight - $fixedTotal)
        $fillH      = if ($fillCount -gt 0) { [int][Math]::Floor($remainingH / $fillCount) } else { 0 }
        $lastFillH  = if ($fillCount -gt 0) { $remainingH - ($fillH * ($fillCount - 1)) } else { 0 }

        $cursorY   = $Y
        $fillIndex = 0
        for ($i = 0; $i -lt $Node.Children.Count; $i++) {
            $child = $Node.Children[$i]
            if ($null -eq $childResolvedH[$i]) {
                $fillIndex++
                $avail = if ($fillIndex -eq $fillCount) { $lastFillH } else { $fillH }
            } else {
                $avail = $resolvedHeight
            }
            $rc = Invoke-ElmPass2 -Node $child -AvailableWidth $resolvedWidth -AvailableHeight $avail -X $X -Y $cursorY
            [void]$resolvedChildren.Add($rc)
            $cursorY += $rc.Height
        }
    }

    return [PSCustomObject]@{
        Type          = 'Box'
        Direction     = $Node.Direction
        Children      = $resolvedChildren.ToArray()
        Style         = $Node.Style
        Width         = $resolvedWidth
        Height        = $resolvedHeight
        NaturalWidth  = $Node.NaturalWidth
        NaturalHeight = $Node.NaturalHeight
        X             = $X
        Y             = $Y
    }
}

function Measure-ElmViewTree {
    <#
    .SYNOPSIS
        Performs two-pass flexbox layout on a view tree, assigning X, Y, Width, Height.

    .DESCRIPTION
        Pass 1 (bottom-up): computes NaturalWidth/NaturalHeight for each node based on
        content, padding, and border settings. Fill children are skipped during parent
        natural-size computation.

        Pass 2 (top-down): resolves final Width/Height using the available space from
        the parent, then assigns absolute X/Y coordinates. Fill nodes receive an equal
        share of remaining space; % nodes are resolved against the parent's resolved width.

    .PARAMETER Root
        The root view node of the tree to measure.

    .PARAMETER TermWidth
        Available terminal width in columns.

    .PARAMETER TermHeight
        Available terminal height in rows.

    .OUTPUTS
        PSCustomObject - a new tree with the same structure as the input but with
        X, Y, Width, Height, NaturalWidth, and NaturalHeight fields on every node.

    .EXAMPLE
        $tree = New-ElmBox -Children @(New-ElmText -Content 'Hello') -Width 'Fill'
        $measured = Measure-ElmViewTree -Root $tree -TermWidth 80 -TermHeight 24

    .NOTES
        Does not mutate the input tree; returns a new copy.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [PSCustomObject]$Root,

        [Parameter(Mandatory)]
        [int]$TermWidth,

        [Parameter(Mandatory)]
        [int]$TermHeight
    )

    $withNatural = Invoke-ElmPass1 -Node $Root
    $measured    = Invoke-ElmPass2 -Node $withNatural -AvailableWidth $TermWidth -AvailableHeight $TermHeight -X 0 -Y 0
    return $measured
}