Renderers/AnsiRenderer.ps1

function ConvertTo-TerminalTableRowLine {
    param(
        [Parameter(Mandatory)][AllowEmptyCollection()][AllowEmptyString()][string[]]$Values,
        [Parameter(Mandatory)][AllowEmptyCollection()][int[]]$Widths,
        [ValidateRange(0, [int]::MaxValue)][int]$StartColumn = 0
    )

    $cells = [System.Collections.Generic.List[string]]::new()
    $cellStartColumn = $StartColumn + 2
    for ($columnIndex = 0; $columnIndex -lt $Values.Count; $columnIndex++) {
        $cells.Add((Pad-TerminalText -Text $Values[$columnIndex] -Width $Widths[$columnIndex] -StartColumn $cellStartColumn))
        $cellStartColumn += $Widths[$columnIndex] + 3
    }
    return '| ' + ($cells -join ' | ') + ' |'
}

function ConvertTo-TerminalTableCellLines {
    param(
        [AllowNull()][string]$Text,
        [ValidateRange(0, [int]::MaxValue)][int]$StartColumn = 0
    )

    $lines = [System.Collections.Generic.List[string]]::new()
    foreach ($logicalLine in (Split-TerminalLogicalRows -Text $Text)) {
        $lines.Add((Expand-TerminalTabs -Text $logicalLine -StartColumn $StartColumn))
    }
    return ,$lines.ToArray()
}

function ConvertTo-TerminalTablePhysicalLines {
    param(
        [Parameter(Mandatory)][AllowEmptyCollection()][string[][]]$Cells,
        [Parameter(Mandatory)][AllowEmptyCollection()][int[]]$Widths,
        [ValidateRange(0, [int]::MaxValue)][int]$StartColumn = 0
    )

    $lineCount = 1
    foreach ($cell in $Cells) { $lineCount = [Math]::Max($lineCount, $cell.Count) }
    $lines = [System.Collections.Generic.List[string]]::new()
    for ($lineIndex = 0; $lineIndex -lt $lineCount; $lineIndex++) {
        $values = [string[]]::new($Cells.Count)
        for ($columnIndex = 0; $columnIndex -lt $Cells.Count; $columnIndex++) {
            $values[$columnIndex] = if ($lineIndex -lt $Cells[$columnIndex].Count) {
                $Cells[$columnIndex][$lineIndex]
            }
            else { '' }
        }
        $lines.Add((ConvertTo-TerminalTableRowLine -Values $values -Widths $Widths -StartColumn $StartColumn))
    }
    return ,$lines.ToArray()
}

function ConvertTo-TableLines {
    param(
        [object]$Content,
        [ValidateRange(0, [int]::MaxValue)][int]$StartColumn = 0
    )
    $rows = @()
    if ($Content -is [System.Collections.IDictionary]) {
        $rows = [object[]]@($Content)
    }
    elseif ($Content -is [System.Collections.IEnumerable] -and $Content -isnot [string]) {
        $rows = @($Content)
    }
    else {
        return ,@([string]$Content)
    }
    if (-not $rows.Count) { return ,@('') }
    $shape = Get-TerminalExportTableShape -Data $rows
    $headers = [string[]]@($shape.Columns)
    $widths = [int[]]::new($headers.Count)
    $normalizedHeaders = [string[][]]::new($headers.Count)
    $normalizedRows = [System.Collections.Generic.List[string[][]]]::new()
    foreach ($row in $rows) { $normalizedRows.Add([string[][]]::new($headers.Count)) }

    $cellStartColumn = $StartColumn + 2
    for ($columnIndex = 0; $columnIndex -lt $headers.Count; $columnIndex++) {
        $header = $headers[$columnIndex]
        $normalizedHeaders[$columnIndex] = ConvertTo-TerminalTableCellLines -Text $header -StartColumn $cellStartColumn
        $widths[$columnIndex] = 3
        foreach ($headerLine in $normalizedHeaders[$columnIndex]) {
            $widths[$columnIndex] = [Math]::Max(
                $widths[$columnIndex],
                (Measure-TextWidth -Text $headerLine -StartColumn $cellStartColumn)
            )
        }
        for ($rowIndex = 0; $rowIndex -lt $rows.Count; $rowIndex++) {
            $row = $rows[$rowIndex]
            $value = [string](Get-TerminalSemanticProperty -InputObject $row -Name $header)
            $normalizedRows[$rowIndex][$columnIndex] = ConvertTo-TerminalTableCellLines -Text $value -StartColumn $cellStartColumn
            foreach ($valueLine in $normalizedRows[$rowIndex][$columnIndex]) {
                $widths[$columnIndex] = [Math]::Max(
                    $widths[$columnIndex],
                    (Measure-TextWidth -Text $valueLine -StartColumn $cellStartColumn)
                )
            }
        }
        $cellStartColumn += $widths[$columnIndex] + 3
    }

    $output = [System.Collections.Generic.List[string]]::new()
    foreach ($headerLine in (ConvertTo-TerminalTablePhysicalLines -Cells $normalizedHeaders -Widths $widths -StartColumn $StartColumn)) {
        $output.Add($headerLine)
    }
    $output.Add('|-' + (($widths | ForEach-Object { ''.PadLeft($_, '-') }) -join '-|-') + '-|')
    foreach ($normalizedRow in $normalizedRows) {
        foreach ($rowLine in (ConvertTo-TerminalTablePhysicalLines -Cells $normalizedRow -Widths $widths -StartColumn $StartColumn)) {
            $output.Add($rowLine)
        }
    }
    return ,$output.ToArray()
}

