src/UI/00-Theme.ps1

# 00-Theme.ps1 — palette, markup helpers, console plumbing, alternate screen.
#
# The palette (docs/design-system-v2.md §1) is read from the shared token
# table in src/Engine/Theme.ps1, so the $PSMM_Theme knob (glacier | ember |
# moss) swaps every token in one place and the startup report renders the
# same colours without Spectre. Explicit 256-colour names render identically
# in every terminal. Nothing outside the theme sources may name a colour.

$psmmThemeTable = Get-PSMMThemeTable
$script:PSMM_ColKey        = $psmmThemeTable['key'].Markup
$script:PSMM_ColMute       = $psmmThemeTable['mute'].Markup
$script:PSMM_ColAccent     = $psmmThemeTable['accent'].Markup
$script:PSMM_ColOk         = $psmmThemeTable['ok'].Markup
$script:PSMM_ColWarn       = $psmmThemeTable['warn'].Markup
$script:PSMM_ColErr        = $psmmThemeTable['err'].Markup
$script:PSMM_ColInfo       = $psmmThemeTable['info'].Markup
$script:PSMM_ColDim        = $psmmThemeTable['dim'].Markup       # de-emphasised cells, legends
$script:PSMM_ColCapsule    = $psmmThemeTable['capsule'].Markup   # key-capsule background
$script:PSMM_ColRowBg      = $psmmThemeTable['rowbg'].Markup     # cursor-row background
$script:PSMM_ColBorder     = $psmmThemeTable['border'].Markup    # ALL table/panel borders
$script:PSMM_ColBrandFg    = $psmmThemeTable['brandfg'].Markup   # the ' psmm ' brand block
$script:PSMM_ColBrandBg    = $psmmThemeTable['brandbg'].Markup
$script:PSMM_ColCapsuleDim = $psmmThemeTable['capsdim'].Markup   # persistent-row capsule background

# Shared border style: every table/panel border goes through this so the
# border colour is themed in one place.
function script:Get-PSMMBorderStyle { [Spectre.Console.Style]::Parse($script:PSMM_ColBorder) }

# Standard table chrome (§1/§5): rounded border in the border token,
# lowercase dim headers. Screens add rows themselves.
function script:New-PSMMTable {
    param([Parameter(Mandatory)][string[]]$Headers)
    $T = [Spectre.Console.Table]::new()
    $T.Border = [Spectre.Console.TableBorder]::Rounded
    $T.BorderStyle = Get-PSMMBorderStyle
    foreach ($h in $Headers) {
        $cell = if ($h.Trim()) { "[$script:PSMM_ColDim]$($h.ToLowerInvariant())[/]" } else { $h }
        [void][Spectre.Console.TableExtensions]::AddColumn($T, $cell)
    }
    $T
}

# Cursor marker for simple (non-grid) list tables: the v2 block bar.
function script:Get-PSMMCursorMark {
    param([bool]$IsCursor)
    if ($IsCursor) { "[$script:PSMM_ColAccent]$([char]0x258C)[/]" } else { ' ' }
}

