Public/Runtime/Start-ElmProgram.ps1

function Start-ElmProgram {
    <#
    .SYNOPSIS
        Starts an Elm-architecture program in the terminal.

    .DESCRIPTION
        Calls InitFn to obtain the initial model, creates a terminal driver to read
        keyboard input, runs the MVU event loop until a Quit command is returned, then
        tears down the driver and returns the final model.

        The three required scriptblocks mirror the Elm Architecture:
          - InitFn : () -> { Model; Cmd }
          - UpdateFn : ($msg, $model) -> { Model; Cmd }
          - ViewFn : ($model) -> view-tree node

    .PARAMETER InitFn
        Scriptblock with no parameters that returns a PSCustomObject with Model and Cmd
        properties. Cmd may be $null.

    .PARAMETER UpdateFn
        Scriptblock accepting ($msg, $model) that returns a PSCustomObject with Model
        and Cmd properties. Return Cmd.Type = 'Quit' to exit the event loop.

    .PARAMETER ViewFn
        Scriptblock accepting ($model) that returns a view-tree node (Type 'Text' or
        'Box') produced by New-ElmText, New-ElmBox, or New-ElmRow.

    .PARAMETER Width
        Terminal width in columns used for layout. Defaults to the current terminal width
        ([Console]::WindowWidth). If the terminal reports no width (e.g. no TTY), falls
        back to 80. Must not exceed the actual terminal width - if it does, a terminating
        error is thrown with instructions to resize or omit the parameter.

    .PARAMETER Height
        Terminal height in rows used for layout. Defaults to the current terminal height
        ([Console]::WindowHeight). If the terminal reports no height, falls back to 24.
        Must not exceed the actual terminal height.

    .PARAMETER SubscriptionFn
        Optional scriptblock that accepts the current model and returns an array of
        subscription objects created by New-ElmKeySub and New-ElmTimerSub.

        When provided, Invoke-ElmSubscriptions becomes the sole InputQueue consumer
        and messages are dispatched via handler scriptblocks before reaching UpdateFn.
        This enables declarative, model-dependent event routing.

        When omitted, the event loop falls back to the legacy direct-dequeue path
        where raw KeyDown events are forwarded to UpdateFn unchanged.

        Example:
            $subFn = {
                param($model)
                $subs = @(New-ElmKeySub -Key 'Q' -Handler { 'Quit' })
                if ($model.Running) {
                    $subs += New-ElmTimerSub -IntervalMs 1000 -Handler { 'Tick' }
                }
                $subs
            }

    .PARAMETER TickMs
        When set to a positive integer, creates a background timer runspace that enqueues
        a Tick message ({ Type = 'Tick'; Key = 'Tick' }) to the input queue at the given
        interval in milliseconds. Use in UpdateFn by handling $msg.Key -eq 'Tick' to
        drive time-based state changes (animations, countdowns, game loops).
        Defaults to 0 (no ticking).

    .OUTPUTS
        PSCustomObject - the final model at the time the event loop exited.

    .EXAMPLE
        $init = { [PSCustomObject]@{ Model = [PSCustomObject]@{ Count = 0 }; Cmd = $null } }
        $update = { param($msg, $model)
            $newCount = if ($msg -eq 'Inc') { $model.Count + 1 } else { $model.Count }
            [PSCustomObject]@{ Model = [PSCustomObject]@{ Count = $newCount }; Cmd = $null }
        }
        $view = { param($model) New-ElmText -Content "Count: $($model.Count)" }
        Start-ElmProgram -InitFn $init -UpdateFn $update -ViewFn $view

    .NOTES
        Requires a terminal that supports ANSI escape sequences. On Windows, ensure
        Enable-VirtualTerminalProcessing has been called before invoking this function.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [scriptblock]$InitFn,

        [Parameter(Mandatory)]
        [scriptblock]$UpdateFn,

        [Parameter(Mandatory)]
        [scriptblock]$ViewFn,

        [Parameter()]
        [int]$Width = 0,

        [Parameter()]
        [int]$Height = 0,

        [Parameter()]
        [AllowNull()]
        [scriptblock]$SubscriptionFn = $null,

        [Parameter()]
        [int]$TickMs = 0
    )

    # Ensure ANSI/VT processing is active (required on Windows PS5.1/conhost; no-op elsewhere)
    $null = Enable-VirtualTerminal

    # Resolve actual terminal dimensions, falling back if running without a TTY
    $termWidth  = if ([Console]::WindowWidth  -gt 0) { [Console]::WindowWidth  } else { 80 }
    $termHeight = if ([Console]::WindowHeight -gt 0) { [Console]::WindowHeight } else { 24 }

    # Validate explicit sizes - must fit in the real terminal
    if ($PSBoundParameters.ContainsKey('Width') -and $Width -gt $termWidth) {
        $ex  = [System.ArgumentOutOfRangeException]::new(
            'Width',
            "Requested width ($Width) exceeds terminal width ($termWidth). " +
            'Resize the terminal or omit -Width to fill the terminal automatically.'
        )
        $err = [System.Management.Automation.ErrorRecord]::new(
            $ex, 'TerminalTooSmall',
            [System.Management.Automation.ErrorCategory]::InvalidArgument,
            $Width
        )
        $PSCmdlet.ThrowTerminatingError($err)
    }
    if ($PSBoundParameters.ContainsKey('Height') -and $Height -gt $termHeight) {
        $ex  = [System.ArgumentOutOfRangeException]::new(
            'Height',
            "Requested height ($Height) exceeds terminal height ($termHeight). " +
            'Resize the terminal or omit -Height to fill the terminal automatically.'
        )
        $err = [System.Management.Automation.ErrorRecord]::new(
            $ex, 'TerminalTooSmall',
            [System.Management.Automation.ErrorCategory]::InvalidArgument,
            $Height
        )
        $PSCmdlet.ThrowTerminatingError($err)
    }

    $resolvedWidth  = if ($PSBoundParameters.ContainsKey('Width'))  { $Width  } else { $termWidth  }
    $resolvedHeight = if ($PSBoundParameters.ContainsKey('Height')) { $Height } else { $termHeight }

    $driver = New-ElmTerminalDriver -AltScreen

    $tickLoop = $null
    if ($TickMs -gt 0) {
        $tickQueue    = $driver.InputQueue
        $tickInterval = $TickMs
        $tickLoop = Invoke-ElmDriverLoop -ScriptBlock {
            param($queue, $intervalMs)
            while ($true) {
                [System.Threading.Thread]::Sleep($intervalMs)
                $queue.Enqueue([PSCustomObject]@{ Type = 'Tick'; Key = 'Tick' })
            }
        } -Arguments @($tickQueue, $tickInterval)
    }

    $initResult    = & $InitFn
    $initialModel  = $initResult.Model

    try {
        $finalModel = Invoke-ElmEventLoop `
            -InitialModel   $initialModel `
            -UpdateFn       $UpdateFn `
            -ViewFn         $ViewFn `
            -InputQueue     $driver.InputQueue `
            -SubscriptionFn $SubscriptionFn `
            -TerminalWidth  $resolvedWidth `
            -TerminalHeight $resolvedHeight
    } finally {
        if ($null -ne $tickLoop) {
            try { $tickLoop.PowerShell.Stop() } catch {}
            try { $tickLoop.Runspace.Close()  } catch {}
        }
        & $driver.Stop
    }

    return $finalModel
}