Private/Format-DriveUsageFrame.ps1

#Requires -Version 5.1

function Format-DriveUsageFrame {
    <#
        .SYNOPSIS
            Renders one text frame for Watch-DriveUsage (pure formatter, no I/O).
 
        .DESCRIPTION
            Takes a data snapshot of the currently explored path and its immediate
            children and returns the complete console frame as a single [string].
            The function contains no console, CIM, filesystem or network calls, so
            it is fully unit-testable without a live Windows disk. Colour is ANSI
            based and can be suppressed with -NoColor.
 
            The renderer draws a header (current path, optional drive label, drive
            usage bar, scanning indicator, current file-visibility mode), a width
            and height aware list of rows with a '>' marker on the selected entry,
            an optional one-line status message, and a key bar. Entry objects must
            already be sorted by the caller and are expected to expose Name,
            SizeBytes, FileCount, IsContainer and Inaccessible, exactly as
            Measure-FolderSize emits them. Rows whose IsContainer is false are
            drawn with a [file] marker so loose files stay distinguishable from
            folders in both colour and NoColor modes.
 
        .PARAMETER CurrentPath
            The path being explored. Shown in the header and truncated with an
            ellipsis when it does not fit the requested width.
 
        .PARAMETER Entries
            The already-sorted rows to draw. Each object must expose Name
            (string), SizeBytes (long), FileCount (long), IsContainer (bool) and
            Inaccessible (long), matching Measure-FolderSize output.
 
        .PARAMETER SelectedIndex
            Zero-based index of the highlighted row. Negative values are clamped
            to 0 and values at or beyond the entry count are clamped to the last
            row; an empty entry set renders no marker.
 
        .PARAMETER DriveLabel
            Optional friendly name of the drive (for example 'Windows') shown next
            to the current path in the header.
 
        .PARAMETER DriveSizeBytes
            Total size of the current drive in bytes, used for the header usage
            bar. When 0 the header bar is drawn at 0 percent.
 
        .PARAMETER DriveFreeBytes
            Free bytes on the current drive, subtracted from DriveSizeBytes to
            compute the used percentage shown in the header.
 
        .PARAMETER TotalBytes
            Denominator for each row percentage. When 0 or absent every row
            percentage is rendered as 0 without dividing by zero.
 
        .PARAMETER Width
            Terminal width in columns. Defaults to 80 and is floored at 80 so a
            too-narrow request never corrupts the layout.
 
        .PARAMETER Height
            Terminal height in rows. Limits how many entry rows are shown; the
            selected row is kept visible and a 'more' indicator is emitted when
            rows are hidden.
 
        .PARAMETER Scanning
            When set, adds a 'Scanning...' indicator to the header so a long scan
            does not look like a hang.
 
        .PARAMETER StatusMessage
            Optional one-line transient message (for example 'Access denied')
            drawn above the key bar.
 
        .PARAMETER IncludeFiles
            When set, the header shows the files mode. Loose-file rows are still
            recognised from their IsContainer property alone; this switch only
            advertises the current mode in the header.
 
        .PARAMETER NoColor
            When set, every ANSI escape sequence is suppressed and the frame is
            returned as plain text.
 
        .EXAMPLE
            Format-DriveUsageFrame -CurrentPath 'C:\' -Entries $entries -SelectedIndex 0
            Renders a minimal frame for the given already-sorted entries.
 
        .EXAMPLE
            Format-DriveUsageFrame -CurrentPath 'C:\' -DriveLabel 'Windows' `
                -DriveSizeBytes 1099511627776 -DriveFreeBytes 164926744166 `
                -TotalBytes 1099511627776 -Entries $entries -SelectedIndex 2 `
                -Width 120 -Height 30 -Scanning -StatusMessage 'Rescanning...'
            Renders a full header with drive usage and a transient status line.
 
        .EXAMPLE
            Format-DriveUsageFrame -CurrentPath 'C:\Windows' -Entries $entries `
                -SelectedIndex 0 -NoColor
            Renders the same frame as plain text with no ANSI escapes, suitable
            for logs or tests.
 
        .OUTPUTS
            System.String
            One complete console frame as a single string. Nothing is printed.
 
        .NOTES
            Author: Franck SALLET
            Version: 1.0.0
            Last Modified: 2026-09-19
            Requires: PowerShell 5.1+ / Windows only
            Scope: Private - not exported
 
        .LINK
            https://github.com/k9fr4n/PSWinOps
 
        .LINK
            https://learn.microsoft.com/en-us/powershell/
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param (
        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$CurrentPath,

        [Parameter(Mandatory = $true)]
        [AllowEmptyCollection()]
        [object[]]$Entries,

        [Parameter(Mandatory = $true)]
        [int]$SelectedIndex,

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

        [Parameter(Mandatory = $false)]
        [long]$DriveSizeBytes = 0,

        [Parameter(Mandatory = $false)]
        [long]$DriveFreeBytes = 0,

        [Parameter(Mandatory = $false)]
        [long]$TotalBytes = 0,

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

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

        [Parameter(Mandatory = $false)]
        [switch]$Scanning,

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

        [Parameter(Mandatory = $false)]
        [switch]$IncludeFiles,

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

    if ($Width -lt 80) { $Width = 80 }
    if ($Height -lt 1) { $Height = 1 }

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

    # 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
    }

    # Truncate plain text with an ASCII ellipsis, never wrapping.
    function ConvertTo-Truncated {
        param([string]$Text, [int]$MaxWidth)
        if ($null -eq $Text) { $Text = '' }
        if ($MaxWidth -le 0) { return '' }
        if ((Get-VisualWidth -Text $Text) -le $MaxWidth) { return $Text }
        if ($MaxWidth -le 3) { return $Text.Substring(0, $MaxWidth) }
        return $Text.Substring(0, $MaxWidth - 3) + '...'
    }

    # Threshold-based foreground colour: 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 colour used for the header percentage.
    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"
    }

    # Render a filled/empty block bar with threshold colour.
    function Format-Bar {
        param([int]$Percent, [int]$BarWidth)
        if ($Percent -lt 0) { $Percent = 0 }
        if ($Percent -gt 100) { $Percent = 100 }
        if ($BarWidth -lt 1) { $BarWidth = 1 }
        $filled    = [int][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 (bytes in -> B/KB/MB/GB/TB, decimals only when needed).
    function Format-Size {
        param([double]$Bytes)
        if ($Bytes -lt 0) { $Bytes = 0 }
        if ($Bytes -lt 1024) { return '{0:N0} B' -f $Bytes }

        $units = @('KB', 'MB', 'GB', 'TB')
        $value = $Bytes / 1024
        foreach ($unit in $units) {
            if ($value -lt 1024 -or $unit -eq 'TB') {
                $rounded = [math]::Round($value, 1)
                if ($rounded -eq [math]::Floor($rounded)) {
                    return '{0:N0} {1}' -f $value, $unit
                }
                return '{0:N1} {1}' -f $value, $unit
            }
            $value = $value / 1024
        }
    }

    # Build one entry row with the selection marker and per-row percentage.
    function Format-EntryRow {
        param($Entry, [int]$Index, [long]$TotalBytes)
        $isSelected = ($Index -eq $selIndex)

        $namePlain = if ($null -eq $Entry.Name) { '' } else { [string]$Entry.Name }

        $isFile = $false
        if ($null -ne $Entry.IsContainer -and -not [bool]$Entry.IsContainer) {
            $isFile = $true
        }

        # A literal marker keeps files distinguishable even with -NoColor; colour
        # mode additionally dims the whole name below. Reserve its width first so
        # the truncated name plus marker never overflows the name column.
        $fileMarker = if ($isFile) { ' [file]' } else { '' }
        $nameMax    = [math]::Max(1, $nameWidth - $fileMarker.Length)
        $nameText   = ConvertTo-Truncated -Text $namePlain -MaxWidth $nameMax
        $nameText   = $nameText + $fileMarker
        $namePadded = $nameText.PadRight($nameWidth)

        $sizeBytes = [double]0
        if ($null -ne $Entry.SizeBytes) { $sizeBytes = [double]$Entry.SizeBytes }
        $sizeText = (Format-Size -Bytes $sizeBytes).PadLeft(9)

        $pctValue = [double]0
        if ($TotalBytes -gt 0) {
            $pctValue = [math]::Min(100.0, [math]::Max(0.0, $sizeBytes * 100.0 / $TotalBytes))
        }
        $pctText = ('{0:N1}%' -f $pctValue).PadLeft(6)

        $fileCount = [long]0
        if ($null -ne $Entry.FileCount) { $fileCount = [long]$Entry.FileCount }
        $countText = ('{0:N0} files' -f $fileCount).PadLeft($countWidth)

        $inaccessible = [long]0
        if ($null -ne $Entry.Inaccessible) { $inaccessible = [long]$Entry.Inaccessible }
        $flagText = if ($inaccessible -gt 0) {
            if ($useColor) { " ${red}!${reset}" } else { ' !' }
        }
        else {
            ' '
        }

        $bar     = Format-Bar -Percent ([int][math]::Round($pctValue)) -BarWidth $rowBarWidth
        $barText = "[${bar}]"

        $marker = if ($isSelected) { '>' } else { ' ' }
        if ($isSelected -and $useColor) { $marker = "${yellow}${marker}${reset}" }

        if ($isSelected -and $useColor) {
            $nameStr = "${bold}${white}${bgSel}${namePadded}${reset}"
        }
        elseif ($isFile -and $useColor) {
            $nameStr = "${dim}${namePadded}${reset}"
        }
        elseif ($useColor) {
            $nameStr = "${white}${namePadded}${reset}"
        }
        else {
            $nameStr = $namePadded
        }

        $row = ' ' + $marker + ' ' + $nameStr + ' ' + $sizeText + ' ' + $pctText +
               ' ' + $barText + ' ' + $countText + $flagText
        return ConvertTo-PaddedLine -Text $row -TargetWidth $Width
    }

    # Static ANSI codes (empty when colour is disabled).
    $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 { '' }
    $yellow = if ($useColor) { "${esc}[93m" }        else { '' }
    $red    = if ($useColor) { "${esc}[91m" }        else { '' }
    $bgSel  = if ($useColor) { "${esc}[48;5;235m" }  else { '' }

    # Normalise input and clamp the selection to a valid row.
    if ($null -eq $Entries) { $Entries = @() }
    $entryCount = @($Entries).Count

    $selIndex = $SelectedIndex
    if ($entryCount -gt 0) {
        if ($selIndex -lt 0) { $selIndex = 0 }
        if ($selIndex -ge $entryCount) { $selIndex = $entryCount - 1 }
    }
    else {
        $selIndex = -1
    }

    # Drive usage figures for the header.
    $usedPercent = 0
    $usedBytes   = [long]0
    if ($DriveSizeBytes -gt 0) {
        $usedBytes = [long]$DriveSizeBytes - [long]$DriveFreeBytes
        if ($usedBytes -lt 0) { $usedBytes = 0 }
        if ($usedBytes -gt $DriveSizeBytes) { $usedBytes = $DriveSizeBytes }
        $usedPercent = [int][math]::Min(100, [math]::Max(0, [math]::Round($usedBytes * 100.0 / $DriveSizeBytes)))
    }

    # Bar and column sizing (name column absorbs whatever the fixed chrome leaves).
    $headerBarWidth = [int][math]::Min(40, [math]::Max(8, $Width - 6))
    $rowBarWidth    = [int][math]::Max(8, [math]::Min(30, [math]::Floor(($Width - 50) / 2)))

    $countWidth = 13
    if ($entryCount -gt 0) {
        foreach ($e in $Entries) {
            $t = '{0:N0} files' -f [long]$e.FileCount
            if ($t.Length -gt $countWidth) { $countWidth = $t.Length }
        }
    }
    $nameWidth = [math]::Max(6, $Width - 31 - $rowBarWidth - $countWidth)

    $lines     = [System.Collections.Generic.List[string]]::new([math]::Max(16, $Height + 4))
    $blankLine = [string]::new(' ', $Width)

    # ---- Header ----
    $leftPlain = $CurrentPath
    if (-not [string]::IsNullOrWhiteSpace($DriveLabel)) {
        $leftPlain = "${CurrentPath} ${DriveLabel}"
    }

    $rightPlain = ''
    if ($DriveSizeBytes -gt 0) {
        $usedStr   = Format-Size -Bytes ([double]$usedBytes)
        $totalStr  = Format-Size -Bytes ([double]$DriveSizeBytes)
        $rightPlain = '{0} / {1} {2,3}% used' -f $usedStr, $totalStr, $usedPercent
    }
    if ($Scanning) {
        if ($rightPlain) { $rightPlain += ' ' }
        $rightPlain += 'Scanning...'
    }

    $modePlain = if ($IncludeFiles) { 'Files' } else { 'Folders' }
    if ($rightPlain) { $rightPlain += ' ' }
    $rightPlain += $modePlain

    $rightVisual = if ($rightPlain) { Get-VisualWidth -Text $rightPlain } else { 0 }
    $maxLeft     = $Width - $rightVisual - 1
    if ($maxLeft -lt 3) { $maxLeft = 3 }
    $leftPlain   = ConvertTo-Truncated -Text $leftPlain -MaxWidth $maxLeft
    $leftVisual  = Get-VisualWidth -Text $leftPlain
    $pad         = $Width - $leftVisual - $rightVisual
    if ($pad -lt 1) { $pad = 1 }

    $coloredLeft  = if ($useColor) { "${bold}${cyan}${leftPlain}${reset}" } else { $leftPlain }
    $coloredRight = if ($rightPlain) {
        if ($useColor) { "${dim}${rightPlain}${reset}" } else { $rightPlain }
    }
    else {
        ''
    }
    $lines.Add((ConvertTo-PaddedLine -Text ($coloredLeft + (' ' * $pad) + $coloredRight) -TargetWidth $Width))

    $headerBar = Format-Bar -Percent $usedPercent -BarWidth $headerBarWidth
    $lines.Add((ConvertTo-PaddedLine -Text ("[${headerBar}]") -TargetWidth $Width))
    $lines.Add($blankLine)

    # ---- Entry rows, height aware ----
    $statusPresent  = -not [string]::IsNullOrWhiteSpace($StatusMessage)
    $chrome         = if ($statusPresent) { 6 } else { 5 }
    $availableRows  = [math]::Max(0, $Height - $chrome)

    if ($entryCount -eq 0) {
        $emptyLine = ' ' + $(if ($useColor) { "${dim}<empty>${reset}" } else { '<empty>' })
        $lines.Add((ConvertTo-PaddedLine -Text $emptyLine -TargetWidth $Width))
    }
    else {
        $showCount = $entryCount
        $start     = 0
        $showMore  = $false
        $hidden    = 0

        if ($entryCount -gt $availableRows) {
            $showMore  = $true
            $showCount = [math]::Max(0, $availableRows - 1)
            if ($showCount -gt 0) {
                $start = 0
                if ($selIndex -ge $showCount) { $start = $selIndex - $showCount + 1 }
                if ($start -lt 0) { $start = 0 }
                if ($start + $showCount -gt $entryCount) { $start = $entryCount - $showCount }
                $hidden = $entryCount - $showCount
            }
            else {
                $hidden = $entryCount
            }
        }

        for ($i = $start; $i -lt ($start + $showCount); $i++) {
            $lines.Add((Format-EntryRow -Entry $Entries[$i] -Index $i -TotalBytes $TotalBytes))
        }

        if ($showMore) {
            $moreLine = ' ' + $(if ($useColor) { "${dim}... ${hidden} more${reset}" } else { "... ${hidden} more" })
            $lines.Add((ConvertTo-PaddedLine -Text $moreLine -TargetWidth $Width))
        }
    }

    # ---- Footer ----
    $lines.Add($blankLine)
    if ($statusPresent) {
        $statusLine = if ($useColor) { "${yellow}${StatusMessage}${reset}" } else { $StatusMessage }
        $lines.Add((ConvertTo-PaddedLine -Text $statusLine -TargetWidth $Width))
    }

    $keyBar = '[Enter] Open [Backspace] Parent [F] Files [R] Refresh [Q] Quit [X] cd here'
    $lines.Add((ConvertTo-PaddedLine -Text $keyBar -TargetWidth $Width))

    return ($lines -join ([Environment]::NewLine))
}