public/tool/New-UiTool.ps1
|
function New-UiTool { <# .SYNOPSIS Builds a form from a command's parameter metadata. .DESCRIPTION New-UiTool reads a command's parameter metadata and builds a form with a matching control per parameter: - [ValidateSet] becomes a dropdown - [switch] and [bool] become toggles - [int]/[double] become number inputs, or a slider when ValidateRange spans 10 steps or fewer - [string] becomes a text input, [string[]] a multi-line text area - [datetime] becomes a date picker - [SecureString] becomes a password input, [PSCredential] a username and password pair - Mandatory parameters gate the run button - .PARAMETER help text becomes the caption under each control Execution runs on a background thread, so the window stays alive during long commands. Results land in a sortable output grid. All of this reads static parameter metadata. Dynamic parameters and validation that depends on live state don't survive inspection; build those forms by hand with the regular controls. For everything else, "time to GUI" for a script is one line. .PARAMETER Command The command to wrap: a cmdlet, function, alias, script path, or a CommandInfo object. Pass a .ps1 that has no param block and only defines functions, and the form wraps its one function, or the one named after the file. Run dot sources the whole file first, so helper functions in the same file work. New-UiTool refuses a file that runs other code at its top level, since every Run would repeat it. .PARAMETER Title Window title. Defaults to the command name. .PARAMETER Width Window width in pixels. Standalone windows default to 600. Inside a host window the child opens at 800 unless you pass one. .PARAMETER Height Window height in pixels. Only applied when you pass it. Inside a host window the child otherwise opens at 600, standalone the window sizes itself. .PARAMETER ParameterSet If the command has multiple parameter sets, specify which one to use. If not specified, uses the default parameter set or shows a selector. .PARAMETER Theme UI theme (Light, Dark, etc.) .PARAMETER ExcludeParameters Array of parameter names to exclude from the UI. .PARAMETER IncludeCommonParameters Include common parameters like -Verbose, -Debug, etc. Default is false. .PARAMETER ResultActions Actions offered against selected rows in the results grid. Pass a { New-UiResultAction ... } definition block, an array of New-UiResultAction output, or the legacy hashtable array (Text + Action required; optional: Icon, Confirm, ObjectType optional). The scriptblock receives $_ as the selected row(s). .PARAMETER SingleSelect When used with ResultActions, limits selection to a single row. .PARAMETER HideThemeButton Removes the theme switcher from the titlebar. .PARAMETER ShowParamType Displays the parameter type next to each input label. .PARAMETER FilePickerParameters Parameter names that should get a file browse button. .PARAMETER FolderPickerParameters Parameter names that should get a folder browse button. .PARAMETER ComputerPickerParameters Parameter names that should get an AD computer picker button. .PARAMETER UserPickerParameters Parameter names that should get an AD user picker button. .PARAMETER GroupPickerParameters Parameter names that should get an AD group picker button. .PARAMETER MemberPickerParameters Parameter names that should get an AD user/group picker button. .PARAMETER OUPickerParameters Parameter names that should get an AD OU picker button. .PARAMETER NoAutoHelpers Disables automatic helper button detection for common parameter names like Path or ComputerName. .PARAMETER LayoutStyle Control arrangement: Stack (vertical) or Wrap (multi-column). .PARAMETER MaxColumns Maximum columns when using Wrap layout. 0 means auto-detect based on window width. .EXAMPLE New-UiTool -Command 'Get-Process' Creates a GUI for Get-Process with inputs for Name, Id, etc. .EXAMPLE New-UiTool -Command 'Get-ChildItem' -Title "File Browser" -ExcludeParameters 'LiteralPath' Creates a file browser tool, excluding the LiteralPath parameter. .EXAMPLE New-UiTool -Command 'Stop-Service' -ParameterSet 'InputObject' Creates a service stopper using a specific parameter set. .EXAMPLE New-UiTool -Command 'Get-Process' -ResultActions { New-UiResultAction 'Stop' -Icon Stop -Confirm 'Stop {0} processes?' -Action { $_ | Stop-Process -Force } } Creates a process viewer with a Stop action that kills selected processes after a confirm. The legacy hashtable form still works: -ResultActions @( @{ Text = 'Stop'; Icon = 'Stop'; Action = { $_ | Stop-Process -Force } } ) .EXAMPLE # A local function, so nothing needs registering globally. function My-CustomTool { param([string]$Name) Write-Host "Hello $Name" } New-UiTool -Command 'My-CustomTool' Creates a GUI for a function defined in your script (detected from the calling scope). .EXAMPLE New-UiTool -Command '.\MyScript.ps1' Creates a GUI for a parameterized script file. #> [CmdletBinding()] param( # Takes a cmdlet name, a function name, a script path, or a CommandInfo object. [Parameter(Mandatory, Position = 0)] [object]$Command, [string]$Title, [int]$Width = 600, [int]$Height = 500, [string]$ParameterSet, [ArgumentCompleter({ [PsUi.ThemeEngine]::GetAvailableThemes() })] [string]$Theme, [string[]]$ExcludeParameters = @(), [switch]$IncludeCommonParameters, [switch]$HideThemeButton, [switch]$ShowParamType, # Untyped, so it takes a New-UiResultAction definition block, an array of definitions, or the legacy hashtable array. [object]$ResultActions, [switch]$SingleSelect, # Input helper parameters put a browse button beside the text box. [string[]]$FilePickerParameters = @(), [string[]]$FolderPickerParameters = @(), [string[]]$ComputerPickerParameters = @(), [string[]]$UserPickerParameters = @(), [string[]]$GroupPickerParameters = @(), [string[]]$MemberPickerParameters = @(), [string[]]$OUPickerParameters = @(), [switch]$NoAutoHelpers, # Layout options for parameter panel [ValidateSet('Stack', 'Wrap')] [string]$LayoutStyle = 'Stack', [ValidateScript({ $_ -eq 0 -or ($_ -ge 1 -and $_ -le 4) })] [int]$MaxColumns = 0 ) Write-Debug "Starting for command '$Command', Width=$Width, Height=$Height" # Normalized now, not when the deferred content block runs mid window build. The user's definition block should execute at call time and errors should name this function. if ($null -ne $ResultActions) { $ResultActions = [hashtable[]](ConvertTo-UiDefinitionArray -InputObject $ResultActions -ParameterName '-ResultActions' -CallerName 'New-UiTool') } # The calling script's SessionState, so a local function it defined can still be found. $callerSessionState = $null try { $callerScope = (Get-PSCallStack)[1] if ($callerScope -and $callerScope.InvocationInfo.MyCommand.ScriptBlock) { $flags = [System.Reflection.BindingFlags]'Instance, NonPublic, Public' $prop = [System.Management.Automation.ScriptBlock].GetProperty('SessionState', $flags) if ($prop) { $callerSessionState = $prop.GetValue($callerScope.InvocationInfo.MyCommand.ScriptBlock) } } } catch { Write-Verbose "[New-UiTool] Could not extract caller SessionState: $_" } Write-Debug "Introspecting command metadata" $defParams = @{ Command = $Command ParameterSet = $ParameterSet ExcludeParameters = $ExcludeParameters IncludeCommonParameters = $IncludeCommonParameters FilePickerParameters = $FilePickerParameters FolderPickerParameters = $FolderPickerParameters ComputerPickerParameters = $ComputerPickerParameters UserPickerParameters = $UserPickerParameters GroupPickerParameters = $GroupPickerParameters MemberPickerParameters = $MemberPickerParameters OUPickerParameters = $OUPickerParameters NoAutoHelpers = $NoAutoHelpers CallerSessionState = $callerSessionState } $uiDef = Get-UiDefinition @defParams Write-Debug "Got definition: $($uiDef.Parameters.Count) parameters, sets: $($uiDef.ParameterSets -join ', ')" Write-Debug "Introspection complete: $($uiDef.Parameters.Count) parameter(s) detected" # No current support for using on PsUi commands and probably won't ever be. Warn rather than refuse, since use for reading parameters is a fair thing to want. # PsUi dot sources a function file itself, so that function's Source reads PsUi too if ($uiDef.CommandInfo -and $uiDef.CommandInfo.Source -eq 'PsUi' -and !$uiDef.FunctionFile) { Write-Warning "$($uiDef.CommandInfo.Name) is a PsUi command. New-UiTool on PsUi's own commands is unsupported and gives unexpected results." } $cmdInfo = $uiDef.CommandInfo $commandInvocation = $uiDef.CommandName $commandDefinition = $uiDef.CommandDefinition $commandDisplayName = $uiDef.DisplayName $description = $uiDef.Description $isExternalScript = $uiDef.IsExternalScript $parameterSetName = $uiDef.ParameterSetName $parameterSets = $uiDef.ParameterSets $hasMultipleSets = $uiDef.HasMultipleSets $targetParams = $uiDef.Parameters $paramDescriptions = $uiDef.ParamDescriptions $inputHelpers = $uiDef.InputHelpers if (!$Title) { $Title = $commandDisplayName } Write-Debug "Rendering UI for '$commandDisplayName'" # Three display modes. Embed inline while the host is still being built, spawn a child window from a click handler, or stand up a top level window when there is no host at all. # Window.IsLoaded is what separates the first two, false during the build and true after Show. # Get-UiSession rather than [SessionManager]::Current, because the pool can switch threads and Current is ThreadStatic. Get-UiSession falls back to a global held per runspace. $existingSession = try { Get-UiSession } catch { $null } $hasWindow = $existingSession -and $existingSession.Window $hostIsLoaded = $false if ($hasWindow) { try { $hostIsLoaded = [bool]$existingSession.Window.IsLoaded } catch { $hostIsLoaded = $false } } $isEmbedded = $hasWindow -and !$hostIsLoaded $isChildWindow = $hasWindow -and $hostIsLoaded Write-Debug "Tool dispatch: embedded=$isEmbedded, childWindow=$isChildWindow, hostIsLoaded=$hostIsLoaded" # Copy variables to avoid GetNewClosure issues with ValidateSet attributes # Auto, the way New-UiWindow defaults, or a tool with no -Theme resets the process theme to Light on its way through Initialize-UITheme. $capturedTheme = if ($Theme) { $Theme } else { 'Auto' } $capturedTitle = $Title $capturedWidth = $Width $capturedHeight = $Height $capturedWidthExplicit = $PSBoundParameters.ContainsKey('Width') $capturedHeightExplicit = $PSBoundParameters.ContainsKey('Height') $capturedHideThemeButton = $HideThemeButton $capturedCommandInvocation = $commandInvocation $capturedCommandDefinition = $commandDefinition $capturedIsExternalScript = $isExternalScript $capturedCommandDisplayName = $commandDisplayName $capturedShowParamType = $ShowParamType $inputHelpers = @{ FilePicker = [System.Collections.Generic.List[string]]::new() FolderPicker = [System.Collections.Generic.List[string]]::new() ComputerPicker = [System.Collections.Generic.List[string]]::new() UserPicker = [System.Collections.Generic.List[string]]::new() GroupPicker = [System.Collections.Generic.List[string]]::new() MemberPicker = [System.Collections.Generic.List[string]]::new() OUPicker = [System.Collections.Generic.List[string]]::new() FilterBuilder = @{} } if ($FilePickerParameters) { $inputHelpers.FilePicker.AddRange($FilePickerParameters) } if ($FolderPickerParameters) { $inputHelpers.FolderPicker.AddRange($FolderPickerParameters) } if ($ComputerPickerParameters) { $inputHelpers.ComputerPicker.AddRange($ComputerPickerParameters) } if ($UserPickerParameters) { $inputHelpers.UserPicker.AddRange($UserPickerParameters) } if ($GroupPickerParameters) { $inputHelpers.GroupPicker.AddRange($GroupPickerParameters) } if ($MemberPickerParameters) { $inputHelpers.MemberPicker.AddRange($MemberPickerParameters) } if ($OUPickerParameters) { $inputHelpers.OUPicker.AddRange($OUPickerParameters) } # Detect command type to determine filter mode $cmdName = $cmdInfo.Name $filterMode = 'Generic' if ($cmdName -match '^Get-AD|^Set-AD|^New-AD|^Remove-AD') { $filterMode = 'AD' } elseif ($cmdName -match '^Get-Wmi|^Get-Cim|^Invoke-Wmi|^Invoke-Cim') { $filterMode = 'WMI' } elseif ($cmdName -match '^Get-ChildItem$|^Get-Item$|^Copy-Item$|^Move-Item$|^Remove-Item$|^Rename-Item$') { $filterMode = 'File' } else { # For scripts/functions, detect file mode if both Path-like and Filter params exist $paramNames = $targetParams | ForEach-Object { $_.Name } $hasPathParam = $paramNames | Where-Object { $_ -match '^Path$|Directory|Folder' } $hasFilterParam = $paramNames | Where-Object { $_ -match '^Filter$' } if ($hasPathParam -and $hasFilterParam) { $filterMode = 'File' } } if (!$NoAutoHelpers) { foreach ($param in $targetParams) { $pName = $param.Name # Skip if already manually specified if ($inputHelpers.FilePicker -contains $pName -or $inputHelpers.FolderPicker -contains $pName -or $inputHelpers.ComputerPicker -contains $pName -or $inputHelpers.UserPicker -contains $pName -or $inputHelpers.GroupPicker -contains $pName -or $inputHelpers.MemberPicker -contains $pName -or $inputHelpers.OUPicker -contains $pName) { continue } # Auto-detect folder parameters (explicit folder names OR generic path params) if ($pName -match 'Directory|Folder|FolderPath|DirectoryPath|^Path$|^LiteralPath$') { $inputHelpers.FolderPicker.Add($pName) } # Auto-detect file parameters (only when 'File' is explicitly in the name) elseif ($pName -match 'File|FileName|FilePath') { $inputHelpers.FilePicker.Add($pName) } # Auto-detect filter parameters, apply detected mode elseif ($pName -match '^Filter$|^Include$|^Exclude$') { $inputHelpers.FilterBuilder[$pName] = $filterMode } # Auto-detect OU parameters elseif ($pName -match '^OU$|^SearchBase$|^BaseDN$|^SearchRoot$|^TargetOU$|OrganizationalUnit') { $inputHelpers.OUPicker.Add($pName) } # Auto-detect user parameters elseif ($pName -match '^Owner$|^Manager$|^User$|^UserName$|^SamAccountName$|^UserPrincipalName$|^UPN$') { $inputHelpers.UserPicker.Add($pName) } # Auto-detect group parameters elseif ($pName -match '^Group$|^GroupName$|^MemberOf$|^GroupDN$') { $inputHelpers.GroupPicker.Add($pName) } # Auto-detect member parameters (users or groups) elseif ($pName -match '^Member$|^Members$') { $inputHelpers.MemberPicker.Add($pName) } # Auto-detect computer name parameters elseif ($pName -match 'ComputerName|Computer|Server|ServerName|HostName|Host|^CN$|MachineName|Machine') { $inputHelpers.ComputerPicker.Add($pName) } } } $capturedInputHelpers = $inputHelpers $capturedLayoutStyle = $LayoutStyle $capturedMaxColumns = $MaxColumns $capturedCommand = $Command $capturedExcludes = $ExcludeParameters # Nullify the validated parameter to prevent GetNewClosure from failing Remove-Variable -Name Theme -Scope Local -ErrorAction SilentlyContinue $toolContent = { # Header with command description in a full-width card if ($description -and $description -ne $commandDisplayName) { # Capture description locally for the nested scriptblock (PS 5.1 closure workaround) $aboutText = $description New-UiCard -Header "About" -FullWidth -Content { $colors = Get-ThemeColors $formattedText = ConvertTo-FormattedTextBlock -Text $aboutText -FontSize 12 -Foreground $colors.SecondaryText # Add to current parent $session = Get-UiSession $parent = $session.CurrentParent if ($parent -is [System.Windows.Controls.Panel]) { [void]$parent.Children.Add($formattedText) } elseif ($parent -is [System.Windows.Controls.ContentControl]) { $parent.Content = $formattedText } } } $session = Get-UiSession $colors = Get-ThemeColors $paramsGroupBox = [System.Windows.Controls.GroupBox]::new() # Lines up with the About card $paramsGroupBox.Margin = [System.Windows.Thickness]::new(4, 0, 4, 8) if ($hasMultipleSets) { $setItems = @($parameterSets) $defaultSet = if ($parameterSetName) { $parameterSetName } else { $setItems[0] } # Re-capture values for the nested OnChange closure (PS 5.1 closure workaround) $capturedHelpers = $capturedInputHelpers $capturedCmdInfoForOnChange = $cmdInfo $capturedCmdForOnChange = $capturedCommand $capturedExcludesForOnChange = $capturedExcludes $capturedShowParamTypeForOnChange = $capturedShowParamType $capturedDescriptionsForOnChange = $paramDescriptions $capturedDefaultsForOnChange = if ($uiDef.ParameterDefaults) { $uiDef.ParameterDefaults } else { @{} } $capturedIncludeCommonForOnChange = [bool]$uiDef.IncludeCommon $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 = "Parameters" $headerText.VerticalAlignment = 'Center' $headerText.FontWeight = 'SemiBold' [System.Windows.Controls.Grid]::SetColumn($headerText, 0) [void]$headerGrid.Children.Add($headerText) $comboResult = New-UiDropdownButton -Items $setItems -Default $defaultSet -Variable 'selectedParameterSet' -Icon 'Filter' -Tooltip "Parameter Set: $defaultSet" -ShowText -NoAutoAdd -OnChange { param($newSet) $sess = Get-UiSession # Skip if selecting the same set that's already active $lastSet = $sess.GetControl('_uiTool_lastParamSet') if ($lastSet -and $lastSet.Tag -eq $newSet) { return } $paramsPanel = $sess.GetControl('_uiTool_paramsContent') if (!$paramsPanel) { return } # Guard against null command info (PS 5.1 closure issue) if (!$capturedCmdInfoForOnChange) { Write-Warning "Parameter set switch failed: command info not captured" return } # The captured CommandInfo, used as is. Fetching it again can miss a function that has since been extracted away. $cmdInfo = $capturedCmdInfoForOnChange $commonParams = @([System.Management.Automation.Cmdlet]::CommonParameters) + @([System.Management.Automation.Cmdlet]::OptionalCommonParameters) $excludeList = @($capturedExcludesForOnChange) if (!$capturedIncludeCommonForOnChange) { $excludeList += $commonParams } # Get the parameter set definition to check mandatory correctly $paramSetDef = $cmdInfo.ParameterSets | Where-Object { $_.Name -eq $newSet } # Suppress DynamicParam errors when iterating parameters (extracted functions lack module context) $oldErrorAction = $ErrorActionPreference $ErrorActionPreference = 'SilentlyContinue' $newParams = [System.Collections.Generic.List[object]]::new() foreach ($paramName in $cmdInfo.Parameters.Keys) { if ($excludeList -contains $paramName) { continue } $param = $cmdInfo.Parameters[$paramName] $inSet = $param.ParameterSets.ContainsKey($newSet) -or $param.ParameterSets.ContainsKey('__AllParameterSets') if (!$inSet) { continue } # Check mandatory for THIS specific parameter set $isMandatoryInSet = $false if ($paramSetDef) { $paramInSet = $paramSetDef.Parameters | Where-Object { $_.Name -eq $paramName } if ($paramInSet) { $isMandatoryInSet = $paramInSet.IsMandatory } } # A switch is "set-defining" if its name matches the parameter set name # AND the set has no other mandatory parameters $isSetDefiningSwitch = $false if ($param.ParameterType -eq [switch]) { if ($paramName -eq $newSet) { $hasMandatoryParams = $paramSetDef.Parameters | Where-Object { $_.IsMandatory } | Select-Object -First 1 if (!$hasMandatoryParams) { $isSetDefiningSwitch = $true } } } $newParams.Add([PSCustomObject]@{ Name = $paramName Type = $param.ParameterType IsMandatory = $isMandatoryInSet -or $isSetDefiningSwitch ValidateSet = ($param.Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateSetAttribute] }).ValidValues ValidateRange = $param.Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateRangeAttribute] } | Select-Object -First 1 DefaultValue = $capturedDefaultsForOnChange[$paramName] HasDefault = $capturedDefaultsForOnChange.ContainsKey($paramName) IsSwitch = $param.ParameterType -eq [switch] Position = ($param.Attributes | Where-Object { $_ -is [System.Management.Automation.ParameterAttribute] }).Position | Where-Object { $_ -ge 0 } | Select-Object -First 1 }) } # Restore error action $ErrorActionPreference = $oldErrorAction # Sort by mandatory first, then position, then alphabetical (matches initial load) $newParams = $newParams | Sort-Object @{Expression={!$_.IsMandatory}}, @{Expression={if ($null -eq $_.Position) { 999 } else { $_.Position }}}, Name # Track the current set $tracker = $sess.GetControl('_uiTool_lastParamSet') if ($tracker) { $tracker.Tag = $newSet } # Descriptions were loaded at startup, so the help does not get parsed twice. $descriptions = $capturedDescriptionsForOnChange # Determine if we're in wrap mode by checking panel type $isWrapMode = $paramsPanel -is [System.Windows.Controls.WrapPanel] # Clear and rebuild with correct layout mode $paramsPanel.Children.Clear() Initialize-UiToolParameters -TargetPanel $paramsPanel -Parameters $newParams -Descriptions $descriptions -ShowParamType:$capturedShowParamTypeForOnChange -InputHelpers $capturedHelpers -UseWrapLayout:$isWrapMode # Manually apply column widths since SizeChanged won't fire (panel size didn't change) if ($isWrapMode -and $paramsPanel.Tag -is [hashtable] -and $paramsPanel.Tag.MaxColumns -gt 0) { $maxCols = $paramsPanel.Tag.MaxColumns $paddingBuffer = 16 $availableWidth = $paramsPanel.ActualWidth - $paddingBuffer if ($availableWidth -gt 0) { $minColumnWidth = 150 $possibleCols = [Math]::Max(1, [Math]::Floor($availableWidth / $minColumnWidth)) $actualCols = [Math]::Min($possibleCols, $maxCols) $actualCols = [Math]::Max($actualCols, 1) $childWidth = [Math]::Floor(($availableWidth / $actualCols) - 8) foreach ($child in $paramsPanel.Children) { if ($child -is [System.Windows.FrameworkElement]) { if ($child.Tag -eq 'FullWidth') { continue } $child.Width = $childWidth } } } } # Update stored param info and validate Run button state $sess.Variables['_uiTool_paramInfo'] = $newParams Update-UiToolRunButtonState Write-Debug "Switched to parameter set: $newSet" }.GetNewClosure() $comboContainer = $comboResult.Container $comboContainer.Margin = [System.Windows.Thickness]::new(8, 0, 0, 0) [System.Windows.Controls.Grid]::SetColumn($comboContainer, 1) [void]$headerGrid.Children.Add($comboContainer) $paramsGroupBox.Header = $headerGrid } else { $paramsGroupBox.Header = "Parameters" } Set-GroupBoxStyle -GroupBox $paramsGroupBox # Create inner panel for parameters (StackPanel or WrapPanel based on LayoutStyle) if ($capturedLayoutStyle -eq 'Wrap') { $paramsContent = [System.Windows.Controls.WrapPanel]@{ Orientation = 'Horizontal' HorizontalAlignment = 'Stretch' } # Store MaxColumns on Tag so it's accessible during param set switching $paramsContent.Tag = @{ MaxColumns = $capturedMaxColumns } # Add responsive sizing when MaxColumns is specified if ($capturedMaxColumns -gt 0) { $paramsContent.Add_SizeChanged({ param($sender, $eventArgs) # Read MaxColumns from Tag $maxCols = 2 if ($sender.Tag -is [hashtable] -and $sender.Tag.MaxColumns) { $maxCols = $sender.Tag.MaxColumns } $paddingBuffer = 16 $availableWidth = $sender.ActualWidth - $paddingBuffer if ($availableWidth -le 0) { return } # Calculate column width based on MaxColumns $minColumnWidth = 150 $possibleCols = [Math]::Max(1, [Math]::Floor($availableWidth / $minColumnWidth)) $actualCols = [Math]::Min($possibleCols, $maxCols) $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]) { if ($child.Tag -eq 'FullWidth') { continue } $child.Width = $childWidth } } }) } } else { $paramsContent = [System.Windows.Controls.StackPanel]::new() $paramsContent.Orientation = 'Vertical' } $paramsGroupBox.Content = $paramsContent $session.AddControlSafe('_uiTool_paramsContent', $paramsContent) # Create a hidden tracker for the last selected parameter set (to avoid redundant refreshes) $paramSetTracker = [System.Windows.Controls.TextBlock]::new() $paramSetTracker.Visibility = 'Collapsed' $paramSetTracker.Tag = $parameterSetName $session.AddControlSafe('_uiTool_lastParamSet', $paramSetTracker) # Add to current parent with full-width constraint $parent = $session.CurrentParent if ($parent -is [System.Windows.Controls.Panel]) { $parent.Children.Add($paramsGroupBox) | Out-Null } elseif ($parent -is [System.Windows.Controls.ContentControl]) { $parent.Content = $paramsGroupBox } Set-FullWidthConstraint -Control $paramsGroupBox -Parent $parent -FullWidth # Build initial parameters using the helper Initialize-UiToolParameters -TargetPanel $paramsContent -Parameters $targetParams -Descriptions $paramDescriptions -ThemeColors $colors -ShowParamType:$capturedShowParamType -InputHelpers $capturedInputHelpers -UseWrapLayout:($capturedLayoutStyle -eq 'Wrap') # Store parameter info in session for validation/clear scripts (works for local functions) $session.Variables['_uiTool_paramInfo'] = $targetParams $session.PSBase.CurrentDefinition = $uiDef New-UiSeparator # Display name for button label $cmdDisplayName = $capturedCommandDisplayName # Action buttons panel. The buttons hold no state and read the command off SessionContext.CurrentDefinition. New-UiPanel -Orientation Horizontal { # Stateless validation script, reading the command off the session. $validateScript = { Invoke-UiToolValidation } # Stateless run script, reading the command off the session. $runScript = { Invoke-UiToolAction } $runBtnParams = @{ Text = "Run $cmdDisplayName" Icon = 'Play' Accent = $true ValidateScript = $validateScript Action = $runScript ResultActions = $ResultActions SingleSelect = $SingleSelect } New-UiButton @runBtnParams # Store Run button reference for enable/disable based on mandatory params $session = Get-UiSession $parent = $session.CurrentParent if ($parent -is [System.Windows.Controls.Panel] -and $parent.Children.Count -gt 0) { $runBtn = $parent.Children[$parent.Children.Count - 1] $session.Variables['_uiTool_runButton'] = $runBtn } # Stateless clear script, reading the parameter names off the session. $clearScript = { Clear-UiToolParameters } New-UiButton -Text "Clear" -Icon "Delete" -NoAsync -Action $clearScript # Stateless help script, reading the command off the session. $helpScript = { Show-UiToolHelp } New-UiButton -Text "Help" -Icon "Help" -ScrollToTop -Action $helpScript } # Initial validation to set Run button state based on mandatory params Update-UiToolRunButtonState }.GetNewClosure() if ($isEmbedded) { # Host build context, so the controls drop straight in. & $toolContent } elseif ($isChildWindow) { # A click handler runs this, already on the host UI thread because New-UiButton's AST scan flipped it to sync. $childParams = @{ Title = $capturedTitle Width = if ($capturedWidthExplicit) { $capturedWidth } else { 800 } } # A tier field form left a third of the old fixed 600 empty; now it sizes to content unless its explictly set if ($capturedHeightExplicit) { $childParams.Height = $capturedHeight } else { $childParams.SizeToContent = $true } New-UiChildWindow @childParams -Content $toolContent } else { # No host at all, so a top level standalone window. $windowParams = @{ Title = $capturedTitle Width = $capturedWidth HideThemeButton = $capturedHideThemeButton } if ($capturedHeightExplicit) { $windowParams.Height = $capturedHeight } if ($capturedTheme) { $windowParams.Theme = $capturedTheme } New-UiWindow @windowParams -Content $toolContent } } |