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 asCtrl+Shift+U
, but you cannot detect whetherCtrl
and/orShift
is 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 earlierTest-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 |
42 | ||
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!