function ConvertTo-ChartLines {
    param([object]$Content, [hashtable]$Properties, [TerminalSlides.Schema.V1.ThemeDefinition]$Theme, [int]$Width)
    $items = @($Content)
    if (-not $items.Count) { return ,@('No chart data') }
    $chartType = ($Properties.ChartType ?? 'HorizontalBar')
    $palette = @($Theme.ChartPalette)
    if (-not $palette.Count) { $palette = @($Theme.Primary, $Theme.Accent, $Theme.Foreground) }
    $output = [System.Collections.Generic.List[object]]::new()
    if ($Properties.Title) {
        $output.Add((New-TerminalStyledLine -Text ([string]$Properties.Title) -Foreground $Theme.Heading -Bold))
    }
    switch ($chartType) {
        'Gauge' {
            $value = [double]($items[0].Value)
            $filled = [Math]::Round(($Width - 10) * ($value / 100))
            $filled = [Math]::Max(0, [Math]::Min($Width - 10, $filled))
            $output.Add((New-TerminalStyledLine -Text "[$(('█' * $filled) + ('░' * ([Math]::Max(0, ($Width - 10 - $filled)))))] $value%" -Foreground $palette[0]))
        }
        'Sparkline' {
            $values = @($items | ForEach-Object { [double]$_.Value })
            $blocks = '▁▂▃▄▅▆▇█'.ToCharArray()
            $min = ($values | Measure-Object -Minimum).Minimum
            $max = ($values | Measure-Object -Maximum).Maximum
            if ($max -eq $min) { $max = $min + 1 }
            $spark = -join ($values | ForEach-Object {
                $idx = [int][Math]::Round((($_ - $min) / ($max - $min)) * ($blocks.Length - 1))
                $blocks[$idx]
            })
            $output.Add((New-TerminalStyledLine -Text $spark -Foreground $palette[0]))
        }
        'Bar' {
            $max = [double](($items | ForEach-Object { $_.Value } | Measure-Object -Maximum).Maximum)
            if ($max -le 0) { $max = 1 }
            for ($index = 0; $index -lt $items.Count; $index++) {
                $item = $items[$index]
                $count = [Math]::Max(0, [Math]::Round((([double]$item.Value) / $max) * ([Math]::Max(1, $Width - 12))))
                $label = Pad-TerminalText -Text ([string]$item.Label) -Width 10
                $color = $palette[$index % $palette.Count]
                $output.Add((New-TerminalStyledLine -Text "$label $('█' * $count)" -Foreground $color))
            }
        }
        'Line' {
            $values = @($items | ForEach-Object { [double]$_.Value })
            $sparkline = ConvertTo-ChartLines -Content $Content -Properties @{ ChartType='Sparkline' } -Theme $Theme -Width $Width
            foreach ($line in $sparkline) { $output.Add($line) }
            $output.Add(($items | ForEach-Object { "{0}: {1}" -f $_.Label, $_.Value }) -join ' ')
        }
        default {
            $max = [double](($items | ForEach-Object { $_.Value } | Measure-Object -Maximum).Maximum)
            if ($max -le 0) { $max = 1 }
            for ($index = 0; $index -lt $items.Count; $index++) {
                $item = $items[$index]
                $label = [string]$item.Label
                $count = [Math]::Max(0, [Math]::Round((([double]$item.Value) / $max) * ([Math]::Max(1, $Width - 20))))
                $label = Pad-TerminalText -Text $label -Width 12
                $color = $palette[$index % $palette.Count]
                $output.Add((New-TerminalStyledLine -Text "$label $('█' * $count) $($item.Value)" -Foreground $color))
            }
        }
    }
    return ,$output.ToArray()
}

