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