Detecting Key Presses

Wouldn’t it be nice for scripts to detect when a key is pressed? Pressing a key could add a pause to scripts, exit loops prematurely, or skip loading things in your profile script.

Wouldn’t it be nice for scripts to detect when a key is pressed? Pressing a key could add a pause to scripts, exit loops prematurely, or skip loading things in your profile script.

By default, PowerShell only supports blocking commands such as Read-Host. To add a “pause” to your scripts with built-in commands, a user would always have to press Enter:

$null = Read-Host 'To continue press ENTER'

Exiting a monitoring loop via Esc, for instance, or skipping profile script code when Ctrl is down is impossible with blocking commands.

Let’s add Test-PSOneKeyPress and fix this. On the journey to a full implementation, there are a number of learning points to take home.

Using the Console API

Most PowerShell hosts use a console window, and console windows have a built-in API that can read characters or entire lines from the user.

This requires a console with full input support. It won’t work for editors like the ISE which hosts PowerShell in its own WPF window and keeps a hidden console only to support console applications. It won’t work really well in VSCode either.

Which is why I am looking at another approach in a bit that works in all PowerShell hosts.

Your “entry point” is the type [Console] with its static methods:

[Console] | Get-Member -Static | Where-Object Name -match '(Read|Key)'

The result shows all members related to reading keys:

   TypeName: System.Console

Name           MemberType Definition                                               
----           ---------- ----------                                               
CancelKeyPress Event      System.ConsoleCancelEventHandler CancelKeyPress(System...
Read           Method     static int Read()                                        
ReadKey        Method     static System.ConsoleKeyInfo ReadKey(), static System....
ReadLine       Method     static string ReadLine()                                 
KeyAvailable   Property   static bool KeyAvailable {get;}     

Making ReadKey() Non-Blocking

All methods provided by the console API to read keyboard input really are Blocking Calls. So they are no better than Read-Host. It’s simple to turn them into Non-Blocking Calls: do not call ReadKey() when there is no key available, and yo shall not wait. Period.

The property KeyAvailable helps you and returns $true if a key has already been pressed and is now waiting in the keyboard buffer to be processed. ReadKey() will never block if you call it only when KeyAvailable is $true.

Here is a simple test that illustrates the moving parts and shows what you’ll receive:

do
{
    # wait for a key to be available:
    if ([Console]::KeyAvailable)
    {
        # read the key, and consume it so it won't
        # be echoed to the console:
        $keyInfo = [Console]::ReadKey($true)
        # exit loop
        break
    }

    # write a dot and wait a second
    Write-Host '.' -NoNewline
    Start-Sleep -Seconds 1

} while ($true)

# emit a new line
Write-Host

# show the received key info object:
$keyInfo

When you run this code in the ISE editor, as expected you’ll receive an exception because of the missing console window:

Cannot see if a key has been pressed when either application does not have a console or when console input has been redirected from a file. Try Console.In.Peek.

If you run the code in a PowerShell console window, or in VSCode (which runs a PowerShell console in the background), you see a line of dots while the code is waiting for you to press a key which proves that the code is non-blocking , and PowerShell can do other things while waiting for a key press.

Once you do press a key, you receive a ConsoleKeyInfo object. I pressed Shift+X and received this:

KeyChar Key Modifiers
------- --- ---------
      X   X     Shift

Limitations

Detecting key presses via the console API is straight-forward and requires only a few lines of code, but it also has severe limitations:

  • Will only work in interactive consoles.

    ISE has no interactive console, so reading keys via the console API won’t work at all. VSCode keeps an interactive console in the background which makes reading input from the console possible but somewhat unreliable: VSCode will always echo the pressed key and seems to ignore the modifier key Alt. You may want to test for yourself: run the code above in a PowerShell console and in VSCode and notice the different behavior).

  • Will only detect true key presses. You can’t detect pressed modifier keys alone, so you can’t figure out whether Ctrl is currently pressed. ReadKey() does report back modifier keys that were pressed when a regular key was pressed, so you can test for key combinations such as Ctrl+Shift+U, but you cannot detect whether Ctrland/or Shiftis currently pressed unless the user also presses a regular key. Again, I encourage you to run the code above and test the results for different keys and key-modifier-combinations.

  • Finally, you’ll soon realize that a lot of key-combinations are already in use by VSCode and will be processed before ReadKey() even had a chance to capture and detect them.

