├── .gitignore ├── docs ├── callback.gif ├── basic-example.gif ├── custom-formatter.gif ├── separator-support.gif └── classes-as-options.gif ├── PSMenu ├── Private │ ├── Get-ConsoleHeight.ps1 │ ├── Test-MenuSeparator.ps1 │ ├── Test-HostSupported.ps1 │ ├── Test-MenuItemArray.ps1 │ ├── Toggle-Selection.ps1 │ ├── Get-WrappedPosition.ps1 │ ├── Get-CalculatedPageIndexNumber.ps1 │ ├── Format-MenuItem.ps1 │ ├── Test-Input.ps1 │ ├── Get-PositionWithVKey.ps1 │ ├── Read-VKey.ps1 │ └── Write-Menu.ps1 ├── Public │ ├── Get-MenuSeparator.ps1 │ └── Show-Menu.ps1 ├── PSMenu.psm1 └── PSMenu.psd1 ├── LICENSE ├── deploy.psdeploy.ps1 ├── psake.ps1 ├── README.md └── Test-Module.ps1 /.gitignore: -------------------------------------------------------------------------------- 1 | *.*~ 2 | *.swp 3 | -------------------------------------------------------------------------------- /docs/callback.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sebazzz/PSMenu/HEAD/docs/callback.gif -------------------------------------------------------------------------------- /docs/basic-example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sebazzz/PSMenu/HEAD/docs/basic-example.gif -------------------------------------------------------------------------------- /docs/custom-formatter.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sebazzz/PSMenu/HEAD/docs/custom-formatter.gif -------------------------------------------------------------------------------- /docs/separator-support.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sebazzz/PSMenu/HEAD/docs/separator-support.gif -------------------------------------------------------------------------------- /docs/classes-as-options.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sebazzz/PSMenu/HEAD/docs/classes-as-options.gif -------------------------------------------------------------------------------- /PSMenu/Private/Get-ConsoleHeight.ps1: -------------------------------------------------------------------------------- 1 | function Get-ConsoleHeight() { 2 | Return (Get-Host).UI.RawUI.WindowSize.Height - 2 3 | } -------------------------------------------------------------------------------- /PSMenu/Private/Test-MenuSeparator.ps1: -------------------------------------------------------------------------------- 1 | function Test-MenuSeparator([Parameter(Mandatory)] $MenuItem) { 2 | $Separator = Get-MenuSeparator 3 | 4 | # Separator is a singleton and we compare it by reference 5 | Return [Object]::ReferenceEquals($Separator, $MenuItem) 6 | } -------------------------------------------------------------------------------- /PSMenu/Private/Test-HostSupported.ps1: -------------------------------------------------------------------------------- 1 | function Test-HostSupported() { 2 | $Whitelist = @("ConsoleHost","Visual Studio Code Host") 3 | 4 | if ($Whitelist -inotcontains $Host.Name) { 5 | Throw "This host is $($Host.Name) and does not support an interactive menu." 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /PSMenu/Private/Test-MenuItemArray.ps1: -------------------------------------------------------------------------------- 1 | function Test-MenuItemArray([Array]$MenuItems) { 2 | foreach ($MenuItem in $MenuItems) { 3 | $IsSeparator = Test-MenuSeparator $MenuItem 4 | if ($IsSeparator -eq $false) { 5 | Return 6 | } 7 | } 8 | 9 | Throw 'The -MenuItems option only contains non-selectable menu-items (like separators)' 10 | } -------------------------------------------------------------------------------- /PSMenu/Private/Toggle-Selection.ps1: -------------------------------------------------------------------------------- 1 | function Toggle-Selection { 2 | param ($Position, [Array]$CurrentSelection) 3 | if ($CurrentSelection -contains $Position) { 4 | $result = $CurrentSelection | where { $_ -ne $Position } 5 | } 6 | else { 7 | $CurrentSelection += $Position 8 | $result = $CurrentSelection 9 | } 10 | 11 | Return $Result 12 | } 13 | -------------------------------------------------------------------------------- /PSMenu/Private/Get-WrappedPosition.ps1: -------------------------------------------------------------------------------- 1 | function Get-WrappedPosition([Array]$MenuItems, [int]$Position, [int]$PositionOffset) { 2 | # Wrap position 3 | if ($Position -lt 0) { 4 | $Position = $MenuItems.Count - 1 5 | } 6 | 7 | if ($Position -ge $MenuItems.Count) { 8 | $Position = 0 9 | } 10 | 11 | # Ensure to skip separators 12 | while (Test-MenuSeparator $($MenuItems[$Position])) { 13 | $Position += $PositionOffset 14 | 15 | $Position = Get-WrappedPosition $MenuItems $Position $PositionOffset 16 | } 17 | 18 | Return $Position 19 | } 20 | -------------------------------------------------------------------------------- /PSMenu/Public/Get-MenuSeparator.ps1: -------------------------------------------------------------------------------- 1 | $Separator = [PSCustomObject]@{ 2 | __MarkSeparator = [Guid]::NewGuid() 3 | } 4 | 5 | <# 6 | 7 | .SYNOPSIS 8 | 9 | Returns a separator for the Show-Menu Cmdlet. The separator is not selectable by the user and 10 | allows a visual distinction of multiple menuitems. 11 | 12 | .EXAMPLE 13 | 14 | $MenuItems = @("Option A", "Option B", $(Get-MenuSeparator), "Quit") 15 | Show-Menu $MenuItems 16 | 17 | #> 18 | function Get-MenuSeparator() { 19 | [CmdletBinding()] 20 | Param() 21 | 22 | # Internally we will check this parameter by-reference 23 | Return $Separator 24 | } -------------------------------------------------------------------------------- /PSMenu/PSMenu.psm1: -------------------------------------------------------------------------------- 1 | 2 | Write-Verbose "Importing Functions..." 3 | 4 | # Import everything in these folders 5 | foreach ($Folder in @('Private', 'Public', 'Classes')) { 6 | $RootFolder = Join-Path -Path $PSScriptRoot -ChildPath $Folder 7 | 8 | if (Test-Path -Path $RootFolder) { 9 | Write-Verbose "`tProcessing folder $RootFolder" 10 | $Files = Get-ChildItem -Path $RootFolder -Filter *.ps1 11 | 12 | # dot source each file 13 | $Files | Where-Object { $_.name -NotLike '*.Tests.ps1' } | ForEach-Object { Write-Verbose "`t`t$($_.name)"; . $_.FullName } 14 | } 15 | } 16 | 17 | Export-ModuleMember -Function (Get-ChildItem -Path "$PSScriptRoot\Public\*.ps1").BaseName -------------------------------------------------------------------------------- /PSMenu/Private/Get-CalculatedPageIndexNumber.ps1: -------------------------------------------------------------------------------- 1 | function Get-CalculatedPageIndexNumber( 2 | [Parameter(Mandatory, Position = 0)][Array] $MenuItems, 3 | [Parameter(Position = 1)][int]$MenuPosition, 4 | [Switch]$TopIndex, 5 | [Switch]$ItemCount, 6 | [Switch]$BottomIndex 7 | ) { 8 | $WindowHeight = Get-ConsoleHeight 9 | 10 | $TopIndexNumber = 0; 11 | $MenuItemCount = $MenuItems.Count 12 | 13 | if ($MenuItemCount -gt $WindowHeight) { 14 | $MenuItemCount = $WindowHeight; 15 | if ($MenuPosition -gt $MenuItemCount) { 16 | $TopIndexNumber = $MenuPosition - $MenuItemCount; 17 | } 18 | } 19 | 20 | if ($TopIndex) { 21 | Return $TopIndexNumber 22 | } 23 | 24 | if ($ItemCount) { 25 | Return $MenuItemCount 26 | } 27 | 28 | if ($BottomIndex) { 29 | Return $TopIndexNumber + [Math]::Min($MenuItemCount, $WindowHeight) - 1 30 | } 31 | 32 | Throw 'Invalid option combination' 33 | } -------------------------------------------------------------------------------- /PSMenu/Private/Format-MenuItem.ps1: -------------------------------------------------------------------------------- 1 | function Format-MenuItem( 2 | [Parameter(Mandatory)] $MenuItem, 3 | [Switch] $MultiSelect, 4 | [Parameter(Mandatory)][bool] $IsItemSelected, 5 | [Parameter(Mandatory)][bool] $IsItemFocused) { 6 | 7 | $SelectionPrefix = ' ' 8 | $FocusPrefix = ' ' 9 | $ItemText = ' -------------------------- ' 10 | 11 | if ($(Test-MenuSeparator $MenuItem) -ne $true) { 12 | if ($MultiSelect) { 13 | $SelectionPrefix = if ($IsItemSelected) { '[x] ' } else { '[ ] ' } 14 | } 15 | 16 | $FocusPrefix = if ($IsItemFocused) { '> ' } else { ' ' } 17 | $ItemText = $MenuItem.ToString() 18 | } 19 | 20 | $WindowWidth = (Get-Host).UI.RawUI.WindowSize.Width 21 | 22 | $Text = "{0}{1}{2}" -f $FocusPrefix, $SelectionPrefix, $ItemText 23 | if ($WindowWidth - ($Text.Length + 2) -gt 0) { 24 | $Text = $Text.PadRight($WindowWidth - ($Text.Length + 2), ' ') 25 | } 26 | 27 | Return $Text 28 | } 29 | 30 | function Format-MenuItemDefault($MenuItem) { 31 | Return $MenuItem.ToString() 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | PSMenu originally created by @chrisseroka as ps-menu and subsequently (heavily) modified @Sebazzz as PSMenu 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /PSMenu/Private/Test-Input.ps1: -------------------------------------------------------------------------------- 1 | # Ref: https://docs.microsoft.com/en-us/windows/desktop/inputdev/virtual-key-codes 2 | $KeyConstants = [PSCustomObject]@{ 3 | VK_RETURN = 0x0D; 4 | VK_ESCAPE = 0x1B; 5 | VK_UP = 0x26; 6 | VK_DOWN = 0x28; 7 | VK_SPACE = 0x20; 8 | VK_PAGEUP = 0x21; # Actually VK_PRIOR 9 | VK_PAGEDOWN = 0x22; # Actually VK_NEXT 10 | VK_END = 0x23; 11 | VK_HOME = 0x24; 12 | } 13 | 14 | function Test-KeyEnter($VKeyCode) { 15 | Return $VKeyCode -eq $KeyConstants.VK_RETURN 16 | } 17 | 18 | function Test-KeyEscape($VKeyCode) { 19 | Return $VKeyCode -eq $KeyConstants.VK_ESCAPE 20 | } 21 | 22 | function Test-KeyUp($VKeyCode) { 23 | Return $VKeyCode -eq $KeyConstants.VK_UP 24 | } 25 | 26 | function Test-KeyDown($VKeyCode) { 27 | Return $VKeyCode -eq $KeyConstants.VK_DOWN 28 | } 29 | 30 | function Test-KeySpace($VKeyCode) { 31 | Return $VKeyCode -eq $KeyConstants.VK_SPACE 32 | } 33 | 34 | function Test-KeyPageDown($VKeyCode) { 35 | Return $VKeyCode -eq $KeyConstants.VK_PAGEDOWN 36 | } 37 | 38 | function Test-KeyPageUp($VKeyCode) { 39 | Return $VKeyCode -eq $KeyConstants.VK_PAGEUP 40 | } 41 | 42 | function Test-KeyEnd($VKeyCode) { 43 | Return $VKeyCode -eq $KeyConstants.VK_END 44 | } 45 | 46 | function Test-KeyHome($VKeyCode) { 47 | Return $VKeyCode -eq $KeyConstants.VK_HOME 48 | } 49 | -------------------------------------------------------------------------------- /PSMenu/Private/Get-PositionWithVKey.ps1: -------------------------------------------------------------------------------- 1 | function Get-PositionWithVKey([Array]$MenuItems, [int]$Position, $VKeyCode) { 2 | $MinPosition = 0 3 | $MaxPosition = $MenuItems.Count - 1 4 | $WindowHeight = Get-ConsoleHeight 5 | 6 | Set-Variable -Name NewPosition -Option AllScope -Value $Position 7 | 8 | <# 9 | .SYNOPSIS 10 | 11 | Updates the position until we aren't on a separator 12 | #> 13 | function Reset-InvalidPosition([Parameter(Mandatory)][int] $PositionOffset) { 14 | $NewPosition = Get-WrappedPosition $MenuItems $NewPosition $PositionOffset 15 | } 16 | 17 | If (Test-KeyUp $VKeyCode) { 18 | $NewPosition-- 19 | 20 | Reset-InvalidPosition -PositionOffset -1 21 | } 22 | 23 | If (Test-KeyDown $VKeyCode) { 24 | $NewPosition++ 25 | 26 | Reset-InvalidPosition -PositionOffset 1 27 | } 28 | 29 | If (Test-KeyPageDown $VKeyCode) { 30 | $NewPosition = [Math]::Min($MaxPosition, $NewPosition + $WindowHeight) 31 | 32 | Reset-InvalidPosition -PositionOffset -1 33 | } 34 | 35 | If (Test-KeyEnd $VKeyCode) { 36 | $NewPosition = $MenuItems.Count - 1 37 | 38 | Reset-InvalidPosition -PositionOffset 1 39 | } 40 | 41 | IF (Test-KeyPageUp $VKeyCode) { 42 | $NewPosition = [Math]::Max($MinPosition, $NewPosition - $WindowHeight) 43 | 44 | Reset-InvalidPosition -PositionOffset -1 45 | } 46 | 47 | IF (Test-KeyHome $VKeyCode) { 48 | $NewPosition = $MinPosition 49 | 50 | Reset-InvalidPosition -PositionOffset -1 51 | } 52 | 53 | Return $NewPosition 54 | } -------------------------------------------------------------------------------- /deploy.psdeploy.ps1: -------------------------------------------------------------------------------- 1 | # Generic module deployment. 2 | # 3 | # ASSUMPTIONS: 4 | # 5 | # * folder structure either like: 6 | # 7 | # - RepoFolder 8 | # - This PSDeploy file 9 | # - ModuleName 10 | # - ModuleName.psd1 11 | # 12 | # OR the less preferable: 13 | # - RepoFolder 14 | # - RepoFolder.psd1 15 | # 16 | # * Nuget key in $ENV:NugetApiKey 17 | # 18 | # * Set-BuildEnvironment from BuildHelpers module has populated ENV:BHModulePath and related variables 19 | 20 | # Publish to gallery with a few restrictions 21 | if ( 22 | $env:BHModulePath -and 23 | ($env:BHBuildSystem -ne 'Unknown' -or $ENV:NuGetApiKey) -and 24 | $env:BHBranchName -eq "master" -and 25 | $env:BHCommitMessage -match '!deploy' 26 | ) { 27 | Deploy Module { 28 | By PSGalleryModule { 29 | FromSource $ENV:BHModulePath 30 | To PSGallery 31 | WithOptions @{ 32 | ApiKey = $ENV:NuGetApiKey 33 | } 34 | } 35 | } 36 | } 37 | else { 38 | "Skipping deployment: To deploy, ensure that...`n" + 39 | "`t* You are in a known build system (Current: $ENV:BHBuildSystem)`n" + 40 | "`t* You are committing to the master branch (Current: $ENV:BHBranchName) `n" + 41 | "`t* Your commit message includes !deploy (Current: $ENV:BHCommitMessage)" | 42 | Write-Host 43 | } 44 | 45 | # Publish to AppVeyor if we're in AppVeyor 46 | if ( 47 | $env:BHModulePath -and 48 | $env:BHBuildSystem -eq 'AppVeyor' 49 | ) { 50 | Deploy DeveloperBuild { 51 | By AppVeyorModule { 52 | FromSource $ENV:BHModulePath 53 | To AppVeyor 54 | WithOptions @{ 55 | Version = $env:APPVEYOR_BUILD_VERSION 56 | } 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /PSMenu/Private/Read-VKey.ps1: -------------------------------------------------------------------------------- 1 | function Read-VKey() { 2 | $CurrentHost = Get-Host 3 | $ErrMsg = "Current host '$CurrentHost' does not support operation 'ReadKey'" 4 | 5 | try { 6 | # Issues with reading up and down arrow keys 7 | # - https://github.com/PowerShell/PowerShell/issues/16443 8 | # - https://github.com/dotnet/runtime/issues/63387 9 | # - https://github.com/PowerShell/PowerShell/issues/16606 10 | if ($IsLinux -or $IsMacOS) { 11 | ## A bug with Linux and Mac where arrow keys are return in 2 chars. First is esc follow by A,B,C,D 12 | $key1 = $CurrentHost.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown") 13 | 14 | if ($key1.VirtualKeyCode -eq 0x1B) { 15 | ## Found that we got an esc chair so we need to grab one more char 16 | $key2 = $CurrentHost.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown") 17 | 18 | ## We just care about up and down arrow mapping here for now. 19 | if ($key2.VirtualKeyCode -eq 0x41) { 20 | # VK_UP = 0x26 up-arrow 21 | $key1.VirtualKeyCode = 0x26 22 | } 23 | if ($key2.VirtualKeyCode -eq 0x42) { 24 | # VK_DOWN = 0x28 down-arrow 25 | $key1.VirtualKeyCode = 0x28 26 | } 27 | } 28 | Return $key1 29 | } 30 | 31 | Return $CurrentHost.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown") 32 | } 33 | catch [System.NotSupportedException] { 34 | Write-Error -Exception $_.Exception -Message $ErrMsg 35 | } 36 | catch [System.NotImplementedException] { 37 | Write-Error -Exception $_.Exception -Message $ErrMsg 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /PSMenu/Private/Write-Menu.ps1: -------------------------------------------------------------------------------- 1 | function Write-MenuItem( 2 | [Parameter(Mandatory)][String] $MenuItem, 3 | [Switch]$IsFocused, 4 | [ConsoleColor]$FocusColor) { 5 | if ($IsFocused) { 6 | Write-Host $MenuItem -ForegroundColor $FocusColor 7 | } 8 | else { 9 | Write-Host $MenuItem 10 | } 11 | } 12 | 13 | function Write-Menu { 14 | param ( 15 | [Parameter(Mandatory)][Array] $MenuItems, 16 | [Parameter(Mandatory)][Int] $MenuPosition, 17 | [Parameter()][Array] $CurrentSelection, 18 | [Parameter(Mandatory)][ConsoleColor] $ItemFocusColor, 19 | [Parameter(Mandatory)][ScriptBlock] $MenuItemFormatter, 20 | [Switch] $MultiSelect 21 | ) 22 | 23 | $CurrentIndex = Get-CalculatedPageIndexNumber -MenuItems $MenuItems -MenuPosition $MenuPosition -TopIndex 24 | $MenuItemCount = Get-CalculatedPageIndexNumber -MenuItems $MenuItems -MenuPosition $MenuPosition -ItemCount 25 | $ConsoleWidth = [Console]::BufferWidth 26 | $MenuHeight = 0 27 | 28 | for ($i = 0; $i -le $MenuItemCount; $i++) { 29 | if ($null -eq $MenuItems[$CurrentIndex]) { 30 | Continue 31 | } 32 | 33 | $RenderMenuItem = $MenuItems[$CurrentIndex] 34 | $MenuItemStr = if (Test-MenuSeparator $RenderMenuItem) { $RenderMenuItem } else { & $MenuItemFormatter $RenderMenuItem } 35 | if (!$MenuItemStr) { 36 | Throw "'MenuItemFormatter' returned an empty string for item #$CurrentIndex" 37 | } 38 | 39 | $IsItemSelected = $CurrentSelection -contains $CurrentIndex 40 | $IsItemFocused = $CurrentIndex -eq $MenuPosition 41 | 42 | $DisplayText = Format-MenuItem -MenuItem $MenuItemStr -MultiSelect:$MultiSelect -IsItemSelected:$IsItemSelected -IsItemFocused:$IsItemFocused 43 | Write-MenuItem -MenuItem $DisplayText -IsFocused:$IsItemFocused -FocusColor $ItemFocusColor 44 | $MenuHeight += [Math]::Max([Math]::Ceiling($DisplayText.Length / $ConsoleWidth), 1) 45 | 46 | $CurrentIndex++; 47 | } 48 | 49 | $MenuHeight 50 | } 51 | -------------------------------------------------------------------------------- /psake.ps1: -------------------------------------------------------------------------------- 1 | # PSake makes variables declared here available in other scriptblocks 2 | # Init some things 3 | Properties { 4 | # Find the build folder based on build system 5 | $ProjectRoot = $ENV:BHProjectPath 6 | if (-not $ProjectRoot) { 7 | $ProjectRoot = Resolve-Path "$PSScriptRoot\.." 8 | } 9 | 10 | $Timestamp = Get-Date -UFormat "%Y%m%d-%H%M%S" 11 | $PSVersion = $PSVersionTable.PSVersion.Major 12 | $TestFile = "TestResults_PS$PSVersion`_$TimeStamp.xml" 13 | $lines = '----------------------------------------------------------------------' 14 | 15 | $Verbose = @{ } 16 | if ($ENV:BHCommitMessage -match "!verbose") { 17 | $Verbose = @{Verbose = $True } 18 | } 19 | 20 | } 21 | 22 | Task Default -Depends Test 23 | 24 | Task Init { 25 | $lines 26 | Set-Location $ProjectRoot 27 | 28 | "Build System Details:" 29 | Get-Item ENV:BH* 30 | "`n" 31 | } 32 | 33 | Task Test -Depends Init { 34 | $lines 35 | "`n`tSTATUS: Testing with PowerShell $PSVersion" 36 | 37 | [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 38 | 39 | # Gather test results. Store them in a variable and file 40 | $TestResults = Invoke-Pester -Path $ProjectRoot\PSMenu -PassThru -OutputFormat NUnitXml -OutputFile "$ProjectRoot\$TestFile" 41 | 42 | # In Appveyor? Upload our tests! #Abstract this into a function? 43 | If ($ENV:BHBuildSystem -eq 'AppVeyor') { 44 | (New-Object 'System.Net.WebClient').UploadFile( 45 | "https://ci.appveyor.com/api/testresults/nunit/$($env:APPVEYOR_JOB_ID)", 46 | "$ProjectRoot\$TestFile" ) 47 | } 48 | 49 | Remove-Item "$ProjectRoot\$TestFile" -Force -ErrorAction SilentlyContinue 50 | 51 | # Failed tests? 52 | # Need to tell psake or it will proceed to the deployment. Danger! 53 | if ($TestResults.FailedCount -gt 0) { 54 | Write-Error "Failed '$($TestResults.FailedCount)' tests, build failed" 55 | } 56 | 57 | "`n" 58 | } 59 | 60 | Task Build -Depends Test { 61 | $lines 62 | 63 | # Load the module, read the exported functions, update the psd1 FunctionsToExport 64 | Set-ModuleFunctions 65 | 66 | # Bump the module version if we didn't already 67 | Try { 68 | $GalleryVersion = Get-NextNugetPackageVersion -Name $env:BHProjectName -ErrorAction Stop 69 | $GithubVersion = Get-MetaData -Path $env:BHPSModuleManifest -PropertyName ModuleVersion -ErrorAction Stop 70 | if ($GalleryVersion -ge $GithubVersion) { 71 | Update-Metadata -Path $env:BHPSModuleManifest -PropertyName ModuleVersion -Value $GalleryVersion -ErrorAction stop 72 | } 73 | } 74 | Catch { 75 | "Failed to update version for '$env:BHProjectName': $_.`nContinuing with existing version" 76 | } 77 | } 78 | 79 | Task Deploy -Depends Build { 80 | $lines 81 | 82 | $Params = @{ 83 | Path = "$ProjectRoot" 84 | Force = $true 85 | Recurse = $false # We keep psdeploy artifacts, avoid deploying those : ) 86 | } 87 | Invoke-PSDeploy @Verbose @Params 88 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PSMenu 2 | 3 | Simple module to generate interactive console menus (like yeoman) 4 | 5 | # Examples 6 | 7 | Very basic example. 8 | 9 | ```powershell 10 | Show-Menu @("option 1", "option 2", "option 3") 11 | ``` 12 | 13 | ![Basic example](./docs/basic-example.gif) 14 | 15 | ## Custom formatting 16 | 17 | Custom formatting of menu items and multi-selection: 18 | 19 | ```powershell 20 | Show-Menu -MenuItems $(Get-NetAdapter) -MultiSelect -MenuItemFormatter { $Args | Select -Exp Name } 21 | ``` 22 | 23 | ![Classes as options (complex example)](./docs/custom-formatter.gif) 24 | 25 | ## Custom options 26 | 27 | You can also use custom options (enriched options). A more complicated example: 28 | 29 | ```powershell 30 | class MyMenuOption { 31 | [String]$DisplayName 32 | [ScriptBlock]$Script 33 | 34 | [String]ToString() { 35 | Return $This.DisplayName 36 | } 37 | } 38 | 39 | function New-MenuItem([String]$DisplayName, [ScriptBlock]$Script) { 40 | $MenuItem = [MyMenuOption]::new() 41 | $MenuItem.DisplayName = $DisplayName 42 | $MenuItem.Script = $Script 43 | Return $MenuItem 44 | } 45 | 46 | $Opts = @( 47 | $(New-MenuItem -DisplayName "Say Hello" -Script { Write-Host "Hello!" }), 48 | $(New-MenuItem -DisplayName "Say Bye!" -Script { Write-Host "Bye!" }) 49 | ) 50 | 51 | $Chosen = Show-Menu -MenuItems $Opts 52 | 53 | & $Chosen.Script 54 | ``` 55 | 56 | This will show the menu items like you expect. 57 | 58 | ![Custom formatters and multiselect](./docs/classes-as-options.gif) 59 | 60 | ## Separators 61 | 62 | ```powershell 63 | Show-Menu @("Option A", "Option B", $(Get-MenuSeparator), "Quit") 64 | ``` 65 | 66 | Separators are unselectable items used for visual distinction in the menu. 67 | 68 | ![Separator support](./docs/separator-support.gif) 69 | 70 | ## Callback 71 | 72 | The Callback option can be used to perform actions while the menu is displayed. 73 | Note: always save & restore the cursor position like in the following example if the host output is changed in the callback. 74 | 75 | ```powershell 76 | Clear-Host 77 | Write-Host "Current time: $(Get-Date)" 78 | Write-Host "" 79 | Show-Menu @("Option A", "Option B") -Callback { 80 | $lastTop = [Console]::CursorTop 81 | [System.Console]::SetCursorPosition(0, 0) 82 | Write-Host "Current time: $(Get-Date)" 83 | [System.Console]::SetCursorPosition(0, $lastTop) 84 | } 85 | ``` 86 | 87 | ![Callback example](./docs/callback.gif) 88 | 89 | # Installation 90 | 91 | You can install it from the PowerShellGallery using PowerShellGet 92 | 93 | ```powershell 94 | Install-Module PSMenu 95 | ``` 96 | 97 | # Features 98 | 99 | - Returns value of selected menu item 100 | - Returns index of selected menu item (using `-ReturnIndex` switch) 101 | - Multi-selection support (using `-MultiSelect` switch), use `spacebar` to select items 102 | - Navigation with `up/down/page-up/page-down/home/end` keys 103 | - Longer list scroll within window 104 | - Support for separators 105 | - Esc key quits the menu (`$null` returned) 106 | - Extensively documented 107 | - Perform actions while the menu is displayed (using `-Callback`) 108 | 109 | # Documentation 110 | 111 | For details, check out the comment-based help in [the source code](./PSMenu/Public/Show-Menu.ps1), 112 | or use `Get-Help Show-Menu` from the command-line. See also [`Get-Help`](https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/get-help?view=powershell-5.1). 113 | 114 | # Contributing 115 | 116 | - Source hosted at [GitHub](https://github.com/Sebazzz/PSMenu) 117 | - Report issues/questions/feature requests on [GitHub Issues](https://github.com/Sebazzz/PSMenu/issues) 118 | 119 | Pull requests are very welcome! 120 | -------------------------------------------------------------------------------- /Test-Module.ps1: -------------------------------------------------------------------------------- 1 | param([Switch]$NoNewShell, [Switch]$SmokeTest, [String]$EncCmd, [ScriptBlock] $AutoExec, [Switch]$Exit) 2 | 3 | $MyPath = $PSScriptRoot 4 | 5 | if ($NoNewShell -eq $false) { 6 | Write-Host "Opening new shell..." 7 | 8 | [string[]]$PsArgs = @() 9 | 10 | if (!$Exit) { 11 | $PsArgs += @("-NoExit") 12 | } 13 | 14 | $PsArgs += @("-File", "$MyPath\Test-Module.ps1", "-NoNewShell") 15 | 16 | if ($SmokeTest) { 17 | $PsArgs += @("-SmokeTest") 18 | } 19 | 20 | if ($AutoExec) { 21 | $EncCmd = [Convert]::ToBase64String([System.Text.Encoding]::Unicode.GetBytes($AutoExec.ToString())) 22 | $PsArgs += @("-EncCmd", $EncCmd) 23 | } 24 | 25 | $Cmd = "powershell" 26 | if (!$(Get-Command $Cmd -ErrorAction SilentlyContinue)) { 27 | $Cmd = "pwsh" 28 | } 29 | 30 | Write-Host "$Cmd $PsArgs" 31 | & $Cmd $PsArgs 32 | Exit $LASTEXITCODE 33 | } 34 | 35 | Clear-Host 36 | Import-Module .\PSMenu\PSMenu.psm1 -Verbose 37 | 38 | function Test-MenuWithStringOptions() { 39 | $Opts = @( 40 | "Cheesecake", 41 | "Fries", 42 | "Yoghurt" 43 | ) 44 | 45 | $Chosen = Show-Menu -MenuItems $Opts 46 | 47 | Write-Host "You chose: $Chosen" 48 | } 49 | 50 | class MyMenuOption { 51 | [String]$DisplayName 52 | [ScriptBlock]$ExecuteCallback 53 | 54 | [String]ToString() { 55 | Return $This.DisplayName 56 | } 57 | 58 | [Void]Execute() { 59 | & $This.ExecuteCallback 60 | } 61 | } 62 | 63 | function New-MenuItem([String]$DisplayName, [ScriptBlock]$ExecuteCallback) { 64 | $MenuItem = [MyMenuOption]::new() 65 | $MenuItem.DisplayName = $DisplayName 66 | $MenuItem.ExecuteCallback = $ExecuteCallback 67 | Return $MenuItem 68 | } 69 | 70 | function Get-TestMenuItems() { 71 | Return @( 72 | $(New-MenuItem -DisplayName "Say Hello" -ExecuteCallback { Write-Host "Hello!" }), 73 | $(New-MenuItem -DisplayName "Say Bye!" -ExecuteCallback { Write-Host "Bye!" }) 74 | ) 75 | } 76 | 77 | function Get-TestMenuItemsByCount($Count) { 78 | for ($Index = 1; $Index -le $Count; $Index++) { 79 | New-MenuItem -DisplayName "Test Menuitem $Index" -ExecuteCallback { Write-Host "This was #$Index" }.GetNewClosure() 80 | } 81 | } 82 | 83 | function Test-MenuWithSeparator() { 84 | $MenuItems = @("Option A", "Option B", $(Get-MenuSeparator), "Quit") 85 | Show-Menu $MenuItems 86 | } 87 | 88 | function Test-MenuWithClassOptions() { 89 | $Opts = Get-TestMenuItems 90 | 91 | $Chosen = Show-Menu -MenuItems $Opts 92 | Write-Host "You chose: $Chosen" 93 | 94 | Write-Host "" 95 | $Chosen.Execute() 96 | Write-Host "" 97 | } 98 | 99 | function Test-ScrollingMenu([int]$SurplusItems = 5, [Switch]$MultiSelect) { 100 | $ItemsToGenerate = [Console]::WindowHeight + $SurplusItems 101 | 102 | Write-Host "Generating $ItemsToGenerate menu items" 103 | $MenuItems = Get-TestMenuItemsByCount -Count $ItemsToGenerate 104 | 105 | Show-Menu -MenuItems $MenuItems -MultiSelect:$MultiSelect 106 | } 107 | 108 | function Test-MenuWithCustomFormatter() { 109 | Show-Menu -MenuItems $(Get-NetAdapter) -MenuItemFormatter { Param($M) $M.Name } 110 | } 111 | 112 | function Test-MenuWithCallback() { 113 | Clear-Host 114 | Write-Host "Current time: $(Get-Date)" 115 | Write-Host "" 116 | Show-Menu @("Option A", "Option B") -Callback { 117 | $lastTop = [Console]::CursorTop 118 | [System.Console]::SetCursorPosition(0, 0) 119 | Write-Host "Current time: $(Get-Date)" 120 | [System.Console]::SetCursorPosition(0, $lastTop) 121 | } 122 | } 123 | 124 | if ($SmokeTest) { 125 | Write-Host "Test-MenuWithClassOptions" -ForegroundColor Cyan 126 | Test-MenuWithClassOptions 127 | 128 | Write-Host "Test-MenuWithCustomFormatter" -ForegroundColor Cyan 129 | Test-MenuWithCustomFormatter 130 | Exit 0 131 | } 132 | 133 | if ($EncCmd) { 134 | $Cmd = [System.Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($EncCmd)) 135 | 136 | Write-Host "Executing: $Cmd" 137 | Invoke-Expression -Command $Cmd 138 | } 139 | -------------------------------------------------------------------------------- /PSMenu/PSMenu.psd1: -------------------------------------------------------------------------------- 1 | # 2 | # Module manifest for module 'PS-Menu' 3 | # 4 | 5 | @{ 6 | 7 | # Script module or binary module file associated with this manifest. 8 | RootModule = 'PSMenu.psm1' 9 | 10 | # Version number of this module. 11 | ModuleVersion = '0.1.9' 12 | 13 | # ID used to uniquely identify this module 14 | GUID = '652b2326-2d29-4e86-8149-03828d75503e' 15 | 16 | # Author of this module 17 | Author = 'Sebastiaan Dammann' 18 | 19 | # Company or vendor of this module 20 | CompanyName = 'Damsteen.nl' 21 | 22 | # Copyright statement for this module 23 | Copyright = '(c) Damsteen.nl' 24 | 25 | # Description of the functionality provided by this module 26 | Description = 'Powershell module to generate interactive console menu. 27 | 28 | Supports: 29 | - Multiselection 30 | - Paging 31 | - Objects 32 | - Custom rendering 33 | - Rendering callback 34 | 35 | Check for examples on how to use it on Github!' 36 | 37 | # Minimum version of the Windows PowerShell engine required by this module 38 | PowerShellVersion = '4.0' 39 | 40 | # Name of the Windows PowerShell host required by this module 41 | # PowerShellHostName = '' 42 | 43 | # Minimum version of the Windows PowerShell host required by this module 44 | # PowerShellHostVersion = '' 45 | 46 | # Minimum version of Microsoft .NET Framework required by this module 47 | # DotNetFrameworkVersion = '' 48 | 49 | # Minimum version of the common language runtime (CLR) required by this module 50 | # CLRVersion = '' 51 | 52 | # Processor architecture (None, X86, Amd64) required by this module 53 | # ProcessorArchitecture = '' 54 | 55 | # Modules that must be imported into the global environment prior to importing this module 56 | # RequiredModules = @() 57 | 58 | # Assemblies that must be loaded prior to importing this module 59 | # RequiredAssemblies = @() 60 | 61 | # Script files (.ps1) that are run in the caller's environment prior to importing this module. 62 | # ScriptsToProcess = @() 63 | 64 | # Type files (.ps1xml) to be loaded when importing this module 65 | # TypesToProcess = @() 66 | 67 | # Format files (.ps1xml) to be loaded when importing this module 68 | # FormatsToProcess = 'ServerOpsMenu.Format.ps1xml' 69 | 70 | # Modules to import as nested modules of the module specified in RootModule/ModuleToProcess 71 | # NestedModules = @() 72 | 73 | # Functions to export from this module 74 | FunctionsToExport = @('Get-MenuSeparator','Show-Menu') 75 | 76 | # Cmdlets to export from this module 77 | # CmdletsToExport = '*' 78 | 79 | # Variables to export from this module 80 | # VariablesToExport = '*' 81 | 82 | # Aliases to export from this module 83 | # AliasesToExport = '*' 84 | 85 | # DSC resources to export from this module 86 | # DscResourcesToExport = @() 87 | 88 | # List of all modules packaged with this module 89 | # ModuleList = @() 90 | 91 | # List of all files packaged with this module 92 | # FileList = @() 93 | 94 | # Private data to pass to the module specified in RootModule/ModuleToProcess. This may also contain a PSData hashtable with additional module metadata used by PowerShell. 95 | PrivateData = @{ 96 | 97 | PSData = @{ 98 | 99 | # Tags applied to this module. These help with module discovery in online galleries. 100 | Tags = @("Menu", "Console", "Interactive") 101 | 102 | # A URL to the license for this module. 103 | # LicenseUri = '' 104 | 105 | # A URL to the main website for this project. 106 | ProjectUri = 'https://github.com/Sebazzz/PSMenu' 107 | 108 | # A URL to an icon representing this module. 109 | # IconUri = '' 110 | 111 | # ReleaseNotes of this module 112 | ReleaseNotes = ' 113 | Improve handling of array-like parameters 114 | ' 115 | 116 | } # End of PSData hashtable 117 | 118 | } # End of PrivateData hashtable 119 | 120 | # HelpInfo URI of this module 121 | # HelpInfoURI = '' 122 | 123 | # Default prefix for commands exported from this module. Override the default prefix using Import-Module -Prefix. 124 | # DefaultCommandPrefix = '' 125 | 126 | } 127 | 128 | -------------------------------------------------------------------------------- /PSMenu/Public/Show-Menu.ps1: -------------------------------------------------------------------------------- 1 | <# 2 | 3 | .SYNOPSIS 4 | Shows an interactive menu to the user and returns the chosen item or item index. 5 | 6 | .DESCRIPTION 7 | Shows an interactive menu on supporting console hosts. The user can interactively 8 | select one (or more, in case of -MultiSelect) items. The cmdlet returns the items 9 | itself, or its indices (in case of -ReturnIndex). 10 | 11 | The interactive menu is controllable by hotkeys: 12 | - Arrow up/down: Focus menu item. 13 | - Enter: Select menu item. 14 | - Page up/down: Go one page up or down - if the menu is larger then the screen. 15 | - Home/end: Go to the top or bottom of the menu. 16 | - Spacebar: If in multi-select mode (MultiSelect parameter), toggle item choice. 17 | 18 | Not all console hosts support the interactive menu (PowerShell ISE is a well-known 19 | host which doesn't support it). The console host needs to support the ReadKey method. 20 | The default PowerShell console host does this. 21 | 22 | .PARAMETER MenuItems 23 | Array of objects or strings containing menu items. Must contain at least one item. 24 | Must not contain $null. 25 | 26 | The items are converted to a string for display by the MenuItemFormatter parameter, which 27 | does by default a ".ToString()" of the underlying object. It is best for this string 28 | to fit on a single line. 29 | 30 | The array of menu items may also contain unselectable separators, which can be used 31 | to visually distinct menu items. You can call Get-MenuSeparator to get a separator object, 32 | and add that to the menu item array. 33 | 34 | .PARAMETER ReturnIndex 35 | Instead of returning the object(s) that has/have been chosen, return the index/indices 36 | of the object(s) that have been chosen. 37 | 38 | .PARAMETER MultiSelect 39 | Allow the user to select multiple items instead of a single item. 40 | 41 | .PARAMETER ItemFocusColor 42 | The console color used for focusing the active item. This by default green, 43 | which looks good on both default PowerShell-blue and black consoles. 44 | 45 | .PARAMETER MenuItemFormatter 46 | A function/scriptblock which accepts a menu item (from the MenuItems parameter) 47 | and returns a string suitable for display. This function will be called many times, 48 | for each menu item once. 49 | 50 | This parameter is optional and by default executes a ".ToString()" on the object. 51 | If you control the objects that you pass in MenuItems, then you want to probably 52 | override the ToString() method. If you don't control the objects, then this parameter 53 | is very useful. 54 | 55 | .PARAMETER InitialSelection 56 | Set initial selections if multi-select mode. This is an array of indecies. 57 | 58 | .PARAMETER Callback 59 | A function/scriptblock which is called every 10 milliseconds while the menu is shown 60 | 61 | .INPUTS 62 | 63 | None. You cannot pipe objects to Show-Menu. 64 | 65 | .OUTPUTS 66 | 67 | Array of chosen menu items or (if the -ReturnIndex parameter is given) the indices. 68 | 69 | .LINK 70 | 71 | https://github.com/Sebazzz/PSMenu 72 | 73 | .EXAMPLE 74 | 75 | Show-Menu @("option 1", "option 2", "option 3") 76 | 77 | .EXAMPLE 78 | 79 | Show-Menu -MenuItems $(Get-NetAdapter) -MenuItemFormatter { $Args | Select -Exp Name } 80 | 81 | .EXAMPLE 82 | 83 | Show-Menu @("Option A", "Option B", $(Get-MenuSeparator), "Quit") 84 | 85 | #> 86 | function Show-Menu { 87 | [CmdletBinding()] 88 | Param ( 89 | [Parameter(Mandatory, Position = 0)][Array] $MenuItems, 90 | [Switch]$ReturnIndex, 91 | [Switch]$MultiSelect, 92 | [ConsoleColor] $ItemFocusColor = [ConsoleColor]::Green, 93 | [ScriptBlock] $MenuItemFormatter = { Param($M) Format-MenuItemDefault $M }, 94 | [Array] $InitialSelection = @(), 95 | [ScriptBlock] $Callback = $null 96 | ) 97 | 98 | Test-HostSupported 99 | Test-MenuItemArray -MenuItems $MenuItems 100 | 101 | # Current pressed virtual key code 102 | $VKeyCode = 0 103 | 104 | # Initialize valid position 105 | $Position = Get-WrappedPosition $MenuItems -Position 0 -PositionOffset 1 106 | 107 | $CurrentSelection = $InitialSelection 108 | 109 | try { 110 | [System.Console]::CursorVisible = $False # Prevents cursor flickering 111 | 112 | # Body 113 | $WriteMenu = { 114 | ([ref]$MenuHeight).Value = Write-Menu -MenuItems $MenuItems ` 115 | -MenuPosition $Position ` 116 | -MultiSelect:$MultiSelect ` 117 | -CurrentSelection:$CurrentSelection ` 118 | -ItemFocusColor $ItemFocusColor ` 119 | -MenuItemFormatter $MenuItemFormatter 120 | } 121 | $MenuHeight = 0 122 | 123 | & $WriteMenu 124 | $NeedRendering = $false 125 | 126 | While ($True) { 127 | If (Test-KeyEscape $VKeyCode) { 128 | Return $null 129 | } 130 | 131 | If (Test-KeyEnter $VKeyCode) { 132 | Break 133 | } 134 | 135 | # While there are 136 | Do { 137 | # Read key when callback and available key, or no callback at all 138 | $VKeyCode = $null 139 | if ($null -eq $Callback -or [Console]::KeyAvailable) { 140 | $CurrentPress = Read-VKey 141 | $VKeyCode = $CurrentPress.VirtualKeyCode 142 | } 143 | 144 | If (Test-KeySpace $VKeyCode) { 145 | $CurrentSelection = Toggle-Selection $Position $CurrentSelection 146 | } 147 | 148 | $Position = Get-PositionWithVKey -MenuItems $MenuItems -Position $Position -VKeyCode $VKeyCode 149 | 150 | If (!$(Test-KeyEscape $VKeyCode)) { 151 | [System.Console]::SetCursorPosition(0, [Math]::Max(0, [Console]::CursorTop - $MenuHeight)) 152 | $NeedRendering = $true 153 | } 154 | } While ($null -eq $Callback -and [Console]::KeyAvailable); 155 | 156 | If ($NeedRendering) { 157 | & $WriteMenu 158 | $NeedRendering = $false 159 | } 160 | 161 | If ($Callback) { 162 | & $Callback 163 | 164 | Start-Sleep -Milliseconds 10 165 | } 166 | } 167 | } 168 | finally { 169 | [System.Console]::CursorVisible = $true 170 | } 171 | 172 | if ($ReturnIndex -eq $false -and $null -ne $Position) { 173 | if ($MultiSelect) { 174 | if ($null -ne $CurrentSelection) { 175 | Return $MenuItems[$CurrentSelection] 176 | } 177 | } 178 | else { 179 | Return $MenuItems[$Position] 180 | } 181 | } 182 | else { 183 | if ($MultiSelect) { 184 | Return $CurrentSelection 185 | } 186 | else { 187 | Return $Position 188 | } 189 | } 190 | } 191 | --------------------------------------------------------------------------------