diff --git a/README.md b/README.md index cd68fab2..6d1077f0 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,8 @@ For the supported operator catalog—including boolean grouping, folder scoping, ### Keyboard shortcuts & previews +All shortcuts below are configurable in `Preferences -> Shortcuts`. The list shows default bindings. + - `Cmd+Shift+Space` – toggle the Cardinal window globally via the quick-launch hotkey. - `Cmd+,` – open Preferences. - `Esc` – hide the Cardinal window. @@ -56,6 +58,7 @@ For the supported operator catalog—including boolean grouping, folder scoping, - `Space` – Quick Look the currently selected row without leaving Cardinal. - `Cmd+O` – open the highlighted result. - `Cmd+R` – reveal the highlighted result in Finder. +- `Cmd+Shift+F` – copy selected file names. - `Cmd+C` – copy the selected files to the clipboard. - `Cmd+Shift+C` – copy the selected paths to the clipboard. - `Cmd+F` – jump focus back to the search bar. diff --git a/cardinal/package-lock.json b/cardinal/package-lock.json index 597030bf..63fff93b 100644 --- a/cardinal/package-lock.json +++ b/cardinal/package-lock.json @@ -3583,9 +3583,9 @@ } }, "node_modules/vite": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", - "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", + "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "dev": true, "license": "MIT", "dependencies": { diff --git a/cardinal/src/App.css b/cardinal/src/App.css index 52a186a2..ec8d3d52 100644 --- a/cardinal/src/App.css +++ b/cardinal/src/App.css @@ -1653,6 +1653,31 @@ button:active:not(:disabled) { outline-offset: 2px; } +.preferences-manage-button { + border: 1px solid var(--color-elevated-border, var(--color-border)); + background: var(--color-elevated-bg); + color: var(--color-text); + border-radius: 10px; + padding: 6px 12px; + font-size: 0.85rem; + cursor: pointer; + transition: + border-color 0.2s ease, + box-shadow 0.2s ease, + transform 0.15s ease; +} + +.preferences-manage-button:hover { + border-color: var(--color-accent); + box-shadow: 0 6px 14px rgba(15, 23, 42, 0.12); + transform: translateY(-1px); +} + +.preferences-manage-button:focus-visible { + outline: 2px solid rgba(var(--color-accent-rgb), 0.4); + outline-offset: 2px; +} + .preferences-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; @@ -1792,6 +1817,119 @@ button:active:not(:disabled) { border-color: var(--color-accent); } +/* === Shortcut Settings Modal === */ +.shortcut-settings-overlay { + position: fixed; + inset: 0; + z-index: 2300; + display: grid; + place-items: center; + padding: 32px; + background: rgba(15, 17, 26, 0.84); + backdrop-filter: blur(8px); +} + +.shortcut-settings-card { + width: 100%; + max-width: 520px; + max-height: calc(100vh - 96px); + background: var(--color-elevated-bg, var(--color-bg)); + color: var(--color-text); + border-radius: 16px; + padding: 24px; + box-shadow: 0 24px 64px rgba(15, 23, 42, 0.36); + display: flex; + flex-direction: column; +} + +.shortcut-settings-card__header { + padding-bottom: 12px; + margin-bottom: 20px; + border-bottom: 1px solid var(--color-border); +} + +.shortcut-settings-card__title { + font-size: 1.05rem; + font-weight: 600; + color: var(--color-header); +} + +.shortcut-settings-section { + display: flex; + flex-direction: column; + gap: 12px; + flex: 1; + overflow-y: auto; + padding-right: 4px; +} + +.shortcut-settings-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 16px; + padding: 4px 0; +} + +.shortcut-settings-row__details { + min-width: 0; +} + +.shortcut-settings-hint { + margin-top: 4px; + font-size: 0.82rem; + color: var(--color-muted); +} + +.shortcut-settings-capture-hint { + margin: 0; + font-size: 0.82rem; + color: var(--color-muted); +} + +.shortcut-settings-capture-button { + min-width: 220px; + border: 1px solid var(--color-elevated-border, var(--color-border)); + background: var(--color-elevated-bg); + color: var(--color-text); + border-radius: 8px; + padding: 7px 10px; + text-align: center; + font-size: 0.9rem; + font-weight: 600; + letter-spacing: 0.01em; + cursor: pointer; + transition: + border-color 0.2s ease, + box-shadow 0.2s ease; +} + +.shortcut-settings-capture-button:hover { + border-color: var(--color-accent); +} + +.shortcut-settings-capture-button:focus-visible { + outline: 2px solid rgba(var(--color-accent-rgb), 0.4); + outline-offset: 2px; +} + +.shortcut-settings-capture-button--recording { + border-color: var(--color-accent); + box-shadow: 0 0 0 2px rgba(var(--color-accent-rgb), 0.22); +} + +.shortcut-settings-error { + margin: 0; +} + +.shortcut-settings-card__footer { + margin-top: 20px; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; +} + /* === Utilities (extracted from inline JSX styles) === */ .flex-fill { flex: 1; diff --git a/cardinal/src/App.tsx b/cardinal/src/App.tsx index c2514ded..1838ef88 100644 --- a/cardinal/src/App.tsx +++ b/cardinal/src/App.tsx @@ -6,6 +6,7 @@ import { SearchBar } from './components/SearchBar'; import { FilesTabContent } from './components/FilesTabContent'; import { PermissionOverlay } from './components/PermissionOverlay'; import PreferencesOverlay from './components/PreferencesOverlay'; +import ShortcutSettingsOverlay from './components/ShortcutSettingsOverlay'; import StatusBar from './components/StatusBar'; import type { SearchResultItem } from './types/search'; import { useColumnResize } from './hooks/useColumnResize'; @@ -30,6 +31,7 @@ import { useAppPreferences } from './hooks/useAppPreferences'; import { useAppWindowListeners } from './hooks/useAppWindowListeners'; import { useFilesTabEffects } from './hooks/useFilesTabEffects'; import { useFilesTabState } from './hooks/useFilesTabState'; +import { useShortcutSettingsController } from './hooks/useShortcutSettingsController'; function App() { const { @@ -66,6 +68,13 @@ function App() { const { caseSensitive } = searchParams; const { eventColWidths, onEventResizeStart, autoFitEventColumns } = useEventColumnWidths(); const { t, i18n } = useTranslation(); + const { + isShortcutSettingsOpen, + shortcuts, + openShortcutSettings, + closeShortcutSettings, + handleShortcutSettingsSave, + } = useShortcutSettingsController(); // `resultsVersion` tracks raw backend search result-set changes. // `displayedResultsVersion` additionally tracks UI ordering/projection changes (e.g. sort toggle). const { @@ -96,6 +105,8 @@ function App() { } = useFilesTabState({ searchQuery: searchParams.query, queueSearch, + shortcuts, + shortcutsEnabled: !isShortcutSettingsOpen, }); const { filteredEvents } = useRecentFSEvents({ caseSensitive, @@ -128,12 +139,12 @@ function App() { const { showContextMenu: showFilesContextMenu, showHeaderContextMenu: showFilesHeaderContextMenu, - } = useContextMenu(autoFitColumns, toggleQuickLook); + } = useContextMenu(autoFitColumns, toggleQuickLook, shortcuts); const { showContextMenu: showEventsContextMenu, showHeaderContextMenu: showEventsHeaderContextMenu, - } = useContextMenu(autoFitEventColumns); + } = useContextMenu(autoFitEventColumns, undefined, shortcuts); const { status: fullDiskAccessStatus, @@ -194,6 +205,8 @@ function App() { activeTab, selectedPaths, selectedIndicesRef, + shortcuts, + enabled: !isShortcutSettingsOpen, focusSearchInput, navigateSelection, triggerQuickLook, @@ -333,7 +346,10 @@ function App() { return ( <> -
+
+ {showFullDiskAccessOverlay && ( ({ default: () => null, })); +vi.mock('../components/ShortcutSettingsOverlay', () => ({ + default: () => null, +})); + vi.mock('../components/StatusBar', () => ({ default: () => null, })); +vi.mock('../tray', () => ({ + refreshTrayMenu: vi.fn(), +})); + +vi.mock('../menu', () => ({ + refreshAppMenu: vi.fn(), + setMenuShortcutsEnabled: vi.fn(), +})); + vi.mock('../components/FSEventsPanel', () => ({ default: forwardRef(function MockFSEventsPanel( { diff --git a/cardinal/src/__tests__/shortcuts.test.ts b/cardinal/src/__tests__/shortcuts.test.ts new file mode 100644 index 00000000..8404ddef --- /dev/null +++ b/cardinal/src/__tests__/shortcuts.test.ts @@ -0,0 +1,59 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { + DEFAULT_SHORTCUTS, + SHORTCUTS_STORAGE_KEY, + getStoredShortcutAccelerators, + getStoredShortcuts, + persistShortcuts, +} from '../shortcuts'; + +describe('shortcuts storage', () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + it('hydrates defaults when storage is empty', () => { + expect(getStoredShortcuts()).toEqual(DEFAULT_SHORTCUTS); + }); + + it('persists and restores local shortcut overrides', () => { + persistShortcuts({ + ...DEFAULT_SHORTCUTS, + openResult: 'Command+P', + searchHistoryUp: 'Control+K', + }); + + expect(getStoredShortcuts()).toMatchObject({ + ...DEFAULT_SHORTCUTS, + openResult: 'Command+P', + searchHistoryUp: 'Control+K', + }); + }); + + it('writes unified shortcuts to localStorage', () => { + persistShortcuts(DEFAULT_SHORTCUTS); + + expect(window.localStorage.getItem(SHORTCUTS_STORAGE_KEY)).toBeTruthy(); + }); + + it('falls back to defaults when storage payload is invalid', () => { + window.localStorage.setItem(SHORTCUTS_STORAGE_KEY, '{bad json'); + + expect(getStoredShortcuts()).toEqual(DEFAULT_SHORTCUTS); + }); + + it('builds app menu/tray accelerator values from stored shortcuts', () => { + persistShortcuts({ + ...DEFAULT_SHORTCUTS, + openPreferences: 'Command+Comma', + hideWindow: 'Esc', + quickLaunch: 'Command+Shift+Space', + }); + + expect(getStoredShortcutAccelerators()).toEqual({ + quickLaunch: 'Command+Shift+Space', + openPreferences: 'Cmd+,', + hideWindow: 'Esc', + }); + }); +}); diff --git a/cardinal/src/components/PreferencesOverlay.tsx b/cardinal/src/components/PreferencesOverlay.tsx index 99bd3fed..e75d34b8 100644 --- a/cardinal/src/components/PreferencesOverlay.tsx +++ b/cardinal/src/components/PreferencesOverlay.tsx @@ -7,6 +7,7 @@ import LanguageSwitcher from './LanguageSwitcher'; type PreferencesOverlayProps = { open: boolean; onClose: () => void; + onOpenShortcutSettings: () => void; sortThreshold: number; defaultSortThreshold: number; onSortThresholdChange: (value: number) => void; @@ -24,6 +25,7 @@ type PreferencesOverlayProps = { export function PreferencesOverlay({ open, onClose, + onOpenShortcutSettings, sortThreshold, defaultSortThreshold, onSortThresholdChange, @@ -170,6 +172,16 @@ export function PreferencesOverlay({

{t('preferences.language')}

+
+

{t('preferences.shortcuts.label')}

+ +

{t('preferences.trayIcon.label')}

diff --git a/cardinal/src/components/ShortcutSettingsOverlay.tsx b/cardinal/src/components/ShortcutSettingsOverlay.tsx new file mode 100644 index 00000000..32a6ac7d --- /dev/null +++ b/cardinal/src/components/ShortcutSettingsOverlay.tsx @@ -0,0 +1,226 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + DEFAULT_SHORTCUTS, + SHORTCUT_DEFINITIONS, + type ShortcutId, + type ShortcutMap, +} from '../shortcuts'; +import { + captureShortcutFromKeydown, + formatShortcutForDisplay, + type ShortcutCaptureError, +} from '../utils/shortcutCapture'; + +type ShortcutSettingsOverlayProps = { + open: boolean; + onClose: () => void; + shortcuts: ShortcutMap; + onShortcutSettingsSave: (shortcuts: ShortcutMap) => Promise; +}; + +const CAPTURE_ERROR_KEY_MAP: Record = { + modifierRequired: 'shortcutSettings.errors.modifierRequired', + keyRequired: 'shortcutSettings.errors.keyRequired', + unsupportedKey: 'shortcutSettings.errors.unsupportedKey', +}; + +export function ShortcutSettingsOverlay({ + open, + onClose, + shortcuts, + onShortcutSettingsSave, +}: ShortcutSettingsOverlayProps): React.JSX.Element | null { + const { t } = useTranslation(); + const recordingText = t('shortcutSettings.recording'); + const [draftShortcuts, setDraftShortcuts] = useState(shortcuts); + const [recordingShortcutId, setRecordingShortcutId] = useState(null); + const [captureError, setCaptureError] = useState(null); + const [submitError, setSubmitError] = useState(null); + const [isSaving, setIsSaving] = useState(false); + + useEffect(() => { + if (!open) { + return; + } + setDraftShortcuts(shortcuts); + setRecordingShortcutId(null); + setCaptureError(null); + setSubmitError(null); + }, [open, shortcuts]); + + useEffect(() => { + if (!open || !recordingShortcutId) { + return; + } + + const handleKeydown = (event: KeyboardEvent): void => { + event.preventDefault(); + event.stopPropagation(); + + const result = captureShortcutFromKeydown(event, false); + if (result.error) { + setCaptureError(result.error); + return; + } + + setCaptureError(null); + setSubmitError(null); + setDraftShortcuts((prev) => ({ + ...prev, + [recordingShortcutId]: result.shortcut, + })); + setRecordingShortcutId(null); + }; + + window.addEventListener('keydown', handleKeydown, true); + return () => window.removeEventListener('keydown', handleKeydown, true); + }, [open, recordingShortcutId]); + + const handleOverlayClick = (event: React.MouseEvent): void => { + if (event.target === event.currentTarget && !isSaving && !recordingShortcutId) { + onClose(); + } + }; + + const handleDialogKeyDown = (event: React.KeyboardEvent): void => { + if (recordingShortcutId) { + event.stopPropagation(); + return; + } + + if (event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); + if (!isSaving) { + onClose(); + } + return; + } + event.stopPropagation(); + }; + + const handleReset = (): void => { + setCaptureError(null); + setSubmitError(null); + setRecordingShortcutId(null); + setDraftShortcuts(DEFAULT_SHORTCUTS); + }; + + const handleCaptureButtonClick = useCallback((shortcutId: ShortcutId): void => { + setCaptureError(null); + setSubmitError(null); + setRecordingShortcutId((current) => (current === shortcutId ? null : shortcutId)); + }, []); + + const handleSave = useCallback(async (): Promise => { + if (isSaving || recordingShortcutId) { + return; + } + + setSubmitError(null); + setIsSaving(true); + try { + await onShortcutSettingsSave(draftShortcuts); + onClose(); + } catch (error) { + console.error('Failed to update shortcut settings', error); + setSubmitError(t('shortcutSettings.errors.registerFailed')); + } finally { + setIsSaving(false); + } + }, [draftShortcuts, isSaving, onClose, onShortcutSettingsSave, recordingShortcutId, t]); + + if (!open) { + return null; + } + + return ( +
+
+
+

{t('shortcutSettings.title')}

+
+ +
+

{t('shortcutSettings.captureHint')}

+ {SHORTCUT_DEFINITIONS.map((shortcutId) => { + const labelKey = `shortcutSettings.items.${shortcutId}.label`; + const descriptionKey = `shortcutSettings.items.${shortcutId}.description`; + const label = t(labelKey); + const isRecording = recordingShortcutId === shortcutId; + const buttonText = isRecording + ? recordingText + : formatShortcutForDisplay(draftShortcuts[shortcutId]); + + return ( +
+
+

{label}

+

{t(descriptionKey)}

+
+
+ +
+
+ ); + })} + {captureError ? ( +

+ {t(CAPTURE_ERROR_KEY_MAP[captureError])} +

+ ) : null} + {submitError ? ( +

+ {submitError} +

+ ) : null} +
+ +
+ + +
+
+
+ ); +} + +export default ShortcutSettingsOverlay; diff --git a/cardinal/src/components/__tests__/PreferencesOverlay.test.tsx b/cardinal/src/components/__tests__/PreferencesOverlay.test.tsx index 5331cb89..e389a810 100644 --- a/cardinal/src/components/__tests__/PreferencesOverlay.test.tsx +++ b/cardinal/src/components/__tests__/PreferencesOverlay.test.tsx @@ -21,6 +21,7 @@ vi.mock('../LanguageSwitcher', () => ({ const baseProps = { open: true, onClose: vi.fn(), + onOpenShortcutSettings: vi.fn(), sortThreshold: 200, defaultSortThreshold: 100, onSortThresholdChange: vi.fn(), @@ -36,6 +37,15 @@ const baseProps = { }; describe('PreferencesOverlay', () => { + it('opens shortcut settings from the dedicated row action', () => { + const onOpenShortcutSettings = vi.fn(); + render(); + + fireEvent.click(screen.getByText('preferences.shortcuts.configure')); + + expect(onOpenShortcutSettings).toHaveBeenCalledTimes(1); + }); + it('saves watch root updates via onWatchConfigChange', () => { const onWatchConfigChange = vi.fn(); render(); diff --git a/cardinal/src/components/__tests__/ShortcutSettingsOverlay.test.tsx b/cardinal/src/components/__tests__/ShortcutSettingsOverlay.test.tsx new file mode 100644 index 00000000..674622e1 --- /dev/null +++ b/cardinal/src/components/__tests__/ShortcutSettingsOverlay.test.tsx @@ -0,0 +1,55 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { DEFAULT_SHORTCUTS } from '../../shortcuts'; +import { ShortcutSettingsOverlay } from '../ShortcutSettingsOverlay'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +describe('ShortcutSettingsOverlay', () => { + it('records shortcut after selecting an item and saves full shortcut map', async () => { + const onShortcutSettingsSave = vi.fn().mockResolvedValue(undefined); + render( + , + ); + + fireEvent.click(screen.getByText('Cmd+Shift+Space')); + expect(screen.getByText('shortcutSettings.recording')).toBeInTheDocument(); + + fireEvent.keyDown(window, { key: 'k', metaKey: true, shiftKey: true }); + expect(screen.getByText('Cmd+Shift+K')).toBeInTheDocument(); + + fireEvent.click(screen.getByText('shortcutSettings.save')); + + await waitFor(() => { + expect(onShortcutSettingsSave).toHaveBeenCalledWith({ + ...DEFAULT_SHORTCUTS, + quickLaunch: 'Command+Shift+K', + }); + }); + }); + + it('shows capture error for unsupported keys', () => { + render( + , + ); + + fireEvent.click(screen.getByText('Cmd+Shift+Space')); + fireEvent.keyDown(window, { key: 'Dead' }); + + expect(screen.getByText('shortcutSettings.errors.unsupportedKey')).toBeInTheDocument(); + }); +}); diff --git a/cardinal/src/hooks/__tests__/useAppHotkeys.test.ts b/cardinal/src/hooks/__tests__/useAppHotkeys.test.ts index 9f5b8978..84646ca2 100644 --- a/cardinal/src/hooks/__tests__/useAppHotkeys.test.ts +++ b/cardinal/src/hooks/__tests__/useAppHotkeys.test.ts @@ -2,6 +2,7 @@ import { act, renderHook, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { invoke } from '@tauri-apps/api/core'; import { subscribeQuickLookKeydown } from '../../runtime/tauriEventRuntime'; +import { DEFAULT_SHORTCUTS, type ShortcutMap } from '../../shortcuts'; import { openResultPath } from '../../utils/openResultPath'; import { useAppHotkeys } from '../useAppHotkeys'; @@ -25,6 +26,8 @@ type HookProps = { activeTab: 'files' | 'events'; selectedPaths: string[]; selectedIndicesRef: { current: number[] }; + shortcuts: ShortcutMap; + enabled: boolean; focusSearchInput: () => void; navigateSelection: (delta: 1 | -1, options?: { extend?: boolean }) => void; triggerQuickLook: () => void; @@ -44,6 +47,8 @@ describe('useAppHotkeys', () => { activeTab: 'files', selectedPaths: ['/tmp/a', '/tmp/b'], selectedIndicesRef: { current: [0] }, + shortcuts: DEFAULT_SHORTCUTS, + enabled: true, focusSearchInput, navigateSelection, triggerQuickLook, @@ -62,7 +67,13 @@ describe('useAppHotkeys', () => { }); }); - it('handles Meta+F, Meta+R, Meta+O, and Meta+C shortcuts on files tab', async () => { + it('handles Meta+F, Meta+R, Meta+O, Meta+Shift+F, and Meta+C shortcuts on files tab', async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(globalThis.navigator, 'clipboard', { + value: { writeText }, + configurable: true, + }); + renderHotkeys(); const findEvent = new KeyboardEvent('keydown', { @@ -109,6 +120,17 @@ describe('useAppHotkeys', () => { expect(mockedInvoke).toHaveBeenCalledWith('copy_files_to_clipboard', { paths: ['/tmp/a', '/tmp/b'], }); + + const copyFilenamesEvent = new KeyboardEvent('keydown', { + key: 'f', + metaKey: true, + shiftKey: true, + cancelable: true, + }); + act(() => { + window.dispatchEvent(copyFilenamesEvent); + }); + expect(writeText).toHaveBeenCalledWith('a b'); }); it('does not override native copy shortcuts inside editable fields', () => { @@ -217,6 +239,8 @@ describe('useAppHotkeys', () => { activeTab: 'events', selectedPaths: ['/tmp/a', '/tmp/b'], selectedIndicesRef: { current: [0] }, + shortcuts: DEFAULT_SHORTCUTS, + enabled: true, focusSearchInput, navigateSelection, triggerQuickLook, diff --git a/cardinal/src/hooks/__tests__/useContextMenu.test.tsx b/cardinal/src/hooks/__tests__/useContextMenu.test.tsx index dc682987..2dd3eb69 100644 --- a/cardinal/src/hooks/__tests__/useContextMenu.test.tsx +++ b/cardinal/src/hooks/__tests__/useContextMenu.test.tsx @@ -3,6 +3,7 @@ import type { ReactNode } from 'react'; import { I18nextProvider } from 'react-i18next'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import i18n from '../../i18n/config'; +import { DEFAULT_SHORTCUTS } from '../../shortcuts'; import { useContextMenu } from '../useContextMenu'; const mocks = vi.hoisted(() => ({ @@ -49,7 +50,9 @@ describe('useContextMenu', () => { }); it('uses plural Copy Paths label and shortcut when multiple paths are selected', async () => { - const { result } = renderHook(() => useContextMenu(null), { wrapper }); + const { result } = renderHook(() => useContextMenu(null, undefined, DEFAULT_SHORTCUTS), { + wrapper, + }); result.current.showContextMenu(createEvent(), ['/a', '/b']); @@ -63,12 +66,16 @@ describe('useContextMenu', () => { accelerator?: string; }>; const copyPaths = items.find((item) => item.id === 'context_menu.copy_paths'); + const copyFilename = items.find((item) => item.id === 'context_menu.copy_filename'); expect(copyPaths?.text).toBe('Copy Paths'); expect(copyPaths?.accelerator).toBe('Cmd+Shift+C'); + expect(copyFilename?.accelerator).toBe('Cmd+Shift+F'); }); it('uses singular Copy Path label when a single path is targeted', async () => { - const { result } = renderHook(() => useContextMenu(null), { wrapper }); + const { result } = renderHook(() => useContextMenu(null, undefined, DEFAULT_SHORTCUTS), { + wrapper, + }); result.current.showContextMenu(createEvent(), ['/a']); @@ -92,7 +99,9 @@ describe('useContextMenu', () => { configurable: true, }); - const { result } = renderHook(() => useContextMenu(null), { wrapper }); + const { result } = renderHook(() => useContextMenu(null, undefined, DEFAULT_SHORTCUTS), { + wrapper, + }); result.current.showContextMenu(createEvent(), ['', '/clicked']); @@ -119,7 +128,9 @@ describe('useContextMenu', () => { configurable: true, }); - const { result } = renderHook(() => useContextMenu(null), { wrapper }); + const { result } = renderHook(() => useContextMenu(null, undefined, DEFAULT_SHORTCUTS), { + wrapper, + }); result.current.showContextMenu(createEvent(), ['/a', '/b']); diff --git a/cardinal/src/hooks/__tests__/useFilesTabState.test.ts b/cardinal/src/hooks/__tests__/useFilesTabState.test.ts index 74f8812e..89ea1c6e 100644 --- a/cardinal/src/hooks/__tests__/useFilesTabState.test.ts +++ b/cardinal/src/hooks/__tests__/useFilesTabState.test.ts @@ -1,6 +1,7 @@ import { act, renderHook } from '@testing-library/react'; import type { ChangeEvent, KeyboardEvent as ReactKeyboardEvent } from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { DEFAULT_SHORTCUTS, type ShortcutMap } from '../../shortcuts'; import { useFilesTabState } from '../useFilesTabState'; import { useSearchHistory } from '../useSearchHistory'; @@ -19,6 +20,8 @@ type HookProps = { onSearchCommitted?: (query: string) => void; }, ) => void; + shortcuts: ShortcutMap; + shortcutsEnabled: boolean; }; describe('useFilesTabState', () => { @@ -33,6 +36,8 @@ describe('useFilesTabState', () => { initialProps: { searchQuery: 'needle', queueSearch, + shortcuts: DEFAULT_SHORTCUTS, + shortcutsEnabled: true, ...overrides, }, }); @@ -69,6 +74,8 @@ describe('useFilesTabState', () => { rerender({ searchQuery: 'needle-updated', queueSearch, + shortcuts: DEFAULT_SHORTCUTS, + shortcutsEnabled: true, }); expect(result.current.searchInputValue).toBe('evt'); diff --git a/cardinal/src/hooks/useAppHotkeys.ts b/cardinal/src/hooks/useAppHotkeys.ts index ac71dd71..2c25e701 100644 --- a/cardinal/src/hooks/useAppHotkeys.ts +++ b/cardinal/src/hooks/useAppHotkeys.ts @@ -2,11 +2,20 @@ import { useEffect, useRef } from 'react'; import type { MutableRefObject } from 'react'; import { invoke } from '@tauri-apps/api/core'; import type { StatusTabKey } from '../components/StatusBar'; +import type { ShortcutId, ShortcutMap } from '../shortcuts'; import { subscribeQuickLookKeydown, type QuickLookKeydownPayload, } from '../runtime/tauriEventRuntime'; -import { openResultPath } from '../utils/openResultPath'; +import { + copyFilesToClipboard, + copyFilenamesToClipboard, + copyPathsToClipboard, + openPaths, + revealPathsInFinder, +} from '../utils/fileActions'; +import { openPreferences } from '../utils/openPreferences'; +import { shortcutMatchesKeydown } from '../utils/shortcutCapture'; import { useStableEvent } from './useStableEvent'; type MoveSelectionOptions = { @@ -17,11 +26,18 @@ type UseAppHotkeysOptions = { activeTab: StatusTabKey; selectedPaths: string[]; selectedIndicesRef: MutableRefObject; + shortcuts: ShortcutMap; + enabled: boolean; focusSearchInput: () => void; navigateSelection: (delta: 1 | -1, options?: MoveSelectionOptions) => void; triggerQuickLook: () => void; }; +type ShortcutRule = { + id: ShortcutId; + run: (event: KeyboardEvent) => void | boolean; +}; + const QUICK_LOOK_KEYCODE_DOWN = 125; const QUICK_LOOK_KEYCODE_UP = 126; @@ -32,93 +48,176 @@ const isEditableTarget = (target: EventTarget | null): boolean => { return tagName === 'INPUT' || tagName === 'TEXTAREA' || element.isContentEditable; }; +const runShortcutRules = ( + event: KeyboardEvent, + shortcutConfig: ShortcutMap, + rules: ShortcutRule[], +): boolean => { + for (const rule of rules) { + if (!shortcutMatchesKeydown(event, shortcutConfig[rule.id])) { + continue; + } + return rule.run(event) !== false; + } + return false; +}; + export function useAppHotkeys({ activeTab, selectedPaths, selectedIndicesRef, + shortcuts, + enabled, focusSearchInput, navigateSelection, triggerQuickLook, }: UseAppHotkeysOptions): void { - const keyboardStateRef = useRef<{ activeTab: StatusTabKey }>({ + const keyboardStateRef = useRef<{ + activeTab: StatusTabKey; + shortcuts: ShortcutMap; + enabled: boolean; + }>({ activeTab, + shortcuts, + enabled, }); useEffect(() => { keyboardStateRef.current.activeTab = activeTab; - }, [activeTab]); - - const handleMetaShortcut = useStableEvent((event: KeyboardEvent, currentTab: StatusTabKey) => { - const key = event.key.toLowerCase(); - if (key === 'f') { - event.preventDefault(); - focusSearchInput(); - return true; - } - - // Preserve native copy/edit behavior when focus is inside an editable control. - // Meta+F is intentionally handled above to focus the app search input. - if (isEditableTarget(event.target)) { - return false; - } - - if (currentTab !== 'files') { - return false; - } - - if (key === 'r' && selectedPaths.length > 0) { - event.preventDefault(); - selectedPaths.forEach((path) => { - void invoke('open_in_finder', { path }); - }); - return true; - } - - if (key === 'o' && selectedPaths.length > 0) { - event.preventDefault(); - selectedPaths.forEach((path) => openResultPath(path)); - return true; - } - - if (key === 'c' && selectedPaths.length > 0) { - event.preventDefault(); - void invoke('copy_files_to_clipboard', { paths: selectedPaths }).catch((error) => { - console.error('Failed to copy files to clipboard', error); - }); - return true; - } - - return false; - }); - - const handleFilesNavigation = useStableEvent((event: KeyboardEvent) => { - const target = event.target as HTMLElement | null; - if (isEditableTarget(target)) { - return false; - } + keyboardStateRef.current.shortcuts = shortcuts; + keyboardStateRef.current.enabled = enabled; + }, [activeTab, enabled, shortcuts]); - const isSpaceKey = event.code === 'Space' || event.key === ' '; - if (isSpaceKey) { - if (event.repeat || !selectedIndicesRef.current.length) { - return true; - } - event.preventDefault(); - triggerQuickLook(); - return true; - } + const handleWindowShortcuts = useStableEvent( + (event: KeyboardEvent, shortcutConfig: ShortcutMap) => { + return runShortcutRules(event, shortcutConfig, [ + { + id: 'openPreferences', + run: (keyboardEvent) => { + keyboardEvent.preventDefault(); + openPreferences(); + }, + }, + { + id: 'hideWindow', + run: (keyboardEvent) => { + keyboardEvent.preventDefault(); + void invoke('hide_main_window'); + }, + }, + { + id: 'focusSearch', + run: (keyboardEvent) => { + keyboardEvent.preventDefault(); + focusSearchInput(); + }, + }, + ]); + }, + ); - if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { - if (event.altKey || event.ctrlKey || event.metaKey) { - return true; + const handleFilesShortcuts = useStableEvent( + (event: KeyboardEvent, shortcutConfig: ShortcutMap) => { + const target = event.target as HTMLElement | null; + // Preserve native copy/edit behavior when focus is inside an editable control. + // Focus-search is handled earlier by `handleWindowShortcuts`. + if (isEditableTarget(target)) { + return false; } - event.preventDefault(); - const delta = event.key === 'ArrowDown' ? 1 : -1; - navigateSelection(delta, { extend: event.shiftKey }); - return true; - } - return false; - }); + return runShortcutRules(event, shortcutConfig, [ + { + id: 'revealInFinder', + run: (keyboardEvent) => { + if (selectedPaths.length === 0) { + return false; + } + keyboardEvent.preventDefault(); + revealPathsInFinder(selectedPaths); + }, + }, + { + id: 'openResult', + run: (keyboardEvent) => { + if (selectedPaths.length === 0) { + return false; + } + keyboardEvent.preventDefault(); + openPaths(selectedPaths); + }, + }, + { + id: 'copyFilenames', + run: (keyboardEvent) => { + if (selectedPaths.length === 0) { + return false; + } + keyboardEvent.preventDefault(); + copyFilenamesToClipboard(selectedPaths); + }, + }, + { + id: 'copyPaths', + run: (keyboardEvent) => { + if (selectedPaths.length === 0) { + return false; + } + keyboardEvent.preventDefault(); + copyPathsToClipboard(selectedPaths); + }, + }, + { + id: 'copyFiles', + run: (keyboardEvent) => { + if (selectedPaths.length === 0) { + return false; + } + keyboardEvent.preventDefault(); + copyFilesToClipboard(selectedPaths); + }, + }, + { + id: 'quickLook', + run: (keyboardEvent) => { + if (keyboardEvent.repeat || !selectedIndicesRef.current.length) { + return true; + } + keyboardEvent.preventDefault(); + triggerQuickLook(); + return true; + }, + }, + { + id: 'moveSelectionDown', + run: (keyboardEvent) => { + keyboardEvent.preventDefault(); + navigateSelection(1, { extend: false }); + }, + }, + { + id: 'moveSelectionUp', + run: (keyboardEvent) => { + keyboardEvent.preventDefault(); + navigateSelection(-1, { extend: false }); + }, + }, + { + id: 'extendSelectionDown', + run: (keyboardEvent) => { + keyboardEvent.preventDefault(); + navigateSelection(1, { extend: true }); + }, + }, + { + id: 'extendSelectionUp', + run: (keyboardEvent) => { + keyboardEvent.preventDefault(); + navigateSelection(-1, { extend: true }); + }, + }, + ]); + }, + ); useEffect(() => { if (typeof window === 'undefined') { @@ -126,9 +225,17 @@ export function useAppHotkeys({ } const handleKeyDown = (event: KeyboardEvent) => { - const { activeTab: currentTab } = keyboardStateRef.current; + const { + activeTab: currentTab, + shortcuts: shortcutConfig, + enabled: shortcutsEnabled, + } = keyboardStateRef.current; + + if (!shortcutsEnabled) { + return; + } - if (event.metaKey && handleMetaShortcut(event, currentTab)) { + if (handleWindowShortcuts(event, shortcutConfig)) { return; } @@ -136,17 +243,17 @@ export function useAppHotkeys({ return; } - if (handleFilesNavigation(event)) { + if (handleFilesShortcuts(event, shortcutConfig)) { return; } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); - }, [handleMetaShortcut, handleFilesNavigation]); + }, [handleFilesShortcuts, handleWindowShortcuts]); const handleQuickLookKeydown = useStableEvent((payload: QuickLookKeydownPayload) => { - if (keyboardStateRef.current.activeTab !== 'files') { + if (keyboardStateRef.current.activeTab !== 'files' || !keyboardStateRef.current.enabled) { return; } diff --git a/cardinal/src/hooks/useContextMenu.ts b/cardinal/src/hooks/useContextMenu.ts index 058cec73..17d008ba 100644 --- a/cardinal/src/hooks/useContextMenu.ts +++ b/cardinal/src/hooks/useContextMenu.ts @@ -1,28 +1,36 @@ import { useCallback } from 'react'; import type { MouseEvent as ReactMouseEvent } from 'react'; -import { invoke } from '@tauri-apps/api/core'; import { Menu } from '@tauri-apps/api/menu'; import type { MenuItemOptions } from '@tauri-apps/api/menu'; import { useTranslation } from 'react-i18next'; -import { openResultPath } from '../utils/openResultPath'; -import { splitPath } from '../utils/path'; +import type { ShortcutId, ShortcutMap } from '../shortcuts'; +import { + copyFilesToClipboard, + copyFilenamesToClipboard, + copyPathsToClipboard, + openPaths, + revealPathsInFinder, +} from '../utils/fileActions'; +import { formatShortcutForDisplay } from '../utils/shortcutCapture'; type UseContextMenuResult = { showContextMenu: (event: ReactMouseEvent, targetPaths: string[]) => void; showHeaderContextMenu: (event: ReactMouseEvent) => void; }; +type FileMenuActionDefinition = { + id: string; + text: string; + shortcutId: ShortcutId; + action: () => void; +}; + export function useContextMenu( autoFitColumns: (() => void) | null = null, - onQuickLookRequest?: () => void | Promise, + onQuickLookRequest: (() => void | Promise) | undefined, + shortcuts: ShortcutMap, ): UseContextMenuResult { const { t } = useTranslation(); - const writeClipboard = useCallback((text: string) => { - if (!navigator?.clipboard?.writeText) { - return; - } - void navigator.clipboard.writeText(text); - }, []); const buildFileMenuItems = useCallback( (targetPathsInput: string[]): MenuItemOptions[] => { @@ -30,75 +38,76 @@ export function useContextMenu( if (targetPaths.length === 0) { return []; } + const copyLabel = targetPaths.length > 1 ? t('contextMenu.copyFiles') : t('contextMenu.copyFile'); const copyFilenameLabel = targetPaths.length > 1 ? t('contextMenu.copyFilenames') : t('contextMenu.copyFilename'); const copyPathLabel = targetPaths.length > 1 ? t('contextMenu.copyPaths') : t('contextMenu.copyPath'); - const items: MenuItemOptions[] = [ + + const definitions: FileMenuActionDefinition[] = [ { id: 'context_menu.open_item', text: t('contextMenu.openItem'), - accelerator: 'Cmd+O', + shortcutId: 'openResult', action: () => { - targetPaths.forEach((itemPath) => openResultPath(itemPath)); + openPaths(targetPaths); }, }, { id: 'context_menu.open_in_finder', text: t('contextMenu.revealInFinder'), - accelerator: 'Cmd+R', + shortcutId: 'revealInFinder', action: () => { - targetPaths.forEach((itemPath) => { - void invoke('open_in_finder', { path: itemPath }); - }); + revealPathsInFinder(targetPaths); }, }, { id: 'context_menu.copy_filename', text: copyFilenameLabel, + shortcutId: 'copyFilenames', action: () => { - const filenames = targetPaths - .map((itemPath) => splitPath(itemPath).name || itemPath) - .join(' '); - writeClipboard(filenames); + copyFilenamesToClipboard(targetPaths); }, }, { id: 'context_menu.copy_paths', text: copyPathLabel, - accelerator: 'Cmd+Shift+C', + shortcutId: 'copyPaths', action: () => { - writeClipboard(targetPaths.join('\n')); + copyPathsToClipboard(targetPaths); }, }, { id: 'context_menu.copy_files', text: copyLabel, - accelerator: 'Cmd+C', + shortcutId: 'copyFiles', action: () => { - void invoke('copy_files_to_clipboard', { paths: targetPaths }).catch((error) => { - console.error('Failed to copy files to clipboard', error); - }); + copyFilesToClipboard(targetPaths); }, }, ]; if (onQuickLookRequest) { - items.push({ + definitions.push({ id: 'context_menu.quicklook', text: t('contextMenu.quickLook'), - accelerator: 'Space', + shortcutId: 'quickLook', action: () => { void onQuickLookRequest(); }, }); } - return items; + return definitions.map((definition) => ({ + id: definition.id, + text: definition.text, + accelerator: formatShortcutForDisplay(shortcuts[definition.shortcutId]), + action: definition.action, + })); }, - [onQuickLookRequest, t, writeClipboard], + [onQuickLookRequest, shortcuts, t], ); const buildHeaderMenuItems = useCallback((): MenuItemOptions[] => { diff --git a/cardinal/src/hooks/useFilesTabState.ts b/cardinal/src/hooks/useFilesTabState.ts index 008d811b..a236390d 100644 --- a/cardinal/src/hooks/useFilesTabState.ts +++ b/cardinal/src/hooks/useFilesTabState.ts @@ -1,6 +1,8 @@ import { useCallback, useMemo, useState } from 'react'; import type { ChangeEvent, KeyboardEvent as ReactKeyboardEvent } from 'react'; import type { StatusTabKey } from '../components/StatusBar'; +import type { ShortcutMap } from '../shortcuts'; +import { shortcutMatchesKeydown } from '../utils/shortcutCapture'; import { useSearchHistory } from './useSearchHistory'; type QueueSearchOptions = { @@ -11,7 +13,8 @@ type QueueSearchOptions = { type UseFilesTabStateOptions = { searchQuery: string; queueSearch: (query: string, options?: QueueSearchOptions) => void; - maxSearchHistoryEntries?: number; + shortcuts: ShortcutMap; + shortcutsEnabled: boolean; }; type UseFilesTabStateResult = { @@ -35,7 +38,8 @@ type UseFilesTabStateResult = { export function useFilesTabState({ searchQuery, queueSearch, - maxSearchHistoryEntries = 50, + shortcuts, + shortcutsEnabled, }: UseFilesTabStateOptions): UseFilesTabStateResult { const [activeTab, setActiveTab] = useState('files'); const [isSearchFocused, setIsSearchFocused] = useState(false); @@ -45,7 +49,7 @@ export function useFilesTabState({ navigate: navigateSearchHistory, ensureTailValue: ensureHistoryBuffer, resetCursorToTail, - } = useSearchHistory({ maxEntries: maxSearchHistoryEntries }); + } = useSearchHistory(); const handleSearchFocus = useCallback(() => { setIsSearchFocused(true); @@ -92,18 +96,20 @@ export function useFilesTabState({ return; } - if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') { + if (!shortcutsEnabled) { return; } - if (event.altKey || event.metaKey || event.ctrlKey || event.shiftKey) { + const isHistoryUp = shortcutMatchesKeydown(event, shortcuts.searchHistoryUp); + const isHistoryDown = shortcutMatchesKeydown(event, shortcuts.searchHistoryDown); + if (!isHistoryUp && !isHistoryDown) { return; } event.preventDefault(); - handleHistoryNavigation(event.key === 'ArrowUp' ? 'older' : 'newer'); + handleHistoryNavigation(isHistoryUp ? 'older' : 'newer'); }, - [activeTab, handleHistoryNavigation, submitFilesQuery], + [activeTab, handleHistoryNavigation, shortcuts, shortcutsEnabled, submitFilesQuery], ); const onQueryChange = useCallback( diff --git a/cardinal/src/hooks/useShortcutSettingsController.ts b/cardinal/src/hooks/useShortcutSettingsController.ts new file mode 100644 index 00000000..40bd711e --- /dev/null +++ b/cardinal/src/hooks/useShortcutSettingsController.ts @@ -0,0 +1,52 @@ +import { useCallback, useEffect, useState } from 'react'; +import { refreshAppMenu, setMenuShortcutsEnabled } from '../menu'; +import { getStoredShortcuts, persistShortcuts, type ShortcutMap } from '../shortcuts'; +import { refreshTrayMenu } from '../tray'; +import { setGlobalShortcutsPaused, updateQuickLaunchShortcut } from '../utils/globalShortcuts'; + +type UseShortcutSettingsControllerResult = { + isShortcutSettingsOpen: boolean; + shortcuts: ShortcutMap; + openShortcutSettings: () => void; + closeShortcutSettings: () => void; + handleShortcutSettingsSave: (nextShortcuts: ShortcutMap) => Promise; +}; + +export function useShortcutSettingsController(): UseShortcutSettingsControllerResult { + const [isShortcutSettingsOpen, setIsShortcutSettingsOpen] = useState(false); + const [shortcuts, setShortcuts] = useState(() => getStoredShortcuts()); + + useEffect(() => { + void setGlobalShortcutsPaused(isShortcutSettingsOpen); + setMenuShortcutsEnabled(!isShortcutSettingsOpen); + return () => { + if (isShortcutSettingsOpen) { + void setGlobalShortcutsPaused(false); + setMenuShortcutsEnabled(true); + } + }; + }, [isShortcutSettingsOpen]); + + const openShortcutSettings = useCallback(() => { + setIsShortcutSettingsOpen(true); + }, []); + + const closeShortcutSettings = useCallback(() => { + setIsShortcutSettingsOpen(false); + }, []); + + const handleShortcutSettingsSave = useCallback(async (nextShortcuts: ShortcutMap) => { + await updateQuickLaunchShortcut(nextShortcuts.quickLaunch); + persistShortcuts(nextShortcuts); + setShortcuts(nextShortcuts); + await Promise.all([refreshAppMenu(), refreshTrayMenu()]); + }, []); + + return { + isShortcutSettingsOpen, + shortcuts, + openShortcutSettings, + closeShortcutSettings, + handleShortcutSettingsSave, + }; +} diff --git a/cardinal/src/i18n/resources/en-US.json b/cardinal/src/i18n/resources/en-US.json index 4259994c..08114716 100644 --- a/cardinal/src/i18n/resources/en-US.json +++ b/cardinal/src/i18n/resources/en-US.json @@ -122,6 +122,10 @@ "themeHint": "Choose light, dark, or follow the system.", "language": "Language", "languageHint": "Switch the interface language instantly.", + "shortcuts": { + "label": "Shortcuts", + "configure": "Configure" + }, "trayIcon": { "label": "Show tray icon" }, @@ -132,6 +136,86 @@ "reset": "Restore defaults", "close": "Close" }, + "shortcutSettings": { + "title": "Shortcut Settings", + "captureHint": "Click a shortcut button below, then press the new key combination.", + "recording": "Press keys...", + "save": "Save", + "saving": "Saving...", + "reset": "Use default", + "items": { + "quickLaunch": { + "label": "Quick launch", + "description": "Toggle Cardinal globally." + }, + "openPreferences": { + "label": "Open preferences", + "description": "Open Preferences panel." + }, + "hideWindow": { + "label": "Hide window", + "description": "Hide Cardinal window." + }, + "focusSearch": { + "label": "Focus search", + "description": "Jump to the search input." + }, + "openResult": { + "label": "Open result", + "description": "Open the selected result." + }, + "revealInFinder": { + "label": "Reveal in Finder", + "description": "Reveal selected result in Finder." + }, + "copyFiles": { + "label": "Copy files", + "description": "Copy selected files to clipboard." + }, + "copyFilenames": { + "label": "Copy filenames", + "description": "Copy selected file names." + }, + "copyPaths": { + "label": "Copy paths", + "description": "Copy selected file paths." + }, + "quickLook": { + "label": "Quick Look", + "description": "Preview selection with Quick Look." + }, + "moveSelectionUp": { + "label": "Move selection up", + "description": "Select previous item." + }, + "moveSelectionDown": { + "label": "Move selection down", + "description": "Select next item." + }, + "extendSelectionUp": { + "label": "Extend selection up", + "description": "Expand selection upward." + }, + "extendSelectionDown": { + "label": "Extend selection down", + "description": "Expand selection downward." + }, + "searchHistoryUp": { + "label": "History previous", + "description": "Navigate to older search history." + }, + "searchHistoryDown": { + "label": "History next", + "description": "Navigate to newer search history." + } + }, + "errors": { + "modifierRequired": "Shortcut must include at least one modifier key.", + "keyRequired": "Press a non-modifier key together with modifiers.", + "unsupportedKey": "That key is not supported for global shortcuts.", + "registerFailed": "Unable to register this shortcut. It may already be in use." + } + }, "language": { "label": "Language", "separator": ":", diff --git a/cardinal/src/i18n/resources/zh-CN.json b/cardinal/src/i18n/resources/zh-CN.json index 148aa3cc..56e0e2dd 100644 --- a/cardinal/src/i18n/resources/zh-CN.json +++ b/cardinal/src/i18n/resources/zh-CN.json @@ -121,6 +121,10 @@ "themeHint": "选择浅色、深色或跟随系统。", "language": "语言", "languageHint": "立即切换界面语言。", + "shortcuts": { + "label": "快捷键", + "configure": "设置" + }, "trayIcon": { "label": "显示托盘图标" }, @@ -131,6 +135,86 @@ "reset": "恢复默认设置", "close": "关闭" }, + "shortcutSettings": { + "title": "快捷键设置", + "captureHint": "点击下方某个快捷键按钮,然后按下新的组合键。", + "recording": "请按键...", + "save": "保存", + "saving": "保存中...", + "reset": "恢复默认", + "items": { + "quickLaunch": { + "label": "全局唤起", + "description": "全局显示或隐藏 Cardinal。" + }, + "openPreferences": { + "label": "打开偏好设置", + "description": "打开偏好设置面板。" + }, + "hideWindow": { + "label": "隐藏窗口", + "description": "隐藏 Cardinal 主窗口。" + }, + "focusSearch": { + "label": "聚焦搜索框", + "description": "将焦点切回搜索输入框。" + }, + "openResult": { + "label": "打开结果", + "description": "打开当前选中的结果。" + }, + "revealInFinder": { + "label": "在 Finder 中显示", + "description": "在 Finder 中定位所选结果。" + }, + "copyFiles": { + "label": "复制文件", + "description": "将所选文件复制到剪贴板。" + }, + "copyFilenames": { + "label": "复制文件名", + "description": "复制所选文件名。" + }, + "copyPaths": { + "label": "复制路径", + "description": "复制所选文件路径。" + }, + "quickLook": { + "label": "快速预览", + "description": "使用 Quick Look 预览当前选择。" + }, + "moveSelectionUp": { + "label": "上移选择", + "description": "选择上一个条目。" + }, + "moveSelectionDown": { + "label": "下移选择", + "description": "选择下一个条目。" + }, + "extendSelectionUp": { + "label": "向上扩展选择", + "description": "向上扩展多选范围。" + }, + "extendSelectionDown": { + "label": "向下扩展选择", + "description": "向下扩展多选范围。" + }, + "searchHistoryUp": { + "label": "上一条历史", + "description": "切换到更早的搜索历史。" + }, + "searchHistoryDown": { + "label": "下一条历史", + "description": "切换到更新的搜索历史。" + } + }, + "errors": { + "modifierRequired": "快捷键必须包含至少一个修饰键。", + "keyRequired": "请同时按下修饰键和一个非修饰键。", + "unsupportedKey": "该按键暂不支持作为全局快捷键。", + "registerFailed": "注册快捷键失败,可能已被其他应用占用。" + } + }, "language": { "label": "语言", "separator": ":", diff --git a/cardinal/src/menu.ts b/cardinal/src/menu.ts index 5046f658..a8a9d898 100644 --- a/cardinal/src/menu.ts +++ b/cardinal/src/menu.ts @@ -3,11 +3,13 @@ import { invoke } from '@tauri-apps/api/core'; import { Menu, MenuItem, PredefinedMenuItem, Submenu } from '@tauri-apps/api/menu'; import { openUrl } from '@tauri-apps/plugin-opener'; import i18n from './i18n/config'; +import { getStoredShortcutAccelerators } from './shortcuts'; import { openPreferences } from './utils/openPreferences'; const HELP_UPDATES_URL = 'https://github.com/cardisoft/cardinal/releases'; let menuInitPromise: Promise | null = null; +let menuShortcutsEnabled = true; export function initializeAppMenu(): Promise { if (!menuInitPromise) { @@ -17,8 +19,22 @@ export function initializeAppMenu(): Promise { return menuInitPromise ?? Promise.resolve(); } +export function setMenuShortcutsEnabled(enabled: boolean): void { + if (menuShortcutsEnabled === enabled) { + return; + } + menuShortcutsEnabled = enabled; + scheduleMenuBuild(); +} + +export function refreshAppMenu(): Promise { + scheduleMenuBuild(); + return menuInitPromise ?? Promise.resolve(); +} + async function buildAppMenu(): Promise { const name = (await getName().catch(() => null)) ?? 'Cardinal'; + const shortcuts = getStoredShortcutAccelerators(); const aboutItem = await PredefinedMenuItem.new({ item: { About: null }, text: i18n.t('menu.about', { appName: name }), @@ -26,7 +42,7 @@ async function buildAppMenu(): Promise { const preferencesItem = await MenuItem.new({ id: 'menu.preferences', text: i18n.t('menu.preferences'), - accelerator: 'CmdOrCtrl+,', + accelerator: menuShortcutsEnabled ? shortcuts.openPreferences : undefined, action: () => { openPreferences(); }, @@ -34,7 +50,7 @@ async function buildAppMenu(): Promise { const hideItem = await MenuItem.new({ id: 'menu.hide', text: i18n.t('menu.hide'), - accelerator: 'Esc', + accelerator: menuShortcutsEnabled ? shortcuts.hideWindow : undefined, action: () => { void invoke('hide_main_window'); }, diff --git a/cardinal/src/shortcuts.ts b/cardinal/src/shortcuts.ts new file mode 100644 index 00000000..8addc6bd --- /dev/null +++ b/cardinal/src/shortcuts.ts @@ -0,0 +1,98 @@ +import { normalizeShortcut, toMenuAccelerator } from './utils/shortcutCapture'; + +export const SHORTCUTS_STORAGE_KEY = 'cardinal.shortcuts'; +export const DEFAULT_QUICK_LAUNCH_SHORTCUT = 'Command+Shift+Space'; + +export type ShortcutId = + | 'quickLaunch' + | 'openPreferences' + | 'hideWindow' + | 'focusSearch' + | 'openResult' + | 'revealInFinder' + | 'copyFilenames' + | 'copyFiles' + | 'copyPaths' + | 'quickLook' + | 'moveSelectionUp' + | 'moveSelectionDown' + | 'extendSelectionUp' + | 'extendSelectionDown' + | 'searchHistoryUp' + | 'searchHistoryDown'; + +export type ShortcutMap = Record; + +export const DEFAULT_SHORTCUTS: ShortcutMap = { + quickLaunch: DEFAULT_QUICK_LAUNCH_SHORTCUT, + openPreferences: 'Command+Comma', + hideWindow: 'Esc', + focusSearch: 'Command+F', + openResult: 'Command+O', + revealInFinder: 'Command+R', + copyFilenames: 'Command+Shift+F', + copyFiles: 'Command+C', + copyPaths: 'Command+Shift+C', + quickLook: 'Space', + moveSelectionUp: 'Up', + moveSelectionDown: 'Down', + extendSelectionUp: 'Shift+Up', + extendSelectionDown: 'Shift+Down', + searchHistoryUp: 'Up', + searchHistoryDown: 'Down', +}; + +export const SHORTCUT_DEFINITIONS = Object.keys(DEFAULT_SHORTCUTS) as ShortcutId[]; + +const normalizeValue = (value: unknown): string | null => + typeof value === 'string' ? normalizeShortcut(value) : null; + +const normalizeShortcutMap = (input: Partial>): ShortcutMap => { + const next = { ...DEFAULT_SHORTCUTS }; + for (const id of SHORTCUT_DEFINITIONS) { + const normalized = normalizeValue(input[id]); + if (normalized) { + next[id] = normalized; + } + } + return next; +}; + +export const getStoredShortcuts = (): ShortcutMap => { + if (typeof window === 'undefined') { + return DEFAULT_SHORTCUTS; + } + + try { + const raw = window.localStorage.getItem(SHORTCUTS_STORAGE_KEY); + const parsed = raw ? (JSON.parse(raw) as Partial>) : {}; + return normalizeShortcutMap(parsed); + } catch { + return DEFAULT_SHORTCUTS; + } +}; + +export const getShortcutAccelerators = (shortcuts: ShortcutMap) => { + const { quickLaunch, openPreferences, hideWindow } = shortcuts; + return { + quickLaunch, + openPreferences: toMenuAccelerator(openPreferences), + hideWindow: toMenuAccelerator(hideWindow), + }; +}; + +export const getStoredShortcutAccelerators = () => getShortcutAccelerators(getStoredShortcuts()); + +export const persistShortcuts = (shortcuts: ShortcutMap): void => { + if (typeof window === 'undefined') { + return; + } + + const normalized = normalizeShortcutMap(shortcuts); + + try { + window.localStorage.setItem(SHORTCUTS_STORAGE_KEY, JSON.stringify(normalized)); + } catch { + // Ignore storage failures. + } +}; diff --git a/cardinal/src/tray.ts b/cardinal/src/tray.ts index a3e4299a..be8e9a98 100644 --- a/cardinal/src/tray.ts +++ b/cardinal/src/tray.ts @@ -3,7 +3,7 @@ import { invoke } from '@tauri-apps/api/core'; import { Menu, MenuItem, PredefinedMenuItem } from '@tauri-apps/api/menu'; import { TrayIcon, type TrayIconOptions } from '@tauri-apps/api/tray'; import i18n from './i18n/config'; -import { QUICK_LAUNCH_SHORTCUT } from './utils/globalShortcuts'; +import { getStoredShortcutAccelerators } from './shortcuts'; const TRAY_ID = 'cardinal.tray'; @@ -42,30 +42,46 @@ export async function setTrayEnabled(enabled: boolean): Promise { await Promise.allSettled([current?.close(), TrayIcon.removeById(TRAY_ID)]); } +export async function refreshTrayMenu(): Promise { + if (!trayIcon) { + return; + } + + const menu = await createTrayMenu(); + await trayIcon.setMenu(menu).catch((error) => { + console.error('Failed to refresh tray menu', error); + }); +} + async function createTray(): Promise { + const menu = await createTrayMenu(); + const options: TrayIconOptions = { + id: TRAY_ID, + tooltip: 'Cardinal', + icon: (await defaultWindowIcon()) ?? undefined, + menu, + }; + + trayIcon = await TrayIcon.new(options); +} + +async function createTrayMenu(): Promise { + const shortcuts = getStoredShortcutAccelerators(); const openItem = await MenuItem.new({ id: 'tray.open', text: i18n.t('tray.open'), - accelerator: QUICK_LAUNCH_SHORTCUT, + accelerator: shortcuts.quickLaunch, action: () => { void activateMainWindow(); }, }); - const menu = await Menu.new({ + return Menu.new({ items: [ openItem, await PredefinedMenuItem.new({ item: 'Separator' }), await PredefinedMenuItem.new({ item: 'Quit', text: i18n.t('tray.quit') }), ], }); - const options: TrayIconOptions = { - id: TRAY_ID, - tooltip: 'Cardinal', - icon: (await defaultWindowIcon()) ?? undefined, - menu, - }; - - trayIcon = await TrayIcon.new(options); } async function activateMainWindow(): Promise { diff --git a/cardinal/src/utils/__tests__/shortcutCapture.test.ts b/cardinal/src/utils/__tests__/shortcutCapture.test.ts new file mode 100644 index 00000000..ef875247 --- /dev/null +++ b/cardinal/src/utils/__tests__/shortcutCapture.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest'; +import { + captureShortcutFromKeydown, + formatShortcutForDisplay, + normalizeShortcut, + shortcutMatchesKeydown, + toMenuAccelerator, +} from '../shortcutCapture'; + +describe('captureShortcutFromKeydown', () => { + it('captures modifier + key combinations', () => { + const result = captureShortcutFromKeydown({ + key: 'f', + metaKey: true, + ctrlKey: false, + altKey: false, + shiftKey: true, + }); + + expect(result).toEqual({ + shortcut: 'Command+Shift+F', + error: null, + }); + }); + + it('requires at least one modifier', () => { + const result = captureShortcutFromKeydown({ + key: 'f', + metaKey: false, + ctrlKey: false, + altKey: false, + shiftKey: false, + }); + + expect(result).toEqual({ + shortcut: null, + error: 'modifierRequired', + }); + }); + + it('requires a non-modifier key', () => { + const result = captureShortcutFromKeydown({ + key: 'Shift', + metaKey: false, + ctrlKey: false, + altKey: false, + shiftKey: true, + }); + + expect(result).toEqual({ + shortcut: null, + error: 'keyRequired', + }); + }); + + it('maps special keys', () => { + const result = captureShortcutFromKeydown({ + key: 'ArrowDown', + metaKey: false, + ctrlKey: true, + altKey: true, + shiftKey: false, + }); + + expect(result).toEqual({ + shortcut: 'Control+Option+Down', + error: null, + }); + }); + + it('matches comma shortcuts and formats labels', () => { + expect( + shortcutMatchesKeydown( + { key: ',', metaKey: true, ctrlKey: false, altKey: false, shiftKey: false }, + 'Command+Comma', + ), + ).toBe(true); + + expect(formatShortcutForDisplay('Command+Comma')).toBe('Cmd+,'); + expect(toMenuAccelerator('Command+Comma')).toBe('Cmd+,'); + expect(normalizeShortcut(' cmd + shift + arrowdown ')).toBe('Command+Shift+Down'); + }); +}); diff --git a/cardinal/src/utils/fileActions.ts b/cardinal/src/utils/fileActions.ts new file mode 100644 index 00000000..7ae510f9 --- /dev/null +++ b/cardinal/src/utils/fileActions.ts @@ -0,0 +1,39 @@ +import { invoke } from '@tauri-apps/api/core'; +import { openResultPath } from './openResultPath'; +import { splitPath } from './path'; + +const writeClipboard = (text: string, errorMessage: string): void => { + const writePromise = navigator.clipboard?.writeText(text); + if (!writePromise) { + return; + } + + void writePromise.catch((error) => { + console.error(errorMessage, error); + }); +}; + +export const openPaths = (paths: string[]): void => { + paths.forEach((path) => openResultPath(path)); +}; + +export const revealPathsInFinder = (paths: string[]): void => { + paths.forEach((path) => { + void invoke('open_in_finder', { path }); + }); +}; + +export const copyFilenamesToClipboard = (paths: string[]): void => { + const filenames = paths.map((path) => splitPath(path).name || path).join(' '); + writeClipboard(filenames, 'Failed to copy file names to clipboard'); +}; + +export const copyPathsToClipboard = (paths: string[]): void => { + writeClipboard(paths.join('\n'), 'Failed to copy paths to clipboard'); +}; + +export const copyFilesToClipboard = (paths: string[]): void => { + void invoke('copy_files_to_clipboard', { paths }).catch((error) => { + console.error('Failed to copy files to clipboard', error); + }); +}; diff --git a/cardinal/src/utils/globalShortcuts.ts b/cardinal/src/utils/globalShortcuts.ts index bd9ad8c0..5f4ef594 100644 --- a/cardinal/src/utils/globalShortcuts.ts +++ b/cardinal/src/utils/globalShortcuts.ts @@ -1,15 +1,94 @@ import { invoke } from '@tauri-apps/api/core'; -import { register } from '@tauri-apps/plugin-global-shortcut'; +import { register, unregister } from '@tauri-apps/plugin-global-shortcut'; +import { DEFAULT_QUICK_LAUNCH_SHORTCUT, getStoredShortcuts, persistShortcuts } from '../shortcuts'; + +let registeredQuickLaunchShortcut: string | null = null; +let globalShortcutsPaused = false; + +const handleQuickLaunchShortcut = (event: { state: string }): void => { + if (event.state === 'Released') { + void invoke('toggle_main_window'); + } +}; + +const registerQuickLaunchShortcut = async (shortcut: string): Promise => { + await register(shortcut, handleQuickLaunchShortcut); + registeredQuickLaunchShortcut = shortcut; +}; -export const QUICK_LAUNCH_SHORTCUT = 'Command+Shift+Space'; export async function initializeGlobalShortcuts(): Promise { + const preferredShortcut = getStoredShortcuts().quickLaunch; + try { - await register(QUICK_LAUNCH_SHORTCUT, (event) => { - if (event.state === 'Released') { - void invoke('toggle_main_window'); - } - }); + await registerQuickLaunchShortcut(preferredShortcut); } catch (error) { console.error('Failed to register global shortcuts', error); + if (preferredShortcut !== DEFAULT_QUICK_LAUNCH_SHORTCUT) { + try { + await registerQuickLaunchShortcut(DEFAULT_QUICK_LAUNCH_SHORTCUT); + persistShortcuts({ + ...getStoredShortcuts(), + quickLaunch: DEFAULT_QUICK_LAUNCH_SHORTCUT, + }); + } catch (fallbackError) { + console.error('Failed to register fallback global shortcut', fallbackError); + } + } + } +} + +export async function updateQuickLaunchShortcut(shortcut: string): Promise { + const nextShortcut = shortcut.trim(); + if (!nextShortcut) { + throw new Error('Shortcut cannot be empty'); + } + + const previousShortcut = registeredQuickLaunchShortcut; + if (previousShortcut === nextShortcut) { + return; + } + + if (globalShortcutsPaused) { + registeredQuickLaunchShortcut = nextShortcut; + return; } + + if (previousShortcut) { + await unregister(previousShortcut).catch(() => {}); + } + + try { + await registerQuickLaunchShortcut(nextShortcut); + } catch (error) { + if (previousShortcut) { + try { + await registerQuickLaunchShortcut(previousShortcut); + } catch (restoreError) { + console.error('Failed to restore previous global shortcut', restoreError); + } + } else { + registeredQuickLaunchShortcut = null; + } + throw error; + } +} + +export async function setGlobalShortcutsPaused(paused: boolean): Promise { + if (globalShortcutsPaused === paused) { + return; + } + + globalShortcutsPaused = paused; + + if (paused) { + if (registeredQuickLaunchShortcut) { + await unregister(registeredQuickLaunchShortcut).catch(() => {}); + } + return; + } + + const shortcutToRegister = registeredQuickLaunchShortcut ?? getStoredShortcuts().quickLaunch; + await registerQuickLaunchShortcut(shortcutToRegister).catch((error) => { + console.error('Failed to resume global shortcuts', error); + }); } diff --git a/cardinal/src/utils/shortcutCapture.ts b/cardinal/src/utils/shortcutCapture.ts new file mode 100644 index 00000000..50281c60 --- /dev/null +++ b/cardinal/src/utils/shortcutCapture.ts @@ -0,0 +1,301 @@ +export type ShortcutCaptureError = 'modifierRequired' | 'keyRequired' | 'unsupportedKey'; + +export type ShortcutCaptureResult = + | { + shortcut: string; + error: null; + } + | { + shortcut: null; + error: ShortcutCaptureError; + }; + +export type ShortcutLikeEvent = Pick< + KeyboardEvent, + 'key' | 'metaKey' | 'ctrlKey' | 'altKey' | 'shiftKey' +>; + +type ModifierToken = 'Command' | 'Control' | 'Option' | 'Shift'; + +type ParsedShortcut = { + modifiers: ModifierToken[]; + key: string; +}; + +type TokenMetadata = { + aliases?: readonly string[]; + display?: string; + menu?: string; +}; + +const MODIFIER_ORDER: ModifierToken[] = ['Command', 'Control', 'Option', 'Shift']; + +const symbolToken = (symbol: string): TokenMetadata => ({ + aliases: [symbol], + display: symbol, + menu: symbol, +}); + +const MODIFIER_METADATA: Record = { + Command: { + aliases: ['cmd', 'meta'], + display: 'Cmd', + menu: 'Cmd', + }, + Control: { + aliases: ['ctrl'], + display: 'Ctrl', + menu: 'Ctrl', + }, + Option: { + aliases: ['opt', 'alt'], + display: 'Opt', + menu: 'Alt', + }, + Shift: { + menu: 'Shift', + }, +}; + +const SPECIAL_KEY_METADATA: Record = { + Space: { + aliases: [' ', 'spacebar'], + menu: 'Space', + }, + Up: { + aliases: ['arrowup'], + display: '↑', + menu: 'Up', + }, + Down: { + aliases: ['arrowdown'], + display: '↓', + menu: 'Down', + }, + Left: { + aliases: ['arrowleft'], + display: '←', + menu: 'Left', + }, + Right: { + aliases: ['arrowright'], + display: '→', + menu: 'Right', + }, + Esc: { + aliases: ['escape'], + menu: 'Esc', + }, + Comma: symbolToken(','), + Period: symbolToken('.'), + Semicolon: symbolToken(';'), + Slash: symbolToken('/'), + Backslash: symbolToken('\\'), + Backquote: symbolToken('`'), + BracketLeft: symbolToken('['), + BracketRight: symbolToken(']'), + Quote: symbolToken("'"), + Minus: symbolToken('-'), + Equal: symbolToken('='), + Backspace: {}, + Delete: {}, + Insert: {}, + Home: {}, + End: {}, + PageUp: {}, + PageDown: {}, + Tab: {}, + Enter: {}, +}; + +const buildTokenMaps = (metadata: Record) => { + const alias = {} as Record; + const display: Record = {}; + const menu: Record = {}; + + for (const [token, value] of Object.entries(metadata) as Array<[T, TokenMetadata]>) { + alias[token.toLowerCase()] = token; + for (const tokenAlias of value.aliases ?? []) { + alias[tokenAlias.toLowerCase()] = token; + } + if (value.display) { + display[token] = value.display; + } + if (value.menu) { + menu[token] = value.menu; + } + } + + return { alias, display, menu }; +}; + +const modifierMaps = buildTokenMaps(MODIFIER_METADATA); +const keyMaps = buildTokenMaps(SPECIAL_KEY_METADATA); + +const MODIFIER_ALIAS = modifierMaps.alias; +const KEY_ALIAS = keyMaps.alias; + +const DISPLAY_TOKEN_MAP: Record = { + ...modifierMaps.display, + ...keyMaps.display, +}; + +const MENU_TOKEN_MAP: Record = { + ...modifierMaps.menu, + ...keyMaps.menu, +}; + +const isModifierOnlyKey = (key: string): boolean => + key === 'Meta' || key === 'Control' || key === 'Alt' || key === 'Shift'; + +const normalizeModifierToken = (token: string): ModifierToken | null => { + const normalized = MODIFIER_ALIAS[token.trim().toLowerCase()]; + return normalized ?? null; +}; + +const normalizeKeyToken = (token: string): string | null => { + const normalized = token === ' ' ? token : token.trim(); + if (!normalized) { + return null; + } + + if (/^[a-z]$/i.test(normalized)) { + return normalized.toUpperCase(); + } + if (/^[0-9]$/.test(normalized)) { + return normalized; + } + if (/^f([1-9]|1[0-9]|2[0-4])$/i.test(normalized)) { + return normalized.toUpperCase(); + } + + const aliased = KEY_ALIAS[normalized.toLowerCase()]; + return aliased ?? null; +}; + +const parseShortcut = (shortcut: string): ParsedShortcut | null => { + const tokens = shortcut + .split('+') + .map((part) => part.trim()) + .filter((part) => part.length > 0); + + if (!tokens.length) { + return null; + } + + const modifiers: ModifierToken[] = []; + let key: string | null = null; + + for (const token of tokens) { + const modifier = normalizeModifierToken(token); + if (modifier) { + if (!modifiers.includes(modifier)) { + modifiers.push(modifier); + } + continue; + } + + const normalizedKey = normalizeKeyToken(token); + if (!normalizedKey || key) { + return null; + } + key = normalizedKey; + } + + if (!key) { + return null; + } + + const orderedModifiers = MODIFIER_ORDER.filter((modifier) => modifiers.includes(modifier)); + return { + modifiers: orderedModifiers, + key, + }; +}; + +const serializeShortcut = ({ modifiers, key }: ParsedShortcut): string => + [...modifiers, key].join('+'); + +const getModifiersFromEvent = (event: ShortcutLikeEvent): ModifierToken[] => { + const modifiers: ModifierToken[] = []; + if (event.metaKey) { + modifiers.push('Command'); + } + if (event.ctrlKey) { + modifiers.push('Control'); + } + if (event.altKey) { + modifiers.push('Option'); + } + if (event.shiftKey) { + modifiers.push('Shift'); + } + return modifiers; +}; + +export const normalizeShortcut = (shortcut: string): string | null => { + const parsed = parseShortcut(shortcut); + return parsed ? serializeShortcut(parsed) : null; +}; + +export const captureShortcutFromKeydown = ( + event: ShortcutLikeEvent, + requireModifier = true, +): ShortcutCaptureResult => { + const modifiers = getModifiersFromEvent(event); + + if (isModifierOnlyKey(event.key)) { + return { shortcut: null, error: 'keyRequired' }; + } + + const key = normalizeKeyToken(event.key); + if (!key) { + return { shortcut: null, error: 'unsupportedKey' }; + } + + if (requireModifier && modifiers.length === 0) { + return { shortcut: null, error: 'modifierRequired' }; + } + + return { + shortcut: serializeShortcut({ modifiers, key }), + error: null, + }; +}; + +export const shortcutMatchesKeydown = (event: ShortcutLikeEvent, shortcut: string): boolean => { + const expected = normalizeShortcut(shortcut); + if (!expected) { + return false; + } + + const captured = captureShortcutFromKeydown(event, false); + if (captured.error) { + return false; + } + + return captured.shortcut === expected; +}; + +export const formatShortcutForDisplay = (shortcut: string): string => { + const normalized = normalizeShortcut(shortcut); + if (!normalized) { + return shortcut; + } + + return normalized + .split('+') + .map((token) => DISPLAY_TOKEN_MAP[token] ?? token) + .join('+'); +}; + +export const toMenuAccelerator = (shortcut: string): string | undefined => { + const parsed = parseShortcut(shortcut); + if (!parsed) { + return undefined; + } + + const mappedModifiers = parsed.modifiers.map((modifier) => MENU_TOKEN_MAP[modifier] ?? modifier); + const mappedKey = MENU_TOKEN_MAP[parsed.key] ?? parsed.key; + return [...mappedModifiers, mappedKey].join('+'); +}; diff --git a/doc/inner/shortcut-customization.md b/doc/inner/shortcut-customization.md new file mode 100644 index 00000000..b956be0e --- /dev/null +++ b/doc/inner/shortcut-customization.md @@ -0,0 +1,120 @@ +# Shortcut Customization Overview + +This chapter documents how Cardinal implements configurable shortcuts across UI, menu/tray, and global hotkeys. + +--- + +## Scope and goals + +The shortcut system is designed to: +- replace hardcoded key bindings with a persisted `ShortcutMap`; +- provide a dedicated shortcut settings overlay from Preferences; +- pause all shortcut handlers while shortcut capture is active; +- keep menu/tray/context-menu labels aligned with current shortcut values. + +Current non-goals: +- no shortcut conflict detection or resolution policy; +- no Tauri command-protocol changes for shortcut customization. + +--- + +## Data model and persistence + +Implementation: `cardinal/src/shortcuts.ts` + +- `ShortcutId` defines all supported shortcut actions. +- `ShortcutMap` is `Record`. +- `DEFAULT_SHORTCUTS` is the canonical default set. +- `SHORTCUT_DEFINITIONS` is derived from `Object.keys(DEFAULT_SHORTCUTS)` to avoid duplicated metadata. + +Storage behavior: +- single storage key: `cardinal.shortcuts`; +- `getStoredShortcuts()` reads and normalizes values, then merges into defaults; +- `persistShortcuts()` normalizes and writes the full map. + +There is no legacy quick-launch compatibility layer in the current implementation. + +--- + +## Parsing, capture, and formatting + +Implementation: `cardinal/src/utils/shortcutCapture.ts` + +The module is the single source for shortcut token rules and conversion: +- `normalizeShortcut()` canonicalizes persisted/user-provided values. +- `captureShortcutFromKeydown(event, requireModifier)` captures a shortcut from keyboard events. +- `shortcutMatchesKeydown()` matches runtime keyboard events against configured shortcuts. +- `formatShortcutForDisplay()` formats values for UI labels (example: `Command+Comma` -> `Cmd+,`). +- `toMenuAccelerator()` converts values to macOS menu accelerator format. + +Key alias/display/menu rules are derived from unified token metadata instead of separate ad-hoc maps. + +--- + +## Settings UI flow + +Entry points: +- Preferences exposes `Shortcuts -> Configure`. +- The standalone editor is `ShortcutSettingsOverlay`. + +Behavior: +- rows are generated from `SHORTCUT_DEFINITIONS`; +- i18n keys follow `shortcutSettings.items.${shortcutId}.label|description`; +- clicking a row button toggles recording mode for that shortcut; +- the next captured key combination updates draft state; +- Reset restores `DEFAULT_SHORTCUTS`, Save commits all draft values. + +Capture errors (`modifierRequired`, `keyRequired`, `unsupportedKey`) are mapped to i18n error messages. + +--- + +## Runtime integration + +### App-level handlers +- `useAppHotkeys` consumes `ShortcutMap` for window shortcuts and files-tab actions/navigation. +- `useFilesTabState` consumes `searchHistoryUp`/`searchHistoryDown` from `ShortcutMap`. +- `useContextMenu` displays per-action accelerators from current shortcuts. + +### Menu and tray +- `menu.ts` reads accelerators from `getStoredShortcutAccelerators()` for Preferences/Hide. +- `tray.ts` shows the current quick-launch accelerator. +- `refreshAppMenu()` and `refreshTrayMenu()` rebuild labels after shortcut updates. + +### Global shortcut +- `globalShortcuts.ts` manages registration of the quick-launch shortcut only. +- `initializeGlobalShortcuts()` registers the stored value (with fallback to default on failure). +- `updateQuickLaunchShortcut()` updates runtime registration and persists quick-launch value. + +--- + +## Pause behavior while settings are open + +`useShortcutSettingsController` coordinates the pause/resume behavior: +- `setGlobalShortcutsPaused(true)` pauses the OS-level quick-launch registration; +- `setMenuShortcutsEnabled(false)` removes menu accelerators; +- `App.tsx` passes `enabled: false` to `useAppHotkeys`; +- `App.tsx` passes `shortcutsEnabled: false` to `useFilesTabState`. + +Result: while `ShortcutSettingsOverlay` is open, shortcut handlers are effectively disabled across global, menu, and in-window layers. + +--- + +## Save pipeline + +When the user saves in `ShortcutSettingsOverlay`, the controller runs: +1. `updateQuickLaunchShortcut(nextShortcuts.quickLaunch)` +2. `persistShortcuts(nextShortcuts)` +3. local React state update (`setShortcuts`) +4. `Promise.all([refreshAppMenu(), refreshTrayMenu()])` + +This keeps runtime registration, local persistence, and visible accelerator labels in sync. + +--- + +## Test coverage + +Current frontend tests cover: +- shortcut storage defaults, persistence, and accelerator mapping (`shortcuts.test.ts`); +- capture/normalize/match/format behavior (`shortcutCapture.test.ts`); +- settings-overlay capture and save flow (`ShortcutSettingsOverlay.test.tsx`); +- app/file-tab shortcut execution paths (`useAppHotkeys.test.ts`, `useFilesTabState.test.ts`).