@theia/core 1.77.0-next.56 → 1.77.0-next.57

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/lib/browser/catalog.json +66 -0
  2. package/lib/browser/common-frontend-contribution.d.ts +6 -1
  3. package/lib/browser/common-frontend-contribution.d.ts.map +1 -1
  4. package/lib/browser/common-frontend-contribution.js +18 -1
  5. package/lib/browser/common-frontend-contribution.js.map +1 -1
  6. package/lib/browser/keybinding.d.ts +95 -17
  7. package/lib/browser/keybinding.d.ts.map +1 -1
  8. package/lib/browser/keybinding.js +405 -75
  9. package/lib/browser/keybinding.js.map +1 -1
  10. package/lib/browser/keybinding.spec.js +786 -30
  11. package/lib/browser/keybinding.spec.js.map +1 -1
  12. package/lib/browser/keyboard/browser-keyboard-frontend-contribution.js +1 -1
  13. package/lib/browser/keyboard/browser-keyboard-frontend-contribution.js.map +1 -1
  14. package/lib/browser/keyboard/browser-keyboard-layout-provider.d.ts +3 -4
  15. package/lib/browser/keyboard/browser-keyboard-layout-provider.d.ts.map +1 -1
  16. package/lib/browser/keyboard/browser-keyboard-layout-provider.js +3 -3
  17. package/lib/browser/keyboard/browser-keyboard-layout-provider.js.map +1 -1
  18. package/lib/browser/keyboard/browser-keyboard-layout-provider.spec.js +39 -0
  19. package/lib/browser/keyboard/browser-keyboard-layout-provider.spec.js.map +1 -1
  20. package/lib/browser/keyboard/keyboard-layout-service.d.ts +67 -15
  21. package/lib/browser/keyboard/keyboard-layout-service.d.ts.map +1 -1
  22. package/lib/browser/keyboard/keyboard-layout-service.js +309 -98
  23. package/lib/browser/keyboard/keyboard-layout-service.js.map +1 -1
  24. package/lib/browser/keyboard/keyboard-layout-service.spec.js +538 -12
  25. package/lib/browser/keyboard/keyboard-layout-service.spec.js.map +1 -1
  26. package/lib/browser/keyboard/keys.spec.js +183 -0
  27. package/lib/browser/keyboard/keys.spec.js.map +1 -1
  28. package/lib/browser/menu/action-menu-node.d.ts +5 -3
  29. package/lib/browser/menu/action-menu-node.d.ts.map +1 -1
  30. package/lib/browser/menu/action-menu-node.js +2 -11
  31. package/lib/browser/menu/action-menu-node.js.map +1 -1
  32. package/lib/browser/menu/action-menu-node.spec.d.ts +2 -0
  33. package/lib/browser/menu/action-menu-node.spec.d.ts.map +1 -0
  34. package/lib/browser/menu/action-menu-node.spec.js +54 -0
  35. package/lib/browser/menu/action-menu-node.spec.js.map +1 -0
  36. package/lib/browser/menu/browser-menu-plugin.js +1 -1
  37. package/lib/browser/menu/browser-menu-plugin.js.map +1 -1
  38. package/lib/browser/test/jsdom.d.ts.map +1 -1
  39. package/lib/browser/test/jsdom.js +11 -0
  40. package/lib/browser/test/jsdom.js.map +1 -1
  41. package/lib/common/keyboard/keyboard-layout-provider.d.ts +7 -0
  42. package/lib/common/keyboard/keyboard-layout-provider.d.ts.map +1 -1
  43. package/lib/common/keyboard/keyboard-layout-provider.js.map +1 -1
  44. package/lib/common/keys.d.ts +86 -3
  45. package/lib/common/keys.d.ts.map +1 -1
  46. package/lib/common/keys.js +262 -23
  47. package/lib/common/keys.js.map +1 -1
  48. package/lib/common/ternary-search-tree.js +2 -2
  49. package/lib/common/ternary-search-tree.js.map +1 -1
  50. package/lib/common/ternary-search-tree.spec.js +12 -0
  51. package/lib/common/ternary-search-tree.spec.js.map +1 -1
  52. package/lib/electron-browser/keyboard/electron-keyboard-module.d.ts +2 -0
  53. package/lib/electron-browser/keyboard/electron-keyboard-module.d.ts.map +1 -1
  54. package/lib/electron-browser/keyboard/electron-keyboard-module.js +8 -1
  55. package/lib/electron-browser/keyboard/electron-keyboard-module.js.map +1 -1
  56. package/lib/electron-browser/keyboard/electron-keyboard-module.spec.d.ts +2 -0
  57. package/lib/electron-browser/keyboard/electron-keyboard-module.spec.d.ts.map +1 -0
  58. package/lib/electron-browser/keyboard/electron-keyboard-module.spec.js +28 -0
  59. package/lib/electron-browser/keyboard/electron-keyboard-module.spec.js.map +1 -0
  60. package/lib/electron-browser/menu/electron-main-menu-factory.d.ts.map +1 -1
  61. package/lib/electron-browser/menu/electron-main-menu-factory.js +3 -2
  62. package/lib/electron-browser/menu/electron-main-menu-factory.js.map +1 -1
  63. package/lib/electron-browser/menu/electron-menu-contribution.d.ts.map +1 -1
  64. package/lib/electron-browser/menu/electron-menu-contribution.js +3 -0
  65. package/lib/electron-browser/menu/electron-menu-contribution.js.map +1 -1
  66. package/lib/electron-common/electron-api.d.ts +5 -0
  67. package/lib/electron-common/electron-api.d.ts.map +1 -1
  68. package/lib/electron-common/electron-api.js +21 -0
  69. package/lib/electron-common/electron-api.js.map +1 -1
  70. package/lib/electron-common/electron-api.spec.d.ts +2 -0
  71. package/lib/electron-common/electron-api.spec.d.ts.map +1 -0
  72. package/lib/electron-common/electron-api.spec.js +40 -0
  73. package/lib/electron-common/electron-api.spec.js.map +1 -0
  74. package/lib/electron-main/electron-api-main.d.ts +0 -1
  75. package/lib/electron-main/electron-api-main.d.ts.map +1 -1
  76. package/lib/electron-main/electron-api-main.js +1 -12
  77. package/lib/electron-main/electron-api-main.js.map +1 -1
  78. package/package.json +4 -4
  79. package/src/browser/common-frontend-contribution.ts +16 -0
  80. package/src/browser/keybinding.spec.ts +878 -34
  81. package/src/browser/keybinding.ts +470 -85
  82. package/src/browser/keyboard/browser-keyboard-frontend-contribution.ts +1 -1
  83. package/src/browser/keyboard/browser-keyboard-layout-provider.spec.ts +52 -1
  84. package/src/browser/keyboard/browser-keyboard-layout-provider.ts +5 -5
  85. package/src/browser/keyboard/keyboard-layout-service.spec.ts +640 -15
  86. package/src/browser/keyboard/keyboard-layout-service.ts +358 -106
  87. package/src/browser/keyboard/keys.spec.ts +206 -1
  88. package/src/browser/menu/action-menu-node.spec.ts +60 -0
  89. package/src/browser/menu/action-menu-node.ts +8 -14
  90. package/src/browser/menu/browser-menu-plugin.ts +1 -1
  91. package/src/browser/test/jsdom.ts +11 -0
  92. package/src/common/keyboard/keyboard-layout-provider.ts +8 -0
  93. package/src/common/keys.ts +311 -26
  94. package/src/common/ternary-search-tree.spec.ts +14 -0
  95. package/src/common/ternary-search-tree.ts +2 -2
  96. package/src/electron-browser/keyboard/electron-keyboard-module.spec.ts +29 -0
  97. package/src/electron-browser/keyboard/electron-keyboard-module.ts +9 -2
  98. package/src/electron-browser/menu/electron-main-menu-factory.ts +3 -3
  99. package/src/electron-browser/menu/electron-menu-contribution.ts +4 -0
  100. package/src/electron-common/electron-api.spec.ts +42 -0
  101. package/src/electron-common/electron-api.ts +23 -0
  102. package/src/electron-main/electron-api-main.ts +2 -13
