TreeControl/SRxTreeControl.ps1

class SRxTreeNode {
    [string] $Id
    [string] $ParentId
    [string] $Text
    [object] $Tag
    [System.Collections.ArrayList] $ChildIds
    [bool]   $ChildrenLoaded
    [bool]   $HasChildrenHint

    SRxTreeNode([string]$id, [string]$parentId, [string]$text, $tag) {
        $this.Id = $id
        $this.ParentId = $parentId
        $this.Text = $text
        $this.Tag = $tag
        $this.ChildIds = New-Object System.Collections.ArrayList
        $this.ChildrenLoaded = $true
        $this.HasChildrenHint = $false
    }
}

class SRxTreeControl : SRxBorderedUserControl {

    hidden [System.Collections.Specialized.OrderedDictionary] $_model
    hidden [System.Collections.ArrayList] $_rootIds

    hidden [System.Windows.Forms.TreeView] $_tree
    hidden [System.Windows.Forms.ToolStrip] $_toolStrip
    hidden [System.Collections.Hashtable] $_uiIndex

    hidden [System.Collections.ArrayList] $_overlays
    hidden [System.Collections.Hashtable] $_fontCache

    hidden [boolean] $_pickActive = $false
    hidden [boolean] $_pickMulti = $false
    hidden $_pickPredicate = $null
    hidden $_pickCallback = $null

    hidden [boolean] $_suppressEvents = $false

    [scriptblock] $OnNodeSelected = $null
    [scriptblock] $OnNodeExpanding = $null
    [scriptblock] $OnNodeDoubleClick = $null

    [scriptblock] $PickKeepPredicate = $null

    [scriptblock] $OnPickOtherNode = $null


    [bool] $CheckBoxes = $false
    [scriptblock] $OnNodeChecked = $null

    [scriptblock] $OnNodeRealized = $null
    [bool] $LazyRealize = $false
    hidden [System.Collections.Hashtable] $_openIds = @{}
    hidden [int]  $_overlayVersion = 0
    hidden [string] $_visStamp = ''
    hidden $_visCache = $null

    SRxTreeControl() {
        $this._model    = New-Object System.Collections.Specialized.OrderedDictionary
        $this._rootIds  = New-Object System.Collections.ArrayList
        $this._uiIndex  = @{}
        $this._overlays = New-Object System.Collections.ArrayList
        $this._fontCache = @{}
        $this.Dock = [System.Windows.Forms.DockStyle]::Fill
        $this.buildView()
    }

    hidden [void] buildView() {
        $this.BorderStyle = [System.Windows.Forms.BorderStyle]::None

        $this._tree = [System.Windows.Forms.TreeView]::new()
        $this._tree.Dock = [System.Windows.Forms.DockStyle]::Fill
        $this._tree.BorderStyle = [System.Windows.Forms.BorderStyle]::None
        $this._tree.HideSelection = $false
        $this._tree.ShowNodeToolTips = $true
        $this._tree.HotTracking = $true
        $this._tree.Tag = $this

        $this._tree.add_AfterSelect({
            param($s,$e)
            $c = $s.Tag
            if ($c.IsSuppressed()) { return }
            $c.handleAfterSelect($e.Node)
        })
        $this._tree.add_BeforeExpand({
            param($s,$e)
            $c = $s.Tag
            $c.handleBeforeExpand($e.Node)
        })
        $this._tree.add_NodeMouseDoubleClick({
            param($s,$e)
            $c = $s.Tag
            if ($c.OnNodeDoubleClick -and $null -ne $e.Node) { & $c.OnNodeDoubleClick ([string]$e.Node.Name) }
        })
        $this._tree.add_AfterCheck({
            param($s,$e)
            $c = $s.Tag
            if ($c.IsSuppressed()) { return }
            if ($c.OnNodeChecked -and $null -ne $e.Node) {
                & $c.OnNodeChecked ([string]$e.Node.Name) ([bool]$e.Node.Checked)
            }
        })

        $this._toolStrip = [System.Windows.Forms.ToolStrip]::new()
        $this._toolStrip.Dock = [System.Windows.Forms.DockStyle]::Top
        $this._toolStrip.GripStyle = [System.Windows.Forms.ToolStripGripStyle]::Hidden
        $this._toolStrip.BackColor = [System.Drawing.SystemColors]::ControlLight
        $this._toolStrip.ImageScalingSize = [System.Drawing.Size]::new(20, 20)
        $this._toolStrip.AutoSize = $false
        $this._toolStrip.Height = 35


        [void]$this.Controls.Add($this._tree)
        [void]$this.Controls.Add($this._toolStrip)
    }

