Introduction
When handling keyboard input on Windows, a VirtualKey received from the OnKeyDown event does not directly represent an actual character.
The resulting character depends on the current keyboard layout, modifier keys, and input language.
In this article, I’ll show how I tried to map a VirtualKey to a character in .NET — and why the obvious solution fails for some keyboard layouts (notably Czech).
The naïve approach
My initial goal was simple: convert a VirtualKey to a character while respecting the current keyboard layout.
My first attempt looked like this:
// !!! DO NOT USE THIS CODE, see text below !!!
using System.Runtime.InteropServices;
[DllImport("user32.dll")]
private static extern int ToUnicode(
uint wVirtKey, uint wScanCode,
byte[] lpKeyState,
System.Text.StringBuilder pwszBuff,
int cchBuff, uint wFlags);
[DllImport("user32.dll")]
private static extern bool GetKeyboardState(byte[] lpKeyState);
[DllImport("user32.dll")]
private static extern uint MapVirtualKey(uint uCode, uint uMapType);
private string? VirtualKeyToString(Windows.System.VirtualKey key)
{
var keyState = new byte[256];
GetKeyboardState(keyState);
uint scanCode = MapVirtualKey((uint)key, 0);
var sb = new System.Text.StringBuilder(5);
int result = ToUnicode((uint)key, scanCode, keyState, sb, sb.Capacity, 0);
return result > 0 ? sb.ToString() : null;
}
At first glance, this code looks correct:
- it uses ToUnicode
- it respects the current keyboard state
- it works for most layouts
…and indeed, it works perfectly for US keyboard layout.
The problem
On the Czech keyboard layout, certain keys produced incorrect characters.
Even worse:
- no error was reported
- some keys returned unexpected results (for example key é produces expected Unicode character U+00E9 but key ě return two control characters U+001B, U+0001)
After a lot of debugging and testing, I discovered that the issue was not related to scan codes or layouts — but to the managed buffer.
➡️ StringBuilder is the problem here.
The real issue: managed buffers and ToUnicode
ToUnicode / ToUnicodeEx expect a raw, unmanaged UTF‑16 buffer.
While StringBuilder works in many cases, it turns out that:
- its internal memory layout
- and how it is marshaled
can cause incorrect results for some keyboard layouts and dead‑key combinations.
The Win32 API documentation subtly hints at this, but doesn’t warn you clearly enough.
The correct (defensive) solution
The final solution I use:
- create the keyboard state manually
- resolve the keyboard layout explicitly
- allocate and pass an unmanaged buffer
- use
ToUnicodeEx
Here is the final working solution:
[DllImport("user32.dll")]
private static extern short GetKeyState(int nVirtKey);
[DllImport("user32.dll")]
private static extern IntPtr GetKeyboardLayout(uint idThread);
[DllImport("user32.dll")]
private static extern uint GetWindowThreadProcessId(IntPtr hWnd, IntPtr lpdwProcessId);
[DllImport("user32.dll")]
private static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll")]
private static extern uint MapVirtualKeyEx(uint uCode, uint uMapType, IntPtr dwhkl);
[DllImport("user32.dll", EntryPoint = "ToUnicodeEx")]
private static extern int ToUnicodeExPtr(
uint wVirtKey, uint wScanCode,
byte[] lpKeyState,
IntPtr pwszBuff,
int cchBuff, uint wFlags,
IntPtr dwhkl);
private static string? VirtualKeyToString(Windows.System.VirtualKey key)
{
// Build keyboard state explicitly
var keyState = new byte[256];
for (int i = 0; i < 256; i++)
{
short state = GetKeyState(i);
keyState[i] = (byte)(state < 0 ? 0x80 : 0);
}
// Resolve keyboard layout explicitly
IntPtr hwnd = GetForegroundWindow();
uint threadId = GetWindowThreadProcessId(hwnd, IntPtr.Zero);
IntPtr layout = GetKeyboardLayout(threadId);
uint scanCode = MapVirtualKeyEx((uint)key, 0, layout);
IntPtr buffer = Marshal.AllocHGlobal(32); // The final trick
try
{
int result = ToUnicodeExPtr((uint)key, scanCode, keyState, buffer, 8, 0, layout);
return result > 0 ? Marshal.PtrToStringUni(buffer, result) : null;
}
finally
{
Marshal.FreeHGlobal(buffer);
}
}
Why this works
✅ avoids StringBuilder
✅ uses an unmanaged UTF‑16 buffer
✅ works reliably for Czech keyboard layout
Yes, it’s verbose.
Yes, it looks defensive.
But it is the most reliable solution I found that works for me.
Final notes
If you only target US keyboards, the naïve solution might be “good enough”.
But if your application:
- runs globally
- handles raw keyboard input
- or needs to be layout‑correct
➡️ use the unmanaged buffer approach.
Top comments (0)