Simple Use Cases for Console Lovers

You can still use the console API to implement a number of useful things - provided you don’t care much about code compatibility to non-console PowerShell hosts like the ISE.

Pause: Wait for a Key Press

This implements a simple “pause” command like the blocking Wait-KeyPress which lets you fully customize the message, colors, and the actual key to wait for:

function Wait-KeyPress
{
    param
    (
        [string]
        $Message = 'Press Arrow-Down to continue',

        [ConsoleKey]
        $Key = [ConsoleKey]::DownArrow
    )
    
    # emit your custom message
    Write-Host -Object $Message -ForegroundColor Yellow -BackgroundColor Black
    
    # use a blocking call because we *want* to wait
    do
    {
        $keyInfo = [Console]::ReadKey($false)
    } until ($keyInfo.Key -eq $key)
}

'First Part'
Wait-KeyPress
'Second Part'

Silently Detecting Key Presses

This implements a non-blocking Test-KeyPress that silently checks whether a key was pressed, and then exits a monitoring loop, for example:

function Test-KeyPress
{
    param
    (
        # submit the key you want to detect
        [Parameter(Mandatory)]
        [ConsoleKey]
        $Key,

        [System.ConsoleModifiers]
        $ModifierKey = 0
    )

    # reading keys is a blocking function. To "unblock" it,
    # let's first check if a key press is available at all:
    if ([Console]::KeyAvailable)
    {
        # since a key was pressed, ReadKey() now is NOT blocking
        # anymore because there is already a pressed key waiting
        # to be picked up
        # submit $true as an argument to consume the key. Else,
        # the pressed key would be echoed in the console window
        # note that ReadKey() returns a ConsoleKeyInfo object
        # the pressed key can be found in its property "Key":
        $pressedKey = [Console]::ReadKey($true)

        # if the pressed key is the key we are after...
        $isPressedKey = $key -eq $pressedKey.Key
        if ($isPressedKey)
        {
            # if you want an EXACT match of modifier keys,
            # check for equality (-eq)
            $pressedKey.Modifiers -eq $ModifierKey
            # if you want to ensure that AT LEAST the specified
            # modifier keys were pressed, but you don't care
            # whether other modifier keys are also pressed, use
            # "binary and" (-band). If all bits are set, the result
            # is equal to the tested bit mask:
            # ($pressedKey.Modifiers -band $ModifierKey) -eq $ModifierKey
        }
        else
        {
            # else emit a short beep to let the user know that
            # a key was pressed that was not expected
            # Beep() takes the frequency in Hz and the beep
            # duration in milliseconds:
            [Console]::Beep(1800, 200)

            # return $false
            $false
        }
    }
}

Test-KeyPress perfectly complements monitoring loops like the FileSystemWatcher which monitors folders for file changes. Monitoring loops by nature are endless loops that do some sort of monitoring per loop iteration. A user would have to press Ctrl+C and abort the script to end monitoring.

That does not only feel a bit uncivilized, it can also be challenging in terms of cleanup work to be done: if the script can only be brute-force aborted via Ctrl+C, all cleanup work must be implemented via Finally blocks.

Not so with Test-KeyPress: thanks to its non-blocking nature, your PowerShell code stays in control and can do whatever is necessary to gracefully end whatever your code was doing.

Example: Exiting a Loop

Here is a simple “monitoring script” with an endless loop (that writes dots and primarily sleeps). When you press Shift+Ctrl+X, it stops:

Write-Warning 'Press Ctrl+Shift+K to exit monitoring!'

do
{
    Write-Host '.' -NoNewline
    $pressed = Test-KeyPress -Key K -ModifierKey 'Control,Shift'
    if ($pressed) { break }
    Start-Sleep -Seconds 1
} while ($true)