# Draw a renderable ON TOP of the current frame, bottom-left, via raw VT
# cursor positioning - Spectre's live display has no z-layers, and appending
# the panel below a full-height frame pushed the screen (live-run feedback).
# DECSC/DECRC keep the live display's cursor bookkeeping intact; the caller
# erases the region (Clear-PSMMOverlay) before the next repaint. Returns the
# drawn region (@{ Top; Count }) or $null when output is redirected.
function script:Write-PSMMOverlay {
    [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '',
        Justification = 'Raw VT cursor positioning must bypass any host/stream formatting.')]
    param([Parameter(Mandatory)] $Renderable)
    try {
        if ([Console]::IsOutputRedirected) { return $null }
        $sw = [System.IO.StringWriter]::new()
        $settings = [Spectre.Console.AnsiConsoleSettings]::new()
        $settings.Out = [Spectre.Console.AnsiConsoleOutput]::new($sw)
        $settings.Interactive = [Spectre.Console.InteractionSupport]::No
        $settings.Ansi = [Spectre.Console.AnsiSupport]::Yes
        $settings.ColorSystem = [Spectre.Console.ColorSystemSupport]::EightBit
        $console = [Spectre.Console.AnsiConsole]::Create($settings)
        $console.Profile.Width = [Math]::Max(40, (Get-PSMMWinSize).Width - 2)
        $console.Write($Renderable)
        $lines = @($sw.ToString() -split "`r?`n" | Where-Object { $_ -ne '' })
        if (-not $lines.Count) { return $null }
        $win = Get-PSMMWinSize
        $top = [Math]::Max(1, $win.Height - $lines.Count)   # 1-based VT row
        $esc = [char]27
        $out = [System.Text.StringBuilder]::new()
        [void]$out.Append("$esc" + '7')                     # DECSC save cursor
        for ($i = 0; $i -lt $lines.Count; $i++) {
            [void]$out.Append("$esc[$($top + $i);2H").Append($lines[$i]).Append("$esc[0m")
        }
        [void]$out.Append("$esc" + '8')                     # DECRC restore
        [Console]::Write($out.ToString())
        @{ Top = $top; Count = $lines.Count }
    } catch { $null }
}

# Erase an overlay region drawn by Write-PSMMOverlay (whole lines; the
# caller's next repaint restores any frame content underneath).
function script:Clear-PSMMOverlay {
    [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '',
        Justification = 'Raw VT cursor positioning must bypass any host/stream formatting.')]
    param($Region)
    if (-not $Region) { return }
    try {
        if ([Console]::IsOutputRedirected) { return }
        $esc = [char]27
        $out = [System.Text.StringBuilder]::new()
        [void]$out.Append("$esc" + '7')
        for ($i = 0; $i -lt $Region.Count; $i++) {
            [void]$out.Append("$esc[$($Region.Top + $i);1H").Append("$esc[2K")
        }
        [void]$out.Append("$esc" + '8')
        [Console]::Write($out.ToString())
    } catch { }
}

# The console every UI write goes through. Production: the real AnsiConsole.
# Tests inject a StringWriter-backed console via Set-PSMMConsole and assert on
# the rendered frames (see D-UI-ARCH).
function script:Get-PSMMConsole {
    if (-not $script:PSMM_Console) { $script:PSMM_Console = [Spectre.Console.AnsiConsole]::Console }
    $script:PSMM_Console
}
function script:Set-PSMMConsole { param($Console) $script:PSMM_Console = $Console }

# Write one markup line through the psmm console.
function script:Write-PSMMLine {
    param([string]$Markup = '')
    (Get-PSMMConsole).Write([Spectre.Console.Markup]::new($Markup + "`n"))
}

function script:Write-PSMMRenderable {
    param([Parameter(Mandatory)] $Renderable)
    (Get-PSMMConsole).Write($Renderable)
}

function script:Clear-PSMMScreen {
    # MUST be Clear($true): IAnsiConsole's zero-arg Clear() is a C# extension
    # method PowerShell cannot see, so .Clear() throws and the catch made every
    # "clear" a silent no-op - sub-screens appended below the grid instead of
    # replacing it (2026-07-05 live-run bug).
    try { (Get-PSMMConsole).Clear($true) } catch { }
}

# Run a live display on the psmm console. $Body is param($ctx) { ... } and
# runs synchronously in module scope (verified - see D-UI-ARCH).
function script:Invoke-PSMMLive {
    param(
        [Parameter(Mandatory)][scriptblock]$Body,
        $Initial
    )
    if (-not $Initial) { $Initial = [Spectre.Console.Markup]::new(' ') }
    $live = [Spectre.Console.LiveDisplay]::new((Get-PSMMConsole), $Initial)
    $live.Start([Action[Spectre.Console.LiveDisplayContext]]$Body)
}

# --- alternate screen buffer (#4: preserve the user's scrollback) ---------
# Entering flips the terminal to the alt buffer exactly like edit/vim/less;
# leaving restores the previous screen contents untouched. No-ops when
# output is redirected (tests, CI).

