Skip to content
Open
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
11 changes: 11 additions & 0 deletions apps/web/src/components/RightPanelTabs.keyboard.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,17 @@ describe("right panel new-tab shortcut", () => {
});
});

describe("right panel launcher letters", () => {
it("opens a surface from the physical key when the layout types a non-Latin letter", async () => {
const addTerminal = vi.fn();
await renderPanel({ terminalAvailable: true, onAddTerminal: addTerminal });
// Ukrainian layout: the T key types "е".
const event = await press("\u0435", { code: "KeyT" });
expect(event.defaultPrevented).toBe(true);
expect(addTerminal).toHaveBeenCalledOnce();
});
});

describe("right panel tab middle click", () => {
it("cancels middle-button defaults and closes once while preserving other buttons", async () => {
const onActivate = vi.fn();
Expand Down
69 changes: 69 additions & 0 deletions apps/web/src/components/RightPanelTabs.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,15 +36,69 @@ describe("surface shortcuts", () => {
const actions = [
{ shortcut: "B", available: true, label: "Browser" },
{ shortcut: "D", available: false, label: "Diff" },
{ shortcut: "T", available: true, label: "Terminal" },
{ shortcut: "M", available: true, label: "Device" },
] as const;

it("matches available surface shortcuts case-insensitively", () => {
expect(surfaceShortcutActionForKey(actions, shortcutEvent("b"))).toBe(actions[0]);
expect(surfaceShortcutActionForKey(actions, shortcutEvent("B"))).toBe(actions[0]);
});

it("falls back to the physical key when the layout types a non-Latin letter or mark", () => {
// Ukrainian layout: the B key types "и".
expect(surfaceShortcutActionForKey(actions, shortcutEvent("\u0438", { code: "KeyB" }))).toBe(
actions[0],
);
// Greek layout: the B key types "β".
expect(surfaceShortcutActionForKey(actions, shortcutEvent("\u03b2", { code: "KeyB" }))).toBe(
actions[0],
);
// Thai layout: the B key types the vowel sign "\u0e34", a combining mark.
expect(surfaceShortcutActionForKey(actions, shortcutEvent("\u0e34", { code: "KeyB" }))).toBe(
actions[0],
);
// Arabic (101) layout: the B key types lam alef "\u0644\u0627", two code points.
expect(
surfaceShortcutActionForKey(actions, shortcutEvent("\u0644\u0627", { code: "KeyB" })),
).toBe(actions[0]);
});

it("follows the typed letter on Latin layouts that move it", () => {
// Dvorak: the key at QWERTY B types "x", and "b" sits on QWERTY N.
expect(surfaceShortcutActionForKey(actions, shortcutEvent("x", { code: "KeyB" }))).toBeNull();
// The same key with Shift or Caps Lock types "X".
expect(surfaceShortcutActionForKey(actions, shortcutEvent("X", { code: "KeyB" }))).toBeNull();
expect(surfaceShortcutActionForKey(actions, shortcutEvent("b", { code: "KeyN" }))).toBe(
actions[0],
);
});

it("keeps accented Latin letters typed on a letter key", () => {
// Bépo: the T key types "è".
expect(
surfaceShortcutActionForKey(actions, shortcutEvent("\u00e8", { code: "KeyT" })),
).toBeNull();
});

it("keeps punctuation typed on a letter key", () => {
// French AZERTY: the M key types ",".
expect(surfaceShortcutActionForKey(actions, shortcutEvent(",", { code: "KeyM" }))).toBeNull();
});

it("keeps a dead key on a letter key", () => {
// A dead key starts an accented letter on a Latin layout.
expect(
surfaceShortcutActionForKey(actions, shortcutEvent("Dead", { code: "KeyT" })),
).toBeNull();
});

it("does not activate unavailable surfaces", () => {
expect(surfaceShortcutActionForKey(actions, shortcutEvent("d"))).toBeNull();
// Ukrainian layout: the D key types "в".
expect(
surfaceShortcutActionForKey(actions, shortcutEvent("\u0432", { code: "KeyD" })),
).toBeNull();
});

it("leaves modified, composing, and already-handled key events alone", () => {
Expand All @@ -56,6 +110,21 @@ describe("surface shortcuts", () => {
surfaceShortcutActionForKey(actions, shortcutEvent("b", { defaultPrevented: true })),
).toBeNull();
});

it("leaves AltGr text alone unless it is a plain letter", () => {
const getModifierState = (key: string) => key === "AltGraph";
// German layout: AltGr+M types "\u00b5" and sets only AltGraph on Linux and Windows.
expect(
surfaceShortcutActionForKey(
actions,
shortcutEvent("\u00b5", { code: "KeyM", getModifierState }),
),
).toBeNull();
// Finnish layout: AltGr+B types "b", which still matches, as it does in keybindings.ts.
expect(
surfaceShortcutActionForKey(actions, shortcutEvent("b", { code: "KeyB", getModifierState })),
).toBe(actions[0]);
});
});

describe("surface shortcut typing contexts", () => {
Expand Down
26 changes: 22 additions & 4 deletions apps/web/src/components/RightPanelTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -248,17 +248,35 @@ function tabAudioState(overlay: DesktopPreviewOverlay | null): TabAudioState {
type SurfaceShortcutEvent = Pick<
KeyboardEvent,
"altKey" | "ctrlKey" | "defaultPrevented" | "isComposing" | "key" | "metaKey"
>;
> &
Partial<Pick<KeyboardEvent, "code" | "getModifierState">>;

export function surfaceShortcutActionForKey<
const Action extends { available: boolean; shortcut: string },
>(actions: readonly Action[], event: SurfaceShortcutEvent): Action | null {
if (event.defaultPrevented || event.isComposing) return null;
if (event.metaKey || event.ctrlKey || event.altKey) return null;
// Linux and Windows report AltGr as AltGraph without Ctrl or Alt, which the
// check above misses, while macOS Option always sets altKey. As in
// keybindings.ts, AltGr text other than a plain letter or digit, like the
// micro sign on German AltGr+M, is not a launcher letter.
if (event.getModifierState?.("AltGraph") && !/^[a-z0-9]$/i.test(event.key)) return null;
const typed = event.key.toLowerCase();
// A layout that types non-Latin letters or combining marks matches by
// physical key, like the modified shortcuts in keybindings.ts. One key can
// type several, such as the Arabic lam alef ligature. Any other key, such as
// an accented Latin letter, punctuation or a dead key, matches only what it
// types.
const physical =
/^[\p{L}\p{M}]+$/u.test(typed) && !/\p{Script=Latin}/u.test(typed)
Comment thread
MarMarLabs marked this conversation as resolved.
? event.code?.match(/^Key([A-Z])$/)?.[1]?.toLowerCase()
Comment thread
MarMarLabs marked this conversation as resolved.
: undefined;
return (
actions.find(
(action) => action.available && action.shortcut.toLowerCase() === event.key.toLowerCase(),
) ?? null
actions.find((action) => {
if (!action.available) return false;
const shortcut = action.shortcut.toLowerCase();
return shortcut === typed || shortcut === physical;
}) ?? null
Comment thread
coderabbitai[bot] marked this conversation as resolved.
);
}

Expand Down
Loading