This works beautifully in the PowerShell console. In sophisticated editors such as VSCode, it seems next to impossible to find a key combination that is not yet taken by some functionality.

Using the Windows API

The console API is incompatible with ISE, so by using it to read data you make your code incompatible with the tool still used by a wide range of users. The console API isn’t playing nice with VSCode either BTW.

Plus there are functional limitations: its inability to detect isolated moderator keys like Ctrl.

The Windows API does not depend on a console window. Its method GetAsyncKeyState() is a non-blocking call that reports whether or not a given key is currently pressed. Which raises a new challenge: this method is unmanaged and not directly available in .NET.

Accessing Unmanaged Methods

To access unmanaged (native) functions via PowerShell, you use C# Code and Add-Type, and make them surface as new .NET types. This sounds more complex than it is, and the hardest part often is to figure out the signature of the unmanaged function you intend to use.

Below example illustrates how PowerShell uses GetAsyncKeyState() to test whether the key A is currently pressed:

# valid key names can be ASCII codes:
$key = [Byte][Char]'A'    
    
# this is the c# definition of a static Windows API method:
$Signature = @'
    [DllImport("user32.dll", CharSet=CharSet.Auto, ExactSpelling=true)] 
    public static extern short GetAsyncKeyState(int virtualKeyCode); 
'@

# Add-Type compiles the source code and adds the type [PsOneApi.Keyboard]:
Add-Type -MemberDefinition $Signature -Name Keyboard -Namespace PsOneApi
    
Write-Host "Press A within the next second!"
Start-Sleep -Seconds 1

# the public static method GetAsyncKeyState() is now availabe from
# within PowerShell and tests whether the key is pressed.
# Actually, one bit is reporting whether the key is pressed,
# and the result is always either 0 or 1, which can easily
# be converted to bool:
$result = [bool]([PsOneApi.Keyboard]::GetAsyncKeyState($key) -eq -32767)
$result

The code asks you to press A, then sleeps for a second to give you the chance to press that key. The result is either $true (key was pressed) or $false.

The first pleasant surprise: the code runs in any PowerShell environment: Windows PowerShell, PowerShell Core, ISE editor, or VSCode.

Defining Keys to Check

GetAsyncKeyState() treats every key the same - regular key or modifier key - and tests always for exactly one key. To test key combinations, simply call the method multiple times, once per key.

Regular keys are defined by their ASCII code, so the key code for “A” would be 65:

[Byte][Char]'A'

Case-sensitivity is achieved via combination with modifier keys such as Shift.

Special Keys

Modifier keys as well as many other special keys (like PrintScreen or CapsLock) use special codes. The easiest way to figure them out is the enumeration [System.Windows.Forms.Keys]. It is part of the Forms assembly that is not normally loaded in PowerShell.

If you wanted to adjust the code above to check for Ctrl instead of A, this is how you figure out the code for Ctrl:

Add-Type -AssemblyName System.Windows.Forms
[int][System.Windows.Forms.Keys]::ControlKey

It turns out the code for Ctrl is 17. So here is Test-ControlKey which returns $true if Ctrl is currently pressed:

function Test-ControlKey
{
  # key code for Ctrl key:
  $key = 17    
    
  # this is the c# definition of a static Windows API method:
  $Signature = @'
    [DllImport("user32.dll", CharSet=CharSet.Auto, ExactSpelling=true)] 
    public static extern short GetAsyncKeyState(int virtualKeyCode); 
'@

  Add-Type -MemberDefinition $Signature -Name Keyboard -Namespace PsOneApi
  [bool]([PsOneApi.Keyboard]::GetAsyncKeyState($key) -eq -32767)
}

Figuring out the code for special keys is the only hard part here and involves some trial&error. For example, the enumeration has values for Control and ControlKey - which one is right?

Codes for special keys are in the range of 1 - 512. Values greater than that are bit masks for other purposes.

So if you wanted to test for Alt, Alt would definitely be wrong because it yields 262,144. Then again, there is no AltKey. Since ControlKey had a value of 17, the other related modifier keys are in the same range, and this would list the names of the immediate neighbor keys:

15..20 | ForEach-Object { '{0} = {1}' -f $_, [System.Windows.Forms.Keys]$_ }

The result would be:

15 = RButton, Return
16 = ShiftKey
17 = ControlKey
18 = Menu
19 = Pause
20 = Capital

With a little testing, it turns out Alt is represented by the code 18 (called Menu).

Forget Forms: No Dependencies!

System.Windows.Forms was invented to create user interfaces. It has nothing to do with GetAsyncKeyState(). Apparently, some Forms developer just thought it would be a nice idea to have a enumeration that can look up key codes for special keys.

Do not load the Forms assembly in your production code (unless you want to create user interfaces based on Forms of course). Instead, use [System.Windows.Forms.Keys] during development only to find out key codes, and use these integers in your production code.

Here is a small function called Test-AltKey which runs completely without dependencies to Forms and returns $true when Alt is pressed:

function Test-AltKey
{
  # key code for ALT key:
  $key = 18    
    
  # this is the c# definition of a static Windows API method:
  $Signature = @'
    [DllImport("user32.dll", CharSet=CharSet.Auto, ExactSpelling=true)] 
    public static extern short GetAsyncKeyState(int virtualKeyCode); 
'@

  Add-Type -MemberDefinition $Signature -Name Keyboard -Namespace PsOneApi
  [bool]([PsOneApi.Keyboard]::GetAsyncKeyState($key) -eq -32767)
}

Test-AltKey and the earlier Test-ControlKey are almost identical. In fact, the testing code is always the same. You simply adjust the key codes to test.

If you replaced the key code by LButton (code 1), you can even wait for the left mouse button to be clicked.

And this is some code to test-drive the function: it runs an endless loop and exits once you press Alt:

Write-Warning 'Press ALT to exit monitoring!'

do
{
    Write-Host '.' -NoNewline
    $pressed = Test-AltKey
    if ($pressed) { break }
    Start-Sleep -Seconds 1
} while ($true)

You can use the tolook up the codes you need.

Table of Key Codes

The console API shares the same key codes with the Windows API. In the table below, you find the key names both for [ConsoleKey] and [Windows.Forms.Keys]. These names differ slightly, but the codes are identical.

As you see, the console API and [ConsoleKey] has no equivalents for modifier keys like Alt, Control, and Shift, as well as other function keys such as CapsLock and NumLock, and Mouse Keys and scroll wheel, because the console API cannot test isolated key presses for special keys.