    [boolean] IsSuppressed() { return $this._suppressEvents }
    [System.Windows.Forms.TreeView] InnerTree() { return $this._tree }
    [System.Windows.Forms.ToolStrip] ToolBar() { return $this._toolStrip }

    [void] ShowToolBar([bool]$on) {
        try { $this._toolStrip.Visible = $on } catch { }
    }


    [void] ClearModel() {
        $this._model.Clear()
        $this._rootIds.Clear()
    }

    [void] AddNode([string]$id, [string]$parentId, [string]$text, $tag) {
        if ([string]::IsNullOrEmpty($id)) { throw "SRxTreeControl.AddNode: id is required" }
        $n = [SRxTreeNode]::new($id, $parentId, $text, $tag)
        if ($this._model.Contains($id)) {
            $old = $this._model[$id]
            $n.ChildIds = $old.ChildIds
            $n.ChildrenLoaded = $old.ChildrenLoaded
            $this._model[$id] = $n
            return
        }
        $this._model[$id] = $n
        if ([string]::IsNullOrEmpty($parentId)) {
            if (-not $this._rootIds.Contains($id)) { [void]$this._rootIds.Add($id) }
        } else {
            if ($this._model.Contains($parentId)) {
                $p = $this._model[$parentId]
                if (-not $p.ChildIds.Contains($id)) { [void]$p.ChildIds.Add($id) }
            }
        }
    }


    [void] SetChildrenLoaded([string]$id, [bool]$loaded, [bool]$hasChildrenHint) {
        if (-not $this._model.Contains($id)) { return }
        $n = $this._model[$id]
        $n.ChildrenLoaded = $loaded
        $n.HasChildrenHint = $hasChildrenHint
        $this.SyncToUI()
    }

    [object] GetNode([string]$id) {
        if ($this._model.Contains($id)) { return $this._model[$id] }
        return $null
    }
    [int] Count() { return $this._model.Count }
    [string[]] RootIds() { return @($this._rootIds) }

    [string[]] ChildIds([string]$id) {
        if (-not $this._model.Contains($id)) { return @() }
        return @($this._model[$id].ChildIds)
    }

    [string[]] AncestorIds([string]$id) {
        $out = New-Object System.Collections.ArrayList
        $cur = $id
        $guard = 0
        while ($this._model.Contains($cur) -and $guard -lt 512) {
            $guard++
            $p = [string]$this._model[$cur].ParentId
            if ([string]::IsNullOrEmpty($p)) { break }
            [void]$out.Add($p)
            $cur = $p
        }
        return @($out)
    }

    [void] SyncToUI() {
        try {
            $this._suppressEvents = $true
            $this._tree.BeginUpdate()

            $expanded = $this.captureExpandedIds()
            $selected = ''
            if ($null -ne $this._tree.SelectedNode) { $selected = [string]$this._tree.SelectedNode.Name }

            $visible = $this.computeVisibleIds()

            $this.syncChildren($null, $this._rootIds, $visible)

            $this.restoreExpandedIds($expanded)
            if (-not [string]::IsNullOrEmpty($selected) -and $this._uiIndex.ContainsKey($selected)) {
                $this._tree.SelectedNode = $this._uiIndex[$selected]
            }

            $this.applyAppearance()
        } catch {
            Write-Host "SRxTreeControl.SyncToUI - $($_.Exception.Message)"
        } finally {
            $this._tree.EndUpdate()
            $this._suppressEvents = $false
        }
    }

