diff --git a/apps/desktop/src/components/shortcut-input.tsx b/apps/desktop/src/components/shortcut-input.tsx index ee017bc..77b26ca 100644 --- a/apps/desktop/src/components/shortcut-input.tsx +++ b/apps/desktop/src/components/shortcut-input.tsx @@ -1,255 +1,198 @@ -import React, { useState, useRef, useEffect } from "react"; +import React, { useState, useEffect } from "react"; import { Button } from "@/components/ui/button"; -import { X, Pencil, AlertCircle, Command } from "lucide-react"; -import { cn } from "@/lib/utils"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; +import { Pencil, X } from "lucide-react"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import { api } from "@/trpc/react"; +import { toast } from "sonner"; interface ShortcutInputProps { value?: string; onChange: (value: string) => void; - placeholder?: string; - className?: string; - isRecording?: boolean; - onRecordingChange?: (recording: boolean) => void; + isRecordingShortcut?: boolean; + onRecordingShortcutChange: (recording: boolean) => void; +} + +const MODIFIER_KEYS = ["Cmd", "Ctrl", "Alt", "Shift", "Fn"]; +const MAX_KEY_COMBINATION_LENGTH = 3; + +type ValidationResult = { + valid: boolean; + shortcut?: string; + error?: string; +}; + +function validateShortcut(keys: string[]): ValidationResult { + if (keys.length === 0) { + return { valid: false, error: "No keys detected" }; + } + + if (keys.length > MAX_KEY_COMBINATION_LENGTH) { + return { valid: false, error: "Maximum 3 keys allowed" }; + } + + const modifierKeys = keys.filter((key) => MODIFIER_KEYS.includes(key)); + const regularKeys = keys.filter((key) => !MODIFIER_KEYS.includes(key)); + + // Single modifier key (e.g., Fn for PTT) + if (keys.length === 1 && modifierKeys.length === 1) { + return { valid: true, shortcut: modifierKeys[0] }; + } + + // Single regular key + if (modifierKeys.length === 0 && regularKeys.length === 1) { + return { valid: true, shortcut: regularKeys[0] }; + } + + // Modifier(s) + up to 2 regular keys + if ( + modifierKeys.length > 0 && + regularKeys.length > 0 && + regularKeys.length <= 2 + ) { + return { + valid: true, + shortcut: [...modifierKeys, ...regularKeys].join("+"), + }; + } + + // Multiple regular keys without modifiers + if (modifierKeys.length === 0 && regularKeys.length > 1) { + return { + valid: false, + error: + "Multiple keys require at least one modifier (Cmd, Ctrl, Alt, Shift, or Fn)", + }; + } + + return { valid: false, error: "Invalid key combination" }; +} + +function RecordingDisplay({ + activeKeys, + onCancel, +}: { + activeKeys: string[]; + onCancel: () => void; +}) { + return ( +
+ {activeKeys.length > 0 ? ( +
+ {activeKeys.map((key, index) => ( + + {key} + + ))} +
+ ) : ( + Press keys... + )} + +
+ ); +} + +function ShortcutDisplay({ + value, + onEdit, +}: { + value?: string; + onEdit: () => void; +}) { + return ( + <> + {value && ( + + {value} + + )} + + + ); } export function ShortcutInput({ value, onChange, - placeholder = "Not set", - className, - isRecording = false, - onRecordingChange, + isRecordingShortcut = false, + onRecordingShortcutChange, }: ShortcutInputProps) { - const [keys, setKeys] = useState>(new Set()); - const inputRef = useRef(null); + const [activeKeys, setActiveKeys] = useState([]); + const handleStartRecording = () => { + onRecordingShortcutChange(true); + }; + + const handleCancelRecording = () => { + onRecordingShortcutChange(false); + setActiveKeys([]); + }; + + // Subscribe to key events when recording + api.settings.activeKeysUpdates.useSubscription(undefined, { + enabled: isRecordingShortcut, + onData: (keys: string[]) => { + const previousKeys = activeKeys; + setActiveKeys(keys); + + // When any key is released, validate the combination + if (previousKeys.length > 0 && keys.length < previousKeys.length) { + const result = validateShortcut(previousKeys); + + if (result.valid && result.shortcut) { + onChange(result.shortcut); + } else { + toast.error(result.error || "Invalid key combination"); + } + + onRecordingShortcutChange(false); + } + }, + onError: (error) => { + console.error("Error subscribing to active keys", error); + }, + }); + + // Reset state when recording starts useEffect(() => { - if (!isRecording) return; - - const handleKeyDown = (e: KeyboardEvent) => { - console.log("handleKeyDown", e); - e.preventDefault(); - e.stopPropagation(); - - const key = getKeyName(e); - if (key) { - setKeys((prev) => { - const newKeys = new Set(prev); - newKeys.add(key); - return newKeys; - }); - } - }; - - const handleKeyUp = (e: KeyboardEvent) => { - e.preventDefault(); - e.stopPropagation(); - - // Build the shortcut string - const shortcut = buildShortcutString(keys); - if (shortcut) { - onChange(shortcut); - } - - onRecordingChange?.(false); - setKeys(new Set()); - }; - - window.addEventListener("keydown", handleKeyDown); - window.addEventListener("keyup", handleKeyUp); - - return () => { - window.removeEventListener("keydown", handleKeyDown); - window.removeEventListener("keyup", handleKeyUp); - }; - }, [isRecording, keys, onChange, onRecordingChange]); - - const handleClick = () => { - onRecordingChange?.(true); - setKeys(new Set()); - inputRef.current?.focus(); - }; - - const handleClear = (e: React.MouseEvent) => { - e.stopPropagation(); - onChange(""); - }; - - const getKeyName = (e: KeyboardEvent): string => { - const specialKeys: Record = { - Control: "Ctrl", - Meta: "Cmd", - Alt: "Alt", - Shift: "Shift", - " ": "Space", - ArrowUp: "Up", - ArrowDown: "Down", - ArrowLeft: "Left", - ArrowRight: "Right", - Enter: "Enter", - Tab: "Tab", - Escape: "Esc", - Backspace: "Backspace", - Delete: "Delete", - Home: "Home", - End: "End", - PageUp: "PageUp", - PageDown: "PageDown", - F1: "F1", - F2: "F2", - F3: "F3", - F4: "F4", - F5: "F5", - F6: "F6", - F7: "F7", - F8: "F8", - F9: "F9", - F10: "F10", - F11: "F11", - F12: "F12", - }; - - // Handle modifier keys - if (e.ctrlKey) keys.add("Ctrl"); - if (e.metaKey) keys.add("Cmd"); - if (e.altKey) keys.add("Alt"); - if (e.shiftKey) keys.add("Shift"); - - if (e.key === "Fn" || e.code === "Fn") { - keys.add("Fn"); + if (isRecordingShortcut) { + setActiveKeys([]); } - - // Get the main key - const key = specialKeys[e.key] || e.key.toUpperCase(); - - return key; - }; - - const buildShortcutString = (keys: Set): string => { - const modifiers = ["Cmd", "Ctrl", "Alt", "Shift", "Fn"]; - const sortedModifiers = modifiers.filter((mod) => keys.has(mod)); - const mainKeys = Array.from(keys).filter((key) => !modifiers.includes(key)); - - if (keys.size === 0) return ""; - - // Allow single key shortcuts - if (mainKeys.length === 0 && sortedModifiers.length > 0) { - return sortedModifiers.join("+"); - } - - return [...sortedModifiers, ...mainKeys].join("+"); - }; + }, [isRecordingShortcut]); return ( -
- {isRecording ? ( -
- Press keys... - - - - - - { - onChange("Fn"); - onRecordingChange?.(false); - setKeys(new Set()); - }} - > - Fn Key - - { - onChange("Fn+Space"); - onRecordingChange?.(false); - setKeys(new Set()); - }} - > - Fn+Space - - - -
+
+ {isRecordingShortcut ? ( + ) : ( - <> - {value ? ( - - {value} - - ) : ( - - {placeholder} - - )} - - - - - - - { - onChange("Fn"); - }} - > - Set to Fn - - { - onChange("Fn+Space"); - }} - > - Set to Fn+Space - - - - {value && ( - - )} - + )}
diff --git a/apps/desktop/src/db/app-settings.ts b/apps/desktop/src/db/app-settings.ts index 79e959a..6d21dec 100644 --- a/apps/desktop/src/db/app-settings.ts +++ b/apps/desktop/src/db/app-settings.ts @@ -36,6 +36,10 @@ const defaultSettings: AppSettingsData = { silenceThreshold: 3, maxRecordingDuration: 60, }, + shortcuts: { + pushToTalk: "Fn", + toggleRecording: "", + }, }; // Get all app settings @@ -93,6 +97,13 @@ export async function updateAppSettings( }; } + if (newSettings.shortcuts && currentSettings.shortcuts) { + mergedSettings.shortcuts = { + ...currentSettings.shortcuts, + ...newSettings.shortcuts, + }; + } + const now = new Date(); await db diff --git a/apps/desktop/src/db/schema.ts b/apps/desktop/src/db/schema.ts index 5cec839..c2e996b 100644 --- a/apps/desktop/src/db/schema.ts +++ b/apps/desktop/src/db/schema.ts @@ -104,6 +104,11 @@ export interface AppSettingsData { silenceThreshold: number; maxRecordingDuration: number; }; + shortcuts?: { + pushToTalk?: string; + toggleRecording?: string; + toggleWindow?: string; + }; } // Export types for TypeScript diff --git a/apps/desktop/src/main/core/app-manager.ts b/apps/desktop/src/main/core/app-manager.ts index eb4bc6e..8824274 100644 --- a/apps/desktop/src/main/core/app-manager.ts +++ b/apps/desktop/src/main/core/app-manager.ts @@ -1,4 +1,4 @@ -import { app, systemPreferences, globalShortcut } from "electron"; +import { app, systemPreferences } from "electron"; import { initializeDatabase } from "../../db/config"; import { logger } from "../logger"; import { WindowManager } from "./window-manager"; @@ -9,6 +9,7 @@ import { EventHandlers } from "./event-handlers"; export class AppManager { private windowManager: WindowManager; private serviceManager: ServiceManager; + private eventHandlers: EventHandlers | null = null; constructor() { this.windowManager = new WindowManager(); @@ -26,8 +27,8 @@ export class AppManager { await this.setupMenu(); // Setup event handlers - const eventHandlers = new EventHandlers(this); - eventHandlers.setupEventHandlers(); + this.eventHandlers = new EventHandlers(this); + this.eventHandlers.setupEventHandlers(); // Auto-update is now handled by update-electron-app in main.ts @@ -99,6 +100,7 @@ export class AppManager { const autoUpdaterService = this.serviceManager.getAutoUpdaterService(); const settingsService = this.serviceManager.getSettingsService(); const swiftBridge = this.serviceManager.getSwiftIOBridge(); + const shortcutManager = this.serviceManager.getShortcutManager(); (globalThis as any).modelManagerService = this.serviceManager.getModelManagerService(); @@ -107,6 +109,8 @@ export class AppManager { (globalThis as any).logger = logger; (globalThis as any).autoUpdaterService = autoUpdaterService; (globalThis as any).swiftBridge = swiftBridge; + (globalThis as any).shortcutManager = shortcutManager; + (globalThis as any).appManager = this; } getWindowManager(): WindowManager { @@ -129,8 +133,11 @@ export class AppManager { return this.serviceManager.getAutoUpdaterService(); } + getEventHandlers(): EventHandlers | null { + return this.eventHandlers; + } + async cleanup(): Promise { - globalShortcut.unregisterAll(); await this.serviceManager.cleanup(); if (this.windowManager) { this.windowManager.cleanup(); diff --git a/apps/desktop/src/main/core/event-handlers.ts b/apps/desktop/src/main/core/event-handlers.ts index 0747ab8..c20c761 100644 --- a/apps/desktop/src/main/core/event-handlers.ts +++ b/apps/desktop/src/main/core/event-handlers.ts @@ -17,39 +17,15 @@ export class EventHandlers { private setupSwiftBridgeEventHandlers(): void { try { const swiftBridge = this.appManager.getSwiftIOBridge(); - const windowManager = this.appManager.getWindowManager(); + // Handle non-shortcut related events only swiftBridge.on("helperEvent", (event: HelperEvent) => { logger.swift.debug("Received helperEvent from SwiftIOBridge", { event, }); - switch (event.type) { - case "flagsChanged": { - const payload = event.payload; - if (payload?.fnKeyPressed !== undefined) { - logger.swift.info("Setting recording state", { - state: payload.fnKeyPressed, - }); - - // Use RecordingManager to handle state changes - const serviceManager = this.appManager.getServiceManager(); - const recordingManager = serviceManager.getRecordingManager(); - - if (payload.fnKeyPressed) { - recordingManager.startRecording(); - } else { - recordingManager.stopRecording(); - } - } - break; - } - case "keyDown": - case "keyUp": - break; - default: - break; - } + // Let ShortcutManager handle all key-related events + // This handler can process other helper events if needed }); swiftBridge.on("error", (error: Error) => { diff --git a/apps/desktop/src/main/managers/recording-manager.ts b/apps/desktop/src/main/managers/recording-manager.ts index 34e8307..0284593 100644 --- a/apps/desktop/src/main/managers/recording-manager.ts +++ b/apps/desktop/src/main/managers/recording-manager.ts @@ -4,6 +4,9 @@ import { logger, logPerformance } from "../logger"; import { ServiceManager } from "./service-manager"; import type { RecordingState } from "../../types/recording"; import { Mutex } from "async-mutex"; +import type { ShortcutManager } from "../services/shortcut-manager"; + +export type RecordingMode = "idle" | "ptt" | "handsfree"; /** * Manages recording state and coordinates audio recording across the application @@ -13,12 +16,37 @@ export class RecordingManager extends EventEmitter { private currentSessionId: string | null = null; private recordingState: RecordingState = "idle"; private recordingMutex = new Mutex(); + private recordingMode: RecordingMode = "idle"; constructor(private serviceManager: ServiceManager) { super(); this.setupIPCHandlers(); } + // Setup listeners for shortcut events + public setupShortcutListeners(shortcutManager: ShortcutManager) { + let lastPTTState = false; + + // Handle PTT state changes + shortcutManager.on("ptt-state-changed", async (isPressed: boolean) => { + // Only act on state changes + if (isPressed !== lastPTTState) { + lastPTTState = isPressed; + + if (isPressed) { + await this.startPTT(); + } else { + await this.stopPTT(); + } + } + }); + + // Handle toggle recording + shortcutManager.on("toggle-recording-triggered", async () => { + await this.toggleHandsFree(); + }); + } + private setState(newState: RecordingState): void { const oldState = this.recordingState; this.recordingState = newState; @@ -147,6 +175,62 @@ export class RecordingManager extends EventEmitter { }); } + public async toggleRecording() { + if (this.recordingState === "idle") { + await this.startRecording(); + } else if (this.recordingState === "recording") { + await this.stopRecording(); + } else { + logger.audio.warn("Cannot toggle recording in current state", { + currentState: this.recordingState, + }); + } + } + + // PTT-specific methods + public async startPTT() { + // Don't start PTT if already in hands-free mode + if (this.recordingMode === "handsfree") { + logger.audio.info("Ignoring PTT - already in hands-free mode"); + return; + } + + this.recordingMode = "ptt"; + await this.startRecording(); + } + + public async stopPTT() { + // Only stop if we're actually in PTT mode + if (this.recordingMode === "ptt") { + this.recordingMode = "idle"; + await this.stopRecording(); + } + } + + // Hands-free mode toggle + public async toggleHandsFree() { + if (this.recordingMode === "handsfree") { + this.recordingMode = "idle"; + await this.stopRecording(); + logger.audio.info("Hands-free mode disabled"); + } else { + // If in PTT mode, just switch to hands-free without restarting + if (this.recordingMode === "ptt") { + this.recordingMode = "handsfree"; + logger.audio.info("Switched from PTT to hands-free mode"); + } else { + this.recordingMode = "handsfree"; + await this.startRecording(); + logger.audio.info("Hands-free mode enabled"); + } + } + } + + // Get current mode + public getRecordingMode(): RecordingMode { + return this.recordingMode; + } + private async handleAudioChunk( chunk: Buffer, isFinalChunk: boolean, diff --git a/apps/desktop/src/main/managers/service-manager.ts b/apps/desktop/src/main/managers/service-manager.ts index 004ea62..cb4b693 100644 --- a/apps/desktop/src/main/managers/service-manager.ts +++ b/apps/desktop/src/main/managers/service-manager.ts @@ -6,6 +6,7 @@ import { SwiftIOBridge } from "../../services/platform/swift-bridge-service"; import { AutoUpdaterService } from "../services/auto-updater"; import { RecordingManager } from "./recording-manager"; import { VADService } from "../../services/vad-service"; +import { ShortcutManager } from "../services/shortcut-manager"; import { createIPCHandler } from "electron-trpc-experimental/main"; import { router } from "../../trpc/router"; import { BrowserWindow } from "electron"; @@ -25,6 +26,7 @@ export class ServiceManager { private swiftIOBridge: SwiftIOBridge | null = null; private autoUpdaterService: AutoUpdaterService | null = null; private recordingManager: RecordingManager | null = null; + private shortcutManager: ShortcutManager | null = null; private trpcHandler: ReturnType | null = null; async initialize(): Promise { @@ -42,6 +44,7 @@ export class ServiceManager { await this.initializeVADService(); await this.initializeAIServices(); this.initializeRecordingManager(); + await this.initializeShortcutManager(); this.initializeAutoUpdater(); this.initializeTRPCHandler(); @@ -134,6 +137,21 @@ export class ServiceManager { logger.main.info("Recording manager initialized"); } + private async initializeShortcutManager(): Promise { + if (!this.recordingManager || !this.settingsService) { + throw new Error( + "RecordingManager and SettingsService must be initialized first", + ); + } + this.shortcutManager = new ShortcutManager(this.settingsService); + await this.shortcutManager.initialize(this.swiftIOBridge); + + // Connect shortcut events to recording manager + this.recordingManager.setupShortcutListeners(this.shortcutManager); + + logger.main.info("Shortcut manager initialized"); + } + private initializeAutoUpdater(): void { this.autoUpdaterService = new AutoUpdaterService(); } @@ -226,6 +244,18 @@ export class ServiceManager { return this.vadService; } + getShortcutManager(): ShortcutManager { + if (!this.isInitialized) { + throw new Error( + "ServiceManager not initialized. Call initialize() first.", + ); + } + if (!this.shortcutManager) { + throw new Error("ShortcutManager failed to initialize"); + } + return this.shortcutManager; + } + getTRPCHandler(): ReturnType | null { if (!this.isInitialized) { throw new Error( @@ -239,6 +269,10 @@ export class ServiceManager { } async cleanup(): Promise { + if (this.shortcutManager) { + logger.main.info("Cleaning up shortcut manager..."); + this.shortcutManager.cleanup(); + } if (this.recordingManager) { logger.main.info("Cleaning up recording manager..."); await this.recordingManager.cleanup(); diff --git a/apps/desktop/src/main/services/shortcut-manager.ts b/apps/desktop/src/main/services/shortcut-manager.ts new file mode 100644 index 0000000..8738e85 --- /dev/null +++ b/apps/desktop/src/main/services/shortcut-manager.ts @@ -0,0 +1,197 @@ +import { EventEmitter } from "events"; +import { globalShortcut } from "electron"; +import { SettingsService } from "@/services/settings-service"; +import { SwiftIOBridge } from "@/services/platform/swift-bridge-service"; +import { matchesShortcutKey, getKeyNameFromPayload } from "@/utils/keycode-map"; +import { KeyEventPayload, HelperEvent } from "@amical/types"; +import { logger } from "@/main/logger"; + +const log = logger.main; + +interface KeyInfo { + key: string; + timestamp: number; +} + +interface ShortcutConfig { + pushToTalk: string; + toggleRecording: string; +} + +export class ShortcutManager extends EventEmitter { + private activeKeys = new Map(); + private shortcuts: ShortcutConfig = { + pushToTalk: "", + toggleRecording: "", + }; + private settingsService: SettingsService; + private swiftIOBridge: SwiftIOBridge | null = null; + + constructor(settingsService: SettingsService) { + super(); + this.settingsService = settingsService; + } + + async initialize(swiftIOBridge: SwiftIOBridge | null) { + this.swiftIOBridge = swiftIOBridge; + await this.loadShortcuts(); + this.setupEventListeners(); + } + + private async loadShortcuts() { + try { + const shortcuts = await this.settingsService.getShortcuts(); + this.shortcuts = shortcuts; + log.info("Shortcuts loaded", { shortcuts }); + } catch (error) { + log.error("Failed to load shortcuts", { error }); + } + } + + async reloadShortcuts() { + await this.loadShortcuts(); + } + + private setupEventListeners() { + if (!this.swiftIOBridge) { + log.warn("SwiftIOBridge not available, shortcuts will not work"); + return; + } + + this.swiftIOBridge.on("helperEvent", (event: HelperEvent) => { + switch (event.type) { + case "flagsChanged": + this.handleFlagsChanged(event.payload); + break; + case "keyDown": + this.handleKeyDown(event.payload); + break; + case "keyUp": + this.handleKeyUp(event.payload); + break; + } + }); + } + + private handleFlagsChanged(payload: KeyEventPayload) { + // Track Fn key state + if (payload.fnKeyPressed !== undefined) { + if (payload.fnKeyPressed) { + this.addActiveKey("Fn"); + } else { + this.removeActiveKey("Fn"); + } + } + + // Track modifier keys + const modifiers = [ + { flag: payload.metaKey, name: "Cmd" }, + { flag: payload.ctrlKey, name: "Ctrl" }, + { flag: payload.altKey, name: "Alt" }, + { flag: payload.shiftKey, name: "Shift" }, + ]; + + modifiers.forEach(({ flag, name }) => { + if (flag !== undefined) { + if (flag) { + this.addActiveKey(name); + } else { + this.removeActiveKey(name); + } + } + }); + + this.checkShortcuts(); + } + + private handleKeyDown(payload: KeyEventPayload) { + const keyName = getKeyNameFromPayload(payload); + if (keyName) { + this.addActiveKey(keyName); + this.checkShortcuts(); + } + } + + private handleKeyUp(payload: KeyEventPayload) { + const keyName = getKeyNameFromPayload(payload); + if (keyName) { + this.removeActiveKey(keyName); + this.checkShortcuts(); + } + } + + private addActiveKey(key: string) { + this.activeKeys.set(key, { key, timestamp: Date.now() }); + this.emitActiveKeysChanged(); + } + + private removeActiveKey(key: string) { + this.activeKeys.delete(key); + this.emitActiveKeysChanged(); + } + + private emitActiveKeysChanged() { + this.emit("activeKeysChanged", this.getActiveKeys()); + } + + getActiveKeys(): string[] { + return Array.from(this.activeKeys.keys()); + } + + private checkShortcuts() { + // Check PTT shortcut + const isPTTPressed = this.isPTTShortcutPressed(); + this.emit("ptt-state-changed", isPTTPressed); + + // Check toggle recording shortcut + if (this.isToggleRecordingShortcutPressed()) { + this.emit("toggle-recording-triggered"); + } + } + + private isPTTShortcutPressed(): boolean { + if (!this.shortcuts.pushToTalk) { + return false; + } + + const pttKeys = this.shortcuts.pushToTalk.split("+"); + const activeKeysList = this.getActiveKeys(); + + // Check if PTT keys match active keys exactly + return ( + pttKeys.length === activeKeysList.length && + pttKeys.every((key) => activeKeysList.includes(key)) + ); + } + + private isToggleRecordingShortcutPressed(): boolean { + if (!this.shortcuts.toggleRecording) { + return false; + } + + const toggleKeys = this.shortcuts.toggleRecording.split("+"); + const activeKeysList = this.getActiveKeys(); + + // Check if toggle recording keys match active keys exactly + return ( + toggleKeys.length === activeKeysList.length && + toggleKeys.every((key) => activeKeysList.includes(key)) + ); + } + + // Register/unregister global shortcuts (for non-Swift platforms) + registerGlobalShortcuts() { + // This can be implemented for Windows/Linux using Electron's globalShortcut + // For now, we rely on Swift bridge for macOS + } + + unregisterAllShortcuts() { + globalShortcut.unregisterAll(); + } + + cleanup() { + this.unregisterAllShortcuts(); + this.removeAllListeners(); + this.activeKeys.clear(); + } +} diff --git a/apps/desktop/src/renderer/main/content.tsx b/apps/desktop/src/renderer/main/content.tsx index 75efb44..d3a6d71 100644 --- a/apps/desktop/src/renderer/main/content.tsx +++ b/apps/desktop/src/renderer/main/content.tsx @@ -65,7 +65,7 @@ const App: React.FC = () => { { onNavigate={handleNavigation} currentView={currentView} /> - +
diff --git a/apps/desktop/src/renderer/main/index.tsx b/apps/desktop/src/renderer/main/index.tsx index b5ca196..7222c9b 100644 --- a/apps/desktop/src/renderer/main/index.tsx +++ b/apps/desktop/src/renderer/main/index.tsx @@ -2,6 +2,7 @@ import React, { Suspense } from "react"; import { createRoot } from "react-dom/client"; import "@/styles/globals.css"; import { ThemeProvider } from "@/components/theme-provider"; +import { Toaster } from "@/components/ui/sonner"; // Lazy import the main content const Content = React.lazy( @@ -75,6 +76,7 @@ const App: React.FC = () => { }> + ); }; diff --git a/apps/desktop/src/renderer/main/pages/settings/components/AdvancedSettings.tsx b/apps/desktop/src/renderer/main/pages/settings/components/AdvancedSettings.tsx new file mode 100644 index 0000000..c7f2a6e --- /dev/null +++ b/apps/desktop/src/renderer/main/pages/settings/components/AdvancedSettings.tsx @@ -0,0 +1,57 @@ +import React from "react"; +import { + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, +} from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; + +export function AdvancedSettings() { + return ( + + + Advanced Settings + Advanced configuration options + + +
+
+ +