Key Code ConsoleKey Equivalent
Add 107 Add
AltKey (missing in enumeration) 18 -
Apps 93 Applications
Attn 246 Attention
Back 8 Backspace
BrowserBack 166 BrowserBack
BrowserFavorites 171 BrowserFavorites
BrowserForward 167 BrowserForward
BrowserHome 172 BrowserHome
BrowserRefresh 168 BrowserRefresh
BrowserSearch 170 BrowserSearch
BrowserStop 169 BrowserStop
Cancel 3 -
Capital 20 -
CapsLock 20 -
Clear 12 Clear
ControlKey 17 -
Crsel 247 Crsel
D0 48 D0
D1 49 D1
D2 50 D2
D3 51 D3
D4 52 D4
D5 53 D5
D6 54 D6
D7 55 D7
D8 56 D8
D9 57 D9
Decimal 110 Decimal
Delete 46 Delete
Divide 111 Divide
Down 40 DownArrow
End 35 End
Enter 13 Enter
EraseEof 249 EraseEndOfFile
Escape 27 Escape
Execute 43 Execute
Exsel 248 Exsel
F 70 F
F1 112 F1
F2 113 F2
F3 114 F3
F4 115 F4
F5 116 F5
F6 117 F6
F7 118 F7
F8 119 F8
F9 120 F9
F10 121 F10
F11 122 F11
F12 123 F12
F13 124 F13
F14 125 F14
F15 126 F15
F16 127 F16
F17 128 F17
F18 129 F18
F19 130 F19
F20 131 F20
F21 132 F21
F22 133 F22
F23 134 F23
F24 135 F24
LaunchApplication1 182 LaunchApp1
LaunchApplication2 183 LaunchApp2
LaunchMail 180 LaunchMail
LButton 1 -
LControlKey 162 -
Left 37 LeftArrow
LineFeed 10 -
LMenu 164 -
LShiftKey 160 -
LWin 91 LeftWindows
MButton 4 -
MediaNextTrack 176 MediaNext
MediaPlayPause 179 MediaPlay
MediaPreviousTrack 177 MediaPrevious
MediaStop 178 MediaStop
Menu 18 -
Multiply 106 Multiply
Next 34 PageDown
NoName 252 NoName
None 0 -
NumLock 144 -
NumPad0 96 NumPad0
NumPad1 97 NumPad1
NumPad2 98 NumPad2
NumPad3 99 NumPad3
NumPad4 100 NumPad4
NumPad5 101 NumPad5
NumPad6 102 NumPad6
NumPad7 103 NumPad7
NumPad8 104 NumPad8
NumPad9 105 NumPad9
Oem1 186 Oem1
Oem102 226 Oem102
Oem2 191 Oem2
Oem3 192 Oem3
Oem4 219 Oem4
Oem5 220 Oem5
Oem6 221 Oem6
Oem7 222 Oem7
Oem8 223 Oem8
OemBackslash 226 Oem102
OemClear 254 OemClear
OemCloseBrackets 221 Oem6
Oemcomma 188 OemComma
OemMinus 189 OemMinus
OemOpenBrackets 219 Oem4
OemPeriod 190 OemPeriod
OemPipe 220 Oem5
Oemplus 187 OemPlus
OemQuestion 191 Oem2
OemQuotes 222 Oem7
OemSemicolon 186 Oem1
Oemtilde 192 Oem3
Pa1 253 Pa1
Packet 231 Packet
PageDown 34 PageDown
PageUp 33 PageUp
Pause 19 Pause
Play 250 Play
Print 42 Print
PrintScreen 44 PrintScreen
Prior 33 PageUp
ProcessKey 229 Process
RButton 2 -
RControlKey 163 -
Return 13 Enter
Right 39 RightArrow
RMenu 165 -
RShiftKey 161 -
RWin 92 RightWindows
Scroll 145 -
Select 41 Select
SelectMedia 181 LaunchMediaSelect
Separator 108 Separator
ShiftKey 16 -
Sleep 95 Sleep
Snapshot 44 PrintScreen
Space 32 SpaceBar
Subtract 109 Subtract
Tab 9 Tab
Up 38 UpArrow
VolumeDown 174 VolumeDown
VolumeMute 173 VolumeMute
VolumeUp 175 VolumeUp
XButton1 5 -
XButton2 6 -
Zoom 251 Zoom

Test-PsOneKeyPress

It is simple to write functions that detect pressing a modifier key like Shift or Ctrl. It is always the same testing code. You just exchange the key code for the key you want to test. So why not parameterize this, and use one function to test any key. Even key combinations. Test-PsOneKeyPress does just that.

The challenge for a function like this is not the code required to test the keys. The true challenge is to provide meaningful parameters so users can easily specify the key(s) they want to monitor or test without having to look up cryptic key codes first.

Considerations

I wanted to avoid any reference to System.Windows.Forms to avoid having to load many megabytes of assembly code. As you learned above, the majority of keys listed in [System.Windows.Forms.Keys] can also be found in [ConsoleKeys], and while the names occasionally slightly differ, their key codes are the same:

# [System.Windows.Forms.Keys] requires a huge assembly to be loaded
$types = Add-Type -AssemblyName System.Windows.Forms -PassThru

# all exported types come from the same assembly:
$types.Assembly | Sort-Object -Unique

# this assembly is huge (6MB):
Get-Item -Path ($types.Assembly | Sort-Object -Unique).Location

# [System.ConsoleKey] uses the very same key codes and is loaded by default:
[int][Windows.Forms.Keys]::LeftArrow -eq [int][ConsoleKey]::Left