    hidden [void] syncChildren($parentUi, $childIds, $visible) {
        $coll = $null
        if ($null -eq $parentUi) { $coll = $this._tree.Nodes } else { $coll = $parentUi.Nodes }
        $wanted = New-Object System.Collections.Generic.List[string]
        $wantedSet = @{}
        foreach ($cid in $childIds) {
            $c = [string]$cid
            if (($null -eq $visible -or $visible.ContainsKey($c)) -and $this._model.Contains($c)) { $wanted.Add($c); $wantedSet[$c] = $true }
        }
        for ($i = $coll.Count - 1; $i -ge 0; $i--) {
            $old = $coll[$i]
            $nm = [string]$old.Name
            if (-not $wantedSet.ContainsKey($nm)) {
                $this.unindexSubtree($old)
                $coll.RemoveAt($i)
            }
        }
        $created = New-Object System.Collections.Generic.List[string]
        if ($coll.Count -eq 0 -and $wanted.Count -gt 0) {
            $arr = New-Object 'System.Windows.Forms.TreeNode[]' $wanted.Count
            for ($i = 0; $i -lt $wanted.Count; $i++) {
                $cid = $wanted[$i]
                $m = $this._model[$cid]
                $ui = [System.Windows.Forms.TreeNode]::new([string]$m.Text)
                $ui.Name = $cid
                $ui.Tag  = $m.Tag
                $arr[$i] = $ui
                $this._uiIndex[$cid] = $ui
                $created.Add($cid)
            }
            $coll.AddRange($arr)
        }
        else {
            for ($i = 0; $i -lt $wanted.Count; $i++) {
                $cid = $wanted[$i]
                $m = $this._model[$cid]
                $ui = $null
                if ($i -lt $coll.Count -and [string]$coll[$i].Name -eq $cid) { $ui = $coll[$i] }
                else {
                    $existingIdx = -1
                    if ($this._uiIndex.ContainsKey($cid)) {
                        $ix = $this._uiIndex[$cid]
                        if ($null -ne $ix -and $null -ne $ix.TreeView -and ($ix.Parent -eq $parentUi)) { $existingIdx = $ix.Index }
                    }
                    if ($existingIdx -lt 0) {
                        $ui = New-Object System.Windows.Forms.TreeNode
                        $ui.Name = $cid
                        $ui.Text = [string]$m.Text
                        $ui.Tag  = $m.Tag
                        [void]$coll.Insert([Math]::Min($i, $coll.Count), $ui)
                        $created.Add($cid)
                    }
                    elseif ($existingIdx -ne $i) {
                        $ui = $coll[$existingIdx]
                        $coll.RemoveAt($existingIdx)
                        [void]$coll.Insert([Math]::Min($i, $coll.Count), $ui)
                    }
                    $ui = $coll[$i]
                }
                if ([string]$ui.Text -ne [string]$m.Text) { $ui.Text = [string]$m.Text }
                if ($null -eq $ui.Tag) { $ui.Tag = $m.Tag }
                $this._uiIndex[$cid] = $ui
            }
        }
        if ($null -ne $this.OnNodeRealized) {
            foreach ($cid in $created) {
                try { & $this.OnNodeRealized $cid $this._uiIndex[$cid] } catch { Write-Host "OnNodeRealized - $($_.Exception.Message)" }
            }
        }
        for ($i = 0; $i -lt $wanted.Count; $i++) {
            $cid = $wanted[$i]
            $m = $this._model[$cid]
            $ui = $this._uiIndex[$cid]
            if ($m.ChildrenLoaded) {
                if ((-not $this.LazyRealize) -or $this._openIds.ContainsKey($cid)) {
                    $this.syncChildren($ui, $m.ChildIds, $visible)
                }
                else {
                    $this.ensurePlaceholder($ui, $cid, $this.hasVisibleChild($m, $visible))
                }
            }
            elseif ($m.HasChildrenHint) {
                $this.ensurePlaceholder($ui, $cid, $true)
            }
        }
    }

    hidden [void] ensurePlaceholder($ui, [string]$cid, [bool]$want) {
        $hasPh = $false
        for ($k = $ui.Nodes.Count - 1; $k -ge 0; $k--) {
            $n = $ui.Nodes[$k]
            if (([string]$n.Name).StartsWith('lazy:')) {
                if ($want -and -not $hasPh) { $hasPh = $true } else { $ui.Nodes.RemoveAt($k) }
            }
            else {
                $this.unindexSubtree($n)
                $ui.Nodes.RemoveAt($k)
            }
        }
        if ($want -and -not $hasPh) {
            $ph = New-Object System.Windows.Forms.TreeNode
            $ph.Name = ('lazy:' + $cid)
            $ph.Text = '...'
            [void]$ui.Nodes.Add($ph)
        }
    }

    hidden [void] unindexSubtree($n) {
        $nm = [string]$n.Name
        if ($this._uiIndex.ContainsKey($nm) -and ($this._uiIndex[$nm] -eq $n)) { [void]$this._uiIndex.Remove($nm) }
        foreach ($c in $n.Nodes) { $this.unindexSubtree($c) }
    }

    hidden [bool] hasVisibleChild($m, $visible) {
        if ($m.ChildIds.Count -eq 0) { return $false }
        if ($null -eq $visible) { return $true }
        foreach ($c in $m.ChildIds) { if ($visible.ContainsKey([string]$c)) { return $true } }
        return $false
    }

    hidden [void] realizeChildren($uiNode, [string]$id) {
        if (-not $this._model.Contains($id)) { return }
        $m = $this._model[$id]
        $this._openIds[$id] = $true
        $wasSuppressed = $this._suppressEvents
        $this._suppressEvents = $true
        $this._tree.BeginUpdate()
        try {
            $visible = $this.computeVisibleIds()
            $this.syncChildren($uiNode, $m.ChildIds, $visible)
            $this.ApplyAppearanceFor([string[]]@($m.ChildIds))
        }
        catch { Write-Host "SRxTreeControl.realizeChildren - $($_.Exception.Message)" }
        finally {
            $this._tree.EndUpdate()
            $this._suppressEvents = $wasSuppressed
        }
    }

