diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts index 99e8ad7d20d8..9abea577d5a4 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts @@ -1,10 +1,13 @@ -import { describe, expect, it } from "vite-plus/test"; +import { assert, describe, expect, it } from "vite-plus/test"; import type { ResolvedKeybindingsConfig } from "@t3tools/contracts"; import { compileResolvedKeybindingsConfig, DEFAULT_RESOLVED_KEYBINDINGS, + parseKeybindingShortcut, } from "@t3tools/shared/keybindings"; +import { resolveShortcutCommand } from "../../keybindings"; + import { buildKeybindingRows, buildKeybindingCommandOptions, @@ -254,6 +257,73 @@ describe("KeybindingsSettings.logic", () => { ).toBe(expected); }); + 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, + metaKey: false, + ctrlKey: false, + altKey, + 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 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", () => { expect( keybindingFromKeyboardEvent( diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.ts index 28409786007a..0c9a5f32a8c4 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"; @@ -389,6 +390,18 @@ function titleCaseCommandSegment(segment: string): string { return words.join(" "); } +function recordedLayoutShortcutKey( + event: Pick, +): string { + const physicalKey = shortcutKeyFromEvent(event); + const layoutKey = event.key.toLowerCase(); + 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; + return unshiftedKey === physicalKey ? physicalKey : layoutKey; +} + function normalizeShortcutKeyToken(key: string): string | null { const normalized = key.toLowerCase(); if ( @@ -422,8 +435,13 @@ function normalizeShortcutKeyToken(key: string): string | null { export function keybindingFromKeyboardEvent( event: Pick, platform: string, + options?: { + 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 995ca8cab379..3740cf91c951 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.tsx +++ b/apps/web/src/components/settings/KeybindingsSettings.tsx @@ -742,7 +742,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 }); }; @@ -1052,7 +1054,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 }); }; 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); }