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 Reset-OctaFrameAnchor { <# Must be called exactly once, before the first Show-OctaFrame call in a session. ponytail: two prior designs both anchored every redraw to an absolute row number and both broke on a real console (confirmed via a Windows 11 VM, live console, not a remoting/redirected one): whatever row is "wherever the cursor happens to be when Invoke-Octa starts" leaves too little headroom below it, and Windows Terminal's ConPTY host ties BufferSize.Height to WindowSize.Height with NO extra scrollback (measured: both 35 on a stock 1366x768 VM session) - and unlike classic conhost, trying to grow BufferSize.Height there doesn't just fail gracefully, it closes the console window outright (measured directly). So once a frame's content nears that height, writing past the last row scrolls EVERYTHING up mid-draw, and any previously stored row number goes stale - the redraw then lands below the old content instead of over it, which is exactly the "everything piles up" bug a real user reported and this reproduces on demand. Fix: make row 0 the permanent anchor for the whole session, and GUARANTEE the rows below it start blank by writing to each of them directly via [Console]::SetCursorPosition - not Clear-Host, not an ANSI/VT sequence, not a buffer resize; all three were already tried and each broke a differently-configured real terminal at some earlier point in this project. ponytail: an earlier version of this printed N blank lines sequentially instead, on the theory that filling the visible window forces the terminal to scroll old content away. Measured this to be wrong on a classic-conhost console with a large scrollback buffer (9001 rows measured, on a real elevated "Ejecutar como administrador" window): printing blank lines from wherever the cursor CURRENTLY was only blanked rows from there downward, never rows 0 through wherever that starting point was - so old content sitting at low row numbers from an earlier launch stayed put and kept showing above the fresh menu. Blanking every row directly by its absolute position, starting from row 0, removes that gap - it no longer matters where the cursor happened to be beforehand. #> Clear-OctaRows -FromRow 0 [Console]::SetCursorPosition(0, 0) Set-OctaWindowTop $script:OctaFrameActive = $false } function Clear-OctaRows { <# Writes a full-width blank line to every row from $FromRow up to a generous cutoff (200), covering whatever could possibly be visible - stopping early, cleanly, the moment the console itself refuses a row. ponytail: reading BufferSize.Height up front and trusting it as the valid upper bound was measured to still throw (a real "El valor debe ser mayor que o igual a cero y menor que el tamano de bufer..." ArgumentOutOfRangeException) on a live ConPTY-hosted console - the value read back doesn't reliably match what SetCursorPosition will actually accept on every host. Rather than chase an exact number a second time, just try each row and stop the instant one is rejected; that is by definition the real boundary, on any host. #> param([Parameter(Mandatory)][int]$FromRow) $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 = $FromRow; $row -lt 200; $row++) { try { [Console]::SetCursorPosition(0, $row) } catch { break } Write-Host $blank -NoNewline } } function Set-OctaWindowTop { <# ponytail: [Console]::SetCursorPosition(0, 0) moves the CURSOR to buffer row 0, but on a classic-conhost console (BufferSize.Height much larger than WindowSize.Height - measured 9001 x 30 on a real elevated "Ejecutar como administrador" window) the VIEWPORT doesn't reliably follow it back up there on its own: whatever was visible before Reset- OctaFrameAnchor's blank-line scroll can stay on screen at the top, above the freshly drawn content below it. Explicitly pinning WindowPosition removes the ambiguity instead of hoping the host auto-scrolls correctly. #> try { $Host.UI.RawUI.WindowPosition = [System.Management.Automation.Host.Coordinates]::new(0, 0) } catch { } } function Show-OctaFrame { <# Wraps a screen-drawing scriptblock (a menu screen, or an action's "> Running: X" + output) so every screen replaces the previous one in place at row 0 (see Reset-OctaFrameAnchor) instead of piling up in the terminal's normal scrollback history. 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. ponytail: an earlier version of this tracked the previous frame's exact height ([Console]::CursorTop at the end minus at the start) and only blanked that many rows. Measured, on the real VM, that this under-counts when a SINGLE Draw call is long enough to scroll partway through itself (a category with several confirmation prompts plus the activity panel easily reaches ~33 of a 35-row buffer) - CursorTop read at the very end no longer lines up with where the frame actually started, so the next blank-and-redraw stopped short and left a stale line or two behind (a real repro: "Presiona una tecla..." surviving underneath the next menu). Blanking the whole visible window every time, always, removes the measurement entirely instead of trying to make it more precise - a few dozen extra blank-line writes per screen transition is free next to the alternative of getting it wrong. #> param( [Parameter(Mandatory)][scriptblock]$Draw ) if ($script:OctaFrameActive) { Clear-OctaRows -FromRow 0 } [Console]::SetCursorPosition(0, 0) Set-OctaWindowTop $result = & $Draw $script:OctaFrameActive = $true 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', [string]$Tagline ) # 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 if ($Tagline) { Show-OctaBanner -Tagline $Tagline } else { 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 } |