    [bool] IsRealized([string]$id) {
        return ($this._uiIndex.ContainsKey($id) -and $null -ne $this._uiIndex[$id].TreeView)
    }
    [int] RealizedCount() { return $this._uiIndex.Count }
    [bool] IsOpened([string]$id) { return $this._openIds.ContainsKey($id) }
    [void] CloseIds([string[]]$ids) { foreach ($i in @($ids)) { [void]$this._openIds.Remove([string]$i) } }

    [object] RealizeTo([string]$id) {
        if ([string]::IsNullOrEmpty($id) -or -not $this._model.Contains($id)) { return $null }
        if ($this.IsRealized($id)) { return $this._uiIndex[$id] }
        $chain = @($this.AncestorIds($id))
        [array]::Reverse($chain)
        foreach ($a in $chain) {
            if (-not $this.IsRealized($a)) { return $null }
            if (-not $this._openIds.ContainsKey($a)) { $this.realizeChildren($this._uiIndex[$a], $a) }
        }
        if ($this.IsRealized($id)) { return $this._uiIndex[$id] }
        return $null
    }

    [void] ForgetCollapsed() {
        foreach ($k in @($this._openIds.Keys)) {
            $ui = $null
            if ($this._uiIndex.ContainsKey($k)) { $ui = $this._uiIndex[$k] }
            if ($null -eq $ui -or $null -eq $ui.TreeView -or -not $ui.IsExpanded) { [void]$this._openIds.Remove($k) }
        }
    }

    hidden [hashtable] computeVisibleIds() {
        $stamp = ('{0}|{1}' -f $this._overlayVersion, $this._model.Count)
        if ($this._visStamp -eq $stamp) { return $this._visCache }
        $hidden = @{}
        foreach ($ov in $this._overlays) {
            foreach ($id in $ov.Styles.Keys) {
                $st = $ov.Styles[$id]
                if ($st.ContainsKey('Hidden') -and [bool]$st['Hidden']) { $hidden[$id] = $true }
                else { [void]$hidden.Remove($id) }
            }
        }
        if ($hidden.Count -eq 0) { $this._visCache = $null; $this._visStamp = $stamp; return $null }
        $visible = @{}
        foreach ($id in $this._model.Keys) { if (-not $hidden.ContainsKey($id)) { $visible[$id] = $true } }
        $marked = @{}
        foreach ($id in @($visible.Keys)) {
            $cur = [string]$this._model[$id].ParentId
            $guard = 0
            while (-not [string]::IsNullOrEmpty($cur) -and $guard -lt 512) {
                $guard++
                if ($marked.ContainsKey($cur)) { break }
                $marked[$cur] = $true
                $visible[$cur] = $true
                if (-not $this._model.Contains($cur)) { break }
                $cur = [string]$this._model[$cur].ParentId
            }
        }
        $this._visCache = $visible
        $this._visStamp = $stamp
        return $visible
    }

    hidden [hashtable] captureExpandedIds() {
        $set = @{}
        foreach ($k in $this._uiIndex.Keys) {
            $ui = $this._uiIndex[$k]
            try { if ($ui.IsExpanded) { $set[$k] = $true } } catch {}
        }
        return $set
    }

    hidden [void] restoreExpandedIds($set) {
        $list = New-Object System.Collections.Generic.List[object]
        foreach ($k in $set.Keys) { $list.Add([pscustomobject]@{ Id = [string]$k; D = @($this.AncestorIds([string]$k)).Count }) }
        foreach ($e in @($list | Sort-Object -Property D)) {
            if ($this._uiIndex.ContainsKey($e.Id)) {
                try { $this._uiIndex[$e.Id].Expand() } catch {}
            }
        }
    }

    [void] PushOverlay([string]$name) {
        $this._overlayVersion++
        foreach ($ov in $this._overlays) { if ($ov.Name -eq $name) { return } }
        [void]$this._overlays.Add(@{ Name = $name; Styles = @{} })
    }

    [void] PopOverlay([string]$name) {
        $this._overlayVersion++
        for ($i = $this._overlays.Count - 1; $i -ge 0; $i--) {
            if ($this._overlays[$i].Name -eq $name) { $this._overlays.RemoveAt($i); break }
        }
        $this.RebuildUI()
    }

    [void] ClearOverlays() {
        $this._overlayVersion++
        $this._overlays.Clear()
        $this.SyncToUI()
    }