How to Submit Keys

So I implemented parameter -Key as an array of [ConsoleKeys].

I added missing modifier and special keys via a second parameter -SpecialKey and made this a string array. A hashtable translates the strings to key codes.

“Hey, why aren’t you using your own custom enumeration for -SpecialKey?”

Because I wanted to make sure Test-PsOneKeyPress is a robust and stand-alone function. This conflicts with the use of enumerations. Whenever a parameter uses a enumeration type, this type must be implemented before the function can run.

In essence, enumerations add dependencies. If you plan to move the code to a module, enumerations are the way to go since modules can implement all required enumerations when they load.

Built-In Waiting

A good number of use-cases will want to use Test-PsOneKeyPress to wait until the keys have been pressed. I decided to add the parameters -Wait and -TimeOut to directly support this. A final parameter -ShowProgress draws a simple progress bar.

Implementation

Here is the final Test-PsOneKeyPress:


function Test-PSOneKeyPress
{
  <#
      .SYNOPSIS
      Tests whether keys are currently pressed

      .DESCRIPTION
      Returns $true when ALL of the submitted keys are currently pressed.
      Uses API calls and does not rely on the console. It works in all PowerShell Hosts
      including ISE and VSCode/EditorServices        

      .EXAMPLE
      Test-PsOneKeyPress -Key A,B -SpecialKey Control,ShiftLeft -Wait
      returns once the keys A, B, Control and left Shift were simultaneously pressed

      .EXAMPLE
      Test-PsOneKeyPress -SpecialKey Control -Wait -Timeout 00:00:05 -ShowProgress
      returns once the keys A, B, Control and left Shift were simultaneously pressed

      .EXAMPLE
      Test-PSOneKeyPress -Key Escape -Timeout '00:00:20' -Wait -ShowProgress
      wait for user to press ESC, and timeout after 20 seconds

      .EXAMPLE
      Test-PSOneKeyPress -Key H -SpecialKey Alt,Shift -Wait -ShowProgress
      wait for Alt+Shift+H

      .LINK
      https://powershell.one
  #>
    
  [CmdletBinding(DefaultParameterSetName='test')]
  param
  (
    # regular key, can be a comma-separated list
    [Parameter(ParameterSetName='wait')]
    [Parameter(ParameterSetName='test')]
    [ConsoleKey[]]
    $Key = $null,

    # special key, can be a comma-separated list
    [Parameter(ParameterSetName='wait')]
    [Parameter(ParameterSetName='test')]
    [ValidateSet('Alt','CapsLock','Control','ControlLeft','ControlRight','LeftMouseButton','MiddleMouseButton', 'RightMouseButton','NumLock','Shift','ShiftLeft','ShiftRight','MouseWheel')]
    [string[]]
    $SpecialKey = $null,
    
    # waits for the key combination to be pressed
    [Parameter(Mandatory,ParameterSetName='wait')]
    [switch]
    $Wait,
    
    # timeout (timespan) for the key combination to be pressed
    [Parameter(ParameterSetName='wait')]
    [Timespan]
    $Timeout=[Timespan]::Zero,
    
    # show progress
    [Parameter(ParameterSetName='wait')]
    [Switch]
    $ShowProgress
  )
    
  # at least one key is mandatory:
  if (($Key.Count + $SpecialKey.Count) -lt 1)
  {
    throw "No key specified."
  }
  # use a hashtable to translate string values to integers
  # this could have also been done using a enumeration
  # however if a parameter is using a enumeration as type,
  # the enumeration must be defined before the function
  # can be called. 
  # My goal was to create a hassle-free stand-alone function,
  # so enumerations were no option
  $converter = @{
    Shift = 16
    ShiftLeft = 160
    ShiftRight = 161
    Control = 17
    Alt = 18
    CapsLock = 20
    ControlLeft = 162
    ControlRight = 163
    LeftMouseButton = 1
    RightMouseButton = 2
    MiddleMouseButton = 4
    MouseWheel = 145
    NumLock = 144
  }

  # create an array with ALL keys from BOTH groups
    
  # start with an integer list of regular keys 
  if ($Key.Count -gt 0)
  {
    $list = [System.Collections.Generic.List[int]]$Key.value__
  }
  else
  {
    $list = [System.Collections.Generic.List[int]]::new()
  }
  # add codes for all special characters
  foreach($_ in $SpecialKey)
  {
    $list.Add($converter[$_])
  }
  # $list now is a list of all key codes for all keys to test
    
  # access the windows api
  $Signature = @'
    [DllImport("user32.dll", CharSet=CharSet.Auto, ExactSpelling=true)] 
    public static extern short GetAsyncKeyState(int virtualKeyCode); 
'@

  # Add-Type compiles the source code and adds the type [PsOneApi.Keyboard]:
  Add-Type -MemberDefinition $Signature -Name Keyboard -Namespace PsOneApi
    
  # was -Wait specified?
  $isNoWait = $PSCmdlet.ParameterSetName -ne 'wait'
  
  # do we need to watch a timeout?
  $hasTimeout = ($Timeout -ne [Timespan]::Zero) -and ($isNoWait -eq $false)
  if ($hasTimeout)
  {
    $stopWatch = [System.Diagnostics.Stopwatch]::StartNew()
  }  
  
  # use a try..finally to clean up
  try
  {
    # use a counter
    $c = 0
    
    # if -Wait was specified, the loop repeats until
    # either the keys were pressed or the timeout is exceeded
    # else, the loop runs only once
    do
    {
      # increment counter
      $c++
      
      # test each key in $list. If any key returns $false, the total result is $false:
      foreach ($_ in $list)
      {
          $pressed = [bool]([PsOneApi.Keyboard]::GetAsyncKeyState($_) -eq -32767)
          # if there is a key NOT pressed, we can skip the rest and bail out 
          # because ALL keys need to be pressed
          if (!$pressed) { break }
      }
    
      # is the timeout exceeded?
      if ($hasTimeout)
      {
        if ($stopWatch.Elapsed -gt $Timeout)
        {
          throw "Waiting for keypress timed out."
        }
      }
      
      # show progress indicator? if so, only every second
      if ($ShowProgress -and ($c % 2 -eq 0))
      {
        Write-Host '.' -NoNewline
      }
      # if the keys were not pressed and the function waits for the keys,
      # sleep a little:
      if (!$isNoWait -and !$pressed)
      {
        Start-Sleep -Milliseconds 500
      }
    } until ($pressed -or $isNoWait)
  
    # if this is just checking the key states, return the result:
    if ($isNoWait)
    {
      return $pressed
    }
  }
  finally
  {
    if ($hasTimeout)
    {
      $stopWatch.Stop()    
    }
    if ($ShowProgress)
    {
      Write-Host
    }
  }
}

