TreeControl/SRxTreeControl.ps1

<#
================================================================================
  SRxTreeControl.psm1 STEP 2 of the tree extraction
  -----------------------------------------------------------------------------
  A GENERIC, MODEL-BACKED WinForms tree control.

  WHY THIS EXISTS
    Today the TreeView *is* the model: colour, expansion, pruning and "which node
    is the scope" are all read back off TreeNode objects. That is why resetting to
    a clean state has no cheap implementation, and why we hit
      BUG-2 (a pick snapshotted an already-recoloured tree) and
      BUG-3 (restoring re-inserted nodes into a rebuilt tree -> duplicates).

  THE KEY IDEA - PRESENTATION OVERLAYS, NOT SNAPSHOTS
    The MODEL is authoritative and is never mutated for presentation.
    Appearance is a STACK of named overlays:
        PushOverlay('pick') -> highlight eligible, hide the rest
        PopOverlay('pick') -> appearance reverts; model untouched
    Reverting is dropping a dictionary, so nodes are never removed/re-inserted
    => duplication is impossible, and a prior selection's colouring lives in a
       DIFFERENT overlay => it cannot be captured as a "baseline".

  TREE-AGNOSTIC BY DESIGN (see Step 1 finding)
    Two very different trees will host this:
      Provisioning Schema : identity = term GUID, meaning = ts_* role markers
      XML Template : identity = xPath, meaning = element name
    This control therefore knows ONLY about Id / ParentId / Text / Tag .
    All semantics live in the thin wrappers built in Step 3.

  NOT WIRED TO ANYTHING. Exercise it with SRxTreeControl.Harness.ps1.

  REQUIRES: WinForms/Drawing loaded BEFORE Import-Module (proven rule in this
            codebase - a UserControl-derived class resolves its base at parse time).
================================================================================
#>


class SRxTreeNode {
    [string] $Id
    [string] $ParentId
    [string] $Text
    [object] $Tag
    [System.Collections.ArrayList] $ChildIds
    [bool]   $ChildrenLoaded          # for lazy population
    [bool]   $HasChildrenHint         # show an expander before children are loaded

    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 { #System.Windows.Forms.UserControl {

    # ---------------- model ----------------
    hidden [System.Collections.Specialized.OrderedDictionary] $_model   # Id -> SRxTreeNode
    hidden [System.Collections.ArrayList] $_rootIds

    # ---------------- view -----------------
    hidden [System.Windows.Forms.TreeView] $_tree
    hidden [System.Windows.Forms.ToolStrip] $_toolStrip
    hidden [System.Collections.Hashtable] $_uiIndex                     # Id -> TreeNode

    # ---------------- overlays -------------
    # each overlay: @{ Name; Styles = @{ Id -> @{Fore;Back;FontStyle;Hidden;Badge} } }
    hidden [System.Collections.ArrayList] $_overlays
    hidden [System.Collections.Hashtable] $_fontCache                   # style -> Font

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

    # ---------------- suppression ----------
    hidden [boolean] $_suppressEvents = $false

    # ---------------- public callbacks -----
    [scriptblock] $OnNodeSelected = $null      # & cb $id
    [scriptblock] $OnNodeExpanding = $null     # & cb $id (lazy population)
    [scriptblock] $OnNodeDoubleClick = $null   # & cb $id

    # During a pick: nodes the HOST wants kept VISIBLE although they are not
    # pick targets (Customizations subtrees under eligible layers).
    # & $PickKeepPredicate $id $tag -> [bool]
    [scriptblock] $PickKeepPredicate = $null

    # During a pick: a click on a node that is NOT a pick target.
    # The pick stays armed. & $OnPickOtherNode $id
    [scriptblock] $OnPickOtherNode = $null


    # Checkbox support (the pipeline selector uses it).
    [bool] $CheckBoxes = $false
    [scriptblock] $OnNodeChecked = $null      # & $OnNodeChecked $id $checked

    [scriptblock] $OnNodeRealized = $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

        # ---- the tree ----
        $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)
            }
        })

<#
        # ---- the toolbar (hosts fill it with their own items) ----
        $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)

        # 35px matches SRxPropertyBoxControl/_title and SRxFilterBoxControl/_title,
        # which were themselves matched to the tree's old TableLayoutPanel row
        # (RowStyles.Item(0).Height = 35). Encapsulation removed that row, so the
        # strip must now pin the height itself or it auto-sizes shorter.
        $this._toolStrip.AutoSize = $false
        $this._toolStrip.Height = 35

        $this._toolStrip.Renderer = [System.Windows.Forms.ToolStripSystemRenderer]::new()
#>

        $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)
        # Cosmetic fix: match the fixed 35px header-bar height used by the
        # left-hand tree panel's own top bar (Explorer.ps1's _toolStripTree,
        # hosted in a TableLayoutPanel row with RowStyles.Item(0).Height=35).
        # Left unset, a ToolStrip auto-sizes to a shorter height than that,
        # so the two header bars looked visually mismatched side-by-side.
        $this._toolStrip.AutoSize = $false
        $this._toolStrip.Height = 35


        # Z-ORDER: Fill first, Top last.
        [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 }
    # The hosts add THEIR items to this strip; the control owns the strip itself.
    [System.Windows.Forms.ToolStrip] ToolBar() { return $this._toolStrip }

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


    # ======================================================== MODEL

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

    # Add or replace a node. $parentId = '' means root.
    [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            # keep existing children on replace
            $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()          # unconditional - _uiIndex may still be empty
    }

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

    # ======================================================== SYNC (diff-apply)

    # Rebuilds the VIEW from the MODEL without destroying expansion or selection.
    [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
        }
    }

    # diff-apply one level: add missing, remove stale, reorder to model order
    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.ArrayList
        foreach ($cid in $childIds) { if ($visible.ContainsKey($cid)) { [void]$wanted.Add($cid) } }

        # remove nodes no longer wanted
        for ($i = $coll.Count - 1; $i -ge 0; $i--) {
            $ui = $coll[$i]
            if (-not $wanted.Contains([string]$ui.Name)) {
                [void]$this._uiIndex.Remove([string]$ui.Name)
                $coll.RemoveAt($i)
            }
        }

        # add / reorder to match the model
        for ($i = 0; $i -lt $wanted.Count; $i++) {
            $cid = [string]$wanted[$i]
            $m = $this._model[$cid]
            $existingIdx = -1
            for ($j = 0; $j -lt $coll.Count; $j++) { if ([string]$coll[$j].Name -eq $cid) { $existingIdx = $j; break } }

            $wasCreated = $false

            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)
                $wasCreated = $true
            } 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 }   # host may replace it with a decorated shape
            $this._uiIndex[$cid] = $ui

            # let the host decorate a freshly materialised node (menus, tooltips)
            if ($wasCreated -and $this.OnNodeRealized) {
                try { & $this.OnNodeRealized $cid $ui } catch { Write-Host "OnNodeRealized - $($_.Exception.Message)" }
            }

            if ($m.ChildrenLoaded) {
                $this.syncChildren($ui, $m.ChildIds, $visible)
            }
            elseif ($m.HasChildrenHint) {
                # ensure exactly ONE placeholder so the expander appears
                $hasPh = $false
                for ($k = 0; $k -lt $ui.Nodes.Count; $k++) {
                    if ([string]$ui.Nodes[$k].Name -like 'lazy:*') { $hasPh = $true; break }
                }
                if (-not $hasPh) {
                    $ph = New-Object System.Windows.Forms.TreeNode
                    $ph.Name = ('lazy:' + $cid)
                    $ph.Text = '...'
                    [void]$ui.Nodes.Add($ph)
                }
            }

        }
    }

    # An id is visible unless an overlay hides it. Ancestors of a visible node
    # stay visible so the path to it is never broken.
    hidden [hashtable] computeVisibleIds() {
        $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) }     # a later overlay can un-hide
            }
        }
        $visible = @{}
        foreach ($id in $this._model.Keys) { if (-not $hidden.ContainsKey($id)) { $visible[$id] = $true } }
        # keep ancestors of visible nodes
        foreach ($id in @($visible.Keys)) {
            foreach ($a in $this.AncestorIds($id)) { $visible[$a] = $true }
        }
        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) {
        foreach ($k in $set.Keys) {
            if ($this._uiIndex.ContainsKey($k)) {
                try { $this._uiIndex[$k].Expand() } catch {}
            }
        }
    }

    # ======================================================== OVERLAYS

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

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

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

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

    # $style keys: Fore (Color), Back (Color), FontStyle (FontStyle), Hidden (bool), Badge (string)
    [void] SetOverlayStyle([string]$name, [string]$id, [hashtable]$style) {
        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 } }
    }
    # Replace an overlay entire style map in one pass, then paint once.
    [void] SetOverlayStyles([string]$overlayName, [hashtable]$styleById) {
        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) {
        foreach ($ov in $this._overlays) { if ($ov.Name -eq $name) { $ov.Styles.Clear(); return } }
    }

    # flatten overlays bottom-to-top, then paint
    [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 }   # never blank an orphan UI node
            $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 }
            }
        }
    }

    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
    }

    # Discard the entire UI tree and rebuild it from the model. Used when a
    # previous operation PRUNED nodes - a diff-sync cannot resurrect a subtree
    # whose parent was removed, because it only recurses into nodes that exist.
    [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
        }
    }    

    # ======================================================== PICK (an overlay)

    # $predicate : { param($id, $tag) -> [bool] }
    # $onPicked : { param($id) }
    [void] BeginNodePick($predicate, $onPicked, [bool]$multi, [bool]$hideOthers) {
        try {
            # A previous pick left the UI PRUNED. AbortNodePick drops the overlay
            # but does not re-sync, so the culled branches never return. Clear the
            # overlay AND rebuild the full tree before computing a new prune.
            $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 the PATH to every eligible node
            $keep = @{}
            foreach ($id in $eligible.Keys) {
                $keep[$id] = $true
                foreach ($a in $this.AncestorIds($id)) { $keep[$a] = $true }
            }

            # nodes the HOST wants visible but NOT selectable as a pick target
            $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)) {
                    # plain: visible, default look, not a pick target
                }
                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)" }
    }

    # Ending a pick just drops the overlay - the model was never touched,
    # so nodes cannot duplicate and no reload is required.
    [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.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.applyAppearance()      # never leave stale styling behind
    }

    [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()
    }

    # Text placed BEFORE the node label. (Badges are appended AFTER it.)
    [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)
    }

    # Add keys to an existing style WITHOUT discarding what is already there.
    # Needed because SetOverlayStyle REPLACES a style outright - setting the
    # green Back would otherwise wipe the Prefix written a moment earlier.
    [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)
    }


    # ======================================================== SELECTION

    [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) {
        foreach ($a in $this.AncestorIds($id)) {
            if ($this._uiIndex.ContainsKey($a)) { try { $this._uiIndex[$a].Expand() } catch {} }
        }
    }

    [void] ExpandAll()   { $this._tree.ExpandAll() }
    [void] CollapseAll() { $this._tree.CollapseAll() }

    # "Collapsed" view: ONE top node -> it stays EXPANDED, all its children are
    # collapsed. Several top nodes -> all collapsed.
    [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)" }
    }

    # Collapse the given nodes AND everything inside them.
    [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)" }
    }


    # Overlays named "pick.*" belong to the pick and end together with it.
    hidden [void] dropPickExtras() {
        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)" }
    }


    # ======================================================== EVENTS

    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) {
                    # not a pick target - let the host react (a customization
                    # node was clicked, for instance). The pick stays armed.
                    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 ($m.ChildrenLoaded) { return }

            # No handler yet -> do NOT consume the lazy state (ExpandAll would
            # otherwise permanently mark the branch as loaded-but-empty).
            if (-not $this.OnNodeExpanding) { return }

            & $this.OnNodeExpanding $id
            $m.ChildrenLoaded = $true

            for ($i = $uiNode.Nodes.Count - 1; $i -ge 0; $i--) {
                if ([string]$uiNode.Nodes[$i].Name -like 'lazy:*') { $uiNode.Nodes.RemoveAt($i) }
            }
            $this.SyncToUI()
        } catch { Write-Host "SRxTreeControl.handleBeforeExpand - $($_.Exception.Message)" }
    }
    # Yellow background on the given nodes. The overlay is re-added LAST, so its
    # background wins over every overlay beneath it. An empty list removes it.
    # Names starting with "pick." are dropped automatically when a pick ends.
    [void] SetFocusHighlights([string]$overlayName, [string[]]$ids) {
        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', @()) }
}