lib/Spinner.ps1
|
function New-Spinner { <# .SYNOPSIS Starts an animated spinner in a background runspace. The spinner renders directly to [Console] until Stop-Spinner is called. Output is enqueued via Write-SpinnerLine and printed between spinner ticks so the spinner persists across multiple tasks without flicker. Returns $null when output isn't going to an interactive console (stdout redirected, -Disabled set, or running under CI). Downstream callers handle a $null spinner by printing output directly to the Information stream. #> [CmdletBinding()] param( [string] $InitialMessage = 'Working...', # Force the spinner off (caller already decided, e.g. -NoSpinner). [switch] $Disabled ) if ($Disabled) { return $null } # Don't draw a spinner when output isn't going to an interactive console. if ([Console]::IsOutputRedirected) { return $null } # GitHub Actions / most CI runners set these. IsOutputRedirected doesn't # always trip in Actions' pwsh steps, so the spinner would otherwise fill # the log with carriage-return noise. if ($env:CI -or $env:GITHUB_ACTIONS -or $env:TF_BUILD) { return $null } $shared = [hashtable]::Synchronized(@{ Stop = $false Message = $InitialMessage OutputLines = [System.Collections.Concurrent.ConcurrentQueue[string]]::new() UseColor = [bool]$script:UseColor }) $rs = [runspacefactory]::CreateRunspace() $rs.Open() $rs.SessionStateProxy.SetVariable('shared', $shared) $ps = [powershell]::Create().AddScript({ $esc = [char]27 $glyphs = '|', '/', '-', '\' $i = 0 $lastVisibleLen = 0 try { while (-not $shared.Stop) { # Drain queued output, clearing the spinner line first. $line = $null while ($shared.OutputLines.TryDequeue([ref]$line)) { if ($lastVisibleLen -gt 0) { [Console]::Write("`r" + (' ' * $lastVisibleLen) + "`r") $lastVisibleLen = 0 } [Console]::WriteLine($line) } # Redraw the spinner with the current message. Track the # visible length (ANSI escapes don't count) so we can pad # if the message shrinks between ticks. $g = $glyphs[$i % $glyphs.Length] $msg = $shared.Message $visibleText = "[$g] $msg" $visibleLen = $visibleText.Length $styled = if ($shared.UseColor) { "$esc[36m[$g]$esc[0m $msg" } else { $visibleText } $padding = if ($visibleLen -lt $lastVisibleLen) { ' ' * ($lastVisibleLen - $visibleLen) } else { '' } [Console]::Write("`r$styled$padding") $lastVisibleLen = $visibleLen Start-Sleep -Milliseconds 100 $i++ } } finally { # Erase the spinner line and flush any remaining output. if ($lastVisibleLen -gt 0) { [Console]::Write("`r" + (' ' * $lastVisibleLen) + "`r") } $line = $null while ($shared.OutputLines.TryDequeue([ref]$line)) { [Console]::WriteLine($line) } } }) $ps.Runspace = $rs $handle = $ps.BeginInvoke() [PSCustomObject]@{ Shared = $shared PS = $ps Runspace = $rs Handle = $handle } } function Set-SpinnerMessage { <# .SYNOPSIS Updates the active spinner's message. #> [CmdletBinding()] param( [Parameter()] $Spinner, [Parameter(Mandatory)] [string] $Message ) if ($null -eq $Spinner) { return } $Spinner.Shared.Message = $Message } function Write-SpinnerLine { <# .SYNOPSIS Queues a line for the spinner to print between ticks. Falls back to Write-Information when no spinner is active. #> [CmdletBinding()] param( [Parameter()] $Spinner, [Parameter(Mandatory)] [AllowEmptyString()] [string] $Line ) if ($null -eq $Spinner) { Write-Information $Line return } $Spinner.Shared.OutputLines.Enqueue($Line) } function Stop-Spinner { <# .SYNOPSIS Stops the spinner and disposes its runspace. #> [CmdletBinding()] param( [Parameter()] $Spinner ) if ($null -eq $Spinner) { return } $Spinner.Shared.Stop = $true $Spinner.PS.EndInvoke($Spinner.Handle) | Out-Null $Spinner.PS.Dispose() $Spinner.Runspace.Close() $Spinner.Runspace.Dispose() } |