From 05b61cc8c1da73e9f812c202006a57d21b898cb5 Mon Sep 17 00:00:00 2001 From: macodev00 Date: Fri, 16 Oct 2026 06:33:11 +0000 Subject: [PATCH 1/4] fix(web): record the layout character in the keybinding editor The keybinding editor stored the US name of a physical key, so Shift on a German Mac # key saved shift+\. Store the character the layout typed instead. A US shifted glyph maps back to its key name only while Shift is held, so an unshifted { stays {. Global shortcut recording is unchanged. --- .../KeybindingsSettings.logic.test.ts | 139 +++++++++++++++++- .../settings/KeybindingsSettings.logic.ts | 56 ++++++- .../settings/KeybindingsSettings.tsx | 8 +- 3 files changed, 198 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts index 2096e2e1d908..40eb4fee0331 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from "vite-plus/test"; import type { ResolvedKeybindingsConfig } from "@t3tools/contracts"; -import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; +import { DEFAULT_RESOLVED_KEYBINDINGS, parseKeybindingShortcut } from "@t3tools/shared/keybindings"; + +import { resolveShortcutCommand } from "../../keybindings"; import { buildKeybindingRows, @@ -178,7 +180,7 @@ describe("KeybindingsSettings.logic", () => { ["@", "Digit2", "mod+shift+2"], ['"', "Digit2", "mod+shift+2"], ["@", "Quote", "mod+shift+'"], - ])("captures %s at %s by physical key", (key, code, expected) => { + ])("captures %s at %s by physical key for global shortcuts", (key, code, expected) => { expect( keybindingFromKeyboardEvent( { @@ -194,6 +196,139 @@ describe("KeybindingsSettings.logic", () => { ).toBe(expected); }); + const layoutModifiers = { metaKey: false, ctrlKey: false, altKey: false, shiftKey: false }; + + it.each([ + ["~", "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", "="], + ["_", "Minus", "-"], + [">", "Period", "."], + ['"', "Quote", "'"], + [":", "Semicolon", ";"], + ["?", "Slash", "/"], + ])("keeps unshifted %s on %s and maps it to %s only with Shift", (key, code, usName) => { + expect( + keybindingFromKeyboardEvent({ ...layoutModifiers, key, code }, "MacIntel", { + layoutCharacter: true, + }), + ).toBe(key); + expect( + keybindingFromKeyboardEvent({ ...layoutModifiers, key, code, shiftKey: true }, "MacIntel", { + layoutCharacter: true, + }), + ).toBe(`shift+${usName}`); + }); + + it("records the character a German layout types and still matches that press", () => { + const unshifted = { + ...layoutModifiers, + key: "#", + code: "Backslash", + }; + expect(keybindingFromKeyboardEvent(unshifted, "MacIntel", { layoutCharacter: true })).toBe("#"); + + const pressed = { + ...layoutModifiers, + key: "'", + code: "Backslash", + shiftKey: true, + }; + // The default path is the US name, which global shortcut recording still uses. + expect(keybindingFromKeyboardEvent(pressed, "MacIntel")).toBe("shift+\\"); + const input = keybindingFromKeyboardEvent(pressed, "MacIntel", { layoutCharacter: true }); + expect(input).toBe("shift+'"); + const shortcut = parseKeybindingShortcut(input!)!; + expect( + resolveShortcutCommand(pressed, [{ command: "chat.new", shortcut }], { + platform: "MacIntel", + }), + ).toBe("chat.new"); + }); + + it("keeps an unshifted { on BracketLeft and matches that press", () => { + const pressed = { + ...layoutModifiers, + key: "{", + code: "BracketLeft", + metaKey: true, + }; + const input = keybindingFromKeyboardEvent(pressed, "MacIntel", { layoutCharacter: true }); + expect(input).toBe("mod+{"); + const shortcut = parseKeybindingShortcut(input!)!; + expect(shortcut.key).toBe("{"); + expect( + resolveShortcutCommand(pressed, [{ command: "chat.new", shortcut }], { + platform: "MacIntel", + }), + ).toBe("chat.new"); + }); + + it("maps Shift plus the US { glyph to [ and still matches that press", () => { + const pressed = { + ...layoutModifiers, + key: "{", + code: "BracketLeft", + metaKey: true, + shiftKey: true, + }; + const input = keybindingFromKeyboardEvent(pressed, "MacIntel", { layoutCharacter: true }); + expect(input).toBe("mod+shift+["); + const shortcut = parseKeybindingShortcut(input!)!; + expect( + resolveShortcutCommand(pressed, [{ command: "thread.previous", shortcut }], { + platform: "MacIntel", + }), + ).toBe("thread.previous"); + }); + + it("records a Latin layout letter instead of the punctuation position", () => { + expect( + keybindingFromKeyboardEvent( + { + key: "m", + code: "Semicolon", + metaKey: true, + ctrlKey: false, + altKey: false, + shiftKey: false, + }, + "MacIntel", + { layoutCharacter: true }, + ), + ).toBe("mod+m"); + }); + + it("records a non-US shifted glyph instead of the US digit name", () => { + expect( + keybindingFromKeyboardEvent( + { + key: '"', + code: "Digit2", + metaKey: true, + ctrlKey: false, + altKey: false, + shiftKey: true, + }, + "MacIntel", + { layoutCharacter: true }, + ), + ).toBe('mod+shift+"'); + }); + it("captures Latin layout keys instead of their punctuation position", () => { expect( keybindingFromKeyboardEvent( diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.ts index a396941a9861..9541a5c498bf 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.ts @@ -381,6 +381,49 @@ function titleCaseCommandSegment(segment: string): string { return words.join(" "); } +/** + * US shifted glyph and the unshifted name of that physical key. Consulted only + * while Shift is held, so an unshifted `{` on BracketLeft stays `{`. + */ +const US_SHIFTED_EVENT_CODE_KEYS: Readonly> = { + 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: ["+", "="], + Minus: ["_", "-"], + Period: [">", "."], + Quote: ['"', "'"], + Semicolon: [":", ";"], + Slash: ["?", "/"], +}; + +/** + * Key token the keybinding editor stores. A single typed character is kept + * (`#`, `'`, `ü`). Shift plus the US shifted glyph for that `code` stores the + * US name (`{` on BracketLeft is `[`), so a US layout still records `shift+[`. + */ +function recordedLayoutShortcutKey( + event: Pick, +): string { + const layoutKey = event.key.toLowerCase(); + const shifted = event.code ? US_SHIFTED_EVENT_CODE_KEYS[event.code] : undefined; + if (event.shiftKey && shifted && layoutKey === shifted[0]) return shifted[1]; + if (layoutKey.length === 1) return layoutKey; + return shortcutKeyFromEvent(event); +} + function normalizeShortcutKeyToken(key: string): string | null { const normalized = key.toLowerCase(); if ( @@ -411,11 +454,22 @@ function normalizeShortcutKeyToken(key: string): string | null { return null; } +/** + * `layoutCharacter` stores the character the active layout typed. Without it, + * the key is the US name of the physical position, which global shortcut + * recording still uses. + */ export function keybindingFromKeyboardEvent( event: Pick, platform: string, + options?: { + /** Store the typed character instead of the US name of `event.code`. */ + readonly layoutCharacter?: boolean; + }, ): string | null { - const keyToken = normalizeShortcutKeyToken(shortcutKeyFromEvent(event)); + const keyToken = normalizeShortcutKeyToken( + options?.layoutCharacter ? recordedLayoutShortcutKey(event) : shortcutKeyFromEvent(event), + ); if (!keyToken) return null; const parts: string[] = []; diff --git a/apps/web/src/components/settings/KeybindingsSettings.tsx b/apps/web/src/components/settings/KeybindingsSettings.tsx index eab23eb08afa..009509d2e5c0 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.tsx +++ b/apps/web/src/components/settings/KeybindingsSettings.tsx @@ -743,7 +743,9 @@ function useKeybindingRowEditor({ setDraft({ keyDraft: row.key, isRecording: false }); return; } - const next = keybindingFromKeyboardEvent(event.nativeEvent, navigator.platform); + const next = keybindingFromKeyboardEvent(event.nativeEvent, navigator.platform, { + layoutCharacter: true, + }); if (!next) return; setDraft({ keyDraft: next, isRecording: false }); }; @@ -1053,7 +1055,9 @@ function useNewKeybindingDraft({ setDraft({ keyDraft: "", isRecording: false }); return; } - const next = keybindingFromKeyboardEvent(event.nativeEvent, navigator.platform); + const next = keybindingFromKeyboardEvent(event.nativeEvent, navigator.platform, { + layoutCharacter: true, + }); if (!next) return; setDraft({ keyDraft: next, isRecording: false }); }; From 7ea01949c6710d3ea84ff1ba85058e3a24d6855c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 04:26:38 +0000 Subject: [PATCH 2/4] test(web): focus layout keybinding capture coverage --- .../KeybindingsSettings.logic.test.ts | 182 +++++------------- 1 file changed, 51 insertions(+), 131 deletions(-) diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts index 3f44658405d1..17ef906f6e72 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vite-plus/test"; +import { assert, describe, expect, it } from "vite-plus/test"; import type { ResolvedKeybindingsConfig } from "@t3tools/contracts"; import { DEFAULT_RESOLVED_KEYBINDINGS, parseKeybindingShortcut } from "@t3tools/shared/keybindings"; @@ -195,7 +195,7 @@ describe("KeybindingsSettings.logic", () => { ["@", "Digit2", "mod+shift+2"], ['"', "Digit2", "mod+shift+2"], ["@", "Quote", "mod+shift+'"], - ])("captures %s at %s by physical key for global shortcuts", (key, code, expected) => { + ])("captures %s at %s by physical key", (key, code, expected) => { expect( keybindingFromKeyboardEvent( { @@ -211,137 +211,57 @@ describe("KeybindingsSettings.logic", () => { ).toBe(expected); }); - const layoutModifiers = { metaKey: false, ctrlKey: false, altKey: false, shiftKey: false }; - - it.each([ - ["~", "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", "="], - ["_", "Minus", "-"], - [">", "Period", "."], - ['"', "Quote", "'"], - [":", "Semicolon", ";"], - ["?", "Slash", "/"], - ])("keeps unshifted %s on %s and maps it to %s only with Shift", (key, code, usName) => { - expect( - keybindingFromKeyboardEvent({ ...layoutModifiers, key, code }, "MacIntel", { - layoutCharacter: true, - }), - ).toBe(key); - expect( - keybindingFromKeyboardEvent({ ...layoutModifiers, key, code, shiftKey: true }, "MacIntel", { - layoutCharacter: true, - }), - ).toBe(`shift+${usName}`); - }); - - it("records the character a German layout types and still matches that press", () => { - const unshifted = { - ...layoutModifiers, - key: "#", - code: "Backslash", - }; - expect(keybindingFromKeyboardEvent(unshifted, "MacIntel", { layoutCharacter: true })).toBe("#"); - - const pressed = { - ...layoutModifiers, - key: "'", - code: "Backslash", - shiftKey: true, - }; - // The default path is the US name, which global shortcut recording still uses. - expect(keybindingFromKeyboardEvent(pressed, "MacIntel")).toBe("shift+\\"); - const input = keybindingFromKeyboardEvent(pressed, "MacIntel", { layoutCharacter: true }); - expect(input).toBe("shift+'"); - const shortcut = parseKeybindingShortcut(input!)!; - expect( - resolveShortcutCommand(pressed, [{ command: "chat.new", shortcut }], { - platform: "MacIntel", - }), - ).toBe("chat.new"); - }); - - it("keeps an unshifted { on BracketLeft and matches that press", () => { - const pressed = { - ...layoutModifiers, - key: "{", - code: "BracketLeft", - metaKey: true, - }; - const input = keybindingFromKeyboardEvent(pressed, "MacIntel", { layoutCharacter: true }); - expect(input).toBe("mod+{"); - const shortcut = parseKeybindingShortcut(input!)!; - expect(shortcut.key).toBe("{"); - expect( - resolveShortcutCommand(pressed, [{ command: "chat.new", shortcut }], { - platform: "MacIntel", - }), - ).toBe("chat.new"); - }); - - it("maps Shift plus the US { glyph to [ and still matches that press", () => { - const pressed = { - ...layoutModifiers, - key: "{", - code: "BracketLeft", - metaKey: true, - shiftKey: true, - }; - const input = keybindingFromKeyboardEvent(pressed, "MacIntel", { layoutCharacter: true }); - expect(input).toBe("mod+shift+["); - const shortcut = parseKeybindingShortcut(input!)!; - expect( - resolveShortcutCommand(pressed, [{ command: "thread.previous", shortcut }], { - platform: "MacIntel", - }), - ).toBe("thread.previous"); - }); - - it("records a Latin layout letter instead of the punctuation position", () => { - expect( - keybindingFromKeyboardEvent( - { - key: "m", - code: "Semicolon", - metaKey: true, - ctrlKey: false, - altKey: false, - shiftKey: false, - }, - "MacIntel", - { layoutCharacter: true }, - ), - ).toBe("mod+m"); + it("records German punctuation that triggers the saved binding while default recording stays physical", () => { + for (const { key, shiftKey, expected, defaultInput } of [ + { key: "#", shiftKey: false, expected: "#", defaultInput: "\\" }, + { key: "'", shiftKey: true, expected: "shift+'", defaultInput: "shift+\\" }, + ]) { + const pressed = { + key, + code: "Backslash", + metaKey: false, + ctrlKey: false, + altKey: false, + shiftKey, + }; + expect(keybindingFromKeyboardEvent(pressed, "MacIntel")).toBe(defaultInput); + const input = keybindingFromKeyboardEvent(pressed, "MacIntel", { layoutCharacter: true }); + expect(input).toBe(expected); + assert(input); + const shortcut = parseKeybindingShortcut(input); + assert(shortcut); + expect( + resolveShortcutCommand(pressed, [{ command: "chat.new", shortcut }], { + platform: "MacIntel", + }), + ).toBe("chat.new"); + } }); - it("records a non-US shifted glyph instead of the US digit name", () => { - expect( - keybindingFromKeyboardEvent( - { - key: '"', - code: "Digit2", - metaKey: true, - ctrlKey: false, - altKey: false, - shiftKey: true, - }, - "MacIntel", - { layoutCharacter: true }, - ), - ).toBe('mod+shift+"'); + it("records an unshifted brace literally and a US shifted brace as Shift+[; both trigger the saved binding", () => { + for (const { shiftKey, expected } of [ + { shiftKey: false, expected: "mod+{" }, + { shiftKey: true, expected: "mod+shift+[" }, + ]) { + const pressed = { + key: "{", + code: "BracketLeft", + metaKey: true, + ctrlKey: false, + altKey: false, + shiftKey, + }; + const input = keybindingFromKeyboardEvent(pressed, "MacIntel", { layoutCharacter: true }); + expect(input).toBe(expected); + assert(input); + const shortcut = parseKeybindingShortcut(input); + assert(shortcut); + expect( + resolveShortcutCommand(pressed, [{ command: "thread.previous", shortcut }], { + platform: "MacIntel", + }), + ).toBe("thread.previous"); + } }); it("captures Latin layout keys instead of their punctuation position", () => { From 68fe0571fd8d73eb79208e6887bf2fc0f7cc2381 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 04:30:12 +0000 Subject: [PATCH 3/4] fix(web): reuse key normalization for layout shortcut capture --- .../settings/KeybindingsSettings.logic.ts | 50 +++---------------- apps/web/src/terminal/ghostty/keyCodes.ts | 28 ++--------- packages/shared/src/keybindings.ts | 24 +++++++++ 3 files changed, 34 insertions(+), 68 deletions(-) diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.ts index 38ec4564d14b..fd7ef22fc094 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.ts @@ -9,6 +9,7 @@ import { import { DEFAULT_RESOLVED_KEYBINDINGS, parseKeybindingWhenExpression, + US_SHIFTED_TO_UNSHIFTED_KEYS, } from "@t3tools/shared/keybindings"; import { shortcutKeyFromEvent } from "../../keybindings"; @@ -386,47 +387,16 @@ function titleCaseCommandSegment(segment: string): string { return words.join(" "); } -/** - * US shifted glyph and the unshifted name of that physical key. Consulted only - * while Shift is held, so an unshifted `{` on BracketLeft stays `{`. - */ -const US_SHIFTED_EVENT_CODE_KEYS: Readonly> = { - 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: ["+", "="], - Minus: ["_", "-"], - Period: [">", "."], - Quote: ['"', "'"], - Semicolon: [":", ";"], - Slash: ["?", "/"], -}; - -/** - * Key token the keybinding editor stores. A single typed character is kept - * (`#`, `'`, `ü`). Shift plus the US shifted glyph for that `code` stores the - * US name (`{` on BracketLeft is `[`), so a US layout still records `shift+[`. - */ function recordedLayoutShortcutKey( event: Pick, ): string { + const physicalKey = shortcutKeyFromEvent(event); const layoutKey = event.key.toLowerCase(); - const shifted = event.code ? US_SHIFTED_EVENT_CODE_KEYS[event.code] : undefined; - if (event.shiftKey && shifted && layoutKey === shifted[0]) return shifted[1]; - if (layoutKey.length === 1) return layoutKey; - return shortcutKeyFromEvent(event); + if (layoutKey.length !== 1) return physicalKey; + // Normalize only US shifted glyphs at their US position. Other layouts can + // produce the same glyph without Shift or at a different position. + const unshiftedKey = event.shiftKey ? US_SHIFTED_TO_UNSHIFTED_KEYS.get(layoutKey) : undefined; + return unshiftedKey === physicalKey ? physicalKey : layoutKey; } function normalizeShortcutKeyToken(key: string): string | null { @@ -459,16 +429,10 @@ function normalizeShortcutKeyToken(key: string): string | null { return null; } -/** - * `layoutCharacter` stores the character the active layout typed. Without it, - * the key is the US name of the physical position, which global shortcut - * recording still uses. - */ export function keybindingFromKeyboardEvent( event: Pick, platform: string, options?: { - /** Store the typed character instead of the US name of `event.code`. */ readonly layoutCharacter?: boolean; }, ): string | null { diff --git a/apps/web/src/terminal/ghostty/keyCodes.ts b/apps/web/src/terminal/ghostty/keyCodes.ts index 7751f9b14b50..cab2c671e744 100644 --- a/apps/web/src/terminal/ghostty/keyCodes.ts +++ b/apps/web/src/terminal/ghostty/keyCodes.ts @@ -1,3 +1,5 @@ +import { US_SHIFTED_TO_UNSHIFTED_KEYS } from "@t3tools/shared/keybindings"; + // This order mirrors GhosttyKey in ghostty/vt/key/event.h. The values are // intentionally derived from the official W3C-aligned enum instead of // maintaining a second keyboard protocol. @@ -197,30 +199,6 @@ export interface GhosttyKeyboardLayoutMap { get(code: string): string | undefined; } -const shiftedToUnshiftedCharacter = new Map([ - ["!", "1"], - ["@", "2"], - ["#", "3"], - ["$", "4"], - ["%", "5"], - ["^", "6"], - ["&", "7"], - ["*", "8"], - ["(", "9"], - [")", "0"], - ["~", "`"], - ["_", "-"], - ["+", "="], - ["{", "["], - ["}", "]"], - ["|", "\\"], - [":", ";"], - ['"', "'"], - ["<", ","], - [">", "."], - ["?", "/"], -]); - let keyboardLayoutMapPromise: Promise | undefined; export function loadGhosttyKeyboardLayoutMap(): Promise { @@ -258,7 +236,7 @@ export function ghosttyUnshiftedCodepoint( } if (/^[A-Z]$/u.test(event.key)) return event.key.charCodeAt(0) + 32; if (event.shiftKey) { - const unshiftedCharacter = shiftedToUnshiftedCharacter.get(event.key); + const unshiftedCharacter = US_SHIFTED_TO_UNSHIFTED_KEYS.get(event.key); if (unshiftedCharacter) return unshiftedCharacter.codePointAt(0) ?? 0; const lowercase = event.key.toLowerCase(); if (lowercase !== event.key && [...lowercase].length === 1) { diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index afadd35b6a1a..b511f830f69b 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -53,6 +53,30 @@ export const EVENT_CODE_SHORTCUT_KEYS: Readonly> = { Slash: "/", }; +export const US_SHIFTED_TO_UNSHIFTED_KEYS: ReadonlyMap = new Map([ + ["!", "1"], + ["@", "2"], + ["#", "3"], + ["$", "4"], + ["%", "5"], + ["^", "6"], + ["&", "7"], + ["*", "8"], + ["(", "9"], + [")", "0"], + ["~", "`"], + ["_", "-"], + ["+", "="], + ["{", "["], + ["}", "]"], + ["|", "\\"], + [":", ";"], + ['"', "'"], + ["<", ","], + [">", "."], + ["?", "/"], +]); + export function isMacPlatform(platform: string): boolean { return /mac|iphone|ipad|ipod/i.test(platform); } From b5756594c3fa4ad7bb46a3a9197384d8ac84a592 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 04:38:32 +0000 Subject: [PATCH 4/4] fix(web): preserve physical Alt shortcut recording --- .../KeybindingsSettings.logic.test.ts | 26 ++++++++++++++----- .../settings/KeybindingsSettings.logic.ts | 4 +-- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts index 17ef906f6e72..4f9e48921a40 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts @@ -211,17 +211,31 @@ describe("KeybindingsSettings.logic", () => { ).toBe(expected); }); - it("records German punctuation that triggers the saved binding while default recording stays physical", () => { - for (const { key, shiftKey, expected, defaultInput } of [ - { key: "#", shiftKey: false, expected: "#", defaultInput: "\\" }, - { key: "'", shiftKey: true, expected: "shift+'", defaultInput: "shift+\\" }, + it("records layout punctuation while preserving physical Alt shortcuts; saved bindings still trigger", () => { + for (const { key, code, altKey = false, shiftKey, expected, defaultInput } of [ + { key: "#", code: "Backslash", shiftKey: false, expected: "#", defaultInput: "\\" }, + { + key: "'", + code: "Backslash", + shiftKey: true, + expected: "shift+'", + defaultInput: "shift+\\", + }, + { + key: "¡", + code: "Digit1", + altKey: true, + shiftKey: false, + expected: "alt+1", + defaultInput: "alt+1", + }, ]) { const pressed = { key, - code: "Backslash", + code, metaKey: false, ctrlKey: false, - altKey: false, + altKey, shiftKey, }; expect(keybindingFromKeyboardEvent(pressed, "MacIntel")).toBe(defaultInput); diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.ts index fd7ef22fc094..0269b943cab0 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.ts @@ -388,11 +388,11 @@ function titleCaseCommandSegment(segment: string): string { } function recordedLayoutShortcutKey( - event: Pick, + event: Pick, ): string { const physicalKey = shortcutKeyFromEvent(event); const layoutKey = event.key.toLowerCase(); - if (layoutKey.length !== 1) return physicalKey; + if (event.altKey || layoutKey.length !== 1) return physicalKey; // Normalize only US shifted glyphs at their US position. Other layouts can // produce the same glyph without Shift or at a different position. const unshiftedKey = event.shiftKey ? US_SHIFTED_TO_UNSHIFTED_KEYS.get(layoutKey) : undefined;