public/output/Out-Datagrid.ps1

function Out-Datagrid {
    <#
    .SYNOPSIS
        Like Out-GridView, but you can read it and theme it.
    .DESCRIPTION
        Pipe objects in and receive a sortable filterable grid. Add -PassThru and you get the
        selected rows back on OK. Closing the window or clicking Cancel returns nothing.
 
        Filter, sort, copy, export to CSV, and a column picker are in the toolbar.
 
        Opens the window in place when the caller can host one directly (ISE, PsUi -Sync
        actions). From a console session that can't, spins up a dedicated UI host and shows
        the window there. Either way the call blocks until you close it.
 
        Can't be called from inside an async button action. Use -NoAsync on the button.
    .PARAMETER Data
        The objects to show. Take from the pipeline or pass directly.
    .PARAMETER TitleText
        Window title.
    .PARAMETER IsFilterable
        Show the filter textbox.
    .PARAMETER PassThru
        Return the selected rows on OK.
    .PARAMETER OutputMode
        How many rows can be selected: None, Single, or Multiple (default - Ctrl/Shift to extend).
    .PARAMETER Theme
        Color theme. See Get-UiThemeTemplate for the list.
    .PARAMETER Width
        Window width in pixels (400-2000).
    .PARAMETER Height
        Window height in pixels (300-1500).
    .PARAMETER IconFont
        Which icon font to use for the toolbar: Inherit (default), Auto, SegoeMDL2 (Win10),
        or SegoeFluentIcons (Win11). Only matters when this is the top-level window. When
        hosted inside another PsUi window, that window's font wins.
    .PARAMETER NoIconFontFallback
        Don't fall back to other icon fonts for missing glyphs. Mostly useful for tightening
        Tab completion on -Icon parameters.
    .PARAMETER RowBackground
        Scriptblock that colors rows. Returns a color string (e.g. '#33FF6B6B') or $null.
        `$_` is the row inside the scriptblock. Runs as rows scroll into view.
    .PARAMETER DefaultSort
        Sort the grid before showing it. Accepts:
          - 'PropName'
          - 'PropName -Descending'
          - @{ Property = 'PropName'; Direction = 'Descending' }
          - an array of any of the above for multi-key sorting
    .PARAMETER NoSafeWrap
        Skip the protective wrapping done on input items. Faster, but if a property getter
        on one of your objects throws, the whole grid blows up.
    .EXAMPLE
        Get-Process | Out-Datagrid -TitleText 'Processes' -IsFilterable
    .EXAMPLE
        Get-Service | Out-Datagrid -PassThru | Restart-Service
    .EXAMPLE
        Get-ChildItem | Out-Datagrid -PassThru -OutputMode Single
    .EXAMPLE
        $rowBg = { if ($_.Status -eq 'Stopped') { '#33FF6B6B' } }
        $dgSplat = @{ RowBackground = $rowBg; DefaultSort = 'Status' }
        Get-Service | Out-Datagrid @dgSplat
    #>

    [CmdletBinding()]
    param(
        [Parameter(ValueFromPipeline)]
        [object[]]$Data,

        [Alias('Title')]
        [string]$TitleText = 'Data Grid',

        [switch]$IsFilterable,

        [switch]$PassThru,

        [ValidateSet('None', 'Single', 'Multiple')]
        [string]$OutputMode = 'Multiple',

        [ArgumentCompleter({ [PsUi.ThemeEngine]::GetAvailableThemes() })]
        [string]$Theme = 'Light',

        [ValidateRange(400, 2000)]
        [int]$Width = 900,

        [ValidateRange(300, 1500)]
        [int]$Height = 600,

        [ValidateSet('Inherit', 'Auto', 'SegoeMDL2', 'SegoeFluentIcons')]
        [string]$IconFont = 'Inherit',

        [switch]$NoIconFontFallback,

        [scriptblock]$RowBackground,

        $DefaultSort,

        [switch]$NoSafeWrap
    )

    begin {
        $accumulated = [System.Collections.Generic.List[object]]::new()
    }

    process {
        # $null -ne, not truthiness: a piped 0/''/$false binds as a one element array that PS evaluates falsy.
        if ($null -ne $Data) {
            foreach ($item in $Data) { [void]$accumulated.Add($item) }
        }
    }

    end {
        if ($accumulated.Count -eq 0) {
            Write-Warning 'No data to display'
            return
        }

        # ShowDialog needs the UI thread. Match Out-CSVDataGrid's stance and bounce with a clear error rather than freezing your button action partway through.
        if ([PsUi.AsyncExecutor]::CurrentExecutor) {
            Write-Error 'Out-Datagrid cannot be called from an async button action (ShowDialog requires the UI thread). Use -NoAsync on the button.'
            return
        }

        $effectiveSelectionMode = switch ($OutputMode) {
            'Single' { 'Single' }
            'None'   { 'None' }
            default  { 'Extended' }
        }

        # Carry everything the worker needs. Hashtables travel tghrough runspace boundaries by reference so OK button writes to SharedResult are visible in this scope after the worker returns.
        $sharedResult = @{ OK = $false; Selection = $null }
        $ctx = @{
            Items               = $accumulated
            TitleText           = $TitleText
            Width               = $Width
            Height              = $Height
            Theme               = $Theme
            IconFont            = $IconFont
            NoIconFontFallback  = $NoIconFontFallback.IsPresent
            IsFilterable        = $IsFilterable.IsPresent
            PassThru            = $PassThru.IsPresent
            EffSelMode          = $effectiveSelectionMode
            RowBackground       = $RowBackground
            DefaultSort         = $DefaultSort
            NoSafeWrap          = $NoSafeWrap.IsPresent
            ThemeBound          = $PSBoundParameters.ContainsKey('Theme')
            IconFontBound       = $PSBoundParameters.ContainsKey('IconFont')
            NoIconFontFbBound   = $PSBoundParameters.ContainsKey('NoIconFontFallback')
            SharedResult        = $sharedResult
        }

        # For the injected function cleanup inside buildAndShow. $PSCmdlet.SessionState is the calling script's (outside the module) session state. Removing a global function that shadows a module private one only works from a session state outside the module - a plain Remove-Item in module scope hits the shadow and silently leaks the global. Null inside the spawned MTA runspace, which is disposed anyway.
        $callerSessionState = $PSCmdlet.SessionState
        $fnRemover = [scriptblock]::Create("Remove-Item -LiteralPath ('Function:' + `$args[0]) -Force -ErrorAction SilentlyContinue")

        # Window construction + ShowDialog packaged as one closure. Runs inline when the calling script is STA (keeps its UI thread the same as the Application's so theme switches don't cross threads), shipped onto a spawned STA runspace when it's MTA.
        $buildAndShow = {
            param($context)

            # Create the WPF Application on this thread (the one that will ShowDialog) before any theme call. Does nothing if one exists. Without it, Initialize-UITheme below would create the App via ThemeEngine, which no longer does so - and a later New-UiWindow that inherited a cross thread App would lose all content theming. Must live inside the closure: the MTA path reparses this from a string and runs it on a fresh STA thread.
            [void][PsUi.ThemeEngine]::EnsureApplication()

            # Inheriting from parent's __WPFThemeColors only makes sense in a runspace that HAS it in scope. The spawned STA runspace path won't.
            $isStandalone = !(Test-Path variable:__WPFThemeColors)
            $themeName    = $context.Theme
            if (!$context.ThemeBound) {
                $active = [PsUi.ModuleContext]::ActiveTheme
                if (![string]::IsNullOrWhiteSpace($active)) { $themeName = $active }
            }
            if ($isStandalone) {
                $colors = Initialize-UITheme -Theme $themeName
            }
            else {  $colors = Get-Variable -Name __WPFThemeColors -ValueOnly -ErrorAction SilentlyContinue    }
            if (!$colors) { $colors = Initialize-UITheme -Theme $themeName }

            # Synthesise BoundParameters for Push-UiIconFontOverride from the bound flags.
            $bp = @{}
            if ($context.IconFontBound)     { $bp['IconFont']           = $context.IconFont }
            if ($context.NoIconFontFbBound) { $bp['NoIconFontFallback'] = $context.NoIconFontFallback }
            $overrideParams = @{
                IsStandalone       = $isStandalone
                BoundParameters    = $bp
                IconFont           = $context.IconFont
                NoIconFontFallback = [bool]$context.NoIconFontFallback
            }
            $iconFontSnap = Push-UiIconFontOverride @overrideParams

            # Full teardown on the error path. No try/finally around the build+ShowDialog below: this closure runs outside the pipeline (inline STA branch, called from a -NoAsync click delegate) where PS's CheckActionPreference NREs on try block exit. The tail block after ShowDialog mirrors this for the success path. Guards cover an early throw before the session exists.
            trap {
                foreach ($fnName in $injectedFns) {
                    if ($callerSessionState) { $ExecutionContext.InvokeCommand.InvokeScript($callerSessionState, $fnRemover, @($fnName)) }
                }
                if ($null -ne $sessionId -and $sessionId -ne [Guid]::Empty) { [PsUi.SessionManager]::DisposeSession($sessionId)  }
                if ($null -ne $priorSessionId -and $priorSessionId -ne [Guid]::Empty) { [PsUi.SessionManager]::SetCurrentSession($priorSessionId) }
                
                if ($null -ne $priorGlobalId) {  $Global:__PsUiSessionId = $priorGlobalId  }
                else { Remove-Variable -Name __PsUiSessionId -Scope Global -ErrorAction SilentlyContinue }
                
                if ($iconFontSnap) {
                    [PsUi.ModuleContext]::RestoreIconFontState($iconFontSnap)
                    $iconFontSnap = $null
                }

                break
            }

            $window = [System.Windows.Window]@{
                Title                 = $context.TitleText
                Width                 = $context.Width
                Height                = $context.Height
                MinWidth              = 400
                MinHeight             = 300
                WindowStartupLocation = 'CenterScreen'
                FontFamily            = [System.Windows.Media.FontFamily]::new('Segoe UI')
                ResizeMode            = 'CanResizeWithGrip'
            }

            # Pin owner / center BEFORE the session swap below - after it $session.Window is THIS window, and CenterOnParent($window, $window) NullRefs reading the bounds of an unshown self owner.
            if (!$isStandalone) {
                Set-UiDialogPosition -Dialog $window
            }
            else {
                $null = Set-WindowOwner -Window $window
            }

            $window.SetResourceReference([System.Windows.Window]::BackgroundProperty, 'WindowBackgroundBrush')
            $window.SetResourceReference([System.Windows.Window]::ForegroundProperty, 'ControlForegroundBrush')

            Set-UIResources -Window $window -Colors $colors

            try {
                $appId = "PsUi.OutDatagrid." + [Guid]::NewGuid().ToString("N").Substring(0, 8)
                [PsUi.WindowManager]::SetWindowAppId($window, $appId)
            }
            catch { Write-Debug "SetWindowAppId failed: $_" }

            $datagridIcon = $null
            try {
                $datagridIcon = New-WindowIcon -Colors $colors
                if ($datagridIcon) { $window.Icon = $datagridIcon }
            }
            catch { Write-Verbose "Failed to create window icon: $_" }

            $capturedWindow = $window
            $capturedIcon   = $datagridIcon
            $window.Add_Loaded({
                if ($capturedIcon) {
                    try { [PsUi.WindowManager]::SetTaskbarIcon($capturedWindow, $capturedIcon) }
                    catch { Write-Debug "SetTaskbarIcon failed: $_" }
                }
            }.GetNewClosure())

            $mainPanel = [System.Windows.Controls.DockPanel]@{ LastChildFill = $true }
            $window.Content = $mainPanel

            $headerBorder = [System.Windows.Controls.Border]@{
                Padding = [System.Windows.Thickness]::new(16, 12, 16, 12)
                Tag     = 'HeaderBorder'
            }
            $headerBorder.SetResourceReference([System.Windows.Controls.Border]::BackgroundProperty, 'HeaderBackgroundBrush')
            [System.Windows.Controls.DockPanel]::SetDock($headerBorder, 'Top')

            $headerGrid = [System.Windows.Controls.Grid]::new()
            [void]$headerGrid.ColumnDefinitions.Add([System.Windows.Controls.ColumnDefinition]@{  Width = [System.Windows.GridLength]::new(1, [System.Windows.GridUnitType]::Star) })
            [void]$headerGrid.ColumnDefinitions.Add([System.Windows.Controls.ColumnDefinition]@{  Width = [System.Windows.GridLength]::Auto  })

            $headerStack = [System.Windows.Controls.StackPanel]@{ Orientation = 'Horizontal' }
            [System.Windows.Controls.Grid]::SetColumn($headerStack, 0)

            $headerIcon = [System.Windows.Controls.TextBlock]@{
                Text              = [PsUi.ModuleContext]::GetIcon('GridView')
                FontFamily        = [PsUi.ModuleContext]::ActiveIconFontFamily
                FontSize          = 24
                VerticalAlignment = 'Center'
                Width             = 32
                TextAlignment     = 'Center'
                Margin            = [System.Windows.Thickness]::new(0, 0, 12, 0)
                Tag               = 'HeaderText'
            }
            $headerIcon.SetResourceReference([System.Windows.Controls.TextBlock]::ForegroundProperty, 'HeaderForegroundBrush')
            [void]$headerStack.Children.Add($headerIcon)

            $headerTitle = [System.Windows.Controls.TextBlock]@{
                Text              = $context.TitleText
                FontSize          = 18
                FontWeight        = [System.Windows.FontWeights]::SemiBold
                VerticalAlignment = 'Center'
                Tag               = 'HeaderText'
            }
            $headerTitle.SetResourceReference([System.Windows.Controls.TextBlock]::ForegroundProperty, 'HeaderForegroundBrush')
            [void]$headerStack.Children.Add($headerTitle)

            [void]$headerGrid.Children.Add($headerStack)

            if ($isStandalone) {
                $themeButtonData = New-ThemePopupButton -Container $window -CurrentTheme $themeName
                [System.Windows.Controls.Grid]::SetColumn($themeButtonData.Button, 1)
                [void]$headerGrid.Children.Add($themeButtonData.Button)
            }

            $headerBorder.Child = $headerGrid
            [void]$mainPanel.Children.Add($headerBorder)

            $bodyGrid = [System.Windows.Controls.Grid]@{  Margin = [System.Windows.Thickness]::new(12)  }
            [void]$bodyGrid.RowDefinitions.Add([System.Windows.Controls.RowDefinition]@{  Height = [System.Windows.GridLength]::new(1, [System.Windows.GridUnitType]::Star) })
            [void]$bodyGrid.RowDefinitions.Add([System.Windows.Controls.RowDefinition]@{  Height = [System.Windows.GridLength]::Auto })
            [void]$mainPanel.Children.Add($bodyGrid)

            $gridHost = [System.Windows.Controls.Grid]::new()
            [System.Windows.Controls.Grid]::SetRow($gridHost, 0)
            [void]$bodyGrid.Children.Add($gridHost)

            $buttonBar = [System.Windows.Controls.StackPanel]@{
                Orientation         = 'Horizontal'
                HorizontalAlignment = 'Right'
                Margin              = [System.Windows.Thickness]::new(0, 8, 0, 0)
            }
            [System.Windows.Controls.Grid]::SetRow($buttonBar, 1)
            [void]$bodyGrid.Children.Add($buttonBar)

            # Get-UiSession checks $Global:__PsUiSessionId BEFORE SessionManager.Current. Without an override on the global, New-UiDataGrid finds the parent's session and adds the grid to the parent's CurrentParent (the wrong window) instead of this gridHost.
            $priorSessionId = [PsUi.SessionManager]::CurrentSessionId
            $priorGlobalId  = if (Test-Path variable:Global:__PsUiSessionId) { $Global:__PsUiSessionId } else { $null }

            $sessionId = [PsUi.SessionManager]::CreateSession()
            [PsUi.SessionManager]::SetCurrentSession($sessionId)
            $Global:__PsUiSessionId = $sessionId.ToString()

            $session = [PsUi.SessionManager]::Current
            $session.Window        = $window
            $session.CurrentParent = $gridHost

            # Grid handlers and the OK button are GetNewClosure scriptblocks - WPF resolves their calls against the runspace GLOBAL scope. New-UiWindow injects private functions for exactly this. These paths get none, so copy/export/PassThru died CommandNotFound.
            $injectedFns = [System.Collections.Generic.List[string]]::new()
            $privFns     = [PsUi.ModuleContext]::PrivateFunctions
            if ($privFns) {
                foreach ($fnName in @($privFns.Keys)) {
                    # Add only what's missing so cleanup can't take out a preexisting global
                    if (!(Test-Path -LiteralPath "function:global:$fnName")) {
                        Set-Item -LiteralPath "function:global:$fnName" -Value ([scriptblock]::Create([string]$privFns[$fnName]))
                        [void]$injectedFns.Add($fnName)
                    }
                }
            }

            $gridArgs = @{
                Items              = $context.Items
                Variable           = 'picked'
                SelectionMode      = $context.EffSelMode
                FullWidth          = $true
                CaptureScrollWheel = $true
            }
            if (!$context.IsFilterable) { $gridArgs.NoFilter      = $true }
            if ($context.RowBackground) { $gridArgs.RowBackground = $context.RowBackground }
            if ($context.DefaultSort)   { $gridArgs.DefaultSort   = $context.DefaultSort }
            if ($context.NoSafeWrap)    { $gridArgs.NoSafeWrap    = $true }

            New-UiDataGrid @gridArgs

            $session.CurrentParent = $buttonBar

            # Direct refs for the click actions - Get-UiSession comes back null in WPF click scopes (and isn't resolvable at all on the inline path), which made (Get-UiSession).Window.Close() NullRef at OK time.
            $winRef    = $window
            $resultRef = $context.SharedResult
            $gridRef   = $session.GetControl('picked')

            if ($context.PassThru) {
                New-UiButton -Text 'OK' -NoAsync -Action {
                    # -NoAsync skips variable hydration so $picked is empty - read SelectedItems off the captured grid ref, then close via the captured window ref.
                    try {
                        if ($gridRef -and $resultRef) {
                            $resultRef.OK        = $true
                            $resultRef.Selection = @($gridRef.SelectedItems | ForEach-Object {
                                if ($null -ne $_._BaseObject) { $_._BaseObject } else { $_ }
                            })
                        }
                    }
                    catch { Write-Debug "Out-Datagrid OK action selection capture failed: $_" }
                    if ($winRef) { $winRef.Close() }
                }.GetNewClosure()
                New-UiButton -Text 'Cancel' -NoAsync -Action {
                    if ($winRef) { $winRef.Close() }
                }.GetNewClosure()
            }
            else {
                New-UiButton -Text 'Close' -NoAsync -Action {
                    if ($winRef) { $winRef.Close() }
                }.GetNewClosure()
            }

            for ($i = 1; $i -lt $buttonBar.Children.Count; $i++) {
                $buttonBar.Children[$i].Margin = [System.Windows.Thickness]::new(8, 0, 0, 0)
            }

            [void]$window.ShowDialog()

            # Success path teardown, mirroring the trap above. Plain statements, not a finally - outside the pipeline the finally would NRE on exit. The trap covers the error path.
            foreach ($fnName in $injectedFns) {
                # A plain Remove-Item runs in module scope and hits the module's own shadow, never the injected global copy (leaks). Run it in the calling session state (outside the module) where Function:name resolves to the global one. Null in the spawned MTA runspace (disposed).
                if ($callerSessionState) { $ExecutionContext.InvokeCommand.InvokeScript($callerSessionState, $fnRemover, @($fnName)) }
            }
            [PsUi.SessionManager]::DisposeSession($sessionId)
            if ($priorSessionId -ne [Guid]::Empty) {  [PsUi.SessionManager]::SetCurrentSession($priorSessionId)   }
            
            if ($null -ne $priorGlobalId) {  $Global:__PsUiSessionId = $priorGlobalId  }
            else {  Remove-Variable -Name __PsUiSessionId -Scope Global -ErrorAction SilentlyContinue  }
            
            if ($iconFontSnap) {
                [PsUi.ModuleContext]::RestoreIconFontState($iconFontSnap)
                $iconFontSnap = $null
            }

            # Emit the final result after cleanup. The MTA path reads this through $ps.Invoke() output. Cross runspace AddArgument marshaling of $context.SharedResult is within the same process so the hashtable mutation usually round-trips, but emit is the contract.
            [PSCustomObject]@{
                OK        = [bool]$context.SharedResult.OK
                Selection = $context.SharedResult.Selection
            }
        }

        # The build scriptblock owns its cleanup. This catch logs breadcrumbs and rethrows.
        # Swallowing made any build/ShowDialog failure look exactly like Cancel - no window, no error, -PassThru empty. A mistitled parent error popup beats silent.
        $emitted = $null
        try {
            if ([System.Threading.Thread]::CurrentThread.GetApartmentState() -eq [System.Threading.ApartmentState]::STA) {
                $emitted = & $buildAndShow $ctx
            }
            else {
                # MTA host (typically pwsh.exe console). Window construction requires STA - spawn one. No parent UI thread to block in this case so the original cross thread theme bug doesn't apply.
                $modulePath = (Get-Module -Name PsUi).Path
                if (!$modulePath) {
                    Write-Error "Out-Datagrid: PsUi module path not resolvable; can't spawn STA runspace."
                    return
                }

                $rs = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace()
                $rs.ApartmentState = [System.Threading.ApartmentState]::STA
                $rs.ThreadOptions  = [System.Management.Automation.Runspaces.PSThreadOptions]::UseNewThread
                $rs.Open()
                $ps = $null
                try {
                    $ps = [System.Management.Automation.PowerShell]::Create()
                    $ps.Runspace = $rs
                    [void]$ps.AddCommand('Import-Module').AddArgument($modulePath).AddParameter('Force', $true)
                    [void]$ps.Invoke()
                    $ps.Commands.Clear()

                    # Pass the scriptblock as a string - ScriptBlock objects are pinned to their creation runspace. The $wrapper reparses it inside the module's session state so private functions resolve. Otherwise the first private call dies.
                    $wrapper = {
                        param($scriptText, $context)
                        # Two PsUi modules load in the spawned runspace: binary (PsUi.dll) and script (PsUi.psm1). Only the script one can be invoked with & - the binary throws "Cannot use '&' to invoke in the context of binary module".
                        $mod = Get-Module PsUi | Where-Object { $_.ModuleType -eq 'Script' } | Select-Object -First 1
                        if (!$mod) { throw "PsUi script module not loaded in spawned runspace" }
                        & $mod {
                            param($text, $ctx)
                            $sb = [scriptblock]::Create($text)
                            & $sb $ctx
                        } $scriptText $context
                    }
                    [void]$ps.AddScript($wrapper.ToString()).AddArgument($buildAndShow.ToString()).AddArgument($ctx)
                    $emitted = $ps.Invoke()

                    foreach ($errorRecord in $ps.Streams.Error) { $Host.UI.WriteErrorLine($errorRecord.ToString()) }
                }
                finally {
                    if ($ps) { $ps.Dispose() }
                    $rs.Close()
                    $rs.Dispose()
                }
            }
        }
        catch {
            Write-Debug "Out-Datagrid dispatch caught: $($_.Exception.GetType().Name): $($_.Exception.Message)"
            Write-Debug "Stack: $($_.ScriptStackTrace)"
            throw
        }

        # Pick the emitted result object out of whatever buildAndShow streamed. Picks last so any intermediate writes from inner code don't take precedence over the final summary.
        $result = $emitted |
            Where-Object { $_ -is [System.Management.Automation.PSObject] -and $_.PSObject.Properties['OK'] } |
            Select-Object -Last 1

        if ($PassThru -and $result -and $result.OK) { return $result.Selection }
    }
}