Skip to content

Move keybinding dispatching off e.keyCode #17521

@alexdima

Description

@alexdima

Intro

VS Code dispatches keyboard shorcuts to commands by listening to keydown events.

At the time VS Code was first released, we shipped with Chromium version 45. It did not support KeyboardEvent.key, nor KeyboardEvent.code (see the current spec here).

It supported KeyboardEvent.keyCode, KeyboardEvent.charCode, KeyboardEvent.which and KeyboardEvent.keyIdentifier:

  • KeyboardEvent.charCode is often not set for keydown events, therefore unreliable.
  • KeyboardEvent.which was, from my testing, always equal to KeyboardEvent.keyCode.
  • KeyboardEvent.keyIdentifier was known to be severely flawed already at that time, and it was marked as deprecated (it got removed in Chromium version 54).
  • we were therefore stuck with using KeyboardEvent.keyCode for dispatching keyboard shortcuts.

What is wrong with KeyboardEvent.keyCode and why is it deprecated?

The w3c spec is dry (which is a good thing in general, maybe not in this case), and does not explain what went wrong or why it is now deprecated.
In this section, after having browsed through Chromium's source code, I will try to explain what I think is wrong with it.

To understand the shortcomings of KeyboardEvent.keyCode, we must first understand what its value means and where it comes from. The only reasonable explanation I could find is that it must be rooted in how Windows does keyboard input.

I've found a good explanation about keyboard input in Windows here.

Assigned to each key on a keyboard is a unique value called a scan code, a device-dependent identifier for the key on the keyboard. A keyboard generates two scan codes when the user types a key—one when the user presses the key and another when the user releases the key.

The keyboard device driver interprets a scan code and translates (maps) it to a virtual-key code, a device-independent value defined by the system that identifies the purpose of a key.

... and a hint to how keyboard layouts work:

A keyboard layout not only specifies the physical position of the keys on the keyboard but also determines the character values generated by pressing those keys. Each layout identifies the current input language and determines which character values are generated by which keys and key combinations.

So a keyboard layout on Windows consists of two mappings. The first one maps scan codes to virtual keys and the second one maps virtual keys and modifiers combinations to generated characters. The list of Virtual Key Codes is defined here.

Since this is pretty abstract, let's pick an example where we compare the US standard keyboard layout with the GER (Germany) keyboard layout, as it can exemplify both mappings:

Standard US GER (Germany)
image image
Physical Key Scan Code US layout GER layout Notes
Key Code Char Key Code Char
Y 21 89 (0x59) y 90 (0x5A) z The first mapping (scan code -> key code) is changed, and the second mapping (key code -> character) is identical.
Z 44 90 (0x5A) z 89 (0x59) y
7 8 55 (0x37) 7 55 (0x37) 7 The first mapping (scan code -> key code) is identical, and the second mapping (key code -> character) is changed.
Shift+7 8 55 (0x37) & 55 (0x37) /

Turns out the indirection through Virtual Key Codes is quite helpful, e.g. one can write an application and simply look for Ctrl+0x5A (i.e. Ctrl+Z) keydown events and this will work as expected on keyboards where keys are moved around, e.g. QWERTZ or AZERTY keyboards, etc.

By looking for KeyboardCodeFromNative in Chromium's sources we can confirm that under Windows, KeyboardEvent.keyCode is simply equal to the Windows Virtual Key Code:

KeyboardCode KeyboardCodeFromNative(const base::NativeEvent& native_event) {
  return KeyboardCodeForWindowsKeyCode(static_cast<WORD>(native_event.wParam));
}
// ...
KeyboardCode KeyboardCodeForWindowsKeyCode(WORD keycode) {
  return static_cast<KeyboardCode>(keycode);
}

The problem: Linux and Mac

AFAICT keyboard input and keyboard layouts on Linux and Mac do not work through this double indirection (from scan code to key code, and from key code and modifiers to character), apparently keyboard input on Linux and Mac goes straight from scan code and modifiers to characters.

This raises the question, what then is the value of KeyboardEvent.keyCode on Linux and Mac?

By looking for the Linux and Mac equivalents of KeyboardCodeForWindowsKeyCode (i.e. KeyboardCodeFromXKeyEvent and KeyboardCodeFromNSEvent) we can conclude that nobody really knows.

On Linux:

// Get an ui::KeyboardCode from an X keyevent
KeyboardCode KeyboardCodeFromXKeyEvent(const XEvent* xev) {
  // Gets correct VKEY code from XEvent is performed as the following steps:
  // 1. Gets the keysym without modifier states.
  // 2. For [a-z] & [0-9] cases, returns the VKEY code accordingly.
  // 3. Find keysym in map0.
  // 4. If not found, fallback to find keysym + hardware_code in map1.
  // 5. If not found, fallback to find keysym + keysym_shift + hardware_code
  //    in map2.
  // 6. If not found, fallback to find keysym + keysym_shift + keysym_altgr +
  //    hardware_code in map3.
  // 7. If not found, fallback to find in KeyboardCodeFromXKeysym(), which
  //    mainly for non-letter keys.
  // 8. If not found, fallback to find with the hardware code in US layout.
  // ...
}

On Mac:

KeyboardCode KeyboardCodeFromNSEvent(NSEvent* event) {
  // ...
  NSString* characters = [event characters];
  if ([characters length] > 0)
    code = KeyboardCodeFromCharCode([characters characterAtIndex:0]);
  // ...
  characters = [event charactersIgnoringModifiers];
  if ([characters length] > 0)
    code = KeyboardCodeFromCharCode([characters characterAtIndex:0]);
  // ...
}

// Translates from character code to keyboard code.
KeyboardCode KeyboardCodeFromCharCode(unichar charCode) {
  switch (charCode) {
    // ...
    // U.S. Specific mappings.  Mileage may vary.
    case ';': case ':': return VKEY_OEM_1;
    case '=': case '+': return VKEY_OEM_PLUS;
    case ',': case '<': return VKEY_OEM_COMMA;
    case '-': case '_': return VKEY_OEM_MINUS;
    case '.': case '>': return VKEY_OEM_PERIOD;
    case '/': case '?': return VKEY_OEM_2;
    case '`': case '~': return VKEY_OEM_3;
    case '[': case '{': return VKEY_OEM_4;
    case '\\': case '|': return VKEY_OEM_5;
    case ']': case '}': return VKEY_OEM_6;
    case '\'': case '"': return VKEY_OEM_7;
  }

  return VKEY_UNKNOWN;
}

The way KeyboardEvent.keyCode is computed leads to some weird situations, such as the one documented in issue #1302:

Given the German (Swiss) keyboard layout, ... it is very difficult to figure out what actual physical key was pressed. E.g.:

  • when pressing 7, the keydown event we receive has keyCode:55
  • this is OK
  • when pressing Shift+7, the keydown we receive has shiftKey:true and keyCode: 191
  • this is unexpected, we should receive shiftKey:true and keyCode:55

Here are the values observed in KeyboardEvent:

Physical Key US layout Win US layout Mac GER layout Win GER layout Mac
Y
shift: false
keyCode: 89
shift: false
keyCode: 89
shift: false
keyCode: 90
shift: false
keyCode: 90
Z
shift: false
keyCode: 90
shift: false
keyCode: 90
shift: false
keyCode: 89
shift: false
keyCode: 89
7
shift: false
keyCode: 55
shift: false
keyCode: 55
shift: false
keyCode: 55
shift: false
keyCode: 55
Shift+7
shift: true
keyCode: 55
shift: true
keyCode: 55
shift: true
keyCode: 55
shift: true
keyCode: 191

This is probably the case from above where "mileage may vary". We have tried to workaround this on our side, but the workaround is limited and only functions for a subset of these key codes (i.e. does not work, perhaps it is even making things worse for DVORAK).

Issues that possibly all share the same root cause (the fuzzyness of keyCode on Linux and Mac and/or our workaround not working or making things worse):

The path forward

We can now use KeyboardEvent.code and KeyboardEvent.key (see the current spec here). The simplified explanation is that KeyboardEvent.code is a string representation of the scan code, and KeyboardEvent.key is the produced character (skipping entirely the Virtual Keys hop on Windows). This has a chance to work correctly because all Operating Systems have these concepts.

Physical Key US layout Win US layout Mac GER layout Win GER layout Mac
Y
shift: false
code: "KeyY"
key: "y"
shift: false
code: "KeyY"
key: "y"
shift: false
code: "KeyY"
key: "z"
shift: false
code: "KeyY"
key: "z"
Z
shift: false
code: "KeyZ"
key: "z"
shift: false
code: "KeyZ"
key: "z"
shift: false
code: "KeyZ"
key: "y"
shift: false
code: "KeyZ"
key: "y"
7
shift: false
code: "Digit7"
key: "7"
shift: false
code: "Digit7"
key: "7"
shift: false
code: "Digit7"
key: "7"
shift: false
code: "Digit7"
key: "7"
Shift+7
shift: true
code: "Digit7"
key: "&"
shift: true
code: "Digit7"
key: "&"
shift: true
code: "Digit7"
key: "/"
shift: true
code: "Digit7"
key: "/"

Metadata

Metadata

Assignees

Labels

feature-requestRequest for new features or functionality

Type

No type

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions