Private/Format-SystemMonitorFrame.ps1

#Requires -Version 5.1

function Format-SystemMonitorFrame {
    <#
        .SYNOPSIS
            Renders a single text frame for Show-SystemMonitor (pure formatter, no I/O).
 
        .DESCRIPTION
            Accepts a snapshot of system metrics and returns the complete console
            frame as a [string]. Contains no interactive, I/O, or CIM calls, making it
            fully unit-testable without a live Windows system.
 
        .PARAMETER CpuTotalPercent
            Overall CPU utilisation percentage (0-100).
 
        .PARAMETER CpuCores
            Array of objects with properties Name (string) and PercentProcessorTime (int),
            one per logical processor. Accepts CimInstance or PSCustomObject.
 
        .PARAMETER MemPercent
            Physical memory utilisation percentage (0-100).
 
        .PARAMETER MemUsedKB
            Used physical memory in kilobytes.
 
        .PARAMETER MemTotalKB
            Total physical memory in kilobytes.
 
        .PARAMETER PagePercent
            Page file utilisation percentage (0-100).
 
        .PARAMETER PageUsedKB
            Used page file in kilobytes.
 
        .PARAMETER PageTotalKB
            Total page file size in kilobytes.
 
        .PARAMETER UptimeStr
            Pre-formatted uptime string (e.g. "3d 02:15:44").
 
        .PARAMETER TimeStr
            Pre-formatted timestamp string (e.g. "2026-05-14 20:00:00").
 
        .PARAMETER ProcessCount
            Total number of running processes shown in the header.
 
        .PARAMETER TopProcesses
            Array of objects with properties PID (int), CPU (double), MemMB (double),
            Name (string), already sorted and pre-capped to the display limit.
 
        .PARAMETER SortMode
            Active sort column shown in the footer. Valid values: CPU, Memory, PID, Name.
 
        .PARAMETER Width
            Terminal width in columns used for bar sizing and line padding.
 
        .PARAMETER Height
            Terminal height in rows used for process list truncation and trailing erase.
 
        .PARAMETER RefreshInterval
            Refresh interval in seconds displayed in the footer.
 
        .PARAMETER NoColor
            When set, ANSI escape sequences are suppressed.
 
        .OUTPUTS
            System.String
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param (
        [Parameter(Mandatory = $false)]
        [int]$CpuTotalPercent = 0,

        [Parameter(Mandatory = $false)]
        [object[]]$CpuCores = @(),

        [Parameter(Mandatory = $false)]
        [int]$MemPercent = 0,

        [Parameter(Mandatory = $false)]
        [double]$MemUsedKB = 0,

        [Parameter(Mandatory = $false)]
        [double]$MemTotalKB = 1,

        [Parameter(Mandatory = $false)]
        [int]$PagePercent = 0,

        [Parameter(Mandatory = $false)]
        [double]$PageUsedKB = 0,

        [Parameter(Mandatory = $false)]
        [double]$PageTotalKB = 1,

        [Parameter(Mandatory = $false)]
        [string]$UptimeStr = '0d 00:00:00',

        [Parameter(Mandatory = $false)]
        [string]$TimeStr = '',

        [Parameter(Mandatory = $false)]
        [int]$ProcessCount = 0,

        [Parameter(Mandatory = $false)]
        [object[]]$TopProcesses = @(),

        [Parameter(Mandatory = $false)]
        [ValidateSet('CPU', 'Memory', 'PID', 'Name')]
        [string]$SortMode = 'CPU',

        [Parameter(Mandatory = $false)]
        [int]$Width = 80,

        [Parameter(Mandatory = $false)]
        [int]$Height = 24,

        [Parameter(Mandatory = $false)]
        [int]$RefreshInterval = 2,

        [Parameter(Mandatory = $false)]
        [switch]$NoColor
    )

    $esc      = [char]27
    $useColor = -not $NoColor.IsPresent

    # Threshold-based fg color: green / yellow / red
    function Get-ColorCode {
        param([int]$Percent)
        if (-not $useColor) { return '' }
        if ($Percent -gt 80) { return "${esc}[91m" }
        if ($Percent -gt 60) { return "${esc}[93m" }
        return "${esc}[92m"
    }

    # Label color: cyan at normal load, yellow at warning, red at critical
    function Get-LabelColor {
        param([int]$Percent)
        if (-not $useColor) { return '' }
        if ($Percent -gt 80) { return "${esc}[91m" }
        if ($Percent -gt 60) { return "${esc}[93m" }
        return "${esc}[96m"
    }

    # Strip ANSI CSI SGR sequences before measuring visual length
    function Get-VisualWidth {
        param([string]$Text)
        ($Text -replace "$([char]27)\[\d+(?:;\d+)*m", '').Length
    }

    # Pad a string (which may contain ANSI escapes) to a target visual width
    function ConvertTo-PaddedLine {
        param([string]$Text, [int]$TargetWidth)
        $visual = Get-VisualWidth -Text $Text
        $needed = $TargetWidth - $visual
        if ($needed -gt 0) { return $Text + [string]::new(' ', $needed) }
        return $Text
    }

    # Render a filled/empty block bar with threshold color
    function Format-Bar {
        param([int]$Percent, [int]$BarWidth)
        if ($Percent -lt 0)   { $Percent = 0 }
        if ($Percent -gt 100) { $Percent = 100 }
        $filled    = [math]::Max(0, [math]::Round($BarWidth * $Percent / 100))
        $empty     = $BarWidth - $filled
        $color     = Get-ColorCode -Percent $Percent
        $dimCode   = if ($useColor) { "${esc}[90m" } else { '' }
        $resetCode = if ($useColor) { "${esc}[0m" }  else { '' }
        $filledStr = [string]::new([char]0x2588, $filled)
        $emptyStr  = [string]::new([char]0x2591, $empty)
        "${color}${filledStr}${dimCode}${emptyStr}${resetCode}"
    }

    # Human-readable size (KB input -> K/M/G output)
    function Format-Size {
        param([double]$SizeKB)
        if ($SizeKB -ge 1048576) { return '{0:N1}G' -f ($SizeKB / 1048576) }
        if ($SizeKB -ge 1024)    { return '{0:N0}M' -f ($SizeKB / 1024) }
        return '{0:N0}K' -f $SizeKB
    }

    # Colored "used / total" ratio line
    function Format-MemRatio {
        param([string]$Used, [string]$Total, [int]$Percent)
        $color     = Get-LabelColor -Percent $Percent
        $dimCode   = if ($useColor) { "${esc}[90m" } else { '' }
        $resetCode = if ($useColor) { "${esc}[0m" }  else { '' }
        $whiteCode = if ($useColor) { "${esc}[97m" } else { '' }
        "${color}${Used}${resetCode} ${dimCode}/${resetCode} ${whiteCode}${Total}${resetCode}"
    }

    # Static ANSI codes
    $dim       = if ($useColor) { "${esc}[90m" }         else { '' }
    $reset     = if ($useColor) { "${esc}[0m" }          else { '' }
    $bold      = if ($useColor) { "${esc}[1m" }          else { '' }
    $cyan      = if ($useColor) { "${esc}[96m" }         else { '' }
    $white     = if ($useColor) { "${esc}[97m" }         else { '' }
    $underline = if ($useColor) { "${esc}[4m" }          else { '' }
    $yellow    = if ($useColor) { "${esc}[93m" }         else { '' }
    $magenta   = if ($useColor) { "${esc}[95m" }         else { '' }
    $bgDimRow  = if ($useColor) { "${esc}[48;5;235m" }   else { '' }
    $fgHot     = if ($useColor) { "${esc}[97m${esc}[1m" } else { '' }

    $coreCount = if ($null -eq $CpuCores -or $CpuCores.Count -eq 0) { 1 } else { $CpuCores.Count }

    $lines     = [System.Collections.Generic.List[string]]::new(64)
    $separator = [string]::new([char]0x2500, [math]::Max(1, $Width - 4))

    # ---- Header ----
    $lines.Add(" ${bold}${cyan}Show-SystemMonitor${reset} ${dim}-${reset} ${bold}${white}${env:COMPUTERNAME}${reset} ${dim}|${reset} Up: ${yellow}${UptimeStr}${reset} ${dim}|${reset} ${dim}${TimeStr}${reset} ${dim}|${reset} Procs: ${white}${ProcessCount}${reset}")
    $lines.Add(" ${dim}${separator}${reset}")

    # ---- Summary bars (CPU / Mem / Swap) ----
    $barWidth = [math]::Min(40, [math]::Max(1, $Width - 30))

    $cpuLabelColor = Get-LabelColor -Percent $CpuTotalPercent
    $cpuBar        = Format-Bar -Percent $CpuTotalPercent -BarWidth $barWidth
    $cpuPctStr     = '{0,5:N1}' -f $CpuTotalPercent
    $lines.Add(" ${cpuLabelColor}${bold}CPU${reset} [${cpuBar}] ${cpuPctStr}% Cores: ${white}${coreCount}${reset}")

    $memLabelColor = Get-LabelColor -Percent $MemPercent
    $memBar        = Format-Bar -Percent $MemPercent -BarWidth $barWidth
    $memPctStr     = '{0,5:N1}' -f $MemPercent
    $safeMemTotal  = if ($MemTotalKB -le 0) { 1 } else { $MemTotalKB }
    $memRatio      = Format-MemRatio -Used (Format-Size $MemUsedKB) -Total (Format-Size $safeMemTotal) -Percent $MemPercent
    $lines.Add(" ${memLabelColor}${bold}Mem${reset} [${memBar}] ${memPctStr}% ${memRatio}")

    $pageLabelColor = Get-LabelColor -Percent $PagePercent
    $pageBar        = Format-Bar -Percent $PagePercent -BarWidth $barWidth
    $pagePctStr     = '{0,5:N1}' -f $PagePercent
    $safePageTotal  = if ($PageTotalKB -le 0) { 1 } else { $PageTotalKB }
    $pageRatio      = Format-MemRatio -Used (Format-Size $PageUsedKB) -Total (Format-Size $safePageTotal) -Percent $PagePercent
    $lines.Add(" ${pageLabelColor}${bold}Swp${reset} [${pageBar}] ${pagePctStr}% ${pageRatio}")
    $lines.Add('')

    # ---- Per-core CPU bars (2 columns) ----
    $coreBarWidth       = [math]::Min(20, [math]::Max(1, [math]::Floor(($Width - 30) / 2)))
    $coreColVisualWidth = $coreBarWidth + 14

    if ($null -ne $CpuCores -and $CpuCores.Count -gt 0) {
        for ($i = 0; $i -lt $CpuCores.Count; $i += 2) {
            $pct          = [math]::Max(0, [math]::Min(100, [int]$CpuCores[$i].PercentProcessorTime))
            $bar          = Format-Bar -Percent $pct -BarWidth $coreBarWidth
            $coreNumColor = Get-ColorCode -Percent $pct
            $coreLabel    = '{0,3}' -f $CpuCores[$i].Name
            $pctStr       = '{0,3}' -f $pct
            $leftCol      = "${coreNumColor}${coreLabel}${reset} [${bar}] ${pctStr}%"

            if ($i + 1 -lt $CpuCores.Count) {
                $leftPadded    = ConvertTo-PaddedLine -Text $leftCol -TargetWidth $coreColVisualWidth
                $pct2          = [math]::Max(0, [math]::Min(100, [int]$CpuCores[$i + 1].PercentProcessorTime))
                $bar2          = Format-Bar -Percent $pct2 -BarWidth $coreBarWidth
                $coreNumColor2 = Get-ColorCode -Percent $pct2
                $coreLabel2    = '{0,3}' -f $CpuCores[$i + 1].Name
                $pct2Str       = '{0,3}' -f $pct2
                $rightCol      = "${coreNumColor2}${coreLabel2}${reset} [${bar2}] ${pct2Str}%"
                $lines.Add(" ${leftPadded} ${rightCol}")
            }
            else {
                $lines.Add(" ${leftCol}")
            }
        }
    }
    $lines.Add(" ${dim}${separator}${reset}")

    # ---- Process table header ----
    $pidH  = if ($SortMode -eq 'PID')    { "${cyan}${underline}PID${reset}" }     else { "${dim}PID${reset}" }
    $cpuH  = if ($SortMode -eq 'CPU')    { "${cyan}${underline}CPU%${reset}" }    else { "${dim}CPU%${reset}" }
    $memH  = if ($SortMode -eq 'Memory') { "${cyan}${underline}MEM(MB)${reset}" } else { "${dim}MEM(MB)${reset}" }
    $nameH = if ($SortMode -eq 'Name')   { "${cyan}${underline}Name${reset}" }    else { "${dim}Name${reset}" }
    $lines.Add(" ${bold} ${pidH} ${cpuH} ${memH} ${nameH}${reset}")

    # ---- Process rows ----
    $availableRows = $Height - $lines.Count - 3
    $displayCount  = if ($null -eq $TopProcesses -or $TopProcesses.Count -eq 0) {
        0
    }
    else {
        [math]::Min($TopProcesses.Count, [math]::Max(5, $availableRows))
    }

    for ($i = 0; $i -lt $displayCount; $i++) {
        $p        = $TopProcesses[$i]
        $cpuColor = Get-ColorCode -Percent ([math]::Min(100, [math]::Max(0, [int]($p.CPU * 2))))
        $pidStr   = '{0,7}' -f $p.PID
        $cpuStr   = '{0,7:N1}' -f $p.CPU
        $memStr   = '{0,9:N1}' -f $p.MemMB

        $rowBg    = if ($useColor -and ($i % 2 -eq 1)) { $bgDimRow } else { '' }
        $rowReset = if ($useColor -and ($i % 2 -eq 1)) { $reset }    else { '' }

        $nameColor = if ($i -eq 0 -and $p.CPU -gt 0) { $magenta } elseif ($p.CPU -gt 50) { $fgHot } else { $white }
        $lines.Add("${rowBg} ${pidStr} ${cpuColor}${cpuStr}${reset}${rowBg} ${memStr} ${nameColor}$($p.Name)${rowReset}${reset}")
    }

    # ---- Footer ----
    $lines.Add('')
    $lines.Add(" ${dim}${separator}${reset}")
    $lines.Add(" ${bold}[${cyan}Q${reset}${bold}]${reset}uit ${bold}[${cyan}C${reset}${bold}]${reset}PU ${bold}[${cyan}M${reset}${bold}]${reset}em ${bold}[${cyan}P${reset}${bold}]${reset}ID ${bold}[${cyan}N${reset}${bold}]${reset}ame ${dim}|${reset} Refresh: ${yellow}${RefreshInterval}s${reset} ${dim}|${reset} Sort: ${cyan}${bold}${SortMode}${reset}")

    # ---- Single write buffer: cursor home + padded lines + erase tail ----
    $frame = [System.Text.StringBuilder]::new($lines.Count * ($Width + 20))
    [void]$frame.Append("${esc}[H")

    foreach ($line in $lines) {
        $padded = ConvertTo-PaddedLine -Text $line -TargetWidth $Width
        [void]$frame.AppendLine($padded)
    }

    $remainingRows = $Height - $lines.Count
    for ($r = 0; $r -lt $remainingRows; $r++) {
        [void]$frame.AppendLine("${esc}[2K")
    }

    return $frame.ToString()
}