Private/Console/Status.psm1

using namespace System
using namespace System.Threading

using module .\Ansi.psm1
using module .\Colors.psm1
using module .\Rendering.psm1
using module .\Progress.psm1
using module .\Spinners.psm1

class StatusContext {
  hidden [object]$_syncRoot
  [string]$Message
  [bool]$IsCompleted
  [bool]$IsFailed

  StatusContext([string]$message) {
    $this._syncRoot = [object]::new()
    $this.Message = $message
    $this.IsCompleted = $false
    $this.IsFailed = $false
  }

  [void] Update([string]$message) {
    [Monitor]::Enter($this._syncRoot)
    try {
      $this.Message = $message
    } finally {
      [Monitor]::Exit($this._syncRoot)
    }
  }

  [void] Complete() {
    [Monitor]::Enter($this._syncRoot)
    try {
      $this.IsCompleted = $true
    } finally {
      [Monitor]::Exit($this._syncRoot)
    }
  }

  [void] Fail() {
    [Monitor]::Enter($this._syncRoot)
    try {
      $this.IsFailed = $true
    } finally {
      [Monitor]::Exit($this._syncRoot)
    }
  }

  [string] SnapshotMessage() {
    [Monitor]::Enter($this._syncRoot)
    try {
      return $this.Message
    } finally {
      [Monitor]::Exit($this._syncRoot)
    }
  }
}

class StatusLiveSession {
  [Status]$Owner
  [StatusContext]$Context
  [LiveDisplayRegion]$Display
  [int]$Frame

  StatusLiveSession([Status]$owner, [StatusContext]$context, [LiveDisplayRegion]$display) {
    $this.Owner = $owner
    $this.Context = $context
    $this.Display = $display
    $this.Frame = 0
  }

  [void] Tick([object]$state) {
    $line = $this.Owner.RenderLine($this.Context.SnapshotMessage(), $this.Frame, $false, $false)
    $this.Frame++
    $this.Display.Render([string[]]@($line))
  }
}

class Status {
  [AnsiWriter]$Writer
  [Spinner]$Spinner
  [Style]$SpinnerStyle
  [int]$RefreshRateMs = 120

  Status([AnsiWriter]$writer) {
    $this.Writer = $writer
    $this.Spinner = [Spinner]""
    $this.SpinnerStyle = [Color]::Yellow
  }

  [void] Start([string]$message, [Action[StatusContext]]$action) {
    $context = [StatusContext]::new($message)
    $display = [LiveDisplayRegion]::new($this.Writer)
    $session = [StatusLiveSession]::new($this, $context, $display)
    $refresh = if ($null -ne $this.Spinner -and $this.Spinner.Interval.TotalMilliseconds -gt 0) { $this.Spinner.Interval.TotalMilliseconds } else { $this.RefreshRateMs }
    $thread = [ProgressRefreshThread]::new(([TimerCallback] $session.Tick), $refresh)
    $failed = $false

    try {
      $action.Invoke($context)
      $context.Complete()
      $session.Tick($null)
    } catch {
      $failed = $true
      $context.Fail()
      throw
    } finally {
      $thread.Dispose()
      $finalLine = $this.RenderLine($context.SnapshotMessage(), $session.Frame, $true, $failed)
      $display.Complete([string[]]@($finalLine))
    }
  }

  hidden [string] RenderLine([string]$message, [int]$frame, [bool]$isFinal, [bool]$isFailed) {
    $safeMessage = if ([string]::IsNullOrWhiteSpace($message)) { 'Working' } else { $message }
    if ($isFinal) {
      $marker = if ($isFailed) { 'x' } else { '+' }
      return '{0} {1}' -f $marker, $safeMessage
    }

    $spinnerList = if ($this.Writer.Capabilities.Unicode -or $this.Spinner.IsUnicode -eq $false) { $this.Spinner.Frames } else { ([spinner]"Ascii").Frames }
    $spinnerFrame = $spinnerList[$frame % $spinnerList.Length]

    if ($this.SpinnerStyle -and $this.Writer.Capabilities.Ansi) {
      $spinnerFrame = "`e[" + [AnsiCodeBuilder]::GetAnsi($this.SpinnerStyle, $this.Writer.Capabilities.ColorSystem) + "m" + $spinnerFrame + "`e[0m"
    }

    return '{0} {1}' -f $spinnerFrame, $safeMessage
  }
}