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 WPF events fire 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.
        Long story short, it'll accept *most* generally used PowerShell lists.
        Accepted inputs:
          - any list like collection: array, ArrayList, List<T>, ObservableCollection, the
            PsUi async collection. All get wrapped and attached, PsUi walks the calling scope
            and repoints every variable holding the original at the new wrap.
          - [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 [ref] (or skip the variable bind entirely with -NoBind).
        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. Mutations from
        outside the wrap won't reach the grid. Drive it through [ref] or the
        Set-/Add-/Clear-UiDataGridItems helpers. 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
          - hashtable[]: full control. Each hashtable 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. Set Sync = $true on the column hashtable for actions that
            have to stay on the UI thread (dialogs or clipboard work). Link cells default to
            http/https/mailto/tel schemes only. Set AllowFileScheme = $true on the column
            hashtable to permit file: URLs (off by default because {Prop} substitution into
            a file: template lets row content launch arbitrary executables).
    .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. Two -Fill grids in the same panel split
        unevenly (first one claims, second gets leftovers). For an even split, wrap them in
        New-UiGrid -Rows '*,*' -Fill so the row layout handles the split natively.
    .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
        Respect $item.PSStandardMembers.DefaultDisplayPropertySet, hiding non-default props
        initially. Hidden ones come back via the column-picker. Off by default for embedded
        grids - showing everything is usually what makes sense there.
    .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 get a themed editor and write back to the
        underlying property. Percolumn Editable=$false in a column hashtable wins.
    .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 mouse-wheel events inside the grid instead of getting grabbed by the parent. The PsUi
        default lets the wheel reach the outer window so it scrolls under the cursor. Turn this
        on for grids tall enough or with enough meaningful data to need their own scroll.
    .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) - 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: -RowDetailsTemplate { New-UiLabel -Text $_.Description; New-UiLabel -Text $_.Notes}
        Example: -RowDetailsTemplate { 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 hashtable mapping a label to an action. The action is either:
          - a scriptblock: { Restart-Service $_.Name }
          - a hashtable: @{ Action = {}; Enabled = {} or $bool; Icon = 'Name'; Sync = $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. Enabled is any passes
        for menu enablement (menu stays enabled while at least one selected row qualifies)
        and per-row during the fan-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.). Set Sync = $true for actions
        that have to stay on the UI thread (Show-UiMessageDialog or clipboard stuff).
        Background action variable capture: PsUi grabs the values of variables your action
        mentions by name and copies them into the background runspace. A local secret with a
        name that collides with something in the action body ($cred is the common) gets
        copied too. Set Sync = $true to skip the variable copy.
        Example: -RowContextMenu @{
            'Restart' = @{
                Action = { Restart-Service $_.Name }
                Icon = 'Refresh'
                Enabled = { $_.Status -eq 'Stopped' }
            }
            'Details' = { Show-UiMessageDialog -Message ($_ | Format-List | Out-String) }
        }
    .PARAMETER SanitizeFormulas
        Prefix exported / copied cells whose first character is =/+/-/@/tab/CR 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 | Select Name, Status, StartType) -Editable -Columns @(
            @{ Name='Name'; ReadOnly=$true }
            @{ Name='Status'; Editable=$true }
            @{ Name='StartType'; Editable=$true; EditorType='ComboBox'; Choices='Automatic','Manual','Disabled' }
            @{ 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
        # Cell-embedded controls: Toggle (two-way), Button (per-row), Link (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 @(
            @{ Name='Name'; ReadOnly=$true }
            @{ Header='Enabled'; Type='Toggle'; Binding='Online'; OnChange={ Write-Host "$($_.Name) -> $($_.Online)" } }
            @{ Header='Ping'; Type='Button'; Text='Ping'; Icon='NetworkAdapter'; Action={ Test-Connection $_.Name -Count 1 } }
            @{ Header='Open'; Type='Link'; Text='Open'; Url='{Url}' }
        )
    .EXAMPLE
        # Live updates via -ItemsSource. Bare ArrayList works because PsUi binds $rows to
        # the wrap - $rows after the call IS the thread-safe collection, 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' -Action {
                [void]$rows.Add([pscustomobject]@{ Time = Get-Date; Value = Get-Random })
            }
        }
    .NOTES
        Variable binding: -ItemsSource wraps your collection in a thread-safe 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. Set Sync = $true on
        the column / menu hashtable for actions that have to stay on the UI thread (dialogs
        and clipboard work).
 
        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.
    #>

    [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,

        # IDictionary, not [hashtable]: a [hashtable] constraint silently converts [ordered]@{} to Hashtable and scrambles the declared menu order.
        [System.Collections.IDictionary]$RowContextMenu,

        [switch]$SanitizeFormulas
    )

    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 caller's collection is bound as-is; 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"

        # Resolve the backing collection
        $collection     = $null
        $isOwned        = $false
        $mirrorAttached = $false

        if ($PSBoundParameters.ContainsKey('ItemsSource')) {
            $uiDispatcher = [System.Windows.Threading.Dispatcher]::CurrentDispatcher

            # PS 5.1 $obj -is [Open`1] throws "Late bound operations cannot be performed on fields with types for which Type.ContainsGenericParameters is true."
            # Check the inheritance and match the generic type definition by FullName instead.
            $kindOf = {
                param($Obj)
                if ($null -eq $Obj)                                             { return 'Null' }
                if ($Obj -is [System.Management.Automation.PSReference])        { return 'Ref' }
                $t = $Obj.GetType()
                while ($null -ne $t) {
                    if ($t.IsGenericType) {
                        $def = $t.GetGenericTypeDefinition().FullName
                        if ($def -eq 'PsUi.AsyncObservableCollection`1')                  { return 'PsUiObservable' }
                        if ($def -eq 'System.Collections.ObjectModel.ObservableCollection`1') { return 'WpfObservable' }
                    }
                    $t = $t.BaseType
                }
                return 'Other'
            }

            # Plain ol' ObservableCollection<T> isn't threadsafe. Helpers fired from async button actions live on a background runspace. Calling .Add() on the user's bare 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.
            $itemsSourceKind = & $kindOf $ItemsSource
            switch ($itemsSourceKind) {
                '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
                    $origKind = & $kindOf $orig
                    if ($origKind -eq 'PsUiObservable') {
                        try { $orig.UpdateDispatcher() } catch { Write-Debug "UpdateDispatcher failed: $_" }
                        $collection = $orig
                    }
                    else {
                        $wrapper           = [PsUi.AsyncObservableCollection[object]]::new($orig, $uiDispatcher)
                        $ItemsSource.Value = $wrapper
                        $collection        = $wrapper
                    }
                    break
                }

                # Value type collection (ArrayList, List<T>, array, anything else IEnumerable).
                  default {
                    $wrapper = [PsUi.AsyncObservableCollection[object]]::new($ItemsSource, $uiDispatcher)
                    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. Null branch has nothing to wrap.
            if (!$NoBind -and
                $itemsSourceKind -in 'WpfObservable', 'Other' -and
                $null -ne $collection -and
                !([object]::ReferenceEquals($collection, $ItemsSource))) {

                $promotedNames = [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 slot 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
                                [void]$promotedNames.Add("`$$($psVar.Name)@$scopeIdx")
                            }
                            catch { Write-Debug "Variable bind rewrite of '$($psVar.Name)' at scope $scopeIdx failed: $_" }
                        }
                    }
                }
                if ($promotedNames.Count -gt 0) {
                    Write-Debug "Variables bound: $($promotedNames -join ', ')"
                }
                else {
                    Write-Debug "Variables bound: nothing matched"
                    # Zero rewrite usually means -ItemsSource got fed a property access ($obj.Items) or an unbound expression (eg Get-Garbage). The grid still binds against the wrap but you have no handle - Add-/Set-/Clear-UiDataGridItems and outside $list.Add() drop on the floor with no obvious clue why.
                    Write-Warning 'New-UiDataGrid -ItemsSource: could not repoint any caller 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
                    $itemsSourceKind -in 'WpfObservable', 'Other' -and
                    $null -ne $collection -and
                    !([object]::ReferenceEquals($collection, $ItemsSource))) {
                # You asked for explicit binding management. Skip the scope rewrite. The wrap is still attached to the grid, but your $list keeps pointing at the original.
                Write-Debug "Variable bind skipped (-NoBind). Caller's 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 PowerShell 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.
        # Suppressed when -DefaultPropertiesOnly is on or -Columns was handpicked. PSCustomObject input has no DefaultDisplayPropertySet ($defaultCount = 0), so the dialog drops the "Load defaults" option and offers cancel only.
        $flood         = Get-UiGridFloodWarning
        $cellThreshold = $flood.CellThreshold
        $allCount      = [int]$colInfo.AllProperties.Count
        $defaultCount  = [int]$colInfo.DefaultProperties.Count
        $rowCount      = [int]$collection.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 bookkeeping the column picker reaches for on each visibility toggle.
        # StretchLastColumn is the gate that reruns Set-LastDataColumnStar, and it still honours the original -NoStretchLastColumn switch passed at construction.
        $dataGrid.Tag = @{
            AllProperties       = $colInfo.AllProperties
            DefaultProperties   = $colInfo.DefaultProperties
            PopulatedProperties = $colInfo.PopulatedProperties
            Collection          = $collection
            StretchLastColumn   = !$NoStretchLastColumn
            IsOwned             = $isOwned
            # New-UiTab's PreviewMouseWheel reads this and lets the wheel through when set.
            CaptureScrollWheel  = [bool]$CaptureScrollWheel
            # 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: nested .GetNewClosure() only captures the immediate scope's locals, not what the outer closure itself 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"
                        $items = [System.Collections.Generic.List[object]]::new()
                        foreach ($entry in $localState.Collection) { [void]$items.Add($entry) }
                        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.
                        # Unbound, there is no view processing to corrupt. Reattaching is a fresh bind, same as construction. Adds landing mid surgery are absorbed by it.
                        #
                        # 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.
                        # $seedGrid is a plain local on purpose. $rebind below closes over it, and an inner GetNewClosure only captures THIS scope's locals - reaching for $localState (an outer closure capture) in there hands back $null and the reattach silently does nothing, leaving the grid empty forever.
                        $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

                        # First arriving row had nothing readable - rearm so a later row can try.
                        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 }
                            }
                        }

                        # Known cosmetic: the last column star set during the unbound build renders at natural width after the rebind (dead space to its right). Some DataGrid internal width state doesn't reengage stars after an ItemsSource cycle - deferred star reapply and reactive arm suspension don't fix it, don't retry them. Freeze and filter behave. Leaving 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: PS .GetNewClosure() only captures 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
        }

        if ($OnSelectionChanged -and $SelectionMode -ne 'None') {
            $selHandler = $OnSelectionChanged
            $dataGrid.Add_SelectionChanged({
                param($sender, $eventArgs)
                $selected = @($sender.SelectedItems)
                try { & $selHandler $selected }
                catch { Write-Debug "OnSelectionChanged failed: $($_.Exception.Message)" }
            }.GetNewClosure())
        }

        if ($OnDoubleClick) {
            $dblHandler = $OnDoubleClick
            $dataGrid.Add_MouseDoubleClick({
                param($sender, $eventArgs)
                $row = $sender.SelectedItem
                if ($null -eq $row) { return }
                try { & $dblHandler $row }
                catch { Write-Debug "OnDoubleClick failed: $($_.Exception.Message)" }
            }.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 }

        # Default: forward the wheel to the parent so the outer window scrolls with the cursor over the grid.
        # -CaptureScrollWheel keeps it inside the grid for its own scroll.
        if (!$CaptureScrollWheel) {
            $dataGrid.Add_PreviewMouseWheel({
                param($sender, $eventArgs)
                if ($eventArgs.Handled) { return }
                $eventArgs.Handled = $true
                $newEvent = [System.Windows.Input.MouseWheelEventArgs]::new($eventArgs.MouseDevice, $eventArgs.Timestamp, $eventArgs.Delta)
                $newEvent.RoutedEvent = [System.Windows.UIElement]::MouseWheelEvent
                $newEvent.Source = $sender
                $parentEl = $sender.Parent -as [System.Windows.UIElement]
                if ($parentEl) { $parentEl.RaiseEvent($newEvent) }
            })
        }

        $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 bare grid).
        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)
    }
}