+ Enable detailed logging +

+
+ +
+ +
+
+ +

+ Automatically check for updates +

+
+ +
+ +
+ +
+ + +
+
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/main/pages/settings/components/FormatterSettings.tsx b/apps/desktop/src/renderer/main/pages/settings/components/FormatterSettings.tsx new file mode 100644 index 0000000..cb91a0c --- /dev/null +++ b/apps/desktop/src/renderer/main/pages/settings/components/FormatterSettings.tsx @@ -0,0 +1,182 @@ +import React, { useState, useEffect } from "react"; +import { + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, +} from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Input } from "@/components/ui/input"; +import { FormatterConfig } from "@/types/formatter"; +import { api } from "@/trpc/react"; +import { toast } from "sonner"; + +// OpenRouter models list +const OPENROUTER_MODELS = [ + { value: "google/gemini-2.0-flash-001", label: "Gemini 2.0 Flash" }, + { value: "anthropic/claude-3.5-sonnet", label: "Claude 3.5 Sonnet" }, + { value: "anthropic/claude-3-haiku", label: "Claude 3 Haiku" }, + { value: "openai/gpt-4o", label: "GPT-4o" }, + { value: "openai/gpt-4o-mini", label: "GPT-4o mini" }, + { value: "openai/gpt-4-turbo", label: "GPT-4 Turbo" }, + { value: "meta-llama/llama-3.1-8b-instruct", label: "Llama 3.1 8B" }, + { value: "meta-llama/llama-3.1-70b-instruct", label: "Llama 3.1 70B" }, + { value: "google/gemini-pro-1.5", label: "Gemini Pro 1.5" }, +]; + +export function FormatterSettings() { + const [formatterProvider, setFormatterProvider] = + useState<"openrouter">("openrouter"); + const [openrouterModel, setOpenrouterModel] = useState(""); + const [openrouterApiKey, setOpenrouterApiKey] = useState(""); + const [formatterEnabled, setFormatterEnabled] = useState(false); + + // tRPC queries and mutations + const formatterConfigQuery = api.settings.getFormatterConfig.useQuery(); + const utils = api.useUtils(); + + const setFormatterConfigMutation = + api.settings.setFormatterConfig.useMutation({ + onSuccess: () => { + toast.success("Configuration saved successfully!"); + utils.settings.getFormatterConfig.invalidate(); + }, + onError: (error) => { + console.error("Failed to save formatter config:", error); + toast.error("Failed to save configuration. Please try again."); + }, + }); + + // Load configuration when query data is available + useEffect(() => { + if (formatterConfigQuery.data) { + const config = formatterConfigQuery.data; + setFormatterProvider(config.provider); + setOpenrouterModel(config.model); + setOpenrouterApiKey(config.apiKey); + setFormatterEnabled(config.enabled); + } + }, [formatterConfigQuery.data]); + + const saveFormatterConfig = async () => { + const config: FormatterConfig = { + provider: formatterProvider, + model: openrouterModel, + apiKey: openrouterApiKey, + enabled: formatterEnabled, + }; + + setFormatterConfigMutation.mutate(config); + }; + + return ( + + + Text Formatting Configuration + + Configure AI-powered post-processing of transcriptions + + + +
+ + +
+ + {formatterProvider === "openrouter" && ( + <> +
+ + +
+ +
+ + setOpenrouterApiKey(e.target.value)} + /> +

+ Get your API key from{" "} + + openrouter.ai + +

+
+ + )} + +
+
+ +

