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" } } |