Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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(
Expand Down
20 changes: 19 additions & 1 deletion apps/web/src/components/settings/KeybindingsSettings.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
import {
DEFAULT_RESOLVED_KEYBINDINGS,
parseKeybindingWhenExpression,
US_SHIFTED_TO_UNSHIFTED_KEYS,
} from "@t3tools/shared/keybindings";

import { shortcutKeyFromEvent } from "../../keybindings";
Expand Down Expand Up @@ -389,6 +390,18 @@ function titleCaseCommandSegment(segment: string): string {
return words.join(" ");
}

function recordedLayoutShortcutKey(
event: Pick<KeyboardEvent, "key" | "code" | "shiftKey" | "altKey">,
): 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 (
Expand Down Expand Up @@ -422,8 +435,13 @@ function normalizeShortcutKeyToken(key: string): string | null {
export function keybindingFromKeyboardEvent(
event: Pick<KeyboardEvent, "key" | "code" | "metaKey" | "ctrlKey" | "altKey" | "shiftKey">,
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[] = [];
Expand Down
8 changes: 6 additions & 2 deletions apps/web/src/components/settings/KeybindingsSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
};
Expand Down Expand Up @@ -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 });
};
Expand Down
28 changes: 3 additions & 25 deletions apps/web/src/terminal/ghostty/keyCodes.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -197,30 +199,6 @@ export interface GhosttyKeyboardLayoutMap {
get(code: string): string | undefined;
}

const shiftedToUnshiftedCharacter = new Map<string, string>([
["!", "1"],
["@", "2"],
["#", "3"],
["$", "4"],
["%", "5"],
["^", "6"],
["&", "7"],
["*", "8"],
["(", "9"],
[")", "0"],
["~", "`"],
["_", "-"],
["+", "="],
["{", "["],
["}", "]"],
["|", "\\"],
[":", ";"],
['"', "'"],
["<", ","],
[">", "."],
["?", "/"],
]);

let keyboardLayoutMapPromise: Promise<GhosttyKeyboardLayoutMap | undefined> | undefined;

export function loadGhosttyKeyboardLayoutMap(): Promise<GhosttyKeyboardLayoutMap | undefined> {
Expand Down Expand Up @@ -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) {
Expand Down
24 changes: 24 additions & 0 deletions packages/shared/src/keybindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,30 @@ export const EVENT_CODE_SHORTCUT_KEYS: Readonly<Record<string, string>> = {
Slash: "/",
};

export const US_SHIFTED_TO_UNSHIFTED_KEYS: ReadonlyMap<string, string> = 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);
}
Expand Down
Loading