public/window/New-UiChildWindow.ps1
|
function New-UiChildWindow { <# .SYNOPSIS Creates a child window that automatically inherits the parent's theme. .DESCRIPTION Creates a child/nested window that uses the active theme. Supports modal and non-modal display, and allows data passing between parent and child. Windows are shown automatically: - Modal windows: Shown with ShowDialog() and return DialogResult (bool?) - Non-modal windows: Shown with Show() and return nothing - PassThru: Returns window object for manual control Child windows called from buttons run synchronously on the UI thread. No threading gymnastics required. .PARAMETER Parent Parent window object.Can be omitted to create an independent window. .PARAMETER Title Window title bar text. .PARAMETER Content ScriptBlock containing child controls. .PARAMETER Width Window width in pixels (150-2000). .PARAMETER Height Window height in pixels (100-1500). .PARAMETER Modal Display as modal dialog (blocks parent until closed). .PARAMETER Position Window position: CenterOnParent, CenterOnScreen, or Manual. .PARAMETER Left Left position (for Manual positioning). .PARAMETER Top Top position (for Manual positioning). .PARAMETER NoResize Prevent user from resizing the window. Windows are resizable by default. .PARAMETER OnClosed ScriptBlock to execute when window closes. .PARAMETER PassThru Return the window object instead of displaying it automatically. .PARAMETER WPFProperties Hashtable of additional WPF properties to set on the control. Allows setting any valid WPF property not explicitly exposed as a parameter. Invalid properties will generate warnings but not stop execution. Supports attached properties using dot notation (e.g., "Grid.Row"). .EXAMPLE # Modal dialog - parent is auto-detected from session New-UiButton -Text "Open Settings" -NoAsync -Action { $result = New-UiChildWindow -Title 'Settings' -Modal -Content { New-UiLabel -Text 'Configure settings' New-UiButton -Text 'Save' -Action { $session = Get-UiSession $session.Window.DialogResult = $true $session.Window.Close() } } if ($result) { Write-Host "User clicked Save" } } .EXAMPLE # Non-modal window - parent auto-detected New-UiButton -Text "Show Monitor" -NoAsync -Action { New-UiChildWindow -Title 'Status Monitor' -Width 300 -Height 200 -Content { New-UiLabel -Text 'Monitoring...' -Variable statusLabel New-UiButton -Text "Close" -Action { (Get-UiSession).Window.Close() } } } .EXAMPLE # Shared data between windows via reference type $counter = @{ Value = 0 } New-UiButton -Text "Open Counter" -LinkedVariables 'counter' -NoAsync -Action { New-UiChildWindow -Title "Counter" -Content { $label = New-UiLabel -Text "Count: 0" -Style SubHeader New-UiButton -Text "Increment" -Action { $counter.Value++ $label.Text = "Count: $($counter.Value)" } } } #> [CmdletBinding()] param( [System.Windows.Window]$Parent, [string]$Title = 'Child Window', [Parameter(Mandatory)] [scriptblock]$Content, [ValidateRange(150, 2000)] [int]$Width = 400, [ValidateRange(100, 1500)] [int]$Height = 300, [switch]$Modal, [ValidateSet('CenterOnParent', 'CenterOnScreen', 'Manual')] [string]$Position = 'CenterOnParent', [System.Nullable[int]]$Left, [System.Nullable[int]]$Top, [switch]$NoResize, [scriptblock]$OnClosed, [switch]$PassThru, [Parameter()] [hashtable]$WPFProperties ) # Reject empty content scriptblock early if ([string]::IsNullOrWhiteSpace($Content.ToString())) { throw "New-UiChildWindow: The -Content scriptblock is empty. Add UI controls inside the block." } # Capture caller's session state for variable resolution $callerSessionState = $PSCmdlet.SessionState # Auto-detect parent window if not provided if (!$Parent) { $session = Get-UiSession if ($session -and $session.Window) { $Parent = $session.Window Write-Verbose "[New-UiChildWindow] Auto-detected parent window from session" } } $colors = if (Test-Path variable:__WPFThemeColors) { Get-Variable -Name __WPFThemeColors -ValueOnly -ErrorAction SilentlyContinue } else { $null } if (!$colors) { $colors = Get-ThemeColors } if (!$colors) { $colors = @{ WindowBg = '#FFFFFF' WindowFg = '#1A1A1A' ControlBg = '#F0F0F0' ControlFg = '#000000' } } # Save parent session ID so we can restore it after child window closes $parentSessionId = [PsUi.SessionManager]::CurrentSessionId $session = Initialize-UiSession if (!$session) { Write-Error "Failed to initialize WPF session for child window" return } # Capture child session ID for cleanup $childSessionId = [PsUi.SessionManager]::CurrentSessionId $startupLocation = switch ($Position) { 'CenterOnParent' { if ($Parent) { 'CenterOwner' } else { 'CenterScreen' } } 'CenterOnScreen' { 'CenterScreen' } 'Manual' { 'Manual' } default { 'CenterScreen' } } # Create the window with custom chrome for shadow support # Add padding to dimensions to accommodate shadow margin $shadowPadding = 16 $window = [System.Windows.Window]@{ Title = $Title Width = $Width + ($shadowPadding * 2) Height = $Height + ($shadowPadding * 2) MinWidth = 200 + ($shadowPadding * 2) MinHeight = 150 + ($shadowPadding * 2) WindowStartupLocation = $startupLocation FontFamily = [System.Windows.Media.FontFamily]::new('Segoe UI') Background = [System.Windows.Media.Brushes]::Transparent Foreground = ConvertTo-UiBrush $colors.WindowFg ResizeMode = if ($NoResize) { 'NoResize' } else { 'CanResize' } WindowStyle = 'None' AllowsTransparency = $true Opacity = 0 } # Set unique AppUserModelID to separate from PowerShell in taskbar $appId = "PsUi.ChildWindow." + [Guid]::NewGuid().ToString("N").Substring(0, 8) [PsUi.WindowManager]::SetWindowAppId($window, $appId) # Create custom window icon (inherit parent's custom logo if set) $childWindowIcon = $null try { $parentSession = Get-UiSession if ($parentSession.CustomLogo -and (Test-Path $parentSession.CustomLogo)) { $childWindowIcon = Get-CustomLogoIcon -Path $parentSession.CustomLogo } else { $childWindowIcon = New-WindowIcon -Colors $colors } if ($childWindowIcon) { $window.Icon = $childWindowIcon } } catch { Write-Verbose "Failed to create window icon: $_" } if ($Parent) { try { $window.Owner = $Parent } catch { Write-Verbose "[New-UiChildWindow] Could not set Owner: $_" # Adjust startup location if we couldn't set owner if ($startupLocation -eq 'CenterOwner') { $window.WindowStartupLocation = 'CenterScreen' } } } $shadowBorder = [System.Windows.Controls.Border]@{ Margin = [System.Windows.Thickness]::new($shadowPadding) Background = ConvertTo-UiBrush $colors.WindowBg BorderBrush = ConvertTo-UiBrush $colors.Border BorderThickness = [System.Windows.Thickness]::new(1) } $shadow = [System.Windows.Media.Effects.DropShadowEffect]@{ BlurRadius = 16 ShadowDepth = 4 Opacity = 0.35 Color = [System.Windows.Media.Colors]::Black Direction = 270 } $shadowBorder.Effect = $shadow $window.Content = $shadowBorder # Use a Grid as container so we can overlay a resize grip $containerGrid = [System.Windows.Controls.Grid]::new() $shadowBorder.Child = $containerGrid # Main layout DockPanel inside the shadow border $outerPanel = [System.Windows.Controls.DockPanel]@{ LastChildFill = $true } [void]$containerGrid.Children.Add($outerPanel) # Add resize grip for borderless window (only if resizable) if (!$NoResize) { $resizeGrip = [System.Windows.Controls.Primitives.ResizeGrip]@{ HorizontalAlignment = 'Right' VerticalAlignment = 'Bottom' Cursor = [System.Windows.Input.Cursors]::SizeNWSE } [void]$containerGrid.Children.Add($resizeGrip) # Calculate minimum dimensions (must match window's MinWidth/MinHeight) $minResizeWidth = 200 + ($shadowPadding * 2) $minResizeHeight = 150 + ($shadowPadding * 2) $resizeGrip.Add_MouseLeftButtonDown({ param($sender, $eventArgs) $sender.CaptureMouse() $eventArgs.Handled = $true }) $capturedWindowForResize = $window $resizeGrip.Add_MouseMove({ param($sender, $eventArgs) if ($sender.IsMouseCaptured) { $mousePos = [System.Windows.Input.Mouse]::GetPosition($capturedWindowForResize) $newWidth = [Math]::Max($minResizeWidth, $mousePos.X) $newHeight = [Math]::Max($minResizeHeight, $mousePos.Y) $capturedWindowForResize.Width = $newWidth $capturedWindowForResize.Height = $newHeight $eventArgs.Handled = $true } }.GetNewClosure()) $resizeGrip.Add_MouseLeftButtonUp({ param($sender, $eventArgs) $sender.ReleaseMouseCapture() $eventArgs.Handled = $true }) } # Custom title bar for drag support and close button $titleBar = [System.Windows.Controls.Border]@{ Background = ConvertTo-UiBrush $colors.HeaderBackground Height = 36 Padding = [System.Windows.Thickness]::new(12, 0, 4, 0) } [System.Windows.Controls.DockPanel]::SetDock($titleBar, 'Top') $titleGrid = [System.Windows.Controls.Grid]::new() $titleBar.Child = $titleGrid $titleText = [System.Windows.Controls.TextBlock]@{ Text = $Title FontSize = 13 FontWeight = 'SemiBold' Foreground = ConvertTo-UiBrush $colors.HeaderForeground VerticalAlignment = 'Center' } [void]$titleGrid.Children.Add($titleText) # Close button with red hover effect # Foreground is set inside the template - do NOT use a property setter on the button, # it creates a local value that overrides template trigger setters after theme changes. $closeBtn = [System.Windows.Controls.Button]@{ Content = [PsUi.ModuleContext]::GetIcon('Close') FontFamily = [System.Windows.Media.FontFamily]::new('Segoe MDL2 Assets') FontSize = 10 Width = 36 Height = 36 HorizontalAlignment = 'Right' Background = [System.Windows.Media.Brushes]::Transparent BorderThickness = [System.Windows.Thickness]::new(0) Cursor = [System.Windows.Input.Cursors]::Hand } $closeBtn.OverridesDefaultStyle = $true # Apply hover template (red background, white foreground on hover) $closeBtnTemplate = @' <ControlTemplate xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" TargetType="Button"> <Border x:Name="border" Background="Transparent"> <ContentPresenter x:Name="content" HorizontalAlignment="Center" VerticalAlignment="Center" TextElement.Foreground="{DynamicResource HeaderForegroundBrush}"/> </Border> <ControlTemplate.Triggers> <Trigger Property="IsMouseOver" Value="True"> <Setter TargetName="border" Property="Background" Value="#E81123"/> <Setter TargetName="content" Property="TextElement.Foreground" Value="White"/> </Trigger> </ControlTemplate.Triggers> </ControlTemplate> '@ $closeBtn.Template = [System.Windows.Markup.XamlReader]::Parse($closeBtnTemplate) $capturedWindow = $window $capturedModal = $Modal $closeBtn.Add_Click({ if ($capturedModal) { $capturedWindow.DialogResult = $false } $capturedWindow.Close() }.GetNewClosure()) [void]$titleGrid.Children.Add($closeBtn) $titleBar.Add_MouseLeftButtonDown({ $capturedWindow.DragMove() }.GetNewClosure()) [void]$outerPanel.Children.Add($titleBar) $dockPanel = [System.Windows.Controls.DockPanel]::new() $scrollViewer = [System.Windows.Controls.ScrollViewer]::new() $scrollViewer.VerticalScrollBarVisibility = 'Auto' $scrollViewer.HorizontalScrollBarVisibility = 'Disabled' $contentStack = [System.Windows.Controls.StackPanel]::new() $contentStack.Margin = [System.Windows.Thickness]::new(16, 12, 16, 12) $scrollViewer.Content = $contentStack [void]$dockPanel.Children.Add($scrollViewer) [void]$outerPanel.Children.Add($dockPanel) $session.Window = $window $session.CurrentParent = $contentStack $controlName = "ChildWindow_$([Guid]::NewGuid().ToString('N').Substring(0, 8))" Register-UiControl -Name $controlName -Control $window Register-UiControl -Name "${controlName}_ContentStack" -Control $contentStack # Build the content using dot-sourcing to run in current scope # Capture variables from caller's scope that are referenced in Content try { $capturedVars = @{} $ast = $Content.Ast # Find all variable references in the Content scriptblock $varExpressions = $ast.FindAll({ param($node) $node -is [System.Management.Automation.Language.VariableExpressionAst] }, $true) # Variables to exclude (built-ins, scope-qualified, etc.) $excludeVars = @( '_', 'args', 'Error', 'false', 'Host', 'input', 'null', 'PSBoundParameters', 'PSCmdlet', 'PSScriptRoot', 'PSVersionTable', 'true', 'env', 'this', 'Verbose', 'Debug', 'ErrorAction', 'WarningAction', 'InformationAction', 'session', 'parent', 'parentSession', 'callerSessionState', 'colors' ) foreach ($varExpr in $varExpressions) { $varName = $varExpr.VariablePath.UserPath if ($excludeVars -contains $varName) { continue } if ($capturedVars.ContainsKey($varName)) { continue } # Skip scope-qualified variables if ($varExpr.VariablePath.IsScript -or $varExpr.VariablePath.IsGlobal -or $varExpr.VariablePath.IsLocal -or $varExpr.VariablePath.IsPrivate) { continue } # Try to get the variable from caller's scope try { $var = $callerSessionState.PSVariable.Get($varName) if ($null -ne $var) { $capturedVars[$varName] = $var.Value } } catch { Write-Debug "Variable capture failed for '$varName': $_" } } # Inject captured variables into current scope before running Content foreach ($key in $capturedVars.Keys) { Set-Variable -Name $key -Value $capturedVars[$key] -Scope Local } Write-Verbose "[New-UiChildWindow] Captured $($capturedVars.Count) variables from caller scope" } catch { Write-Verbose "[New-UiChildWindow] Variable capture failed: $_" } try { Write-Debug "Executing content block" Invoke-UiContent -Content $Content -CallerName 'New-UiChildWindow' } catch { Write-Error $_ Clear-UiSession return $null } # Set up window load event for fade-in and theming $window.Add_Loaded({ # Apply manual positioning if specified if ($Position -eq 'Manual') { if ($null -ne $Left) { $this.Left = $Left } if ($null -ne $Top) { $this.Top = $Top } } # Apply title bar theming using Set-UIResources (same as main window) Set-UIResources -Window $this -Colors $colors -IconPath $null # Force taskbar to use our themed icon (requires window handle) if ($childWindowIcon) { [PsUi.WindowManager]::SetTaskbarIcon($this, $childWindowIcon) } # Fade-in animation with easing $fadeIn = [System.Windows.Media.Animation.DoubleAnimation]@{ From = 0 To = 1 Duration = [System.Windows.Duration]::new([System.TimeSpan]::FromMilliseconds(350)) } $fadeIn.EasingFunction = [System.Windows.Media.Animation.QuadraticEase]@{ EasingMode = [System.Windows.Media.Animation.EasingMode]::EaseOut } $this.BeginAnimation([System.Windows.Window]::OpacityProperty, $fadeIn) }.GetNewClosure()) if ($OnClosed) { $window.Add_Closed({ & $OnClosed }.GetNewClosure()) } # Cleanup child session and restore parent session when window closes $capturedParent = $Parent $window.Add_Closed({ # Dispose the child window's session if ($childSessionId -ne [Guid]::Empty) { [PsUi.SessionManager]::DisposeSession($childSessionId) } # Restore the parent window's session as current (both ThreadStatic and global variable) if ($parentSessionId -ne [Guid]::Empty) { [PsUi.SessionManager]::SetCurrentSession($parentSessionId) $Global:__PsUiSessionId = $parentSessionId.ToString() } # Activate the parent window so it comes back to the foreground if ($capturedParent) { $capturedParent.Activate() } }.GetNewClosure()) if ($WPFProperties) { Set-UiProperties -Control $window -Properties $WPFProperties } if ($PassThru) { return $window } elseif ($Modal) { return $window.ShowDialog() } else { [void]$window.Show() } } |