private/Controls/Set-UiFillParentHeight.ps1

function Set-UiFillParentHeight {
    <#
    .SYNOPSIS
        Gives a -Fill control its share of the window's leftover height.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [System.Windows.FrameworkElement]$Control,

        # DataGrid/Tree/List already stretch via their own templates and don't need this
        [System.Windows.Controls.Panel]$TrackParentWidth,

        [double]$MaxHeight = [double]::PositiveInfinity,

        # Minimum on the computed height. Prevents siblings above from pushing the control below a usable amount
        [double]$MinHeight = 50
    )

    $ctlRef    = $Control
    $parentRef = $TrackParentWidth
    $maxOuter  = $MaxHeight
    $minOuter  = $MinHeight

    # The page ScrollViewer measures at infinite height, so a virtualizing tree realizes every node on its first pass keeps them.
    # 2500 expanded nodes take 2.4s to first frame without this line and 0.7s with it.
    $Control.Height = 200

    $Control.Add_Loaded({
        param($sender, $eventArgs)

        # Recatpure vars since nested GetNewClosure doesnt capture closure inherited vars.
        $ctl = $ctlRef
        $pnt = $parentRef
        $max = $maxOuter
        $min = $minOuter

        # Width tracker (Grid only). The control's own margin comes off the parent's width or its right edge gets clipped.
        $widthHandler = $null
        if ($pnt) {
            $widthHandler = {
                param($sizeSender, $sizeArgs)
                $room = $sizeSender.ActualWidth - $ctl.Margin.Left - $ctl.Margin.Right
                if ($room -gt 50) { $ctl.Width = $room }
            }.GetNewClosure()
            $pnt.add_SizeChanged($widthHandler)
            $room = $pnt.ActualWidth - $ctl.Margin.Left - $ctl.Margin.Right
            if ($room -gt 50) { $ctl.Width = $room }
        }

        # Shrink so the natural content height doesn't push past the ScrollViewer, otherwise the math'll go in the neg
        $ctl.Height = 200

        # Walk out to the outter ScrollViewer, not the inner one. Its presenter and the window get picked up on the way.
        $sv        = $null
        $presenter = $null
        $window    = $null
        $lastSeen  = $null
        $walker    = [System.Windows.Media.VisualTreeHelper]::GetParent($ctl)
        while ($walker) {
            if ($walker -is [System.Windows.Controls.ScrollContentPresenter]) { $lastSeen = $walker }
            if ($walker -is [System.Windows.Controls.ScrollViewer]) { $sv = $walker; $presenter = $lastSeen }
            if ($walker -is [System.Windows.Window]) { $window = $walker; break }
            $walker = [System.Windows.Media.VisualTreeHelper]::GetParent($walker)
        }
        if (!$sv -or !$presenter) { return }

        $svLocal        = $sv
        $presenterLocal = $presenter
        $ctlLocal       = $ctl

        # Loaded can fire twice without Unloaded so old hooks come off first
        $group = $presenterLocal.Tag
        if ($group -is [hashtable] -and $group.FillMembers) {
            foreach ($old in @($group.FillMembers)) {
                if ([object]::ReferenceEquals($old.Control, $ctlLocal)) { & $old.Cleanup $false }
            }
            $group = $presenterLocal.Tag
        }

        # One group on the presenter's Tag for every -Fill control on the page because each one left to itsel takes whatever roomn is left
        if ($group -isnot [hashtable] -or !$group.ContainsKey('FillMembers')) {
            $group = @{ FillMembers = [System.Collections.Generic.List[object]]::new() }
            $group.Compute = {
                trap { Write-Debug "Set-UiFillParentHeight: $($_.Exception.Message)"; continue }

                # Window still sizing itself
                if ($window -and $window.SizeToContent -ne 'Manual') { return }

                # ScrollViewer.ViewportHeight and ExtentHeight don't catch up until LayoutUpdated, which runs after SizeChanged
                $vh = $presenterLocal.ViewportHeight
                if ($vh -le 0) { return }

                # Only the selected tab is in the tree
                $live = [System.Collections.Generic.List[object]]::new()
                foreach ($member in $group.FillMembers) {
                    $fill = $member.Control
                    if (!$fill.IsVisible -or !$presenterLocal.IsAncestorOf($fill)) { continue }
                    $top     = $fill.TranslatePoint([System.Windows.Point]::new(0, 0), $presenterLocal).Y + $presenterLocal.VerticalOffset
                    $current = [double]$fill.ActualHeight
                    $desired = $fill.DesiredSize.Height - $fill.Margin.Top - $fill.Margin.Bottom
                    $shown   = if ($desired -gt 0) { [Math]::Min($current, $desired) } else { $current }
                    $live.Add(@{ Member = $member; Top = $top; Current = $current; Shown = $shown; Nested = $false; Row = $null; AutoGrid = $null })
                }
                if (!$live.Count) { return }

                # Nested members don't get their own row so the parent gets the share and the child gets its own height
                if ($live.Count -gt 1) {
                    $liveControls = [System.Collections.Generic.HashSet[object]]::new()
                    foreach ($entry in $live) { [void]$liveControls.Add($entry.Member.Control) }
                    foreach ($entry in $live) {
                        $up = [System.Windows.Media.VisualTreeHelper]::GetParent($entry.Member.Control)
                        while ($up -and ![object]::ReferenceEquals($up, $presenterLocal)) {
                            if ($liveControls.Contains($up)) { $entry.Nested = $true; break }
                            $up = [System.Windows.Media.VisualTreeHelper]::GetParent($up)
                        }
                    }

                    # Members in Auto rows split their own grid's spare room
                    $autoGrids = [System.Collections.Generic.List[object]]::new()
                    foreach ($entry in $live) {
                        if (!$entry.Nested) { continue }
                        $grid = [System.Windows.Media.VisualTreeHelper]::GetParent($entry.Member.Control) -as [System.Windows.Controls.Grid]
                        if (!$grid -or !$grid.RowDefinitions.Count) { continue }
                        $index = [Math]::Min([System.Windows.Controls.Grid]::GetRow($entry.Member.Control), $grid.RowDefinitions.Count - 1)
                        if (!$grid.RowDefinitions[$index].Height.IsAuto) { continue }
                        $owner = $null
                        foreach ($known in $autoGrids) { if ([object]::ReferenceEquals($known.Grid, $grid)) { $owner = $known } }
                        if (!$owner) {
                            $owner = @{ Grid = $grid; Rows = @{}; Share = $null }
                            $autoGrids.Add($owner)
                        }
                        $owner.Rows[$index] = [Math]::Max([double]$owner.Rows[$index], $entry.Shown)
                        $entry.AutoGrid     = $owner
                    }
                }

                # Side by side members share one row
                $ordered = [System.Collections.Generic.List[object]]::new()
                foreach ($entry in $live) {
                    if ($entry.Nested) { continue }
                    $at = 0
                    while ($at -lt $ordered.Count -and $ordered[$at].Top -le $entry.Top) { $at++ }
                    $ordered.Insert($at, $entry)
                }
                $rows = [System.Collections.Generic.List[object]]::new()
                $row  = $null
                foreach ($entry in $ordered) {
                    $bottom = $entry.Top + $entry.Shown
                    if ($null -eq $row -or $entry.Top -ge $row.Bottom - 0.5) {
                        $row = @{ Top = $entry.Top; Bottom = $bottom; Height = 0.0; Min = 0.0; Max = 0.0; Share = 0.0 }
                        $rows.Add($row)
                    }
                    $entry.Row  = $row
                    $row.Bottom = [Math]::Max($row.Bottom, $bottom)
                    $row.Height = [Math]::Max($row.Height, $entry.Shown)
                    $row.Min    = [Math]::Max($row.Min, $entry.Member.Min)
                    $row.Max    = [Math]::Max($row.Max, $entry.Member.Max)
                }

                $total = 0.0
                foreach ($row in $rows) { $total += $row.Height }
                if ($live.Count -eq 1) { $total = $live[0].Current }
                $total = $total + $vh - $presenterLocal.ExtentHeight

                # Rows share one height unless a MinHeight or MaxHeight holds one back
                $level = $total / $rows.Count
                $held  = $false
                foreach ($row in $rows) { if ($level -lt $row.Min -or $level -gt $row.Max) { $held = $true } }

                if ($held -and $rows.Count -gt 1) {
                    $low  = 0.0
                    $high = [Math]::Max($total, 0.0)
                    while ($high - $low -gt 0.001) {
                        $level = ($low + $high) / 2
                        $sum   = 0.0
                        foreach ($row in $rows) { $sum += [Math]::Min([Math]::Max($level, $row.Min), $row.Max) }
                        if ($sum -lt $total) { $low = $level } else { $high = $level }
                    }

                    $free = 0
                    $left = $total
                    foreach ($row in $rows) {
                        $row.Share = if ($low -lt $row.Min) { $row.Min } elseif ($low -gt $row.Max) { $row.Max } else { $null }
                        if ($null -eq $row.Share) { $free++ } else { $left -= $row.Share }
                    }
                    foreach ($row in $rows) { if ($null -eq $row.Share) { $row.Share = $left / $free } }
                }
                elseif ($rows.Count -eq 1) { $rows[0].Share = $total }
                else { foreach ($row in $rows) { $row.Share = $level } }

                # Capped from its old top
                $shift = 0.0
                foreach ($row in $rows) {
                    $row.Top += $shift
                    $shift   += $row.Share - $row.Height
                }

                $source = [System.Windows.PresentationSource]::FromVisual($presenterLocal)
                $scale  = if ($source) { $source.CompositionTarget.TransformToDevice.M22 } else { 1.0 }

                # Nested members last so Auto rows split their grid's next Height
                $order = [System.Collections.Generic.List[object]]::new()
                foreach ($entry in $live) { if (!$entry.Nested) { $order.Add($entry) } }
                foreach ($entry in $live) { if ($entry.Nested) { $order.Add($entry) } }

                foreach ($entry in $order) {
                    $member  = $entry.Member
                    $current = $entry.Current
                    $top     = $entry.Top
                    if ($entry.AutoGrid) {
                        $owner = $entry.AutoGrid
                        if ($null -eq $owner.Share) {
                            $grid = $owner.Grid
                            $room = $grid.Height
                            if ([double]::IsNaN($room)) {
                                $room = [System.Windows.Controls.Primitives.LayoutInformation]::GetLayoutSlot($grid).Height - $grid.Margin.Top - $grid.Margin.Bottom
                            }
                            $sum = 0.0
                            foreach ($height in $owner.Rows.Values) { $sum += $height }
                            foreach ($definition in $grid.RowDefinitions) { $room -= $definition.ActualHeight }
                            $owner.Share = ($sum + $room) / $owner.Rows.Count
                        }
                        $target = $owner.Share
                        if (!$member.ParentHooked) { & $member.HookParent }
                    }
                    elseif ($entry.Nested) {
                        $slot   = [System.Windows.Controls.Primitives.LayoutInformation]::GetLayoutSlot($member.Control)
                        $target = $slot.Height - $member.Control.Margin.Top - $member.Control.Margin.Bottom
                        if (!$member.ParentHooked) { & $member.HookParent }
                    }
                    else {
                        $target = $entry.Row.Share
                        $top    = $entry.Row.Top

                        # Without this cap every resize adds the same spare room again
                        if ($target -gt $vh - $top) { $target = $vh - $top }
                    }

                    $target = [Math]::Floor($target * $scale) / $scale
                    if ($target -gt $member.Max) { $target = $member.Max }
                    if ($target -lt $member.Min) { $target = $member.Min }

                    Write-Debug "Set-UiFillParentHeight: vh=$vh extent=$($presenterLocal.ExtentHeight) top=$top max=$($member.Max) min=$($member.Min) target=$target current=$current members=$($live.Count)"
                    $member.FirstDone = $true
                    if ($target -le 0 -or [Math]::Abs($target - $current) -lt 0.01) { continue }
                    $member.Control.Height = $target
                }
            }.GetNewClosure()
            $presenterLocal.Tag = $group
        }

        $member = @{
            Control      = $ctlLocal
            Min          = $min
            Max          = $max
            FirstDone    = $false
            Cleanup      = $null
            HookParent   = $null
            ParentHooked = $false
            HookedParent = $null
        }
        $group.FillMembers.Add($member)
        $computeHeight = $group.Compute

        # Initial compute at Loaded priority. Checks after the current layout settles (~5ms), tight enough that the computed shrink doesn't give a jarring 'flash' kind of an effect
        [void]$ctlLocal.Dispatcher.BeginInvoke(  [System.Windows.Threading.DispatcherPriority]::Loaded, [Action]{ & $computeHeight }.GetNewClosure())

        # Crude but workable brute force retry timer for TabItem. Doesn't seem to always catch the right timing, so retry a few times until the first compute succeeds or 20 tries (~1 second) have elapsed, with 50ms inbetween. May need to be tweaked later on.
        $retryTimer = [System.Windows.Threading.DispatcherTimer]::new()
        $retryTimer.Interval = [TimeSpan]::FromMilliseconds(50)
        $retryState = @{ Count = 0; Timer = $retryTimer }
        $retryTimer.Add_Tick({
            $retryState.Count++
            & $computeHeight
            if ($member.FirstDone -or $retryState.Count -ge 20) {
                $retryState.Timer.Stop()
            }
        }.GetNewClosure())
        $retryTimer.Start()

        # SizeChanged handler, held as a variable so the Unloaded cleanup can detach
        $svHandlerSb = {
            param($sizeSender, $sizeArgs)
            Write-Debug "Set-UiFillParentHeight: SV.SizeChanged fired (new H=$($sizeSender.ActualHeight))"
            & $computeHeight
        }.GetNewClosure()
        $svLocal.add_SizeChanged($svHandlerSb)

        # Second pass for nested members
        $parentHandlerSb   = { param($sizeSender, $sizeArgs) & $computeHeight }.GetNewClosure()
        $member.HookParent = {
            $member.ParentHooked = $true
            $hookParent = [System.Windows.Media.VisualTreeHelper]::GetParent($ctlLocal)
            if ($hookParent -isnot [System.Windows.FrameworkElement]) { return }
            $hookParent.add_SizeChanged($parentHandlerSb)
            $member.HookedParent = $hookParent
        }.GetNewClosure()

        # Window.SizeChanged shrinks when content is taller than viewport, ScrollViewer.ActualHeight stalls and its own SizeChanged event never seems to fire
        $windowSizeHandlerSb = $null
        if ($window) {
            $windowSizeHandlerSb = {
                param($wSender, $wArgs)
                Write-Debug "Set-UiFillParentHeight: Window.SizeChanged fired (new $($wArgs.NewSize.Width)x$($wArgs.NewSize.Height))"
                $compute = $computeHeight
                if ($compute) { & $compute }
            }.GetNewClosure()
            $window.add_SizeChanged($windowSizeHandlerSb)
        }

        # Window.StateChanged for max/restore btns
        $stateHandlerSb = $null
        if ($window) {
            $stateHandlerSb = {
                param($wSender, $wArgs)
                $compute = $computeHeight
                if (!$compute) { return }
                [void]$wSender.Dispatcher.BeginInvoke(
                    [System.Windows.Threading.DispatcherPriority]::Loaded,
                    [Action]{ if ($compute) { & $compute } }.GetNewClosure())
            }.GetNewClosure()
            $window.add_StateChanged($stateHandlerSb)
        }

        # Visibility moves every share
        $visibleHandlerSb = {
            param($visSender, $visArgs)
            $compute = $computeHeight
            [void]$visSender.Dispatcher.BeginInvoke(
                [System.Windows.Threading.DispatcherPriority]::Loaded,
                [Action]{ & $compute }.GetNewClosure())
        }.GetNewClosure()
        $ctlLocal.add_IsVisibleChanged($visibleHandlerSb)

        # A closing New-UiWindow shuts down its UI thread before Unloaded arrives so Closed runs the same cleanup.
        $hooks   = @{ Unloaded = $null; Closed = $null }
        $cleanup = {
            param([bool]$Resplit)
            trap { Write-Debug "Set-UiFillParentHeight cleanup: $($_.Exception.Message)"; continue }
            $retryState.Timer.Stop()
            $svLocal.remove_SizeChanged($svHandlerSb)
            $ctlLocal.remove_IsVisibleChanged($visibleHandlerSb)
            $ctlLocal.remove_Unloaded($hooks.Unloaded)
            if ($window) {
                $window.remove_SizeChanged($windowSizeHandlerSb)
                $window.remove_StateChanged($stateHandlerSb)
                $window.remove_Closed($hooks.Closed)
            }
            if ($pnt -and $widthHandler) { $pnt.remove_SizeChanged($widthHandler) }
            if ($member.HookedParent) { $member.HookedParent.remove_SizeChanged($parentHandlerSb) }

            [void]$group.FillMembers.Remove($member)
            if (!$group.FillMembers.Count) {
                if ([object]::ReferenceEquals($presenterLocal.Tag, $group)) { $presenterLocal.Tag = $null }
                return
            }
            if (!$Resplit) { return }
            $compute = $group.Compute
            [void]$presenterLocal.Dispatcher.BeginInvoke(
                [System.Windows.Threading.DispatcherPriority]::Loaded,
                [Action]{ & $compute }.GetNewClosure())
        }.GetNewClosure()
        $member.Cleanup = $cleanup

        $hooks.Unloaded = { param($unloadSender, $unloadArgs) & $cleanup $true }.GetNewClosure()
        $ctlLocal.add_Unloaded($hooks.Unloaded)
        if ($window) {
            $hooks.Closed = { param($closeSender, $closeArgs) & $cleanup $false }.GetNewClosure()
            $window.add_Closed($hooks.Closed)
        }
    }.GetNewClosure())
}