+ 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 };
+ }
+}