public/list/New-UiTree.ps1

function New-UiTree {
    <#
    .SYNOPSIS
        Creates a hierarchical tree view for displaying nested data.
    .DESCRIPTION
        Builds a WPF TreeView from nested hashtables, objects, or flat path-based data.
        For nested data, each item's display text comes from -DisplayProperty and children
        from -ChildrenProperty. For flat data like Get-ChildItem output, use -PathProperty
        to specify which property contains the hierarchical path (e.g., FullName).
 
        For parent-child relationships (like processes), use -IdProperty and -ParentIdProperty.
 
        Right-click for the context menu, which opens or shuts a branch or the whole tree, copies
        a node's text, and on a checkbox tree checks or clears a node and everything beneath it.
        -ExpandAll only applies as the tree is built, so use the menu once it is on screen.
    .PARAMETER Variable
        Variable name for accessing this tree in button actions.
    .PARAMETER Items
        Array of tree items. Can be nested (with Children property) or flat with paths.
    .PARAMETER DisplayProperty
        Property name to display as the node text. Defaults to 'Name'.
    .PARAMETER ChildrenProperty
        Property name containing child items for nested data. Defaults to 'Children'.
    .PARAMETER PathProperty
        Property containing a hierarchical path (e.g., FullName for FileInfo objects).
        When specified, the tree builds hierarchy from path segments instead of nested data.
    .PARAMETER PathSeparator
        Separator character for path segments. Defaults to '\' for filesystem paths.
        Use ',' for AD Distinguished Names, '.' for namespaces.
    .PARAMETER ReversePath
        Reverse the path segment order. Use for AD Distinguished Names where leaf is first
        (CN=User,OU=Sales,DC=corp,DC=com becomes DC=com > DC=corp > OU=Sales > CN=User).
    .PARAMETER IdProperty
        Property containing unique ID for parent-child relationships (e.g., Id for processes).
    .PARAMETER ParentIdProperty
        Property containing parent's ID (e.g., ParentProcessId for processes).
    .PARAMETER Height
        Height of the tree control. Defaults to 200. Ignored when -Fill is set.
    .PARAMETER Fill
        Grow to the rest of the window's vertical viewport instead of the fixed -Height.
        Tree resizes with the window. Use when the tree is the dominant content in the view.
    .PARAMETER CaptureScrollWheel
        Keep every mouse-wheel event inside the tree. Same as -ScrollWheel Capture.
    .PARAMETER ScrollWheel
        Says who gets the wheel while the cursor is over the tree. Page, the default, hands every
        wheel event to the page (the main window), so a window full of them still scrolls. Edge scrolls
        the tree's own nodes until it reaches the top or bottom and gives the page the wheel from there.
        Capture holds on at the ends as well, so the page stays put while the cursor is here.
        A -Fill tree starts on Edge instead, since it holds the viewport and the page behind it
        has almost no scroll of its own left. Pass -ScrollWheel to override that.
    .PARAMETER NoContextMenu
        Kill the right-click menu: Expand All, Collapse All, Expand, Collapse and Copy, plus Check
        All Below and Uncheck All Below on a checkbox tree.
    .PARAMETER ExpandAll
        Expand all nodes on load. That is the only time it applies. Use the right-click menu to
        open and shut nodes afterwards.
    .PARAMETER ParentCheckBoxes
        Checkbox on every item with children. Alone, each box flips only itself. With
        -ChildCheckBoxes alongside, clicking a parent toggles enabled descendants (the
        full picker).
    .PARAMETER ChildCheckBoxes
        Checkbox on every leaf. Alone, parents become unselectable - the box is the only way in.
    .PARAMETER WhenEnabled
        Scriptblock run per item. Returns $false and the box renders disabled; cascade skips it.
    .PARAMETER Checked
        Scriptblock run per item at build time. Returns $true and the box starts checked.
    .PARAMETER NoCascade
        Independent boxes - parent clicks don't cascade. For tagging workflows.
    .PARAMETER WPFProperties
        Hashtable of additional WPF properties to set on the control.
    .EXAMPLE
        # Nested hashtable data
        $data = @(
            @{ Name = 'Root'; Children = @(
                @{ Name = 'Child 1' }
                @{ Name = 'Child 2'; Children = @(
                    @{ Name = 'Grandchild' }
                )}
            )}
        )
        New-UiTree -Variable 'tree' -Items $data
    .EXAMPLE
        # Filesystem
        Get-ChildItem C:\Temp -Recurse -Directory | New-UiTree -Variable 'folders' -PathProperty 'FullName'
    .EXAMPLE
        # Active Directory OUs - DN is reversed, comma-separated
        Get-ADOrganizationalUnit -Filter * | New-UiTree -Variable 'ous' -PathProperty 'DistinguishedName' -PathSeparator ',' -ReversePath
    .EXAMPLE
        # Org chart - parent/child by ID using a dotted property path on the parent reference
        $employees = @(
            [PSCustomObject]@{ EmployeeId = 1; Name = 'CEO'; Manager = $null }
            [PSCustomObject]@{ EmployeeId = 2; Name = 'VP Eng'; Manager = [PSCustomObject]@{ EmployeeId = 1 } }
            [PSCustomObject]@{ EmployeeId = 3; Name = 'Dev'; Manager = [PSCustomObject]@{ EmployeeId = 2 } }
        )
        $employees | New-UiTree -Variable 'org' -IdProperty 'EmployeeId' -ParentIdProperty 'Manager.EmployeeId' -DisplayProperty 'Name'
    .EXAMPLE
        # .NET namespaces in the PowerShell engine
        [psobject].Assembly.GetExportedTypes() |
            New-UiTree -Variable 'types' -PathProperty 'FullName' -PathSeparator '.'
    .EXAMPLE
        # Services by status. Auto start protected, stopped preselected.
        Get-Service | Group-Object Status | ForEach-Object {
            [PSCustomObject]@{ Name = $_.Name; Children = $_.Group }
        } | New-UiTree -Variable 'svc' -ParentCheckBoxes -ChildCheckBoxes `
              -WhenEnabled { $_.StartType -ne 'Automatic' } -Checked { $_.Status -eq 'Stopped' }
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$Variable,

        [Parameter(ValueFromPipeline)]
        [object[]]$Items,

        [Parameter()]
        [string]$DisplayProperty = 'Name',

        [Parameter()]
        [string]$ChildrenProperty = 'Children',

        [Parameter()]
        [string]$PathProperty,

        [Parameter()]
        [string]$PathSeparator = '\',

        [switch]$ReversePath,

        [Parameter()]
        [string]$IdProperty,

        [Parameter()]
        [string]$ParentIdProperty,

        [int]$Height = 200,

        [switch]$Fill,

        [switch]$CaptureScrollWheel,

        [switch]$NoContextMenu,

        [switch]$ExpandAll,

        [switch]$ParentCheckBoxes,

        [switch]$ChildCheckBoxes,

        [scriptblock]$WhenEnabled,

        [scriptblock]$Checked,

        [switch]$NoCascade,

        [Parameter()]
        [hashtable]$WPFProperties,

        [ValidateSet('Page', 'Edge', 'Capture')]
        [string]$ScrollWheel = 'Page'
    )

    begin {
        $collectedItems = [System.Collections.Generic.List[object]]::new()
    }

    process {
        # Drop empty rows. $null.PSObject.Properties[$DisplayProperty] in the build loop'll throw "cannot index into a null array"
        # The piped path already skips them ($Items guard). This covers -Items @(...) where the whole array binds at once.
        if ($Items) {
            foreach ($item in $Items) {
                if ($null -ne $item) { $collectedItems.Add($item) }
            }
        }
    }

    end {
        $session   = Assert-UiSession -CallerName 'New-UiTree'
        $parent    = $session.CurrentParent
        # Application.Current is null without a window up, as in a headless test harness, and a deref throws.
        $app       = [System.Windows.Application]::Current
        $treeStyle = if ($app) { $app.TryFindResource('ModernTreeViewStyle') } else { $null }

        # Resolve a property path, dotted or not, against an object.
        # 'Parent.Id' on a Process returns $process.Parent.Id (not $process.'Parent.Id').
        $getDotted = {
            param($obj, [string]$pathExpr)
            if ($null -eq $obj -or [string]::IsNullOrEmpty($pathExpr)) { return $null }
            $current = $obj
            foreach ($part in $pathExpr.Split('.')) {
                if ($null -eq $current) { return $null }
                $current = $current.$part
            }
            return $current
        }

        # Create the tree control with base styling. -Fill defers the Height to the helper call below (after Add). Otherwise the fixed -Height applies.
        $treeProps = @{
            BorderThickness = [System.Windows.Thickness]::new(1)
            Margin          = [System.Windows.Thickness]::new(4)
        }
        if (!$Fill) { $treeProps.Height = $Height }
        $tree = [System.Windows.Controls.TreeView]$treeProps

        if ($treeStyle) { $tree.Style = $treeStyle }
        [PsUi.ThemeEngine]::RegisterElement($tree)

        # Use collected items from pipeline or direct parameter
        $allItems = if ($collectedItems.Count -gt 0) { $collectedItems } else { $Items }

        if ($IdProperty -and $ParentIdProperty -and $allItems) {
            # Build from parent-child ID relationships (process trees, org charts)
            $nodeMap = @{}

            # First pass makes a node for each item
            foreach ($item in $allItems) {
                $id = & $getDotted $item $IdProperty
                if ($null -eq $id) { continue }

                $displayText = & $getDotted $item $DisplayProperty
                if ($null -eq $displayText) { $displayText = $id.ToString() }

                $node = [System.Windows.Controls.TreeViewItem]@{
                    Header = $displayText
                    Tag    = $item
                }
                if ($ExpandAll) { $node.IsExpanded = $true }

                $nodeMap[$id] = @{ Node = $node; Item = $item }
            }

            # Second pass connects parent to child
            foreach ($id in $nodeMap.Keys) {
                $entry    = $nodeMap[$id]
                $item     = $entry.Item
                $node     = $entry.Node
                $parentId = & $getDotted $item $ParentIdProperty

                if ($parentId -and $nodeMap.ContainsKey($parentId)) {
                    [void]$nodeMap[$parentId].Node.Items.Add($node)
                }
                else {
                    [void]$tree.Items.Add($node)
                }
            }
        }
        elseif ($PathProperty -and $allItems) {
            # Build hierarchy from path strings (filesystem, AD, registry)
            $nodeMap = @{}

            foreach ($item in $allItems | Sort-Object $PathProperty) {
                $path = & $getDotted $item $PathProperty
                if (!$path) { continue }

                # Split path into segments and optionally reverse for DN-style paths
                $segments = $path.Split($PathSeparator, [System.StringSplitOptions]::RemoveEmptyEntries)
                if ($ReversePath) { [array]::Reverse($segments) }

                $currentPath = ''
                $parentNode  = $null

                for ($i = 0; $i -lt $segments.Count; $i++) {
                    $segment     = $segments[$i]
                    $currentPath = if ($currentPath) { "$currentPath$PathSeparator$segment" } else { $segment }
                    $isOwnPath   = ($i -eq $segments.Count - 1)

                    # Reuse existing node or create new one
                    if ($nodeMap.ContainsKey($currentPath)) {
                        $parentNode = $nodeMap[$currentPath]
                        # Items piped in later can match the path of an intermediate already made, so this is an upsert.
                        # Promote the synthesized Tag to the real source object.
                        if ($isOwnPath) { $parentNode.Tag = $item }
                    }
                    else {
                        # Tag is the source item when this segment IS the item's own path, otherwise a created placeholder carrying the accumulated path so consumers always have something actionable to read from .Tag.
                        $tagData = if ($isOwnPath) { $item }
                        else { [PSCustomObject]@{ Path = $currentPath; Synthesized = $true }
                    }

                        $node = [System.Windows.Controls.TreeViewItem]@{
                            Header = $segment
                            Tag    = $tagData
                        }

                        if ($ExpandAll) { $node.IsExpanded = $true }

                        if (!$parentNode) { [void]$tree.Items.Add($node) }
                        else { [void]$parentNode.Items.Add($node)  }

                        $nodeMap[$currentPath] = $node
                        $parentNode = $node
                    }
                }
            }
        }
        elseif ($allItems) {
            # Walk nested data structure (hashtables with Children arrays)
            $buildNodes = {
                param($itemList, $parentNode)

                foreach ($item in $itemList) {
                    # $allItems can fall back to raw $Items on all null input,skip so the deref below is safe.
                    if ($null -eq $item) { continue }
                    $displayText = $null
                    $children    = $null

                    # Handle both hashtables and PSObjects
                    if ($item -is [hashtable]) {
                        $displayText = $item[$DisplayProperty]
                        $children    = $item[$ChildrenProperty]
                    }
                    elseif ($item.PSObject.Properties[$DisplayProperty]) {
                        $displayText = $item.$DisplayProperty
                        $children    = $item.$ChildrenProperty
                    }
                    else {  $displayText = $item.ToString() }

                    $node = [System.Windows.Controls.TreeViewItem]@{
                        Header = $displayText
                        Tag    = $item
                    }

                    # Recurse into children if present
                    if ($children -and $children.Count -gt 0) {
                        & $buildNodes $children $node
                    }

                    if ($ExpandAll) { $node.IsExpanded = $true }

                    if (!$parentNode) {  [void]$tree.Items.Add($node) }
                    else { [void]$parentNode.Items.Add($node)  }
                }
            }

            & $buildNodes $allItems $null
        }

        # Decoration runs after the tree is built so parent/leaf is structural (Items.Count > 0) not predicted. All three build modes converge to the same TreeViewItem layout.
        $useCheckBoxes = $ParentCheckBoxes -or $ChildCheckBoxes
        if ($useCheckBoxes) {

            # Marker the C# extractor reads to flip hydration from snapshot to checked-items array.
            $tree.Tag = @{
                IsCheckBoxTree     = $true
                ParentMode         = [bool]$ParentCheckBoxes
                ChildMode          = [bool]$ChildCheckBoxes
                CascadeInProgress  = $false
            }
            $treeMeta = $tree.Tag

            # Synchronous helpers - only used during build. The cascade handler is stateless.
            $getHeaderCheckBox = {
                param($tvi)
                $hdr = $tvi.Header
                if ($hdr -is [System.Windows.Controls.StackPanel]) {
                    foreach ($child in $hdr.Children) {
                        if ($child -is [System.Windows.Controls.CheckBox]) { return $child }
                    }
                }
                $null
            }

            # Path mode invents stand-in parent nodes that aren't real items - WhenEnabled and Checked skip them.
            $isSynthesized = {
                param($source)
                if ($null -eq $source) { return $true }
                if ($source -is [PSCustomObject]) {
                    $p = $source.PSObject.Properties['Synthesized']
                    return ($p -and $p.Value)
                }
                if ($source -is [hashtable]) { return [bool]$source['Synthesized'] }
                $false
            }

            # One handler instance and two cached descriptors, pulled out of the foreach. 10k row trees notice.
            $dpdIsSelected = [System.ComponentModel.DependencyPropertyDescriptor]::FromProperty( [System.Windows.Controls.TreeViewItem]::IsSelectedProperty, [System.Windows.Controls.TreeViewItem])

            $dpdIsSelectionActive = [System.ComponentModel.DependencyPropertyDescriptor]::FromProperty( [System.Windows.Controls.Primitives.Selector]::IsSelectionActiveProperty, [System.Windows.Controls.TreeViewItem])

            $updateLabelFg = {
                param($sender, $e)

                $tviLocal = $sender -as [System.Windows.Controls.TreeViewItem]

                if ($null -eq $tviLocal -or $tviLocal.Header -isnot [System.Windows.Controls.StackPanel]) { return }
                $isActiveSel = $tviLocal.IsSelected -and [System.Windows.Controls.Primitives.Selector]::GetIsSelectionActive($tviLocal)
                $key = if ($isActiveSel) { 'SelectionForegroundBrush' } else { 'ControlForegroundBrush' }
                foreach ($childCtrl in $tviLocal.Header.Children) {
                    if ($childCtrl -is [System.Windows.Controls.TextBlock]) {
                        $childCtrl.SetResourceReference(
                            [System.Windows.Controls.TextBlock]::ForegroundProperty, $key)
                    }
                }
            }

            # The descriptor holds a strong reference and leaks every TVI in the tree per closed window without the Unloaded unsubscribe.
            # WPF's memory model is more or less just a series of suggestions?
            # Built once rather than per node, since a closure per node is the most expensive thing in this loop.
            $watchSelection = {
                param($sender, $eventArgs)
                $dpdIsSelected.AddValueChanged($sender, $updateLabelFg)
                $dpdIsSelectionActive.AddValueChanged($sender, $updateLabelFg)
            }.GetNewClosure()
            $unwatchSelection = {
                param($sender, $eventArgs)
                $dpdIsSelected.RemoveValueChanged($sender, $updateLabelFg)
                $dpdIsSelectionActive.RemoveValueChanged($sender, $updateLabelFg)
            }.GetNewClosure()

            # Walk every TVI and decorate it.
            $decorate = {
                param($items)
                foreach ($tvi in $items) {
                    $hasChildren = $tvi.Items.Count -gt 0
                    $wantsBox = ($ParentCheckBoxes -and $hasChildren) -or
                                ($ChildCheckBoxes  -and !$hasChildren)

                    # Non checkbox rows in a mixed mode tree shouldn't look selectable. Checkboxes are the selection control.
                    # Focusable=$false kills arrow nav and click select. The expander toggle still works (separate element).
                    if (!$wantsBox -and ($ParentCheckBoxes -or $ChildCheckBoxes)) {  $tvi.Focusable = $false  }

                    if ($wantsBox) {
                        $source     = $tvi.Tag
                        $isFake     = & $isSynthesized $source
                        $headerText = $tvi.Header

                        $cb = [System.Windows.Controls.CheckBox]@{
                            VerticalAlignment = 'Center'
                            Margin            = [System.Windows.Thickness]::new(0, 0, 6, 0)
                        }

                        # Cascade handler is stateless and reads everything from $sender.Tag. The C# extractor walks TreeViewItems not CheckBoxes, so this Tag payload doesn't disturb hydration.
                        $cb.Tag = @{
                            Tvi      = $tvi
                            TreeMeta = $treeMeta
                        }

                        $disableThis = $false
                        if (!$isFake) {
                            # ForEach-Object binds $_. `$source | & $sb` does NOT (verified).
                            if ($Checked) {
                                $checkedResult = $source | ForEach-Object $Checked
                                if ($checkedResult) { $cb.IsChecked = $true }
                            }
                            if ($WhenEnabled) {
                                $enabledResult = $source | ForEach-Object $WhenEnabled
                                if (!$enabledResult) {
                                    $cb.IsEnabled = $false
                                    $disableThis = $true
                                }
                            }
                        }

                        $label = [System.Windows.Controls.TextBlock]@{
                            Text              = [string]$headerText
                            VerticalAlignment = 'Center'
                        }
                        # Default Foreground via DynamicResource so theme swaps reach it.
                        $label.SetResourceReference(
                            [System.Windows.Controls.TextBlock]::ForegroundProperty, 'ControlForegroundBrush')

                        # Watch IsSelected AND IsSelectionActive, Add_Selected misses the inactive case (tree loses focus, row stays selected).
                        $tvi.Add_Loaded($watchSelection)
                        $tvi.Add_Unloaded($unwatchSelection)

                        $stack = [System.Windows.Controls.StackPanel]@{
                            Orientation = 'Horizontal'
                        }
                        [void]$stack.Children.Add($cb)
                        [void]$stack.Children.Add($label)

                        # Set-CheckBoxStyle has no IsEnabled trigger - disabled boxes look enabled.
                        # Dim the whole header instead of patching the module-wide style.
                        if ($disableThis) { $stack.Opacity = 0.45 }
                        $tvi.Header = $stack

                        # Style AFTER the CheckBox is in the visual tree, DynamicResource lookups resolve better with a parent to walk up from.
                        # Set-CheckBoxStyle registers with ThemeEngine itself.
                        Set-CheckBoxStyle -CheckBox $cb
                    }

                    if ($tvi.Items.Count -gt 0) { & $decorate $tvi.Items }
                }
            }

            & $decorate $tree.Items

            # Attach cascade handlers. Skip when -NoCascade is set, or when only one checkbox level is enabled (nothing to cascade to/from).
            $wireCascade = ($ParentCheckBoxes -and $ChildCheckBoxes -and !$NoCascade)
            if ($wireCascade) {

                # Stateless. State via $sender.Tag, no closure capture. PS finally blocks get silently skipped when WPF fires the click, leaving CascadeInProgress stuck at true and locking subsequent clicks out. Reset inline in both paths.
                $cascadeHandler = {
                    param($sender, $eventArgs)
                    $treeMetaForReset = $null
                    try {
                        $data = $sender.Tag
                        if ($data -isnot [hashtable]) { return }
                        $treeMeta  = $data['TreeMeta']
                        $sourceTvi = $data['Tvi']
                        if ($null -eq $treeMeta -or $sourceTvi -isnot [System.Windows.Controls.TreeViewItem]) { return }
                        if ($treeMeta['CascadeInProgress']) { return }
                        $treeMeta['CascadeInProgress'] = $true
                        $treeMetaForReset = $treeMeta
                        $newState = [bool]$sender.IsChecked

                        # Cascade down with a stack, checkbox lookup inlined
                        # Helper script blocks throw on null lookups when called from the click handler.
                        $dnStack = [System.Collections.Generic.Stack[object]]::new()
                        $dnStack.Push($sourceTvi)
                        while ($dnStack.Count -gt 0) {
                            $node = $dnStack.Pop()
                            if ($null -eq $node) { continue }
                            foreach ($child in $node.Items) {
                                if ($null -eq $child) { continue }
                                $childHdr = $child.Header
                                if ($childHdr -is [System.Windows.Controls.StackPanel]) {
                                    foreach ($el in $childHdr.Children) {
                                        if ($el -is [System.Windows.Controls.CheckBox]) {
                                            if ($el.IsEnabled) { $el.IsChecked = $newState }
                                            break
                                        }
                                    }
                                }
                                $dnStack.Push($child)
                            }
                        }

                        # Cascade up. Walk ancestors via ItemsControlFromItemContainer. .Parent sometimes returns the visual-tree parent - this doesn't.
                        $node = $sourceTvi
                        while ($true) {
                            if ($null -eq $node) { break }
                            $parentTvi = [System.Windows.Controls.ItemsControl]::ItemsControlFromItemContainer($node)
                            if ($parentTvi -isnot [System.Windows.Controls.TreeViewItem]) { break }

                            $parentCb  = $null
                            $parentHdr = $parentTvi.Header
                            if ($parentHdr -is [System.Windows.Controls.StackPanel]) {
                                foreach ($el in $parentHdr.Children) {
                                    if ($el -is [System.Windows.Controls.CheckBox]) { $parentCb = $el; break }
                                }
                            }
                            if ($null -eq $parentCb) { break }

                            $allCheckedOrNone = $true
                            $anyEnabled       = $false
                            foreach ($sib in $parentTvi.Items) {
                                if ($null -eq $sib) { continue }
                                $sibCb  = $null
                                $sibHdr = $sib.Header
                                if ($sibHdr -is [System.Windows.Controls.StackPanel]) {
                                    foreach ($el in $sibHdr.Children) {
                                        if ($el -is [System.Windows.Controls.CheckBox]) { $sibCb = $el; break }
                                    }
                                }
                                if ($null -eq $sibCb -or !$sibCb.IsEnabled) { continue }
                                $anyEnabled = $true
                                if ($sibCb.IsChecked -ne $true) { $allCheckedOrNone = $false }
                            }
                            if (!$anyEnabled) { break }
                            $parentCb.IsChecked = $allCheckedOrNone
                            $node = $parentTvi
                        }

                        # Inline reset - finally is unreliable here.
                        if ($treeMetaForReset) { $treeMetaForReset['CascadeInProgress'] = $false }
                    }
                    catch {
                        # Catch path reset too - any throw otherwise locks the flag and kills subsequent cascades.
                        try { if ($treeMetaForReset) { $treeMetaForReset['CascadeInProgress'] = $false } } catch {}
                        try { Write-Warning ("[CASCADE] " + $_.Exception.Message) } catch {}
                    }
                }

                # Attach the cascade handler to every CheckBox placed above. Synchronous, $getHeaderCheckBox in scope.
                $wireBoxes = {
                    param($items)
                    foreach ($tvi in $items) {
                        $cb = & $getHeaderCheckBox $tvi
                        if ($cb) {
                            $cb.Add_Checked($cascadeHandler)
                            $cb.Add_Unchecked($cascadeHandler)
                        }
                        if ($tvi.Items.Count -gt 0) { & $wireBoxes $tvi.Items }
                    }
                }
                & $wireBoxes $tree.Items

                # Seed initial parent state. If -Checked prechecked anything, walk ancestors and recompute their binary state. Loop, not recursion, to mirror the runtime cascade.
                # Off the pipeline 5.1 won't reach a finally. If the pass throws, the tree never gets added and nobody reads the flag.
                $treeMeta.CascadeInProgress = $true

                # Every TVI that starts checked
                $allNodes = [System.Collections.Generic.Stack[object]]::new()
                foreach ($root in $tree.Items) { $allNodes.Push($root) }
                $checkedTvis = [System.Collections.Generic.List[object]]::new()
                while ($allNodes.Count -gt 0) {
                    $n = $allNodes.Pop()
                    foreach ($c in $n.Items) { $allNodes.Push($c) }
                    $ncb = & $getHeaderCheckBox $n
                    if ($ncb -and $ncb.IsChecked -eq $true) { [void]$checkedTvis.Add($n) }
                }

                # Walk up from each checked one
                foreach ($cnode in $checkedTvis) {
                    $node = $cnode
                    while ($true) {
                        $parentTvi = [System.Windows.Controls.ItemsControl]::ItemsControlFromItemContainer($node)
                        if ($parentTvi -isnot [System.Windows.Controls.TreeViewItem]) { break }
                        $parentCb = & $getHeaderCheckBox $parentTvi
                        if (!$parentCb) { break }

                        # Counter names avoid the plain word 'checked', since the function has a [scriptblock]$Checked param and PS is case insensitive.
                        # `$checked = 0` inherits the [scriptblock] constraint and throws
                        $allCheckedOrNone = $true
                        $anyEnabled       = $false
                        foreach ($sib in $parentTvi.Items) {
                            $sibCb = & $getHeaderCheckBox $sib
                            if (!$sibCb -or !$sibCb.IsEnabled) { continue }
                            $anyEnabled = $true
                            if ($sibCb.IsChecked -ne $true) { $allCheckedOrNone = $false }
                        }
                        if (!$anyEnabled) { break }
                        $parentCb.IsChecked = $allCheckedOrNone
                        $node = $parentTvi
                    }
                }
                $treeMeta.CascadeInProgress = $false
            }
        }

        # Register control for variable hydration
        $session.AddControlSafe($Variable, $tree)

        $wheelMode = if ($CaptureScrollWheel) { 'Capture' } else { $ScrollWheel }

        if ($Fill -and $wheelMode -eq 'Page' -and !$PSBoundParameters.ContainsKey('ScrollWheel')) { $wheelMode = 'Edge' }
        Set-UiWheelRouting -Control $tree -Mode $wheelMode

        # After the nodes exist and the checkbox pass set the Tag (the menu reads IsCheckBoxTree off it).
        if (!$NoContextMenu) { New-UiTreeContextMenu -Tree $tree }

        if ($WPFProperties) {
            # Tag is reserved only on checkbox trees since they keep hydration metadata there (IsCheckBoxTree et al) and the C# extractor reads it, so a user-set Tag would break checked items hydration.
            # Plain trees never touch $tree.Tag, so let those through. Copy before Remove - [hashtable] binds your own table by reference.
            if ($useCheckBoxes -and $WPFProperties.ContainsKey('Tag')) {
                Write-Warning "New-UiTree: -WPFProperties Tag is reserved (stores tree hydration metadata). Ignoring."
                $WPFProperties = @{} + $WPFProperties
                [void]$WPFProperties.Remove('Tag')
            }
            if ($WPFProperties.Count -gt 0) { Set-UiProperties -Control $tree -Properties $WPFProperties }
        }

        Add-UiControlToParent -Control $tree -Parent $parent

        if ($Fill) { Set-UiFillParentHeight -Control $tree }
    }
}