There are a number of learning points in the code that I discuss below, together with some example code:

How Keys are Collected

The main challenge for Test-PsOneKeyPress is to get a list of key codes from the user, and make it as simple as possible for the user to specify the keys. The user can submit keys via two parameters:

  • -Key: an array of type [ConsoleKey[]], can be empty
  • -SpecialKey: an array of type [string[]], can also be empty

This example waits for Alt+Shift+H and shows a progress bar meanwhile:

Test-PSOneKeyPress -Key H -SpecialKey Alt,Shift -Wait -ShowProgress

Converting Enumeration Items to Integer Lists

If the user specified -Key, the list of console keys needs to be translated to the actual key codes. Each enumeration item has a hidden property value__ which returns the underlying integer value. These values are then converted to a strongly typed List (which is most efficient when adding new items later):

$list = [System.Collections.Generic.List[int]]$Key.value__

Converting FriendlyText to Integer Codes

Now the special keys submitted in -SpecialKey need to be added to $list. To do that, the strings in $SpecialKey need to be translated into the integer key codes. I use a hashtable to translate:

foreach($_ in $SpecialKey)
{
  $list.Add($converter[$_])
}

How Keys Are Tested

In $list, there are any number of key codes to test. GetAsyncKeyState() can test only one key at a time, so all codes in $list need to be tested in a loop. The result is $true only when they all are pressed.

