Public/Show-FluenceWindow.ps1
|
<# Copyright (c) 2026, Dan Cunningham. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. #> function Show-FluenceWindow { <# .SYNOPSIS Shows a themed Fluent (FluenceWindow) window built from a content scriptblock or XAML, and returns the value the window stashed before closing. .DESCRIPTION Hosts a full Fluence.Wpf window from PowerShell. Three shapes are supported: a -Content scriptblock that builds the window body in code, a -Xaml string parsed at runtime, or a -XamlPath file loaded at runtime. A XAML root that is itself a Window is shown directly; any other root is hosted inside a FluenceWindow. The optional -Initialize block (XAML modes) runs against the window after it is built, so named controls can be wired with FindName and add_* handlers. Close the window with Close-FluenceWindow -Result <value> to set the return value; the value stashed on the window's Tag is returned. XAML and callbacks are trusted code: do not load XAML from an untrusted source. XamlReader does not provide a sandbox. .PARAMETER Content A scriptblock that builds the window body. It receives the FluenceWindow as the first argument and the -Data hashtable as the second, and typically sets $Window.Content and wires handlers. .PARAMETER Xaml A XAML string parsed with XamlReader.Parse. A FluenceWindow (or any Window) root is shown directly; any other root is hosted inside a new FluenceWindow. .PARAMETER XamlPath A path to a .xaml file loaded with XamlReader.Load. The path is resolved on the calling thread. .PARAMETER Initialize A scriptblock run against the window after XAML is loaded (XamlString and XamlFile only). It receives the window as the first argument and the -Data hashtable as the second. .PARAMETER Title The window title. -TitleBarText is an alias. Defaults to 'Fluence'. For a Window XAML root, the XAML title remains unless this parameter is bound. .PARAMETER TitleBarIcon Title bar icon from a local file path, file: URI, or pack: URI. Other schemes and missing local files are rejected before UI dispatch. This does not change a message severity icon. .PARAMETER Width The window width in device-independent pixels. .PARAMETER Height The window height in device-independent pixels. .PARAMETER MinWidth The minimum window width. .PARAMETER MinHeight The minimum window height. .PARAMETER SizeToContent How the window sizes to its content (Manual, Width, Height, or WidthAndHeight). Default Manual. .PARAMETER StartupLocation Where the window first appears (Manual, CenterScreen, or CenterOwner). Default CenterScreen; forced to CenterOwner when -Owner is supplied. .PARAMETER Topmost Show the window above other windows. .PARAMETER ResizeMode The window resize mode (NoResize, CanMinimize, CanResize, or CanResizeWithGrip). .PARAMETER CornerStyle The DWM rounded-corner preference: Default, DoNotRound, Round, or RoundSmall. .PARAMETER ExtendsContentIntoTitleBar Extend the window content into the title bar area. .PARAMETER ShowTitle Show the title text in the title bar. .PARAMETER ShowIcon Show the window icon in the title bar. .PARAMETER NoMinimizeButton Hide the minimize caption button. .PARAMETER NoMaximizeButton Hide the maximize caption button. .PARAMETER NoCloseButton Hide the close caption button. .PARAMETER Theme Auto, Light, Dark, or HighContrast. Omit it to keep the theme already applied to the process; the first Fluence call in a process applies Auto. .PARAMETER Backdrop Mica, Acrylic, Tabbed, None, or Auto. Omit it to keep the backdrop already applied to the process; the first Fluence call in a process applies Mica. .PARAMETER Accent Optional accent color (System.Windows.Media.Color or a parseable string). Defaults to system accent. .PARAMETER WatchSystemTheme Follow OS light/dark/high-contrast changes while the window is open; stops watching on close. .PARAMETER Owner An owning System.Windows.Window for modal parenting; forces CenterOwner startup location. .PARAMETER Data A hashtable passed as the second argument to the -Content and -Initialize blocks. On an MTA host this is how values cross to the UI runspace, since caller-defined functions, variables, and closures are not available there. .PARAMETER PassThru Return a Fluence.WindowResult object (Result and Closed) instead of the bare stashed value. .EXAMPLE Show-FluenceWindow -Content { param($Window, $Data) $stack = [System.Windows.Controls.StackPanel]::new() $stack.Margin = [System.Windows.Thickness]::new(24) $ok = [Fluence.Wpf.Controls.Button]::new() $ok.Content = 'OK' $ok.add_Click({ Close-FluenceWindow -Window $Window -Result 'ok' }.GetNewClosure()) $null = $stack.Children.Add($ok) $Window.Content = $stack } -Title 'Hello' -Width 400 -Height 200 Builds the window body in code and returns 'ok' when the button is clicked. .EXAMPLE $xaml = @' <fluence:FluenceWindow xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:fluence="http://schemas.fluencewpf.com" Title="From XAML" Width="480" Height="260"> <fluence:Button x:Name="Ok" Content="OK" /> </fluence:FluenceWindow> '@ Show-FluenceWindow -Xaml $xaml -Initialize { param($Window, $Data) $Window.FindName('Ok').add_Click({ Close-FluenceWindow -Window $Window -Result 'done' }.GetNewClosure()) } Parses a FluenceWindow from a XAML string and wires its named button in -Initialize. .EXAMPLE Show-FluenceWindow -XamlPath 'C:\ui\MainWindow.xaml' -Initialize { param($Window, $Data) $Window.FindName('AccentButton').add_Click({ [Fluence.Wpf.ApplicationAccentColorManager]::ApplySystemAccent() }.GetNewClosure()) } -WatchSystemTheme Loads the window from a .xaml file, wires a named control, and follows OS theme changes. .OUTPUTS System.Object .NOTES Establishes or reuses a WPF Application on an STA UI thread and blocks until the window closes. On a multi-threaded-apartment host (for example pwsh -mta) the content and handler scriptblocks run on a separate module-owned UI runspace and cannot reference caller-defined functions, variables, or closures; pass values through -Data instead. #> [CmdletBinding(DefaultParameterSetName = 'ContentBlock')] [OutputType([object])] param ( [Parameter(ParameterSetName = 'ContentBlock', Position = 0)] [scriptblock]$Content, [Parameter(Mandatory = $true, ParameterSetName = 'XamlString')] [string]$Xaml, [Parameter(Mandatory = $true, ParameterSetName = 'XamlFile')] [string]$XamlPath, [Parameter(ParameterSetName = 'XamlString')] [Parameter(ParameterSetName = 'XamlFile')] [scriptblock]$Initialize, [Parameter()] [Alias('TitleBarText')] [string]$Title = 'Fluence', [Parameter()] [string]$TitleBarIcon, [Parameter()] [double]$Width, [Parameter()] [double]$Height, [Parameter()] [double]$MinWidth, [Parameter()] [double]$MinHeight, [Parameter()] [System.Windows.SizeToContent]$SizeToContent = [System.Windows.SizeToContent]::Manual, [Parameter()] [System.Windows.WindowStartupLocation]$StartupLocation = [System.Windows.WindowStartupLocation]::CenterScreen, [Parameter()] [switch]$Topmost, [Parameter()] [System.Windows.ResizeMode]$ResizeMode, [Parameter()] [ValidateSet('Default', 'DoNotRound', 'Round', 'RoundSmall')] [string]$CornerStyle, [Parameter()] [switch]$ExtendsContentIntoTitleBar, [Parameter()] [switch]$ShowTitle, [Parameter()] [switch]$ShowIcon, [Parameter()] [switch]$NoMinimizeButton, [Parameter()] [switch]$NoMaximizeButton, [Parameter()] [switch]$NoCloseButton, [Parameter()] [ValidateSet('Auto', 'Light', 'Dark', 'HighContrast')] [string]$Theme, [Parameter()] [ValidateSet('Mica', 'Acrylic', 'Tabbed', 'None', 'Auto')] [string]$Backdrop, [Parameter()] [System.Windows.Media.Color]$Accent, [Parameter()] [switch]$WatchSystemTheme, [Parameter()] [System.Windows.Window]$Owner, [Parameter()] [hashtable]$Data, [Parameter()] [switch]$PassThru ) # Caller-thread work: collect only the chrome the caller explicitly bound, so unset properties keep # the window's own defaults. Switches use ContainsKey so an explicit -ShowTitle:$false is honored. $chromeBound = @{} if ($PSBoundParameters.ContainsKey('Title')) { $chromeBound['Title'] = $Title } if ($PSBoundParameters.ContainsKey('Width')) { $chromeBound['Width'] = $Width } if ($PSBoundParameters.ContainsKey('Height')) { $chromeBound['Height'] = $Height } if ($PSBoundParameters.ContainsKey('MinWidth')) { $chromeBound['MinWidth'] = $MinWidth } if ($PSBoundParameters.ContainsKey('MinHeight')) { $chromeBound['MinHeight'] = $MinHeight } if ($PSBoundParameters.ContainsKey('SizeToContent')) { $chromeBound['SizeToContent'] = $SizeToContent } if ($PSBoundParameters.ContainsKey('StartupLocation')) { $chromeBound['WindowStartupLocation'] = $StartupLocation } if ($PSBoundParameters.ContainsKey('ResizeMode')) { $chromeBound['ResizeMode'] = $ResizeMode } if ($PSBoundParameters.ContainsKey('CornerStyle')) { $chromeBound['CornerStyle'] = ConvertTo-FluenceCornerPreference -CornerStyle $CornerStyle } if ($PSBoundParameters.ContainsKey('Backdrop')) { $chromeBound['SystemBackdropType'] = (ConvertTo-FluenceBackdropType -Backdrop $Backdrop) } if ($PSBoundParameters.ContainsKey('ExtendsContentIntoTitleBar')) { $chromeBound['ExtendsContentIntoTitleBar'] = [bool]$ExtendsContentIntoTitleBar } if ($PSBoundParameters.ContainsKey('ShowTitle')) { $chromeBound['ShowTitle'] = [bool]$ShowTitle } if ($PSBoundParameters.ContainsKey('ShowIcon')) { $chromeBound['ShowIcon'] = [bool]$ShowIcon } if ($PSBoundParameters.ContainsKey('Topmost')) { $chromeBound['Topmost'] = [bool]$Topmost } if ($PSBoundParameters.ContainsKey('NoMinimizeButton') -and $NoMinimizeButton) { $chromeBound['IsMinimizeButtonVisible'] = [System.Windows.Visibility]::Collapsed } if ($PSBoundParameters.ContainsKey('NoMaximizeButton') -and $NoMaximizeButton) { $chromeBound['IsMaximizeButtonVisible'] = [System.Windows.Visibility]::Collapsed } if ($PSBoundParameters.ContainsKey('NoCloseButton') -and $NoCloseButton) { $chromeBound['IsCloseButtonVisible'] = [System.Windows.Visibility]::Collapsed } # A distinct local name: $accent would collide case-insensitively with the [Color] $Accent # parameter, and assigning to it would force the unbound struct parameter to materialize, # coercing null to Color and throwing. $accentColor = $null if ($PSBoundParameters.ContainsKey('Accent')) { $accentColor = $Accent } $titleBarIconSource = $null if ($PSBoundParameters.ContainsKey('TitleBarIcon')) { $titleBarIconSource = Resolve-FluenceImageSource -Image $TitleBarIcon -ParameterName 'TitleBarIcon' } $mode = $PSCmdlet.ParameterSetName # Resolve the XAML file on the calling thread so a missing path surfaces a clear caller-thread # error before any UI work is dispatched. $resolved = $null if ($mode -eq 'XamlFile') { # -ErrorAction Stop is what makes that true: without it a missing path writes a # non-terminating error, $resolved stays $null, and the failure resurfaces on the UI thread # as an opaque "value cannot be an empty string" from the XAML reader. $resolved = (Resolve-Path -LiteralPath $XamlPath -ErrorAction Stop).Path } # Capture each user block's text alongside the live object. The UI thread picks one per host shape # (live on a shared runspace, recreated from text on the module-owned MTA runspace). $contentText = $null if ($null -ne $Content) { $contentText = $Content.ToString() } $initializeText = $null if ($null -ne $Initialize) { $initializeText = $Initialize.ToString() } $callerRunspaceId = [System.Management.Automation.Runspaces.Runspace]::DefaultRunspace.InstanceId # One-time notice on an MTA host with no host application: the UI runspace cannot see caller scope. if ($script:WindowMtaWarningShown -ne $true -and [System.Threading.Thread]::CurrentThread.GetApartmentState() -eq [System.Threading.ApartmentState]::MTA -and $null -eq [System.Windows.Application]::Current) { Write-Warning ('On a multi-threaded-apartment host, Show-FluenceWindow content and handler ' + 'scriptblocks run on a separate UI runspace and cannot reference caller-defined functions, ' + 'variables, or closures. Pass values through -Data instead.') $script:WindowMtaWarningShown = $true } $spec = @{ Mode = $mode Accent = $accentColor ChromeBound = $chromeBound TitleBarIcon = $titleBarIconSource Content = $Content ContentText = $contentText Initialize = $Initialize InitializeText = $initializeText Xaml = $Xaml XamlPath = $resolved Owner = $Owner Data = $Data WatchSystemTheme = [bool]$WatchSystemTheme CallerRunspaceId = $callerRunspaceId } # Theme and backdrop reach the spec only when the caller asked for one, so a window opened after # Set-FluenceTheme or Set-FluenceBackdrop keeps that state; see Initialize-FluenceApplication. foreach ($name in @('Theme', 'Backdrop')) { if ($PSBoundParameters.ContainsKey($name)) { $spec[$name] = $PSBoundParameters[$name] } } $raw = Invoke-OnFluenceUi -Script { param($s) Show-FluenceWindowCore -Spec $s } -ArgumentList @($spec) # Invoke-OnFluenceUi yields the bare result hashtable (@{ Result = ... } from # Show-FluenceWindowCore); a $null result means the window returned nothing. $value = $null if ($null -ne $raw) { $value = $raw.Result } if ($PassThru) { return [pscustomobject]@{ PSTypeName = 'Fluence.WindowResult' Result = $value Closed = $true } } return $value } |