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 { } } } |