function script:Enter-PSMMAltScreen {
    [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '',
        Justification = 'Raw VT escape for the alternate screen buffer must bypass any host/stream formatting.')]
    param()
    try {
        if (-not [Console]::IsOutputRedirected) {
            # ?25l: hide the console cursor for the TUI - it blinked over the
            # frames (live-run feedback); text prompts show it again
            [Console]::Write("$([char]27)[?1049h$([char]27)[H$([char]27)[?25l")
            $script:PSMM_AltScreenActive = $true
        }
    } catch { }
}

function script:Exit-PSMMAltScreen {
    [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '',
        Justification = 'Raw VT escape for the alternate screen buffer must bypass any host/stream formatting.')]
    param()
    try {
        if ($script:PSMM_AltScreenActive) {
            [Console]::Write("$([char]27)[?25h$([char]27)[?1049l")
            $script:PSMM_AltScreenActive = $false
        }
    } catch { }
}

# Render any renderable to plain text lines at the current window width.
# Used to feed tables into the shared pager: inside the alternate screen
# buffer there is no scrollback, so anything potentially tall must scroll.
function script:ConvertTo-PSMMTextLines {
    param([Parameter(Mandatory)] $Renderable)
    $sw = [System.IO.StringWriter]::new()
    $settings = [Spectre.Console.AnsiConsoleSettings]::new()
    $settings.Out = [Spectre.Console.AnsiConsoleOutput]::new($sw)
    $settings.Interactive = [Spectre.Console.InteractionSupport]::No
    $settings.Ansi = [Spectre.Console.AnsiSupport]::No
    $console = [Spectre.Console.AnsiConsole]::Create($settings)
    $console.Profile.Width = (Get-PSMMWinSize).Width - 4
    $console.Write($Renderable)
    # Ansi=No is NOT always honoured: Spectre's environment detection (e.g.
    # GITHUB_ACTIONS=true) force-enables ANSI over the explicit setting, and
    # escape codes here would end up as literal garbage in the pager. Strip.
    (($sw.ToString() -replace '\x1b\[[0-9;?]*[A-Za-z]', '') -split "`r?`n")
}

# --- small shared formatting helpers --------------------------------------

# Fast markup escape for the hot render path (avoids a cmdlet call per cell).
function script:ConvertTo-PSMMSafe { param([string]$s) if ($null -eq $s) { '' } else { $s.Replace('[', '[[').Replace(']', ']]') } }

# Truncate with an ellipsis so rows never wrap to a second line.
function script:Get-PSMMTrunc {
    param([string]$s, [int]$max)
    if ($null -eq $s) { return '' }
    if ($s.Length -le $max) { return $s }
    if ($max -le 1) { return $s.Substring(0, [Math]::Max(0, $max)) }
    $s.Substring(0, $max - 1) + [char]0x2026   # …
}

# Console size, defaulting safely when there is no real console.
function script:Get-PSMMWinSize {
    $h = 25; $w = 120
    try { $h = [Console]::WindowHeight; $w = [Console]::WindowWidth } catch { }
    if ($h -le 0) { $h = 25 }
    if ($w -le 0) { $w = 120 }
    [pscustomobject]@{ Height = $h; Width = $w }
}

# Render "key=action" pairs as one consistently-styled hint line.
# Design system v2 (§3): every key is a capsule - reverse-video key block on
# the capsule background, mute label, two-space separator. Keys are always
# lowercase; '^' before a key means ctrl, and any line using a '^' chord
# carries the dim '^ = ctrl' legend at the end of the row.
function script:Get-PSMMHint {
    param(
        [Parameter(Mandatory)][string[]]$Pairs,
        # rows that sit above a persistent strip (which carries the legend
        # itself) suppress their own to avoid repeating it
        [switch]$NoLegend
    )
    $parts = foreach ($p in $Pairs) {
        $k, $v = $p -split '=', 2
        "[$script:PSMM_ColKey on $script:PSMM_ColCapsule] $($k.ToLowerInvariant()) [/] [$script:PSMM_ColMute]$v[/]"
    }
    if (-not $NoLegend -and @($Pairs | Where-Object { ($_ -split '=', 2)[0] -match '\^' }).Count) {
        $parts = @($parts) + @("[$script:PSMM_ColDim]^ = ctrl[/]")
    }
    $parts -join ' '
}