@@ -75,7 +75,7 @@ export namespace KeySequence {
75
75
 
76
76
  export function parse(keybinding: string): KeySequence {
77
77
  const keyCodes = [];
78
- const rawKeyCodes = keybinding.trim().split(/\s+/g);
78
+ const rawKeyCodes = splitKeySequence(keybinding.trim());
79
79
  for (const rawKeyCode of rawKeyCodes) {
80
80
  const keyCode = KeyCode.parse(rawKeyCode);
81
81
  if (keyCode !== undefined) {
@@ -97,6 +97,35 @@ export interface Keystroke {
97
97
  readonly modifiers?: KeyModifier[];
98
98
  }
99
99
 
100
+ /**
101
+ * The keyboard-layout modifier layer used to produce a logical character, ordered by resolution cost.
102
+ */
103
+ export type LayoutModifiers = 'none' | 'shift' | 'altGraph' | 'shiftAltGraph';
104
+
105
+ export function layoutModifiersIncludeShift(layoutModifiers: LayoutModifiers): boolean {
106
+ return layoutModifiers === 'shift' || layoutModifiers === 'shiftAltGraph';
107
+ }
108
+
109
+ export function layoutModifiersIncludeAltGraph(layoutModifiers: LayoutModifiers): boolean {
110
+ return layoutModifiers === 'altGraph' || layoutModifiers === 'shiftAltGraph';
111
+ }
112
+
113
+ export function isPrintableKey(key: Key): boolean {
114
+ return isPrintableCharacter(key.easyString);
115
+ }
116
+
117
+ export function isPrintableCharacter(character: string | undefined): character is string {
118
+ return !!character && character !== ' ' && isSingleCharacter(character);
119
+ }
120
+
121
+ /**
122
+ * Identifies how a key code was derived for runtime matching.
123
+ * `authored` is the constructor default and represents an authored binding, even for manually constructed instances.
124
+ * `commandModifiers` preserves the event's modifiers as command modifiers, while `layoutModifiers` attributes them to
125
+ * the keyboard layout's character-producing layer.
126
+ */
127
+ export type KeyCodeInterpretation = 'authored' | 'commandModifiers' | 'layoutModifiers';
128
+
100
129
  export interface KeyCodeSchema {
101
130
  key?: Partial<Key>;
102
131
  ctrl?: boolean;
@@ -104,6 +133,180 @@ export interface KeyCodeSchema {
104
133
  alt?: boolean;
105
134
  meta?: boolean;
106
135
  character?: string;
136
+ layoutModifiers?: LayoutModifiers;
137
+ interpretation?: KeyCodeInterpretation;
138
+ supportedByLayout?: boolean;
139
+ authoredToken?: string;
140
+ physical?: boolean;
141
+ }
142
+
143
+ /**
144
+ * Plain keyboard input data used by keybinding dispatch and diagnostics.
145
+ */
146
+ export interface NormalizedKeyboardInput {
147
+ key?: string;
148
+ code?: string;
149
+ keyCode?: number;
150
+ charCode?: number;
151
+ ctrlKey?: boolean;
152
+ shiftKey?: boolean;
153
+ altKey?: boolean;
154
+ metaKey?: boolean;
155
+ altGraph?: boolean;
156
+ repeat?: boolean;
157
+ isComposing?: boolean;
158
+ location?: number;
159
+ timeStamp?: number;
160
+ keyIdentifier?: string;
161
+ getModifierState?(keyArg: string): boolean;
162
+ }
163
+
164
+ /**
165
+ * Read AltGraph state, preferring explicitly transported state over native event state.
166
+ */
167
+ export function readAltGraph(input: NormalizedKeyboardInput): boolean {
168
+ if (input.altGraph !== undefined) {
169
+ return input.altGraph;
170
+ }
171
+ return input.getModifierState?.('AltGraph') ?? false;
172
+ }
173
+
174
+ /**
175
+ * Copy native or transported keyboard input into a normalized, transport-independent representation.
176
+ * The parameter and result share this type because a native `KeyboardEvent` structurally satisfies the permissive input
177
+ * shape. The returned data always has AltGraph resolved even though the field is optional in the input type.
178
+ * `getModifierState` is retained for local diagnostics only and must not be relied on after transport.
179
+ */
180
+ export function normalizeKeyboardInput(input: NormalizedKeyboardInput): NormalizedKeyboardInput {
181
+ return {
182
+ key: input.key,
183
+ code: input.code,
184
+ keyCode: input.keyCode,
185
+ charCode: input.charCode,
186
+ ctrlKey: input.ctrlKey,
187
+ shiftKey: input.shiftKey,
188
+ altKey: input.altKey,
189
+ metaKey: input.metaKey,
190
+ altGraph: readAltGraph(input),
191
+ repeat: input.repeat,
192
+ isComposing: input.isComposing,
193
+ location: input.location,
194
+ timeStamp: input.timeStamp,
195
+ keyIdentifier: input.keyIdentifier,
196
+ getModifierState: input.getModifierState?.bind(input)
197
+ };
198
+ }
199
+
200
+ function splitKeySequence(keybinding: string): string[] {
201
+ const result: string[] = [];
202
+ let current = '';
203
+ let bracketed = false;
204
+ for (let index = 0; index < keybinding.length; index++) {
205
+ const character = keybinding[index];
206
+ if (character === '[') {
207
+ const closingBracket = keybinding.indexOf(']', index + 1);
208
+ const nextWhitespace = keybinding.slice(index + 1).search(/\s/);
209
+ bracketed = closingBracket !== -1 && (nextWhitespace === -1 || closingBracket < index + 1 + nextWhitespace);
210
+ } else if (character === ']' && bracketed) {
211
+ bracketed = false;
212
+ }
213
+ if (/\s/.test(character) && !bracketed) {
214
+ if (current) {
215
+ result.push(current);
216
+ current = '';
217
+ }
218
+ } else {
219
+ current += character;
220
+ }
221
+ }
222
+ if (current) {
223
+ result.push(current);
224
+ }
225
+ return result;
226
+ }
227
+
228
+ function splitKeyCode(keybinding: string): string[] {
229
+ const result: string[] = [];
230
+ let current = '';
231
+ let bracketed = false;
232
+ for (let index = 0; index < keybinding.length; index++) {
233
+ const character = keybinding[index];
234
+ if (character === '[' && keybinding.indexOf(']', index + 1) !== -1) {
235
+ bracketed = true;
236
+ } else if (character === ']' && bracketed) {
237
+ bracketed = false;
238
+ }
239
+ if (!bracketed && current && (character === '+' || character === '-')) {
240
+ result.push(current);
241
+ current = '';
242
+ } else if (!bracketed && character === '+') {
243
+ continue;
244
+ } else {
245
+ current += character;
246
+ }
247
+ }
248
+ if (current) {
249
+ result.push(current);
250
+ }
251
+ return result;
252
+ }
253
+
254
+ function parseScanCode(token: string): Key | undefined {
255
+ const match = /^\[([^\]]+)\]$/.exec(token);
256
+ if (!match || match[1].toLocaleLowerCase().startsWith('char:')) {
257
+ return undefined;
258
+ }
259
+ const key = Key.getKey(match[1]) ?? Object.values(CODE_TO_KEY).find(candidate => candidate.code.toLocaleLowerCase() === match[1].toLocaleLowerCase());
260
+ if (!key) {
261
+ throw new Error(nls.localize('theia/core/keybinding/unknownScanCode', 'Unknown scan code {0}. Use a supported KeyboardEvent.code name.', match[1]));
262
+ }
263
+ return key;
264
+ }
265
+
266
+ /** Parse a logical-character token, rejecting bare grammar delimiters. */
267
+ function parseCharacterToken(token: string): string | undefined {
268
+ const match = /^\[char:(.*)\]$/i.exec(token);
269
+ if (match) {
270
+ const payload = match[1];
271
+ let character: string;
272
+ if (/^0x[0-9a-f]+$/i.test(payload)) {
273
+ const value = Number.parseInt(payload.slice(2), 16);
274
+ if (value > 0x10ffff || value >= 0xd800 && value <= 0xdfff) {
275
+ throw new Error(nls.localize('theia/core/keybinding/invalidCharacter', 'Invalid Unicode character {0}.', payload));
276
+ }
277
+ character = String.fromCodePoint(value);
278
+ } else {
279
+ character = payload;
280
+ }
281
+ if (!isSingleCharacter(character) || !/^0x/i.test(payload) && RESERVED_BARE_CHARACTERS.has(character)) {
282
+ throw new Error(nls.localize('theia/core/keybinding/invalidCharacter', 'Invalid Unicode character {0}.', payload));
283
+ }
284
+ return character;
285
+ }
286
+ return isSingleCharacter(token) && !RESERVED_BARE_CHARACTERS.has(token) ? token : undefined;
287
+ }
288
+
289
+ function isSingleCharacter(value: string): boolean {
290
+ return Array.from(value).length === 1;
291
+ }
292
+
293
+ /** Characters reserved by the keybinding grammar and therefore unavailable as bare logical-character tokens. */
294
+ const RESERVED_BARE_CHARACTERS = new Set([' ', '+', '-', '[', ']']);
295
+
296
+ /**
297
+ * Serialize a logical character for the keybinding grammar.
298
+ * `+` and `-` are excluded from legacy bare spelling because they delimit key-code tokens. Uppercase ASCII letters are
299
+ * escaped so their case is not lost by case-insensitive legacy key lookup.
300
+ */
301
+ export function characterToken(character: string): string {
302
+ if (!isSingleCharacter(character)) {
303
+ throw new Error(nls.localize('theia/core/keybinding/invalidCharacter', 'Invalid Unicode character {0}.', character));
304
+ }
305
+ const legacyCharacter = !!EASY_TO_KEY[character] && character !== '+' && character !== '-';
306
+ const requiresEscape = /^[A-Z]$/.test(character) || RESERVED_BARE_CHARACTERS.has(character) && !legacyCharacter;
307
+ return requiresEscape
308
+ ? `[char:0x${character.codePointAt(0)!.toString(16).toUpperCase()}]`
309
+ : character;
107
310
  }
108
311
 
109
312
  /**
@@ -117,6 +320,13 @@ export class KeyCode {
117
320
  public readonly alt: boolean;
118
321
  public readonly meta: boolean;
119
322
  public readonly character: string | undefined;
323
+ public readonly layoutModifiers: LayoutModifiers;
324
+ public readonly interpretation: KeyCodeInterpretation;
325
+ /** Whether the authored logical character is available on the active keyboard layout. */
326
+ public readonly supportedByLayout: boolean;
327
+ public readonly authoredToken: string | undefined;
328
+ /** Whether the authored token denotes a physical scan code rather than a logical character. */
329
+ public readonly physical: boolean;
120
330
 
121
331
  public constructor(schema: KeyCodeSchema) {
122
332
  const key = schema.key;
@@ -134,13 +344,18 @@ export class KeyCode {
134
344
  this.alt = !!schema.alt;
135
345
  this.meta = !!schema.meta;
136
346
  this.character = schema.character;
347
+ this.layoutModifiers = schema.layoutModifiers ?? 'none';
348
+ this.interpretation = schema.interpretation ?? 'authored';
349
+ this.supportedByLayout = schema.supportedByLayout !== false;
350
+ this.authoredToken = schema.authoredToken;
351
+ this.physical = !!schema.physical;
137
352
  }
138
353
 
139
354
  /**
140
355
  * Return true if this KeyCode only contains modifiers.
141
356
  */
142
357
  public isModifierOnly(): boolean {
143
- return this.key === undefined;
358
+ return this.key === undefined && this.character === undefined;
144
359
  }
145
360
 
146
361
  /**
@@ -150,11 +365,11 @@ export class KeyCode {
150
365
  if (this.key && (!other.key || this.key.code !== other.key.code) || !this.key && other.key) {
151
366
  return false;
152
367
  }
153
- return this.ctrl === other.ctrl && this.alt === other.alt && this.shift === other.shift && this.meta === other.meta;
368
+ return this.dispatchString() === other.dispatchString();
154
369
  }
155
370
 
156
- /*
157
- * Return a keybinding string compatible with the `Keybinding.keybinding` property.
371
+ /**
372
+ * Return a keybinding string that round-trips the authored spelling through {@link authoredToken}.
158
373
  */
159
374
  toString(): string {
160
375
  const result = [];
@@ -170,8 +385,69 @@ export class KeyCode {
170
385
  if (this.ctrl) {
171
386
  result.push(Key.CONTROL_LEFT.easyString);
172
387
  }
388
+ if (this.authoredToken) {
389
+ result.push(this.authoredToken);
390
+ } else if (this.key) {
391
+ result.push(this.key.easyString);
392
+ }
393
+ return result.join('+');
394
+ }
395
+
396
+ /**
397
+ * Return the runtime match identity used for keybinding dispatch.
398
+ * The interpretation is diagnostic metadata and does not affect this identity.
399
+ */
400
+ dispatchString(): string {
401
+ const result: string[] = [];
402
+ if (this.meta) {
403
+ result.push(SpecialCases.META);
404
+ }
405
+ if (this.shift) {
406
+ result.push(Key.SHIFT_LEFT.easyString);
407
+ }
408
+ if (this.alt) {
409
+ result.push(Key.ALT_LEFT.easyString);
410
+ }
411
+ if (this.ctrl) {
412
+ result.push(Key.CONTROL_LEFT.easyString);
413
+ }
173
414
  if (this.key) {
174
415
  result.push(this.key.easyString);
416
+ } else if (this.character) {
417
+ result.push(characterToken(this.character));
418
+ }
419
+ const suffix = this.layoutModifiers === 'shift' ? '@shift'
420
+ : this.layoutModifiers === 'altGraph' ? '@altgraph'
421
+ : this.layoutModifiers === 'shiftAltGraph' ? '@shift@altgraph' : '';
422
+ return result.join('+') + suffix;
423
+ }
424
+
425
+ /**
426
+ * Derive an authored keybinding stroke from a key event for recorder persistence.
427
+ * This cannot use {@link toString}: event-derived key codes have no {@link authoredToken}, so `toString` would persist
428
+ * the physical US key name instead of the committed logical character. Non-printable keys, Space and numpad keys are
429
+ * persisted by name; printable keys without a committed character are persisted as scan codes.
430
+ */
431
+ toAuthoredKeybindingString(): string {
432
+ const result: string[] = [];
433
+ if (this.meta) {
434
+ result.push(SpecialCases.META);
435
+ }
436
+ const character = isPrintableCharacter(this.character) && !this.key?.code.startsWith('Numpad') ? this.character : undefined;
437
+ const shiftedLetter = !!character && /^[A-Z]$/.test(character);
438
+ if (this.shift && (!character || shiftedLetter)) {
439
+ result.push(Key.SHIFT_LEFT.easyString);
440
+ }
441
+ if (this.alt) {
442
+ result.push(Key.ALT_LEFT.easyString);
443
+ }
444
+ if (this.ctrl) {
445
+ result.push(Key.CONTROL_LEFT.easyString);
446
+ }
447
+ if (character) {
448
+ result.push(characterToken(shiftedLetter ? character.toLocaleLowerCase() : character));
449
+ } else if (this.key) {
450
+ result.push(isPrintableKey(this.key) ? `[${this.key.code}]` : this.key.easyString);
175
451
  }
176
452
  return result.join('+');
177
453
  }
@@ -179,7 +455,7 @@ export class KeyCode {
179
455
  /**
180
456
  * Create a KeyCode from one of several input types.
181
457
  */
182
- public static createKeyCode(input: KeyboardEvent | Keystroke | KeyCodeSchema | string, eventDispatch: 'code' | 'keyCode' = 'code'): KeyCode {
458
+ public static createKeyCode(input: KeyboardEvent | NormalizedKeyboardInput | Keystroke | KeyCodeSchema | string, eventDispatch: 'code' | 'keyCode' = 'code'): KeyCode {
183
459
  if (typeof input === 'string') {
184
460
  const parts = input.split('+');
185
461
  if (!KeyCode.isModifierString(parts[0])) {
@@ -189,7 +465,7 @@ export class KeyCode {
189
465
  });
190
466
  }
191
467
  return KeyCode.createKeyCode({ modifiers: parts as KeyModifier[] });
192
- } else if (KeyCode.isKeyboardEvent(input)) {
468
+ } else if (KeyCode.isKeyboardEvent(input) || KeyCode.isNormalizedKeyboardInput(input)) {
193
469
  const key = KeyCode.toKey(input, eventDispatch);
194
470
  return new KeyCode({
195
471
  key: Key.isModifier(key.code) ? undefined : key,
@@ -238,25 +514,14 @@ export class KeyCode {
238
514
  }
239
515
 
240
516
  const schema: KeyCodeSchema = {};
241
- const keys = [];
242
- let currentKey = '';
243
- for (const character of keybinding.trim().toLowerCase()) {
244
- if (currentKey && (character === '-' || character === '+')) {
245
- keys.push(currentKey);
246
- currentKey = '';
247
- } else if (character !== '+') {
248
- currentKey += character;
249
- }
250
- }
251
- if (currentKey) {
252
- keys.push(currentKey);
253
- }
517
+ const keys = splitKeyCode(keybinding.trim());
254
518
  /* If duplicates i.e ctrl+ctrl+a or alt+alt+b or b+alt+b it is invalid */
255
- if (keys.length !== new Set(keys).size) {
519
+ if (keys.length !== new Set(keys.map(key => key.toLocaleLowerCase())).size) {
256
520
  throw new Error(nls.localize('theia/core/keybinding/duplicateModifierError', "Can't parse keybinding {0} Duplicate modifiers", keybinding));
257
521
  }
258
522
 
259
- for (let keyString of keys) {
523
+ for (const token of keys) {
524
+ let keyString = token.toLocaleLowerCase();
260
525
  if (SPECIAL_ALIASES[keyString] !== undefined) {
261
526
  keyString = SPECIAL_ALIASES[keyString];
262
527
  }
@@ -289,7 +554,20 @@ export class KeyCode {
289
554
  schema.key = key;
290
555
  }
291
556
  } else {
292
- throw new Error(nls.localize('theia/core/keybinding/unrecognizedKeyError', 'Unrecognized key {0} in {1}', keyString, keybinding));
557
+ const scanCode = parseScanCode(token);
558
+ if (scanCode) {
559
+ schema.key = scanCode;
560
+ schema.authoredToken = `[${scanCode.code}]`;
561
+ schema.physical = true;
562
+ continue;
563
+ }
564
+ const character = parseCharacterToken(token);
565
+ if (character) {
566
+ schema.character = character;
567
+ schema.authoredToken = characterToken(character);
568
+ continue;
569
+ }
570
+ throw new Error(nls.localize('theia/core/keybinding/unrecognizedKeyError', 'Unrecognized key {0} in {1}', token, keybinding));
293
571
  }
294
572
  }
295
573
 
@@ -339,6 +617,13 @@ export namespace KeyCode {
339
617
  return false;
340
618
  }
341
619
 
620
+ /**
621
+ * Return true if the given object carries normalized keyboard input fields.
622
+ */
623
+ export function isNormalizedKeyboardInput(input: object): input is NormalizedKeyboardInput {
624
+ return 'code' in input || 'keyCode' in input || 'charCode' in input || 'keyIdentifier' in input;
625
+ }
626
+
342
627
  /**
343
628
  * Determine the pressed key of a keyboard event. This key should correspond to the physical key according
344
629
  * to a standard US keyboard layout. International keyboard layouts are handled by `KeyboardLayoutService`.
@@ -346,7 +631,7 @@ export namespace KeyCode {
346
631
  * `keyIdentifier` is used to access this deprecated field:
347
632
  * https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/keyIdentifier
348
633
  */
349
- export function toKey(event: KeyboardEvent, dispatch: 'code' | 'keyCode' = 'code'): Key {
634
+ export function toKey(event: NormalizedKeyboardInput, dispatch: 'code' | 'keyCode' = 'code'): Key {
350
635
  const code = event.code;
351
636
  if (code && dispatch === 'code') {
352
637
  if (isOSX) {
@@ -382,7 +667,7 @@ export namespace KeyCode {
382
667
  }
383
668
  }
384
669
 
385
- const keyIdentifier = (event as KeyboardEvent & { keyIdentifier?: string }).keyIdentifier;
670
+ const keyIdentifier = event.keyIdentifier;
386
671
  if (keyIdentifier) {
387
672
  const key = Key.getKey(keyIdentifier);
388
673
  if (key) {
@@ -397,7 +682,7 @@ export namespace KeyCode {
397
682
  * If the key does not correspond to a printable character, `undefined` is returned.
398
683
  * The result may be altered by modifier keys.
399
684
  */
400
- export function toCharacter(event: KeyboardEvent): string | undefined {
685
+ export function toCharacter(event: NormalizedKeyboardInput): string | undefined {
401
686
  const key = event.key;
402
687
  // Use the key property if it contains exactly one unicode character
403
688
  if (key && Array.from(key).length === 1) {
@@ -15,6 +15,7 @@
15
15
  // *****************************************************************************
16
16
 
17
17
  import { expect } from 'chai';
18
+ import * as sinon from 'sinon';
18
19
  import { TernarySearchTree, KeySequenceIterator } from './ternary-search-tree';
19
20
  import { KeyCode, KeySequence, Key, KeyModifier } from './keys';
20
21
 
@@ -69,6 +70,19 @@ describe('KeySequenceIterator', () => {
69
70
  // Different value should return non-zero
70
71
  expect(iterator.cmp('different')).to.not.equal(0);
71
72
  });
73
+
74
+ it('should use dispatch identity for values and comparisons', () => {
75
+ const keyCode = KeyCode.createKeyCode({ first: Key.KEY_A, modifiers: [KeyModifier.CtrlCmd] });
76
+ const dispatchString = sinon.stub(keyCode, 'dispatchString').returns('dispatch-a');
77
+ const toString = sinon.stub(keyCode, 'toString').returns('serialized-a');
78
+
79
+ iterator.reset([keyCode]);
80
+
81
+ expect(iterator.value()).to.equal('dispatch-a');
82
+ expect(iterator.cmp('dispatch-a')).to.equal(0);
83
+ expect(dispatchString.called).to.be.true;
84
+ expect(toString.called).to.be.false;
85
+ });
72
86
  });
73
87
 
74
88
  describe('TernarySearchTree for KeySequences', () => {
@@ -208,12 +208,12 @@ export class KeySequenceIterator implements IKeyIterator<KeySequence> {
208
208
  }
209
209
 
210
210
  cmp(a: string): number {
211
- const current = this._value[this._index]?.toString() ?? '';
211
+ const current = this._value[this._index]?.dispatchString() ?? '';
212
212
  return compare(a, current);
213
213
  }
214
214
 
215
215
  value(): string {
216
- return this._value[this._index]?.toString() ?? '';
216
+ return this._value[this._index]?.dispatchString() ?? '';
217
217
  }
218
218
  }
219
219
 
@@ -0,0 +1,29 @@
1
+ // *****************************************************************************
2
+ // Copyright (C) 2026 EclipseSource and others.
3
+ //
4
+ // This program and the accompanying materials are made available under the
5
+ // terms of the Eclipse Public License v. 2.0 which is available at
6
+ // http://www.eclipse.org/legal/epl-2.0.
7
+ //
8
+ // This Source Code may also be made available under the following Secondary
9
+ // Licenses when the conditions for such availability set forth in the Eclipse
10
+ // Public License v. 2.0 are satisfied: GNU General Public License, version 2
11
+ // with the GNU Classpath Exception which is available at
12
+ // https://www.gnu.org/software/classpath/license.html.
13
+ //
14
+ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
15
+ // *****************************************************************************
16
+
17
+ import { expect } from 'chai';
18
+ import { NativeKeyboardLayout } from '../../common/keyboard/keyboard-layout-provider';
19
+ import { createElectronKeyboardLayoutProvider } from './electron-keyboard-module';
20
+
21
+ describe('Electron keyboard layout provider', () => {
22
+ it('reports the native-keymap source and delegates layout retrieval', async () => {
23
+ const layout: NativeKeyboardLayout = { info: { id: 'test', lang: 'en' }, mapping: {} };
24
+ const provider = createElectronKeyboardLayoutProvider({ getNativeLayout: async () => layout });
25
+
26
+ expect(provider.layoutSource).to.equal('native-keymap');
27
+ expect(await provider.getNativeLayout()).to.equal(layout);
28
+ });
29
+ });
@@ -19,10 +19,17 @@ import { KeyboardLayoutProvider, keyboardPath, KeyboardLayoutChangeNotifier } fr
19
19
  import { WebSocketConnectionProvider } from '../../browser/messaging/ws-connection-provider';
20
20
  import { ElectronKeyboardLayoutChangeNotifier } from './electron-keyboard-layout-change-notifier';
21
21
 
22
+ export function createElectronKeyboardLayoutProvider(delegate: KeyboardLayoutProvider): KeyboardLayoutProvider {
23
+ return {
24
+ layoutSource: 'native-keymap',
25
+ getNativeLayout: () => delegate.getNativeLayout()
26
+ };
27
+ }
28
+
22
29
  export default new ContainerModule((bind, unbind, isBound, rebind) => {
23
- bind(KeyboardLayoutProvider).toDynamicValue(ctx =>
30
+ bind(KeyboardLayoutProvider).toDynamicValue(ctx => createElectronKeyboardLayoutProvider(
24
31
  WebSocketConnectionProvider.createLocalProxy<KeyboardLayoutProvider>(ctx.container, keyboardPath)
25
- ).inSingletonScope();
32
+ )).inSingletonScope();
26
33
  bind(ElectronKeyboardLayoutChangeNotifier).toSelf().inSingletonScope();
27
34
  bind(KeyboardLayoutChangeNotifier).toService(ElectronKeyboardLayoutChangeNotifier);
28
35
  });
@@ -22,7 +22,7 @@ import { CommonCommands } from '../../browser';
22
22
  import debounce = require('lodash.debounce');
23
23
  import { BrowserMainMenuFactory } from '../../browser/menu/browser-menu-plugin';
24
24
  import { ContextMatcher } from '../../browser/context-key-service';
25
- import { MenuDto, MenuRole } from '../../electron-common/electron-api';
25
+ import { electronMenuAcceleratorMetadata, MenuDto, MenuRole } from '../../electron-common/electron-api';
26
26
 
27
27
  /**
28
28
  * Representation of possible electron menu options.
@@ -249,7 +249,7 @@ export class ElectronMainMenuFactory extends BrowserMainMenuFactory {
249
249
  return parentItems;
250
250
  }
251
251
 
252
- const accelerator = AcceleratorSource.is(menu) ? menu.getAccelerator(options.context).join(' ') : undefined;
252
+ const acceleratorSequence = AcceleratorSource.is(menu) ? menu.getAccelerator(options.context, 'physical') : [];
253
253
 
254
254
  const menuItem: MenuDto = {
255
255
  id: menu.id,
@@ -258,7 +258,7 @@ export class ElectronMainMenuFactory extends BrowserMainMenuFactory {
258
258
  checked: menu.isToggled(effectivePath, ...args),
259
259
  enabled: !honorDisabled || menu.isEnabled(effectivePath, ...args), // see https://github.com/eclipse-theia/theia/issues/446
260
260
  visible: true,
261
- accelerator,
261
+ ...electronMenuAcceleratorMetadata(acceleratorSequence),
262
262
  execute: async () => {
263
263
  const wasToggled = menuItem.checked;
264
264
  await menu.run(effectivePath, ...args);
@@ -362,6 +362,10 @@ export class ElectronMenuContribution extends BrowserMenuBarContribution impleme
362
362
  command: ElectronCommands.RELOAD.id,
363
363
  keybinding: 'ctrlcmd+r'
364
364
  },
365
+ {
366
+ command: ElectronCommands.ZOOM_IN.id,
367
+ keybinding: 'ctrlcmd+[char:0x2B]'
368
+ },
365
369
  {
366
370
  command: ElectronCommands.ZOOM_IN.id,
367
371
  keybinding: 'ctrlcmd+='
@@ -0,0 +1,42 @@
1
+ // *****************************************************************************
2
+ // Copyright (C) 2026 EclipseSource and others.
3
+ //
4
+ // This program and the accompanying materials are made available under the
5
+ // terms of the Eclipse Public License v. 2.0 which is available at
6
+ // http://www.eclipse.org/legal/epl-2.0.
7
+ //
8
+ // This Source Code may also be made available under the following Secondary
9
+ // Licenses when the conditions for such availability set forth in the Eclipse
10
+ // Public License v. 2.0 are satisfied: GNU General Public License, version 2
11
+ // with the GNU Classpath Exception which is available at
12
+ // https://www.gnu.org/software/classpath/license.html.
13
+ //
14
+ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
15
+ // *****************************************************************************
16
+
17
+ import { expect } from 'chai';
18
+ import { electronMenuAccelerator, electronMenuAcceleratorMetadata, isElectronAcceleratorRepresentable } from './electron-api';
19
+
20
+ describe('Electron menu accelerators', () => {
21
+ it('accepts only ASCII accelerators without AltGr', () => {
22
+ expect(isElectronAcceleratorRepresentable('Ctrl+8')).to.be.true;
23
+ expect(isElectronAcceleratorRepresentable('Ctrl+AltGr+8')).to.be.false;
24
+ expect(isElectronAcceleratorRepresentable('Ctrl+ä')).to.be.false;
25
+ expect(isElectronAcceleratorRepresentable('Ctrl++')).to.be.false;
26
+ expect(isElectronAcceleratorRepresentable('Ctrl+K Ctrl+S')).to.be.false;
27
+ });
28
+
29
+ it('forwards renderer accelerator text without precomputing representability', () => {
30
+ expect(electronMenuAcceleratorMetadata(['Ctrl+8'])).to.deep.equal({ accelerator: 'Ctrl+8' });
31
+ expect(electronMenuAcceleratorMetadata(['Ctrl+K', 'Ctrl+S'])).to.deep.equal({ accelerator: 'Ctrl+K Ctrl+S' });
32
+ });
33
+
34
+ it('omits unrepresentable accelerators', () => {
35
+ expect(electronMenuAccelerator({ accelerator: 'Ctrl+AltGr+8' })).to.deep.equal({});
36
+ expect(electronMenuAccelerator({ accelerator: 'Ctrl+K Ctrl+S' })).to.deep.equal({});
37
+ });
38
+
39
+ it('returns representable accelerators', () => {
40
+ expect(electronMenuAccelerator({ accelerator: 'Ctrl+8' })).to.deep.equal({ accelerator: 'Ctrl+8' });
41
+ });
42
+ });
@@ -22,6 +22,29 @@ import { LaunchArguments } from '../common/launch-arguments';
22
22
 
23
23
  export type MenuRole = ('undo' | 'redo' | 'cut' | 'copy' | 'paste' | 'selectAll' | 'about' | 'services' | 'hide' | 'hideOthers' | 'unhide' | 'quit');
24
24
 
25
+ export function isElectronAcceleratorRepresentable(accelerator: string | undefined): accelerator is string {
26
+ if (!accelerator || /altgr/i.test(accelerator) || /\s/.test(accelerator) || accelerator.split('+').some(token => !token)) {
27
+ return false;
28
+ }
29
+ for (let i = 0; i < accelerator.length; i++) {
30
+ if (accelerator.charCodeAt(i) > 127) {
31
+ return false;
32
+ }
33
+ }
34
+ return true;
35
+ }
36
+
37
+ export type ElectronMenuAccelerator = Pick<MenuDto, 'accelerator'>;
38
+
39
+ export function electronMenuAcceleratorMetadata(acceleratorSequence: string[]): ElectronMenuAccelerator {
40
+ return { accelerator: acceleratorSequence.join(' ') || undefined };
41
+ }
42
+
43
+ /** Return an Electron accelerator only when the rendered single chord is representable. */
44
+ export function electronMenuAccelerator(dto: ElectronMenuAccelerator): ElectronMenuAccelerator {
45
+ return isElectronAcceleratorRepresentable(dto.accelerator) ? { accelerator: dto.accelerator } : {};
46
+ }
47
+
25
48
  export interface MenuDto {
26
49
  id?: string,
27
50
  label?: string,