private/Menu.ps1
|
# FR-018: interactive checklist menu. Supports two equivalent ways to select an item - # a direct shortcut keypress (case-insensitive) or Up/Down arrow navigation + Enter. Renders # the banner (FR-022) above the list on every screen, not only once. function Enter-OctaAltScreen { <# Switches to the terminal's alternate screen buffer (the same xterm mechanism vim/htop/ less use for full-screen apps) - measured directly (not guessed): entering it drops BufferHeight to match the visible window (no scrollback), and repeated clear+redraw cycles inside it leave WindowTop at a fixed 0, unlike plain Clear-Host or a bare `Esc[2J`+`Esc[H` sequence on the MAIN buffer, which was measured to move WindowTop to follow the cursor instead of staying put - that's what made every arrow-key press look like the terminal was jumping/scrolling. Windows 11's terminals process this VT sequence by default, no extra native-API call needed. #> $esc = [char]27 Write-Host "$esc[?1049h" -NoNewline } function Exit-OctaAltScreen { <# Restores the terminal's main screen buffer exactly as it was before Enter-OctaAltScreen - the user's prior scrollback/content is untouched, same as leaving vim/htop/less. #> $esc = [char]27 Write-Host "$esc[?1049l" -NoNewline } function Clear-OctaScreen { $esc = [char]27 Write-Host "$esc[H$esc[2J" -NoNewline } function Get-OctaTruncated { <# Truncates text to fit a given width, appending an ellipsis when it doesn't - category descriptions can run past 150 characters (e.g. background-activity's), which wrapped unpredictably mid-item on a narrow/compact terminal and broke the one-line-per-item layout the menu depends on. Never returns a negative-length substring. #> param( [Parameter(Mandatory)][AllowEmptyString()][string]$Text, [Parameter(Mandatory)][int]$MaxWidth ) # ponytail: plain ASCII "..." instead of a single Unicode ellipsis character - found the hard # way that a non-ASCII char in a .ps1 file without a UTF-8 BOM gets misread as three garbled # characters under Windows PowerShell 5.1's default (system codepage) file parsing, an own # Pester test caught it (truncated length came out 22, not 20) before it ever reached a # user's screen as visible mojibake. if ($MaxWidth -le 0) { return '' } if ($Text.Length -le $MaxWidth) { return $Text } if ($MaxWidth -le 3) { return $Text.Substring(0, $MaxWidth) } return $Text.Substring(0, $MaxWidth - 3) + '...' } function Show-OctaMenu { <# $Items: array of @{ Key = '1'; Label = 'Telemetry'; Description = '...' } Returns the Key of the chosen item. #> [CmdletBinding()] param( [Parameter(Mandatory)][object[]]$Items, [Parameter(Mandatory)][string]$Header, [string]$NavHint = 'Up/Down to navigate - Enter to select - or press the number/letter directly' ) # ponytail: check interactivity up front, not just via the ReadKey try/catch below - some # non-interactive hosts (PS Direct/WinRM remoting) don't throw on ReadKey, they just hang # forever waiting for a keystroke that can never arrive. Test-OctaInteractiveConsole catches # that case; the try/catch remains as a second guard for hosts that do throw instead. if (-not (Test-OctaInteractiveConsole)) { throw "Octa's interactive menu needs a real console (Up/Down + Enter, or a direct keypress). This session has none - use the exported cmdlets directly instead (Invoke-OctaCategory, Invoke-OctaQuickClean, Show-OctaDashboard, etc.) for non-interactive/automated use." } $selectedIndex = 0 $labelWidth = 22 $prefixWidth = 4 + $labelWidth # pointer(1) + space(1) + key(1) + 2 spaces = 4, label = $labelWidth Enter-OctaAltScreen try { while ($true) { $width = 80 try { $width = $Host.UI.RawUI.WindowSize.Width } catch { } if ($width -lt 40 -or $width -gt 300) { $width = 80 } $descWidth = [Math]::Max(0, $width - $prefixWidth - 1) Clear-OctaScreen Show-OctaBanner Write-Host $Header Write-Host '' for ($i = 0; $i -lt $Items.Count; $i++) { $item = $Items[$i] $pointer = if ($i -eq $selectedIndex) { '>' } else { ' ' } $desc = Get-OctaTruncated -Text $item.Description -MaxWidth $descWidth Write-Host ("{0} {1} {2,-$labelWidth}{3}" -f $pointer, $item.Key.ToString().ToUpperInvariant(), $item.Label, $desc) } Write-Host '' Write-Host (Get-OctaTruncated -Text $NavHint -MaxWidth $width) # ponytail: ReadKey throws in a non-interactive host (no console attached - a # scheduled task, a remote session, redirected output). Without this guard the caught # exception was a non-terminating error, so the loop kept redrawing forever instead of # stopping - fail fast with a clear message instead. try { $key = [Console]::ReadKey($true) } catch [System.InvalidOperationException] { throw "Octa's interactive menu needs a real console (Up/Down + Enter, or a direct keypress). This session has none - use the exported cmdlets directly instead (Invoke-OctaCategory, Invoke-OctaQuickClean, Show-OctaDashboard, etc.) for non-interactive/automated use." } switch ($key.Key) { 'UpArrow' { $selectedIndex = ($selectedIndex - 1 + $Items.Count) % $Items.Count } 'DownArrow' { $selectedIndex = ($selectedIndex + 1) % $Items.Count } 'Enter' { return $Items[$selectedIndex].Key } default { $typed = $key.KeyChar.ToString().ToLowerInvariant() $match = $Items | Where-Object { $_.Key.ToString().ToLowerInvariant() -eq $typed } if ($match) { return $match[0].Key } } } } } finally { Exit-OctaAltScreen } } |