    [boolean] HasOverlay([string]$name) {
        foreach ($ov in $this._overlays) { if ($ov.Name -eq $name) { return $true } }
        return $false
    }

    [void] SetOverlayStyle([string]$name, [string]$id, [hashtable]$style) {
        $this._overlayVersion++
        foreach ($ov in $this._overlays) {
            if ($ov.Name -eq $name) { $ov.Styles[$id] = $style; return }
        }
        $this.PushOverlay($name)
        foreach ($ov in $this._overlays) { if ($ov.Name -eq $name) { $ov.Styles[$id] = $style; return } }
    }
    [void] SetOverlayStyles([string]$overlayName, [hashtable]$styleById) {
        $this._overlayVersion++
        try {
            $this.PushOverlay($overlayName)
            foreach ($ov in $this._overlays) {
                if ($ov.Name -ne $overlayName) { continue }
                $ov.Styles.Clear()
                if ($null -ne $styleById) {
                    foreach ($id in $styleById.Keys) { $ov.Styles[[string]$id] = $styleById[$id] }
                }
                break
            }
            $this.applyAppearance()
        } catch { Write-Host "SetOverlayStyles - $($_.Exception.Message)" }
    }

    [void] ClearOverlayStyles([string]$name) {
        $this._overlayVersion++
        foreach ($ov in $this._overlays) { if ($ov.Name -eq $name) { $ov.Styles.Clear(); return } }
    }

    [void] applyAppearance() {
        $eff = @{}
        foreach ($ov in $this._overlays) {
            foreach ($id in $ov.Styles.Keys) {
                if (-not $eff.ContainsKey($id)) { $eff[$id] = @{} }
                $s = $ov.Styles[$id]
                foreach ($k in $s.Keys) { $eff[$id][$k] = $s[$k] }
            }
        }
        foreach ($id in $this._uiIndex.Keys) {
            if (-not $this._model.Contains($id)) { continue }
            $ui = $this._uiIndex[$id]
            $m  = $null
            if ($this._model.Contains($id)) { $m = $this._model[$id] }
            $baseText = ''
            if ($null -ne $m) { $baseText = [string]$m.Text }

            if ($eff.ContainsKey($id)) {
                $s = $eff[$id]
                if ($s.ContainsKey('Fore')) { $ui.ForeColor = $s['Fore'] } else { $ui.ForeColor = [System.Drawing.SystemColors]::WindowText }
                if ($s.ContainsKey('Back')) { $ui.BackColor = $s['Back'] } else { $ui.BackColor = [System.Drawing.Color]::Transparent }
                if ($s.ContainsKey('FontStyle')) { $ui.NodeFont = $this.getFont($s['FontStyle']) } else { $ui.NodeFont = $null }

                $label = $baseText
                if ($s.ContainsKey('Prefix') -and -not [string]::IsNullOrEmpty([string]$s['Prefix'])) {
                    $label = [string]$s['Prefix'] + $label
                }
                if ($s.ContainsKey('Suffix') -and -not [string]::IsNullOrEmpty([string]$s['Suffix'])) {
                    $label = $label + [string]$s['Suffix']
                }
                if ($s.ContainsKey('Badge') -and -not [string]::IsNullOrEmpty([string]$s['Badge'])) {
                    $label = $label + ' - ' + [string]$s['Badge']
                }
                if ([string]$ui.Text -ne $label) { $ui.Text = $label }

            } else {
                $ui.ForeColor = [System.Drawing.SystemColors]::WindowText
                $ui.BackColor = [System.Drawing.Color]::Transparent
                $ui.NodeFont  = $null
                if ([string]$ui.Text -ne $baseText) { $ui.Text = $baseText }
            }
        }
    }

