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 } } |