# test each key in $list. If any key returns $false, the total result is $false:
foreach ($_ in $list)
{
    $pressed = [bool]([PsOneApi.Keyboard]::GetAsyncKeyState($_) -eq -32767)
    # if there is a key NOT pressed, we can skip the rest and bail out 
    # because ALL keys need to be pressed
    if (!$pressed) { break }
}

Waiting and Timeout

If the user specifies -Wait, the function checks key state as long as either the requested keys are pressed or a timeout kicks in. The timeout is defined as TimeSpan. To see whether the timeout is reached, the function initializes a stopwatch at the beginning:

$stopWatch = [System.Diagnostics.Stopwatch]::StartNew()

In the monitoring loop, it repeatedly checks whether the elapsed timespan is greater than the specified timeout:

if ($stopWatch.Elapsed -gt $Timeout)
{
  throw "Waiting for keypress timed out."
}

Timeouts can be easily specified as string. This would wait for Esc, show a progress bar, and time out after 20 seconds:

Test-PSOneKeyPress -Key Escape -Timeout '00:00:20' -Wait -ShowProgress

When you do not specify -Wait, the function returns immediately so you can integrate it into your own loops:

Write-Warning 'Press CapsLock to exit!'

do
{
  Write-Host '.' -NoNewline

  $pressed = Test-PSOneKeyPress -SpecialKey CapsLock
  if ($pressed) { break }
  
  Start-Sleep -Seconds 1
  
} while ($true)

Conclusions

The console API is simple to use but has at least one k.o. criterion: if you want to keep your PowerShell code compatible to all major hosts, you cannot use the console api to read. Your code would fail in ISE and yield unexpected results in VSCode.

Using GetAsyncKeyState() is much more versatile, and also allows to test for pressing isolated modifier keys, mouse keys, and a number of other special keys.

As soon as you start testing for key combinations, you need to call GetAsyncKeyState() multiple times, once for each key you want to test. If you’re waiting for the key presses, you need to do this in a loop. Testing keys too frequently wastes CPU cycles, but even worse: you may no longer pick up the key presses you are after. Let’s find out why.

Understanding GetAsyncKeyState()

Here is a simple endless loop that endlessly tests whether A is pressed:

$key = [Byte][Char]'A'    
$Signature = @'
    [DllImport("user32.dll", CharSet=CharSet.Auto, ExactSpelling=true)] 
    public static extern short GetAsyncKeyState(int virtualKeyCode); 
'@
Add-Type -MemberDefinition $Signature -Name Keyboard -Namespace PsOneApi
do
{
  [bool]([PsOneApi.Keyboard]::GetAsyncKeyState($key) -eq -32767)
  Start-Sleep -Milliseconds 100
  
} while($true)

When you run this code, it emits a long row of False. When you now start holding A, you see a single True, then a number of False, and then a long list of True again until you release A.

The first True was produced by your A keypress. Even though you kept pressing the key, GetAsyncKeyState() only detected the key once, and returned False afterwards.

Because you kept holding A, after a short while Windows enabled the Repeat Mode and started to actively produce more A keypresses, each of which was again picked up by GetAsyncKeyState().

Or in essence: GetAsyncKeyState() picks up each pressed key only once per press.

Detecting Multiple Keys

This provides a challenge when you want to test multiple keys, and use a short testing interval: during the first interval, the user may have managed to press the first couple keys (but not yet all). GetAsyncKeyState() would correctly pick these keys up.

By the time the user finally made it and pressed all keys, your code may run a new loop iteration. GetAsyncKeyState() will now pick up the new keys but won’t recognize the already-picked up keys from the last iteration.

So if you are checking the key combination in fast intervals, you would also need to implement some caching mechanism that remembers key presses from previous iterations.

PowerShell Conference EU 2020

If you enjoy stuff like this, make sure you have PowerShell Conference EU 2020 on your plate, either as delegate or as a speaker. See you there!