    [void] ApplyAppearanceFor([string[]]$ids) {
        foreach ($id in @($ids)) {
            if ([string]::IsNullOrEmpty($id)) { continue }
            if (-not $this._uiIndex.ContainsKey($id) -or -not $this._model.Contains($id)) { continue }
            $ui = $this._uiIndex[$id]
            $baseText = [string]$this._model[$id].Text
            $s = $null
            foreach ($ov in $this._overlays) {
                if (-not $ov.Styles.ContainsKey($id)) { continue }
                if ($null -eq $s) { $s = @{} }
                $st = $ov.Styles[$id]
                foreach ($k in $st.Keys) { $s[$k] = $st[$k] }
            }
            if ($null -ne $s) {
                $fore = [System.Drawing.SystemColors]::WindowText
                if ($s.ContainsKey('Fore')) { $fore = $s['Fore'] }
                if ($ui.ForeColor -ne $fore) { $ui.ForeColor = $fore }
                $back = [System.Drawing.Color]::Transparent
                if ($s.ContainsKey('Back')) { $back = $s['Back'] }
                if ($ui.BackColor -ne $back) { $ui.BackColor = $back }
                if ($s.ContainsKey('FontStyle')) { $ui.NodeFont = $this.getFont($s['FontStyle']) } elseif ($null -ne $ui.NodeFont) { $ui.NodeFont = $null }
                $label = $baseText
                if ($s.ContainsKey('Prefix') -and -not [string]::IsNullOrEmpty([string]$s['Prefix'])) { $label = [string]$s['Prefix'] + $label }
                if ($s.ContainsKey('Suffix') -and -not [string]::IsNullOrEmpty([string]$s['Suffix'])) { $label = $label + [string]$s['Suffix'] }
                if ($s.ContainsKey('Badge') -and -not [string]::IsNullOrEmpty([string]$s['Badge'])) { $label = $label + ' - ' + [string]$s['Badge'] }
                if ([string]$ui.Text -ne $label) { $ui.Text = $label }
            }
            else {
                if ($ui.ForeColor -ne [System.Drawing.SystemColors]::WindowText) { $ui.ForeColor = [System.Drawing.SystemColors]::WindowText }
                if ($ui.BackColor -ne [System.Drawing.Color]::Transparent) { $ui.BackColor = [System.Drawing.Color]::Transparent }
                if ($null -ne $ui.NodeFont) { $ui.NodeFont = $null }
                if ([string]$ui.Text -ne $baseText) { $ui.Text = $baseText }
            }
        }
    }

    [void] InvalidateIds([string[]]$ids) {
        try {
            $w = $this._tree.ClientSize.Width
            foreach ($id in @($ids)) {
                if (-not $this._uiIndex.ContainsKey($id)) { continue }
                $b = $this._uiIndex[$id].Bounds
                if ($b.Height -le 0) { continue }
                $this._tree.Invalidate([System.Drawing.Rectangle]::new(0, $b.Y, $w, $b.Height))
            }
        } catch { }
    }

    hidden [System.Drawing.Font] getFont($style) {
        $key = [string]$style
        if ($this._fontCache.ContainsKey($key)) { return $this._fontCache[$key] }
        $f = [System.Drawing.Font]::new($this._tree.Font, [System.Drawing.FontStyle]$style)
        $this._fontCache[$key] = $f
        return $f
    }

    [void] RebuildUI() {
        try {
            $this._suppressEvents = $true
            $this._tree.BeginUpdate()
            $expanded = $this.captureExpandedIds()
            $selected = ''
            if ($null -ne $this._tree.SelectedNode) { $selected = [string]$this._tree.SelectedNode.Name }

            $this._tree.Nodes.Clear()
            $this._uiIndex.Clear()

            $visible = $this.computeVisibleIds()
            $this.syncChildren($null, $this._rootIds, $visible)

            $this.restoreExpandedIds($expanded)
            if (-not [string]::IsNullOrEmpty($selected) -and $this._uiIndex.ContainsKey($selected)) {
                $this._tree.SelectedNode = $this._uiIndex[$selected]
            }
            $this.applyAppearance()
        } catch {
            Write-Host "SRxTreeControl.RebuildUI - $($_.Exception.Message)"
        } finally {
            $this._tree.EndUpdate()
            $this._suppressEvents = $false
        }
    }    

    [void] BeginNodePick($predicate, $onPicked, [bool]$multi, [bool]$hideOthers) {
        $this._overlayVersion++
        try {
            $this._pickActive = $false
            $this._pickMulti = $false
            $this._pickPredicate = $null
            $this._pickCallback = $null
            for ($i = $this._overlays.Count - 1; $i -ge 0; $i--) {
                if ($this._overlays[$i].Name -eq 'pick') { $this._overlays.RemoveAt($i) }
            }

            $this.dropPickExtras()

            $this._pickPredicate = $predicate
            $this._pickCallback  = $onPicked
            $this._pickMulti     = $multi
            $this._pickActive    = $true

            $this.PushOverlay('pick')

            $eligible = @{}
            foreach ($id in $this._model.Keys) {

                $ok = $false
                try { $ok = [bool](& $predicate $id $this._model[$id].Tag) } catch { $ok = $false }
                if ($ok) { $eligible[$id] = $true }
            }

            $keep = @{}
            foreach ($id in $eligible.Keys) {
                $keep[$id] = $true
                foreach ($a in $this.AncestorIds($id)) { $keep[$a] = $true }
            }

            $extra = @{}
            if ($null -ne $this.PickKeepPredicate) {
                foreach ($id in @($this._model.Keys)) {
                    if ($eligible.ContainsKey($id)) { continue }
                    $ok2 = $false
                    try { $ok2 = [bool](& $this.PickKeepPredicate $id $this._model[$id].Tag) } catch { $ok2 = $false }
                    if ($ok2) {
                        $extra[$id] = $true
                        foreach ($a in $this.AncestorIds($id)) { $keep[$a] = $true }
                    }
                }
            }

            foreach ($id in @($this._model.Keys)) {
                if ($eligible.ContainsKey($id)) {
                    $this.SetOverlayStyle('pick', $id, @{
                        Back      = [System.Drawing.Color]::FromArgb(255, 248, 200)
                        FontStyle = [System.Drawing.FontStyle]::Bold
                    })
                }
                elseif ($extra.ContainsKey($id)) {
                }
                elseif ($keep.ContainsKey($id)) {
                    $this.SetOverlayStyle('pick', $id, @{
                        Fore      = [System.Drawing.SystemColors]::GrayText
                        FontStyle = [System.Drawing.FontStyle]::Regular
                    })
                }
                elseif ($hideOthers) {
                    $this.SetOverlayStyle('pick', $id, @{ Hidden = $true })
                }
            }
           
            $this.RebuildUI()
            $this._tree.ExpandAll()
        } catch { Write-Host "SRxTreeControl.BeginNodePick - $($_.Exception.Message)" }
    }

    [void] EndNodePick() {
        try {
            $this.dropPickExtras()
            if (-not $this._pickActive) { return }
            $this._pickActive = $false
            $this._pickMulti = $false
            $this._pickPredicate = $null
            $this._pickCallback = $null
            $this.PopOverlay('pick')
        } catch { Write-Host "SRxTreeControl.EndNodePick - $($_.Exception.Message)" }
    }

    [void] AbortNodePick() {
        $this._overlayVersion++
        $this.dropPickExtras()
        $this._pickActive = $false
        $this._pickMulti = $false
        $this._pickPredicate = $null
        $this._pickCallback = $null
        for ($i = $this._overlays.Count - 1; $i -ge 0; $i--) {
            if ($this._overlays[$i].Name -eq 'pick') { $this._overlays.RemoveAt($i); break }
        }
        $this._overlayVersion++
        $this.applyAppearance()
    }

    [boolean] IsNodePickActive() { return $this._pickActive }

    [void] SetNodeBadge([string]$overlayName, [string]$id, [string]$badge) {
        $style = @{}
        foreach ($ov in $this._overlays) {
            if ($ov.Name -eq $overlayName -and $ov.Styles.ContainsKey($id)) {
                $style = $ov.Styles[$id].Clone()
            }
        }
        $style['Badge'] = $badge
        $this.SetOverlayStyle($overlayName, $id, $style)
        $this.applyAppearance()
    }

    [void] SetNodePrefix([string]$overlayName, [string]$id, [string]$prefix) {
        $style = @{}
        foreach ($ov in $this._overlays) {
            if ($ov.Name -eq $overlayName -and $ov.Styles.ContainsKey($id)) {
                $style = $ov.Styles[$id].Clone()
            }
        }
        $style['Prefix'] = $prefix
        $this.SetOverlayStyle($overlayName, $id, $style)
    }

    [void] MergeOverlayStyle([string]$overlayName, [string]$id, [hashtable]$extra) {
        $style = @{}
        foreach ($ov in $this._overlays) {
            if ($ov.Name -eq $overlayName -and $ov.Styles.ContainsKey($id)) {
                $style = $ov.Styles[$id].Clone()
            }
        }
        foreach ($k in $extra.Keys) { $style[$k] = $extra[$k] }
        $this.SetOverlayStyle($overlayName, $id, $style)
    }


    [string] SelectedId() {
        if ($null -eq $this._tree.SelectedNode) { return '' }
        return [string]$this._tree.SelectedNode.Name
    }

    [void] SelectId([string]$id) {
        if ($this._uiIndex.ContainsKey($id)) {
            $this._suppressEvents = $true
            try { $this._tree.SelectedNode = $this._uiIndex[$id] } finally { $this._suppressEvents = $false }
        }
    }

    [void] ExpandTo([string]$id) {
        $chain = @($this.AncestorIds($id))
        [array]::Reverse($chain)
        foreach ($a in $chain) {
            if ($this._uiIndex.ContainsKey($a)) { try { $this._uiIndex[$a].Expand() } catch {} }
        }
    }
    [void] ExpandAll()   { $this._tree.ExpandAll() }
    [void] CollapseAll() { $this._tree.CollapseAll() }

