private/Controls/New-ColumnVisibilityPopup.ps1

function New-ColumnVisibilityPopup {
    <#
    .SYNOPSIS
        Popup for toggling DataGrid column visibility. Rebuilds on Add_Opened when the column set changes.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [System.Windows.Controls.DataGrid]$DataGrid,

        # Scriptblock returning @{ All=...; Default=...; Populated=... } from current grid state.
        # Letting the popup pull rather than push removes the construction time snapshot bug that left the picker empty when -ItemsSource grids started with no rows.
        [Parameter(Mandatory)]
        [scriptblock]$PropertiesProvider,

        [scriptblock]$ItemsProvider,

        # The output window builds this on its first Columns click after the click that opened it let go of the session
        [Nullable[Guid]]$SessionId
    )

    # Sized to match toolbar icon buttons (32x28, 6px gap).
    $colButton = [System.Windows.Controls.Button]@{
        Content = [System.Windows.Controls.TextBlock]@{
            Text                = [PsUi.ModuleContext]::GetIcon('AllApps')
            FontFamily          = [PsUi.ModuleContext]::ActiveIconFontFamily
            FontSize            = 14
            HorizontalAlignment = 'Center'
            VerticalAlignment   = 'Center'
        }
        Padding = [System.Windows.Thickness]::new(0)
        Width   = 32
        Height  = 28
        ToolTip = 'Show/Hide Columns'
        Margin  = [System.Windows.Thickness]::new(0)
    }

    Set-ButtonStyle -Button $colButton -IconOnly

    $popup = [System.Windows.Controls.Primitives.Popup]@{
        PlacementTarget    = $colButton
        Placement          = [System.Windows.Controls.Primitives.PlacementMode]::Bottom
        StaysOpen          = $false
        AllowsTransparency = $true
    }

    $popupBorder = [System.Windows.Controls.Border]@{
        BorderThickness = [System.Windows.Thickness]::new(1)
        Padding         = [System.Windows.Thickness]::new(8)
        CornerRadius    = [System.Windows.CornerRadius]::new(4)
        Tag             = 'PopupBorder'
        MaxHeight       = 400
        Effect          = [System.Windows.Media.Effects.DropShadowEffect]@{
            BlurRadius  = 10
            ShadowDepth = 2
            Opacity     = 0.3
        }
    }

    # One popup per grid, so a frozen brush would hold the theme it was first opened under.
    $popupBorder.SetResourceReference([System.Windows.Controls.Border]::BackgroundProperty, 'ControlBackgroundBrush')
    $popupBorder.SetResourceReference([System.Windows.Controls.Border]::BorderBrushProperty, 'BorderBrush')

    $scrollViewer = [System.Windows.Controls.ScrollViewer]@{
        VerticalScrollBarVisibility   = 'Auto'
        HorizontalScrollBarVisibility = 'Disabled'
    }

    $checkStack = [System.Windows.Controls.StackPanel]@{ Orientation = 'Vertical' }

    $headerLabel = [System.Windows.Controls.TextBlock]@{
        Text       = 'Visible Columns'
        FontWeight = [System.Windows.FontWeights]::SemiBold
        FontSize   = 12
        Margin     = [System.Windows.Thickness]::new(0, 0, 0, 8)
    }
    $headerLabel.SetResourceReference([System.Windows.Controls.TextBlock]::ForegroundProperty, 'ControlForegroundBrush')
    [void]$checkStack.Children.Add($headerLabel)

    $buttonPanel = [System.Windows.Controls.StackPanel]@{
        Orientation = 'Horizontal'
        Margin      = [System.Windows.Thickness]::new(0, 0, 0, 4)
    }

    $selectAllBtn = [System.Windows.Controls.Button]@{
        Content  = 'All'
        FontSize = 11
        Padding  = [System.Windows.Thickness]::new(6, 2, 6, 2)
        Margin   = [System.Windows.Thickness]::new(0, 0, 4, 0)
        ToolTip  = 'Show all columns'
    }
    Set-ButtonStyle -Button $selectAllBtn

    $unselectAllBtn = [System.Windows.Controls.Button]@{
        Content  = 'None'
        FontSize = 11
        Padding  = [System.Windows.Thickness]::new(6, 2, 6, 2)
        Margin   = [System.Windows.Thickness]::new(0, 0, 4, 0)
        ToolTip  = 'Hide all columns (except primary)'
    }
    Set-ButtonStyle -Button $unselectAllBtn

    $defaultOnlyBtn = [System.Windows.Controls.Button]@{
        Content  = 'Default'
        FontSize = 11
        Padding  = [System.Windows.Thickness]::new(6, 2, 6, 2)
        Margin   = [System.Windows.Thickness]::new(0, 0, 4, 0)
        ToolTip  = 'Show only default columns'
    }
    Set-ButtonStyle -Button $defaultOnlyBtn

    $populatedBtn = [System.Windows.Controls.Button]@{
        Content  = 'Has Data'
        FontSize = 11
        Padding  = [System.Windows.Thickness]::new(6, 2, 6, 2)
        ToolTip  = 'Show only columns with values'
    }
    Set-ButtonStyle -Button $populatedBtn

    [void]$buttonPanel.Children.Add($selectAllBtn)
    [void]$buttonPanel.Children.Add($unselectAllBtn)
    [void]$buttonPanel.Children.Add($defaultOnlyBtn)
    [void]$buttonPanel.Children.Add($populatedBtn)
    [void]$checkStack.Children.Add($buttonPanel)

    $separator = [System.Windows.Controls.Border]@{
        Height = 1
        Margin = [System.Windows.Thickness]::new(0, 4, 0, 8)
    }
    $separator.SetResourceReference([System.Windows.Controls.Border]::BackgroundProperty, 'BorderBrush')
    [void]$checkStack.Children.Add($separator)

    # Dynamic checkboxes live in this sub panel. Cleared/refilled per rebuild so the header and bulk action buttons above stay put.
    $checkboxStack = [System.Windows.Controls.StackPanel]@{ Orientation = 'Vertical' }
    [void]$checkStack.Children.Add($checkboxStack)

    $emptyPlaceholder = [System.Windows.Controls.TextBlock]@{
        Text      = '(no columns yet)'
        FontStyle = [System.Windows.FontStyles]::Italic
        FontSize  = 11
        Margin    = [System.Windows.Thickness]::new(0, 4, 0, 4)
    }
    $emptyPlaceholder.SetResourceReference([System.Windows.Controls.TextBlock]::ForegroundProperty, 'SecondaryTextBrush')

    # State the bulk action buttons close over. Checkboxes is a stable List instance, rebuild changes it in place via Clear() and Add() so the closures see the current contents.
    $state = @{
        Signature      = $null
        Checkboxes     = [System.Collections.Generic.List[System.Windows.Controls.CheckBox]]::new()
        CountTargets   = [System.Collections.Generic.List[System.Windows.Controls.CheckBox]]::new()
        PopulatedSet   = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
        CountSignature = $null
    }

    # Past ~10k cells the grid gets sluggish. Confirm before showing that many columns.
    # Threshold and clause come from Get-UiGridFloodWarning, shared with New-UiDataGrid's construction time prompt so the two can't drift. Resolve to plain values here so the closure captures them (GetNewClosure wouldn't carry the private function resolution).
    $flood              = Get-UiGridFloodWarning
    $cellCountThreshold = $flood.CellThreshold
    $floodClause        = $flood.Clause

    $gridSessionId = if ($null -ne $SessionId) { $SessionId } else { (Get-UiSession).SessionId }
    $pushSession   = ${function:Push-UiSession}
    $popSession    = ${function:Pop-UiSession}

    $confirmFlood = {
        param([int]$cols, [int]$rows)
        $cellCount = $cols * $rows
        if ($cellCount -le $cellCountThreshold) { return $true }
        $answer       = $true
        $sessionToken = & $pushSession -SessionId $gridSessionId
        try {
            $msg = ("This will show {0} columns across {1} rows. $floodClause " +
                    "Use 'Default' for a smaller starting set, or flip columns on one at a time.") -f $cols, $rows
            $dialogArgs = @{
                Title       = 'Show all columns?'
                Message     = $msg
                ConfirmText = 'Show all'
                CancelText  = 'Cancel'
            }
            $answer = [bool](Show-UiConfirmDialog @dialogArgs)
        }
        catch { Write-Debug "Flood confirm dialog failed: $_" }
        & $popSession -Token $sessionToken
        return $answer
    }.GetNewClosure()

    $getRowCount = {
        if (!$ItemsProvider) { return 0 }
        try {
            $items = & $ItemsProvider
            if ($items) { return @($items).Count }
        }
        catch { Write-Debug "Row count lookup failed: $_" }
        return 0
    }.GetNewClosure()

    # Checkboxes carry property names, but explicit column definitions can render a different Header (Name='Status', Header='Service Status').
    # SortMemberPath holds the property the column sorts by so it wins. Header is the fallback for pathless columns.
    $findColumn = {
        param($propertyName)

        # Handles on a live Process row sorts on HandleCount as well
        foreach ($col in $DataGrid.Columns) {
            if ($col.SortMemberPath -eq $propertyName -and $col.Header -eq $propertyName) { return $col }
        }
        foreach ($col in $DataGrid.Columns) {
            if ($col.SortMemberPath -eq $propertyName) { return $col }
        }
        foreach ($col in $DataGrid.Columns) {
            if ($col.Header -eq $propertyName) { return $col }
        }
        return $null
    }.GetNewClosure()

    # Defined at function body, since nested GetNewClosure captures only the immediate parent scope and a handler built inside $rebuild sees $DataGrid = $null at click time.
    # One instance attaches across every checkbox. $this IS the firing CheckBox. Attached to both Checked and Unchecked, $this.IsChecked says which way it just flipped.
    $onToggle = {
        $col = & $findColumn $this.Tag.Name
        if ($col) {
            $col.Visibility = if ($this.IsChecked) { [System.Windows.Visibility]::Visible }
                              else { [System.Windows.Visibility]::Collapsed }
        }
        $skip = ($DataGrid.Tag -is [hashtable]) -and
                ($DataGrid.Tag.ContainsKey('StretchLastColumn')) -and
                (!$DataGrid.Tag.StretchLastColumn)
        Set-LastDataColumnStar -DataGrid $DataGrid -Skip:$skip
    }.GetNewClosure()

    # Clears the dynamic checkbox stack and refills from the props snapshot.
    # Called from Add_Opened when the column signature changes.
    $rebuild = {
        param($props)
        $checkboxStack.Children.Clear()
        $state.Checkboxes.Clear()
        $state.CountTargets.Clear()
        $state.PopulatedSet.Clear()

        if ($props.Populated) {
            foreach ($prop in $props.Populated) { [void]$state.PopulatedSet.Add([string]$prop) }
        }

        $allProps = if ($props.All) { @($props.All) } else { @() }
        if ($allProps.Count -eq 0) {
            [void]$checkboxStack.Children.Add($emptyPlaceholder)
            # No columns for the bulk buttons to act on, so they dim.
            $selectAllBtn.IsEnabled   = $false
            $unselectAllBtn.IsEnabled = $false
            $defaultOnlyBtn.IsEnabled = $false
            $populatedBtn.IsEnabled   = $false
            return
        }

        $defaults = if ($props.Default) { @($props.Default) } else { @() }

        # Labels start without counts, first popup open triggers the count scan. Computing upfront stalled the window on large grids (50 cols x 10k rows).
        $isFirst = $true
        foreach ($propName in $allProps) {

            # TextBlock content so the lazy compute can drop in the (filled/total) Run later.
            $tb       = [System.Windows.Controls.TextBlock]::new()
            $nameRun  = [System.Windows.Documents.Run]::new($propName)
            $countRun = [System.Windows.Documents.Run]::new('')
            $countRun.SetResourceReference([System.Windows.Documents.TextElement]::ForegroundProperty, 'SecondaryTextBrush')
            $countRun.FontSize = 11
            [void]$tb.Inlines.Add($nameRun)
            [void]$tb.Inlines.Add($countRun)

            $checkBox = [System.Windows.Controls.CheckBox]@{
                Content  = $tb
                FontSize = 12
                Margin   = [System.Windows.Thickness]::new(0, 2, 0, 2)
                MinWidth = 150
            }

            $isDefault = $defaults -contains $propName

            $matchedColumn = & $findColumn $propName
            $isVisible     = if ($matchedColumn) { $matchedColumn.Visibility -eq [System.Windows.Visibility]::Visible } else { $true }
            $checkBox.IsChecked = $isVisible

            # Frozen columns can't be hidden, hiding one would shift the freeze boundary.
            $frozenCount = [int]$DataGrid.FrozenColumnCount
            $isFrozen    = $matchedColumn -and $frozenCount -gt 0 -and $matchedColumn.DisplayIndex -lt $frozenCount

            # Tag carries what the lazy count needs, the property name, the default flag and the count run.
            $checkBox.Tag = @{ Name = $propName; IsDefault = $isDefault; CountRun = $countRun }

            # Every column gets a count, and only the toggles skip the locked ones.
            [void]$state.CountTargets.Add($checkBox)

            # Lock the primary column (first one) and any frozen column. Excluded from $state.Checkboxes so Select All / Unselect All / Default Only / Has Data don't touch them.
            if ($isFirst -or $isFrozen) {
                $checkBox.IsEnabled = $false
                $checkBox.IsChecked = $true
                $checkBox.ToolTip   = if ($isFrozen) { 'Frozen column - cannot be hidden' } else { 'Primary column cannot be hidden' }
            }
            else { [void]$state.Checkboxes.Add($checkBox) }
            $isFirst = $false

            # Shared handler from the function body, for the reason the $onToggle comment gives.
            $checkBox.Add_Checked($onToggle)
            $checkBox.Add_Unchecked($onToggle)

            Set-CheckBoxStyle -CheckBox $checkBox
            [void]$checkboxStack.Children.Add($checkBox)
        }

        # Buttons need at least one unlocked checkbox to act on. A single column grid (primary only) lands here with $state.Checkboxes empty.
        $haveCheckboxes = $state.Checkboxes.Count -gt 0
        $selectAllBtn.IsEnabled   = $haveCheckboxes
        $unselectAllBtn.IsEnabled = $haveCheckboxes
        $defaultOnlyBtn.IsEnabled = $haveCheckboxes
        $populatedBtn.IsEnabled   = $haveCheckboxes
    }.GetNewClosure()

    $selectAllBtn.Add_Click({
        $rows  = & $getRowCount
        $count = $state.Checkboxes.Count
        if (!(& $confirmFlood $count $rows)) { return }
        foreach ($checkbox in $state.Checkboxes) {
            $checkbox.IsChecked = $true
        }
    }.GetNewClosure())

    $unselectAllBtn.Add_Click({
        foreach ($checkbox in $state.Checkboxes) {
            $checkbox.IsChecked = $false
        }
    }.GetNewClosure())

    $defaultOnlyBtn.Add_Click({
        foreach ($checkbox in $state.Checkboxes) {
            $checkbox.IsChecked = $checkbox.Tag.IsDefault
        }
    }.GetNewClosure())

    # With ItemsProvider, recompute per click. Without one, fall back to the latest rebuild's set.
    $populatedBtn.Add_Click({
        $currentItems = $null
        $set = $state.PopulatedSet
        if ($ItemsProvider) {
            try {
                $currentItems = & $ItemsProvider
                $props        = & $PropertiesProvider
                if ($currentItems -and $props -and $props.All) {
                    # Get-PopulatedProperties comma-wraps its return, so the HashSet[string] arrives as a single object instead of unrolling. Feed it straight into the constructor.
                    $fresh = Get-PopulatedProperties -Items $currentItems -PropertyNames @($props.All)
                    $set = [System.Collections.Generic.HashSet[string]]::new(
                        $fresh,
                        [System.StringComparer]::OrdinalIgnoreCase
                    )
                }
            }
            catch { Write-Debug "Has Data live recompute failed: $_" }
        }

        $rowCount = if ($currentItems) { @($currentItems).Count } else { 0 }
        if (!(& $confirmFlood $set.Count $rowCount)) { return }

        foreach ($checkbox in $state.Checkboxes) {
            $propName = $checkbox.Tag.Name
            $checkbox.IsChecked = $set.Contains($propName)
        }
    }.GetNewClosure())

    $scrollViewer.Content = $checkStack
    $popupBorder.Child    = $scrollViewer
    $popup.Child          = $popupBorder
    $colButton.Tag        = $popup

    # Each Open rereads the column set via PropertiesProvider. Keyed by signature: only rebuild the checkbox stack when the column list actually changed. Lazy count cache is signature bound so it invalidates with the columns.
    $popup.Add_Opened({
        $props = $null
        try { $props = & $PropertiesProvider }
        catch { Write-Debug "PropertiesProvider failed: $_"; return }
        if (!$props) { return }

        $all = if ($props.All) { @($props.All) } else { @() }
        # NUL separator + count prefix so two different column sets can't collide as strings.
        $sig = $all.Count.ToString() + "`0" + ($all -join "`0")

        if ($sig -ne $state.Signature) {
            & $rebuild $props
            $state.Signature      = $sig
            $state.CountSignature = $null
        }

        # Lazy populated count fill. Only runs once per signature. Reruns after a rebuild.
        if ($state.CountSignature -eq $sig) { return }
        if (!$ItemsProvider)                { $state.CountSignature = $sig; return }
        if ($state.CountTargets.Count -eq 0) { $state.CountSignature = $sig; return }

        # Mark the signature first so a second Open while the fill is still running doesn't start a duplicate timer.
        $state.CountSignature = $sig

        try {
            $items = & $ItemsProvider
            if (!$items) { return }
            $arr   = @($items)
            $total = $arr.Count
            if ($total -eq 0) { return }

            # Cell count is no guide (MainModule is ~7ms a row on 5.1, CommandLine about 30ms on 7, so eight seconds for one column over 280 rows).
            # About 20ms a tick, resuming mid column, and the budget is checked before each row so one slow row still overruns it.
            foreach ($cb in $state.CountTargets) {
                if ($cb.Tag.CountRun) { $cb.Tag.CountRun.Text = ' (...)' }
            }

            # State travels in Tag ($this is the timer).
            $fillTimer          = [System.Windows.Threading.DispatcherTimer]::new([System.Windows.Threading.DispatcherPriority]::Background)
            $fillTimer.Interval = [TimeSpan]::Zero
            $fillTimer.Tag      = @{
                Pending = [System.Collections.Generic.Queue[object]]::new($state.CountTargets)
                Rows    = $arr
                Total   = $total
                Popup   = $popup
                State   = $state
                Current = $null
                Index   = 0
                Filled  = 0
                Spent   = 0
            }
            $fillTimer.Add_Tick({
                $ft = $this.Tag
                if (!$ft.Popup.IsOpen) {
                    # Dismissing partway leaves the signature marked and the next open trusting a count that never landed
                    $this.Stop()
                    $ft.State.CountSignature = $null
                    return
                }

                $budget = [System.Diagnostics.Stopwatch]::StartNew()
                while ($budget.ElapsedMilliseconds -lt 20) {
                    if (!$ft.Current) {
                        if ($ft.Pending.Count -eq 0) { $this.Stop(); return }
                        $ft.Current = $ft.Pending.Dequeue()
                        $ft.Index   = 0
                        $ft.Filled  = 0
                        $ft.Spent   = 0
                    }

                    # Values count unless they're null, whitespace, the '[Access Denied]' string, or an empty list, and Count reads -1 for a scalar so only an empty list or hashtable fails.
                    $propName  = [string]$ft.Current.Tag.Name
                    $tickStart = $budget.ElapsedMilliseconds
                    while ($ft.Index -lt $ft.Rows.Count -and $budget.ElapsedMilliseconds -lt 20) {
                        $row = $ft.Rows[$ft.Index]
                        $ft.Index++
                        if ($null -eq $row) { continue }
                        try {
                            $value = $row.$propName
                            if ($null -ne $value) {
                                if ($value -is [string]) {
                                    if (![string]::IsNullOrWhiteSpace($value) -and $value -ne '[Access Denied]') { $ft.Filled++ }
                                }
                                elseif ([PsUi.ValueKind]::Count($value) -ne 0) { $ft.Filled++ }
                            }
                        }
                        catch { }
                    }

                    $ft.Spent += $budget.ElapsedMilliseconds - $tickStart

                    if ($ft.Index -ge $ft.Rows.Count) {
                        if ($ft.Current.Tag.CountRun) { $ft.Current.Tag.CountRun.Text = " ($($ft.Filled)/$($ft.Total))" }
                        $ft.Current = $null
                    }
                    elseif ($ft.Spent -ge 250) {
                        # All of CommandLine holds the other sixty up for half a minute.
                        # Loosening the cap starves the cheap columns, since one row of Description blocks most of a second on a protected process.
                        if ($ft.Current.Tag.CountRun) { $ft.Current.Tag.CountRun.Text = '' }
                        $ft.Current = $null
                    }
                }
            })
            $fillTimer.Start()
        }
        catch {
            Write-Debug "Lazy column count failed: $_"
            # Failed before completion, so the next open retries.
            $state.CountSignature = $null
        }
    }.GetNewClosure())

    $colButton.Add_Click({
        try { $popup.IsOpen = !$popup.IsOpen }
        catch { Write-Verbose "Failed to toggle popup: $_" }
    }.GetNewClosure())

    return @{ Button = $colButton; Popup = $popup }
}