+ Apply AI formatting to transcriptions +

+
+ +
+ +
+ +
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/main/pages/settings/components/GeneralSettings.tsx b/apps/desktop/src/renderer/main/pages/settings/components/GeneralSettings.tsx new file mode 100644 index 0000000..47251bf --- /dev/null +++ b/apps/desktop/src/renderer/main/pages/settings/components/GeneralSettings.tsx @@ -0,0 +1,53 @@ +import React from "react"; +import { + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, +} from "@/components/ui/card"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { ThemeToggle } from "@/components/theme-toggle"; + +export function GeneralSettings() { + return ( + + + General Settings + Configure your general preferences + + +
+
+ +

+ Start Amical when you log in +

+
+ +
+ +
+
+ +

+ Keep running in system tray when closed +

+
+ +
+ +
+
+ +

+ Choose your preferred theme +

+
+ +
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/main/pages/settings/components/MicrophoneSettings.tsx b/apps/desktop/src/renderer/main/pages/settings/components/MicrophoneSettings.tsx new file mode 100644 index 0000000..f6254ac --- /dev/null +++ b/apps/desktop/src/renderer/main/pages/settings/components/MicrophoneSettings.tsx @@ -0,0 +1,50 @@ +import React from "react"; +import { + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, +} from "@/components/ui/card"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; + +export function MicrophoneSettings() { + return ( + + + Microphone Settings + Configure your microphone preferences + + +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/main/pages/settings/components/SettingsManager.tsx b/apps/desktop/src/renderer/main/pages/settings/components/SettingsManager.tsx index efb0cc8..6061d4e 100644 --- a/apps/desktop/src/renderer/main/pages/settings/components/SettingsManager.tsx +++ b/apps/desktop/src/renderer/main/pages/settings/components/SettingsManager.tsx @@ -1,86 +1,12 @@ -import React, { useState, useEffect } from "react"; -import { - Card, - CardHeader, - CardTitle, - CardDescription, - CardContent, -} from "@/components/ui/card"; -import { Button } from "@/components/ui/button"; -import { Label } from "@/components/ui/label"; -import { Switch } from "@/components/ui/switch"; +import React from "react"; import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { Input } from "@/components/ui/input"; -import { ThemeToggle } from "@/components/theme-toggle"; -import { FormatterConfig } from "@/types/formatter"; -import { api } from "@/trpc/react"; -import { toast } from "sonner"; - -// OpenRouter models list -const OPENROUTER_MODELS = [ - { value: "google/gemini-2.0-flash-001", label: "Gemini 2.0 Flash" }, - { value: "anthropic/claude-3.5-sonnet", label: "Claude 3.5 Sonnet" }, - { value: "anthropic/claude-3-haiku", label: "Claude 3 Haiku" }, - { value: "openai/gpt-4o", label: "GPT-4o" }, - { value: "openai/gpt-4o-mini", label: "GPT-4o mini" }, - { value: "openai/gpt-4-turbo", label: "GPT-4 Turbo" }, - { value: "meta-llama/llama-3.1-8b-instruct", label: "Llama 3.1 8B" }, - { value: "meta-llama/llama-3.1-70b-instruct", label: "Llama 3.1 70B" }, - { value: "google/gemini-pro-1.5", label: "Gemini Pro 1.5" }, -]; +import { GeneralSettings } from "./GeneralSettings"; +import { MicrophoneSettings } from "./MicrophoneSettings"; +import { ShortcutsSettings } from "./ShortcutsSettings"; +import { FormatterSettings } from "./FormatterSettings"; +import { AdvancedSettings } from "./AdvancedSettings"; export function SettingsManager() { - const [formatterProvider, setFormatterProvider] = - useState<"openrouter">("openrouter"); - const [openrouterModel, setOpenrouterModel] = useState(""); - const [openrouterApiKey, setOpenrouterApiKey] = useState(""); - const [formatterEnabled, setFormatterEnabled] = useState(false); - - // tRPC queries and mutations - const formatterConfigQuery = api.settings.getFormatterConfig.useQuery(); - const utils = api.useUtils(); - - const setFormatterConfigMutation = - api.settings.setFormatterConfig.useMutation({ - onSuccess: () => { - toast.success("Configuration saved successfully!"); - utils.settings.getFormatterConfig.invalidate(); - }, - onError: (error) => { - console.error("Failed to save formatter config:", error); - toast.error("Failed to save configuration. Please try again."); - }, - }); - - // Load configuration when query data is available - useEffect(() => { - if (formatterConfigQuery.data) { - const config = formatterConfigQuery.data; - setFormatterProvider(config.provider); - setOpenrouterModel(config.model); - setOpenrouterApiKey(config.apiKey); - setFormatterEnabled(config.enabled); - } - }, [formatterConfigQuery.data]); - - const saveFormatterConfig = async () => { - const config: FormatterConfig = { - provider: formatterProvider, - model: openrouterModel, - apiKey: openrouterApiKey, - enabled: formatterEnabled, - }; - - setFormatterConfigMutation.mutate(config); - }; - return (
@@ -93,270 +19,23 @@ export function SettingsManager() { - - - General Settings - - Configure your general preferences - - - -
-
- -

- Start Amical when you log in -

-
- -
- -
-
- -

- Keep running in system tray when closed -

-
- -
- -
-
- -

- Choose your preferred theme -

-
- -
-
-
+
- - - Microphone Settings - - Configure your microphone preferences - - - -
- - -
- -
- - -
- -
- - -
-
-
+
- - - Keyboard Shortcuts - - Customize your keyboard shortcuts - - - -
-
- -

- Start/stop recording -

-
- - Ctrl+Shift+Space - -
- -
-
- -

- Show/hide main window -

-
- - Ctrl+Shift+A - -
- - -
-
+
- - - Text Formatting Configuration - - Configure AI-powered post-processing of transcriptions - - - -
- - -
- - {formatterProvider === "openrouter" && ( - <> -
- - -
- -
- - setOpenrouterApiKey(e.target.value)} - /> -

- Get your API key from{" "} - - openrouter.ai - -

-
- - )} - -
-
- -

- Apply AI formatting to transcriptions -

-
- -
- -
- -
-
-
+
- - - Advanced Settings - Advanced configuration options - - -
-
- -

- Enable detailed logging -

-
- -
- -
-
- -

- Automatically check for updates -

-
- -
- -
- -
- - -
-
-
-
+
diff --git a/apps/desktop/src/renderer/main/pages/settings/components/ShortcutsSettings.tsx b/apps/desktop/src/renderer/main/pages/settings/components/ShortcutsSettings.tsx new file mode 100644 index 0000000..9dda326 --- /dev/null +++ b/apps/desktop/src/renderer/main/pages/settings/components/ShortcutsSettings.tsx @@ -0,0 +1,104 @@ +import React, { useState, useEffect } from "react"; +import { + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, +} from "@/components/ui/card"; +import { Label } from "@/components/ui/label"; +import { ShortcutInput } from "@/components/shortcut-input"; +import { api } from "@/trpc/react"; +import { toast } from "sonner"; + +export function ShortcutsSettings() { + const [pushToTalkShortcut, setPushToTalkShortcut] = useState(""); + const [toggleRecordingShortcut, setToggleRecordingShortcut] = useState(""); + const [recordingShortcut, setRecordingShortcut] = useState< + "pushToTalk" | "toggleRecording" | null + >(null); + + // tRPC queries and mutations + const shortcutsQuery = api.settings.getShortcuts.useQuery(); + const utils = api.useUtils(); + + const setShortcutMutation = api.settings.setShortcut.useMutation({ + onSuccess: () => { + utils.settings.getShortcuts.invalidate(); + }, + onError: (error) => { + console.error("Failed to save shortcut:", error); + toast.error("Failed to save shortcut. Please try again."); + }, + }); + + // Load shortcuts when query data is available + useEffect(() => { + if (shortcutsQuery.data) { + setPushToTalkShortcut(shortcutsQuery.data.pushToTalk); + setToggleRecordingShortcut(shortcutsQuery.data.toggleRecording); + } + }, [shortcutsQuery.data]); + + const handlePushToTalkChange = (shortcut: string) => { + setPushToTalkShortcut(shortcut); + setShortcutMutation.mutate({ + type: "pushToTalk", + shortcut: shortcut, + }); + toast.success("Push to Talk shortcut updated"); + }; + + const handleToggleRecordingChange = (shortcut: string) => { + setToggleRecordingShortcut(shortcut); + setShortcutMutation.mutate({ + type: "toggleRecording", + shortcut: shortcut, + }); + toast.success("Toggle Recording shortcut updated"); + }; + + return ( + + + Keyboard Shortcuts + Customize your keyboard shortcuts + + +
+
+ +

+ Hold to dictate while key is pressed +

+
+ + setRecordingShortcut(recording ? "pushToTalk" : null) + } + /> +
+ +
+
+ +

+ Start/stop dictation +

+
+ + setRecordingShortcut(recording ? "toggleRecording" : null) + } + /> +
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/main/pages/transcriptions/components/TranscriptionsList.tsx b/apps/desktop/src/renderer/main/pages/transcriptions/components/TranscriptionsList.tsx index 4521fe4..8590e06 100644 --- a/apps/desktop/src/renderer/main/pages/transcriptions/components/TranscriptionsList.tsx +++ b/apps/desktop/src/renderer/main/pages/transcriptions/components/TranscriptionsList.tsx @@ -120,6 +120,9 @@ export const TranscriptionsList: React.FC = () => { }; const getTitle = (text: string) => { + if (!text || text.trim() === "") { + return `no words detected`; + } const firstSentence = text.split(".")[0]; return firstSentence.length > 50 ? firstSentence.substring(0, 50) + "..." @@ -127,7 +130,174 @@ export const TranscriptionsList: React.FC = () => { }; const getWordCount = (text: string) => { - return text.split(" ").length; + const trimmedText = text.trim(); + if (!trimmedText) return 0; + return trimmedText.split(/\s+/).length; + }; + + const renderLoadingState = () => ( + + +
+
+

+ Loading transcriptions... +

+
+
+
+ ); + + const renderEmptyState = () => ( + + +
+ +

No transcriptions found

+

+ {searchTerm + ? "Try adjusting your search terms." + : "Start recording to see your transcriptions here."} +

+ {!searchTerm && } +
+
+
+ ); + + const renderTranscriptionCard = (transcription: Transcription) => ( + + +
+
+
+

+ {getTitle(transcription.text)} +

+
+
+ +
+
+ + {getWordCount(transcription.text)} words + + + {format(new Date(transcription.timestamp), "MMM d")} + + {format(new Date(transcription.timestamp), "h:mm a")} + + {transcription.language?.toUpperCase() || "EN"} + +
+ + + + + + Copy transcription + + + + {transcription.audioFile && ( + + + + + + Play audio + + + )} + + + setOpenDropdownId(open ? transcription.id : null) + } + > + + + + + Actions + {transcription.audioFile && ( + <> + handleDownloadAudio(transcription.id)} + disabled={downloadAudioMutation.isPending} + > + + Download Audio + + + + )} + handleDelete(transcription.id)} + className="text-destructive" + disabled={deleteTranscriptionMutation.isPending} + > + + Delete + + + +
+
+
+
+ ); + + const renderTranscriptionsList = () => ( +
+ {transcriptions.map(renderTranscriptionCard)} +
+ ); + + const renderFooter = () => { + if (loading || transcriptions.length === 0) return null; + + return ( +
+ + Showing {transcriptions.length} of {totalCount} transcription + {totalCount !== 1 ? "s" : ""} + + + Total:{" "} + {transcriptions.reduce((acc, t) => acc + getWordCount(t.text), 0)}{" "} + words + +
+ ); + }; + + const renderContent = () => { + if (loading) return renderLoadingState(); + if (transcriptions.length === 0) return renderEmptyState(); + return renderTranscriptionsList(); }; return ( @@ -145,161 +315,11 @@ export const TranscriptionsList: React.FC = () => {
- {/* Transcriptions Grid */} - {loading ? ( - - -
-
-

- Loading transcriptions... -

-
-
-
- ) : transcriptions.length === 0 ? ( - - -
- -

No transcriptions found

-

- {searchTerm - ? "Try adjusting your search terms." - : "Start recording to see your transcriptions here."} -

- {!searchTerm && } -
-
-
- ) : ( -
- {transcriptions.map((transcription) => ( - - -
-
-
-

- {getTitle(transcription.text)} -

-
- - {getWordCount(transcription.text)} words - - - {format(new Date(transcription.timestamp), "MMM d")} - - - {format(new Date(transcription.timestamp), "h:mm a")} - - - {transcription.language?.toUpperCase() || "EN"} - -
-
-
+ {/* Transcriptions Content */} + {renderContent()} -
- - - - - - Copy transcription - - - - {transcription.audioFile && ( - - - - - - Play audio - - - )} - - - setOpenDropdownId(open ? transcription.id : null) - } - > - - - - - Actions - {transcription.audioFile && ( - - handleDownloadAudio(transcription.id) - } - disabled={downloadAudioMutation.isPending} - > - - Download Audio - - )} - - handleDelete(transcription.id)} - className="text-destructive" - disabled={deleteTranscriptionMutation.isPending} - > - - Delete - - - -
-
-
-
- ))} -
- )} - - {!loading && transcriptions.length > 0 && ( -
- - Showing {transcriptions.length} of {totalCount} transcription - {totalCount !== 1 ? "s" : ""} - - - Total:{" "} - {transcriptions.reduce((acc, t) => acc + getWordCount(t.text), 0)}{" "} - words - -
- )} + {/* Footer Stats */} + {renderFooter()}
); }; diff --git a/apps/desktop/src/services/settings-service.ts b/apps/desktop/src/services/settings-service.ts index 7dbea0e..3510e46 100644 --- a/apps/desktop/src/services/settings-service.ts +++ b/apps/desktop/src/services/settings-service.ts @@ -10,6 +10,11 @@ import type { AppSettingsData } from "../db/schema"; /** * Database-backed settings service with typed configuration */ +export interface ShortcutsConfig { + pushToTalk: string; + toggleRecording: string; +} + export class SettingsService { constructor() {} @@ -89,4 +94,28 @@ export class SettingsService { ): Promise { await updateSettingsSection("recording", recordingSettings); } + + /** + * Get shortcuts configuration with defaults + */ + async getShortcuts(): Promise { + const shortcuts = await getSettingsSection("shortcuts"); + // Return defaults if not set + return { + pushToTalk: shortcuts?.pushToTalk || "Fn", + toggleRecording: shortcuts?.toggleRecording || "Fn+Space", + }; + } + + /** + * Update shortcuts configuration + */ + async setShortcuts(shortcuts: ShortcutsConfig): Promise { + // Store empty strings as undefined to clear shortcuts + const dataToStore = { + pushToTalk: shortcuts.pushToTalk || undefined, + toggleRecording: shortcuts.toggleRecording || undefined, + }; + await updateSettingsSection("shortcuts", dataToStore); + } } diff --git a/apps/desktop/src/trpc/routers/settings.ts b/apps/desktop/src/trpc/routers/settings.ts index 39e0209..cc24d5c 100644 --- a/apps/desktop/src/trpc/routers/settings.ts +++ b/apps/desktop/src/trpc/routers/settings.ts @@ -1,4 +1,5 @@ import { initTRPC } from "@trpc/server"; +import { observable } from "@trpc/server/observable"; import superjson from "superjson"; import { z } from "zod"; import { SettingsService } from "../../services/settings-service"; @@ -21,8 +22,15 @@ declare global { var transcriptionService: any; var settingsService: any; var logger: any; + var appManager: any; + var shortcutManager: any; } +// Shortcut schema +const SetShortcutSchema = z.object({ + type: z.enum(["pushToTalk", "toggleRecording"]), + shortcut: z.string(), +}); export const settingsRouter = t.router({ // Get formatter configuration getFormatterConfig: t.procedure.query(async () => { @@ -73,4 +81,92 @@ export const settingsRouter = t.router({ throw error; } }), + // Get shortcuts configuration + getShortcuts: t.procedure.query(async () => { + try { + if (!globalThis.settingsService) { + throw new Error("SettingsService not available"); + } + return await globalThis.settingsService.getShortcuts(); + } catch (error) { + if (globalThis.logger) { + globalThis.logger.main.error("Error getting shortcuts:", error); + } + return {}; + } + }), + + // Set individual shortcut + setShortcut: t.procedure + .input(SetShortcutSchema) + .mutation(async ({ input }) => { + try { + if (!globalThis.settingsService) { + throw new Error("SettingsService not available"); + } + + // Get current shortcuts and update the specific one + const currentShortcuts = + await globalThis.settingsService.getShortcuts(); + const updatedShortcuts = { + ...currentShortcuts, + [input.type]: input.shortcut, + }; + + await globalThis.settingsService.setShortcuts(updatedShortcuts); + + if (globalThis.logger) { + globalThis.logger.main.info("Shortcut updated", input); + } + + // Notify shortcut manager to reload shortcuts + if (globalThis.shortcutManager) { + await globalThis.shortcutManager.reloadShortcuts(); + globalThis.logger.main.info( + "Shortcut manager notified of shortcut change", + ); + } + + return true; + } catch (error) { + if (globalThis.logger) { + globalThis.logger.main.error("Error setting shortcut:", error); + } + throw error; + } + }), + + // Active keys subscription for shortcut recording + activeKeysUpdates: t.procedure.subscription(() => { + return observable((emit) => { + if (!globalThis.shortcutManager) { + globalThis.logger?.main.warn( + "ShortcutManager not available for activeKeys subscription", + ); + emit.next([]); + return () => {}; + } + + // Emit initial state + emit.next(globalThis.shortcutManager.getActiveKeys()); + + // Set up listener for changes + const handleActiveKeysChanged = (keys: string[]) => { + emit.next(keys); + }; + + globalThis.shortcutManager.on( + "activeKeysChanged", + handleActiveKeysChanged, + ); + + // Cleanup function + return () => { + globalThis.shortcutManager.off( + "activeKeysChanged", + handleActiveKeysChanged, + ); + }; + }); + }), }); diff --git a/apps/desktop/src/utils/keycode-map.ts b/apps/desktop/src/utils/keycode-map.ts new file mode 100644 index 0000000..de6334f --- /dev/null +++ b/apps/desktop/src/utils/keycode-map.ts @@ -0,0 +1,107 @@ +// macOS keycode mappings +export const keycodeToKey: Record = { + // Letters + 0: "A", + 1: "S", + 2: "D", + 3: "F", + 4: "H", + 5: "G", + 6: "Z", + 7: "X", + 8: "C", + 9: "V", + 11: "B", + 12: "Q", + 13: "W", + 14: "E", + 15: "R", + 16: "Y", + 17: "T", + 31: "O", + 32: "U", + 34: "I", + 35: "P", + 37: "L", + 38: "J", + 40: "K", + 45: "N", + 46: "M", + + // Numbers + 18: "1", + 19: "2", + 20: "3", + 21: "4", + 22: "6", + 23: "5", + 25: "9", + 26: "7", + 28: "8", + 29: "0", + + // Special keys + 48: "Tab", + 49: "Space", + 51: "Delete", + 52: "Enter", + 53: "Escape", + + // Function keys + 122: "F1", + 120: "F2", + 99: "F3", + 118: "F4", + 96: "F5", + 97: "F6", + 98: "F7", + 100: "F8", + 101: "F9", + 109: "F10", + 103: "F11", + 111: "F12", + + // Arrow keys + 123: "Left", + 124: "Right", + 125: "Down", + 126: "Up", + + // Punctuation and symbols + 27: "-", + 24: "=", + 33: "[", + 30: "]", + 42: "\\", + 41: ";", + 39: "'", + 43: ",", + 47: ".", + 44: "/", + 50: "`", +}; + +export function getKeyFromKeycode(keycode: number): string | undefined { + return keycodeToKey[keycode]; +} + +export function matchesShortcutKey( + keycode: number | undefined, + keyName: string, +): boolean { + if (keycode === undefined) return false; + + const mappedKey = keycodeToKey[keycode]; + if (!mappedKey) return false; + + return mappedKey.toUpperCase() === keyName.toUpperCase(); +} + +export function getKeyNameFromPayload(payload: any): string | undefined { + // Try to get key name from various sources + if (payload.key) return payload.key; + if (payload.keyCode !== undefined && keycodeToKey[payload.keyCode]) { + return keycodeToKey[payload.keyCode]; + } + return undefined; +}