function ConvertTo-DiagramLines {
    param([hashtable]$Content)
    $nodes = @($Content.Nodes)
    $edges = @($Content.Edges)
    if (-not $nodes.Count) { return ,@('Empty diagram') }
    $lines = [System.Collections.Generic.List[string]]::new()
    for ($i = 0; $i -lt $nodes.Count; $i++) {
        $node = $nodes[$i]
        $box = "[$($node.Id)] $($node.Label)"
        $lines.Add($box)
        foreach ($edge in $edges | Where-Object { $_.From -eq $node.Id }) {
            $target = $nodes | Where-Object Id -eq $edge.To | Select-Object -First 1
            if ($target) {
                $label = if ($edge.Label) { " ($($edge.Label))" } else { '' }
                $lines.Add(" └─> [$($target.Id)] $($target.Label)$label")
            }
        }
    }
    return ,$lines.ToArray()
}

function ConvertTo-ElementLines {
    param(
        [TerminalSlides.Schema.V1.SlideElement]$Element,
        [TerminalSlides.Schema.V1.ThemeDefinition]$Theme,
        [int]$Width,
        [int]$Height = 0,
        [TerminalSlides.Schema.V1.TerminalCapability]$Capability,
        [ValidateRange(0, [int]::MaxValue)][int]$StartColumn = 0
    )
    $payload = Get-TerminalElementPayload -Element $Element
    switch ($payload.Kind) {
        'Title' {
            $title = if ($Theme.HeadingStyle -eq 'banner') { $payload.Text.ToUpperInvariant() } else { $payload.Text }
            return ,(Format-WordWrap -Text $title -Width $Width -StartColumn $StartColumn)
        }
        'Subtitle' { return ,(Format-WordWrap -Text $payload.Text -Width $Width -StartColumn $StartColumn) }
        'Text' { return ,(Format-WordWrap -Text $payload.Text -Width $Width -OverflowBehavior $Element.OverflowBehavior -StartColumn $StartColumn) }
        'Bullet' {
            $prefix = "$($Theme.BulletSymbol) "
            $prefixWidth = Measure-TextWidth -Text $prefix
            $wrapped = Format-WordWrap -Text $payload.Text -Width ([Math]::Max(1, $Width - $prefixWidth)) -StartColumn ($StartColumn + $prefixWidth)
            $result = [System.Collections.Generic.List[string]]::new()
            for ($i = 0; $i -lt $wrapped.Count; $i++) {
                if ($i -eq 0) { $result.Add($prefix + $wrapped[$i]) }
                else { $result.Add((' ' * $prefixWidth) + $wrapped[$i]) }
            }
            return ,$result.ToArray()
        }
        'Code' {
            $lines = [System.Collections.Generic.List[TerminalStyledLine]]::new()
            foreach ($highlightedLine in (Get-SyntaxHighlight -Code $payload.Code -Language $payload.Language -Theme $Theme)) {
                foreach ($wrappedLine in (Split-TerminalStyledLineByCellWidth -Line $highlightedLine -Width $Width -StartColumn $StartColumn)) {
                    $lines.Add($wrappedLine)
                }
            }
            return ,$lines.ToArray()
        }
        'Table' { return ,(ConvertTo-TableLines -Content $payload.Rows -StartColumn $StartColumn) }
        'Chart' {
            $properties = @{ ChartType = $payload.ChartType; Title = $payload.Title }
            return ,(ConvertTo-ChartLines -Content $payload.Rows -Properties $properties -Theme $Theme -Width $Width)
        }
        'Diagram' { return ,(ConvertTo-DiagramLines -Content @{ Nodes=$payload.Nodes; Edges=$payload.Edges }) }
        'Image' {
            $imageLines = ConvertTo-TerminalImageLines -Path $payload.Path -Width $Width -Height $Height -SourceDirectory $payload.SourceDirectory -Capability $Capability
            if ($null -ne $imageLines -and $imageLines.Count -gt 0) {
                return ,$imageLines
            }
            return ,@("Image: $($payload.Path)", $payload.AltText)
        }
        'Quote' {
            $lines = [System.Collections.Generic.List[string]]::new()
            foreach ($line in (Format-WordWrap -Text ('“' + $payload.Text + '”') -Width $Width -StartColumn $StartColumn)) { $lines.Add($line) }
            if ($payload.Attribution) { $lines.Add("— $($payload.Attribution)") }
            return ,$lines.ToArray()
        }
        'Box' {
            $boxChars = Get-BoxCharacters -Style $Theme.BoxDrawingStyle
            $innerStartColumn = $StartColumn + 2
            $inner = Format-WordWrap -Text $payload.Text -Width ([Math]::Max(1, $Width - 4)) -StartColumn $innerStartColumn
            $border = $boxChars.Tl + ($boxChars.H * ([Math]::Max(1, $Width - 2))) + $boxChars.Tr
            $bottom = $boxChars.Bl + ($boxChars.H * ([Math]::Max(1, $Width - 2))) + $boxChars.Br
            $lines = [System.Collections.Generic.List[string]]::new()
            $lines.Add($border)
            foreach ($line in $inner) { $lines.Add($boxChars.V + ' ' + (Pad-TerminalText -Text $line -Width ([Math]::Max(1, $Width - 4)) -StartColumn $innerStartColumn) + ' ' + $boxChars.V) }
            $lines.Add($bottom)
            return ,$lines.ToArray()
        }
    }
}

