From 57f8c7615c0fb59b990516a9d69823c6bb3348c4 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 22 Mar 2026 16:28:09 +0700 Subject: [PATCH] Add chord keyboard shortcuts and staged capture UI --- .../app/src/components/agent-input-area.tsx | 18 +- .../app/src/components/command-center.tsx | 2 +- packages/app/src/components/left-sidebar.tsx | 4 +- packages/app/src/components/message-input.tsx | 12 +- .../src/components/sidebar-workspace-list.tsx | 6 +- packages/app/src/components/ui/shortcut.tsx | 50 ++++- packages/app/src/hooks/use-command-center.ts | 11 +- .../app/src/hooks/use-keyboard-shortcuts.ts | 55 +++++- packages/app/src/hooks/use-shortcut-keys.ts | 9 +- .../src/keyboard/keyboard-shortcuts.test.ts | 115 ++++++++++-- .../app/src/keyboard/keyboard-shortcuts.ts | 176 ++++++++++++++++-- packages/app/src/keyboard/shortcut-string.ts | 12 ++ .../settings/keyboard-shortcuts-section.tsx | 107 ++++++++--- .../workspace/workspace-desktop-tabs-row.tsx | 8 +- .../stores/keyboard-shortcuts-store.test.ts | 8 + .../src/stores/keyboard-shortcuts-store.ts | 4 + 16 files changed, 498 insertions(+), 99 deletions(-) diff --git a/packages/app/src/components/agent-input-area.tsx b/packages/app/src/components/agent-input-area.tsx index c3868e55b..a95dadff6 100644 --- a/packages/app/src/components/agent-input-area.tsx +++ b/packages/app/src/components/agent-input-area.tsx @@ -594,12 +594,14 @@ export function AgentInputArea({ )} - - Interrupt - {dictationCancelKeys ? : null} - - - + + Interrupt + {dictationCancelKeys ? ( + + ) : null} + + + ) : null; const rightContent = ( @@ -626,7 +628,9 @@ export function AgentInputArea({ Voice mode - {voiceToggleKeys && } + {voiceToggleKeys ? ( + + ) : null} diff --git a/packages/app/src/components/command-center.tsx b/packages/app/src/components/command-center.tsx index 4ff7af037..b243ace72 100644 --- a/packages/app/src/components/command-center.tsx +++ b/packages/app/src/components/command-center.tsx @@ -178,7 +178,7 @@ export function CommandCenter() { {action.shortcutKeys ? ( - + ) : null} diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index b8ef91dac..88d95589b 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -565,7 +565,7 @@ function MobileSidebar({ Add project - {newAgentKeys && } + {newAgentKeys ? : null} @@ -745,7 +745,7 @@ function DesktopSidebar({ Add project - {newAgentKeys ? : null} + {newAgentKeys ? : null} diff --git a/packages/app/src/components/message-input.tsx b/packages/app/src/components/message-input.tsx index 05bfd25e4..8519126b8 100644 --- a/packages/app/src/components/message-input.tsx +++ b/packages/app/src/components/message-input.tsx @@ -1013,7 +1013,11 @@ export const MessageInput = forwardRef(funct {(isRealtimeVoiceForCurrentAgent ? voiceMuteToggleKeys : dictationToggleKeys) ? ( ) : null} @@ -1039,7 +1043,9 @@ export const MessageInput = forwardRef(funct Queue - {queueKeys ? : null} + {queueKeys ? ( + + ) : null} @@ -1062,7 +1068,7 @@ export const MessageInput = forwardRef(funct Send - {sendKeys ? : null} + {sendKeys ? : null} diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index ae6ff3d7a..59be73978 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -150,7 +150,7 @@ interface WorkspaceRowInnerProps { onArchive?: () => void; onCopyBranchName?: () => void; onCopyPath?: () => void; - archiveShortcutKeys?: ShortcutKey[] | null; + archiveShortcutKeys?: ShortcutKey[][] | null; } function resolveStatusDotColor(input: { @@ -322,7 +322,7 @@ function NewWorktreeButton({ New worktree {showShortcutHint && newWorktreeKeys ? ( - + ) : null} @@ -842,7 +842,7 @@ function WorkspaceRowInner({ } - trailing={archiveShortcutKeys ? : null} + trailing={archiveShortcutKeys ? : null} status={archiveStatus} pendingLabel={archivePendingLabel} onSelect={onArchive} diff --git a/packages/app/src/components/ui/shortcut.tsx b/packages/app/src/components/ui/shortcut.tsx index 60097f763..a1e8b7e06 100644 --- a/packages/app/src/components/ui/shortcut.tsx +++ b/packages/app/src/components/ui/shortcut.tsx @@ -6,22 +6,49 @@ import { getShortcutOs } from "@/utils/shortcut-platform"; export function Shortcut({ keys, + chord, style, textStyle, }: { - keys: ShortcutKey[]; + keys?: ShortcutKey[]; + chord?: ShortcutKey[][]; style?: StyleProp; textStyle?: StyleProp; }): ReactElement { + const displayChord = chord ?? (keys ? [keys] : []); + const shortcutOs = getShortcutOs(); + const singleCombo = displayChord[0]; + + if (!singleCombo) { + return ; + } + + if (displayChord.length === 1) { + return ( + + {formatShortcut(singleCombo, shortcutOs)} + + ); + } + return ( - - {formatShortcut(keys, getShortcutOs())} + + {displayChord.map(function (combo, index) { + return ( + + + {formatShortcut(combo, shortcutOs)} + + {index < displayChord.length - 1 ? : null} + + ); + })} ); } const styles = StyleSheet.create((theme) => ({ - root: { + badge: { paddingHorizontal: theme.spacing[1], paddingVertical: 2, borderRadius: theme.borderRadius.md, @@ -29,6 +56,21 @@ const styles = StyleSheet.create((theme) => ({ borderWidth: theme.borderWidth[1], borderColor: theme.colors.borderAccent, }, + sequence: { + flexDirection: "row", + alignItems: "center", + flexWrap: "wrap", + gap: theme.spacing[1], + }, + sequenceItem: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + }, + separator: { + fontSize: theme.fontSize.xs, + color: theme.colors.foregroundMuted, + }, text: { fontSize: theme.fontSize.xs, fontWeight: theme.fontWeight.normal, diff --git a/packages/app/src/hooks/use-command-center.ts b/packages/app/src/hooks/use-command-center.ts index ac8c3b432..6c4857e1b 100644 --- a/packages/app/src/hooks/use-command-center.ts +++ b/packages/app/src/hooks/use-command-center.ts @@ -13,7 +13,7 @@ import { } from "@/utils/command-center-focus-restore"; import { buildHostSettingsRoute, parseServerIdFromPathname } from "@/utils/host-routes"; import type { ShortcutKey } from "@/utils/format-shortcut"; -import { comboStringToShortcutKeys } from "@/keyboard/shortcut-string"; +import { chordStringToShortcutKeys } from "@/keyboard/shortcut-string"; import { getBindingIdForAction, getDefaultKeysForAction } from "@/keyboard/keyboard-shortcuts"; import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides"; import { getShortcutOs } from "@/utils/shortcut-platform"; @@ -91,7 +91,7 @@ export type CommandCenterActionItem = { title: string; icon?: "plus" | "settings"; route?: Href; - shortcutKeys?: ShortcutKey[]; + shortcutKeys?: ShortcutKey[][]; }; export type CommandCenterItem = @@ -107,7 +107,7 @@ export type CommandCenterItem = function resolveActionShortcutKeys( actionId: string | undefined, overrides: Record, -): ShortcutKey[] | undefined { +): ShortcutKey[][] | undefined { if (!actionId) return undefined; const isMac = getShortcutOs() === "mac"; const isDesktop = getIsDesktop(); @@ -115,8 +115,9 @@ function resolveActionShortcutKeys( const bindingId = getBindingIdForAction(actionId, platform); if (!bindingId) return undefined; const override = overrides[bindingId]; - if (override) return comboStringToShortcutKeys(override); - return getDefaultKeysForAction(actionId, platform) ?? undefined; + if (override) return chordStringToShortcutKeys(override); + const defaultKeys = getDefaultKeysForAction(actionId, platform); + return defaultKeys ? [defaultKeys] : undefined; } export function useCommandCenter() { diff --git a/packages/app/src/hooks/use-keyboard-shortcuts.ts b/packages/app/src/hooks/use-keyboard-shortcuts.ts index fe1b5865f..7434e2b9a 100644 --- a/packages/app/src/hooks/use-keyboard-shortcuts.ts +++ b/packages/app/src/hooks/use-keyboard-shortcuts.ts @@ -1,4 +1,4 @@ -import { useEffect, useMemo } from "react"; +import { useEffect, useMemo, useRef } from "react"; import { Platform } from "react-native"; import { usePathname } from "expo-router"; import { getIsDesktop } from "@/constants/layout"; @@ -17,7 +17,11 @@ import { } from "@/keyboard/actions"; import { canToggleFileExplorerShortcut } from "@/keyboard/keyboard-shortcut-routing"; import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher"; -import { resolveKeyboardShortcut, buildEffectiveBindings } from "@/keyboard/keyboard-shortcuts"; +import { + type ChordState, + resolveKeyboardShortcut, + buildEffectiveBindings, +} from "@/keyboard/keyboard-shortcuts"; import { resolveKeyboardFocusScope } from "@/keyboard/focus-scope"; import { getShortcutOs } from "@/utils/shortcut-platform"; import { useOpenProjectPicker } from "@/hooks/use-open-project-picker"; @@ -41,6 +45,11 @@ export function useKeyboardShortcuts({ const resetModifiers = useKeyboardShortcutsStore((s) => s.resetModifiers); const { overrides } = useKeyboardShortcutOverrides(); const bindings = useMemo(() => buildEffectiveBindings(overrides), [overrides]); + const chordStateRef = useRef({ + candidateIndices: [], + step: 0, + timeoutId: null, + }); const activeServerIdFromPath = parseServerIdFromPathname(pathname); const activeServerId = hosts.find((host) => host.serverId === activeServerIdFromPath)?.serverId ?? @@ -271,6 +280,11 @@ export function useKeyboardShortcuts({ return; } + const store = useKeyboardShortcutsStore.getState(); + if (store.capturingShortcut) { + return; + } + const key = event.key ?? ""; if (key === "Alt" && !event.shiftKey) { useKeyboardShortcutsStore.getState().setAltDown(true); @@ -285,12 +299,11 @@ export function useKeyboardShortcuts({ } } - const store = useKeyboardShortcutsStore.getState(); const focusScope = resolveKeyboardFocusScope({ target: event.target, commandCenterOpen: store.commandCenterOpen, }); - const match = resolveKeyboardShortcut({ + const result = resolveKeyboardShortcut({ event, context: { isMac, @@ -303,25 +316,41 @@ export function useKeyboardShortcuts({ toggleFileExplorer, }), }, + chordState: chordStateRef.current, + onChordReset: () => { + chordStateRef.current = { + candidateIndices: [], + step: 0, + timeoutId: null, + }; + }, bindings, }); - if (!match) { + + chordStateRef.current = result.nextChordState; + + if (result.preventDefault) { + event.preventDefault(); + event.stopPropagation(); + } + + if (!result.match) { return; } const handled = handleAction({ - action: match.action, - payload: match.payload, + action: result.match.action, + payload: result.match.payload, event, }); if (!handled) { return; } - if (match.preventDefault) { + if (result.match.preventDefault) { event.preventDefault(); } - if (match.stopPropagation) { + if (result.match.stopPropagation) { event.stopPropagation(); } }; @@ -345,6 +374,14 @@ export function useKeyboardShortcuts({ window.addEventListener("blur", handleBlurOrHide); document.addEventListener("visibilitychange", handleBlurOrHide); return () => { + if (chordStateRef.current.timeoutId !== null) { + clearTimeout(chordStateRef.current.timeoutId); + chordStateRef.current = { + candidateIndices: [], + step: 0, + timeoutId: null, + }; + } window.removeEventListener("keydown", handleKeyDown, true); window.removeEventListener("keyup", handleKeyUp, true); window.removeEventListener("blur", handleBlurOrHide); diff --git a/packages/app/src/hooks/use-shortcut-keys.ts b/packages/app/src/hooks/use-shortcut-keys.ts index 2cfa3044e..dd43ae9fc 100644 --- a/packages/app/src/hooks/use-shortcut-keys.ts +++ b/packages/app/src/hooks/use-shortcut-keys.ts @@ -1,12 +1,12 @@ import { useMemo } from "react"; import type { ShortcutKey } from "@/utils/format-shortcut"; -import { comboStringToShortcutKeys } from "@/keyboard/shortcut-string"; +import { chordStringToShortcutKeys } from "@/keyboard/shortcut-string"; import { getBindingIdForAction, getDefaultKeysForAction } from "@/keyboard/keyboard-shortcuts"; import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides"; import { getShortcutOs } from "@/utils/shortcut-platform"; import { getIsDesktop } from "@/constants/layout"; -export function useShortcutKeys(actionId: string): ShortcutKey[] | null { +export function useShortcutKeys(actionId: string): ShortcutKey[][] | null { const { overrides } = useKeyboardShortcutOverrides(); const isMac = getShortcutOs() === "mac"; const isDesktop = getIsDesktop(); @@ -18,9 +18,10 @@ export function useShortcutKeys(actionId: string): ShortcutKey[] | null { const override = overrides[bindingId]; if (override) { - return comboStringToShortcutKeys(override); + return chordStringToShortcutKeys(override); } - return getDefaultKeysForAction(actionId, platform); + const defaultKeys = getDefaultKeysForAction(actionId, platform); + return defaultKeys ? [defaultKeys] : null; }, [actionId, overrides, isMac, isDesktop]); } diff --git a/packages/app/src/keyboard/keyboard-shortcuts.test.ts b/packages/app/src/keyboard/keyboard-shortcuts.test.ts index 2e1d0a6a6..21bc67c72 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.test.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.test.ts @@ -1,8 +1,11 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { buildKeyboardShortcutHelpSections, + buildEffectiveBindings, resolveKeyboardShortcut, + type ChordState, type KeyboardShortcutContext, + type ParsedShortcutBinding, } from "./keyboard-shortcuts"; function keyboardEvent(overrides: Partial): KeyboardEvent { @@ -31,6 +34,30 @@ function shortcutContext( }; } +function initialChordState(): ChordState { + return { + candidateIndices: [], + step: 0, + timeoutId: null, + }; +} + +function resolveShortcut(input: { + event: Partial; + context?: Partial; + chordState?: ChordState; + onChordReset?: () => void; + bindings?: readonly ParsedShortcutBinding[]; +}) { + return resolveKeyboardShortcut({ + event: keyboardEvent(input.event), + context: shortcutContext(input.context), + chordState: input.chordState ?? initialChordState(), + onChordReset: input.onChordReset ?? (() => undefined), + ...(input.bindings ? { bindings: input.bindings } : {}), + }); +} + function expectShortcutResolution(input: { event: Partial; context?: Partial; @@ -39,29 +66,33 @@ function expectShortcutResolution(input: { preventDefault?: boolean; stopPropagation?: boolean; }) { - const match = resolveKeyboardShortcut({ - event: keyboardEvent(input.event), - context: shortcutContext(input.context), + const result = resolveShortcut({ + event: input.event, + context: input.context, }); - expect(match?.action).toBe(input.action); + expect(result.match?.action).toBe(input.action); if ("payload" in input) { - expect(match?.payload).toEqual(input.payload); + expect(result.match?.payload).toEqual(input.payload); } - expect(match?.preventDefault).toBe(input.preventDefault ?? true); - expect(match?.stopPropagation).toBe(input.stopPropagation ?? true); + expect(result.match?.preventDefault).toBe(input.preventDefault ?? true); + expect(result.match?.stopPropagation).toBe(input.stopPropagation ?? true); + expect(result.preventDefault).toBe(false); + expect(result.nextChordState).toEqual(initialChordState()); } function expectNoShortcutResolution(input: { event: Partial; context?: Partial; }) { - const match = resolveKeyboardShortcut({ - event: keyboardEvent(input.event), - context: shortcutContext(input.context), + const result = resolveShortcut({ + event: input.event, + context: input.context, }); - expect(match).toBeNull(); + expect(result.match).toBeNull(); + expect(result.preventDefault).toBe(false); + expect(result.nextChordState).toEqual(initialChordState()); } type MatchingShortcutCase = { @@ -338,6 +369,66 @@ describe("keyboard-shortcuts", () => { it.each(nonMatchingCases)("$name", ({ event, context }) => { expectNoShortcutResolution({ event, context }); }); + + it("prefers advancing chord candidates over single-combo matches on the same prefix", () => { + const bindings = buildEffectiveBindings({ + "workspace-terminal-new-ctrl-shift-t-non-mac": "Ctrl+W S", + }); + const chordBindingIndex = bindings.findIndex( + (binding) => binding.id === "workspace-terminal-new-ctrl-shift-t-non-mac", + ); + expect(chordBindingIndex).toBeGreaterThan(-1); + + const firstResult = resolveShortcut({ + event: { key: "w", code: "KeyW", ctrlKey: true }, + context: { isMac: false, isDesktop: true }, + bindings, + }); + + expect(firstResult.match).toBeNull(); + expect(firstResult.preventDefault).toBe(true); + expect(firstResult.nextChordState.step).toBe(1); + expect(firstResult.nextChordState.candidateIndices).toEqual([chordBindingIndex]); + + const secondResult = resolveShortcut({ + event: { key: "s", code: "KeyS" }, + context: { isMac: false, isDesktop: true }, + chordState: firstResult.nextChordState, + bindings, + }); + + expect(secondResult.match?.action).toBe("workspace.terminal.new"); + expect(secondResult.match?.payload).toBeNull(); + expect(secondResult.match?.preventDefault).toBe(true); + expect(secondResult.match?.stopPropagation).toBe(true); + expect(secondResult.preventDefault).toBe(false); + expect(secondResult.nextChordState).toEqual(initialChordState()); + }); + + it("schedules a chord reset timeout for advancing candidates", () => { + vi.useFakeTimers(); + + const bindings = buildEffectiveBindings({ + "workspace-terminal-new-ctrl-shift-t-non-mac": "Ctrl+W S", + }); + const onChordReset = vi.fn(); + + const result = resolveShortcut({ + event: { key: "w", code: "KeyW", ctrlKey: true }, + context: { isMac: false, isDesktop: true }, + onChordReset, + bindings, + }); + + expect(result.match).toBeNull(); + expect(result.preventDefault).toBe(true); + expect(result.nextChordState.timeoutId).not.toBeNull(); + + vi.advanceTimersByTime(1500); + + expect(onChordReset).toHaveBeenCalledTimes(1); + vi.useRealTimers(); + }); }); describe("keyboard-shortcut help sections", () => { diff --git a/packages/app/src/keyboard/keyboard-shortcuts.ts b/packages/app/src/keyboard/keyboard-shortcuts.ts index b016f6bd2..8a46d8189 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.ts @@ -5,7 +5,7 @@ import type { KeyboardShortcutPayload, MessageInputKeyboardActionKind, } from "@/keyboard/actions"; -import { type KeyCombo, parseShortcutString } from "@/keyboard/shortcut-string"; +import { type KeyCombo, parseChordString } from "@/keyboard/shortcut-string"; export type { KeyCombo } from "@/keyboard/shortcut-string"; @@ -88,7 +88,13 @@ interface ShortcutBinding { } export interface ParsedShortcutBinding extends ShortcutBinding { - parsedCombo: KeyCombo; + parsedChord: KeyCombo[]; +} + +export interface ChordState { + candidateIndices: number[]; + step: number; + timeoutId: ReturnType | null; } // --- Constants --- @@ -817,11 +823,12 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ // --- Parse bindings at module load --- function parseBinding(binding: ShortcutBinding): ParsedShortcutBinding { - const parsedCombo = parseShortcutString(binding.combo); - if (binding.repeat === false) { - parsedCombo.repeat = false; + const parsedChord = parseChordString(binding.combo); + const lastCombo = parsedChord.at(-1); + if (binding.repeat === false && lastCombo) { + lastCombo.repeat = false; } - return { ...binding, parsedCombo }; + return { ...binding, parsedChord }; } export const DEFAULT_BINDINGS: readonly ParsedShortcutBinding[] = @@ -835,16 +842,17 @@ export function buildEffectiveBindings( if (override === undefined) { return binding; } - let parsedCombo: KeyCombo; + let parsedChord: KeyCombo[]; try { - parsedCombo = parseShortcutString(override); + parsedChord = parseChordString(override); } catch { return binding; } - if (binding.repeat === false) { - parsedCombo.repeat = false; + const lastCombo = parsedChord.at(-1); + if (binding.repeat === false && lastCombo) { + lastCombo.repeat = false; } - return { ...binding, combo: override, parsedCombo }; + return { ...binding, combo: override, parsedChord }; }); } @@ -921,6 +929,27 @@ function resolvePayload( } } +const CHORD_TIMEOUT_MS = 1500; + +function clearChordTimeout(timeoutId: ReturnType | null): void { + if (timeoutId !== null) { + clearTimeout(timeoutId); + } +} + +function createChordTimeout(onChordReset: () => void): ReturnType { + return setTimeout(onChordReset, CHORD_TIMEOUT_MS); +} + +function resetChordState(input: ChordState): ChordState { + clearChordTimeout(input.timeoutId); + return { + candidateIndices: [], + step: 0, + timeoutId: null, + }; +} + function helpMatchesPlatform( when: ShortcutWhen | undefined, context: KeyboardShortcutPlatformContext, @@ -935,24 +964,131 @@ function helpMatchesPlatform( export function resolveKeyboardShortcut(input: { event: KeyboardEvent; context: KeyboardShortcutContext; + chordState: ChordState; + onChordReset: () => void; bindings?: readonly ParsedShortcutBinding[]; -}): KeyboardShortcutMatch | null { - const { event, context, bindings = DEFAULT_BINDINGS } = input; - for (const binding of bindings) { - if (!matchesCombo(binding.parsedCombo, event, context.isMac)) { +}): { + match: KeyboardShortcutMatch | null; + nextChordState: ChordState; + preventDefault: boolean; +}; +export function resolveKeyboardShortcut(input: { + event: KeyboardEvent; + context: KeyboardShortcutContext; + chordState: ChordState; + onChordReset: () => void; + bindings?: readonly ParsedShortcutBinding[]; +}): { + match: KeyboardShortcutMatch | null; + nextChordState: ChordState; + preventDefault: boolean; +} { + const { event, context, chordState, onChordReset, bindings = DEFAULT_BINDINGS } = input; + + if (chordState.step === 0) { + const advancingCandidateIndices: number[] = []; + let singleComboMatch: KeyboardShortcutMatch | null = null; + + for (const [index, binding] of bindings.entries()) { + const firstCombo = binding.parsedChord[0]; + if (!firstCombo) { + continue; + } + if (!matchesCombo(firstCombo, event, context.isMac)) { + continue; + } + if (!matchesWhen(binding.when, context)) { + continue; + } + if (binding.parsedChord.length > 1) { + advancingCandidateIndices.push(index); + continue; + } + if (!singleComboMatch) { + singleComboMatch = { + action: binding.action, + payload: resolvePayload(binding.payload, event), + preventDefault: binding.preventDefault ?? true, + stopPropagation: binding.stopPropagation ?? true, + }; + } + } + + if (advancingCandidateIndices.length > 0) { + return { + match: null, + nextChordState: { + candidateIndices: advancingCandidateIndices, + step: 1, + timeoutId: createChordTimeout(onChordReset), + }, + preventDefault: true, + }; + } + + return { + match: singleComboMatch, + nextChordState: resetChordState(chordState), + preventDefault: false, + }; + } + + const matchingCandidateIndices: number[] = []; + let completedMatch: KeyboardShortcutMatch | null = null; + + for (const index of chordState.candidateIndices) { + const binding = bindings[index]; + if (!binding) { + continue; + } + const combo = binding.parsedChord[chordState.step]; + if (!combo) { + continue; + } + if (!matchesCombo(combo, event, context.isMac)) { continue; } if (!matchesWhen(binding.when, context)) { continue; } + if (chordState.step + 1 === binding.parsedChord.length) { + completedMatch = { + action: binding.action, + payload: resolvePayload(binding.payload, event), + preventDefault: binding.preventDefault ?? true, + stopPropagation: binding.stopPropagation ?? true, + }; + break; + } + matchingCandidateIndices.push(index); + } + + if (completedMatch) { return { - action: binding.action, - payload: resolvePayload(binding.payload, event), - preventDefault: binding.preventDefault ?? true, - stopPropagation: binding.stopPropagation ?? true, + match: completedMatch, + nextChordState: resetChordState(chordState), + preventDefault: false, }; } - return null; + + if (matchingCandidateIndices.length > 0) { + clearChordTimeout(chordState.timeoutId); + return { + match: null, + nextChordState: { + candidateIndices: matchingCandidateIndices, + step: chordState.step + 1, + timeoutId: createChordTimeout(onChordReset), + }, + preventDefault: true, + }; + } + + return { + match: null, + nextChordState: resetChordState(chordState), + preventDefault: false, + }; } export function getBindingIdForAction( diff --git a/packages/app/src/keyboard/shortcut-string.ts b/packages/app/src/keyboard/shortcut-string.ts index f55bcb637..2fc3e2e4a 100644 --- a/packages/app/src/keyboard/shortcut-string.ts +++ b/packages/app/src/keyboard/shortcut-string.ts @@ -103,6 +103,10 @@ export function parseShortcutString(s: string): KeyCombo { return combo; } +export function parseChordString(s: string): KeyCombo[] { + return s.split(" ").map(parseShortcutString); +} + export function keyComboToString(combo: KeyCombo): string { const parts: string[] = []; @@ -122,6 +126,10 @@ export function keyComboToString(combo: KeyCombo): string { return parts.join("+"); } +export function chordToString(chord: KeyCombo[]): string { + return chord.map(keyComboToString).join(" "); +} + const MODIFIER_CODES = new Set([ "MetaLeft", "MetaRight", @@ -158,6 +166,10 @@ export function comboStringToShortcutKeys(comboString: string): ShortcutKey[] { return keys; } +export function chordStringToShortcutKeys(s: string): ShortcutKey[][] { + return s.split(" ").map(comboStringToShortcutKeys); +} + export function keyboardEventToComboString(event: KeyboardEvent): string | null { if (MODIFIER_CODES.has(event.code)) { return null; diff --git a/packages/app/src/screens/settings/keyboard-shortcuts-section.tsx b/packages/app/src/screens/settings/keyboard-shortcuts-section.tsx index 7b1d91759..5d4ca56f4 100644 --- a/packages/app/src/screens/settings/keyboard-shortcuts-section.tsx +++ b/packages/app/src/screens/settings/keyboard-shortcuts-section.tsx @@ -10,16 +10,31 @@ import { getBindingIdForAction, type KeyboardShortcutHelpRow, } from "@/keyboard/keyboard-shortcuts"; -import { comboStringToShortcutKeys, keyboardEventToComboString } from "@/keyboard/shortcut-string"; +import { + chordStringToShortcutKeys, + comboStringToShortcutKeys, + keyboardEventToComboString, +} from "@/keyboard/shortcut-string"; +import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { getShortcutOs } from "@/utils/shortcut-platform"; import { getIsDesktop } from "@/constants/layout"; +function ShortcutSequence({ chord }: { chord: string[] | null }) { + if (!chord || chord.length === 0) { + return Press shortcut...; + } + + return ; +} + function ShortcutRow({ row, bindingId, overrideCombo, isCapturing, + capturedCombos, onRebind, + onDone, onCancel, onReset, }: { @@ -27,29 +42,38 @@ function ShortcutRow({ bindingId: string | null; overrideCombo: string | undefined; isCapturing: boolean; + capturedCombos: string[]; onRebind: () => void; + onDone: () => void; onCancel: () => void; onReset: () => void; }) { - const displayKeys = overrideCombo ? comboStringToShortcutKeys(overrideCombo) : row.keys; + const displayChord = overrideCombo ? chordStringToShortcutKeys(overrideCombo) : [row.keys]; return ( {row.label} {isCapturing ? ( - Press shortcut... + ) : ( - + )} {bindingId !== null && ( - + <> + {isCapturing && capturedCombos.length > 0 ? ( + + ) : null} + + )} {overrideCombo !== undefined && !isCapturing && (