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. Omit it and the session's window is picked up automatically; with
        no session window open, the child stands alone.
    .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). Ignored when -SizeToContent is set.
    .PARAMETER SizeToContent
        Base the height on the content instead of -Height, capped at the avaiable screen area.
        The window sets that height once it is on screen, so the grip and the edges still drag.
    .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.
        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
        # 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' -NoAsync -Action {
                    # Setting DialogResult closes a modal by itself. Do NOT also call Close(). The second close lands on a window mid teardown and throws.
                    (Get-UiSession).Window.DialogResult = $true
                }
            }
            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...'
                New-UiButton -Text "Close" -NoAsync -Action { Close-UiWindow }
            }
        }
    .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 {
                New-UiButton -Text "Increment" -NoAsync -Action {
                    $counter.Value++
                    (Get-UiSession).Window.Title = "Counter: $($counter.Value)"
                }
            }
        }
    .NOTES
        Closing a child closes the windows it owns first and an output window still running asks
        before it closes. A Closing handler of your own that cancels keeps them all open only if
        it was attached before the child was shown.
    #>

    [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]$SizeToContent,

        [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 the calling script'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'
        }
    }

    # The session this child takes the thread from, and hands it back to
    $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
    }

    # Straight from a console there's no Application to hold the styles
    if (![System.Windows.Application]::Current) { [PsUi.ThemeEngine]::ApplyStandaloneTheme($window, $colors) }

    # Two field forms leave most of a fixed height window empty, so the height comes off the content unless one is passed.
    # Capped to the parent's monitor, since SystemParameters.WorkArea is the primary screen only and a tall window on a shorter second screen runs off the bottom.
    if ($SizeToContent) {
        $ownerHandle          = if ($Parent) { [System.Windows.Interop.WindowInteropHelper]::new($Parent).Handle } else { [IntPtr]::Zero }
        $ownerSource          = if ($Parent) { [System.Windows.PresentationSource]::FromVisual($Parent) }

        # The monitor's work area comes back in device pixels, MaxHeight wants DIPs
        $toDip                = if ($ownerSource) { $ownerSource.CompositionTarget.TransformFromDevice.M22 } else { 1 }
        $window.MaxHeight     = [PsUi.WindowManager]::GetWorkAreaForWindow($ownerHandle).Height * $toDip
        $window.SizeToContent = 'Height'
    }

    [PsUi.WindowManager]::SetWindowAppId($window, 'PsUi.ChildWindow')

    # 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 the startup location when the owner could not be set
            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

    # A Grid as the container, so a resize grip can sit on top
    $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

    # WindowStyle='None' on the child window means the OS-native title-bar icon never renders and custom chrome has to draw its own. Copy the same 16x16 pattern as the main window's BuildTitleBar in NewUiWindowCommand.Builder.cs.
    # Skip the Image if no icon was resolved above.
    $titleTextLeftMargin = 0
    if ($childWindowIcon) {
        $titleIcon = [System.Windows.Controls.Image]@{
            Source              = $childWindowIcon
            Width               = 16
            Height              = 16
            HorizontalAlignment = 'Left'
            VerticalAlignment   = 'Center'
        }
        [void]$titleGrid.Children.Add($titleIcon)
        $titleTextLeftMargin = 24
    }

    $titleText = [System.Windows.Controls.TextBlock]@{
        FontSize          = 13
        FontWeight        = 'SemiBold'
        Foreground        = ConvertTo-UiBrush $colors.HeaderForeground
        VerticalAlignment = 'Center'
        Margin            = [System.Windows.Thickness]::new($titleTextLeftMargin, 0, 0, 0)
    }

    # Lets (Get-UiSession).Window.Title set from an action retitle the drawn title bar too
    $titleBinding        = [System.Windows.Data.Binding]::new('Title')
    $titleBinding.Source = $window
    [void]$titleText.SetBinding([System.Windows.Controls.TextBlock]::TextProperty, $titleBinding)
    [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          = [PsUi.ModuleContext]::ActiveIconFontFamily
        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({
        # Setting DialogResult already closes a modal. Close() on top of it re-enters teardown on a disposing window and throws an NRE out of the second pass.
        if ($capturedModal) { $capturedWindow.DialogResult = $false }
        else { $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 the calling 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 the calling 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"

    # Inside a window the block comes from New-UiWindow's generated text without a file, so Invoke-UiContent takes the file and line from the window's session
    $parentSession = [PsUi.SessionManager]::GetSession($parentSessionId)
    if ($parentSession -and !$session.CallerScriptName) {
        $session.CallerScriptName = $parentSession.CallerScriptName
        $session.CallerScriptLine = $parentSession.CallerScriptLine
    }
    Invoke-UiContent -Content $Content -CallerName 'New-UiChildWindow'
}
catch {
    # The window never got shown, so the Add_Closed restore below was never attached. Undo the child session and hand the parent back by hand - Clear-UiSession would leave the calling script on a fresh empty session and every later Get-UiSession on this thread would miss the parent.
    if ($childSessionId -ne [Guid]::Empty) {
        [PsUi.SessionManager]::DisposeSession($childSessionId)
    }

    if ($parentSessionId -ne [Guid]::Empty -and [PsUi.SessionManager]::GetSession($parentSessionId)) {
        [PsUi.SessionManager]::SetCurrentSession($parentSessionId)
        $Global:__PsUiSessionId = $parentSessionId.ToString()
    }
    else { Remove-Variable -Name __PsUiSessionId -Scope Global -ErrorAction SilentlyContinue }

    # After the cleanup, since it throws under Stop. Write-Error $_ rewraps it on 5.1 with a new error id.
    $PSCmdlet.WriteError($_)
    return $null
}

# Resolve private function ahead of time so the closure below carries the resolved CommandInfo.
# WPF event handlers fire under the dispatcher's session state, which doesn't see module-private functions even though the script block was defined inside the module. Capturing here sidesteps the lookup at fire time.
$setUiResourcesCmd = Get-Command Set-UIResources -ErrorAction SilentlyContinue

# Set up window load event for fade-in and theming
$window.Add_Loaded({
    # Set the height the content came out at, so the grip and the edges drag it from here.
    if ($this.SizeToContent -ne 'Manual') {
        $this.Height        = $this.ActualHeight
        $this.SizeToContent = 'Manual'
    }

    # 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)
    if ($setUiResourcesCmd) { & $setUiResourcesCmd -Window $this -Colors $colors -IconPath $null }

    # Force the taskbar onto the themed icon (needs the 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) {
        $invokeCallback = ${function:Invoke-UiCallback}

        # trap, not try/catch/finally. Off the pipeline a finally NREs, and this handler runs after the window is already gone, so a throw here is silent and kills the rest of the teardown.
        $window.Add_Closed({
            trap { Write-Warning "New-UiChildWindow OnClosed error: $_"; continue }
            $null = & $invokeCallback -ScriptBlock $OnClosed -Label 'New-UiChildWindow OnClosed'
        }.GetNewClosure())
    }

    # ShowDialog never returns once its owner is gone
    $closedAlong    = [System.Collections.Generic.List[object]]::new()
    $closingHandler = [System.ComponentModel.CancelEventHandler]{
        param($sender, $eventArgs)

        # The script's own unsaved changes guard already said no
        if ($eventArgs.Cancel) { return }

        foreach ($owned in @($sender.OwnedWindows)) {
            # Close() throws on a window that's already closing, so trap and continue
            & {
                trap { Write-Debug "Closing an owned window: $_"; continue }
                $owned.Close()
            }

            # Still up means it said No or it's still asking
            if ($owned.IsVisible) {
                $eventArgs.Cancel = $true
                break
            }
            $closedAlong.Add($owned)
        }
    }.GetNewClosure()
    $window.Add_Closing($closingHandler)

    # Added again from inside Show(), behind any guard the script put on the -PassThru window
    $window.Add_Loaded({
        $this.Remove_Closing($closingHandler)
        $this.Add_Closing($closingHandler)
    }.GetNewClosure())

    # Nearest opener first
    if (!$script:_childSessionOpeners) { $script:_childSessionOpeners = @{} }
    $openerMap = $script:_childSessionOpeners
    $openers   = [System.Collections.Generic.List[Guid]]::new()
    if ($parentSessionId -ne [Guid]::Empty) {
        $openers.Add($parentSessionId)
        if ($openerMap[$parentSessionId]) { $openers.AddRange($openerMap[$parentSessionId]) }
    }
    $openerMap[$childSessionId] = $openers

    $capturedParent = $Parent
    $window.Add_Closed({
        # Leave the thread alone while a later child holds it
        $holdsThread = [PsUi.SessionManager]::CurrentSessionId -eq $childSessionId -or $Global:__PsUiSessionId -eq $childSessionId.ToString()

        # Still ours if it sits on an Out-Datagrid this window took down
        $threadSession = [PsUi.SessionManager]::Current
        if ($threadSession -and $threadSession.Window -and $closedAlong.Contains($threadSession.Window)) { $holdsThread = $true }

        if ($childSessionId -ne [Guid]::Empty) { [PsUi.SessionManager]::DisposeSession($childSessionId) }
        [void]$openerMap.Remove($childSessionId)

        if ($holdsThread) {
            # The opener can be gone already when -Parent pointed past it
            $landing = $null
            foreach ($openerId in $openers) {
                if ([PsUi.SessionManager]::GetSession($openerId)) {
                    $landing = $openerId
                    break
                }
            }

            # The implicit window check reads a dead id in the global as a window still open
            if ($landing) {
                [PsUi.SessionManager]::SetCurrentSession($landing)
                $Global:__PsUiSessionId = $landing.ToString()
            }
            else { Remove-Variable -Name __PsUiSessionId -Scope Global -ErrorAction SilentlyContinue }
        }

        # Activate throws on a closed or unshown parent
        if ($capturedParent -and $capturedParent.IsVisible) { $capturedParent.Activate() }
    }.GetNewClosure())

    # New-UiWindow's key listener never sees this window's hotkeys
    $hotkeyInvoker = ${function:Invoke-UiHotkeyAction}
    $window.Add_PreviewKeyDown({
        param($sender, $eventArgs)
        if ($eventArgs.Handled) { return }

        $keySession = [PsUi.SessionManager]::GetSession($childSessionId)
        if (!$keySession -or @($keySession.GetRegisteredHotkeys()).Count -eq 0) { return }

        # Plain keys in an editable box are typing, except F1 to F24
        $modifiers = [System.Windows.Input.Keyboard]::Modifiers
        $key       = if ($eventArgs.Key -eq 'System') { $eventArgs.SystemKey } else { $eventArgs.Key }
        $target    = $eventArgs.OriginalSource
        $isTyping  = ($target -is [System.Windows.Controls.Primitives.TextBoxBase] -and !$target.IsReadOnly) -or $target -is [System.Windows.Controls.PasswordBox]
        $isFnKey   = $key -ge [System.Windows.Input.Key]::F1 -and $key -le [System.Windows.Input.Key]::F24
        if ($isTyping -and !$isFnKey -and !($modifiers -band ([System.Windows.Input.ModifierKeys]::Control -bor [System.Windows.Input.ModifierKeys]::Alt))) { return }

        $combo = [System.Collections.Generic.List[string]]::new()
        if ($modifiers -band [System.Windows.Input.ModifierKeys]::Control) { $combo.Add('Ctrl') }
        if ($modifiers -band [System.Windows.Input.ModifierKeys]::Alt) { $combo.Add('Alt') }
        if ($modifiers -band [System.Windows.Input.ModifierKeys]::Shift) { $combo.Add('Shift') }
        $combo.Add("$key")

        $hotkeyContext = $keySession.GetHotkeyAction($combo -join '+')
        if (!$hotkeyContext) { return }

        $eventArgs.Handled = $true
        & $hotkeyInvoker -Context $hotkeyContext
    }.GetNewClosure())

    if ($WPFProperties) {  Set-UiProperties -Control $window -Properties $WPFProperties }

    if ($PassThru) {  return $window }
    elseif ($Modal) {
        # Until ShowDialog returns, the child's handlers write to the opener's error stream, and a -NoAsync opener would list their errors in its own dialog
        return Invoke-UiCallback -ScriptBlock { $window.ShowDialog() } -Label 'New-UiChildWindow'
    }
    else { [void]$window.Show() }
}