function Write-LinesToFrame {
    param(
        [FrameBuffer]$FrameBuffer,
        [object[]]$Lines,
        [hashtable]$Region,
        [TerminalSlides.Schema.V1.ThemeDefinition]$Theme,
        [TerminalSlides.Schema.V1.SlideElement]$Element,
        [int]$StartY
    )
    $elementKind = (Get-TerminalElementPayload -Element $Element).Kind
    $fg = if ($Element.ForegroundColor) { $Element.ForegroundColor }
          elseif ($elementKind -eq 'Code' -and $Theme.CodeForeground) { $Theme.CodeForeground }
          else { $Theme.Foreground }
    $bg = if ($Element.BackgroundColor) { $Element.BackgroundColor }
          elseif ($elementKind -eq 'Code' -and $Theme.CodeBackground) { $Theme.CodeBackground }
          else { $null }
    $y = $StartY
    $usableWidth = [Math]::Max(1, $Region.Width - ($Element.Padding * 2))
    $contentStartColumn = $Region.X + $Element.Padding
    $preparedLines = ConvertTo-TerminalPreparedLines -Lines $Lines -StartColumn $contentStartColumn -MaxWidth $usableWidth -Alignment $Element.Alignment
    foreach ($preparedLine in $preparedLines) {
        if ($y -ge ($Region.Y + $Region.Height)) { break }
        $x = $preparedLine.StartColumn
        $baseBold = ($elementKind -eq 'Title' -and $Theme.HeadingStyle -ne 'plain')
        $baseItalic = ($elementKind -eq 'Subtitle')
        $col = $x
        foreach ($run in $preparedLine.Runs) {
            $runForeground = if ($run.Foreground) { $run.Foreground } else { $fg }
            $runBackground = if ($run.Background) { $run.Background } else { $bg }
            Set-FrameText -FrameBuffer $FrameBuffer -X $col -Y $y -Text $run.Text -Foreground $runForeground -Background $runBackground -Bold:($baseBold -or $run.Bold) -Italic:($baseItalic -or $run.Italic) -Underline:$run.Underline
            $col += $run.Width
        }
        $y++
    }
    return $y
}

