private/60-dynamic.ps1

# --------------------------------------------------------------- dynamic content --

function Add-UI4MEUiContent {
    <#
    .SYNOPSIS
    Renders components at runtime into a container element (from a callback or -OnReady). Returns a handle for Remove-UI4MEUiContent.
    #>

    [CmdletBinding()]
    param([Parameter(Mandatory)][string]$Into, [Parameter(Mandatory)][scriptblock]$Content,
          [ValidateSet('beforeend', 'afterbegin', 'replace')][string]$Position = 'beforeend', [object[]]$ArgumentList = @())
    $ctx = Assert-UiBuilder
    if (-not $ctx.Document) { throw 'Add-UI4MEUiContent needs a running application.' }
    $handle = Invoke-UiRender $Content $ArgumentList
    if ($Position -eq 'replace') {
        Set-UI4MEDocumentHtml -Document $ctx.Document -Element $Into -Html $handle.Html
    } else {
        Set-UI4MEDocumentHtml -Document $ctx.Document -Element $Into -Html $handle.Html -Position $Position
    }
    Invoke-UiSetups $handle.Setups
    return $handle
}

function Invoke-UiRender {
    # Build markup for runtime content inside a fresh owner and wrapper element.
    param([scriptblock]$Content, [object[]]$ArgumentList, [string]$Tag = 'div', [string]$Class = 'u4-region')
    $ctx = Assert-UiBuilder
    $owner = New-UiOwner $ctx.CurrentOwner
    $target = [pscustomobject]@{ Markup = (New-Object System.Text.StringBuilder); Setups = (New-Object System.Collections.ArrayList) }
    $previousTarget = $ctx.Target; $previousOwner = $ctx.CurrentOwner
    $ctx.Target = $target; $ctx.CurrentOwner = $owner
    $ctx.Sequence = [int]$ctx.Sequence + 1
    $wrapper = 'u4ps-region-{0}' -f $ctx.Sequence
    try {
        Add-UiMarkup ('<{0} id="{1}" class="{2}">' -f $Tag, $wrapper, $Class)
        $null = & $Content @ArgumentList
        Add-UiMarkup "</$Tag>"
    } catch {
        Remove-UiOwner $owner
        throw
    } finally {
        $ctx.Target = $previousTarget; $ctx.CurrentOwner = $previousOwner
    }
    $handle = [pscustomobject]@{ Id = $wrapper; Owner = $owner; Html = $target.Markup.ToString(); Setups = $target.Setups }
    $handle.PSObject.TypeNames.Insert(0, 'UI4ME.UiContent')
    return $handle
}

function Remove-UI4MEUiContent {
    <# .SYNOPSIS Removes runtime content; its handlers, bindings, timers and tasks are released. #>
    [CmdletBinding()] param([Parameter(Mandatory)]$Handle)
    $ctx = Assert-UiBuilder
    if ($Handle.Owner.Disposed) { return }
    Remove-UiOwner $Handle.Owner
    if ($ctx.Document -and -not $ctx.Stopping) { Remove-UI4MEElement -Document $ctx.Document -Element $Handle.Id }
}

function New-UI4MEWhen {
    <#
    .SYNOPSIS
    Shows -Content while -Condition (a state) is true, else -Else. The visible branch is rebuilt each time it appears,
    and the hidden branch is disposed (its handlers, timers and tasks stop).
    #>

    [CmdletBinding()]
    param([Parameter(Mandatory)][object]$Condition, [Parameter(Mandatory)][scriptblock]$Content, [scriptblock]$Else)
    if (-not (Test-UiState $Condition)) { throw '-Condition takes a state from New-UI4MEState/New-UI4MEComputed' }
    $id = New-UiId 'when'
    $ctx = Assert-UiBuilder
    $region = [pscustomobject]@{ Current = $null }
    $branch = if ([bool]$Condition.Value) { $Content } else { $Else }
    $initial = ''
    if ($branch) {
        $region.Current = Invoke-UiRender $branch @()
        $initial = $region.Current.Html
        foreach ($setup in $region.Current.Setups) { [void]$ctx.Target.Setups.Add($setup) }
    }
    Add-UiMarkup ('<div id="{0}" class="u4-region">{1}</div>' -f $id, $initial)
    $region | Add-Member -NotePropertyName Then -NotePropertyValue $Content
    $region | Add-Member -NotePropertyName Otherwise -NotePropertyValue $Else
    $null = Register-UiBinding $Condition $id 'Set-UiWhenBinding' $region
    return $id
}

function Set-UiWhenBinding { param($ctx, $element, $value, $region)
    if ($region.Current) { Remove-UiOwner $region.Current.Owner; $region.Current = $null }
    $next = if ([bool]$value) { $region.Then } else { $region.Otherwise }
    if ($next) { $region.Current = Add-UI4MEUiContent -Into $element -Content $next -Position replace }
    else { Set-UI4MEDocumentHtml -Document $ctx.Document -Element $element -Html '' }
}