# Tier-two hint row (§3): the persistent strip - always the same keys,
# always last, accent keys on the darker capsule with dim labels.
function script:Get-PSMMPersistentHint {
    param([string[]]$Pairs = @("g=goto$([char]0x2026)", '/=filter', '?=help', '^q=quit'))
    $parts = foreach ($p in $Pairs) {
        $k, $v = $p -split '=', 2
        "[$script:PSMM_ColAccent on $script:PSMM_ColCapsuleDim] $($k.ToLowerInvariant()) [/] [$script:PSMM_ColDim]$v[/]"
    }
    if (@($Pairs | Where-Object { ($_ -split '=', 2)[0] -match '\^' }).Count) {
        $parts = @($parts) + @("[$script:PSMM_ColDim]^ = ctrl[/]")
    }
    $parts -join ' '
}

# One-line header bar (§2), every screen: brand block + breadcrumb (+ dim
# counts) with version · engine · elevated · ⇡ update right-aligned. Returns
# a markup string padded to the window width.
function script:Get-PSMMHeaderBar {
    param(
        [string[]]$Breadcrumb = @('home'),
        [string]$CountsMarkup,
        [string]$RightMarkup
    )
    $ui = $script:PSMM_UI
    $crumbs = @(for ($i = 0; $i -lt $Breadcrumb.Count; $i++) {
        if ($i -lt $Breadcrumb.Count - 1) { "[$script:PSMM_ColDim]$(ConvertTo-PSMMSafe $Breadcrumb[$i]) $([char]0x203A)[/]" }
        else { "[default]$(ConvertTo-PSMMSafe $Breadcrumb[$i])[/]" }
    })
    $left = "[$script:PSMM_ColBrandFg on $script:PSMM_ColBrandBg] psmm [/] " + ($crumbs -join ' ')
    if ($CountsMarkup) { $left += " $CountsMarkup" }
    $right = if ($PSBoundParameters.ContainsKey('RightMarkup')) { $RightMarkup }
             else {
                 $parts = @()
                 if ($ui -and $ui.Version) { $parts += "v$($ui.Version)" }
                 if ($ui -and $ui.Engine) { $parts += "$($ui.Engine)" }
                 if ($ui -and $ui.Elevated) { $parts += 'elevated' }
                 $r = "[$script:PSMM_ColDim]$($parts -join " $([char]0x00B7) ")[/]"
                 if ($ui -and $ui.SelfUpdate) { $r += " [$script:PSMM_ColWarn]$([char]0x21E1) update[/]" }
                 $r
             }
    $lLen = [Spectre.Console.Markup]::Remove($left).Length
    $rLen = [Spectre.Console.Markup]::Remove($right).Length
    $pad = [Math]::Max(1, (Get-PSMMWinSize).Width - $lLen - $rLen - 1)
    # full-width bar background (§2); inner tags override only the foreground
    "[default on $script:PSMM_ColCapsuleDim]" + $left + (' ' * $pad) + $right + "[/]"
}

# v2 display language (§5): the JSON schema keeps the Mode/Install enums;
# these are the plain words the UI shows for them.
function script:Get-PSMMStartupWord {
    param($Mode)
    switch ("$Mode") {
        'Load'        { 'load' }
        'InstallOnly' { 'install' }
        'Ignore'      { 'off' }
        default       { '-' }
    }
}

function script:Get-PSMMGalleryWord {
    param($Install)
    switch ("$Install") {
        'IfMissing' { 'if-missing' }
        'CheckOnly' { 'check-only' }
        'Latest'    { 'latest' }
        default     { '-' }
    }
}

# State glyph + word (§5): glyph and word travel together, never glyph alone.
function script:Get-PSMMStateMarkup {
    param([Parameter(Mandatory)] $Entry)
    if ($Entry.PSObject.Properties['Unmanaged']) { return "[$script:PSMM_ColInfo]$([char]0x25CC) unmanaged[/]" }
    if ($Entry.Loaded)    { return "[$script:PSMM_ColOk]$([char]0x25CF) loaded[/]" }
    if ($Entry.Installed) { return "[$script:PSMM_ColWarn]$([char]0x25D0) installed[/]" }
    "[$script:PSMM_ColErr]$([char]0x25CB) missing[/]"
}