function Get-SlideRenderDimensions {
    param([TerminalSlides.Schema.V1.TerminalPresentation]$Presentation, [TerminalSlides.Schema.V1.TerminalCapability]$Capability)
    $width = if ($Presentation.Width -gt 0) { $Presentation.Width } elseif ($Capability.Width -gt 0) { $Capability.Width } else { 80 }
    $height = if ($Presentation.Height -gt 0) { $Presentation.Height } elseif ($Capability.Height -gt 0) { $Capability.Height } else { 24 }
    return Resolve-TerminalViewport -Width $width -Height $height
}

function Get-RenderedSlideFrame {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][TerminalSlides.Schema.V1.TerminalPresentation]$Presentation,
        [Parameter(Mandatory)][int]$SlideIndex,
        [int]$RevealStep = 0,
        [switch]$ShowNotes,
        [ValidateSet('Slide','Overview','Help','Blank')][string]$DisplayMode = 'Slide',
        [timespan]$Elapsed = [timespan]::Zero,
        [switch]$ShowTimer,
        [TerminalSlides.Schema.V1.TerminalCapability]$Capability = $script:Capabilities
    )

    $theme = Resolve-TerminalPresentationTheme -Presentation $Presentation
    $dims = Get-SlideRenderDimensions -Presentation $Presentation -Capability $Capability
    $frame = [FrameBuffer]::new($dims.Width, $dims.Height)
    Fill-FrameRegion -FrameBuffer $frame -Background $theme.Background -Foreground $theme.Foreground
    if ($DisplayMode -eq 'Blank') { return $frame }

    if ($DisplayMode -eq 'Help') {
        $help = @(
            'TerminalSlides Help',
            '',
            'Right / Space / N Next reveal or slide',
            'PageDown Next slide',
            'Left / Backspace / P Previous reveal or slide',
            'PageUp Previous slide',
            'Home / End First / last slide',
            'S Toggle notes',
            'O Toggle overview',
            'B Blank screen',
            'T Toggle timer',
            'H / ? Toggle help',
            'Q / Esc Quit'
        )
        $region = @{ X = 2; Y = 2; Width = $dims.Width - 4; Height = $dims.Height - 4 }
        Draw-FrameBox -FrameBuffer $frame -X $region.X -Y $region.Y -Width $region.Width -Height $region.Height -Foreground $theme.Border -Background $theme.Background -Style $theme.BoxDrawingStyle
        Write-LinesToFrame -FrameBuffer $frame -Lines $help -Region @{ X = 4; Y = 3; Width = $dims.Width - 8; Height = $dims.Height - 6 } -Theme $theme -Element (New-InternalSlideElement -Kind Text -Payload ([TerminalSlides.Schema.V1.TextPayload]::new('')) -ForegroundColor $theme.Foreground) -StartY 3 | Out-Null
        return $frame
    }

    if ($DisplayMode -eq 'Overview') {
        Set-FrameText -FrameBuffer $frame -X 2 -Y 1 -Text $Presentation.Title -Foreground $theme.Heading -Background $theme.Background -Bold
        $y = 3
        for ($i = 0; $i -lt $Presentation.Slides.Count -and $y -lt $dims.Height - 2; $i++) {
            $prefix = if ($i -eq $SlideIndex) { '>' } else { ' ' }
            Set-FrameText -FrameBuffer $frame -X 2 -Y $y -Text ("$prefix [{0}] {1}" -f ($i + 1), $Presentation.Slides[$i].Title) -Foreground $theme.Foreground -Background $theme.Background
            $y++
        }
        return $frame
    }

    $plan = Get-TerminalSlideLayoutPlan -Presentation $Presentation -SlideIndex $SlideIndex -RevealStep $RevealStep -Capability $Capability
    $slide = $plan.Slide
    if ($slide.Background) {
        Fill-FrameRegion -FrameBuffer $frame -Background $slide.Background -Foreground $theme.Foreground
    }

    foreach ($placement in $plan.Placements) {
        if ($placement.Border) {
            $border = $placement.BorderRegion
            $borderFg = if ($placement.Element.ForegroundColor) { $placement.Element.ForegroundColor } else { $theme.Border }
            $borderBg = if ($placement.Element.BackgroundColor) { $placement.Element.BackgroundColor } else { $theme.Background }
            Draw-FrameBox -FrameBuffer $frame -X $border.X -Y $border.Y -Width $border.Width -Height $border.Height -Foreground $borderFg -Background $borderBg -Style $placement.Element.BorderStyle
        }
        Write-LinesToFrame -FrameBuffer $frame -Lines $placement.Lines -Region $placement.Region -Theme $theme -Element $placement.Element -StartY $placement.StartY | Out-Null
    }
    $progressWidth = [Math]::Max(10, $dims.Width - 24)
    $ratio = ($SlideIndex + 1) / $Presentation.Slides.Count
    $filled = [Math]::Round($progressWidth * $ratio)
    $progressBar = ('█' * $filled).PadRight($progressWidth, '░')
    $status = "Slide {0} of {1} {2}" -f ($SlideIndex + 1), $Presentation.Slides.Count, $progressBar
    $status = $status.Substring(0, [Math]::Min($status.Length, $dims.Width - 2))
    Set-FrameText -FrameBuffer $frame -X 1 -Y ($dims.Height - 2) -Text $status -Foreground $theme.Muted -Background $theme.Background
    if ($ShowTimer) {
        $timerText = $Elapsed.ToString('hh\:mm\:ss')
        Set-FrameText -FrameBuffer $frame -X ([Math]::Max(1, $dims.Width - $timerText.Length - 2)) -Y ($dims.Height - 1) -Text $timerText -Foreground $theme.Accent -Background $theme.Background
    }
    if ($ShowNotes -and $slide.Notes) {
        $notes = Format-WordWrap -Text ("Notes: $($slide.Notes)") -Width ([Math]::Max(10, $dims.Width - 4))
        $notesHeight = [Math]::Min(4, $notes.Count + 2)
        $notesY = [Math]::Max(0, $dims.Height - $notesHeight - 1)
        Draw-FrameBox -FrameBuffer $frame -X 1 -Y $notesY -Width ($dims.Width - 2) -Height $notesHeight -Foreground $theme.Border -Background $theme.Background -Style $theme.BoxDrawingStyle
        Write-LinesToFrame -FrameBuffer $frame -Lines $notes -Region @{ X = 2; Y = $notesY + 1; Width = $dims.Width - 4; Height = $notesHeight - 2 } -Theme $theme -Element (New-InternalSlideElement -Kind Text -Payload ([TerminalSlides.Schema.V1.TextPayload]::new('')) -ForegroundColor $theme.Foreground) -StartY ($notesY + 1) | Out-Null
    }
    return $frame
}

function Render-TerminalPresentationToString {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][TerminalSlides.Schema.V1.TerminalPresentation]$Presentation,
        [int]$SlideIndex = 0,
        [int]$RevealStep = 0,
        [switch]$PlainText,
        [switch]$ShowNotes,
        [ValidateSet('Slide','Overview','Help','Blank')][string]$DisplayMode = 'Slide',
        [timespan]$Elapsed = [timespan]::Zero,
        [switch]$ShowTimer,
        [TerminalSlides.Schema.V1.TerminalCapability]$Capability = $script:Capabilities
    )
    $frame = Get-RenderedSlideFrame -Presentation $Presentation -SlideIndex $SlideIndex -RevealStep $RevealStep -ShowNotes:$ShowNotes -DisplayMode $DisplayMode -Elapsed $Elapsed -ShowTimer:$ShowTimer -Capability $Capability
    if ($PlainText) {
        $rows = [System.Collections.Generic.List[string]]::new()
        for ($r = 0; $r -lt $frame.Height; $r++) {
            $text = $frame.GetRowText($r)
            $rows.Add($text.TrimEnd())
        }
        return ($rows -join [Environment]::NewLine)
    }
    return $frame.Render($Capability.TrueColorSupport, $Capability.Color256Support)
}