    [void] CollapseToRoot() {
        try {
            $this._tree.BeginUpdate()
            try {
                $this._tree.CollapseAll()
                if ($this._tree.Nodes.Count -eq 1) { $this._tree.Nodes[0].Expand() }
            } finally { $this._tree.EndUpdate() }
        } catch { Write-Host "CollapseToRoot - $($_.Exception.Message)" }
    }

    [void] CollapseIds([string[]]$ids) {
        try {
            foreach ($id in @($ids)) {
                if ($this._uiIndex.ContainsKey($id)) { $this._uiIndex[$id].Collapse($false) }
            }
        } catch { Write-Host "CollapseIds - $($_.Exception.Message)" }
    }


    hidden [void] dropPickExtras() {
        $this._overlayVersion++
        for ($i = $this._overlays.Count - 1; $i -ge 0; $i--) {
            if ([string]$this._overlays[$i].Name -like 'pick.*') { $this._overlays.RemoveAt($i) }
        }
    }

    [void] EnableCheckBoxes([bool]$on) {
        try {
            $this.CheckBoxes = $on
            $this._tree.CheckBoxes = $on
        } catch { Write-Host "EnableCheckBoxes - $($_.Exception.Message)" }
    }

    [void] SetChecked([string]$id, [bool]$checked) {
        try {
            if (-not $this._uiIndex.ContainsKey($id)) { return }
            $this._suppressEvents = $true
            try { $this._uiIndex[$id].Checked = $checked } finally { $this._suppressEvents = $false }
        } catch { Write-Host "SetChecked - $($_.Exception.Message)" }
    }

    [void] ClearAllChecks() {
        try {
            $this._suppressEvents = $true
            try {
                foreach ($k in $this._uiIndex.Keys) { $this._uiIndex[$k].Checked = $false }
            } finally { $this._suppressEvents = $false }
        } catch { Write-Host "ClearAllChecks - $($_.Exception.Message)" }
    }


    hidden [void] handleAfterSelect($uiNode) {
        try {
            if ($null -eq $uiNode) { return }
            $id = [string]$uiNode.Name

            if ($this._pickActive) {
                $ok = $false
                if ($this._pickPredicate -and $this._model.Contains($id)) {
                    try { $ok = [bool](& $this._pickPredicate $id $this._model[$id].Tag) } catch { $ok = $false }
                }
                if (-not $ok) {
                    if ($this.OnPickOtherNode) { & $this.OnPickOtherNode $id }
                    return
                }
                $cb = $this._pickCallback
                if (-not $this._pickMulti) {
                    $this._pickActive = $false
                    $this._pickMulti = $false
                    $this._pickPredicate = $null
                    $this._pickCallback = $null
                    $this.dropPickExtras()
                    $this.PopOverlay('pick')
                }
                if ($cb) { & $cb $id }
                return
            }

            if ($this.OnNodeSelected) { & $this.OnNodeSelected $id }
        } catch { Write-Host "SRxTreeControl.handleAfterSelect - $($_.Exception.Message)" }
    }

    hidden [void] handleBeforeExpand($uiNode) {
        try {
            if ($null -eq $uiNode) { return }
            $id = [string]$uiNode.Name
            if (-not $this._model.Contains($id)) { return }
            $m = $this._model[$id]
            if (-not $m.ChildrenLoaded) {
                if (-not $this.OnNodeExpanding) { return }
                & $this.OnNodeExpanding $id
                $m.ChildrenLoaded = $true
                $this.realizeChildren($uiNode, $id)
                return
            }
            if ($this.LazyRealize -and -not $this._openIds.ContainsKey($id)) {
                $this.realizeChildren($uiNode, $id)
            }
        } catch { Write-Host "SRxTreeControl.handleBeforeExpand - $($_.Exception.Message)" }
    }

    [void] SetFocusHighlights([string]$overlayName, [string[]]$ids) {
        $this._overlayVersion++
        try {
            for ($i = $this._overlays.Count - 1; $i -ge 0; $i--) {
                if ([string]$this._overlays[$i].Name -eq $overlayName) { $this._overlays.RemoveAt($i) }
            }
            $styles = @{}
            foreach ($id in @($ids)) {
                if (-not [string]::IsNullOrEmpty([string]$id)) {
                    $styles[[string]$id] = @{ Back = [System.Drawing.Color]::FromArgb(255, 240, 110) }
                }
            }
            if ($styles.Count -gt 0) { [void]$this._overlays.Add(@{ Name = $overlayName; Styles = $styles }) }
            $this.applyAppearance()
        } catch { Write-Host "SetFocusHighlights - $($_.Exception.Message)" }
    }

    [void] SetFocusHighlight([string]$id) { $this.SetFocusHighlights('focus', @($id)) }

    [void] ClearFocusHighlight() { $this.SetFocusHighlights('focus', @()) }
}