From c0007301c123c85887d2dee93995aa13e7735377 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Breitbart?= Date: Sat, 16 May 2026 18:08:10 +0200 Subject: [PATCH] multiple changes: - layout detector - browser helper to create layout maps - DE, ES, GR, RU, US maps --- fixtures/keyboard_layout.html | 270 ++++++++++++++++ .../{DE_QWERTZ.json => DE.json} | 8 +- fixtures/keyboard_layouts/ES.json | 50 +++ fixtures/keyboard_layouts/ES_LA.json | 50 +++ .../{FR_AZERTY.json => FR.json} | 20 +- fixtures/keyboard_layouts/GR.json | 50 +++ fixtures/keyboard_layouts/RU.json | 50 +++ .../{US_QWERTY.json => US.json} | 0 src/common/input/Layout.ts | 293 ++++++++++++++++++ 9 files changed, 777 insertions(+), 14 deletions(-) create mode 100644 fixtures/keyboard_layout.html rename fixtures/keyboard_layouts/{DE_QWERTZ.json => DE.json} (88%) create mode 100644 fixtures/keyboard_layouts/ES.json create mode 100644 fixtures/keyboard_layouts/ES_LA.json rename fixtures/keyboard_layouts/{FR_AZERTY.json => FR.json} (75%) create mode 100644 fixtures/keyboard_layouts/GR.json create mode 100644 fixtures/keyboard_layouts/RU.json rename fixtures/keyboard_layouts/{US_QWERTY.json => US.json} (100%) create mode 100644 src/common/input/Layout.ts diff --git a/fixtures/keyboard_layout.html b/fixtures/keyboard_layout.html new file mode 100644 index 000000000..9ac068863 --- /dev/null +++ b/fixtures/keyboard_layout.html @@ -0,0 +1,270 @@ + + + + + + + Keyboard Layout + + + +

Keyboard Base Layout

+

+ To record your keyboard base layout (unshifted), press all keys from left to right.
+ While you can record most keys with this helper, it is enough to press all character producing keys.

+ + Esc deletes last key except itself.
+ Enter starts new key row (Enter itself gets not shown as key). +

+
+
+ Esc +
+ +
+
+ + +
+

+    
+  
+
diff --git a/fixtures/keyboard_layouts/DE_QWERTZ.json b/fixtures/keyboard_layouts/DE.json
similarity index 88%
rename from fixtures/keyboard_layouts/DE_QWERTZ.json
rename to fixtures/keyboard_layouts/DE.json
index 9f737086e..1623cab94 100644
--- a/fixtures/keyboard_layouts/DE_QWERTZ.json
+++ b/fixtures/keyboard_layouts/DE.json
@@ -1,7 +1,7 @@
 {
   "Backquote": "^",
   "Backslash": "#",
-  "BracketLeft": "\u00fc",
+  "BracketLeft": "ü",
   "BracketRight": "+",
   "Comma": ",",
   "Digit0": "0",
@@ -42,9 +42,9 @@
   "KeyX": "x",
   "KeyY": "z",
   "KeyZ": "y",
-  "Minus": "\u00df",
+  "Minus": "ß",
   "Period": ".",
-  "Quote": "\u00e4",
-  "Semicolon": "\u00f6",
+  "Quote": "ä",
+  "Semicolon": "ö",
   "Slash": "-"
 }
