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 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 Write-OctaPaddedLine { <# Writes one line of text, truncated (if needed) and padded to exactly $Width - so a shorter new line always fully overwrites whatever text was on that screen row before. Found the hard way: picking a shorter menu Header ("Categorias", 10 chars) right after a longer one ("Menu principal", 14 chars) left the old header's trailing characters stuck on-screen ("Categoriasipal") since the Header/NavHint lines were never padded, only item rows were (via Write-OctaMenuRow). #> param( [Parameter(Mandatory)][AllowEmptyString()][string]$Text, [Parameter(Mandatory)][int]$Width ) $line = Get-OctaTruncated -Text $Text -MaxWidth $Width if ($line.Length -lt $Width) { $line = $line.PadRight($Width) } Write-Host $line } function Write-OctaMenuRow { <# Writes exactly one menu item's line at the current cursor position - for the initial top-to-bottom render, or for an in-place update after the caller has already moved the cursor there with [Console]::SetCursorPosition. Always pads to $Width so a shorter new line fully overwrites whatever was on that row before, without an ANSI clear-line code. #> param( [Parameter(Mandatory)][object]$Item, [Parameter(Mandatory)][bool]$IsSelected, [Parameter(Mandatory)][int]$LabelWidth, [Parameter(Mandatory)][int]$Width ) $pointer = if ($IsSelected) { '>' } else { ' ' } $prefixWidth = 4 + $LabelWidth # pointer(1) + space(1) + key(1) + 2 spaces = 4, label = $LabelWidth $descWidth = [Math]::Max(0, $Width - $prefixWidth) $desc = Get-OctaTruncated -Text $Item.Description -MaxWidth $descWidth $line = ("{0} {1} {2,-$LabelWidth}{3}" -f $pointer, $Item.Key.ToString().ToUpperInvariant(), $Item.Label, $desc) if ($line.Length -lt $Width) { $line = $line.PadRight($Width) } Write-Host $line -NoNewline } function Show-OctaFrame { <# Wraps a screen-drawing scriptblock (a menu screen, or an action's "> Running: X" + output) so every screen anchors to the same starting row and never leaves stale content below from a taller previous screen - this is what makes a full session (menu -> pick a category -> see its output -> back to the menu) feel like one fixed, updating screen instead of piling up in the terminal's normal scrollback history. Uses only [Console]::SetCursorPosition, the same mechanism already confirmed working for the menu itself - no ANSI/VT sequence involved. A screen taller than the terminal's own visible window still scrolls normally when it's drawn (e.g. a long Uninstaller program list) - that's ordinary, expected terminal behavior for content that doesn't fit, not the "history piling up between unrelated screens" this fixes. #> param( [Parameter(Mandatory)][int]$AnchorRow, [Parameter(Mandatory)][scriptblock]$Draw ) [Console]::SetCursorPosition(0, $AnchorRow) $result = & $Draw $endRow = [Console]::CursorTop if ($null -ne $script:OctaLastFrameEndRow -and $endRow -lt $script:OctaLastFrameEndRow) { $width = 80 try { $width = $Host.UI.RawUI.WindowSize.Width } catch { } if ($width -lt 40 -or $width -gt 300) { $width = 80 } $blank = ' ' * ($width - 1) for ($row = $endRow; $row -le $script:OctaLastFrameEndRow; $row++) { [Console]::SetCursorPosition(0, $row) Write-Host $blank -NoNewline } [Console]::SetCursorPosition(0, $endRow) } $script:OctaLastFrameEndRow = $endRow return $result } function Show-OctaMenu { <# $Items: array of @{ Key = '1'; Label = 'Telemetry'; Description = '...' } Returns the Key of the chosen item. Real user testing found two prior redraw approaches (Clear-Host, then an ANSI clear+home sequence, then the terminal's alternate screen buffer) all still caused visible flicker/scrolling on their specific terminal, even though each was verified to work correctly by direct measurement on the dev machine's own terminal - none of that is directly testable against the user's real terminal from here. This version drops ANSI/VT entirely: the full menu prints exactly once, top to bottom, like any normal command's output (no clearing at all), and each item's real on-screen row is captured as it's written. Arrow-key navigation then only rewrites the two affected rows via [Console]::SetCursorPosition - a basic Win32 console API, not an escape sequence the terminal has to interpret - so at most 2 of perhaps 15-20 on-screen lines are ever touched per keypress, and the mechanism doesn't depend on the terminal implementing any particular VT mode correctly. #> [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 $width = 80 try { $width = $Host.UI.RawUI.WindowSize.Width } catch { } if ($width -lt 40 -or $width -gt 300) { $width = 80 } $safeWidth = $width - 1 # never write the very last column - avoids a soft-wrap-triggered scroll Show-OctaBanner Write-OctaPaddedLine -Text $Header -Width $safeWidth Write-Host '' $itemRows = @() for ($i = 0; $i -lt $Items.Count; $i++) { $itemRows += [Console]::CursorTop Write-OctaMenuRow -Item $Items[$i] -IsSelected ($i -eq $selectedIndex) -LabelWidth $labelWidth -Width $safeWidth Write-Host '' } Write-Host '' Write-OctaPaddedLine -Text $NavHint -Width $safeWidth # ponytail: this is the one true "bottom of the frame" row, captured once right after the # full initial render - every exit path below repositions here before returning, instead of # leaving the cursor wherever the last arrow-key row rewrite happened to leave it (mid-list, # via Write-OctaMenuRow's -NoNewline). Found via code review after a real report that # Show-OctaFrame's replacement logic broke down: it reads [Console]::CursorTop right after # Show-OctaMenu returns to know how tall this screen was, and a wrong (too-small) value from # an inconsistent exit position corrupted every later frame's clearing calculation. $bottomRow = [Console]::CursorTop $chosenKey = $null while ($null -eq $chosenKey) { # 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." } $newIndex = $selectedIndex switch ($key.Key) { 'UpArrow' { $newIndex = ($selectedIndex - 1 + $Items.Count) % $Items.Count } 'DownArrow' { $newIndex = ($selectedIndex + 1) % $Items.Count } 'Enter' { $chosenKey = $Items[$selectedIndex].Key } default { $typed = $key.KeyChar.ToString().ToLowerInvariant() $match = $Items | Where-Object { $_.Key.ToString().ToLowerInvariant() -eq $typed } if ($match) { $chosenKey = $match[0].Key } } } if ($null -ne $chosenKey) { break } if ($newIndex -ne $selectedIndex) { $oldIndex = $selectedIndex $selectedIndex = $newIndex [Console]::SetCursorPosition(0, $itemRows[$oldIndex]) Write-OctaMenuRow -Item $Items[$oldIndex] -IsSelected $false -LabelWidth $labelWidth -Width $safeWidth [Console]::SetCursorPosition(0, $itemRows[$selectedIndex]) Write-OctaMenuRow -Item $Items[$selectedIndex] -IsSelected $true -LabelWidth $labelWidth -Width $safeWidth } } # Always exit from the same known position, regardless of how much arrow-key navigation # happened in between - this is the fix: Show-OctaFrame's caller reads CursorTop right after # this function returns to know how tall the screen was, and that must be consistent every # time, not wherever the last in-place row rewrite left it. [Console]::SetCursorPosition(0, $bottomRow) return $chosenKey } |