# Minimum terminal size a table screen needs; below it Spectre collapses the
# table to '...'. Screens render Get-PSMMTooSmallView instead of the table.
function script:Test-PSMMWinTooSmall {
    param([int]$MinWidth = 60, [int]$MinHeight = 14)
    $win = Get-PSMMWinSize
    ($win.Width -lt $MinWidth) -or ($win.Height -lt $MinHeight)
}

function script:Get-PSMMTooSmallView {
    param([int]$MinWidth = 60, [int]$MinHeight = 14)
    $win = Get-PSMMWinSize
    $items = [System.Collections.Generic.List[Spectre.Console.Rendering.IRenderable]]::new()
    $items.Add([Spectre.Console.Markup]::new("[$script:PSMM_ColWarn]window too small to draw this screen[/]"))
    $items.Add([Spectre.Console.Markup]::new("[$script:PSMM_ColMute]current $($win.Width)x$($win.Height), need at least ${MinWidth}x${MinHeight} - enlarge the terminal[/]"))
    $items.Add([Spectre.Console.Markup]::new((Get-PSMMHint -Pairs @('esc=back', '^q=quit'))))
    [Spectre.Console.Rows]::new($items)
}

# Minimal single-line text prompt: enter accepts (empty input returns the
# default when one is set), ESC CANCELS and returns $null - the Spectre
# prompts have no abort path (live-run feedback). The console cursor is
# shown for the duration (hidden otherwise in the TUI).
function script:Read-PSMMText {
    [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '',
        Justification = 'A raw key-by-key line editor (esc-cancel) needs direct console echo.')]
    param(
        [Parameter(Mandatory)][string]$Message,
        [string]$DefaultAnswer = '',
        [switch]$AllowEmpty
    )
    $hint = if ($DefaultAnswer) { " [$script:PSMM_ColDim]($(ConvertTo-PSMMSafe $DefaultAnswer))[/]" } else { '' }
    Write-PSMMRenderable ([Spectre.Console.Markup]::new(
            "[$script:PSMM_ColMute]$(ConvertTo-PSMMSafe $Message)[/]$hint[$script:PSMM_ColMute]:[/] [$script:PSMM_ColDim](esc cancels)[/] "))
    $buf = [System.Text.StringBuilder]::new()
    try {
        try { [Console]::Write("$([char]27)[?25h") } catch { }
        while ($true) {
            $k = [Console]::ReadKey($true)
            if ($k.Key -eq [ConsoleKey]::Escape) { [Console]::WriteLine(); return $null }
            if ($k.Key -eq [ConsoleKey]::Enter) {
                [Console]::WriteLine()
                $text = $buf.ToString()
                if (-not $text -and $DefaultAnswer) { return $DefaultAnswer }
                if (-not $text -and -not $AllowEmpty) { return $null }   # nothing to accept
                return $text
            }
            if ($k.Key -eq [ConsoleKey]::Backspace) {
                if ($buf.Length) { $buf.Length--; [Console]::Write("`b `b") }
                continue
            }
            if ($k.KeyChar -and -not [char]::IsControl($k.KeyChar)) {
                [void]$buf.Append($k.KeyChar)
                [Console]::Write($k.KeyChar)
            }
        }
    } finally {
        try { [Console]::Write("$([char]27)[?25l") } catch { }
    }
}

# Two-column label/value grid for detail panels.
function script:New-PSMMDetailGrid {
    param([Parameter(Mandatory)] $Rows)   # array of @(label, valueMarkup)
    $g = [Spectre.Console.Grid]::new()
    [void]$g.AddColumn([Spectre.Console.GridColumn]::new())
    [void]$g.AddColumn([Spectre.Console.GridColumn]::new())
    foreach ($r in $Rows) {
        [void][Spectre.Console.GridExtensions]::AddRow($g, [string[]]@("[$script:PSMM_ColAccent]$($r[0])[/]", $r[1]))
    }
    $g
}