Public/Show-FluenceProgress.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-FluenceProgress
{
    <#
    .SYNOPSIS
        Shows a non-modal themed progress window and returns a handle for updating and closing it.
    .DESCRIPTION
        Opens a fixed-width FluenceWindow with a message line, an optional detail line, and a Fluence
        ProgressBar, indeterminate by default. The call returns as soon as the window is shown; the
        script keeps running. Change the text or percentage with Update-FluenceProgress and close the
        window with Close-FluenceProgress. The user cannot close it: the caption buttons are hidden.

        On the inline STA host (Windows PowerShell and pwsh by default) the window shares the caller's
        thread, so it repaints on every Show-FluenceProgress and Update-FluenceProgress call and stays
        static in between; update it at least every few seconds during long work. On an MTA host
        (pwsh -mta) the window lives on the module-owned UI runspace, stays responsive between updates,
        and other Fluence dialogs can still be shown while it is open. An existing host application is supported only when this command runs on its dispatcher
        thread; automatic dispatch to a foreign UI thread is not supported.

        Use Message as the required status line. Add Detail for a second line, and PercentComplete
        when progress is determinate.
    .PARAMETER Message
        The main status line.
    .PARAMETER Title
        The window title. -TitleBarText is an alias.
    .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 Detail
        An optional second line in the secondary text color, for example the current file or step.
    .PARAMETER PercentComplete
        When given, the bar is determinate at this value (clamped to 0..100). Omit for an
        indeterminate bar.
    .PARAMETER NotTopmost
        Do not keep the window above other windows. Topmost is the default.
    .PARAMETER Position
        Center (default), TopRight, or BottomRight of the primary work area.
    .PARAMETER Width
        The window width in device-independent pixels. Defaults to 450.
    .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.
    .EXAMPLE
        $progress = Show-FluenceProgress -Title 'Contoso Suite' -Message 'Installing...' -Detail 'Copying files'
        Update-FluenceProgress -Handle $progress -Detail 'Registering components' -PercentComplete 60
        Close-FluenceProgress -Handle $progress
    .OUTPUTS
        Fluence.ProgressHandle
    .NOTES
        Uses the calling STA thread or a module-owned STA runspace. An existing host application
        is supported only when the command already runs on its dispatcher thread. Only one progress window can be
        open at a time.
    #>

    [CmdletBinding()]
    [OutputType('Fluence.ProgressHandle')]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '',
        Justification = 'Shows a transient in-process window; makes no persistent or destructive system change, so ShouldProcess prompting is not appropriate.')]
    param
    (
        [Parameter(Mandatory = $true, Position = 0)]
        [string]$Message,

        [Parameter()]
        [Alias('TitleBarText')]
        [string]$Title = 'Fluence',

        [Parameter()]
        [string]$TitleBarIcon,

        [Parameter()]
        [string]$Detail,

        [Parameter()]
        [double]$PercentComplete,

        [Parameter()]
        [switch]$NotTopmost,

        [Parameter()]
        [ValidateSet('Center', 'TopRight', 'BottomRight')]
        [string]$Position = 'Center',

        [Parameter()]
        [ValidateRange(200, 1600)]
        [int]$Width = 450,

        [Parameter()]
        [ValidateSet('Auto', 'Light', 'Dark', 'HighContrast')]
        [string]$Theme,

        [Parameter()]
        [ValidateSet('Mica', 'Acrylic', 'Tabbed', 'None', 'Auto')]
        [string]$Backdrop,

        [Parameter()]
        [System.Windows.Media.Color]$Accent
    )

    if ($null -ne $script:ProgressHandle -and $script:ProgressHandle.IsOpen)
    {
        throw 'A Fluence progress window is already open. Close it with Close-FluenceProgress before showing another.'
    }

    # A completed pump still owns disposable pipeline handles until collected.
    if ($null -ne $script:ProgressHandle -and $null -ne $script:UiPump)
    {
        Close-FluenceProgress -Handle $script:ProgressHandle
    }

    $accentColor = $null
    if ($PSBoundParameters.ContainsKey('Accent'))
    {
        $accentColor = $Accent
    }

    $titleBarIconSource = $null
    if ($PSBoundParameters.ContainsKey('TitleBarIcon'))
    {
        $titleBarIconSource = Resolve-FluenceImageSource -Image $TitleBarIcon -ParameterName 'TitleBarIcon'
    }

    # The state hashtable is shared with the UI thread (and, on an MTA host, with another runspace),
    # so it is synchronized. CloseRequested is the pump's exit signal.
    $state = [hashtable]::Synchronized((Resolve-FluenceProgressState -Bound $PSBoundParameters))
    $state.CloseRequested = $false

    $spec = @{
        Title       = $Title
        TitleBarIcon = $titleBarIconSource
        Topmost     = (-not [bool]$NotTopmost)
        Position    = $Position
        Width       = $Width
        AccentColor = $accentColor
    }

    # Theme and backdrop reach the spec only when the caller asked for one, so a progress window
    # opened after Set-FluenceTheme keeps that theme; see Initialize-FluenceApplication.
    foreach ($name in @('Theme', 'Backdrop'))
    {
        if ($PSBoundParameters.ContainsKey($name))
        {
            $spec[$name] = $PSBoundParameters[$name]
        }
    }

    $mode = Get-FluenceUiHostMode
    $handle = [pscustomobject]@{
        PSTypeName = 'Fluence.ProgressHandle'
        Id         = [guid]::NewGuid()
        Mode       = $mode
        IsOpen     = $true
        State      = $state
        Spec       = $spec
        Parts      = $null
    }

    if ($mode -eq 'Runspace')
    {
        # The STA runspace must stay pumping for the window to remain live, so the window is shown by
        # an asynchronous pipeline that keeps running until Close-FluenceProgress; later UI work is
        # queued to that pump by Invoke-InFluenceStaRunspace.
        $script:OwnsApplication = $true
        $null = Initialize-FluenceStaRunspace

        $pump = [hashtable]::Synchronized(@{
                Active      = $true
                Queue       = [System.Collections.Concurrent.ConcurrentQueue[object]]::new()
                Shown       = [System.Threading.ManualResetEventSlim]::new($false)
                PowerShell  = $null
                AsyncResult = $null
            })
        $ps = [powershell]::Create()
        $ps.Runspace = $script:StaRunspace
        $null = $ps.AddScript({
                param($h, $p)
                $module = Get-Module -Name 'Fluence.Wpf.PowerShell'
                & $module { param($h2, $p2) Start-FluenceUiPump -Handle $h2 -Pump $p2 } $h $p
            })
        $null = $ps.AddArgument($handle)
        $null = $ps.AddArgument($pump)
        $pump.PowerShell = $ps
        $script:UiPump = $pump
        $script:ProgressHandle = $handle
        $pump.AsyncResult = $ps.BeginInvoke()

        $deadline = [System.DateTime]::UtcNow.AddSeconds(15)
        while (-not $pump.Shown.Wait(250))
        {
            $pipelineState = $ps.InvocationStateInfo.State
            if ($pipelineState -in @('Completed', 'Failed', 'Stopped') -or [System.DateTime]::UtcNow -gt $deadline)
            {
                $reason = "pipeline state $pipelineState"
                if ($ps.Streams.Error.Count -gt 0)
                {
                    $reason = $ps.Streams.Error[0].ToString()
                }
                $handle.State.CloseRequested = $true
                if ($pump.AsyncResult.AsyncWaitHandle.WaitOne(5000))
                {
                    $ps.Dispose()
                    $pump.Shown.Dispose()
                    $script:UiPump = $null
                    $script:ProgressHandle = $null
                }
                throw "The progress window did not open on the Fluence UI runspace: $reason"
            }
        }
    }
    else
    {
        $parts = Invoke-OnFluenceUi -Script {
            param($h)
            Initialize-FluenceApplication -Theme $h.Spec.Theme -Backdrop $h.Spec.Backdrop -Accent $h.Spec.AccentColor
            $built = New-FluenceProgressWindow -Spec $h.Spec -State $h.State
            $built.Window.Show()
            if ($h.Mode -eq 'Inline')
            {
                Invoke-FluenceDispatcherPump
            }
            return $built
        } -ArgumentList @($handle)
        $handle.Parts = $parts
    }

    $script:ProgressHandle = $handle
    return $handle
}