Private/RenderPanel.ps1

function Split-InTUIPlainTextByDisplayWidth {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string]$Text,

        [Parameter(Mandatory)]
        [int]$Width
    )

    if ([string]::IsNullOrEmpty($Text)) {
        return @('')
    }

    if ($Width -le 0) {
        return @($Text)
    }

    $segments = [System.Collections.Generic.List[string]]::new()
    $remaining = $Text

    while ((Measure-InTUIDisplayWidth -Text $remaining) -gt $Width) {
        $cutLength = 0
        $lineWidth = 0

        for ($index = 0; $index -lt $remaining.Length; $index++) {
            $charWidth = Measure-InTUIDisplayWidth -Text ([string]$remaining[$index])
            if (($lineWidth + $charWidth) -gt $Width) {
                break
            }

            $lineWidth += $charWidth
            $cutLength++
        }

        if ($cutLength -le 0) {
            $cutLength = 1
        }

        $candidate = $remaining.Substring(0, $cutLength).TrimEnd()
        if ($cutLength -lt $remaining.Length -and $remaining[$cutLength] -eq ' ') {
            $segments.Add($candidate)
            $remaining = $remaining.Substring($cutLength + 1).TrimStart()
        }
        elseif ($candidate.LastIndexOf(' ') -gt 0) {
            $breakIndex = $candidate.LastIndexOf(' ')
            $segments.Add($candidate.Substring(0, $breakIndex).TrimEnd())
            $remaining = $remaining.Substring($breakIndex + 1).TrimStart()
        }
        else {
            $segments.Add($candidate)
            $remaining = $remaining.Substring($cutLength)
        }
    }

    $segments.Add($remaining)
    return $segments.ToArray()
}

function Split-InTUIPanelContentLine {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string]$Line,

        [Parameter(Mandatory)]
        [int]$Width
    )

    $plainText = Strip-InTUIMarkup -Text $Line
    $plainWidth = Measure-InTUIDisplayWidth -Text $plainText

    if ($plainWidth -le $Width) {
        return @([pscustomobject]@{
                Text         = ConvertFrom-InTUIMarkup -Text $Line
                DisplayWidth = $plainWidth
            })
    }

    $style = $null
    $textToWrap = $plainText
    if ($Line -match '^\[(?<Style>[^\]]+)\](?<Text>.*)\[/\]$') {
        $style = $Matches.Style
        $textToWrap = $Matches.Text
    }

    $wrappedLines = [System.Collections.Generic.List[object]]::new()
    foreach ($wrappedText in (Split-InTUIPlainTextByDisplayWidth -Text $textToWrap -Width $Width)) {
        $displayText = $wrappedText
        if ($null -ne $style) {
            $displayText = ConvertFrom-InTUIMarkup -Text "[$style]$wrappedText[/]"
        }

        $wrappedLines.Add([pscustomobject]@{
                Text         = $displayText
                DisplayWidth = Measure-InTUIDisplayWidth -Text $wrappedText
            })
    }

    return $wrappedLines.ToArray()
}

function Render-InTUIPanel {
    <#
    .SYNOPSIS
        Renders content inside a Unicode box with gradient top/bottom borders.
        Replaces Format-SpectrePanel.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$Content,

        [Parameter()]
        [string]$Title = '',

        [Parameter()]
        [string]$BorderColor = 'Blue'
    )

    $palette = Get-InTUIColorPalette
    $reset = $palette.Reset
    $innerWidth = Get-InTUIConsoleInnerWidth

    # Map border color name to palette
    $borderAnsi = switch ($BorderColor.ToLower()) {
        'blue'   { $palette.Blue }
        'green'  { $palette.Green }
        'red'    { $palette.Red }
        'yellow' { $palette.Yellow }
        'cyan'   { $palette.Cyan }
        'cyan1'  { $palette.Cyan }
        'grey'   { $palette.Grey }
        'mauve'  { $palette.Mauve }
        default  { $palette.Blue }
    }

    # Box characters
    $topLeft     = [char]0x256D
    $topRight    = [char]0x256E
    $bottomLeft  = [char]0x2570
    $bottomRight = [char]0x256F
    $horizontal  = [char]0x2500
    $vertical    = [char]0x2502

    # Match accordion box layout: 2-space indent + border + innerWidth + border = WindowWidth
    $boxWidth = $innerWidth + 2
    $contentWidth = $boxWidth - 4  # 2 border + 2 padding
    $indent = ' '

    # Top border with optional title
    if ($Title) {
        $plainTitle = Strip-InTUIMarkup -Text $Title
        $ansiTitle = ConvertFrom-InTUIMarkup -Text $Title
        $titleDisplayWidth = Measure-InTUIDisplayWidth -Text $plainTitle
        $lineLen = $boxWidth - 4 - $titleDisplayWidth
        $leftLine = [Math]::Max(1, [int]([Math]::Floor($lineLen / 2)))
        $rightLine = [Math]::Max(1, $lineLen - $leftLine)
        Write-Host "$indent$borderAnsi$topLeft$([string]::new($horizontal, $leftLine))$reset $ansiTitle $borderAnsi$([string]::new($horizontal, $rightLine))$topRight$reset"
    }
    else {
        Write-Host "$indent$borderAnsi$topLeft$([string]::new($horizontal, ($boxWidth - 2)))$topRight$reset"
    }

    # Content lines
    $lines = $Content -split "`n"
    foreach ($line in $lines) {
        $line = $line.TrimEnd("`r")
        foreach ($wrappedLine in (Split-InTUIPanelContentLine -Line $line -Width $contentWidth)) {
            $padRight = [Math]::Max(0, $contentWidth - $wrappedLine.DisplayWidth)
            Write-Host "$indent$borderAnsi$vertical$reset $($wrappedLine.Text)$reset$(' ' * $padRight) $borderAnsi$vertical$reset"
        }
    }

    # Bottom border
    Write-Host "$indent$borderAnsi$bottomLeft$([string]::new($horizontal, ($boxWidth - 2)))$bottomRight$reset"
}