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.
    .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 ExpandAll
        Expand all nodes on load.
    .PARAMETER ParentCheckBoxes
        Checkbox on every item with children. Click a parent to toggle enabled descendants.
        Combine with -ChildCheckBoxes for 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
        [AppDomain]::CurrentDomain.GetAssemblies().GetTypes() |
            Select -Unique FullName |
            New-UiTree -Variable 'types' -PathProperty 'FullName' -PathSeparator '.'
    .EXAMPLE
        # Services by status; auto-start protected, stopped pre-selected.
        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]$ExpandAll,

        [switch]$ParentCheckBoxes,

        [switch]$ChildCheckBoxes,

        [scriptblock]$WhenEnabled,

        [scriptblock]$Checked,

        [switch]$NoCascade,

        [Parameter()]
        [hashtable]$WPFProperties
    )

    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 with no window up (headless test harness) - deref throws.
        $app       = [System.Windows.Application]::Current
        $treeStyle = if ($app) { $app.TryFindResource('ModernTreeViewStyle') } else { $null }

        # Resolve a possibly-dotted property path 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: create nodes 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: connect parent child relatonships
            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]
                        # Upsert: an item piped in later may match an existing intermediate's path.
                        # 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)
                    }
                }
            }

            # Walk every TVI and decorate it.
            $decorate = {
                param($items)
                # Drag the outer descriptors and handler into local scope. GetNewClosure only captures locals, parent scope vars resolve to null after this function returns and Loaded fires.
                $dpdSel = $dpdIsSelected
                $dpdAct = $dpdIsSelectionActive
                $upd    = $updateLabelFg
                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).
                        # Loaded subscribes, Unloaded unsubscribes. The descriptor holds a strong reference and leaks a tree's worth of TVIs per closed window otherwise.
                        # WPF's memory model is more or less just a series of suggestions.
                        $tvi.Add_Loaded({
                            $dpdSel.AddValueChanged($tvi, $upd)
                            $dpdAct.AddValueChanged($tvi, $upd)
                        }.GetNewClosure())

                        $tvi.Add_Unloaded({
                            $dpdSel.RemoveValueChanged($tvi, $upd)
                            $dpdAct.RemoveValueChanged($tvi, $upd)
                        }.GetNewClosure())

                        $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: walk children 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 we placed. 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.
                $treeMeta.CascadeInProgress = $true
                try {
                    # Collect 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) }
                    }

                    # For each checked node, ascend and refresh parent binary state.
                    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 bare word 'checked' - the function has a [scriptblock]$Checked param and PS is case insensitive.
                            # `$checked = 0` inherits the [scriptblock] constraint and throws at compile time.
                            $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
                        }
                    }
                }
                finally { $treeMeta.CascadeInProgress = $false }
            }
        }

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

        # Reraise scroll events at the parent ScrollViewer so the tree doesn't swallow them.
        # Only without -Fill - a filled tree leaves the outer ScrollViewer nothing to scroll, so re-raising there kills the wheel entirely. The tree's own scrollbar has to own it.
        if (!$Fill) {
            $tree.Add_PreviewMouseWheel({
                param($sender, $eventArgs)
                if (!$eventArgs.Handled) {
                    $eventArgs.Handled = $true
                    $newEvent = [System.Windows.Input.MouseWheelEventArgs]::new($eventArgs.MouseDevice, $eventArgs.Timestamp, $eventArgs.Delta)
                    $newEvent.RoutedEvent = [System.Windows.UIElement]::MouseWheelEvent
                    $newEvent.Source = $sender
                    $parentElement = $sender.Parent -as [System.Windows.UIElement]
                    if ($parentElement) { $parentElement.RaiseEvent($newEvent) }
                }
            })
        }

        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 }
        }

        # Attach to parent container
        if ($parent -is [System.Windows.Controls.Panel]) { [void]$parent.Children.Add($tree)  }
        elseif ($parent -is [System.Windows.Controls.ItemsControl]) {  [void]$parent.Items.Add($tree)  }
        elseif ($parent -is [System.Windows.Controls.ContentControl]) {   $parent.Content = $tree  }

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