public/controls/New-UiButton.ps1
|
function New-UiButton { <# .SYNOPSIS Creates a styled button with async action support. .DESCRIPTION Creates a themed WPF button that executes actions asynchronously by default, with full output streaming, console interception, and result handling. Can be used standalone or as part of other layouts (toolbars, forms, etc.). Use -Action to provide an inline scriptblock, or -File to run an external script file. These parameters are mutually exclusive. Yeah, there's a lot of parameters here. Splitting them into separate cmdlets would mean more boilerplate for every button. They group logically: appearance (Text/Icon/Width), execution (Action/NoAsync/NoWait), data binding (LinkedVariables/Capture), and result handling (ResultActions). You configure all of these together when defining a button, not separately. .PARAMETER Text The button label text. .PARAMETER Action The scriptblock to execute when clicked. Mutually exclusive with -File. .PARAMETER File Path to a script file to execute when clicked. Supports .ps1, .bat, .cmd, .vbs, and .exe files. The file must exist at button creation time. Mutually exclusive with -Action. .PARAMETER ArgumentList Hashtable of arguments to pass to the script file. For .ps1 files, these are splatted as parameters. For other file types, values are passed as command-line arguments. .PARAMETER Icon Optional icon name from Segoe MDL2 Assets shown before the text. .PARAMETER Accent Use accent color styling for the button. .PARAMETER Width Button width in pixels. Defaults to auto-sizing. .PARAMETER Height Button height in pixels. Defaults to 28. .PARAMETER NoAsync Execute synchronously on the UI thread (blocks UI). .PARAMETER NoWait Execute async with output window, but don't block the parent window. Other buttons remain clickable while this action runs. The clicked button is still disabled to prevent duplicate execution of the same action. .PARAMETER NoOutput Execute async but don't show output window. .PARAMETER NoInteractive Use fast pooled execution. The action must not require interactive input (Read-Host, Get-Credential, etc.). If interactive input is attempted, an error is thrown. Use this for pure data-processing actions. .PARAMETER HideEmptyOutput Show output window only when there's actual content. .PARAMETER ResultActions Hashtable array defining actions for DataGrid results. .PARAMETER SingleSelect If specified, ResultActions work with single selection. .PARAMETER LinkedVariables Variable names to capture from caller's scope. .PARAMETER LinkedFunctions Function names to capture from caller's scope. .PARAMETER LinkedModules Module paths to import in the async runspace. .PARAMETER Capture Variable names to capture from the runspace after execution completes. Captured variables are stored in the session and available to subsequent button actions, and persist in global scope after the window closes. .PARAMETER Parameters Hashtable of parameters to pass to the action. .PARAMETER Variables Hashtable of variables to inject into the action. .PARAMETER OutputTitle Title for the output window. Defaults to button text. .PARAMETER GridColumn If specified, sets Grid.Column attached property. .PARAMETER GridRow If specified, sets Grid.Row attached property. .PARAMETER EnabledWhen Conditional enabling based on another control's state. Accepts either: - A control proxy (e.g., $toggleControl) - enables when that control is truthy - A scriptblock (e.g., { $toggle -and $userName }) - enables when expression is true Truthy values: CheckBox=checked, TextBox=non-empty, ComboBox=has selection. .PARAMETER Variable Optional name to register the button for -SubmitButton lookups. When specified, inputs using -SubmitButton with this name will trigger the button's click event when Enter is pressed. .PARAMETER ValidateScript ScriptBlock for custom validation. Runs before the action, receives control values as variables. .PARAMETER WPFProperties Hashtable of WPF properties to apply to the button. .EXAMPLE New-UiButton -Text "Save" -Icon "Save" -Accent -Action { Save-Data } .EXAMPLE New-UiButton -Text "Run Query" -Action { Get-Process } -HideEmptyOutput .EXAMPLE New-UiButton -Text "Deploy" -File "C:\Scripts\Deploy.ps1" -ArgumentList @{ Environment = 'Prod' } .EXAMPLE New-UiButton -Text "Backup" -File ".\scripts\backup.bat" -NoOutput .EXAMPLE # Capture variables for use in other buttons or after window closes New-UiButton -Text "Load" -Capture services, loadTime -Action { $services = Get-Service | Where-Object Status -eq 'Running' $loadTime = Get-Date } # In another button, $services and $loadTime are now available .EXAMPLE # In a custom layout $toolbar = [System.Windows.Controls.StackPanel]@{ Orientation = 'Horizontal' } New-UiButton -Text "Add" -Icon "Add" -Action { Add-Item } New-UiButton -Text "Delete" -Icon "Delete" -Action { Remove-Item } #> [CmdletBinding(DefaultParameterSetName = 'ScriptBlock')] param( [Parameter(Mandatory)] [string]$Text, [Parameter(Mandatory, ParameterSetName = 'ScriptBlock')] [scriptblock]$Action, [Parameter(Mandatory, ParameterSetName = 'File')] [string]$File, [Parameter(ParameterSetName = 'File')] [hashtable]$ArgumentList, [switch]$Accent, [int]$Width, [int]$Height = 28, # Action execution parameters [switch]$NoAsync, [switch]$NoWait, [switch]$NoOutput, [switch]$NoInteractive, [switch]$HideEmptyOutput, [hashtable[]]$ResultActions, [switch]$SingleSelect, [string[]]$LinkedVariables, [string[]]$LinkedFunctions, [string[]]$LinkedModules, [string[]]$Capture, [hashtable]$Parameters, [hashtable]$Variables, [string]$OutputTitle, # Pre-action validation script - runs synchronously before Action # Should return $null or empty array on success, or array of error strings on failure [scriptblock]$ValidateScript, # Layout parameters [int]$GridColumn = -1, [int]$GridRow = -1, [Parameter()] [object]$EnabledWhen, [Parameter()] [string]$Variable, [Parameter()] [hashtable]$WPFProperties ) DynamicParam { Get-IconDynamicParameter -ParameterName 'Icon' } begin { $Icon = $PSBoundParameters['Icon'] } process { # Can't use both - pick one if ($NoOutput -and $HideEmptyOutput) { throw "Parameters -NoOutput and -HideEmptyOutput are mutually exclusive. Use only one." } # Catch bad variable names early instead of failing mid-execution if ($Capture) { foreach ($varName in $Capture) { if (![PsUi.Constants]::IsValidIdentifier($varName)) { throw "Invalid variable name for -Capture: '$varName'. Names must start with a letter or underscore and contain only letters, numbers, underscores, or hyphens." } } } # Convert -File parameter to an Action scriptblock if ($PSCmdlet.ParameterSetName -eq 'File') { $Action = ConvertTo-UiFileAction -File $File -ArgumentList $ArgumentList } $session = Assert-UiSession -CallerName 'New-UiButton' Write-Debug "Text='$Text', Icon='$Icon', Accent=$Accent, NoAsync=$NoAsync" $colors = Get-ThemeColors $parent = $session.CurrentParent Write-Debug "Parent: $($parent.GetType().Name)" $btnWidth = if ($Width -gt 0) { $Width } else { [double]::NaN } $button = [PsUi.ControlFactory]::CreateButton($Text, $btnWidth, $Height) # Configure button content (icon + text or just text) $button.Padding = [System.Windows.Thickness]::new(8, 4, 8, 4) $button.Margin = [System.Windows.Thickness]::new(4) $iconText = if ($Icon) { [PsUi.ModuleContext]::GetIcon($Icon) } else { $null } if ($iconText) { # Create horizontal stack for icon + text $contentPanel = [System.Windows.Controls.StackPanel]@{ Orientation = 'Horizontal' VerticalAlignment = 'Center' } $iconBlock = [System.Windows.Controls.TextBlock]@{ Text = $iconText FontFamily = [System.Windows.Media.FontFamily]::new('Segoe MDL2 Assets') FontSize = 12 FontWeight = 'Light' VerticalAlignment = 'Center' Margin = [System.Windows.Thickness]::new(0, 0, 6, 0) } # Accent buttons use contrasting foreground, regular buttons use accent color for icon if ($Accent) { $iconBlock.Tag = 'AccentButtonIcon' $iconBlock.Foreground = ConvertTo-UiBrush $colors.AccentHeaderFg } else { $iconBlock.Tag = 'AccentBrush' $iconBlock.SetResourceReference([System.Windows.Controls.TextBlock]::ForegroundProperty, 'AccentBrush') } [PsUi.ThemeEngine]::RegisterElement($iconBlock) [void]$contentPanel.Children.Add($iconBlock) $textBlock = [System.Windows.Controls.TextBlock]@{ Text = $Text VerticalAlignment = 'Center' TextTrimming = 'CharacterEllipsis' } # Set foreground: accent buttons use contrasting color, regular buttons use ButtonForeground if ($Accent) { $textBlock.Tag = 'AccentButtonText' $textBlock.Foreground = ConvertTo-UiBrush $colors.AccentHeaderFg } else { $textBlock.Tag = 'ButtonFgBrush' $textBlock.SetResourceReference([System.Windows.Controls.TextBlock]::ForegroundProperty, 'ButtonForegroundBrush') } [void]$contentPanel.Children.Add($textBlock) # Always use ViewBox for icon+text buttons to handle overflow gracefully $viewBox = [System.Windows.Controls.Viewbox]@{ StretchDirection = 'DownOnly' Stretch = 'Uniform' } if ($Width -gt 0) { $viewBox.MaxWidth = $Width - 16 $viewBox.MaxHeight = $Height - 8 } $viewBox.Child = $contentPanel $button.Content = $viewBox } else { # Just text - use ViewBox for auto-scaling if needed $textBlock = [System.Windows.Controls.TextBlock]@{ Text = $Text TextAlignment = 'Center' VerticalAlignment = 'Center' } # Set foreground: accent buttons use contrasting color, regular buttons use ButtonForeground if ($Accent) { $textBlock.Tag = 'AccentButtonText' $textBlock.Foreground = ConvertTo-UiBrush $colors.AccentHeaderFg } else { $textBlock.Tag = 'ButtonFgBrush' $textBlock.SetResourceReference([System.Windows.Controls.TextBlock]::ForegroundProperty, 'ButtonForegroundBrush') } if ($Width -gt 0) { # Fixed width - use ViewBox for scaling $viewBox = [System.Windows.Controls.Viewbox]@{ StretchDirection = 'DownOnly' Stretch = 'Uniform' MaxWidth = $Width - 16 MaxHeight = $Height - 8 } $viewBox.Child = $textBlock $button.Content = $viewBox } else { $button.Content = $textBlock } } # Apply grid positioning if specified if ($GridColumn -ge 0) { [System.Windows.Controls.Grid]::SetColumn($button, $GridColumn) } if ($GridRow -ge 0) { [System.Windows.Controls.Grid]::SetRow($button, $GridRow) } # Context capture via Get-UiActionContext # Captures variables, functions, and modules from caller scope using AST analysis $ctxParams = @{ Action = $Action LinkedVariables = $LinkedVariables LinkedFunctions = $LinkedFunctions LinkedModules = $LinkedModules ExplicitVariables = $Variables } $actionContext = Get-UiActionContext @ctxParams $capturedVars = $actionContext.CapturedVars $capturedFuncs = $actionContext.CapturedFuncs $resolvedModules = $actionContext.LinkedModules # Store action context in button tag for click handler $displayTitle = if ($OutputTitle) { $OutputTitle } else { $Text } $button.Tag = @{ Action = $Action Parameters = $Parameters WindowRef = $session.Window Text = $displayTitle NoAsync = $NoAsync NoWait = $NoWait IsCSharpLoaded = [PsUi.ModuleContext]::IsInitialized ResultActions = $ResultActions SingleSelect = $SingleSelect CapturedVars = $capturedVars CapturedFuncs = $capturedFuncs LinkedModules = $resolvedModules Capture = $Capture NoOutput = $NoOutput NoInteractive = $NoInteractive HideEmptyOutput = $HideEmptyOutput ValidateScript = $ValidateScript IsAccent = $Accent.IsPresent } # Apply accent styling AFTER Tag is set (so Set-ButtonStyle can merge IsAccent properly) if ($Accent) { Set-ButtonStyle -Button $button -Accent } # Click handler $button.Add_Click({ param($sender, $eventArgs) $ctx = $this.Tag if ($null -eq $ctx) { Write-Warning "[New-UiButton] Tag is null - cannot execute action" return } Write-Debug "Click handler fired, Action is null: $($null -eq $ctx.Action)" $btn = $this $originalContent = $btn.Content # Capture current button size before swapping content to spinner $originalMinWidth = $btn.MinWidth $originalMinHeight = $btn.MinHeight if ($btn.ActualWidth -gt 0) { $btn.MinWidth = $btn.ActualWidth } if ($btn.ActualHeight -gt 0) { $btn.MinHeight = $btn.ActualHeight } # Run pre-validation script synchronously if provided if ($ctx.ValidateScript) { try { $validationErrors = & $ctx.ValidateScript if ($validationErrors -and $validationErrors.Count -gt 0) { # Use [char]0x2022 for bullet point (PS 5.1 compatible, unlike `u{2022}) $bullet = [char]0x2022 $errorMessage = "Please fix the following issues:`n`n" + (($validationErrors | ForEach-Object { " $bullet $_" }) -join "`n") Show-UiMessageDialog -Title 'Validation Error' -Message $errorMessage -Icon Warning -Buttons OK | Out-Null return } } catch { Show-UiMessageDialog -Title 'Validation Error' -Message "Validation failed: $_" -Icon Error -Buttons OK | Out-Null return } } $themeColors = Get-ThemeColors # Use contrasting spinner color for accent buttons $isAccentButton = $btn.Tag -is [System.Collections.IDictionary] -and $btn.Tag['IsAccent'] $spinnerColor = if ($isAccentButton) { $themeColors.AccentHeaderFg } else { $themeColors.Accent } $spinner = New-UiLoadingSpinner -Size 14 -Color $spinnerColor $btn.Content = $spinner $btn.IsEnabled = $false try { $forceSynchronous = $ctx.NoAsync if (!$forceSynchronous -and $ctx.Action) { $actionText = $ctx.Action.ToString() if ($actionText -match 'New-UiChildWindow') { $forceSynchronous = $true } } if ($forceSynchronous -eq $true) { $result = if ($ctx.Parameters) { & $ctx.Action @($ctx.Parameters) } else { & $ctx.Action } $btn.Content = $originalContent $btn.MinWidth = $originalMinWidth $btn.MinHeight = $originalMinHeight $btn.IsEnabled = $true } elseif ($ctx.IsCSharpLoaded) { $executor = [PsUi.AsyncExecutor]::new() # Store executor in session for Stop-UiAsync cancellation $execSession = [PsUi.SessionManager]::Current if ($execSession) { $execSession.ActiveExecutor = $executor } # Set the UI dispatcher for proper thread marshaling (critical for NoOutput mode) $executor.UiDispatcher = $btn.Dispatcher $currentThemeColors = Get-ThemeColors $varsWithTheme = if ($ctx.CapturedVars) { $ctx.CapturedVars.Clone() } else { @{} } if ($currentThemeColors) { $varsWithTheme['__WPFThemeColors'] = $currentThemeColors } # Inject credentials from session.Variables at CLICK TIME (not capture time) $clickSession = [PsUi.SessionManager]::Current if ($clickSession) { foreach ($credKvp in $clickSession.Variables.GetEnumerator()) { $credName = $credKvp.Key $credWrapper = $credKvp.Value # Skip if already in captured vars if ($varsWithTheme.ContainsKey($credName)) { continue } # Credential wrappers need their inner controls extracted if ($credWrapper -and $credWrapper.PSObject.TypeNames -contains 'PsUi.CredentialControl') { $userBox = $credWrapper.UsernameBox $passBox = $credWrapper.PasswordBox if ($userBox -and $passBox) { $username = $userBox.Text $secPass = $passBox.SecurePassword if (![string]::IsNullOrWhiteSpace($username) -and $secPass.Length -gt 0) { $cred = [System.Management.Automation.PSCredential]::new($username, $secPass) $varsWithTheme[$credName] = $cred Write-Debug "Injected credential '$credName' at click time" } } } } } if ($ctx.NoOutput) { # For NoOutput mode only: register handlers to restore button and dispose executor # Show-UiOutput modes handle this themselves via the output window lifecycle $buttonToRestore = $btn $contentToRestore = $originalContent $minWidthToRestore = $originalMinWidth $minHeightToRestore = $originalMinHeight $executorToDispose = $executor # Wire up window close handler to prevent zombie executors # If window closes while task runs, cancel executor to avoid crash on dead dispatcher # # GetNewClosure() captures the entire scope into a dynamic module. If you are # doing something insane like creating 100 buttons in a loop where the scope has a # giant array, congrats - you now have 100 references to that array. For normal forms # with 5-20 buttons this is fine. We clean up on window close so nothing leaks after. # If you hit memory issues, refactor your loop or stop holding massive objects in scope. $parentWindow = $ctx.WindowRef if ($parentWindow) { $closedHandler = [System.EventHandler]{ param($sender, $eventArgs) if ($executorToDispose.IsRunning) { try { $executorToDispose.Cancel() } catch { <# Best-effort cleanup #> } } }.GetNewClosure() $parentWindow.Add_Closed($closedHandler) # Store handler reference so we can remove it when task completes $windowToCleanup = $parentWindow $handlerToRemove = $closedHandler } # Add input providers unless NoInteractive was specified # Without providers, executor uses fast pooled runspace but throws on Read-Host if (!$ctx.NoInteractive) { $inputParams = @{ Executor = $executor DebugEnabled = $false } Add-InputProviders @inputParams } # Shared cleanup: unhook window handler, restore button state, dispose executor. # Called from OnComplete, OnError, and OnCancelled to avoid triple copy-paste. $restoreAndDispose = { param([string]$CallerName) if ($windowToCleanup -and $handlerToRemove) { try { $windowToCleanup.Remove_Closed($handlerToRemove) } catch { <# Window may already be closed #> } } try { if ($buttonToRestore.Dispatcher.HasShutdownStarted) { return } $buttonToRestore.Dispatcher.Invoke([Action]{ $buttonToRestore.Content = $contentToRestore $buttonToRestore.MinWidth = $minWidthToRestore $buttonToRestore.MinHeight = $minHeightToRestore $buttonToRestore.IsEnabled = $true }) } catch { Write-Debug "$CallerName UI restore skipped (window closed): $_" } try { $executorToDispose.Dispose() } catch { Write-Debug "$CallerName dispose error: $_" } }.GetNewClosure() $executor.add_OnComplete({ & $restoreAndDispose 'OnComplete' }.GetNewClosure()) $executor.add_OnError({ param($errorRecord) # Show error dialog since NoOutput mode has no console to display errors if ($errorRecord) { try { if (!$buttonToRestore.Dispatcher.HasShutdownStarted) { $errorMsg = $errorRecord.ToString() $buttonToRestore.Dispatcher.Invoke([Action]{ Show-UiMessageDialog -Title 'Action Error' -Message $errorMsg -Icon Error }) } } catch { Write-Debug "OnError dialog skipped (window closed): $_" } } & $restoreAndDispose 'OnError' }.GetNewClosure()) $executor.add_OnCancelled({ & $restoreAndDispose 'OnCancelled' }.GetNewClosure()) # Set capture variables if specified if ($ctx.Capture) { $executor.CaptureVariables = [string[]]$ctx.Capture } # Fire and forget - handlers will restore button when done $executor.ExecuteAsync( $ctx.Action, $ctx.Parameters, $varsWithTheme, $ctx.CapturedFuncs, [string[]]@($ctx.LinkedModules | Where-Object { $_ }) ) return } else { # Output window path (HideEmptyOutput and normal share the same flow) try { $outParams = @{ Executor = $executor Title = $ctx.Text ParentWindow = $ctx.WindowRef Action = $ctx.Action Parameters = $ctx.Parameters ResultActions = $ctx.ResultActions SingleSelect = $ctx.SingleSelect LinkedVariableValues = $varsWithTheme LinkedFunctionDefinitions = $ctx.CapturedFuncs LinkedModules = $ctx.LinkedModules Capture = $ctx.Capture NoWait = $ctx.NoWait } if ($ctx.HideEmptyOutput) { $outParams['HideUntilContent'] = $true } $outputWindow = Show-UiOutput @outParams # NoWait mode: wire up Closed event to restore button when output window closes if ($ctx.NoWait -and $outputWindow) { $buttonToRestore = $btn $contentToRestore = $originalContent $minWidthToRestore = $originalMinWidth $minHeightToRestore = $originalMinHeight $outputWindow.Add_Closed({ $buttonToRestore.Content = $contentToRestore $buttonToRestore.MinWidth = $minWidthToRestore $buttonToRestore.MinHeight = $minHeightToRestore $buttonToRestore.IsEnabled = $true }.GetNewClosure()) return } } catch { Write-Warning "Output window error: $($_.Exception.Message)" # Kill the executor if it's still running try { if ($executor.IsRunning) { $executor.Cancel() } $executor.Dispose() } catch { Write-Debug "Output cleanup error: $_" } } } # Restore button after output window closes (whether success or failure) $btn.Content = $originalContent $btn.MinWidth = $originalMinWidth $btn.MinHeight = $originalMinHeight $btn.IsEnabled = $true } else { Write-Warning "Async unavailable. Running synchronously." $result = if ($ctx.Parameters) { & $ctx.Action @($ctx.Parameters) } else { & $ctx.Action } $btn.Content = $originalContent $btn.MinWidth = $originalMinWidth $btn.MinHeight = $originalMinHeight $btn.IsEnabled = $true } } catch { $btn.Content = $originalContent $btn.MinWidth = $originalMinWidth $btn.MinHeight = $originalMinHeight $btn.IsEnabled = $true # Log error details for debugging Write-Debug "Action error: $($_.Exception.GetType().Name) - $($_.Exception.Message)" Write-Debug "Stack: $($_.ScriptStackTrace)" # Show error dialog for sync execution errors Show-UiMessageDialog -Title "Error: $($ctx.Text)" -Message $_.Exception.Message -Icon Error -Buttons OK | Out-Null } }) # Apply custom WPF properties if specified if ($WPFProperties) { Set-UiProperties -Control $button -Properties $WPFProperties } # Wire up conditional enabling if specified if ($EnabledWhen) { Register-UiCondition -TargetControl $button -Condition $EnabledWhen } # Register button by name for -SubmitButton lookups if ($Variable) { $session.RegisterButton($Variable, $button) } Write-Debug "Adding to $($parent.GetType().Name)" $addedToParent = $false if ($parent -is [System.Windows.Controls.Panel]) { [void]$parent.Children.Add($button) $addedToParent = $true } elseif ($parent -is [System.Windows.Controls.ItemsControl]) { [void]$parent.Items.Add($button) $addedToParent = $true } elseif ($parent -is [System.Windows.Controls.ContentControl]) { $parent.Content = $button $addedToParent = $true } # Only return button if not added to parent (for manual layout scenarios) if (!$addedToParent) { return $button } } } |