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
}