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', @()) } } |