public/dialogs/Show-UiMessageDialog.ps1
|
function Show-UiMessageDialog { <# .SYNOPSIS Displays a themed message dialog with customizable buttons and icons. .DESCRIPTION Shows a custom WPF dialog that respects the current theme. Replaces standard MessageBox. A message taller than the screen scrolls inside the dialog, which stays away from the taskbar with its buttons in view. .PARAMETER Title Dialog window title. .PARAMETER Message Message text to display. .PARAMETER Buttons Button configuration: OK, OKCancel, YesNo, or YesNoCancel. .PARAMETER Icon Icon type: Info, Warning, Error, Question, or None. .PARAMETER PowerShell When used, displays the message in a PowerShell console-styled code viewer. Uses Consolas font, blue background (#012456), white text, and makes the dialog larger (700x500) and resizable with horizontal/vertical scrollbars. For source code and command snippets. .PARAMETER CustomButtons Custom buttons, rendered left to right in declaration order. Pass a { New-UiDialogButton ... } definition block, an array of New-UiDialogButton output, or the legacy hashtable array where each hashtable has: - Label: The button text (required) - Value: The value returned when clicked (required) - IsDefault: If true, Enter activates this button (optional) - IsAccent: If true, button uses accent color (optional) - IsCancel: If true, Esc activates this button (optional) When provided, the -Buttons parameter is ignored. .PARAMETER ThemeColors Override theme colors for this dialog. Pass a colors hashtable directly. .EXAMPLE Show-UiMessageDialog -Title 'Confirmation' -Message 'Are you sure?' -Buttons YesNo -Icon Question .EXAMPLE $answer = Show-UiMessageDialog -Title 'Unsaved Changes' -Message 'Save changes?' -Icon Question -CustomButtons { New-UiDialogButton 'Save' -Accent -Default New-UiDialogButton 'Discard' New-UiDialogButton 'Cancel' -Cancel } .EXAMPLE $result = Show-UiMessageDialog -Title 'Success' -Message 'Operation completed!' -Buttons OK -Icon Info .EXAMPLE Show-UiMessageDialog -Title 'Source Code' -Message $scriptBlock.ToString() -PowerShell .EXAMPLE # Legacy hashtable form, still supported $buttons = @( @{ Label = 'Save'; Value = 'Save'; IsAccent = $true; IsDefault = $true } @{ Label = 'Discard'; Value = 'Discard' } @{ Label = 'Cancel'; Value = 'Cancel'; IsCancel = $true } ) Show-UiMessageDialog -Title 'Unsaved Changes' -Message 'Save changes?' -CustomButtons $buttons -Icon Question #> [CmdletBinding()] param( [string]$Title = 'Message', [Parameter(Mandatory)] [string]$Message, [ValidateSet('OK', 'OKCancel', 'YesNo', 'YesNoCancel')] [string]$Buttons = 'OK', [ValidateSet('Info', 'Warning', 'Error', 'Question', 'None')] [string]$Icon = 'Info', [object]$ThemeColors, [switch]$PowerShell, # Untyped: takes a New-UiDialogButton definition block, an array of definitions, or the legacy hashtable array. [array] would swallow a definition block as a one-element array wrapping the scriptblock. [object]$CustomButtons ) Write-Debug "Title='$Title' Buttons='$Buttons' Icon='$Icon' PowerShell=$PowerShell" if ($null -ne $CustomButtons) { $CustomButtons = ConvertTo-UiDefinitionArray -InputObject $CustomButtons -ParameterName '-CustomButtons' -CallerName 'Show-UiMessageDialog' -AllowObject } # Calculate width based on button count - each button is ~90px wide $buttonCount = if ($CustomButtons) { $CustomButtons.Count } else { 3 } $minForButtons = [Math]::Max(420, ($buttonCount * 90) + 50) # Long messages (error dumps, stack traces, joined multi-row failures) get a wide resizable dialog - at 420 a stack line wraps four deep and the whole thing reads like a ransom note. # Short prompts keep the compact fixed layout. $messageLines = @(if ($Message) { $Message -split "`n" } else { '' }) $longestLine = 0 foreach ($msgLine in $messageLines) { if ($msgLine.Length -gt $longestLine) { $longestLine = $msgLine.Length } } $isBigMessage = ($longestLine -gt 80) -or ($messageLines.Count -gt 10) # PowerShell mode uses fixed size; standard mode sizes to content $dialogParams = @{ Title = $Title Width = if ($PowerShell) { 700 } elseif ($isBigMessage) { [Math]::Max(700, $minForButtons) } else { $minForButtons } Height = if ($PowerShell) { 500 } else { 0 } MaxHeight = if ($PowerShell -or $isBigMessage) { 10000 } else { 800 } SizeToContent = if ($PowerShell) { 'Manual' } else { 'Height' } ResizeMode = if ($PowerShell -or $isBigMessage) { 'CanResize' } else { 'NoResize' } AppIdSuffix = 'Message' ThemeColors = $ThemeColors } # Set overlay icon based on message type $overlayGlyph = switch ($Icon) { 'Info' { [PsUi.ModuleContext]::GetIcon('Info') } 'Warning' { [PsUi.ModuleContext]::GetIcon('Alert') } 'Error' { [PsUi.ModuleContext]::GetIcon('Error') } 'Question' { [PsUi.ModuleContext]::GetIcon('Help') } default { [PsUi.ModuleContext]::GetIcon('Info') } } $dialogParams['OverlayGlyph'] = $overlayGlyph # Use standard helper to create the window shell $dialog = New-DialogWindow @dialogParams $window = $dialog.Window $contentPanel = $dialog.ContentPanel $colors = $dialog.Colors # Color for icon matches overlay $iconColor = switch ($Icon) { 'Info' { $colors.Accent } 'Warning' { $colors.Warning } 'Error' { $colors.Error } 'Question' { $colors.Accent } default { $colors.Accent } } # Button panel at bottom using Grid for left/right alignment $buttonBar = [System.Windows.Controls.Grid]::new() $buttonBar.Margin = [System.Windows.Thickness]::new(0, 12, 0, 0) [void]$buttonBar.ColumnDefinitions.Add([System.Windows.Controls.ColumnDefinition]@{ Width = '*' }) [void]$buttonBar.ColumnDefinitions.Add([System.Windows.Controls.ColumnDefinition]@{ Width = 'Auto' }) [System.Windows.Controls.DockPanel]::SetDock($buttonBar, 'Bottom') [void]$contentPanel.Children.Add($buttonBar) # Add copy button for informational dialogs (not Question/choice dialogs) $showCopyButton = $Icon -in @('Info', 'Warning', 'Error') -or $PowerShell if ($showCopyButton) { $copyBtn = [System.Windows.Controls.Button]@{ Height = 28 Padding = [System.Windows.Thickness]::new(10, 4, 10, 4) ToolTip = 'Copy message to clipboard' HorizontalAlignment = 'Left' } # Icon + text content for copy button $copyContent = [System.Windows.Controls.StackPanel]@{ Orientation = 'Horizontal' } $copyIcon = [System.Windows.Controls.TextBlock]@{ Text = [PsUi.ModuleContext]::GetIcon('Copy') FontFamily = [PsUi.ModuleContext]::ActiveIconFontFamily FontSize = 12 Margin = [System.Windows.Thickness]::new(0, 0, 6, 0) VerticalAlignment = 'Center' } $copyText = [System.Windows.Controls.TextBlock]@{ Text = 'Copy' VerticalAlignment = 'Center' } [void]$copyContent.Children.Add($copyIcon) [void]$copyContent.Children.Add($copyText) $copyBtn.Content = $copyContent # Apply standard styling and hook click Set-ButtonStyle -Button $copyBtn $copyBtn.Tag = $Message $copyBtn.Add_Click({ [System.Windows.Clipboard]::SetText($this.Tag) # Brief visual feedback - change icon to checkmark $panel = $this.Content $iconBlock = $panel.Children[0] $originalIcon = $iconBlock.Text $iconBlock.Text = [PsUi.ModuleContext]::GetIcon('Accept') # Reset after 1.5 seconds $timer = [System.Windows.Threading.DispatcherTimer]::new() $timer.Interval = [TimeSpan]::FromMilliseconds(1500) $capturedIconBlock = $iconBlock $capturedOriginal = $originalIcon $timer.Add_Tick({ $capturedIconBlock.Text = $capturedOriginal $this.Stop() }.GetNewClosure()) $timer.Start() }) [System.Windows.Controls.Grid]::SetColumn($copyBtn, 0) [void]$buttonBar.Children.Add($copyBtn) } # Right-aligned button panel for action buttons $buttonPanel = [System.Windows.Controls.StackPanel]@{ Orientation = 'Horizontal' HorizontalAlignment = 'Right' } [System.Windows.Controls.Grid]::SetColumn($buttonPanel, 1) [void]$buttonBar.Children.Add($buttonPanel) # Build message content area - different for PowerShell vs standard mode if ($PowerShell) { # PowerShell console-styled TextBox $codeBox = [System.Windows.Controls.TextBox]@{ Text = $Message IsReadOnly = $true AcceptsReturn = $true TextWrapping = 'NoWrap' VerticalContentAlignment = 'Top' HorizontalScrollBarVisibility = 'Auto' VerticalScrollBarVisibility = 'Auto' FontFamily = [System.Windows.Media.FontFamily]::new('Consolas') FontSize = 13 Background = [System.Windows.Media.BrushConverter]::new().ConvertFrom('#012456') Foreground = [System.Windows.Media.Brushes]::White BorderThickness = [System.Windows.Thickness]::new(1) BorderBrush = [System.Windows.Media.BrushConverter]::new().ConvertFrom('#1E3A5F') Padding = [System.Windows.Thickness]::new(5) Margin = [System.Windows.Thickness]::new(0, 0, 0, 12) } $codeBox.Resources[[System.Windows.SystemColors]::ControlBrushKey] = $codeBox.Background # Add themed context menu for copy/select all $codeBox.ContextMenu = New-TextBoxContextMenu -ReadOnly [void]$contentPanel.Children.Add($codeBox) } else { # Standard message area with optional icon, wrapped in ScrollViewer $scrollViewer = [System.Windows.Controls.ScrollViewer]@{ VerticalScrollBarVisibility = 'Auto' HorizontalScrollBarVisibility = 'Disabled' Padding = [System.Windows.Thickness]::new(0, 0, 8, 0) } $messageGrid = [System.Windows.Controls.Grid]::new() [void]$messageGrid.ColumnDefinitions.Add([System.Windows.Controls.ColumnDefinition]::new()) [void]$messageGrid.ColumnDefinitions.Add([System.Windows.Controls.ColumnDefinition]::new()) $hasIcon = $Icon -ne 'None' if ($hasIcon) { $messageGrid.ColumnDefinitions[0].Width = [System.Windows.GridLength]::new(48) } else { $messageGrid.ColumnDefinitions[0].Width = [System.Windows.GridLength]::new(0) } $messageGrid.ColumnDefinitions[1].Width = [System.Windows.GridLength]::new(1, [System.Windows.GridUnitType]::Star) # Add icon if needed if ($hasIcon) { $iconBlock = [System.Windows.Controls.TextBlock]@{ Text = $overlayGlyph FontFamily = [PsUi.ModuleContext]::ActiveIconFontFamily FontSize = 32 Foreground = ConvertTo-UiBrush $iconColor VerticalAlignment = 'Center' HorizontalAlignment = 'Center' Margin = [System.Windows.Thickness]::new(0, 0, 12, 0) } [System.Windows.Controls.Grid]::SetColumn($iconBlock, 0) [void]$messageGrid.Children.Add($iconBlock) } $messageText = [System.Windows.Controls.TextBlock]@{ Text = $Message FontSize = 13 TextWrapping = 'Wrap' Foreground = ConvertTo-UiBrush $colors.ControlFg VerticalAlignment = 'Top' Margin = [System.Windows.Thickness]::new(0, 0, 0, 0) } [System.Windows.Controls.Grid]::SetColumn($messageText, 1) [void]$messageGrid.Children.Add($messageText) $scrollViewer.Content = $messageGrid [void]$contentPanel.Children.Add($scrollViewer) } # Add buttons - either custom buttons or standard button sets if ($CustomButtons -and $CustomButtons.Count -gt 0) { foreach ($btnDef in $CustomButtons) { $btn = [System.Windows.Controls.Button]@{ Content = $btnDef.Label MinWidth = 70 Height = 28 Margin = [System.Windows.Thickness]::new(6, 0, 0, 0) Padding = [System.Windows.Thickness]::new(14, 4, 14, 4) } if ($btnDef.IsAccent) { Set-ButtonStyle -Button $btn -Accent } else { Set-ButtonStyle -Button $btn } # Store value AND window reference to avoid closure issues $btn.Tag = @{ Value = $btnDef.Value; Window = $window } $btn.Add_Click({ $this.Tag.Window.Tag = $this.Tag.Value $this.Tag.Window.Close() }) [void]$buttonPanel.Children.Add($btn) if ($btnDef.IsDefault) { $btn.IsDefault = $true } if ($btnDef.IsCancel) { $btn.IsCancel = $true } } } else { # Standard button configurations - helper to create styled button $createButton = { param($Text, $IsAccent, $Result, $TargetWindow) $btn = [System.Windows.Controls.Button]@{ Content = $Text Width = 80 Height = 28 Margin = [System.Windows.Thickness]::new(4, 0, 0, 0) } if ($IsAccent) { Set-ButtonStyle -Button $btn -Accent } else { Set-ButtonStyle -Button $btn } $btn.Tag = @{ Result = $Result; Window = $TargetWindow } $btn.Add_Click({ $this.Tag.Window.Tag = $this.Tag.Result; $this.Tag.Window.Close() }) return $btn } switch ($Buttons) { 'OK' { $okBtn = & $createButton 'OK' $true 'OK' $window $okBtn.IsDefault = $true [void]$buttonPanel.Children.Add($okBtn) } 'OKCancel' { $okBtn = & $createButton 'OK' $true 'OK' $window $okBtn.IsDefault = $true [void]$buttonPanel.Children.Add($okBtn) $cancelBtn = & $createButton 'Cancel' $false 'Cancel' $window $cancelBtn.IsCancel = $true [void]$buttonPanel.Children.Add($cancelBtn) } 'YesNo' { $yesBtn = & $createButton 'Yes' $true 'Yes' $window $yesBtn.IsDefault = $true [void]$buttonPanel.Children.Add($yesBtn) $noBtn = & $createButton 'No' $false 'No' $window $noBtn.IsCancel = $true [void]$buttonPanel.Children.Add($noBtn) } 'YesNoCancel' { $yesBtn = & $createButton 'Yes' $true 'Yes' $window $yesBtn.IsDefault = $true [void]$buttonPanel.Children.Add($yesBtn) $noBtn = & $createButton 'No' $false 'No' $window [void]$buttonPanel.Children.Add($noBtn) $cancelBtn = & $createButton 'Cancel' $false 'Cancel' $window $cancelBtn.IsCancel = $true [void]$buttonPanel.Children.Add($cancelBtn) } } } # Attach the standard fade-in Initialize-UiWindowLoaded -Window $window -TitleBarBackground $colors.HeaderBackground -TitleBarForeground $colors.HeaderForeground # CenterOnParent places the dialog before SizeToContent has measured it, a long message grows down past the taskbar $fitWindow = $window $window.Add_Loaded({ $source = [System.Windows.PresentationSource]::FromVisual($fitWindow) if (!$source) { return } $handle = [System.Windows.Interop.WindowInteropHelper]::new($fitWindow).Handle $area = [PsUi.WindowManager]::GetWorkAreaForWindow($handle) # Device pixels, not DIPs $toDip = $source.CompositionTarget.TransformFromDevice $top = $toDip.Transform($area.TopLeft).Y $bottom = $toDip.Transform($area.BottomRight).Y # Without UpdateLayout, ActualHeight still reads the old height if ($fitWindow.MaxHeight -gt $bottom - $top) { $fitWindow.MaxHeight = $bottom - $top $fitWindow.UpdateLayout() } if ($fitWindow.Top + $fitWindow.ActualHeight -gt $bottom) { $fitWindow.Top = $bottom - $fitWindow.ActualHeight } # CenterOnParent stops at the virtual screen's top, which a taskbar docked up there covers if ($fitWindow.Top -lt $top) { $fitWindow.Top = $top } }.GetNewClosure()) # Position and show Set-UiDialogPosition -Dialog $window Write-Debug "Showing modal dialog" [void]$window.ShowDialog() $result = $window.Tag Write-Debug "Result: $result" return $result } |