DEV Community

Tomas Kouba
Tomas Kouba

Posted on

Mapping Windows Virtual Keys to actual characters in .NET

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;
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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)