function New-UI4MEEach {
    <#
    .SYNOPSIS
    Keyed list: renders -Template once per item of -Items (a state holding an array). Items are matched by -Key:
    unchanged keys keep their DOM (focus, typed text, scroll); removed keys are disposed; reorders move nodes.
    .EXAMPLE
    New-UI4MEEach -Items $todos -Key id -Template { param($item) New-UI4METext -Text $item.title }
    #>

    [CmdletBinding()]
    param([Parameter(Mandatory)][object]$Items, [Parameter(Mandatory)][string]$Key,
          [Parameter(Mandatory)][scriptblock]$Template, [scriptblock]$Empty)
    if (-not (Test-UiState $Items)) { throw '-Items takes a state holding an array' }
    $id = New-UiId 'each'
    $ctx = Assert-UiBuilder
    $rendered = [ordered]@{}
    $sb = New-Object System.Text.StringBuilder
        foreach ($item in @($Items.Value)) {
        $k = [string](Get-UiItemKey $item $Key)
        if ($rendered.Contains($k)) { throw "Duplicate key '$k' in New-UI4MEEach" }
        $handle = Invoke-UiRender $Template @(, $item)
        $rendered[$k] = $handle
        [void]$sb.Append($handle.Html)
        foreach ($setup in $handle.Setups) { [void]$ctx.Target.Setups.Add($setup) }
    }
    $emptyId = "$id-empty"
    $emptyMarkup = ''
    if ($Empty) {
        $emptyHandle = Invoke-UiRender $Empty @()
        $emptyMarkup = '<div id="{0}"{1}>{2}</div>' -f $emptyId, $(if ($rendered.Count) { ' hidden' } else { '' }), $emptyHandle.Html
        foreach ($setup in $emptyHandle.Setups) { [void]$ctx.Target.Setups.Add($setup) }
    }
    Add-UiMarkup ('<div id="{0}" class="u4-column u4-gap-sm">{1}</div>{2}' -f $id, $sb.ToString(), $emptyMarkup)
    $data = [pscustomobject]@{ Rendered = $rendered; Key = $Key; Template = $Template; EmptyId = $(if ($Empty) { $emptyId } else { $null }) }
    $null = Register-UiBinding $Items $id 'Set-UiEachBinding' $data
    return $id
}

function Set-UiEachBinding { param($c, $element, $value, $data)
    $rendered = $data.Rendered
    $next = [ordered]@{}
    foreach ($item in @($value)) {
        $k = [string](Get-UiItemKey $item $data.Key)
        if ($next.Contains($k)) { throw "Duplicate key '$k' in New-UI4MEEach" }
        $next[$k] = $item
    }
    $ops = New-Object System.Collections.ArrayList
    foreach ($k in @($rendered.Keys)) {
        if (-not $next.Contains($k)) {
            Remove-UiOwner $rendered[$k].Owner
            [void]$ops.Add(@{ op = 'remove_element'; element = $rendered[$k].Id })
            $rendered.Remove($k)
        }
    }
    $setups = New-Object System.Collections.ArrayList
    foreach ($k in @($next.Keys)) {
        if (-not $rendered.Contains($k)) {
            $handle = Invoke-UiRender $data.Template @(, $next[$k])
            $rendered[$k] = $handle
            [void]$ops.Add(@{ op = 'insert_html'; element = $element; position = 'beforeend'; html = $handle.Html })
            foreach ($setup in $handle.Setups) { [void]$setups.Add($setup) }
        } else {
            [void]$ops.Add(@{ op = 'move_element'; element = $rendered[$k].Id; target = $element; position = 'beforeend' })
        }
    }
    # Keep the bookkeeping order equal to the DOM order.
    $ordered = [ordered]@{}
    foreach ($k in @($next.Keys)) { $ordered[$k] = $rendered[$k] }
    $rendered.Clear()
    foreach ($k in @($ordered.Keys)) { $rendered[$k] = $ordered[$k] }
    if ($data.EmptyId) {
        if ($next.Count) { [void]$ops.Add(@{ op = 'set_attribute'; element = $data.EmptyId; name = 'hidden'; value = '' }) }
        else { [void]$ops.Add(@{ op = 'remove_attribute'; element = $data.EmptyId; name = 'hidden' }) }
    }
    for ($i = 0; $i -lt $ops.Count; $i += 1000) {
        $last = [Math]::Min($ops.Count, $i + 1000) - 1
        Invoke-UI4MEBatch -Document $c.Document -Operations @($ops[$i..$last])
    }
    Invoke-UiSetups $setups
}

function Get-UiItemKey {
    param($Item, [string]$Key)
    if ($Item -is [System.Collections.IDictionary]) { return $Item[$Key] }
    if ($null -ne $Item -and $null -ne $Item.PSObject.Properties[$Key]) { return $Item.$Key }
    throw "Item has no key property '$Key'"
}