public/output/Out-Datagrid.ps1

function Out-Datagrid {
    <#
    .SYNOPSIS
        Displays data in a filterable, sortable grid - a better Out-GridView.
    .DESCRIPTION
        Creates a themed data grid window with filtering, sorting, export to CSV,
        copy to clipboard, and optional selection passthrough. Use -PassThru to
        return selected items when the window closes.
    .PARAMETER Data
        Data to display in the grid. Accepts pipeline input.
    .PARAMETER TitleText
        Window title.
    .PARAMETER IsFilterable
        Enable live text filtering.
    .PARAMETER PassThru
        Return selected items when the OK button is clicked.
    .PARAMETER OutputMode
        Selection mode: None, Single, or Multiple. Defaults to Multiple with PassThru.
    .PARAMETER Theme
        Color theme: Light, Dark, etc.
    .PARAMETER Width
        Window width (400-2000).
    .PARAMETER Height
        Window height (300-1500).
    .EXAMPLE
        Get-Process | Out-Datagrid -TitleText 'Processes' -IsFilterable
        # Display processes in a grid
    .EXAMPLE
        Get-Service | Out-Datagrid -PassThru | Restart-Service
        # Select services and restart them
    .EXAMPLE
        Get-ChildItem | Out-Datagrid -PassThru -OutputMode Single
        # Select a single file
    #>

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

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

    begin {
        # ShowDialog requires the UI thread - block async button actions from calling this
        if ([PsUi.AsyncExecutor]::CurrentExecutor) {
            Write-Error 'Out-DataGrid cannot be called from an async button action (ShowDialog requires the UI thread). Use -NoAsync on your button, or call this function outside the DSL.'
            return
        }

        Write-Debug "Starting with Title='$TitleText', Theme='$Theme', PassThru=$PassThru"

        # Helper to show themed dialogs
        function Show-ThemedDialog {
            param(
                [string]$Title,
                [string]$Message,
                [string]$Buttons = 'OK',
                [string]$Icon = 'Info'
            )
            Show-UiMessageDialog -Title $Title -Message $Message -Buttons $Buttons -Icon $Icon -ThemeColors $colors
        }

        $allData = [System.Collections.Generic.List[object]]::new()
        $result = @{ Selection = $null }
    }

    process {
        if ($Data) {
            foreach ($item in $Data) {
                [void]$allData.Add($item)
            }
        }
    }

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

        $isStandalone = !(Test-Path variable:__WPFThemeColors)
        Write-Debug "Context: isStandalone=$isStandalone, Items=$($allData.Count)"

        if ($isStandalone) {
            $colors = Initialize-UITheme -Theme $Theme
        }
        else {
            $colors = Get-Variable -Name __WPFThemeColors -ValueOnly -ErrorAction SilentlyContinue
        }

        if (!$colors) {
            $colors = Initialize-UITheme -Theme 'Light'
        }

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

        # Link to parent window for proper layering
        $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

        $appId = "PsUi.DataGrid." + [Guid]::NewGuid().ToString("N").Substring(0, 8)
        [PsUi.WindowManager]::SetWindowAppId($window, $appId)

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

        $overlayIcon = $null
        try {
            $gridGlyph = [PsUi.ModuleContext]::GetIcon('Grid')
            $overlayIcon = New-TaskbarOverlayIcon -GlyphChar $gridGlyph -Color $colors.Accent
            # Store glyph in resources for theme updates
            $window.Resources['OverlayGlyph'] = $gridGlyph
        }
        catch { Write-Debug "Taskbar overlay failed: $_" }

        $capturedGridWindow = $window
        $capturedGridIcon   = $gridWindowIcon
        $capturedOverlay    = $overlayIcon

        $window.Add_Loaded({
            if ($capturedGridIcon) {
                [PsUi.WindowManager]::SetTaskbarIcon($capturedGridWindow, $capturedGridIcon)
            }
            if ($capturedOverlay) {
                [PsUi.WindowManager]::SetTaskbarOverlay($capturedGridWindow, $capturedOverlay, 'Data')
            }
        }.GetNewClosure())

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

        # Header bar
        $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()
        $col1 = [System.Windows.Controls.ColumnDefinition]@{ Width = [System.Windows.GridLength]::new(1, [System.Windows.GridUnitType]::Star) }
        $col2 = [System.Windows.Controls.ColumnDefinition]@{ Width = [System.Windows.GridLength]::Auto }
        [void]$headerGrid.ColumnDefinitions.Add($col1)
        [void]$headerGrid.ColumnDefinitions.Add($col2)

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

        $headerIcon = [System.Windows.Controls.TextBlock]@{
            Text              = [PsUi.ModuleContext]::GetIcon('Grid')
            FontFamily        = [System.Windows.Media.FontFamily]::new('Segoe MDL2 Assets')
            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              = $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)

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

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

        # Content area
        $contentPanel = [System.Windows.Controls.DockPanel]@{
            Margin        = [System.Windows.Thickness]::new(12)
            LastChildFill = $true
        }
        [void]$mainPanel.Children.Add($contentPanel)

        # Filter toolbar
        $toolbar = [System.Windows.Controls.StackPanel]@{
            Orientation = 'Horizontal'
            Margin      = [System.Windows.Thickness]::new(0, 0, 0, 8)
        }
        [System.Windows.Controls.DockPanel]::SetDock($toolbar, 'Top')
        [void]$contentPanel.Children.Add($toolbar)

        $filterBox = $null
        if ($IsFilterable) {
            $filterLabel = [System.Windows.Controls.TextBlock]@{
                Text              = 'Filter:'
                VerticalAlignment = 'Center'
                Margin            = [System.Windows.Thickness]::new(0, 0, 8, 0)
            }
            $filterLabel.SetResourceReference([System.Windows.Controls.TextBlock]::ForegroundProperty, 'ControlForegroundBrush')
            [void]$toolbar.Children.Add($filterLabel)

            $filterBoxContainer = [System.Windows.Controls.Grid]@{
                Width             = 200
                Height            = 26
                VerticalAlignment = 'Center'
            }

            $filterBox = [System.Windows.Controls.TextBox]@{
                Height  = 26
                Padding = [System.Windows.Thickness]::new(4, 0, 20, 0)
            }
            Set-TextBoxStyle -TextBox $filterBox
            [void]$filterBoxContainer.Children.Add($filterBox)

            # Clear button overlaid on filter box
            $filterClearBtn = [System.Windows.Controls.Button]@{
                Content             = [PsUi.ModuleContext]::GetIcon('Cancel')
                FontFamily          = [System.Windows.Media.FontFamily]::new('Segoe MDL2 Assets')
                FontSize            = 10
                Width               = 16
                Height              = 16
                Padding             = [System.Windows.Thickness]::new(0)
                Margin              = [System.Windows.Thickness]::new(0, 0, 5, 0)
                HorizontalAlignment = 'Right'
                VerticalAlignment   = 'Center'
                Background          = [System.Windows.Media.Brushes]::Transparent
                BorderThickness     = [System.Windows.Thickness]::new(0)
                Cursor              = [System.Windows.Input.Cursors]::Hand
                Visibility          = 'Collapsed'
                ToolTip             = 'Clear'
                Tag                 = $filterBox
            }
            $filterClearBtn.SetResourceReference([System.Windows.Controls.Button]::ForegroundProperty, 'SecondaryTextBrush')
            $filterClearBtn.Add_Click({ $this.Tag.Text = ''; $this.Tag.Focus() }.GetNewClosure())
            [void]$filterBoxContainer.Children.Add($filterClearBtn)

            $filterBox.Tag = @{ ClearButton = $filterClearBtn }
            [void]$toolbar.Children.Add($filterBoxContainer)
        }

        # Button panel at bottom
        $buttonPanel = [System.Windows.Controls.StackPanel]@{
            Orientation         = 'Horizontal'
            HorizontalAlignment = 'Right'
            Margin              = [System.Windows.Thickness]::new(0, 8, 0, 0)
        }
        [System.Windows.Controls.DockPanel]::SetDock($buttonPanel, 'Bottom')
        [void]$contentPanel.Children.Add($buttonPanel)

        # Export button
        $exportBtn = [System.Windows.Controls.Button]@{
            Width   = 36
            Height  = 32
            Margin  = [System.Windows.Thickness]::new(0, 0, 8, 0)
            ToolTip = 'Export to CSV'
            Padding = [System.Windows.Thickness]::new(0)
        }
        $exportIcon = [System.Windows.Controls.TextBlock]@{
            Text                = [PsUi.ModuleContext]::GetIcon('SaveLocal')
            FontFamily          = [System.Windows.Media.FontFamily]::new('Segoe MDL2 Assets')
            FontSize            = 16
            HorizontalAlignment = 'Center'
            VerticalAlignment   = 'Center'
        }
        $exportBtn.Content = $exportIcon
        Set-ButtonStyle -Button $exportBtn
        [void]$buttonPanel.Children.Add($exportBtn)

        # Copy button
        $copyBtn = [System.Windows.Controls.Button]@{
            Width   = 36
            Height  = 32
            Margin  = [System.Windows.Thickness]::new(0, 0, 8, 0)
            ToolTip = 'Copy selected to clipboard'
            Padding = [System.Windows.Thickness]::new(0)
        }
        $copyIcon = [System.Windows.Controls.TextBlock]@{
            Text                = [PsUi.ModuleContext]::GetIcon('Copy')
            FontFamily          = [System.Windows.Media.FontFamily]::new('Segoe MDL2 Assets')
            FontSize            = 16
            HorizontalAlignment = 'Center'
            VerticalAlignment   = 'Center'
        }
        $copyBtn.Content = $copyIcon
        Set-ButtonStyle -Button $copyBtn
        [void]$buttonPanel.Children.Add($copyBtn)

        # OK button (only shown when PassThru)
        $okBtn = $null
        if ($PassThru) {
            $okBtn = [System.Windows.Controls.Button]@{
                Content = 'OK'
                Width   = 80
                Height  = 32
                Margin  = [System.Windows.Thickness]::new(0, 0, 8, 0)
            }
            Set-ButtonStyle -Button $okBtn -Accent
            [void]$buttonPanel.Children.Add($okBtn)
        }

        # Cancel/Close button
        $cancelBtn = [System.Windows.Controls.Button]@{
            Content = if ($PassThru) { 'Cancel' } else { 'Close' }
            Width   = 80
            Height  = 32
        }
        Set-ButtonStyle -Button $cancelBtn
        [void]$buttonPanel.Children.Add($cancelBtn)

        # DataGrid
        $selectionMode = switch ($OutputMode) {
            'Single' { 'Single' }
            default  { 'Extended' }
        }
        $dataGrid = [System.Windows.Controls.DataGrid]::new()
        Set-DataGridStyle -Grid $dataGrid -SelectionMode $selectionMode
        $dataGrid.AutoGenerateColumns         = $true
        $dataGrid.IsReadOnly                  = $true
        $dataGrid.EnableRowVirtualization     = $true
        $dataGrid.EnableColumnVirtualization  = $true
        [System.Windows.Controls.VirtualizingPanel]::SetIsVirtualizing($dataGrid, $true)
        [System.Windows.Controls.VirtualizingPanel]::SetVirtualizationMode($dataGrid, 'Recycling')
        [void]$contentPanel.Children.Add($dataGrid)

        # Context menu
        $null = New-DataGridContextMenu -DataGrid $dataGrid

        # Per-window state captured by closures (avoids $script: collisions between grids)
        $gridState = @{
            SearchCache     = @{}
            UnfilteredItems = [System.Collections.Generic.List[object]]::new()
            DataObservable  = $null
            CollectionView  = $null
            FilterTimer     = $null
            CopyTimer       = $null
        }

        # Load data and pre-cache search strings for fast filtering
        $observable = [System.Collections.ObjectModel.ObservableCollection[object]]::new()
        foreach ($item in $allData) {
            [void]$observable.Add($item)
            [void]$gridState.UnfilteredItems.Add($item)
            # Build a single concatenated search string for each item
            $searchParts = [System.Collections.Generic.List[string]]::new()
            foreach ($prop in $item.PSObject.Properties) {
                if ($null -ne $prop.Value) {
                    $searchParts.Add($prop.Value.ToString())
                }
            }
            $gridState.SearchCache[$item] = $searchParts -join '|'
        }
        $gridState.DataObservable = $observable
        $gridState.CollectionView = [System.Windows.Data.CollectionViewSource]::GetDefaultView($observable)
        $dataGrid.ItemsSource = $gridState.CollectionView

        # Manually create columns with explicit SortMemberPath for reliable sorting
        $dataGrid.AutoGenerateColumns = $false
        if ($allData.Count -gt 0) {
            $firstItem = $allData[0]
            foreach ($prop in $firstItem.PSObject.Properties) {
                $col = [System.Windows.Controls.DataGridTextColumn]::new()
                $col.Header = $prop.Name
                $col.Binding = [System.Windows.Data.Binding]::new($prop.Name)
                $col.SortMemberPath = $prop.Name
                $col.CanUserSort = $true
                [void]$dataGrid.Columns.Add($col)
            }
        }

        # Filter handler with debouncing - rebuilds collection to avoid delegate issues with sorting
        if ($IsFilterable -and $filterBox) {
            $filterBox.Add_TextChanged({
                $clearBtn = $filterBox.Tag.ClearButton
                if ($clearBtn) {
                    $clearBtn.Visibility = if ([string]::IsNullOrEmpty($filterBox.Text)) { 'Collapsed' } else { 'Visible' }
                }

                if ($gridState.FilterTimer) {
                    $gridState.FilterTimer.Stop()
                    $gridState.FilterTimer = $null
                }

                $gridState.FilterTimer = [System.Windows.Threading.DispatcherTimer]::new()
                $gridState.FilterTimer.Interval = [TimeSpan]::FromMilliseconds(300)

                $gridState.FilterTimer.Add_Tick({
                    $text = $filterBox.Text.Trim()

                    # Collection rebuild avoids delegate issues with WPF sorting
                    if ($gridState.UnfilteredItems -and $gridState.DataObservable) {
                        # Capture current sort state
                        $sortDescriptions = @()
                        if ($gridState.CollectionView) {
                            foreach ($sd in $gridState.CollectionView.SortDescriptions) {
                                $sortDescriptions += $sd
                            }
                        }

                        $gridState.DataObservable.Clear()

                        foreach ($item in $gridState.UnfilteredItems) {
                            if ([string]::IsNullOrEmpty($text)) {
                                [void]$gridState.DataObservable.Add($item)
                            }
                            else {
                                $cached = $gridState.SearchCache[$item]
                                if ($cached -and $cached.IndexOf($text, [StringComparison]::OrdinalIgnoreCase) -ge 0) {
                                    [void]$gridState.DataObservable.Add($item)
                                }
                            }
                        }

                        # Reapply sort
                        if ($gridState.CollectionView -and $sortDescriptions.Count -gt 0) {
                            $gridState.CollectionView.SortDescriptions.Clear()
                            foreach ($sd in $sortDescriptions) {
                                $gridState.CollectionView.SortDescriptions.Add($sd)
                            }
                        }
                    }

                    $gridState.FilterTimer.Stop()
                    $gridState.FilterTimer = $null
                })

                $gridState.FilterTimer.Start()
            })
        }

        # Export handler
        $exportBtn.Add_Click({
            $saveDialog = [Microsoft.Win32.SaveFileDialog]::new()
            $saveDialog.Filter = 'CSV Files (*.csv)|*.csv|All Files (*.*)|*.*'
            $saveDialog.DefaultExt = '.csv'
            $saveDialog.FileName = 'export.csv'
            if ($saveDialog.ShowDialog()) {
                try {
                    $items = @($dataGrid.ItemsSource)
                    if ($items.Count -gt 0) {
                        $items | Export-Csv -Path $saveDialog.FileName -NoTypeInformation -Force
                        Show-ThemedDialog -Title 'Export Complete' -Message "Exported to:`n$($saveDialog.FileName)" -Buttons OK -Icon Info
                    }
                }
                catch {
                    Show-ThemedDialog -Title 'Export Failed' -Message "Failed: $_" -Buttons OK -Icon Error
                }
            }
        }.GetNewClosure())

        # Copy handler
        $copyBtn.Add_Click({
            if ($dataGrid.SelectedItems.Count -gt 0) {
                try {
                    $text = $dataGrid.SelectedItems | ConvertTo-Csv -NoTypeInformation | Out-String
                    [System.Windows.Clipboard]::SetText($text)

                    # Visual feedback
                    $copyIcon.Text = [PsUi.ModuleContext]::GetIcon('Check')
                    $originalBg = $copyBtn.Background
                    $accentBrush = [System.Windows.Application]::Current.TryFindResource('AccentBrush')
                    if ($accentBrush) { $copyBtn.Background = $accentBrush }

                    $gridState.CopyTimer = [System.Windows.Threading.DispatcherTimer]::new()
                    $gridState.CopyTimer.Interval = [TimeSpan]::FromMilliseconds(1500)
                    $gridState.CopyTimer.Tag = @{ Button = $copyBtn; Icon = $copyIcon; OriginalBg = $originalBg }
                    $gridState.CopyTimer.Add_Tick({
                        param($sender, $eventArgs)
                        $data = $sender.Tag
                        $data.Button.Background = $data.OriginalBg
                        $data.Icon.Text = [PsUi.ModuleContext]::GetIcon('Copy')
                        $sender.Stop()
                    })
                    $gridState.CopyTimer.Start()
                }
                catch {
                    Show-ThemedDialog -Title 'Copy Failed' -Message "Failed: $_" -Buttons OK -Icon Error
                }
            }
            else {
                Show-ThemedDialog -Title 'No Selection' -Message 'Select rows to copy' -Buttons OK -Icon Warning
            }
        }.GetNewClosure())

        # OK button returns selection and closes
        if ($okBtn) {
            $okBtn.Add_Click({
                $result.Selection = @($dataGrid.SelectedItems)
                $window.Close()
            }.GetNewClosure())
        }

        # Cancel just closes
        $cancelBtn.Add_Click({ $window.Close() }.GetNewClosure())

        # Standard window setup
        Initialize-UiWindowLoaded -Window $window -SetIcon

        # Cleanup on window close
        $window.Add_Closed({
            # Stop any active filter timer to prevent callbacks on disposed controls
            if ($gridState.FilterTimer) {
                $gridState.FilterTimer.Stop()
                $gridState.FilterTimer = $null
            }

            # Release data structures
            $gridState.SearchCache     = $null
            $gridState.UnfilteredItems = $null
            $gridState.DataObservable  = $null
            $gridState.CollectionView  = $null

            if ($isStandalone) {
                $sessionId = [PsUi.SessionManager]::CurrentSessionId
                if ($sessionId -ne [Guid]::Empty) {
                    [PsUi.SessionManager]::DisposeSession($sessionId)
                }
            }
        }.GetNewClosure())

        # Position window on parent's monitor when launched from PsUi context
        if (!$isStandalone) {
            Set-UiDialogPosition -Dialog $window
        }

        [void]$window.ShowDialog()

        # Return selection if PassThru and OK was clicked
        if ($PassThru -and $result.Selection) {
            return $result.Selection
        }
    }
}