Functions/Show-Menu.ps1

$script:SelectedItems = [System.Collections.ArrayList]@()
$global:Display = @{}
<#
.Synopsis
    Displays a menu and handles user selection.
.DESCRIPTION
    This function displays a menu with the specified name and allows the user to select an option.
.PARAMETER InvokeItem
    The index of the menu item to invoke.
.PARAMETER Force
    Forces the execution of the selected menu item without confirmation.
.PARAMETER MenuName
    The name of the menu to display.
.PARAMETER LastResult
    OPTIONAL. The result of the last executed menu item.
.PARAMETER Multi
    OPTIONAL. If set, allows multiple selections in the menu.
.EXAMPLE
    Show-Menu -MenuName Main
.NOTES
    NAME: Show-Menu
    KEYWORDS: General scripting Controller Menu
#>

function Show-Menu
{
    [cmdletbinding()]
    Param
    (
        [int]$InvokeItem,
        [switch]$Force,
        [string]$MenuName,
        [PSCustomObject]$LastResult = @{},
        [switch]$Multi
    )

    BEGIN
    {
        $f = $MyInvocation.InvocationName
        Write-Verbose -Message "$f - START"

        $mainMenu = Get-Menu -MainMenu
        if (-not $mainMenu)
        {
            Write-Warning -Message "Please add a menu first using the New-Menu cmdlet"
            return
        }
    }

    PROCESS
    {
        if ($PSBoundParameters.ContainsKey('InvokeItem'))
        {
            $menuSelected = (Get-Menu -Name $MenuName).MenuItems[$InvokeItem]
            if ($menuSelected)
            {
                if ($menuSelected.ConfirmBeforeInvoke -and -not $Force)
                {
                    $Continue = Read-Host -Prompt "Are you sure you want to execute [$($menuSelected.Name)] Y/N?"
                    if ($Continue -ne 'y') { Write-Host "Execution aborted" -ForegroundColor DarkYellow; return }
                }

                if ($menuSelected.Action)
                {
                    Write-Host "Invoking [$($menuSelected.Name)]" -ForegroundColor DarkYellow
                    if ($menuSelected.Action.Ast.ParamBlock.Parameters.Count -gt 0) {
                        $actionResult = $menuSelected.Action.Invoke($menuSelected)
                    } else {
                        $actionResult = $menuSelected.Action.Invoke()
                    }
                    Write-Host "Invoke DONE!" -ForegroundColor DarkYellow
                    return $actionResult
                }
            }
            return
        }
    }

    END
    {
        $MaxWidth = $script:MenuOptions.MaxWidth
        $MaxHeight = $script:MenuOptions.MaxHeight
        #Adjust for the input line
        $MaxHeight = $MaxHeight - 1
        function Get-MenuLine
        {
            Param(
                [string]$Text,
                [ConsoleColor]$Color = [System.ConsoleColor]::White,
                [bool]$IsMenuItem = $false
            )

            if ($IsMenuItem)
            {
                $lineText = ' ' + $Text + ' ' * (($MaxWidth - 1) - ($Text.Length + 1) - 1)
            }
            else
            {
                $textLength = $Text.Length
                $padding = [Math]::Floor((($MaxWidth - 2) - $textLength) / 2)
                $lineText = ' ' * $padding + $Text + ' ' * (($MaxWidth - 1) - ($padding + $textLength) - 1)
            }

            [pscustomobject]@{ Text = $lineText; Color = $Color }
        }

        function New-MenuLines
        {
            Param(
                [PSCustomObject]$Menu,
                [int]$SelectedIndex,
                [int]$ScrollOffset = 0
            )

            $menuLines = New-Object System.Collections.ArrayList
            $menuIndex  = $script:Menus.IndexOf($Menu)
            $menuFrame  = $script:MenuOptions.MenuFillChar * ($MaxWidth - 2)
            $menuEmpty  = ' ' * ($MaxWidth - 2)
            
            #Prepare display lines for footer
            if ($global:Display.Count -gt 0) {
                $DisplayRaw = "| " + (($global:Display.GetEnumerator() | ForEach-Object { "$($_.Key): $($_.Value)" }) -join " | ") + " |"
            } else {
                $DisplayRaw = ""
            }
            $displayLines = [System.Collections.ArrayList]::new()
            $DisplayRawLength = $DisplayRaw.Length
            if ($DisplayRawLength -le $MaxWidth - 4) {
                $displayLines.Add($DisplayRaw) | Out-Null
            } else {
                # If the raw display string is too long to fit on one line, split it into multiple lines
                $currentLine = ""
                foreach ($part in $DisplayRaw.Split("|"))
                {
                    if ($part.Trim().Length -eq 0) { continue }
                    # If we have already filled half the available height with display lines, stop adding more to avoid pushing menu content off the screen
                    if ($displayLines.Count -gt $script:availableHeight / 2) {
                        break
                    }
                    # If adding the next part would exceed the max width split the line
                    if ($currentLine.Length + $part.Length + 3 -gt $MaxWidth - 10) {
                        $newPart = $part.SubString(0, $MaxWidth - 10 - $currentLine.Length) + "..."
                        $currentLine += " | " + $newPart
                        $displayLines.Add($currentLine) | Out-Null
                        $nextLineLength = $part.Length - $newPart.Length
                        $nextLineLength = if ($nextLineLength -gt $MaxWidth - 10) { $MaxWidth - 10 } else { $nextLineLength }
                        $currentLine = "..." + $part.SubString($newPart.Length- 3, $nextLineLength).Trim()                        
                    } else {
                        $currentLine += " | " + $part
                    }
                }
                if ($currentLine.Length -gt 0) { $displayLines.Add($currentLine + " |") | Out-Null }
            }

            $script:availableHeight = $script:availableHeight - $displayLines.Count

            # Header block
            $menuLines.Add((Get-MenuLine -Text $menuFrame -Color $script:MenuOptions.MenuFillColor)) | Out-Null
            $menuLines.Add((Get-MenuLine -Text $Menu.DisplayName -Color $script:MenuOptions.MenuNameColor)) | Out-Null
            $menuLines.Add((Get-MenuLine -Text $script:MenuOptions.Heading -Color $script:MenuOptions.HeadingColor)) | Out-Null
            $menuLines.Add((Get-MenuLine -Text $script:MenuOptions.SubHeading -Color $script:MenuOptions.SubHeadingColor)) | Out-Null
            $menuLines.Add((Get-MenuLine -Text $menuFrame -Color $script:MenuOptions.MenuFillColor)) | Out-Null
            
            
            # Body item lines
            $menuLines.Add((Get-MenuLine -Text $menuEmpty -Color $script:MenuOptions.MenuFillColor)) | Out-Null
            $counter = 0
            
            if ($Menu.MenuItems.Count -eq 0)
            {
                $menuLines.Add((Get-MenuLine -Text "[No items in this menu]" -IsMenuItem $true -Color DarkYellow)) | Out-Null
                for ($i = 0; $i -lt $script:availableHeight - 1 ; $i++)
                {
                    $menuLines.Add((Get-MenuLine -Text $menuEmpty -Color $script:MenuOptions.MenuFillColor)) | Out-Null
                }
            }
            # Calculate which items to show based on scroll offset
            # If only 1 item is over the limit, include it (pointless to scroll for just 1)
            $remainingItems = $Menu.MenuItems.Count - ($ScrollOffset + $script:availableHeight)
            $endIndex = if ($remainingItems -eq 1) { $Menu.MenuItems.Count } else { [Math]::Min($ScrollOffset + $script:availableHeight, $Menu.MenuItems.Count) }
            $itemsToShow = if ($Menu.MenuItems.Count -gt 0) { @($Menu.MenuItems[$ScrollOffset..($endIndex - 1)]) } else { @() }
            foreach ($item in $itemsToShow)
            {
                if ($counter -ge $script:availableHeight) { break }
                $menuItemIndex = $script:Menus[$menuIndex].MenuItems.IndexOf($item)
                $DisplayItemIndex = $menuItemIndex + 1
                $DisplayText = "$DisplayItemIndex. $($item.DisplayName)"

                if ($menuItemIndex -eq $SelectedIndex)
                {
                    $menuLines.Add((Get-MenuLine -Text ">> $DisplayText <<" -IsMenuItem $true -Color DarkGreen)) | Out-Null
                }
                elseif ($script:SelectedItems.Contains($menuItemIndex))
                {
                    $menuLines.Add((Get-MenuLine -Text " $DisplayText " -IsMenuItem $true -Color DarkGreen)) | Out-Null
                }
                else
                {
                    $menuLines.Add((Get-MenuLine -Text " $DisplayText " -IsMenuItem $true -Color $script:MenuOptions.MenuItemColor)) | Out-Null
                }

                $counter++
            }

            if (($ScrollOffset + $counter) -lt $Menu.MenuItems.Count)
            {
                $truncated = $Menu.MenuItems.Count - ($ScrollOffset + $counter)
                if ($truncated -gt 1)
                {
                    $menuLines.Add((Get-MenuLine -Text " [and $truncated more items]" -IsMenuItem $true -Color DarkGray)) | Out-Null
                }
                elseif ($truncated -eq 1)
                {
                    # Show the last remaining item instead of truncation message
                    $lastItem = $Menu.MenuItems[$Menu.MenuItems.Count - 1]
                    $lastItemIndex = $Menu.MenuItems.Count - 1
                    $DisplayText = "$($Menu.MenuItems.Count). $($lastItem.DisplayName)"
                    if ($lastItemIndex -eq $SelectedIndex)
                    {
                        $menuLines.Add((Get-MenuLine -Text ">> $DisplayText <<" -IsMenuItem $true -Color DarkGreen)) | Out-Null
                    }
                    elseif ($script:SelectedItems.Contains($lastItemIndex))
                    {
                        $menuLines.Add((Get-MenuLine -Text " $DisplayText " -IsMenuItem $true -Color DarkGreen)) | Out-Null
                    }
                    else
                    {
                        $menuLines.Add((Get-MenuLine -Text " $DisplayText " -IsMenuItem $true -Color $script:MenuOptions.MenuItemColor)) | Out-Null
                    }
                    $counter++
                }
            }

            $fillLimit = if ($Menu.MenuItems.Count -gt $script:availableHeight) { $script:availableHeight } else { $script:availableHeight + 1 }
            while ($counter -lt $fillLimit -and $Menu.MenuItems.Count -gt 0)
            {
                $menuLines.Add((Get-MenuLine -Text $menuEmpty -Color $script:MenuOptions.MenuFillColor)) | Out-Null
                $counter++
            }

            $menuLines.Add((Get-MenuLine -Text $menuEmpty -Color $script:MenuOptions.MenuFillColor)) | Out-Null
            # Footer block
            $menuLines.Add((Get-MenuLine -Text $menuFrame -Color $script:MenuOptions.MenuFillColor)) | Out-Null
            
            foreach ($displayText in $displayLines)
            {
                $menuLines.Add((Get-MenuLine -Text $displayText -Color $script:MenuOptions.FooterTextColor)) | Out-Null
            }
            # $menuLines.Add((Get-MenuLine -Text $script:MenuOptions.FooterText -Color $script:MenuOptions.FooterTextColor)) | Out-Null
            # $menuLines.Add((Get-MenuLine -Text $menuEmpty -Color $script:MenuOptions.MenuFillColor)) | Out-Null
            $menuLines.Add((Get-MenuLine -Text $menuFrame -Color $script:MenuOptions.MenuFillColor)) | Out-Null

            return $menuLines
        }

        $menu = if ($PSBoundParameters.ContainsKey('MenuName')) { Get-Menu -Name $MenuName } else { Get-Menu -MainMenu }
        if (-not $menu) { Write-Error -Message "$f - Could not find menu"; return }

        $selectedIndex = 0
        $scrollOffset = 0
        $maxItems = $menu.MenuItems.Count
        $oldCursor = [Console]::CursorVisible
        [Console]::CursorVisible = $false
        while ($true)
        {
            $headerFooterLines = 13
            $script:availableHeight = [Math]::Max(3, $MaxHeight - $headerFooterLines)
            
            $menuLines = New-MenuLines -Menu $menu -SelectedIndex $selectedIndex -ScrollOffset $scrollOffset
            [Console]::SetCursorPosition(0,0)

            foreach ($line in $menuLines)
            {
                Write-Host -Object $script:MenuOptions.MenuFillChar -ForegroundColor $script:MenuOptions.MenuFillColor -NoNewline
                Write-Host -Object $line.Text -ForegroundColor $line.Color -NoNewline
                Write-Host -Object $script:MenuOptions.MenuFillChar -ForegroundColor $script:MenuOptions.MenuFillColor
            }

            $inputLine = if($Multi) { "Use UP/DOWN arrows to navigate, ENTER to select, SPACE to toggle selection" } else { "Use UP/DOWN arrows to navigate, ENTER to select" }
            $inputLinePadded = $inputLine + ' ' * ($MaxWidth - $inputLine.Length)
            Write-Host $inputLinePadded -ForegroundColor Yellow
            $empty  = ' ' * $MaxWidth
            if ([bool]($lastResult.PSobject.Properties.name -match "menu") -or $lastResult -eq $null) {
                Write-Host $empty
            } else {
                $resultString = "$lastResult"
                if ($MaxWidth - 8 - $resultString.Length -gt 0) {
                    $fillResponseSpace = ' ' * ($MaxWidth - 8 - $resultString.Length)
                } else {
                    $resultString = $resultString.Substring(0, $MaxWidth - 11) + "..."
                    $fillResponseSpace = ''
                }
                Write-Host "DONE=>[$resultString]$fillResponseSpace"
            }
            $yPosition = $menuLines.Count + 1
            $currentPosition = $Host.UI.RawUI.CursorPosition.Y
            [Console]::SetCursorPosition(0,[Math]::Min($yPosition,$currentPosition))
            $key = $Host.UI.RawUI.ReadKey('NoEcho,IncludeKeyDown')
            # Calculate actual visible range to match what New-MenuLines displays
            $remainingItems = $menu.MenuItems.Count - ($scrollOffset + $script:availableHeight)
            $endIndex = if ($remainingItems -eq 1) { $menu.MenuItems.Count } else { [Math]::Min($scrollOffset + $script:availableHeight, $menu.MenuItems.Count) }
            $visibleRangeStart = $scrollOffset
            $visibleRangeEnd = $endIndex - 1

            # Handle up arrow
            if ($key.VirtualKeyCode -eq 38)
            {
                $newIndex = if ($selectedIndex -eq 0) { $maxItems - 1 } else { $selectedIndex - 1 }
                # Only scroll if at top of visible range (but not wrapping from 0)
                if ($selectedIndex -eq $visibleRangeStart -and $selectedIndex -gt 0)
                {
                    $scrollOffset = $newIndex
                }
                # Handle wrapping to end of list
                elseif ($selectedIndex -eq 0 -and $newIndex -eq $maxItems - 1)
                {
                    $scrollOffset = [Math]::Max(0, $maxItems - $script:availableHeight - 1)
                }
                $selectedIndex = $newIndex
            }
            # Handle left arrow - move up by available height / 2
            elseif ($key.VirtualKeyCode -eq 37) { 
                # Move by available height / 2 up the list on left arrow
                $newIndex = [Math]::Max($selectedIndex - [Math]::Floor($script:availableHeight / 2), 0)
                # Only scroll if at top of visible range (but not wrapping from 0)
                if (($selectedIndex -le $visibleRangeStart -or $newIndex -lt $visibleRangeStart) -and $selectedIndex -gt 0)
                {
                    $scrollOffset = $newIndex
                }
                $selectedIndex = $newIndex
            }
            # Handle down arrow
            elseif ($key.VirtualKeyCode -eq 40)
            {
                $newIndex = if ($selectedIndex -eq $maxItems - 1) { 0 } else { $selectedIndex + 1 }
                # Only scroll if at bottom of visible range (but not wrapping to 0)
                if ($selectedIndex -eq $visibleRangeEnd -and $selectedIndex -lt $maxItems - 2)
                {
                    $scrollOffset = [Math]::Max(0, $newIndex - $script:availableHeight + 1)
                }
                # Handle wrapping to start of list
                elseif ($selectedIndex -eq $maxItems - 1 -and $newIndex -eq 0)
                {
                    $scrollOffset = 0
                }
                $selectedIndex = $newIndex
            }
            # Handle right arrow - move down by available height / 2
            elseif ($key.VirtualKeyCode -eq 39) { 
                # Move by available height / 2 down the list on right arrow
                $newIndex = [Math]::Min($selectedIndex + [Math]::Floor($script:availableHeight / 2), $maxItems - 1)
                # Only scroll if at bottom of visible range (but not wrapping to 0)
                if (($selectedIndex -ge $visibleRangeEnd -or $newIndex -ge $visibleRangeEnd) -and $selectedIndex -lt $maxItems - 2)
                {
                    $scrollOffset = [Math]::Max(0, $newIndex - $script:availableHeight + 1)
                    if ($newIndex -eq $maxItems - 1) {
                        $scrollOffset = if ($scrollOffset -gt 0) { $scrollOffset - 1 } else { 0 }
                    }
                }
                $selectedIndex = $newIndex
            }
            # Handle enter or space
            elseif ($key.VirtualKeyCode -eq 13 -or $key.VirtualKeyCode -eq 32) { 
                if ($Multi -and $menu.MenuItems[$selectedIndex].DisableMultiSelect -ne $true) {
                    #Toggle selection of the current item on space, but only invoke on enter
                    if ($key.VirtualKeyCode -eq 32) {
                        if($script:SelectedItems.Contains($selectedIndex)) {
                            $script:SelectedItems.Remove($selectedIndex) | Out-Null
                        } else {
                            $script:SelectedItems.Add($selectedIndex) | Out-Null
                        }
                    }
                    if ($key.VirtualKeyCode -eq 13) {
                        # If enter is pressed, invoke and return all selected items
                        if ($script:SelectedItems.Count -eq 0) { $script:SelectedItems.Add($selectedIndex) | Out-Null }
                        $results = @()
                        foreach ($index in $script:SelectedItems) {
                            $item = $menu.MenuItems[$index]
                            if ($item.Action) {
                                if ($item.Action.Ast.ParamBlock.Parameters.Count -gt 0) {
                                    $actionResult = $item.Action.Invoke($item)
                                } else {
                                    $actionResult = $item.Action.Invoke()
                                }
                                $results += $actionResult
                            } else {
                                $results += $item.DisplayName
                            }
                        }
                        if ($results.Count -gt 0) {
                            [Console]::CursorVisible = $oldCursor
                            $script:SelectedItems.Clear()
                            return $results
                        }
                    }
                } else {
                    $script:SelectedItems.Clear()
                    break
                }
            }
        }
        [Console]::CursorVisible = $oldCursor
        $actionItemSelectionIndex = $selectedIndex
        $menuSelected = $menu.MenuItems[$actionItemSelectionIndex]

        if ($menuSelected)
        {
            if ($menuSelected.ConfirmBeforeInvoke)
            {
                $Continue = Read-Host -Prompt "Are you sure you want to execute [$($menuSelected.Name)] Y/N?"
                if ($Continue -ne 'y') { Write-Host "Execution aborted" -ForegroundColor DarkYellow; return }
            }

            if ($menuSelected.Action)
            {
                Write-Verbose "Invoking [$($menuSelected.Name)]"
                if ($menuSelected.Action.Ast.ParamBlock.Parameters.Count -gt 0) {
                    $actionResult = $menuSelected.Action.Invoke($menuSelected)
                } else {
                    $actionResult = $menuSelected.Action.Invoke()
                }
                Write-Verbose "Invoke DONE!"
                if ($actionResult) { 
                    Write-Output $actionResult                    
                }
            }
        }

        Write-Verbose -Message "$f - END"
    }
}