public/layout/New-UiPanel.ps1
|
function New-UiPanel { <# .SYNOPSIS Creates a panel container for organizing child controls. .DESCRIPTION The workhorse container. Stacks children vertically or horizontally, or wraps them into responsive columns with -LayoutStyle Wrap (-MaxColumns caps the count). -Header puts it in a themed GroupBox. In a horizontal or wrapped row, buttons beside a labeled input line up with its box. .PARAMETER Content ScriptBlock containing child controls to render inside the panel. .PARAMETER Header Optional header text. Wraps the panel in a themed GroupBox when set. .PARAMETER Type Container type: Stack (default), Tab, or Wrap. .PARAMETER LayoutStyle Child layout mode: Stack (default) or Wrap. .PARAMETER Orientation Stack direction when Type is Stack. Defaults to Vertical. .PARAMETER FullWidth Forces the panel to take full width in WrapPanel layouts. .PARAMETER MaxColumns Maximum responsive columns for Wrap layout (1-4). Children resize automatically. .PARAMETER HeaderAction Optional action button in the panel header. Requires -Header to be set. Pass New-UiHeaderAction output, or the legacy hashtable containing: Icon (string), Tooltip (string), Action (scriptblock). .PARAMETER ShowSourceButton When used with -Header, automatically adds a "View Source Code" button that displays the Content scriptblock in a PowerShell-styled dialog. .PARAMETER WPFProperties Hashtable of additional WPF properties to set on the control. Allows setting any valid WPF property not explicitly exposed as a parameter. Bad values warn and get skipped. A property name that does not exist on the control is skipped silently (-Verbose shows it). Nothing stops execution. Supports attached properties using dot notation (e.g., "Grid.Row"). .EXAMPLE New-UiPanel -Header "Example Panel" -ShowSourceButton -Content { New-UiLabel -Text "This code can be viewed by clicking the button" } .EXAMPLE New-UiPanel -Header "Custom Action" -HeaderAction ( New-UiHeaderAction -Icon Info -Tooltip 'Show Help' -Action { Show-UiMessageDialog -Message 'Help text' } ) -Content { New-UiLabel -Text "Panel content" } The legacy hashtable form still works: -HeaderAction @{ Icon = 'Info'; Tooltip = 'Show Help'; Action = { ... } } .EXAMPLE # Wrap layout flows children into columns, capped at two here. New-UiPanel -LayoutStyle Wrap -MaxColumns 2 -Content { New-UiToggle -Label 'Wake on LAN' -Variable 'wol' New-UiToggle -Label 'Remote registry' -Variable 'remoteReg' New-UiToggle -Label 'ICMP echo' -Variable 'icmp' New-UiToggle -Label 'SMB v1' -Variable 'smb1' } .EXAMPLE New-UiPanel -Content { } -WPFProperties @{ "Grid.Row" = 1 "Grid.Column" = 2 } #> [CmdletBinding()] param( [Parameter(Mandatory)] [scriptblock]$Content, [string]$Header, [ValidateSet('Stack', 'Tab', 'Wrap')] [string]$Type = 'Stack', [ValidateSet('Stack', 'Wrap')] [string]$LayoutStyle = 'Stack', [System.Windows.Controls.Orientation]$Orientation = 'Vertical', [switch]$FullWidth, [ValidateScript({ $_ -eq 0 -or ($_ -ge 1 -and $_ -le 4) })] [int]$MaxColumns = 0, [hashtable]$HeaderAction, [switch]$ShowSourceButton, [Parameter()] [hashtable]$WPFProperties ) $session = Assert-UiSession -CallerName 'New-UiPanel' Write-Debug "Header: '$Header', Type: $Type" $parent = $session.CurrentParent $oldParent = $parent # Auto-generate HeaderAction for ShowSourceButton if ($ShowSourceButton -and $Header) { # Preserve indentation by using the ScriptBlock's AST extent # This captures the original formatting including indentation $sourceCode = $Content.Ast.Extent.Text # If AST is not available or doesn't preserve formatting, fall back to ToString() if (!$sourceCode) { $sourceCode = $Content.ToString() } # Strip outer braces and normalize indentation # Remove leading/trailing whitespace $sourceCode = $sourceCode.Trim() # If the code starts with { and ends with }, remove them if ($sourceCode -match '(?s)^\s*\{(.+)\}\s*$') { $sourceCode = $matches[1] } # Normalize indentation - find minimum indentation and remove it from all lines $lines = $sourceCode -split "`r?`n" $nonEmptyLines = $lines | Where-Object { $_ -match '\S' } if ($nonEmptyLines) { $minIndent = ($nonEmptyLines | ForEach-Object { if ($_ -match '^(\s*)') { $matches[1].Length } else { 0 } } | Measure-Object -Minimum).Minimum $normalizedLines = $lines | ForEach-Object { if ($_ -match '\S' -and $_.Length -ge $minIndent) { $_.Substring($minIndent) } else { $_ } } $sourceCode = $normalizedLines -join "`n" } # Final trim to remove leading/trailing blank lines $sourceCode = $sourceCode.Trim() $HeaderAction = @{ Icon = 'Code' Tooltip = 'View Source Code' Action = [scriptblock]::Create(@" Show-UiMessageDialog -Title 'Source Code' -Message @' $sourceCode '@ -PowerShell "@) } } # Create container based on Type and LayoutStyle if ($Type -eq 'Tab') { $innerContainer = [System.Windows.Controls.TabControl]@{ Margin = [System.Windows.Thickness]::new(0, 0, 0, 10) } $fullWidthConstraint = $true } elseif ($Type -eq 'Wrap' -or $LayoutStyle -eq 'Wrap') { # Use WrapPanel for wrapping child controls $innerContainer = [System.Windows.Controls.WrapPanel]@{ Orientation = 'Horizontal' HorizontalAlignment = 'Stretch' } # When MaxColumns is specified, panel should stretch but stay in column layout $fullWidthConstraint = $FullWidth # If MaxColumns specified, add responsive sizing for child controls inside this panel if ($MaxColumns -gt 0) { # Captured for the closure, and independent of the window's own column count $panelMaxCols = $MaxColumns $innerContainer.Add_SizeChanged({ param($sender, $eventArgs) $paddingBuffer = 16 $availableWidth = $sender.ActualWidth - $paddingBuffer if ($availableWidth -le 0) { return } # Calculate column width based on this panel's MaxColumns (NOT window's) # Minimum width per column in the wrap panel $minColumnWidth = 150 $possibleCols = [Math]::Max(1, [Math]::Floor($availableWidth / $minColumnWidth)) $actualCols = [Math]::Min($possibleCols, $panelMaxCols) $actualCols = [Math]::Max($actualCols, 1) $childWidth = [Math]::Floor(($availableWidth / $actualCols) - 8) # Apply width to all children that support it foreach ($child in $sender.Children) { if ($child -is [System.Windows.FrameworkElement]) { # Skip items that explicitly want full width (have Tag = 'FullWidth') if ($child.Tag -eq 'FullWidth') { continue } $child.Width = $childWidth } } }.GetNewClosure()) } } else { # Default Stack behavior $innerContainer = [System.Windows.Controls.StackPanel]@{ Orientation = $Orientation } $fullWidthConstraint = $FullWidth } # Build the display control, a GroupBox around it when Header is set if ($Header) { $displayControl = [System.Windows.Controls.GroupBox]@{ Content = $innerContainer } # When using Wrap layout, panel should stretch to fill available space if ($Type -eq 'Wrap' -or $LayoutStyle -eq 'Wrap') { $displayControl.HorizontalAlignment = 'Stretch' } # Create header with icon button if HeaderAction is provided if ($HeaderAction) { $headerGrid = [System.Windows.Controls.Grid]::new() $col1 = [System.Windows.Controls.ColumnDefinition]::new() $col1.Width = [System.Windows.GridLength]::new(1, [System.Windows.GridUnitType]::Star) $col2 = [System.Windows.Controls.ColumnDefinition]::new() $col2.Width = [System.Windows.GridLength]::Auto [void]$headerGrid.ColumnDefinitions.Add($col1) [void]$headerGrid.ColumnDefinitions.Add($col2) $headerText = [System.Windows.Controls.TextBlock]::new() $headerText.Text = $Header $headerText.VerticalAlignment = 'Center' $headerText.FontWeight = 'SemiBold' $headerText.Tag = 'ControlFgBrush' [PsUi.ThemeEngine]::RegisterElement($headerText) [System.Windows.Controls.Grid]::SetColumn($headerText, 0) [void]$headerGrid.Children.Add($headerText) $iconButton = [System.Windows.Controls.Button]::new() $iconButton.Width = 24 $iconButton.Height = 24 $iconButton.Padding = [System.Windows.Thickness]::new(0) $iconButton.ToolTip = $HeaderAction.Tooltip $iconButton.VerticalAlignment = 'Center' $iconButton.Margin = [System.Windows.Thickness]::new(8, 0, 0, 0) $iconBlock = [System.Windows.Controls.TextBlock]::new() $iconBlock.FontFamily = [PsUi.ModuleContext]::ActiveIconFontFamily $iconBlock.FontSize = 12 $iconBlock.HorizontalAlignment = 'Center' $iconBlock.VerticalAlignment = 'Center' # Map icon name to character using ModuleContext if available $iconChar = $null if ($HeaderAction.Icon) { $iconText = [PsUi.ModuleContext]::GetIcon($HeaderAction.Icon) if ($iconText) { $iconChar = $iconText } else { # Fallback mapping (icons should already be in CharList but just in case) $iconChar = switch ($HeaderAction.Icon) { 'Code' { [PsUi.ModuleContext]::GetIcon('Code') } 'Info' { [PsUi.ModuleContext]::GetIcon('Info') } 'Help' { [PsUi.ModuleContext]::GetIcon('Help') } 'View' { [PsUi.ModuleContext]::GetIcon('View') } default { [PsUi.ModuleContext]::GetIcon('Info') } } } } else { $iconChar = [PsUi.ModuleContext]::GetIcon('Info') } $iconBlock.Text = $iconChar $iconButton.Content = $iconBlock # Style and click handler Set-ButtonStyle -Button $iconButton -IconOnly $actionScript = $HeaderAction.Action $ownerSessionId = $session.SessionId $pushSession = ${function:Push-UiSession} $popSession = ${function:Pop-UiSession} $invokeCallback = ${function:Invoke-UiCallback} # The trap's continue still reaches the pop $iconButton.Add_Click({ trap { Write-Warning "New-UiPanel header action error: $_"; continue } $sessionToken = & $pushSession -SessionId $ownerSessionId $null = & $invokeCallback -ScriptBlock $actionScript -Label 'New-UiPanel header action' & $popSession -Token $sessionToken }.GetNewClosure()) [System.Windows.Controls.Grid]::SetColumn($iconButton, 1) [void]$headerGrid.Children.Add($iconButton) # Use grid as header $displayControl.Header = $headerGrid } else { # Simple string header (existing behavior) $displayControl.Header = $Header } Set-GroupBoxStyle -GroupBox $displayControl } else { $displayControl = $innerContainer } # Apply layout constraints Set-FullWidthConstraint -Control $displayControl -Parent $parent -FullWidth:$fullWidthConstraint Set-ResponsiveConstraints -Control $displayControl -FullWidth:$fullWidthConstraint # Apply custom WPF properties if specified if ($WPFProperties) { Set-UiProperties -Control $displayControl -Properties $WPFProperties } Add-UiControlToParent -Control $displayControl -Parent $parent # Execute content block with innerContainer as the new parent $session.CurrentParent = $innerContainer Write-Debug "Entering content block. Parent set to: $($innerContainer.GetType().Name)" # Execute content - restore parent outside try/finally for PS 5.1 closure compatibility try { Invoke-UiContent -Content $Content -CallerName 'New-UiPanel' -ErrorAction Stop } catch { # Restore parent before re-throwing $session.CurrentParent = $oldParent Write-Debug "Content execution failed: $($_.Exception.Message)" throw } # Restore parent after successful content execution $session.CurrentParent = $oldParent # -Type Tab builds a TabControl, which isn't a Panel if ($innerContainer -is [System.Windows.Controls.Panel]) { Set-UiRowAlignment -Panel $innerContainer } Write-Debug "Content block complete. Added to: $($oldParent.GetType().Name)" } |