private/30-tasks.ps1

# ---------------------------------------------------------------------- timers and tasks --

function Get-UiTimerWait {
    param($ctx, [int]$Maximum)
    $wait = $Maximum
    $now = [DateTime]::UtcNow
    foreach ($timer in @($ctx.Timers)) {
        $ms = [int][Math]::Ceiling(($timer.Due - $now).TotalMilliseconds)
        if ($ms -lt $wait) { $wait = [Math]::Max(0, $ms) }
    }
    if (@($ctx.Tasks).Count -gt 0 -and $wait -gt 20) { $wait = 20 }
    return $wait
}

function Invoke-UiOwned {
    # Run a UI4ME-owned callback (timer, task completion) like a Core event callback:
    # Core defers host notifications until it returns, so event handlers never run
    # nested inside it, and content it creates belongs to $Owner.
    param($ctx, $Owner, $Handler, [object[]]$Arguments, [string]$Context)
    $app = $ctx.App
    $wasDispatching = $false
    if ($null -ne $app) { $wasDispatching = [bool]$app.Dispatching; $app.Dispatching = $true }
    $previous = $ctx.CurrentOwner
    $ctx.CurrentOwner = $Owner
    try { Invoke-UiHandler $Handler $Arguments $Context }
    finally {
        $ctx.CurrentOwner = $previous
        if ($null -ne $app) { $app.Dispatching = $wasDispatching }
    }
}

function Invoke-UiTimers {
    param($ctx)
    $now = [DateTime]::UtcNow
    foreach ($timer in @($ctx.Timers)) {
        if (-not $ctx.Timers.Contains($timer)) { continue }  # already handled by a nested pump
        if ($timer.Cancelled -or ($null -ne $timer.Owner -and $timer.Owner.Disposed)) { [void]$ctx.Timers.Remove($timer); continue }
        if ($timer.Due -gt $now) { continue }
        if ($timer.Repeat) { $timer.Due = $now.AddMilliseconds($timer.Interval) } else { [void]$ctx.Timers.Remove($timer) }
        Invoke-UiOwned $ctx $timer.Owner $timer.Action @() 'timer'
    }
}

function Start-UI4MEUiTimer {
    <#
    .SYNOPSIS
    Runs -Action after -Milliseconds (repeatedly with -Repeat) on the UI runspace. Returns a timer handle.
    .DESCRIPTION
    Timers created while rendering runtime content (New-UI4MEEach/-When items) stop when that content is removed.
    #>

    [CmdletBinding()]
    param([Parameter(Mandatory)][ValidateRange(1, 86400000)][int]$Milliseconds, [Parameter(Mandatory)]$Action,
          [switch]$Repeat)
    $ctx = Assert-UiBuilder
    $timer = [pscustomobject]@{ Due = [DateTime]::UtcNow.AddMilliseconds($Milliseconds); Interval = $Milliseconds
        Repeat = [bool]$Repeat; Action = $Action; Cancelled = $false; Owner = $ctx.CurrentOwner }
    $timer.PSObject.TypeNames.Insert(0, 'UI4ME.UiTimer')
    [void]$ctx.Timers.Add($timer)
    return $timer
}

function Stop-UI4MEUiTimer {
    <# .SYNOPSIS Cancels a timer from Start-UI4MEUiTimer. #>
    [CmdletBinding()] param([Parameter(Mandatory)]$Timer)
    $Timer.Cancelled = $true
}

function Start-UI4MEUiTask {
    <#
    .SYNOPSIS
    Runs -Work in a separate PowerShell runspace; -OnDone/-OnError run later on the UI runspace.
    .DESCRIPTION
    -Work cannot see your script's variables or call UI4ME commands: pass inputs with -ArgumentList
    and return a result. Stop-UI4MEUiTask cancels it (the pipeline is stopped; completion callbacks
    are skipped). Tasks started inside runtime content are cancelled when that content is removed.
    #>

    [CmdletBinding()]
    param([Parameter(Mandatory)][scriptblock]$Work, [object[]]$ArgumentList = @(),
          [scriptblock]$OnDone, [scriptblock]$OnError, [scriptblock]$OnCancel)
    $ctx = Assert-UiBuilder
    $ps = [System.Management.Automation.PowerShell]::Create()
    $null = $ps.AddScript($Work.ToString())
    foreach ($argument in $ArgumentList) { $null = $ps.AddArgument($argument) }
    $task = [pscustomobject]@{ PowerShell = $ps; Async = $null; OnDone = $OnDone; OnError = $OnError; OnCancel = $OnCancel
        State = 'running'; Owner = $ctx.CurrentOwner; Result = $null; Error = $null }
    $task.PSObject.TypeNames.Insert(0, 'UI4ME.UiTask')
    $task.Async = $ps.BeginInvoke()
    [void]$ctx.Tasks.Add($task)
    if ($null -ne $ctx.CurrentOwner) {
        [void]$ctx.CurrentOwner.Cleanups.Add((New-UiCallable 'Stop-UiTaskQuiet' $task))
    }
    return $task
}

function Stop-UI4MEUiTask {
    <# .SYNOPSIS Cancels a running Start-UI4MEUiTask task. Its OnDone/OnError do not run. #>
    [CmdletBinding()] param([Parameter(Mandatory)]$Task, [switch]$Quiet)
    if ($Task.State -ne 'running') { return }
    $Task.State = 'cancelled'
    try { $null = $Task.PowerShell.BeginStop($null, $null) } catch { }
    $ctx = $script:UiBuilder
    if (-not $Quiet -and $Task.OnCancel -and $ctx -and -not $ctx.Stopping) { Invoke-UiHandler $Task.OnCancel @() 'task cancel' }
}

function Stop-UiTaskQuiet { param($Task) Stop-UI4MEUiTask -Task $Task -Quiet }

function Update-UiTasks {
    param($ctx)
    foreach ($task in @($ctx.Tasks)) {
        if (-not $ctx.Tasks.Contains($task)) { continue }  # already handled by a nested pump
        if ($task.State -eq 'cancelled') {
            if ($task.Async.IsCompleted) { [void]$ctx.Tasks.Remove($task); try { $task.PowerShell.Dispose() } catch { } }
            continue
        }
        if (-not $task.Async.IsCompleted) { continue }
        [void]$ctx.Tasks.Remove($task)
        try {
            $output = $task.PowerShell.EndInvoke($task.Async)
            if ($task.PowerShell.HadErrors -and $task.PowerShell.Streams.Error.Count -gt 0) {
                throw $task.PowerShell.Streams.Error[0].Exception
            }
            $task.State = 'succeeded'
            $value = if ($output.Count -eq 1) { $output[0] } else { @($output) }
            $task.Result = $value
            if ($task.OnDone -and ($null -eq $task.Owner -or -not $task.Owner.Disposed)) { Invoke-UiOwned $ctx $task.Owner $task.OnDone @(, $value) 'task' }
        } catch {
            $task.State = 'failed'
            $task.Error = $_
            if ($null -ne $task.Owner -and $task.Owner.Disposed) { }
            elseif ($task.OnError) { Invoke-UiOwned $ctx $task.Owner $task.OnError @($_) 'task error' }
            else { Write-UiError $_ 'background task' }
        } finally {
            try { $task.PowerShell.Dispose() } catch { }
        }
    }
}

function Stop-UiTasks {
    param($ctx)
    foreach ($task in @($ctx.Tasks)) { Stop-UI4MEUiTask -Task $task -Quiet }
    $ctx.Tasks.Clear()
}