Private/Rendering/Compare-ElmViewTree.ps1

function Compare-ElmViewTree {
    <#
    .SYNOPSIS
        Diffs two measured view trees and returns a list of patches.

    .DESCRIPTION
        Compares an old and new measured view tree (produced by Measure-ElmViewTree)
        and returns an array of patch objects for use with ConvertTo-AnsiPatch.

        Patch types:
        - FullRedraw: returned as a single-item array when the old tree is $null or when
          the layout has changed (different number of leaf nodes or any position shifted).
        - Replace: returned for each Text leaf node whose Content or Style has changed.
        - Empty array: returned when both trees are structurally identical with identical
          content and styles.

        Compare-ElmViewTree does not emit Clear patches. The caller should treat
        FullRedraw patches by invoking ConvertTo-AnsiOutput for a full re-render.

    .PARAMETER OldTree
        The previously measured view tree. Pass $null on the first render.

    .PARAMETER NewTree
        The newly measured view tree to compare against the old tree.

    .OUTPUTS
        [object[]] - An array of patch PSCustomObjects. May be empty, contain only a
        single FullRedraw, or contain one or more Replace patches.

    .EXAMPLE
        $measured = Measure-ElmViewTree -Root $view -TermWidth 80 -TermHeight 24
        $patches = Compare-ElmViewTree -OldTree $prevMeasured -NewTree $measured
        if ($patches | Where-Object { $_.Type -eq 'FullRedraw' }) {
            [Console]::Out.Write((ConvertTo-AnsiOutput -MeasuredRoot $measured))
        } else {
            [Console]::Out.Write((ConvertTo-AnsiPatch -Patches $patches))
        }

    .NOTES
        Structural matching is positional (by traversal index). If the leaf count or
        any leaf position differs between old and new, FullRedraw is returned immediately.
    #>

    [CmdletBinding()]
    param(
        [Parameter()]
        [object]$OldTree,

        [Parameter(Mandatory)]
        [object]$NewTree
    )

    if ($null -eq $OldTree) {
        return @([PSCustomObject]@{ Type = 'FullRedraw' })
    }

    $oldLeaves = Get-TextLeaves -Node $OldTree
    $newLeaves = Get-TextLeaves -Node $NewTree

    if ($oldLeaves.Count -ne $newLeaves.Count) {
        return @([PSCustomObject]@{ Type = 'FullRedraw' })
    }

    $patches = [System.Collections.Generic.List[object]]::new()

    for ($i = 0; $i -lt $newLeaves.Count; $i++) {
        $oldLeaf = $oldLeaves[$i]
        $newLeaf = $newLeaves[$i]

        if ($oldLeaf.X -ne $newLeaf.X -or $oldLeaf.Y -ne $newLeaf.Y) {
            return @([PSCustomObject]@{ Type = 'FullRedraw' })
        }

        $oldStyleJson = $oldLeaf.Style | ConvertTo-Json -Compress -Depth 2
        $newStyleJson = $newLeaf.Style | ConvertTo-Json -Compress -Depth 2

        if ($oldLeaf.Content -ne $newLeaf.Content -or $oldStyleJson -ne $newStyleJson) {
            $patches.Add([PSCustomObject]@{
                Type     = 'Replace'
                X        = $newLeaf.X
                Y        = $newLeaf.Y
                Content  = $newLeaf.Content
                Style    = $newLeaf.Style
                Width    = $newLeaf.Width
                OldWidth = $oldLeaf.Width
            })
        }
    }

    return $patches.ToArray()
}

function Get-TextLeaves {
    param(
        [Parameter(Mandatory)]
        [object]$Node
    )

    $leaves = [System.Collections.Generic.List[object]]::new()
    $stack  = [System.Collections.Generic.Stack[object]]::new()
    $stack.Push($Node)

    while ($stack.Count -gt 0) {
        $current = $stack.Pop()
        if ($current.Type -eq 'Text') {
            $leaves.Add($current)
        } elseif ($current.PSObject.Properties['Children'] -and $null -ne $current.Children) {
            for ($i = $current.Children.Count - 1; $i -ge 0; $i--) {
                $stack.Push($current.Children[$i])
            }
        }
    }

    return $leaves.ToArray()
}