UI in Powershell
In the previous post, I finished short series about custom protocol handlers. Today I will focus on a different topic. I’ll describe how to use one of the most basic .net classes: System.Console
to create a console-based, PowerShell Interactive Menu. It’s a fun, old-school experience – but it still might be useful nowadays.
PowerShell is used mainly to make actions: operate on files, administer systems, get information, apply changes, etc. At the same time, it gives many possibilities to interact with users. However, it is rare to use it just for the user interface. But it has many possibilities in this area as well.
By the way, you can also use Windows Forms and Windows Presentation Framework with PowerShell. For simple cases, it works quite well. However, from my experience, for more complex tools it’s much better to use WPF in C# than PowerShell.
Working with .net classes in PowerShell can be stressful for beginner scripters. In case you are the one – don’t worry. It’s not as scary as it might look. Just read the article to the end and I’m sure you will get it on the fly.
Console Class
PowerShell has native cmdlets allowing the user to interact with your script. However, the System.Console
class is just much more flexible. At the same time, it’s really simple making it a good candidate for even beginning scripters. I prefer to keep using the .net class so it’s consistent. Keep in mind that native cmdlets can potentially replace some .net classes and methods.
I’ll also keep using the default possibilities of the class without mixing it with all the enhancements to the PowerShell user experience added in Windows Terminal. If you experiment with it, enhancing the menu visually – let me know in comments.
To learn more about the class itself, look at the documentation from Microsoft.
Show static menu
Let’s start with a simple list of menu entries. I selected List<T>
class from .net instead of native PowerShell array because it’s much more efficient, especially when we would like to extend the menu. You can notice it with dynamic content (look at this great video from Adam Driscoll to see benefits of different array and list types). Anyway, for this menu example, you can just use an array if you prefer.
$menuItems = [System.Collections.Generic.List[string]]::new()
$menuItems.Add("First Option")
$menuItems.Add("Second Option")
$menuItems.Add("Third Option")
Then just show all entries with an identity number. Note that I use Write
and WriteLine
methods. It allows to split writing to the same line into two calls. In this part, it could be unified into one WriteLine
but I’ll need this split at a later point:
for ($i = 0; $i -lt $menuItems.Count; $i++) {
[System.Console]::Write("[$i] ")
[System.Console]::WriteLine($menuItems[$i])
}
Now, I wait for the user’s choice. The ReadKey
method consumes the next character typed by a user. Passing $true
as a parameter hides the character so it’s not visible in the console.
$inputChar=[System.Console]::ReadKey($true)
[Console]::WriteLine("You selected $($inputChar.KeyChar)")
I have a very simple and useless static menu. It even doesn’t validate if the selected option is within the range of available menu items. But, it’s a good baseline so stay with me.
Validate user input
ReadKey
returns a ConsoleKeyInfo
object. We need to parse it to get the selected number. Firstly take the KeyChar
(it contains the ‘raw’, Unicode character, while Key
property can be compared more to the button on the keyboard).
Convert it to an integer so we can match it to our entries’ identity number, ignoring all values other than integers. I just catch any error for simplicity and convert it to -1
which is never valid. If the converted number is within the range, print out the entry number.
try{
$number = [System.Int32]::Parse($inputChar.KeyChar)
}
catch{
$number = -1
}
if ($number -ge 0 -and $number -lt $menuItems.Count){
[Console]::WriteLine("You selected $($inputChar.KeyChar)")
}
else {
[Console]::WriteLine("Invalid choice!")
}
Why don’t I use Read-Host
which would be much simpler and doesn’t require parsing? Here is the advantage of the native Console
class. With ReadKey
I can:
- Just read the next key, without waiting for Enter
- Catch non-standard keys like Enter, Up Arrow, etc. – I’ll use it later.
An interactive menu
I have the basic validation now so it’s a good time to make the menu interactive as my goal is to make PowerShell Interactive Menu. The overall concept is easy – enclose the menu functionality in a loop that waits for user input, validates it, and ends if it’s a number within the range of given menu entries. Another possibility to end the loop is pressing Escape. I’ll extend it later, keeping it simple for now. The condition is:
while (([System.Int16]$inputChar.Key -ne [System.ConsoleKey]::Escape)){
…
}
Now I need the Key
property instead of KeyChar
. It contains interesting information for keys like Enter or Escape.
Clean the console (in this place you can just use Clear-Host
cmdlet) on each iteration of the loop. Put the user interface part into the loop. I also change a little the logic around the Invalid Choice message to preserve it after clearing the output.
$invalidChoice = $false
while (([System.Int16]$inputChar.Key -ne [System.ConsoleKey]::Escape)){
[Console]::Clear()
for ($i = 0; $i -lt $menuItems.Count; $i++) {
[System.Console]::Write("[$i] ")
[System.Console]::WriteLine($menuItems[$i]
}
if ($invalidChoice){
[System.Console]::WriteLine("Invalid Choice! Try again...")
}
$inputChar=[System.Console]::ReadKey($true)
try{
$number = [System.Int32]::Parse($inputChar.KeyChar)
}
catch{
$number = -1
}
if ($number -ge 0 -and $number -lt $menuItems.Count){
[Console]::WriteLine("You selected $($inputChar.KeyChar)")
break
}
else {
$invalidChoice = $true
}
}
Add support for arrows – preparation
To enable the usage of arrows I change the logic further. The loop ends by hitting Enter, instead of any valid integer. There is another state: ‘selection’. After pressing Enter, ‘selection’ changes into ‘choice’. Pressing a number (because it might be bigger than 9) will just cause ‘selection’. Similar to pressing Up or Down Arrow.
As a consequence of that change, I need to indicate somehow the middle state (‘selection’). I use other methods from Console
class for this purpose. Firstly I need a small function reversing background and foreground colors:
function Reverse-Colors{
$bColor = [System.Console]::BackgroundColor
$fColor = [System.Console]::ForegroundColor
[System.Console]::BackgroundColor = $fColor
[System.Console]::ForegroundColor = $bColor
}
Another thing is to instruct users on how to use my menu. It should have a title and some hint. The backtick followed by ‘n’ (`n
) moves the menu one line below hint. It makes it more readable. Color is just to make it prettier.
$title = "Menu Title"
$hint = "Use arrows or type the number. 'Enter' - Run, 'ESC' - Exit`n"
$titleColor = 'green'
I also need two variables. One to store current selection, second to store pressed key:
$selectIndex = 0
$outChar = 'a'
Hiding the cursor makes it prettier:
[System.Console]::CursorVisible = $false
I also need updated conditions for the loop to adjust it to the modified logic.
while (([System.Int16]$inputChar.Key -ne [System.ConsoleKey]::Enter) -and ([System.Int16]$inputChar.Key -ne [System.ConsoleKey]::Escape)){
…
}
Add support for arrows – to the point
Inside the loop, firstly move the cursor to the top. It will overwrite the content in the first place. Also, I don’t clear the console to avoid blinking (again, the benefit of using native Console
class). The [Console]::Clear()
line is moved above the loop – so it’s done only once – as preparation. I also show the title and the hint:
[System.Console]::CursorTop = 0
$tempColor = [System.Console]::ForegroundColor
[System.Console]::ForegroundColor = $titleColor
[System.Console]::WriteLine("$title`n")
[System.Console]::ForegroundColor = $tempColor
[System.Console]::WriteLine($hint)
Be aware that CursorTop
property doesn’t work well in the integrated terminal of VisualStudio Code. Run the script in a console window (like Windows Terminal) to see the difference.
To show the selected item I simply call my Reverse-Colors
function. I do it for the entry with an index matching the one stored in the variable.
for ($i = 0; $i -lt $menuItems.Count; $i++) {
[System.Console]::Write("[$i] ")
if ($selectIndex -eq $i){
Reverse-Colors
[System.Console]::WriteLine($menuItems[$i])
Reverse-Colors
}
else{
[System.Console]::WriteLine($menuItems[$i])
}
}
Then I change a little the invalid choice message, making it an invalid button to fit to the new context. Also, let’s clear this line when a valid button is pressed and then reset the flag. To clean the line only, I use Write
method. Inside I create a new String
using 2 parameter’s constructor and the WindowWidth
property. It replaces all characters in the line (from first to last column – the entire width) with empty ones.
It’s important to set the cursor position to the first column of a line again after this action. Otherwise, the next iteration of the loop would change to the first line but the last column, breaking the overwriting logic.
if ($invalidChoice){
[System.Console]::WriteLine("Invalid button! Try again...")
}
else{
[System.Console]::Write([System.String]::new(' ',[System.Console]::WindowWidth))
[System.Console]::SetCursorPosition(0,[System.Console]::CursorTop)
}
$invalidChoice = $false
Then I find out whether Up or Down Arrow is pressed. I do it in a similar way that I check the Enter or Escape to end the loop. That action will move the selection appropriately. If statement ensures that selection doesn’t go outside the allowed range.
Another condition is a number. If something else was pressed, I ‘raise the flag’.
if ([System.Int16]$inputChar.Key -eq [System.ConsoleKey]::DownArrow){
if ($selectIndex -lt $menuItems.Count -1){
$selectIndex++
}
}
elseif ([System.Int16]$inputChar.Key -eq [System.ConsoleKey]::UpArrow){
if ($selectIndex -gt 0){
$selectIndex--
}
}
elseif ($number -ge 0 -and $number -lt $menuItems.Count){
$selectIndex = $number
}
else {
$invalidChoice = $true
}
At the end, I assign the character from the user’s input to the variable scoped outside the loop. This way, its value is accessible after the choice is made and I can check whether it was Enter or Esc and act adequately.
# [...]
$outChar = $inputChar
} # end of the loop
if ($outChar.Key -eq [System.ConsoleKey]::Enter){
[Console]::WriteLine("You selected $($menuItems[$selectIndex])")
}
Handle double-digits
So far, my PowerShell Interactive Menu has 3 entries. Selecting by number doesn’t work in case there are more than 9 entries. It requires a change within the number condition. Instead of simply assigning the selection, I wait a short while to check if the user types a double-digit number.
Firstly, I populate menu items to have 15:
1..15 | % {$menuItems.Add("Option $_")}
Now I can make the change. It introduces another interesting property of Console
class called KeyAvailable
. Keys that the user type with the console windows focused are just added to the console’s key queue and KeyAvailable
is set to $true
. They are ready for retrieval when requested. By checking the queue after a given interval I know whether the user wanted to add something by pressing another key.
I decided to wait 500 milliseconds. In my opinion, it’s enough to press another button. It’s also not long enough to cause a feeling of unresponsiveness if the user provides a single-digit number. In the middle of that period, I make a check to speed up the process.
After receiving the second digit, I join both with string concatenation and try to convert it to an integer. In case the conversion fails or the number is out of allowed range, I ‘raise’ the invalid button flag.
Here is the code for this part:
elseif ($number -ge 0 -and $number -lt $menuItems.Count){
$timestamp = Get-Date
while (![System.Console]::KeyAvailable -and ((get-date) - $timestamp).TotalMilliseconds -lt 500){
Start-Sleep -Milliseconds 250
}
if ([System.Console]::KeyAvailable){
$secondChar = [System.Console]::ReadKey($true).KeyChar
$fullChar = "$($inputChar.KeyChar)$($secondChar)"
try{
$number = [System.Int32]::Parse($fullChar)
if ($number -ge 0 -and $number -lt $menuItems.Count){
$selectIndex = $number
}
else{
$invalidChoice = $true
}
}
catch{
$invalidChoice = $true
}
}
else{
$selectIndex = $number
}
}
Conclusion
The final outcome of the PowerShell Interactive Menu is:
In this post, I played with System.Console
class, creating an interactive menu with PowerShell. You can extend and enhance it as you like. In the future, I plan to describe for you an example of practical usage.
I wrote the article in a way that shows the process of code creation – from simple baby steps to more advanced usage. Because it might be a little complex to follow all the changes in the code, take a look at the final version here:
Code: https://github.com/wiktormrowczynski/PowerShell/tree/main/Interactive%20Console%20Menu
- Use PowerShell to integrate OpenAI GPT with context menu - October 25, 2023
- Let OpenAI improve and correct your PowerShell code - October 11, 2023
- Create Web Link with PowerShell in Intune - September 27, 2023
Hello,
Thanks for this menu code !
But How to use in real case ?
I try this but no success
# populate menuItems with example entries
$menuItems = [System.Collections.Generic.List[string]]::new()
$menuItems.Add(“First Option”)
$menuItems.Add(“quit”)
#1..15 | % {$menuItems.Add(“Option $_”)}
# show the menu
$continue = $true
while ($continue){
New-Menu $menuItems
switch ($selectIndex) {
‘0’ {
Write-Host “First”
}
‘1’ {
$continue = $false
}
}
}
You can add return $menuItems[$selectIndex] after line 131 to get back the selected item.
Probably you want also to return if the selection was aborted. That would look like:
if ($outChar.Key -eq [System.ConsoleKey]::Enter){
[Console]::WriteLine(“You selected $($menuItems[$selectIndex])”)
return $menuItems[$selectIndex]
} elseif ($outChar.Key -eq [System.ConsoleKey]::Escape) {
return $null
}
Hello, very nice script! how can I retain the cursor/arrow selection from previous selection when using the loop below? I know in your code it resets to top but if I wanted it to retain the previous selection like somewhere in the middle upon a refresh of the menu, is it possible?
$continue = $true
while ($continue){
New-Menu $menuItems
switch ($selectIndex) {
‘0’ {
Write-Host “First”
}
‘1’ {
$continue = $false
}
}
}
Thanks for this script! It’s fantastic! I modified it so the number selection begins from 1 and not 0.
I also added after line 131 the following :
Set-Variable MenuItem ([Int]$SelectIndex) -Scope Script
Now you can access the selected number outside the function.
If I selected number 3, then I can access this like this :
If ( $MenuItem -Eq 3 ) { }
This is my customized version :
# With many thanks to : https://itconstructors.com/powershell-interactive-menu/
Function Reverse-Colors {$bColor = [System.Console]::BackgroundColor; $fColor = [System.Console]::ForegroundColor; [System.Console]::BackgroundColor = $fColor; [System.Console]::ForegroundColor = $bColor }
Function ShowMenu { Param( [Parameter( Mandatory )][System.Collections.Generic.List[String]]$MenuItems, [String]$Title = “AutoPilot”, [String]$Hint = “Maak een keuze of druk ESCAPE om dit menu te verlaten :`n”, [ValidateSet( “Green” , “Yellow” , “Red” , “Black” , “White” , “Blue” )][string]$TitleColor = ‘Blue’); $InvalidChoice = $False; $SelectIndex = 0; $OutChar = ‘a’; [System.Console]::CursorVisible = $False; [Console]::Clear()
While ( ( [System.Int16]$InputChar.Key -Ne [System.ConsoleKey]::Enter ) -And ([System.Int16]$InputChar.Key -Ne [System.ConsoleKey]::Escape ) ) { [System.Console]::CursorTop = 0; $TempColor = [System.Console]::ForegroundColor; [System.Console]::ForegroundColor = $TitleColor; [System.Console]::WriteLine( “$Title`n” ); [System.Console]::ForegroundColor = $TempColor; [System.Console]::WriteLine( $Hint )
For ( $I = 1; $I -Lt $MenuItems.Count + 1; $I++ ) { [System.Console]::Write(“[$I] “); If ( $SelectIndex -Eq $I -1 ){ Reverse-Colors; [System.Console]::WriteLine( $MenuItems[$I -1] ); Reverse-Colors } Else { [System.Console]::WriteLine( $MenuItems[$I -1] ) } }; If ( $InvalidChoice ){ [System.Console]::WriteLine( “Verkeerde optie, probeer opnieuw…” ) } Else { [System.Console]::Write([System.String]::new( ‘ ‘ , [System.Console]::WindowWidth ) ); [System.Console]::SetCursorPosition( 0 , [System.Console]::CursorTop) }; $InvalidChoice = $False; $InputChar=[System.Console]::ReadKey( $True ); Try { $Number = [System.Int32]::Parse( $InputChar.KeyChar ) } Catch { $Number = -1 }
If ( [System.Int16]$inputChar.Key -Eq [System.ConsoleKey]::DownArrow ) { If ( $SelectIndex -Lt $MenuItems.Count -1 ) { $SelectIndex++ } } ElseIf ( [System.Int16]$InputChar.Key -eq [System.ConsoleKey]::UpArrow ) { If ( $SelectIndex -Gt 0 ) { $SelectIndex– } } ElseIf ( $Number -Ge 1 -And $Number -Lt $MenuItems.Count +1) { $Timestamp = Get-Date; While ( ![System.Console]::KeyAvailable -And ( ( Get-Date ) – $Timestamp ).TotalMilliseconds -Lt 50 ) { Start-Sleep -MilliSeconds 10 }
If ( [System.Console]::KeyAvailable ){ $SecondChar = [System.Console]::ReadKey( $True ).KeyChar; $FullChar = “$( $InputChar.KeyChar ) $($secondChar)”; Try { $Number = [System.Int32]::Parse( $FullChar ); If ( $Number -Ge 0 -And $Number -Lt $MenuItems.Count ) { $SelectIndex = $Number } Else { $InvalidChoice = $True } } Catch { $InvalidChoice = $True } } Else { $SelectIndex = $Number } } Else { $InvalidChoice = $True }; $OutChar = $InputChar }
#If ( $OutChar.Key -Eq [System.ConsoleKey]::Enter ){ [Console]::WriteLine(“$( $MenuItems[$SelectIndex] ) gekozen” ); Return $MenuItems[$SelectIndex] } ElseIf ( $OutChar.Key -Eq [System.ConsoleKey]::Escape ) { Return $Null }
Set-Variable MenuItem ([Int]$SelectIndex+1) -Scope Script
}
$MenuItems = [System.Collections.Generic.List[String]]::new()
$MenuItems.Add( “Eerste optie” )
$MenuItems.Add( “Tweede optie” )
$MenuItems.Add( “Derde optie” )
ShowMenu $MenuItems
If ( $MenuItem -Eq 3 ) { Write-Host “TEST” }