\ No newline at end of file
diff --git a/fixtures/keyboard_layouts/ES.json b/fixtures/keyboard_layouts/ES.json
new file mode 100644
index 000000000..130be6d3d
--- /dev/null
+++ b/fixtures/keyboard_layouts/ES.json
@@ -0,0 +1,50 @@
+{
+  "Backquote": "º",
+  "Backslash": "ç",
+  "BracketLeft": "`",
+  "BracketRight": "+",
+  "Comma": ",",
+  "Digit0": "0",
+  "Digit1": "1",
+  "Digit2": "2",
+  "Digit3": "3",
+  "Digit4": "4",
+  "Digit5": "5",
+  "Digit6": "6",
+  "Digit7": "7",
+  "Digit8": "8",
+  "Digit9": "9",
+  "Equal": "¡",
+  "IntlBackslash": "<",
+  "KeyA": "a",
+  "KeyB": "b",
+  "KeyC": "c",
+  "KeyD": "d",
+  "KeyE": "e",
+  "KeyF": "f",
+  "KeyG": "g",
+  "KeyH": "h",
+  "KeyI": "i",
+  "KeyJ": "j",
+  "KeyK": "k",
+  "KeyL": "l",
+  "KeyM": "m",
+  "KeyN": "n",
+  "KeyO": "o",
+  "KeyP": "p",
+  "KeyQ": "q",
+  "KeyR": "r",
+  "KeyS": "s",
+  "KeyT": "t",
+  "KeyU": "u",
+  "KeyV": "v",
+  "KeyW": "w",
+  "KeyX": "x",
+  "KeyY": "y",
+  "KeyZ": "z",
+  "Minus": "'",
+  "Period": ".",
+  "Quote": "´",
+  "Semicolon": "ñ",
+  "Slash": "-"
+}
\ No newline at end of file
diff --git a/fixtures/keyboard_layouts/ES_LA.json b/fixtures/keyboard_layouts/ES_LA.json
new file mode 100644
index 000000000..7178f4355
--- /dev/null
+++ b/fixtures/keyboard_layouts/ES_LA.json
@@ -0,0 +1,50 @@
+{
+  "Backquote": "|",
+  "Backslash": "}",
+  "BracketLeft": "´",
+  "BracketRight": "+",
+  "Comma": ",",
+  "Digit0": "0",
+  "Digit1": "1",
+  "Digit2": "2",
+  "Digit3": "3",
+  "Digit4": "4",
+  "Digit5": "5",
+  "Digit6": "6",
+  "Digit7": "7",
+  "Digit8": "8",
+  "Digit9": "9",
+  "Equal": "¿",
+  "IntlBackslash": "<",
+  "KeyA": "a",
+  "KeyB": "b",
+  "KeyC": "c",
+  "KeyD": "d",
+  "KeyE": "e",
+  "KeyF": "f",
+  "KeyG": "g",
+  "KeyH": "h",
+  "KeyI": "i",
+  "KeyJ": "j",
+  "KeyK": "k",
+  "KeyL": "l",
+  "KeyM": "m",
+  "KeyN": "n",
+  "KeyO": "o",
+  "KeyP": "p",
+  "KeyQ": "q",
+  "KeyR": "r",
+  "KeyS": "s",
+  "KeyT": "t",
+  "KeyU": "u",
+  "KeyV": "v",
+  "KeyW": "w",
+  "KeyX": "x",
+  "KeyY": "y",
+  "KeyZ": "z",
+  "Minus": "'",
+  "Period": ".",
+  "Quote": "{",
+  "Semicolon": "ñ",
+  "Slash": "-"
+}
\ No newline at end of file
diff --git a/fixtures/keyboard_layouts/FR_AZERTY.json b/fixtures/keyboard_layouts/FR.json
similarity index 75%
rename from fixtures/keyboard_layouts/FR_AZERTY.json
rename to fixtures/keyboard_layouts/FR.json
index 5a5db35aa..bfde35829 100644
--- a/fixtures/keyboard_layouts/FR_AZERTY.json
+++ b/fixtures/keyboard_layouts/FR.json
@@ -1,20 +1,20 @@
 {
-  "Backquote": "\u00b2",
-  "Backslash": "μ",
+  "Backquote": "²",
+  "Backslash": "*",
   "BracketLeft": "^",
   "BracketRight": "$",
   "Comma": ";",
-  "Digit0": "\u00e0",
+  "Digit0": "à",
   "Digit1": "&",
-  "Digit2": "\u00e9",
+  "Digit2": "é",
   "Digit3": "\"",
   "Digit4": "'",
   "Digit5": "(",
-  "Digit6": "§",
-  "Digit7": "\u00e8",
-  "Digit8": "!",
+  "Digit6": "-",
+  "Digit7": "è",
+  "Digit8": "_",
   "Digit9": "ç",
-  "Equal": "-",
+  "Equal": "=",
   "IntlBackslash": "<",
   "KeyA": "q",
   "KeyB": "b",
@@ -44,7 +44,7 @@
   "KeyZ": "w",
   "Minus": ")",
   "Period": ":",
-  "Quote": "\u00f9",
+  "Quote": "ù",
   "Semicolon": "m",
-  "Slash": "="
+  "Slash": "!"
 }
