public/list/New-UiDataGrid.ps1
|
function New-UiDataGrid { <# .SYNOPSIS Themed datagrid. Drops inside a New-UiWindow next to other controls. .DESCRIPTION Drop a grid into a window, get the selected rows back in a button action via -Variable hydration. Columns auto-generate from the first row's properties, or specify them explicitly with -Columns. Cells can be text, checkbox, dropdown, date picker, or button/checkbox/link controls. OnCellEdit / OnRowEdit callbacks run when edits commit. A toolbar with basic filter, copy, export, and column picker is on by default, opt out of any with the matching -No* switch. Replace, append, or clear rows from a button action with Set-/Add-/Clear-UiDataGridItems. .PARAMETER Variable Hydration name. The selected rows show up as $varName in button actions, and the Set-/Add-/Clear-UiDataGridItems helpers look the grid up by this name. .PARAMETER Items Row objects to show. Take from the pipeline or pass directly. Can't be combined with -ItemsSource. .PARAMETER ItemsSource Point the grid at a collection you own. After the call, $list.Add() from anywhere lands in the grid, whether from a button action or the console. Accepted inputs: - any of the usual list types: array, ArrayList, List<T>, ObservableCollection, the PsUi async collection. Everything except the async collection gets wrapped, and PsUi walks the calling scope to repoint every variable holding the original at the wrap. The async collection already is the wrap, so it binds as is. - [ref] to any of the above The variable bind can't reach: - a collection held by a property: $obj.Items - a collection held inside a hashtable or dictionary: $state.list - a collection passed as a literal expression: -ItemsSource (Get-Thing) - a variable rebound to a different value after the call: $list = Get-Process - a variable captured by a closure built before the call In all of those the grid still binds, but you have no handle. Use a local variable, or pass an ObservableCollection, which the grid follows wherever it lives. A [ref] helps only when it points at a variable: a [ref] built from a property or a hashtable entry fills the holder and leaves the original where it was, and the grid warns when it spots one. Can't be combined with -Items. .PARAMETER NoBind Skip the variable bind step. -ItemsSource still wraps the collection and points the grid at the wrap, but the variable you passed in keeps its original value, and a [ref] keeps pointing where it pointed. The two only stay in step while the mirror holds, so an ObservableCollection carries its own changes across and a plain list does not. Drive it through Set-UiDataGridItems, Add-UiDataGridItem and Clear-UiDataGridItems. Off by default. .PARAMETER Columns How to lay out columns. Three options: - omit it: auto-generate from the first row - string[]: limit auto-generated columns to these property names, in this order - full control: a { New-UiColumn ... } definition block, an array of New-UiColumn output, or the equivalent hashtables. Each column can set Name, Header, Width, Format, ReadOnly, Editable ($true/$false/scriptblock/property name), EditorType (Auto/Text/CheckBox/ComboBox/DatePicker), Choices, Validator, plus Type=Button/Toggle/Link for live controls in the cell (with Text, Icon, Action, Binding, OnChange, Url as needed). Button and Link actions run in a background runspace by default; -NoAsync (or NoAsync = $true in the hashtable form) keeps one on the UI thread (dialogs or clipboard work). Link cells default to http/https/mailto/tel schemes only; -AllowFileScheme permits file: URLs (off by default because {Prop} substitution into a file: template lets row content launch arbitrary executables). Property-name strings and full column definitions mix in one array. .PARAMETER Height Height cap in pixels. Defaults to 300. The grid scrolls internally once row count pushes past it. Pass -Fill to lift the cap and grow with the window. .PARAMETER Width Fixed width in pixels. Optional. .PARAMETER FullWidth In a wrapping layout, stretch to fill the available width. .PARAMETER Fill Grow to the rest of the window's available height, lifting the -Height cap. The grid claims whatever space the window has left below it and resizes with the window. Use when the DataGrid is the dominant content. Several -Fill controls in one window split the leftover height evenly, each held to its own -MinFillHeight and -MaxFillHeight. .PARAMETER MaxFillHeight Cap on -Fill growth in pixels. Defaults to no cap. Useful on 4K / multi-monitor setups where unbounded fill looks too tall. .PARAMETER MinFillHeight Floor on -Fill height in pixels. Defaults to 50. Raises the minimum so a tall sibling above can't pulverize the grid to a slim little slice. .PARAMETER SelectionMode Single (one row), Extended (Ctrl/Shift multi-select, default), or None (rows still highlight on click but OnSelectionChanged won't fire). .PARAMETER DefaultPropertiesOnly Show only the object's default display properties, in the set's order, and hide the rest behind the column picker. Files show Mode, LastWriteTime, Length and Name, and folders the same without Length. Without it every column shows with the defaults first. .PARAMETER HideEmptyColumns Hide columns where every value is null or empty. .PARAMETER NoArrayPopup Don't pop a viewer when an array cell is clicked. .PARAMETER NoDictionaryPopup Don't pop a viewer when a nested hashtable / dictionary cell is clicked. .PARAMETER NoSafeWrap Skip the protective wrap done on input objects. Faster on big clean datasets, but one throwing property getter takes the whole grid down. .PARAMETER Editable Make the whole grid editable. Text cells write back to the property, and Editable = $false on a column wins. A property without a setter, or marked [ReadOnly], stays read-only. Edits reach the objects you passed in, -Items grids included, so editing a file's timestamps changes the file on disk. .PARAMETER OnCellEdit Runs after a cell edit commits. Usage: param($row, $columnName, $newValue, $oldValue) $newValue is the editor's raw value (string from TextBox, bool from CheckBox, date from DatePicker, ComboBox SelectedItem). For typed row properties, read $row.$columnName inside the callback for the post-coercion value. Example: -OnCellEdit { param($r, $c, $new, $old) Write-Host "$($r.Id): $c $old -> $new" } .PARAMETER OnRowEdit Runs after the last cell in a row commits. Usage: param($row, $changedColumns) Example: -OnRowEdit { param($r, $c) Save-Item $r; Write-Host "$($r.Id): $($c -join ',')" } .PARAMETER NoToolbar Kill the toolbar entirely. .PARAMETER NoFilter Hide the filter textbox. .PARAMETER NoExport Kill the Export-to-CSV button. .PARAMETER NoCopy Kill the copy button. .PARAMETER NoSort Kill click-to-sort on column headers. .PARAMETER NoContextMenu Kill the right-click Copy/Export/Select-All menu. .PARAMETER NoColumnPicker Kill the show/hide columns button. .PARAMETER NoStretchLastColumn Don't stretch the rightmost column to fill the grid. Use when every column should size to its content and trailing whitespace is fine. .PARAMETER NoVisualValues Render bool and null values as plain text. By default bool columns show a green check or red cross, and nulls show a dash instead of "True"/"False"/blank. Editable bools show the glyph normally and swap to a themed checkbox on edit, double-click or F2 to flip. .PARAMETER NoMarkEmptyCells Don't mark empty cells. By default null / empty-string cells get a subtle diagonal hatch so empty data is easier to spot at a glance. Only applies to text columns. .PARAMETER CaptureScrollWheel Keep every mouse-wheel event inside the grid, ends included. Same as -ScrollWheel Capture. .PARAMETER ScrollWheel Says what gets the wheel while the cursor is over the grid. Page, the default, hands every wheel event to the page, so a window full of them still scrolls. Edge scrolls the grid's own rows until it reaches the top or bottom and gives the page the wheel from there. Capture holds on at the ends as well, so the page stays put while the cursor is here. A -Fill grid starts on Edge instead, since it holds the viewport and the page behind it has almost no scroll of its own left. Pass -ScrollWheel to override that. .PARAMETER OnSelectionChanged Runs when the selected row(s) change. Usage: param($selectedItems) Example: -OnSelectionChanged { param($sel) Write-Host "Selected $($sel.Count) row(s)" } .PARAMETER OnDoubleClick Runs on row double-click. Usage: param($row) Example: -OnDoubleClick { param($r) Show-UiMessageDialog -Message ($r|Out-String) } .PARAMETER EnabledWhen Variable name (or scriptblock). The grid is enabled while it's truthy. .PARAMETER WPFProperties Extra properties to set on the grid (or its toolbar host if there's a toolbar). Hashtable of property name to value. .PARAMETER RowDetailsTemplate Scriptblock that builds the expandable detail panel under the selected row. `$_`/`$row` inside are the row data. Runs on the UI thread when the row expands, use Invoke-UiAsync inside for anything slow. Example: `{ New-UiLabel -Text $_.Description; New-UiLabel -Text $_.Notes }` Example: `{ New-UiTextArea -Default ($_|Out-String) -ReadOnly }` .PARAMETER RowBackground Scriptblock that colors rows. Returns a color string (e.g. '#33FF6B6B') or `$null`. `$_`/`$row` is the row data. Runs as rows scroll into view. .PARAMETER FrozenColumns How many columns to pin on the left during horizontal scroll. Useful when the leftmost columns have identifying info and the grid is wide enough to need horizontal scroll. .PARAMETER EmptyMessage Text shown over the grid when the collection is empty. .PARAMETER NoAlternatingRowBrush Kill the alternating row stripe. The default striping uses a theme color that flips direction across light/dark themes so the stripe stays readable either way; the built-in WPF stripe is too subtle in most themes. .PARAMETER DefaultSort Sort the grid before showing it. Accepts: - 'PropName' (ascending) - 'PropName -Descending' - @{ Property = 'PropName'; Direction = 'Ascending'|'Descending' } - an array of any of the above for multi-key sorting .PARAMETER RowHeight Fixed pixel row height. Default sizes rows to their content. .PARAMETER RowContextMenu Custom items to add to the right-click menu, shown above the standard Copy/Export entries. Pass a { New-UiMenuItem ... } definition block (menu order follows call order), or the legacy hashtable mapping a label to an action, where the action is: - a scriptblock: { Restart-Service $_.Name } - a hashtable: @{ Action = {}; Enabled = {} or $bool; Icon = 'Name'; NoAsync = $false } Inside the action, $_ is the row being acted upon. The grid refreshes itself after the action runs, so $_.Status = 'Stopped' actually shows up. Write-Host goes wherever PsUi normally puts host output (the output window, the active status bar). Multi-select: if the right-click lands on a row that's part of a multi-selection, the action fans out across every selected row (Excel / Explorer convention). Right-clicking outside the selection acts on just the click target. With a multi-selection, Enabled runs twice: once for the menu (enabled while at least one of the first 20 selected rows qualifies; bigger selections enable without probing and the click still filters) and once per row as the action fans out (ineligible rows are skipped silently). Actions run in a background runspace by default so the UI stays responsive during slow work (Restart-Service, Invoke-WebRequest, etc.). Use -NoAsync on New-UiMenuItem (NoAsync = $true in the hashtable form) for actions that have to stay on the UI thread (Show-UiMessageDialog or clipboard stuff). Background action variable capture: PsUi grabs the values of the variables your action mentions by name and injects them into the background runspace. A local secret with a name that collides with something in the action body ($cred is the common one) rides along too. Sync actions skip the injection entirely. .PARAMETER SanitizeFormulas Prefix exported / copied cells whose first character is =/+/-/@/tab/CR/LF with an apostrophe so Excel (assuming it's the default) treats them as literal text. Off by default. Clean data roundtrips (export then re-import) stay byte identical without this. Flip it on when the grid is showing values from untrusted sources (user-supplied filenames, log lines, anything off the network) that a downstream user might open in Excel. .EXAMPLE New-UiWindow -Title 'Procs' -Content { Get-Process | New-UiDataGrid -Variable procs -Height 400 -DefaultPropertiesOnly New-UiButton -Text 'Kill Selected' -Action { foreach ($p in $procs) { Stop-Process -Id $p.Id } } } .EXAMPLE # Editable grid with mixed cell types New-UiDataGrid -Variable svc -Items (Get-Service) -Editable -Columns { New-UiColumn Name -ReadOnly New-UiColumn Status -Editable $true New-UiColumn StartType -Editable $true -EditorType ComboBox -Choices 'Automatic', 'Manual', 'Disabled' New-UiColumn -Header 'Restart' -Type Button -Text 'Restart' -Action { Restart-Service $_.Name } } -OnCellEdit { param($row, $col, $new, $old) Write-Host "$($row.Name): $col $old -> $new" } .EXAMPLE # Row details with conditional row coloring $gridParams = @{ Variable = 'svc' Items = Get-Service Height = 500 RowBackground = { if ($_.Status -eq 'Stopped') { '#33FF6B6B' } } RowDetailsTemplate = { New-UiLabel -Text "Dependencies: $($_.DependentServices.Count)" New-UiLabel -Text $_.Description } } New-UiDataGrid @gridParams .EXAMPLE # Controls that live in a cell are Toggle (two way), Button (per row) and Link (a clickable URL). $rows = @( [pscustomobject]@{ Name='node-a'; Online=$true; Url='https://node-a.local' } [pscustomobject]@{ Name='node-b'; Online=$false; Url='https://node-b.local' } ) New-UiDataGrid -Variable nodes -Items $rows -Editable -Columns { New-UiColumn Name -ReadOnly New-UiColumn -Header 'Enabled' -Type Toggle -Binding Online -OnChange { Write-Host "$($_.Name) -> $($_.Online)" } New-UiColumn -Header 'Ping' -Type Button -Text 'Ping' -Icon NetworkAdapter -Action { Test-Connection $_.Name -Count 1 } New-UiColumn -Header 'Open' -Type Link -Text 'Open' -Url '{Url}' } .EXAMPLE # Live updates via -ItemsSource. A plain ArrayList works because PsUi binds $rows to # the wrap - $rows after the call IS the threadsafe list, so $rows.Add() from # a button action lands in the grid without ceremony. $rows = [System.Collections.ArrayList]::new() New-UiWindow -Title 'Live feed' -Content { New-UiDataGrid -Variable feed -ItemsSource $rows -Fill New-UiButton -Text 'Add row' -NoOutput -Action { [void]$rows.Add([pscustomobject]@{ Time = Get-Date; Value = Get-Random }) } } .EXAMPLE # Right-click actions per row New-UiDataGrid -Variable svc -Items (Get-Service) -RowContextMenu { New-UiMenuItem 'Restart' -Icon Refresh -Action { Restart-Service $_.Name } -Enabled { $_.Status -eq 'Stopped' } New-UiMenuItem 'Details' -NoAsync -Action { Show-UiMessageDialog -Message ($_ | Format-List | Out-String) } } .EXAMPLE # Legacy hashtable forms (still supported), columns and menu items alike New-UiDataGrid -Variable svc -Items (Get-Service) -Columns @( @{ Name='Name'; ReadOnly=$true } @{ Header='Restart'; Type='Button'; Text='Restart'; Action={ Restart-Service $_.Name } } ) -RowContextMenu ([ordered]@{ 'Restart' = @{ Action = { Restart-Service $_.Name }; Icon = 'Refresh'; Enabled = { $_.Status -eq 'Stopped' } } 'Details' = @{ Action = { Show-UiMessageDialog -Message ($_ | Format-List | Out-String) }; NoAsync = $true } }) .NOTES Variable binding: -ItemsSource wraps your list in a threadsafe one and repoints the scope variables that hold the original at the wrap. End result, $list IS the wrap afterwards. Five cases the rebind can't reach (see -ItemsSource). When none of your scope variables get rebound, a warning fires. Pass -NoBind to opt out and manage the binding yourself. Async by default actions: cell embedded Button actions and -RowContextMenu items run in a background runspace so slow work doesn't freeze the grid. Use -NoAsync on New-UiColumn / New-UiMenuItem (NoAsync = $true in the hashtable forms) for actions that have to stay on the UI thread (dialogs and clipboard work). The switch answers to -Sync and the hashtables to Sync = $true, which is what both were called before. First-row column seeding: if the grid starts empty and rows arrive later, columns are built from the first row with readable properties. Once columns exist, additional properties on later rows won't add columns. Pass -Columns explicitly for grids whose schema isn't uniform across rows. Plain .NET objects in an -ItemsSource list don't carry what PowerShell adds, such as PSPath, Mode or a process's CPU, so those columns start hidden. #> [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Variable, [Parameter(ValueFromPipeline)] [object[]]$Items, # Untyped so the body can accept ObservableCollection, AsyncObservableCollection, # [ref] to either or to a raw IList, or any IEnumerable. [System.Collections.IEnumerable] # would reject [ref] (PSReference doesn't implement IEnumerable). $ItemsSource, $Columns, [int]$Height = 300, [int]$Width, [switch]$FullWidth, [switch]$Fill, [double]$MaxFillHeight = [double]::PositiveInfinity, [double]$MinFillHeight = 50, [ValidateSet('Single', 'Extended', 'None')] [string]$SelectionMode = 'Extended', [switch]$DefaultPropertiesOnly, [switch]$HideEmptyColumns, [switch]$NoArrayPopup, [switch]$NoDictionaryPopup, [switch]$NoSafeWrap, [switch]$NoBind, [switch]$Editable, [scriptblock]$OnCellEdit, [scriptblock]$OnRowEdit, [switch]$NoToolbar, [switch]$NoFilter, [switch]$NoExport, [switch]$NoCopy, [switch]$NoSort, [switch]$NoContextMenu, [switch]$NoColumnPicker, [switch]$NoStretchLastColumn, [switch]$NoVisualValues, [switch]$NoMarkEmptyCells, [switch]$CaptureScrollWheel, [scriptblock]$OnSelectionChanged, [scriptblock]$OnDoubleClick, [object]$EnabledWhen, [hashtable]$WPFProperties, [scriptblock]$RowDetailsTemplate, [scriptblock]$RowBackground, [int]$FrozenColumns, [string]$EmptyMessage = 'No items to display.', [switch]$NoAlternatingRowBrush, $DefaultSort, [double]$RowHeight, # Untyped, so it takes a New-UiMenuItem definition block or array, or the legacy IDictionary keyed by label. No [hashtable] constraint on the legacy form: it silently converts [ordered]@{} to Hashtable and scrambles the declared menu order. $RowContextMenu, [switch]$SanitizeFormulas, # Last in the list, so it takes no position an existing positional call already uses. [ValidateSet('Page', 'Edge', 'Capture')] [string]$ScrollWheel = 'Page' ) begin { $accumulated = [System.Collections.Generic.List[object]]::new() } process { if ($null -ne $Items) { foreach ($entry in $Items) { [void]$accumulated.Add($entry) } } } end { # An empty ObservableCollection passed to -ItemsSource is falsy in PS, but still counts as "you supplied a collection". if ($accumulated.Count -gt 0 -and $PSBoundParameters.ContainsKey('ItemsSource')) { throw "New-UiDataGrid cannot use both -Items and -ItemsSource. Only one can be used." } if ($PSBoundParameters.ContainsKey('ItemsSource') -and $NoSafeWrap) { Write-Warning "New-UiDataGrid: -NoSafeWrap has no effect with -ItemsSource. The collection is used as it is, and the safe wrap pass only runs on the -Items path." } $session = Assert-UiSession -CallerName 'New-UiDataGrid' $parent = $session.CurrentParent $colors = Get-ThemeColors Write-Debug "Creating data grid '$Variable' items=$($accumulated.Count) editable=$Editable" # Builder input for -Columns (New-UiColumn block or array) normalizes here. Plain property name strings stay legal in the mix. Empty coerces back to $null so the auto column path still triggers. if ($null -ne $Columns) { $Columns = ConvertTo-UiDefinitionArray -InputObject $Columns -ParameterName '-Columns' -CallerName 'New-UiDataGrid' -AllowString if ($Columns.Count -eq 0) { $Columns = $null } } # Builder input for -RowContextMenu folds into the ordered form keyed by label that the menu builder already eats. The legacy IDictionary passes through whole. if ($null -ne $RowContextMenu) { $RowContextMenu = ConvertTo-UiDefinitionArray -InputObject $RowContextMenu -ParameterName '-RowContextMenu' -CallerName 'New-UiDataGrid' -PassThruDictionary if ($RowContextMenu -isnot [System.Collections.IDictionary]) { $folded = [ordered]@{} foreach ($menuDef in $RowContextMenu) { if (!$menuDef['Text']) { throw "New-UiDataGrid: each -RowContextMenu item needs a Text key. Use New-UiMenuItem, or the legacy [ordered]@{ 'Label' = @{ Action = ... } } form." } if ($folded.Contains([string]$menuDef['Text'])) { throw "New-UiDataGrid: duplicate -RowContextMenu label '$($menuDef['Text'])'. Menu items are keyed by label, and labels must be unique (case-insensitive)." } # Copy all but Text so absent Sync/Enabled stay absent. The menu builder reads .Contains() on them. $itemDef = @{} foreach ($key in $menuDef.Keys) { if ($key -ne 'Text') { $itemDef[$key] = $menuDef[$key] } } $folded[[string]$menuDef['Text']] = $itemDef } $RowContextMenu = $folded } } # Resolve the backing collection $collection = $null $isOwned = $false $mirrorAttached = $false if ($PSBoundParameters.ContainsKey('ItemsSource')) { $uiDispatcher = [System.Windows.Threading.Dispatcher]::CurrentDispatcher # Plain ol' ObservableCollection<T> isn't threadsafe. Helpers fired from async button actions live on a background runspace. Calling .Add() on the user's unwrapped ObservableCollection from there throws "This type of CollectionView does not support changes to its SourceCollection from a thread different from the Dispatcher thread." # Only PsUi.AsyncObservableCollection is safe to bind directly. Everything else gets wrapped + mirrored. $itemsSourceType = Get-UiCollectionType -Obj $ItemsSource switch ($itemsSourceType) { 'Null' { $collection = [PsUi.GridOwnedCollection[object]]::new() $isOwned = $true break } # Already the threadsafe observable, bind directly. Refresh its Dispatcher in case the collection was constructed on a different STA thread. 'PsUiObservable' { try { $ItemsSource.UpdateDispatcher() } catch { Write-Debug "UpdateDispatcher failed: $_" } $collection = $ItemsSource break } # Plain WPF ObservableCollection. Same wrap and mirror as the value type branch. 'WpfObservable' { $wrapper = [PsUi.AsyncObservableCollection[object]]::new($ItemsSource, $uiDispatcher) $wrapper.AttachMirror($ItemsSource) $mirrorAttached = $true $collection = $wrapper break } # [ref] promotion 'Ref' { $orig = $ItemsSource.Value $origType = Get-UiCollectionType -Obj $orig if ($origType -eq 'PsUiObservable') { try { $orig.UpdateDispatcher() } catch { Write-Debug "UpdateDispatcher failed: $_" } $collection = $orig } else { $origName = if ($null -eq $orig) { 'null' } else { $orig.GetType().Name } try { $wrapper = [PsUi.AsyncObservableCollection[object]]::new($orig, $uiDispatcher) } catch { throw "New-UiDataGrid -ItemsSource takes a list. The [ref] points at a $origName, which is not one, and PowerShell cannot read it as a sequence either." } # Without this an ObservableCollection behind [ref]$state.Rows seeds the grid once and never reaches it again. if ($orig -is [System.Collections.IList] -and !$orig.IsReadOnly -and !$orig.IsFixedSize) { $wrapper.AttachMirror($orig) $mirrorAttached = $true } $collection = $wrapper if (!$NoBind) { try { $ItemsSource.Value = $wrapper } catch { Write-Debug "[ref] target refused the wrap: $_" } # [ArrayList]$rows behind the ref converts the wrap, so the read back is a copy the grid never sees again. $landed = $ItemsSource.Value if (!([object]::ReferenceEquals($landed, $wrapper))) { $landedDesc = if ($null -eq $landed) { 'refused it and kept its null' } else { "converted it into a fresh $($landed.GetType().Name) the grid cannot see" } Write-Warning "New-UiDataGrid -ItemsSource: the variable behind the [ref] is type constrained, so PowerShell $landedDesc. Drop the type, or use -NoBind and drive the grid with Add-UiDataGridItem." } # [ref]$state.Rows stops at the holder, and an ObservableCollection there still reaches the grid through the mirror, so warning about it would be wrong. elseif (!(Test-UiRefWritesThrough -Reference $ItemsSource) -and $orig -isnot [System.Collections.Specialized.INotifyCollectionChanged]) { Write-Warning 'New-UiDataGrid -ItemsSource: only a [ref] to a variable can be repointed, so a [ref] built from a property still holds the original list. Add through the [ref] .Value.' } } } break } # Value type collection (ArrayList, List<T>, array, anything else IEnumerable). default { try { $wrapper = [PsUi.AsyncObservableCollection[object]]::new($ItemsSource, $uiDispatcher) } catch { throw "New-UiDataGrid -ItemsSource takes a list. A $($ItemsSource.GetType().Name) is not one, and PowerShell cannot read it as a sequence either." } if ($ItemsSource -is [System.Collections.IList] -and !$ItemsSource.IsReadOnly -and !$ItemsSource.IsFixedSize) { $wrapper.AttachMirror($ItemsSource) $mirrorAttached = $true } $collection = $wrapper } } # Walk up the scopes, repoint every variable that ref equals the original at the wrap. # $list.Add() from outside now lands on the threadsafe collection. Ref/PsUiObservable are handled above. The Null branch has no collection to wrap. if (!$NoBind -and $itemsSourceType -in 'WpfObservable', 'Other' -and $null -ne $collection -and !([object]::ReferenceEquals($collection, $ItemsSource))) { $promotedNames = [System.Collections.Generic.List[string]]::new() $convertedNames = [System.Collections.Generic.List[string]]::new() for ($scopeIdx = 1; $scopeIdx -lt 50; $scopeIdx++) { try { $scopeVars = Get-Variable -Scope $scopeIdx -ErrorAction Stop } catch [System.ArgumentOutOfRangeException] { break } catch { Write-Debug "Variable bind scope $scopeIdx walk failed: $_"; continue } foreach ($psVar in $scopeVars) { # $psVar.Value can throw for lazy loaded variables (disposed COM, missing registry, etc). Catch per variable so one misbehaving variable doesn't kill the rest of the scope. $matched = $false try { $matched = [object]::ReferenceEquals($psVar.Value, $ItemsSource) } catch { Write-Debug "Variable bind read of '$($psVar.Name)' failed: $_"; continue } if ($matched) { try { Set-Variable -Name $psVar.Name -Value $collection -Scope $scopeIdx -Force -ErrorAction Stop $landed = $collection try { $landed = Get-Variable -Name $psVar.Name -Scope $scopeIdx -ValueOnly -ErrorAction Stop } catch { Write-Debug "Variable bind read back of '$($psVar.Name)' failed: $_" } if ([object]::ReferenceEquals($landed, $collection)) { [void]$promotedNames.Add("`$$($psVar.Name)@$scopeIdx") } else { $landedDesc = if ($null -eq $landed) { 'which refused it and holds null' } else { "now a fresh $($landed.GetType().Name)" } [void]$convertedNames.Add("`$$($psVar.Name), $landedDesc") } } catch { Write-Debug "Variable bind rewrite of '$($psVar.Name)' at scope $scopeIdx failed: $_" } } } } if ($convertedNames.Count -gt 0) { Write-Warning "New-UiDataGrid -ItemsSource: a type constrained target converted the threadsafe collection on assignment, and the grid never sees the copy ($($convertedNames -join ', ')). Drop the type, or use -NoBind and drive the grid with Add-UiDataGridItem." } if ($promotedNames.Count -gt 0) { Write-Debug "Variables bound: $($promotedNames -join ', ')" } # The conversion branch above already warned about this variable. elseif ($convertedNames.Count -eq 0) { Write-Debug "Variables bound: nothing matched" # Zero rewrites usually means a property access ($obj.Items) or an expression without a variable behind it. Write-Warning 'New-UiDataGrid -ItemsSource: could not repoint any script variable to the autowrapped collection. Use a local variable or [ref] so $list.Add() and the helpers stay connected to the grid.' } } elseif ($NoBind -and $itemsSourceType -in 'WpfObservable', 'Other' -and $null -ne $collection -and !([object]::ReferenceEquals($collection, $ItemsSource))) { # -NoBind leaves $list on the original with the wrap still attached to the grid. Write-Debug "Variable bind skipped (-NoBind). The variable still points at the original collection." } } else { $rawItems = $accumulated.ToArray() $safeItems = if ($NoSafeWrap -or $rawItems.Count -eq 0) { $rawItems } else { @(ConvertTo-SafeDataArray -DataArray $rawItems) } # Flatten to PSCustomObject so WPF binding stops pretending PS added properties don't exist. The snapshot tucks a cached _SearchText on each row for the filter and keeps the original at $row._BaseObject. Skip on empty input - Mandatory binding chokes. $snapItems = if ($null -eq $safeItems -or $safeItems.Count -eq 0) { @() } else { @(ConvertTo-UiDataGridSnapshot -Items $safeItems -BuildSearchIndex) } # GridOwnedCollection is how Set/Add/Clear-UiDataGridItems tell owned (built here) from what you passed via -ItemsSource. $observable = [PsUi.GridOwnedCollection[object]]::new() foreach ($entry in $snapItems) { [void]$observable.Add($entry) } $collection = $observable $isOwned = $true } # WPF doesn't ship a "highlight but don't fire events" mode. Single mode plus a suppressed SelectionChanged handler here is the closest thing. $effectiveSelMode = if ($SelectionMode -eq 'None') { 'Single' } else { $SelectionMode } $singleSelect = ($effectiveSelMode -eq 'Single') $dataGrid = New-StyledDataGrid -SingleSelect:$singleSelect -NoSort:$NoSort -NoContextMenu:$NoContextMenu -NoStarResizeUnlock:$NoStretchLastColumn $dataGrid.ItemsSource = [System.Windows.Data.CollectionViewSource]::GetDefaultView($collection) $dataGrid.IsReadOnly = !$Editable # Height is a cap. Grid shrinks to data when there's less of it (no dead space below the last row), scrolls internally once data overflows. $dataGrid.MaxHeight = if ($Fill) { [double]::PositiveInfinity } else { $Height } $dataGrid.MinHeight = 64 if ($Width) { $dataGrid.Width = $Width } else { # MinWidth floors autosize so a default properties Get-Service grid (~450px wide) doesn't open in a dwarf of a window. Stretch is preserved - a wider parent still gets a wider grid. $dataGrid.MinWidth = 600 } if ($RowHeight) { $dataGrid.RowHeight = $RowHeight } elseif ($Editable) { # Edit controls (TextBox focus border, ComboBox padding) are taller than the display cell, so lock the row height or click to edit ends up bouncing the row taller. $dataGrid.RowHeight = 32 } # Default cell padding (12,6,12,6) eats up 12px vertical. Fine at 32px but text can sometimes clip below that. Tighten the padding when rows are short or editable. $wantsCompactCells = $Editable -or ($RowHeight -gt 0 -and $RowHeight -lt 32) if ($wantsCompactCells) { $cellBaseStyle = if ($null -ne [System.Windows.Application]::Current) { [System.Windows.Application]::Current.TryFindResource('ModernDataGridCellStyle') } else { $null } $compactCellStyle = if ($cellBaseStyle) { [System.Windows.Style]::new([System.Windows.Controls.DataGridCell], $cellBaseStyle) } else { [System.Windows.Style]::new([System.Windows.Controls.DataGridCell]) } [void]$compactCellStyle.Setters.Add([System.Windows.Setter]::new( [System.Windows.Controls.Control]::PaddingProperty, [System.Windows.Thickness]::new(12, 2, 12, 2))) $dataGrid.CellStyle = $compactCellStyle } # FrozenColumns lands after the column build below. Build-UiDataGridColumns rebuilds the column collection and WPF helpfully resets FrozenColumnCount in the process. # Alternating row brush handling sits after RegisterElement below - the registration call (and eve bnry theme switch after) resets the stripe color, which would clobber it here. # Build-UiDataGridColumns takes [object[]], so binding copies $collection into an array. It only probes Count and indexers, both fine on the copy. $colBuildParams = @{ DataGrid = $dataGrid Items = $collection Columns = $Columns Editable = $Editable DefaultPropertiesOnly = $DefaultPropertiesOnly HideEmptyColumns = $HideEmptyColumns NoStretchLastColumn = $NoStretchLastColumn VisualValues = !$NoVisualValues MarkEmptyCells = !$NoMarkEmptyCells } $colInfo = Build-UiDataGridColumns @colBuildParams # Prerender flood guard. Threshold and clause come from Get-UiGridFloodWarning, shared with the column picker's $confirmFlood (New-ColumnVisibilityPopup.ps1) so the two can't drift. # Off under -DefaultPropertiesOnly or handpicked -Columns. Without a default set every column counts as a default, and the dialog offers "Cancel load" or "Continue". $flood = Get-UiGridFloodWarning $cellThreshold = $flood.CellThreshold $defaultCount = [int]$colInfo.DefaultProperties.Count $rowCount = [int]$collection.Count $allCount = [int]$colInfo.AllProperties.Count - [int]$colInfo.PsOnlyProperties.Count $cellCount = $rowCount * $allCount $hasDefaults = ($defaultCount -gt 0 -and $defaultCount -lt $allCount) if ($cellCount -gt $cellThreshold -and !$DefaultPropertiesOnly -and $null -eq $Columns) { $msg = if ($hasDefaults) { ("This grid is about to load {0} columns across {1} rows. $($flood.Clause) " + "Load the default set of {2} columns instead? Extras come back through the 'Show/Hide Columns' button in the toolbar.") -f $allCount, $rowCount, $defaultCount } else { ("This grid is about to load {0} columns across {1} rows. $($flood.Clause) " + "Continue?") -f $allCount, $rowCount } $dialogArgs = @{ Title = 'Load all columns?' Message = $msg ConfirmText = if ($hasDefaults) { 'Load defaults' } else { 'Cancel load' } CancelText = if ($hasDefaults) { 'Load all' } else { 'Continue' } } try { if (Show-UiConfirmDialog @dialogArgs) { if ($hasDefaults) { # Collapse nondefaults in place. The WPF column objects already exist. Cheaper than rebuilding. foreach ($col in $dataGrid.Columns) { $hdr = if ($null -ne $col.Header) { [string]$col.Header } else { '' } if ($hdr -and ($hdr -notin $colInfo.DefaultProperties)) { $col.Visibility = [System.Windows.Visibility]::Collapsed } } # Star moved with the now hidden original last column. Reassign so the row fills again and ResizeUnlock's slack recovery has something to track. Set-LastDataColumnStar -DataGrid $dataGrid -Skip:$NoStretchLastColumn } else { # No defaults to fall back to. Confirm = cancel load: collapse every column. foreach ($col in $dataGrid.Columns) { $col.Visibility = [System.Windows.Visibility]::Collapsed } } } } catch { Write-Debug "Flood prompt at construction failed: $_" } } # Tag carries the state the column picker reads on each visibility toggle. # StretchLastColumn is the gate that reruns Set-LastDataColumnStar, and it still honors the original -NoStretchLastColumn switch passed at construction. $dataGrid.Tag = @{ AllProperties = $colInfo.AllProperties DefaultProperties = $colInfo.DefaultProperties PopulatedProperties = $colInfo.PopulatedProperties Collection = $collection StretchLastColumn = !$NoStretchLastColumn IsOwned = $isOwned # Export / copy paths consult this and quote prefix Excel formula triggers. SanitizeFormulas = [bool]$SanitizeFormulas } # Resize unlock already attached inside New-StyledDataGrid (skipped under -NoStretchLastColumn: no star, no lockout). # No starter data means no row to read property names from. Watch for the first add, seed columns, then drop the sub so later Adds don't reenter the guard. if ($colInfo.AllProperties.Count -eq 0 -and $collection -is [System.Collections.Specialized.INotifyCollectionChanged]) { $seedState = @{ DataGrid = $dataGrid Collection = $collection Params = $colBuildParams FrozenColumns = $FrozenColumns Handler = $null } $seedHandler = { param($sender, $eventArgs) if ($seedState.DataGrid.Columns.Count -gt 0) { return } if ($seedState.Collection.Count -eq 0) { return } # Detach, then defer the real work to Background priority. Building columns and reapplying FrozenColumnCount INSIDE the CollectionChanged notification corrupts the ItemContainerGenerator's accumulated count - WPF then spams "ItemsControl is inconsistent with its items source" on every layout pass. Same rule as the ScrollChanged pin in Add-UiDataGridStarResizeUnlock: never mutate layout affecting grid state from inside the pipeline that's mid notification. # MulticastDelegate.Invoke snapshots its invocation list, so removing from inside the fire is safe. # The closure reaches its own delegate through $seedState - it captured the hashtable reference, with the delegate stashed there after GetNewClosure. if ($seedState.Handler) { $seedState.Collection.remove_CollectionChanged($seedState.Handler) $seedState.Handler = $null } # Rebind to a local, since a nested .GetNewClosure() captures only the immediate scope's locals and never what the outer closure captured. $localState = $seedState [void]$seedState.DataGrid.Dispatcher.BeginInvoke( [System.Windows.Threading.DispatcherPriority]::Background, [Action]{ if ($localState.DataGrid.Columns.Count -gt 0) { return } Write-Debug "New-UiDataGrid: seeding columns from first arrived row" # List[object] unwraps each row, the column build checks for it $items = @($localState.Collection) if ($items.Count -eq 0) { return } # Detach ItemsSource for the whole surgery. Building columns on a LIVE grid corrupts the ItemContainerGenerator's change bookkeeping ("ItemsControl is inconsistent with its items source" on every layout pass after), and DeferRefresh is no answer - Build's BeginInit/EndInit refreshes the ItemCollection, which throws on a defer pending view. # Without an ItemsSource attached there is no view processing to corrupt, and the reattach is a fresh bind that absorbs any adds landing mid surgery. # # No try/finally: PS's CheckActionPreference NREs on try block exit when the scriptblock runs off pipeline (this is a Dispatcher delegate), and the hijacked unwind SKIPS finally - the grid stayed detached, showing zero rows forever. trap handles the error path. The tail reattach handles success. Same pattern as Add-UiDataGridRowDetails. # An inner GetNewClosure only captures THIS scope's locals ($rebind reads $null for anything else and the reattach never runs). $seedGrid = $localState.DataGrid $savedSource = $seedGrid.ItemsSource $savedView = $savedSource -as [System.ComponentModel.ICollectionView] $savedSorts = if ($savedView) { @($savedView.SortDescriptions) } else { @() } # Built BEFORE the detach below, because a trap covers its whole scope no matter where the statement sits. Dfined after, an error thrown on the detach itself would reach the trap with $rebind still $null, and & $null throws before the reattach ever runs. # Cycling ItemsSource wipes the view's SortDescriptions, so -DefaultSort on a grid that started empty died the moment its first rows landed (it applied at build, then vanished). SortDescription is a struct, so the array above holds copies and survives the clear. Same save and restore the column picker and the errors tab already do around their rebuilds. $rebind = { $seedGrid.ItemsSource = $savedSource if (!$savedView -or $savedSorts.Count -eq 0 -or $savedView.SortDescriptions.Count -gt 0) { return } foreach ($sd in $savedSorts) { # Per entry catch, same as Add-UiDataGridDefaultSort: a mixed type column throws on Add and has to come back out, or every later refresh rethrows it. try { $savedView.SortDescriptions.Add($sd) } catch { [void]$savedView.SortDescriptions.Remove($sd) Write-Debug "Seed rebind dropped sort '$($sd.PropertyName)': $($_.Exception.Message)" } } }.GetNewClosure() $seedGrid.ItemsSource = $null trap { & $rebind Write-Debug "New-UiDataGrid seed build failed: $($_.Exception.Message)" continue } $rebuildParams = $localState.Params $rebuildParams.Items = $items $newInfo = Build-UiDataGridColumns @rebuildParams # The first row to arrive had no readable property, so rearm for a later one. if (!$newInfo -or $newInfo.AllProperties.Count -eq 0) { if ($localState.SelfRef -and !$localState.Handler) { $localState.Handler = $localState.SelfRef $localState.Collection.add_CollectionChanged($localState.SelfRef) } & $rebind return } $tag = $localState.DataGrid.Tag if ($tag -is [hashtable]) { $tag.AllProperties = $newInfo.AllProperties $tag.DefaultProperties = $newInfo.DefaultProperties $tag.PopulatedProperties = $newInfo.PopulatedProperties } # WPF coerced the construction time FrozenColumnCount to 0 against the then empty Columns collection and never recoerces on column adds. # CoerceValue reruns it against the real columns. The reassign to 0 then N covers hosts where the retained base value didn't survive. The accent helper bailed on the empty grid too. if ($localState.FrozenColumns -gt 0) { $localState.DataGrid.CoerceValue([System.Windows.Controls.DataGrid]::FrozenColumnCountProperty) if ($localState.DataGrid.FrozenColumnCount -ne $localState.FrozenColumns) { $localState.DataGrid.FrozenColumnCount = 0 $localState.DataGrid.FrozenColumnCount = $localState.FrozenColumns } Add-UiDataGridFrozenColumnAccent -DataGrid $localState.DataGrid -FrozenColumns $localState.FrozenColumns foreach ($col in $localState.DataGrid.Columns) { if ($col.DisplayIndex -lt $localState.FrozenColumns) { $col.CanUserReorder = $false } } } # The last column's star width renders natural after the rebind, leaving dead space to its right. Neither a deferred star reapply nor reactive arm suspension fixes it. & $rebind }.GetNewClosure()) }.GetNewClosure() $seedState.Handler = $seedHandler $seedState.SelfRef = $seedHandler $collection.add_CollectionChanged($seedHandler) } else { $seedState = $null } # Both the mirror subscription (set in the WpfObservable / writable IList branches above) and the seed handler subscription (just above) outlive the visual grid via delegate refs back to closures built here. Without an explicit detach, every long lived source collection or stale grid pins both alive until the user's collection is itself collected. Hook the owning Window.Closed - Unloaded would overfire on tab virtualization and rip both subs out the first time the user clicks away. if ($mirrorAttached -or ($null -ne $seedState -and $null -ne $seedState.Handler)) { $cleanup = @{ Done = $false Collection = $collection MirrorAttached = $mirrorAttached SeedState = $seedState } $hookCleanup = { if ($cleanup.Done) { return } $window = [System.Windows.Window]::GetWindow($this) if (!$window) { return } # Rebind to a local, since .GetNewClosure() captures only the immediate parent scope. # Without this hop the inner Closed scriptblock sees an empty $cleanup. $localCleanup = $cleanup $window.Add_Closed({ if ($localCleanup.MirrorAttached) { try { $localCleanup.Collection.DetachMirror() } catch { Write-Debug "Grid mirror detach failed: $_" } } if ($localCleanup.SeedState -and $localCleanup.SeedState.Handler) { try { $localCleanup.SeedState.Collection.remove_CollectionChanged($localCleanup.SeedState.Handler) } catch { Write-Debug "Seed handler detach failed: $_" } $localCleanup.SeedState.Handler = $null } }.GetNewClosure()) $cleanup.Done = $true }.GetNewClosure() # Initialized fallback - grids built and disposed without ever loading still get the detach hook. Double hook protection lives in $cleanup.Done. $dataGrid.Add_Loaded($hookCleanup) $dataGrid.Add_Initialized($hookCleanup) } if (!$NoArrayPopup) { Add-ArrayCellPopupHandler -DataGrid $dataGrid } if (!$NoDictionaryPopup) { Add-DictionaryValuePopupHandler -DataGrid $dataGrid } if ($Editable) { Add-UiDataGridEditHandling -Grid $dataGrid -Columns $Columns -OnCellEdit $OnCellEdit -OnRowEdit $OnRowEdit } $gridSessionId = $session.SessionId $pushSession = ${function:Push-UiSession} $popSession = ${function:Pop-UiSession} if ($OnSelectionChanged -and $SelectionMode -ne 'None') { $selHandler = $OnSelectionChanged $dataGrid.Add_SelectionChanged({ param($sender, $eventArgs) $selected = @($sender.SelectedItems) $sessionToken = & $pushSession -SessionId $gridSessionId try { & $selHandler $selected } catch { Write-Debug "OnSelectionChanged failed: $($_.Exception.Message)" } & $popSession -Token $sessionToken }.GetNewClosure()) } if ($OnDoubleClick) { $dblHandler = $OnDoubleClick $dataGrid.Add_MouseDoubleClick({ param($sender, $eventArgs) $row = $sender.SelectedItem if ($null -eq $row) { return } $sessionToken = & $pushSession -SessionId $gridSessionId try { & $dblHandler $row } catch { Write-Debug "OnDoubleClick failed: $($_.Exception.Message)" } & $popSession -Token $sessionToken }.GetNewClosure()) } if ($RowDetailsTemplate) { Add-UiDataGridRowDetails -DataGrid $dataGrid -Template $RowDetailsTemplate } if ($RowBackground) { Add-UiDataGridRowBackground -DataGrid $dataGrid -RowBackground $RowBackground } if ($RowContextMenu -and $RowContextMenu.Count -gt 0 -and !$NoContextMenu) { Add-UiDataGridRowContextMenuItems -DataGrid $dataGrid -Items $RowContextMenu } # FrozenColumnCount lands AFTER the column build - WPF resets it to zero on each column add. if ($FrozenColumns -gt 0) { $dataGrid.FrozenColumnCount = $FrozenColumns Add-UiDataGridFrozenColumnAccent -DataGrid $dataGrid -FrozenColumns $FrozenColumns # Frozen columns can't be dragged out of the freeze range... foreach ($col in $dataGrid.Columns) { if ($col.DisplayIndex -lt $FrozenColumns) { $col.CanUserReorder = $false } } # CanUserReorder=false doesn't stop nonfrozen columns landing inside the freeze range. # Snap them back out. ColumnReordered (not ColumnDisplayIndexChanged) so no recursion. $frozenCountForHandler = $FrozenColumns $dataGrid.Add_ColumnReordered({ param($sender, $eventArgs) $col = $eventArgs.Column if ($null -ne $col -and $col.DisplayIndex -lt $frozenCountForHandler) { $col.DisplayIndex = $frozenCountForHandler } }.GetNewClosure()) } [PsUi.ThemeEngine]::RegisterElement($dataGrid) if (!$NoAlternatingRowBrush) { Add-UiDataGridAlternatingBrush -DataGrid $dataGrid } # -CaptureScrollWheel predates -ScrollWheel and only ever meant Capture. $wheelMode = if ($CaptureScrollWheel) { 'Capture' } else { $ScrollWheel } # -Fill leaves the page almost no scroll of its own for Page mode to hand the wheel to. if ($Fill -and $wheelMode -eq 'Page' -and !$PSBoundParameters.ContainsKey('ScrollWheel')) { $wheelMode = 'Edge' } Set-UiWheelRouting -Control $dataGrid -Mode $wheelMode $useToolbar = !$NoToolbar -and !($NoFilter -and $NoExport -and $NoCopy -and $NoColumnPicker) # Overlay wraps the DataGrid, not the dock panel. Otherwise the icon floats over the toolbar instead of the column header band. $gridArea = $dataGrid if (![string]::IsNullOrEmpty($EmptyMessage)) { $gridArea = Add-UiDataGridEmptyOverlay -HostControl $dataGrid -DataGrid $dataGrid -Message $EmptyMessage } $hostControl = $gridArea if ($useToolbar) { # Captures the live collection. Both paths are reference types, so the provider always sees current data. $collectionRef = $collection $itemsProvider = { $collectionRef }.GetNewClosure() $toolbarParams = @{ DataGrid = $dataGrid Colors = $colors NoFilter = $NoFilter NoExport = $NoExport NoCopy = $NoCopy NoColumnPicker = $NoColumnPicker AllProperties = $colInfo.AllProperties DefaultProperties = $colInfo.DefaultProperties PopulatedProperties = $colInfo.PopulatedProperties ItemsProvider = $itemsProvider } $tb = New-UiDataGridToolbar @toolbarParams if ($tb.FilterBox) { # Stash the FilterBox on Tag so Add-UiDataGridEditHandling can reach FilterBox.Tag.ClearSearchCache after a row commit. Without this key the filter keeps matching the old text and the edited row vanishes from a filtered view. if ($dataGrid.Tag -is [hashtable]) { $dataGrid.Tag.FilterBox = $tb.FilterBox } New-UiDataGridFilterController -DataGrid $dataGrid -FilterBox $tb.FilterBox | Out-Null } $dock = [System.Windows.Controls.DockPanel]@{ LastChildFill = $true Margin = [System.Windows.Thickness]::new(4, 4, 4, 8) } [System.Windows.Controls.DockPanel]::SetDock($tb.Container, 'Top') [void]$dock.Children.Add($tb.Container) [void]$dock.Children.Add($gridArea) $hostControl = $dock } else { $gridArea.Margin = [System.Windows.Thickness]::new(4, 4, 4, 8) } Set-FullWidthConstraint -Control $hostControl -Parent $parent -FullWidth:$FullWidth # -WPFProperties hits whichever container is onscreen (toolbar host or the grid on its own). if ($WPFProperties) { Set-UiProperties -Control $hostControl -Properties $WPFProperties } [void]$parent.Children.Add($hostControl) # Attach the fill helper to the outer host. With a toolbar that's the DockPanel - toolbar docks Top, DataGrid (LastChildFill) takes whatever's left. Without a toolbar, $hostControl is the grid (or its empty overlay wrap). if ($Fill) { Set-UiFillParentHeight -Control $hostControl -MaxHeight $MaxFillHeight -MinHeight $MinFillHeight } if ($EnabledWhen) { Register-UiCondition -TargetControl $hostControl -Condition $EnabledWhen } if ($DefaultSort) { Add-UiDataGridDefaultSort -DataGrid $dataGrid -Sort $DefaultSort } # Register the grid (not the host) for hydration and Set/Add/Clear lookup Register-UiControlComplete -Name $Variable -Control $dataGrid $session.RegisterListCollection($Variable, $collection) } } |