public/controls/status/New-UiProgress.ps1

function New-UiProgress {
    <#
    .SYNOPSIS
        Creates a progress bar.
    .DESCRIPTION
        Determinate or indeterminate progress bar. Drive it with Set-UiProgress while an
        action runs, or just assign the hydrated variable and let it sync at action end.
    .PARAMETER Variable
        Variable name to reference the bar later.
    .PARAMETER Label
        Optional text shown above the bar. Updateable via Set-UiProgress -Label.
    .PARAMETER Minimum
        Lower limit. Default 0.
    .PARAMETER Maximum
        Upper limit. Default 100.
    .PARAMETER Default
        Initial value. Default 0.
    .PARAMETER Height
        Bar height in pixels.
    .PARAMETER Indeterminate
        Animated bar instead of a fill.
    .PARAMETER ShowValue
        Render the current value as text beside the bar. Pointless with
        -Indeterminate (the bar has no real value to display), but allowed.
    .PARAMETER ValueFormat
        Format string for the value text. {0} is current, {1} is max.
        Defaults to '{0:N0}%'. '{0}/{1}' gives you the x/y look.
    .PARAMETER Severity
        Color tint: Info (default), Success, Warning, Error.
    .PARAMETER WPFProperties
        Hashtable of additional WPF properties.
    .EXAMPLE
        New-UiProgress -Variable 'progress'
    .EXAMPLE
        New-UiProgress -Variable 'loading' -Indeterminate -Label 'Connecting...'
    .EXAMPLE
        New-UiProgress -Variable 'files' -Maximum 250 -ShowValue -ValueFormat '{0}/{1} files'
    .EXAMPLE
        New-UiProgress -Variable 'disk' -Default 87 -ShowValue -Severity Warning -Label 'Disk usage'
    .EXAMPLE
        # Set-UiProgress drives the bar from any action
        New-UiProgress -Variable 'copyBar' -ShowValue
        New-UiButton -Text 'Copy' -NoOutput -Action {
            foreach ($step in 1..10) {
                Set-UiProgress -Variable 'copyBar' -Value ($step * 10)
                Start-Sleep -Milliseconds 250
            }
        }
    #>

    [CmdletBinding()]
    param(
        # Optional. A display bar on its own needs no name, and leaving it out means the bar is never registered for Set-UiProgress by name. The body puts up a throwaway name for the registry.
        [string]$Variable,

        [string]$Label,

        [double]$Minimum = 0,

        [double]$Maximum = 100,

        [double]$Default = 0,

        [int]$Height = 6,

        [switch]$Indeterminate,

        [switch]$ShowValue,

        [string]$ValueFormat = '{0:N0}%',

        [ValidateSet('Info', 'Success', 'Warning', 'Error')]
        [string]$Severity = 'Info',

        [Parameter()]
        [hashtable]$WPFProperties
    )

    $session = Assert-UiSession -CallerName 'New-UiProgress'
    $parent  = $session.CurrentParent
    Write-Debug "Variable='$Variable', Min=$Minimum Max=$Maximum Default=$Default Severity=$Severity"

    if ($Maximum -le $Minimum) {
        throw "New-UiProgress: -Maximum ($Maximum) must be greater than -Minimum ($Minimum)."
    }

    $progress = [System.Windows.Controls.ProgressBar]::new()
    $progress.Minimum         = $Minimum
    $progress.Maximum         = $Maximum
    $progress.Value           = [Math]::Max($Minimum, [Math]::Min($Maximum, $Default))
    $progress.IsIndeterminate = $Indeterminate.IsPresent
    $progress.Margin          = [System.Windows.Thickness]::new(4, 4, 4, 8)

    # ThemeEngine reads Tag.BrushTag and rebinds Foreground on theme switches.
    $brushKey = Get-SeverityBrushKey -Severity $Severity -UseAccentDefault

    # Tag goes on early so the first ApplyTheme pass picks up the right brush.
    # ValueBlock/LabelBlock get filled in below if the wrapper is built.
    $labelBlock = $null
    $valueBlock = $null
    $progress.Tag = @{
        LabelBlock  = $labelBlock
        ValueBlock  = $valueBlock
        ValueFormat = $ValueFormat
        Severity    = $Severity
        BrushTag    = $brushKey
    }

    Set-ProgressBarStyle -ProgressBar $progress

    if ($PSBoundParameters.ContainsKey('Height')) { $progress.Height = $Height }

    if ($WPFProperties) {
        # Tag is reserved - it stores the metadata that makes -Label/-ShowValue/-Severity work. Letting the caller stomp it would silently break all three.
        if ($WPFProperties.ContainsKey('Tag')) {
            Write-Warning "New-UiProgress: -WPFProperties Tag is reserved (used for label/value/severity bookkeeping). Ignoring."
            $WPFProperties = @{} + $WPFProperties
            [void]$WPFProperties.Remove('Tag')
        }
        Set-UiProperties -Control $progress -Properties $WPFProperties
    }

    # Wrap in a stack only when there's a label or value text. Unwrapped bars stay unwrapped so existing layouts don't shift a pixel.
    $needsWrapper = $Label -or $ShowValue
    if ($needsWrapper) {
        $colors = Get-ThemeColors
        $stack = [System.Windows.Controls.StackPanel]::new()
        $stack.Margin = [System.Windows.Thickness]::new(4, 4, 4, 8)

        if ($Label) {
            $labelBlock = [System.Windows.Controls.TextBlock]@{
                Text       = $Label
                FontSize   = 12
                Foreground = ConvertTo-UiBrush $colors.ControlFg
                Margin     = [System.Windows.Thickness]::new(0, 0, 0, 4)
                Tag        = 'ControlForegroundBrush'
            }
            [PsUi.ThemeEngine]::RegisterElement($labelBlock)
            [void]$stack.Children.Add($labelBlock)
        }

        # Bar + value text share a row so the percentage rides next to it
        if ($ShowValue) {
            $row = [System.Windows.Controls.Grid]::new()
            $col1 = [System.Windows.Controls.ColumnDefinition]::new()
            $col1.Width = [System.Windows.GridLength]::new(1, [System.Windows.GridUnitType]::Star)
            $col2 = [System.Windows.Controls.ColumnDefinition]::new()
            $col2.Width = [System.Windows.GridLength]::Auto
            $row.ColumnDefinitions.Add($col1)
            $row.ColumnDefinitions.Add($col2)

            $progress.Margin = [System.Windows.Thickness]::new(0, 0, 8, 0)
            $progress.VerticalAlignment = 'Center'
            [System.Windows.Controls.Grid]::SetColumn($progress, 0)
            [void]$row.Children.Add($progress)

            $valueBlock = [System.Windows.Controls.TextBlock]@{
                Text              = ($ValueFormat -f $progress.Value, $Maximum)
                FontSize          = 12
                Foreground        = ConvertTo-UiBrush $colors.ControlFg
                VerticalAlignment = 'Center'
                MinWidth          = 48
                TextAlignment     = 'Right'
                Tag               = 'ControlForegroundBrush'
            }
            [PsUi.ThemeEngine]::RegisterElement($valueBlock)
            [System.Windows.Controls.Grid]::SetColumn($valueBlock, 1)
            [void]$row.Children.Add($valueBlock)

            # Update the text whenever the bar moves. Read Maximum from the sender so post-construction tweaks (yes, people do that) stay in sync.
            $capturedFormat = $ValueFormat
            $progress.Add_ValueChanged({
                param($sender, $e)
                $valueBlock.Text = $capturedFormat -f $e.NewValue, $sender.Maximum
            }.GetNewClosure())

            [void]$stack.Children.Add($row)
        }
        else {
            [void]$stack.Children.Add($progress)
        }

        [void]$parent.Children.Add($stack)
    }
    else {
        [void]$parent.Children.Add($progress)
    }

    # Patch the metadata with the actual wrapper blocks (if any).
    $progress.Tag.LabelBlock = $labelBlock
    $progress.Tag.ValueBlock = $valueBlock

    # A display bar on its own has no -Variable, but Register-UiControlComplete's -Name is Mandatory, and an empty string there makes PowerShell prompt for it and the whole window hangs. Hand it a throwaway name (same trick New-UiStatusBar uses for anonymous bars).
    $varName = if ($Variable) { $Variable  }
    else {  '_anonProgress_' + [System.Guid]::NewGuid().ToString('N').Substring(0, 8)  }

    Write-Debug "Registered progress bar '$varName' (wrapper=$needsWrapper)"
    Register-UiControlComplete -Name $varName -Control $progress -InitialValue $progress.Value
}