\ No newline at end of file
diff --git a/fixtures/keyboard_layouts/GR.json b/fixtures/keyboard_layouts/GR.json
new file mode 100644
index 000000000..fc106276f
--- /dev/null
+++ b/fixtures/keyboard_layouts/GR.json
@@ -0,0 +1,50 @@
+{
+  "Backquote": "`",
+  "Backslash": "\\",
+  "BracketLeft": "[",
+  "BracketRight": "]",
+  "Comma": ",",
+  "Digit0": "0",
+  "Digit1": "1",
+  "Digit2": "2",
+  "Digit3": "3",
+  "Digit4": "4",
+  "Digit5": "5",
+  "Digit6": "6",
+  "Digit7": "7",
+  "Digit8": "8",
+  "Digit9": "9",
+  "Equal": "=",
+  "IntlBackslash": "«",
+  "KeyA": "α",
+  "KeyB": "β",
+  "KeyC": "ψ",
+  "KeyD": "δ",
+  "KeyE": "ε",
+  "KeyF": "φ",
+  "KeyG": "γ",
+  "KeyH": "η",
+  "KeyI": "ι",
+  "KeyJ": "ξ",
+  "KeyK": "κ",
+  "KeyL": "λ",
+  "KeyM": "μ",
+  "KeyN": "ν",
+  "KeyO": "ο",
+  "KeyP": "π",
+  "KeyQ": ";",
+  "KeyR": "ρ",
+  "KeyS": "σ",
+  "KeyT": "τ",
+  "KeyU": "θ",
+  "KeyV": "ω",
+  "KeyW": "ς",
+  "KeyX": "χ",
+  "KeyY": "υ",
+  "KeyZ": "ζ",
+  "Minus": "-",
+  "Period": ".",
+  "Quote": "'",
+  "Semicolon": "´",
+  "Slash": "/"
+}
\ No newline at end of file
diff --git a/fixtures/keyboard_layouts/RU.json b/fixtures/keyboard_layouts/RU.json
new file mode 100644
index 000000000..ef0fce478
--- /dev/null
+++ b/fixtures/keyboard_layouts/RU.json
@@ -0,0 +1,50 @@
+{
+  "Backquote": "ё",
+  "Backslash": "\\",
+  "BracketLeft": "х",
+  "BracketRight": "ъ",
+  "Comma": "б",
+  "Digit0": "0",
+  "Digit1": "1",
+  "Digit2": "2",
+  "Digit3": "3",
+  "Digit4": "4",
+  "Digit5": "5",
+  "Digit6": "6",
+  "Digit7": "7",
+  "Digit8": "8",
+  "Digit9": "9",
+  "Equal": "=",
+  "IntlBackslash": "/",
+  "KeyA": "ф",
+  "KeyB": "и",
+  "KeyC": "с",
+  "KeyD": "в",
+  "KeyE": "у",
+  "KeyF": "а",
+  "KeyG": "п",
+  "KeyH": "р",
+  "KeyI": "ш",
+  "KeyJ": "о",
+  "KeyK": "л",
+  "KeyL": "д",
+  "KeyM": "ь",
+  "KeyN": "т",
+  "KeyO": "щ",
+  "KeyP": "з",
+  "KeyQ": "й",
+  "KeyR": "к",
+  "KeyS": "ы",
+  "KeyT": "е",
+  "KeyU": "г",
+  "KeyV": "м",
+  "KeyW": "ц",
+  "KeyX": "ч",
+  "KeyY": "н",
+  "KeyZ": "я",
+  "Minus": "-",
+  "Period": "ю",
+  "Quote": "э",
+  "Semicolon": "ж",
+  "Slash": "."
+}
\ No newline at end of file
diff --git a/fixtures/keyboard_layouts/US_QWERTY.json b/fixtures/keyboard_layouts/US.json
similarity index 100%
rename from fixtures/keyboard_layouts/US_QWERTY.json
rename to fixtures/keyboard_layouts/US.json
diff --git a/src/common/input/Layout.ts b/src/common/input/Layout.ts
new file mode 100644
index 000000000..40e7e3f48
--- /dev/null
+++ b/src/common/input/Layout.ts
@@ -0,0 +1,293 @@
+/**
+ * Copyright (c) 2026 The xterm.js authors. All rights reserved.
+ * @license MIT
+ *
+ * Keyboard layout detection.
+ */
+
+
+interface IMap {
+  acc: {[index: string]: number};
+  map: {[index: string]: string | string[]};
+}
+
+interface IKeyResult {
+  layouts: string[];
+  certain: number;
+  key: string | undefined | (string | undefined)[];
+}
+
+
+/**
+ * rebuild map: node bin/keyboard_layouts.mjs fixtures/keyboard_layouts/*
+ */
+const MAP: IMap = {
+  acc: { DE: 0, ES: 1, ES_LA: 2, FR: 3, GR: 4, RU: 5, US: 6 },
+  map: {
+    Backquote: '^º|²`ё`',
+    Backslash: '#ç}*\\\\\\',
+    BracketLeft: 'ü`´^[х[',
+    BracketRight: '+++$]ъ]',
+    Comma: ',,,;,б,',
+    Digit0: '000à000',
+    Digit1: '111&111',
+    Digit2: '222é222',
+    Digit3: '333"333',
+    Digit4: "444'444",
+    Digit5: '555(555',
+    Digit6: '666-666',
+    Digit7: '777è777',
+    Digit8: '888_888',
+    Digit9: '999ç999',
+    Equal: '´¡¿====',
+    IntlBackslash: '<<<<«/\\',
+    KeyA: 'aaaqαфa',
+    KeyB: 'bbbbβиb',
+    KeyC: 'ccccψсc',
+    KeyD: 'ddddδвd',
+    KeyE: 'eeeeεуe',
+    KeyF: 'ffffφаf',
+    KeyG: 'ggggγпg',
+    KeyH: 'hhhhηрh',
+    KeyI: 'iiiiιшi',
+    KeyJ: 'jjjjξоj',
+    KeyK: 'kkkkκлk',
+    KeyL: 'llllλдl',
+    KeyM: 'mmm,μьm',
+    KeyN: 'nnnnνтn',
+    KeyO: 'ooooοщo',
+    KeyP: 'ppppπзp',
+    KeyQ: 'qqqa;йq',
+    KeyR: 'rrrrρкr',
+    KeyS: 'ssssσыs',
+    KeyT: 'ttttτеt',
+    KeyU: 'uuuuθгu',
+    KeyV: 'vvvvωмv',
+    KeyW: 'wwwzςцw',
+    KeyX: 'xxxxχчx',
+    KeyY: 'zyyyυнy',
+    KeyZ: 'yzzwζяz',
+    Minus: "ß'')---",
+    Period: '...:.ю.',
+    Quote: "ä´{ù'э'",
+    Semicolon: 'öññm´ж;',
+    Slash: '---!/./'
+  }
+};
+
+
+export class LayoutDetector {
+  private _keys: {[index: string]: number} = {};
+  private _values: (string | string[])[] = [];
+  private _rec: string[] = [];
+  private _acc: string[] = Object.keys(MAP.acc);
+  private _cand: {layout: string; match: number;}[];
+  private _key: IKeyResult;
+
+  constructor() {
+    let p = 0;
+    for (const key of Object.keys(MAP.map)) {
+      this._keys[key] = p++;
+      this._values.push(MAP.map[key]);
+      this._rec.push('');
+    }
+    this._cand = [...this._acc].map(e => ({layout: e, match: 0 }));
+    this._key = { layouts: ['US_QWERTY'], certain: 0, key: undefined };
+  }
+
+  /**
+   * Reset detector.
+   */
+  public reset(): void {
+    this._rec = this._rec.map(e => '');
+  }
+
+  /**
+   * Return list of registered layouts.
+   */
+  public get layouts(): string[] {
+    return Object.keys(MAP.acc);
+  }
+
+  /**
+   * Return list of supported key codes.
+   */
+  public get codes(): string[] {
+    return Object.keys(MAP.map);
+  }
+
+  /**
+   * Get character key for key code and layout.
+   * The known key codes can be requested with `.codes`,
+   * the registered layouts with `.layouts`.
+   */
+  public getLayoutKey(code: string, layout: string): string | undefined {
+    if (MAP.acc[layout] !== undefined && MAP.map[code]) {
+      return MAP.map[code][MAP.acc[layout]];
+    }
+  }
+
+  /**
+   * Feed a keyboard event `ev` to the detector.
+   */
+  public feed(ev: KeyboardEvent): void {
+    // FIXME: Should we check for OS? (not supported by Safari)
+    if (ev.shiftKey || ev.ctrlKey || ev.altKey || ev.metaKey
+      || ev.getModifierState('AltGraph')
+      || ev.getModifierState('CapsLock')
+    ) {
+      return;
+    }
+    const pos = this._keys[ev.code];
+    if (pos !== undefined) {
+      if (this._rec[pos] && this._rec[pos] !== ev.key) {
+        // The key value should never change for the same layout,
+        // so we treat a sudden change as a layout change.
+        this.reset();
+      }
+      this._rec[pos] = ev.key;
+    }
+  }
+
+  /**
+   * Shows all known layouts and their degree of matching mappings
+   * sorted descending (likely layouts first).
+   * Ideally there is only one leading layout with a match of 1.
+   * If the leading match is not 1, then the user uses an
+   * unknown or custom layout.
+   */
+  public matches(): {layout: string; match: number;}[] {
+    for (let i = 0; i < this._cand.length; ++i) {
+      this._cand[i].match = 0;
+    }
+    let c = 0;
+    for (let k = 0; k < this._rec.length; ++k) {
+      const v = this._rec[k];
+      if (v) {
+        c++;
+        const values = this._values[k];
+        for (let i = 0; i < this._acc.length; ++i) {
+          if (v === values[i]) {
+            this._cand[i].match++;
+          }
+        }
+      }
+    }
+    if (!c) {
+      return this._cand;
+    }
+    const sorted = [...this._cand].sort((a, b) => b.match - a.match);
+    for (let i = 0; i < sorted.length; ++i) {
+      sorted[i].match /= c;
+    }
+    return sorted;
+  }
+
+  /**
+   * Tries to resolve a key code to a key character.
+   * If `certain` is 1 then the result matches the listed layouts.
+   * Ideally only one layout is returned, then the detector has seen enough
+   * key events in `feed`.
+   * If multiple layouts are returned but only one key, then the layout is
+   * not yet fully determined but the key code is already known from `feed`.
+   * When multiple keys are returned, then the character undetermined
+   * and the layout needs further resolving with resolve.
+   * A certainty lesser than 1 can have different reasons:
+   * - not enough key event fed yet (multiple layouts returned)
+   * - user has an unknown or custom layout
+   * If `certain` is 0 the result should not be used as the dector
+   * has not seen any key events at all.
+   */
+  public getKey(code: string): IKeyResult {
+    const lm = this.matches();
+    let candidates = [];
+    let lastMatch = 0;
+    for (let i = 0; i < lm.length; ++i) {
+      if (lm[i].match === 0) {
+        break;
+      }
+      if (lm[i].match === 1) {
+        lastMatch = 1;
+        candidates.push(lm[i].layout);
+      } else if (lm[i].match >= lastMatch) {
+        lastMatch = lm[i].match;
+        candidates.push(lm[i].layout);
+      }
+    }
+    if (candidates.length === 1) {
+      this._key.layouts = candidates;
+      this._key.certain = lastMatch;
+      this._key.key = MAP.map[code]?.[MAP.acc[candidates[0]]];
+      return this._key;
+    }
+    if (!candidates.length) {
+      candidates = [...this._acc];
+    }
+    const values = [];
+    for (let i = 0; i < candidates.length; ++i) {
+      values.push(MAP.map[code]?.[MAP.acc[candidates[i]]]);
+    }
+    const valuesSet = new Set(values);
+    // if all candidates yield the same char, return it
+    if (valuesSet.size === 1) {
+      this._key.layouts = candidates;
+      this._key.certain = lastMatch;
+      const [value] = values;
+      this._key.key = value;
+      return this._key;
+    }
+    // fallthrough
+    this._key.layouts = candidates;
+    this._key.certain = lastMatch / valuesSet.size;
+    this._key.key = values;
+    return this._key;
+  }
+
+  /**
+   * Calculate distance to resolve keyboard layout.
+   * Returns the candicate layouts and a list of keys resolving layout ambiguity.
+   * The key list is sorted descending by candidate differences for a key code
+   * (picking a high difference code needs less follow-up steps).
+   * The user should be asked to press the corresponding key and the key event
+   * should be fed to `feed`.
+   * Repeat this process until this method returns only one layout.
+   */
+  public resolve(): any {
+    const lm = this.matches();
+    let candidates = [];
+    let lastMatch = 0;
+    for (let i = 0; i < lm.length; ++i) {
+      if (lm[i].match === 0) {
+        break;
+      }
+      if (lm[i].match === 1) {
+        lastMatch = 1;
+        candidates.push(lm[i].layout);
+      } else if (lm[i].match >= lastMatch) {
+        lastMatch = lm[i].match;
+        candidates.push(lm[i].layout);
+      }
+    }
+    if (candidates.length === 1) {
+      return { layouts: candidates, keys: [] };
+    }
+    if (!candidates.length) {
+      candidates = [...this._acc];
+    }
+    const acc = candidates.map(e => MAP.acc[e]);
+    const codes = Object.keys(this._keys);
+    const values = [];
+    for (let i = 0; i < this._values.length; ++i) {
+      const value = new Set();
+      for (let k = 0; k < acc.length; ++k) {
+        value.add(this._values[i][k]);
+      }
+      const len = (new Set(value)).size;
+      if (len > 1) {
+        values.push({code: codes[i], keys: [...value].sort()});
+      }
+    }
+    values.sort((a, b) => b.keys.length - a.keys.length);
+    return { layouts: candidates, keys: values };
+  }
+}