TreeControl/SRxDeferredAction.ps1

<#
================================================================================
  SRxDeferredAction.psm1
  -----------------------------------------------------------------------------
  Run work AFTER the UI has repainted, with busy feedback and safe cancellation.

  THE PROBLEM IT SOLVES
    A handler that changes layout and then does heavy work does BOTH before
    WinForms gets to paint. The user sees a half-laid-out window frozen for
    seconds - a stale toolbar over an empty panel, a button stuck pressed.

    Application::DoEvents() is the usual reach for this and it is the wrong
    tool: it pumps messages MID-HANDLER, so a second click can re-enter the
    first one. RETURNING from the handler is a genuine yield.

  THE PATTERN
        PHASE 1 change layout, show busy, RETURN
                  -> handler exits, pump drains, WinForms paints
        PHASE 2 a short timer tick runs the work on a settled layout

  SCOPE - deliberately narrow
    IN : the timer, the yield, busy cursor/status pairing, re-entrancy
         guarding, cancellation.
    OUT: which panels to move, what work to defer. That is host knowledge and
         belongs in the caller.

  WHY A SCRIPTBLOCK AND NOT AN ACTION NAME
    An earlier hand-rolled version dispatched on a string key with an if/elseif
    chain - it grew with every case and coupled the dispatcher to method names.
    Taking the work as a scriptblock removes the switch entirely.

  USAGE
      # once, at construction
      $this._defer = New-SRxDeferredAction
      $this._defer.Attach($this) # the Form - for cursor + a status sink
      $this._defer.OnStatus = { param($t) $host1._toolstripStatusLabel.Text = $t }

      # at a transition
      $this.ShowSchemaPanel('pick') # PHASE 1 - layout only
      $me = $this
      $this._defer.Run(
          'Preparing composition...', # busy text
          'Composition: pick a layer.', # done text
          { $me.completeCompositionEntry() } # PHASE 2
      )

      # teardown
      $this._defer.Cancel()
================================================================================
#>


class SRxDeferredAction {

    hidden [System.Windows.Forms.Timer] $_timer
    hidden [scriptblock] $_work
    hidden [string] $_doneText
    hidden [object] $_owner          # Form/Control used for the wait cursor
    hidden [bool] $_running

    # Delay before PHASE 2. Long enough for one paint cycle; short enough that
    # the user does not perceive it as lag.
    [int] $Delay = 50

    # & $OnStatus $text - where busy/done messages go (status bar, log, ...)
    [scriptblock] $OnStatus = $null

    # & $OnError $message - optional; defaults to Write-Host
    [scriptblock] $OnError = $null

    SRxDeferredAction() {
        $this._running = $false
        $this._timer = [System.Windows.Forms.Timer]::new()
        $this._timer.Interval = $this.Delay
        $this._timer.Tag = $this
        $this._timer.add_tick({
            param($source, $e)
            $d = $source.Tag
            $d.tick()
        })
    }

    # Supply the Form/Control whose Cursor should show the wait state.
    [void] Attach($owner) { $this._owner = $owner }

    # ======================================================== the API

    # Queue PHASE 2. Call this LAST in the handler - everything before it should
    # be cheap layout work, and returning is what lets the paint happen.
    [void] Run([string]$busyText, [string]$doneText, [scriptblock]$work) {
        try {
            if ($null -eq $work) { return }

            # Re-entrancy: a second Run() while one is queued REPLACES it rather
            # than stacking. Two rapid clicks should do the work once, for the
            # latest request - not twice.
            $this._timer.Stop()

            $this._work = $work
            $this._doneText = $doneText
            $this.setBusy($true, $busyText)

            $this._timer.Interval = $this.Delay
            $this._timer.Start()
        } catch { $this.reportError("Run - $($_.Exception.Message)") }
    }

    # Abandon any queued work. Safe to call when nothing is pending - teardown
    # paths should call it unconditionally so a queued PHASE 2 cannot fire after
    # the objects it touches are gone.
    [void] Cancel() {
        try {
            $this._timer.Stop()
            $this._work = $null
            $this._doneText = ''
            if ($this._running) { $this.setBusy($false, $null) }
            $this._running = $false
        } catch { }
    }

    [bool] IsPending() { return $this._timer.Enabled }
    [bool] IsRunning() { return $this._running }

    # ======================================================== internals

    hidden [void] tick() {
        $this._timer.Stop()

        $w = $this._work
        $done = $this._doneText
        $this._work = $null
        $this._doneText = ''

        if ($null -eq $w) { $this.setBusy($false, $null); return }

        $this._running = $true
        try { & $w }
        catch { $this.reportError("deferred work - $($_.Exception.Message)") }
        finally {
            $this._running = $false
            $this.setBusy($false, $done)
        }
    }

    hidden [void] setBusy([bool]$on, [string]$text) {
        try {
            if ($null -ne $this._owner) {
                if ($on) { $this._owner.Cursor = [System.Windows.Forms.Cursors]::WaitCursor }
                else     { $this._owner.Cursor = [System.Windows.Forms.Cursors]::Default }
            }
        } catch { }
        try {
            if ((-not [string]::IsNullOrEmpty($text)) -and ($null -ne $this.OnStatus)) { & $this.OnStatus $text }
        } catch { }
    }

    hidden [void] reportError([string]$message) {
        try {
            if ($null -ne $this.OnError) { & $this.OnError $message; return }
        } catch { }
        Write-Host "SRxDeferredAction - $message"
    }

    [void] Dispose() {
        try { $this.Cancel(); $this._timer.Dispose() } catch { }
    }
}