From 3e609d1e3bf9d0324fa7aef454736033896ddf3b Mon Sep 17 00:00:00 2001 From: Beingpax Date: Tue, 1 Jul 2025 22:52:09 +0545 Subject: [PATCH] Refactor: Unify and improve hotkey settings UI --- VoiceInk/HotkeyManager.swift | 305 +++++++----- VoiceInk/Services/ImportExportService.swift | 27 +- VoiceInk/Views/ContentView.swift | 7 +- VoiceInk/Views/Metrics/MetricsSetupView.swift | 9 +- .../OnboardingPermissionsView.swift | 102 +++- .../Onboarding/OnboardingTutorialView.swift | 26 +- VoiceInk/Views/RecordView.swift | 299 ----------- VoiceInk/Views/Settings/SettingsView.swift | 466 ++++++++---------- VoiceInk/VoiceInk.swift | 2 - VoiceInk/WindowManager.swift | 10 +- 10 files changed, 519 insertions(+), 734 deletions(-) delete mode 100644 VoiceInk/Views/RecordView.swift diff --git a/VoiceInk/HotkeyManager.swift b/VoiceInk/HotkeyManager.swift index 9384eab..9cc37a6 100644 --- a/VoiceInk/HotkeyManager.swift +++ b/VoiceInk/HotkeyManager.swift @@ -5,57 +5,62 @@ import AppKit extension KeyboardShortcuts.Name { static let toggleMiniRecorder = Self("toggleMiniRecorder") + static let toggleMiniRecorder2 = Self("toggleMiniRecorder2") } @MainActor class HotkeyManager: ObservableObject { - @Published var isListening = false - @Published var isShortcutConfigured = false - @Published var isPushToTalkEnabled: Bool { + @Published var selectedHotkey1: HotkeyOption { didSet { - UserDefaults.standard.set(isPushToTalkEnabled, forKey: "isPushToTalkEnabled") - resetKeyStates() - setupKeyMonitor() + UserDefaults.standard.set(selectedHotkey1.rawValue, forKey: "selectedHotkey1") + setupHotkeyMonitoring() } } - @Published var pushToTalkKey: PushToTalkKey { + @Published var selectedHotkey2: HotkeyOption { didSet { - UserDefaults.standard.set(pushToTalkKey.rawValue, forKey: "pushToTalkKey") - resetKeyStates() + UserDefaults.standard.set(selectedHotkey2.rawValue, forKey: "selectedHotkey2") + setupHotkeyMonitoring() } } private var whisperState: WhisperState - private var currentKeyState = false private var miniRecorderShortcutManager: MiniRecorderShortcutManager - // Change from single monitor to separate local and global monitors + // NSEvent monitoring for modifier keys private var globalEventMonitor: Any? private var localEventMonitor: Any? - // Key handling properties + // Key state tracking + private var currentKeyState = false private var keyPressStartTime: Date? private let briefPressThreshold = 1.0 // 1 second threshold for brief press - private var isHandsFreeMode = false // Track if we're in hands-free recording mode - - // Add cooldown management - private var lastShortcutTriggerTime: Date? - private let shortcutCooldownInterval: TimeInterval = 0.5 // 500ms cooldown + private var isHandsFreeMode = false + // Debounce for Fn key private var fnDebounceTask: Task? private var pendingFnKeyState: Bool? = nil - enum PushToTalkKey: String, CaseIterable { + // Keyboard shortcut state tracking + private var shortcutKeyPressStartTime: Date? + private var isShortcutHandsFreeMode = false + private var shortcutCurrentKeyState = false + private var lastShortcutTriggerTime: Date? + private let shortcutCooldownInterval: TimeInterval = 0.5 + + enum HotkeyOption: String, CaseIterable { + case none = "none" case rightOption = "rightOption" case leftOption = "leftOption" - case leftControl = "leftControl" + case leftControl = "leftControl" case rightControl = "rightControl" case fn = "fn" case rightCommand = "rightCommand" case rightShift = "rightShift" + case custom = "custom" var displayName: String { switch self { + case .none: return "None" case .rightOption: return "Right Option (⌥)" case .leftOption: return "Left Option (⌥)" case .leftControl: return "Left Control (⌃)" @@ -63,10 +68,11 @@ class HotkeyManager: ObservableObject { case .fn: return "Fn" case .rightCommand: return "Right Command (⌘)" case .rightShift: return "Right Shift (⇧)" + case .custom: return "Custom" } } - var keyCode: CGKeyCode { + var keyCode: CGKeyCode? { switch self { case .rightOption: return 0x3D case .leftOption: return 0x3A @@ -75,52 +81,91 @@ class HotkeyManager: ObservableObject { case .fn: return 0x3F case .rightCommand: return 0x36 case .rightShift: return 0x3C + case .custom, .none: return nil } } + + var isModifierKey: Bool { + return self != .custom && self != .none + } } init(whisperState: WhisperState) { - self.isPushToTalkEnabled = UserDefaults.standard.bool(forKey: "isPushToTalkEnabled") - self.pushToTalkKey = PushToTalkKey(rawValue: UserDefaults.standard.string(forKey: "pushToTalkKey") ?? "") ?? .rightCommand + // One-time migration from legacy single-hotkey settings + if UserDefaults.standard.object(forKey: "didMigrateHotkeys_v2") == nil { + // If legacy push-to-talk modifier key was enabled, carry it over + if UserDefaults.standard.bool(forKey: "isPushToTalkEnabled"), + let legacyRaw = UserDefaults.standard.string(forKey: "pushToTalkKey"), + let legacyKey = HotkeyOption(rawValue: legacyRaw) { + UserDefaults.standard.set(legacyKey.rawValue, forKey: "selectedHotkey1") + } + // If a custom shortcut existed, mark hotkey-1 as custom (shortcut itself already persisted) + if KeyboardShortcuts.getShortcut(for: .toggleMiniRecorder) != nil { + UserDefaults.standard.set(HotkeyOption.custom.rawValue, forKey: "selectedHotkey1") + } + // Leave second hotkey as .none + UserDefaults.standard.set(true, forKey: "didMigrateHotkeys_v2") + } + // ---- normal initialisation ---- + self.selectedHotkey1 = HotkeyOption(rawValue: UserDefaults.standard.string(forKey: "selectedHotkey1") ?? "") ?? .rightCommand + self.selectedHotkey2 = HotkeyOption(rawValue: UserDefaults.standard.string(forKey: "selectedHotkey2") ?? "") ?? .none self.whisperState = whisperState self.miniRecorderShortcutManager = MiniRecorderShortcutManager(whisperState: whisperState) - - updateShortcutStatus() } - private func resetKeyStates() { - currentKeyState = false - keyPressStartTime = nil - isHandsFreeMode = false + func startHotkeyMonitoring() { + setupHotkeyMonitoring() } - private func setupKeyMonitor() { - removeKeyMonitor() + private func setupHotkeyMonitoring() { + removeAllMonitoring() - guard isPushToTalkEnabled else { return } - - // Global monitor for capturing flags when app is in background + setupModifierKeyMonitoring() + setupCustomShortcutMonitoring() + } + + private func setupModifierKeyMonitoring() { + // Only set up if at least one hotkey is a modifier key + guard (selectedHotkey1.isModifierKey && selectedHotkey1 != .none) || (selectedHotkey2.isModifierKey && selectedHotkey2 != .none) else { return } + globalEventMonitor = NSEvent.addGlobalMonitorForEvents(matching: .flagsChanged) { [weak self] event in guard let self = self else { return } - Task { @MainActor in - await self.handleNSKeyEvent(event) + await self.handleModifierKeyEvent(event) } } - // Local monitor for capturing flags when app has focus localEventMonitor = NSEvent.addLocalMonitorForEvents(matching: .flagsChanged) { [weak self] event in guard let self = self else { return event } - Task { @MainActor in - await self.handleNSKeyEvent(event) + await self.handleModifierKeyEvent(event) } - - return event // Return the event to allow normal processing + return event } } - private func removeKeyMonitor() { + private func setupCustomShortcutMonitoring() { + // Hotkey 1 + if selectedHotkey1 == .custom { + KeyboardShortcuts.onKeyDown(for: .toggleMiniRecorder) { [weak self] in + Task { @MainActor in await self?.handleCustomShortcutKeyDown() } + } + KeyboardShortcuts.onKeyUp(for: .toggleMiniRecorder) { [weak self] in + Task { @MainActor in await self?.handleCustomShortcutKeyUp() } + } + } + // Hotkey 2 + if selectedHotkey2 == .custom { + KeyboardShortcuts.onKeyDown(for: .toggleMiniRecorder2) { [weak self] in + Task { @MainActor in await self?.handleCustomShortcutKeyDown() } + } + KeyboardShortcuts.onKeyUp(for: .toggleMiniRecorder2) { [weak self] in + Task { @MainActor in await self?.handleCustomShortcutKeyUp() } + } + } + } + + private func removeAllMonitoring() { if let monitor = globalEventMonitor { NSEvent.removeMonitor(monitor) globalEventMonitor = nil @@ -130,87 +175,92 @@ class HotkeyManager: ObservableObject { NSEvent.removeMonitor(monitor) localEventMonitor = nil } + + resetKeyStates() } - private func handleNSKeyEvent(_ event: NSEvent) async { + private func resetKeyStates() { + currentKeyState = false + keyPressStartTime = nil + isHandsFreeMode = false + shortcutCurrentKeyState = false + shortcutKeyPressStartTime = nil + isShortcutHandsFreeMode = false + } + + private func handleModifierKeyEvent(_ event: NSEvent) async { let keycode = event.keyCode let flags = event.modifierFlags - // Check if the target key is pressed based on the modifier flags - var isKeyPressed = false - var isTargetKey = false - - switch pushToTalkKey { - case .rightOption, .leftOption: - isKeyPressed = flags.contains(.option) - isTargetKey = keycode == pushToTalkKey.keyCode - case .leftControl, .rightControl: - isKeyPressed = flags.contains(.control) - isTargetKey = keycode == pushToTalkKey.keyCode - case .fn: - isKeyPressed = flags.contains(.function) - isTargetKey = keycode == pushToTalkKey.keyCode - // Debounce only for Fn key - if isTargetKey { - pendingFnKeyState = isKeyPressed - fnDebounceTask?.cancel() - fnDebounceTask = Task { [pendingState = isKeyPressed] in - try? await Task.sleep(nanoseconds: 75_000_000) // 75ms - // Only act if the state hasn't changed during debounce - if pendingFnKeyState == pendingState { - await MainActor.run { - self.processPushToTalkKey(isKeyPressed: pendingState) - } - } - } - return - } - case .rightCommand: - isKeyPressed = flags.contains(.command) - isTargetKey = keycode == pushToTalkKey.keyCode - case .rightShift: - isKeyPressed = flags.contains(.shift) - isTargetKey = keycode == pushToTalkKey.keyCode + // Determine which hotkey (if any) is being triggered + let activeHotkey: HotkeyOption? + if selectedHotkey1.isModifierKey && selectedHotkey1.keyCode == keycode { + activeHotkey = selectedHotkey1 + } else if selectedHotkey2.isModifierKey && selectedHotkey2.keyCode == keycode { + activeHotkey = selectedHotkey2 + } else { + activeHotkey = nil } - guard isTargetKey else { return } - processPushToTalkKey(isKeyPressed: isKeyPressed) + guard let hotkey = activeHotkey else { return } + + var isKeyPressed = false + + switch hotkey { + case .rightOption, .leftOption: + isKeyPressed = flags.contains(.option) + case .leftControl, .rightControl: + isKeyPressed = flags.contains(.control) + case .fn: + isKeyPressed = flags.contains(.function) + // Debounce Fn key + pendingFnKeyState = isKeyPressed + fnDebounceTask?.cancel() + fnDebounceTask = Task { [pendingState = isKeyPressed] in + try? await Task.sleep(nanoseconds: 75_000_000) // 75ms + if pendingFnKeyState == pendingState { + await MainActor.run { + self.processKeyPress(isKeyPressed: pendingState) + } + } + } + return + case .rightCommand: + isKeyPressed = flags.contains(.command) + case .rightShift: + isKeyPressed = flags.contains(.shift) + case .custom, .none: + return // Should not reach here + } + + processKeyPress(isKeyPressed: isKeyPressed) } - private func processPushToTalkKey(isKeyPressed: Bool) { + private func processKeyPress(isKeyPressed: Bool) { guard isKeyPressed != currentKeyState else { return } currentKeyState = isKeyPressed - // Key is pressed down if isKeyPressed { keyPressStartTime = Date() - // If we're in hands-free mode, stop recording if isHandsFreeMode { isHandsFreeMode = false Task { @MainActor in await whisperState.handleToggleMiniRecorder() } return } - // Show recorder if not already visible if !whisperState.isMiniRecorderVisible { Task { @MainActor in await whisperState.handleToggleMiniRecorder() } } - } - // Key is released - else { + } else { let now = Date() - // Calculate press duration if let startTime = keyPressStartTime { let pressDuration = now.timeIntervalSince(startTime) if pressDuration < briefPressThreshold { - // For brief presses, enter hands-free mode isHandsFreeMode = true - // Continue recording - do nothing on release } else { - // For longer presses, stop and transcribe Task { @MainActor in await whisperState.handleToggleMiniRecorder() } } } @@ -219,41 +269,66 @@ class HotkeyManager: ObservableObject { } } - func updateShortcutStatus() { - isShortcutConfigured = KeyboardShortcuts.getShortcut(for: .toggleMiniRecorder) != nil - if isShortcutConfigured { - setupShortcutHandler() - setupKeyMonitor() - } else { - removeKeyMonitor() - } - } - - private func setupShortcutHandler() { - KeyboardShortcuts.onKeyUp(for: .toggleMiniRecorder) { [weak self] in - Task { @MainActor in - await self?.handleShortcutTriggered() - } - } - } - - private func handleShortcutTriggered() async { - // Check cooldown + private func handleCustomShortcutKeyDown() async { if let lastTrigger = lastShortcutTriggerTime, Date().timeIntervalSince(lastTrigger) < shortcutCooldownInterval { - return // Still in cooldown period + return } - // Update last trigger time + guard !shortcutCurrentKeyState else { return } + shortcutCurrentKeyState = true lastShortcutTriggerTime = Date() + shortcutKeyPressStartTime = Date() - // Handle the shortcut - await whisperState.handleToggleMiniRecorder() + if isShortcutHandsFreeMode { + isShortcutHandsFreeMode = false + await whisperState.handleToggleMiniRecorder() + return + } + + if !whisperState.isMiniRecorderVisible { + await whisperState.handleToggleMiniRecorder() + } + } + + private func handleCustomShortcutKeyUp() async { + guard shortcutCurrentKeyState else { return } + shortcutCurrentKeyState = false + + let now = Date() + + if let startTime = shortcutKeyPressStartTime { + let pressDuration = now.timeIntervalSince(startTime) + + if pressDuration < briefPressThreshold { + isShortcutHandsFreeMode = true + } else { + await whisperState.handleToggleMiniRecorder() + } + } + + shortcutKeyPressStartTime = nil + } + + // Computed property for backward compatibility with UI + var isShortcutConfigured: Bool { + let isHotkey1Configured = (selectedHotkey1 == .custom) ? (KeyboardShortcuts.getShortcut(for: .toggleMiniRecorder) != nil) : true + let isHotkey2Configured = (selectedHotkey2 == .custom) ? (KeyboardShortcuts.getShortcut(for: .toggleMiniRecorder2) != nil) : true + return isHotkey1Configured && isHotkey2Configured + } + + func updateShortcutStatus() { + // Called when a custom shortcut changes + if selectedHotkey1 == .custom || selectedHotkey2 == .custom { + setupHotkeyMonitoring() + } } deinit { Task { @MainActor in - removeKeyMonitor() + removeAllMonitoring() } } } + + diff --git a/VoiceInk/Services/ImportExportService.swift b/VoiceInk/Services/ImportExportService.swift index 8ead1c5..053e22a 100644 --- a/VoiceInk/Services/ImportExportService.swift +++ b/VoiceInk/Services/ImportExportService.swift @@ -6,8 +6,9 @@ import LaunchAtLogin struct GeneralSettings: Codable { let toggleMiniRecorderShortcut: KeyboardShortcuts.Shortcut? - let isPushToTalkEnabled: Bool? - let pushToTalkKeyRawValue: String? + let toggleMiniRecorderShortcut2: KeyboardShortcuts.Shortcut? + let selectedHotkey1RawValue: String? + let selectedHotkey2RawValue: String? let launchAtLoginEnabled: Bool? let isMenuBarOnly: Bool? let useAppleScriptPaste: Bool? @@ -37,8 +38,7 @@ class ImportExportService { private let dictionaryItemsKey = "CustomDictionaryItems" private let wordReplacementsKey = "wordReplacements" - private let keyIsPushToTalkEnabled = "isPushToTalkEnabled" - private let keyPushToTalkKey = "pushToTalkKey" + private let keyIsMenuBarOnly = "IsMenuBarOnly" private let keyUseAppleScriptPaste = "UseAppleScriptPaste" private let keyRecorderType = "RecorderType" @@ -79,8 +79,9 @@ class ImportExportService { let generalSettingsToExport = GeneralSettings( toggleMiniRecorderShortcut: KeyboardShortcuts.getShortcut(for: .toggleMiniRecorder), - isPushToTalkEnabled: hotkeyManager.isPushToTalkEnabled, - pushToTalkKeyRawValue: hotkeyManager.pushToTalkKey.rawValue, + toggleMiniRecorderShortcut2: KeyboardShortcuts.getShortcut(for: .toggleMiniRecorder2), + selectedHotkey1RawValue: hotkeyManager.selectedHotkey1.rawValue, + selectedHotkey2RawValue: hotkeyManager.selectedHotkey2.rawValue, launchAtLoginEnabled: LaunchAtLogin.isEnabled, isMenuBarOnly: menuBarManager.isMenuBarOnly, useAppleScriptPaste: UserDefaults.standard.bool(forKey: keyUseAppleScriptPaste), @@ -206,12 +207,16 @@ class ImportExportService { if let shortcut = general.toggleMiniRecorderShortcut { KeyboardShortcuts.setShortcut(shortcut, for: .toggleMiniRecorder) } - if let pttEnabled = general.isPushToTalkEnabled { - hotkeyManager.isPushToTalkEnabled = pttEnabled + if let shortcut2 = general.toggleMiniRecorderShortcut2 { + KeyboardShortcuts.setShortcut(shortcut2, for: .toggleMiniRecorder2) } - if let pttKeyRaw = general.pushToTalkKeyRawValue, - let pttKey = HotkeyManager.PushToTalkKey(rawValue: pttKeyRaw) { - hotkeyManager.pushToTalkKey = pttKey + if let hotkeyRaw = general.selectedHotkey1RawValue, + let hotkey = HotkeyManager.HotkeyOption(rawValue: hotkeyRaw) { + hotkeyManager.selectedHotkey1 = hotkey + } + if let hotkeyRaw2 = general.selectedHotkey2RawValue, + let hotkey2 = HotkeyManager.HotkeyOption(rawValue: hotkeyRaw2) { + hotkeyManager.selectedHotkey2 = hotkey2 } if let launch = general.launchAtLoginEnabled { LaunchAtLogin.isEnabled = launch diff --git a/VoiceInk/Views/ContentView.swift b/VoiceInk/Views/ContentView.swift index 6fe3e21..c0e6610 100644 --- a/VoiceInk/Views/ContentView.swift +++ b/VoiceInk/Views/ContentView.swift @@ -5,7 +5,6 @@ import KeyboardShortcuts // ViewType enum with all cases enum ViewType: String, CaseIterable { case metrics = "Dashboard" - case record = "Record Audio" case transcribeAudio = "Transcribe Audio" case history = "History" case models = "AI Models" @@ -20,7 +19,6 @@ enum ViewType: String, CaseIterable { var icon: String { switch self { case .metrics: return "gauge.medium" - case .record: return "mic.circle.fill" case .transcribeAudio: return "waveform.circle.fill" case .history: return "doc.text.fill" case .models: return "brain.head.profile" @@ -193,6 +191,8 @@ struct ContentView: View { .frame(minWidth: 940, minHeight: 730) .onAppear { hasLoadedData = true + // Initialize hotkey monitoring after the app is ready + hotkeyManager.startHotkeyMonitoring() } .onReceive(NotificationCenter.default.publisher(for: .navigateToDestination)) { notification in print("ContentView: Received navigation notification") @@ -235,13 +235,12 @@ struct ContentView: View { MetricsView(skipSetupCheck: true) } else { MetricsSetupView() + .environmentObject(hotkeyManager) } case .models: ModelManagementView(whisperState: whisperState) case .enhancement: EnhancementSettingsView() - case .record: - RecordView() case .transcribeAudio: AudioTranscribeView() case .history: diff --git a/VoiceInk/Views/Metrics/MetricsSetupView.swift b/VoiceInk/Views/Metrics/MetricsSetupView.swift index 7b74950..6ab7dbf 100644 --- a/VoiceInk/Views/Metrics/MetricsSetupView.swift +++ b/VoiceInk/Views/Metrics/MetricsSetupView.swift @@ -3,6 +3,7 @@ import KeyboardShortcuts struct MetricsSetupView: View { @EnvironmentObject private var whisperState: WhisperState + @EnvironmentObject private var hotkeyManager: HotkeyManager @State private var isAccessibilityEnabled = AXIsProcessTrusted() @State private var isScreenRecordingEnabled = CGPreflightScreenCaptureAccess() @@ -67,7 +68,7 @@ struct MetricsSetupView: View { switch index { case 0: stepInfo = ( - isCompleted: KeyboardShortcuts.getShortcut(for: .toggleMiniRecorder) != nil, + isCompleted: hotkeyManager.selectedHotkey1 != .none, icon: "command", title: "Set Keyboard Shortcut", description: "Use VoiceInk anywhere with a shortcut." @@ -149,7 +150,7 @@ struct MetricsSetupView: View { openModelManagement() } else { // Handle different permission requests based on which one is missing - if KeyboardShortcuts.getShortcut(for: .toggleMiniRecorder) == nil { + if hotkeyManager.selectedHotkey1 == .none { openSettings() } else if !AXIsProcessTrusted() { if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") { @@ -166,7 +167,7 @@ struct MetricsSetupView: View { } private func getActionButtonTitle() -> String { - if KeyboardShortcuts.getShortcut(for: .toggleMiniRecorder) == nil { + if hotkeyManager.selectedHotkey1 == .none { return "Configure Shortcut" } else if !AXIsProcessTrusted() { return "Enable Accessibility" @@ -185,7 +186,7 @@ struct MetricsSetupView: View { } private var isShortcutAndAccessibilityGranted: Bool { - KeyboardShortcuts.getShortcut(for: .toggleMiniRecorder) != nil && + hotkeyManager.selectedHotkey1 != .none && AXIsProcessTrusted() && CGPreflightScreenCaptureAccess() } diff --git a/VoiceInk/Views/Onboarding/OnboardingPermissionsView.swift b/VoiceInk/Views/Onboarding/OnboardingPermissionsView.swift index ce297b7..e071d59 100644 --- a/VoiceInk/Views/Onboarding/OnboardingPermissionsView.swift +++ b/VoiceInk/Views/Onboarding/OnboardingPermissionsView.swift @@ -203,32 +203,13 @@ struct OnboardingPermissionsView: View { // Keyboard shortcut recorder (only shown for keyboard shortcut step) if permissions[currentPermissionIndex].type == .keyboardShortcut { - VStack(spacing: 16) { - if hotkeyManager.isShortcutConfigured { - if let shortcut = KeyboardShortcuts.getShortcut(for: .toggleMiniRecorder) { - KeyboardShortcutView(shortcut: shortcut) - .scaleEffect(1.2) - } - } - - VStack(spacing: 16) { - KeyboardShortcuts.Recorder("Set Shortcut:", name: .toggleMiniRecorder) { newShortcut in - withAnimation { - if newShortcut != nil { - permissionStates[currentPermissionIndex] = true - showAnimation = true - } else { - permissionStates[currentPermissionIndex] = false - showAnimation = false - } - hotkeyManager.updateShortcutStatus() - } - } - .controlSize(.large) - - SkipButton(text: "Skip for now") { - moveToNext() - } + hotkeyView( + binding: $hotkeyManager.selectedHotkey1, + shortcutName: .toggleMiniRecorder + ) { isConfigured in + withAnimation { + permissionStates[currentPermissionIndex] = isConfigured + showAnimation = isConfigured } } .scaleEffect(scale) @@ -424,4 +405,73 @@ struct OnboardingPermissionsView: View { return permissionStates[currentPermissionIndex] ? "Continue" : "Enable Access" } } + + @ViewBuilder + private func hotkeyView( + binding: Binding, + shortcutName: KeyboardShortcuts.Name, + onConfigured: @escaping (Bool) -> Void + ) -> some View { + VStack(spacing: 16) { + HStack(spacing: 12) { + Spacer() + + Text("Shortcut:") + .font(.system(size: 16, weight: .medium)) + .foregroundColor(.white.opacity(0.8)) + + Menu { + ForEach(HotkeyManager.HotkeyOption.allCases, id: \.self) { option in + if option != .none && option != .custom { // Exclude 'None' and 'Custom' from the list + Button(action: { + binding.wrappedValue = option + onConfigured(option.isModifierKey) + }) { + HStack { + Text(option.displayName) + if binding.wrappedValue == option { + Spacer() + Image(systemName: "checkmark") + } + } + } + } + } + } label: { + HStack(spacing: 8) { + Text(binding.wrappedValue.displayName) + .foregroundColor(.white) + .font(.system(size: 16, weight: .medium)) + Image(systemName: "chevron.up.chevron.down") + .font(.system(size: 12)) + .foregroundColor(.white.opacity(0.6)) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(Color.white.opacity(0.1)) + .cornerRadius(10) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(Color.white.opacity(0.2), lineWidth: 1) + ) + } + .menuStyle(.borderlessButton) + + Spacer() + } + + if binding.wrappedValue == .custom { + KeyboardShortcuts.Recorder(for: shortcutName) { newShortcut in + onConfigured(newShortcut != nil) + } + .controlSize(.large) + } + } + .padding() + .background(Color.white.opacity(0.05)) + .cornerRadius(12) + .onChange(of: binding.wrappedValue) { newValue in + onConfigured(newValue != .none) + } + } } diff --git a/VoiceInk/Views/Onboarding/OnboardingTutorialView.swift b/VoiceInk/Views/Onboarding/OnboardingTutorialView.swift index 9c04759..4a4428e 100644 --- a/VoiceInk/Views/Onboarding/OnboardingTutorialView.swift +++ b/VoiceInk/Views/Onboarding/OnboardingTutorialView.swift @@ -35,13 +35,26 @@ struct OnboardingTutorialView: View { // Keyboard shortcut display VStack(alignment: .leading, spacing: 20) { - Text("Your Shortcut") - .font(.system(size: 28, weight: .semibold, design: .rounded)) - .foregroundColor(.white) + HStack { + Text("Your Shortcut") + .font(.system(size: 28, weight: .semibold, design: .rounded)) + .foregroundColor(.white) + + + } - if let shortcut = KeyboardShortcuts.getShortcut(for: .toggleMiniRecorder) { + if hotkeyManager.selectedHotkey1 == .custom, + let shortcut = KeyboardShortcuts.getShortcut(for: .toggleMiniRecorder) { KeyboardShortcutView(shortcut: shortcut) .scaleEffect(1.2) + } else if hotkeyManager.selectedHotkey1 != .none && hotkeyManager.selectedHotkey1 != .custom { + Text(hotkeyManager.selectedHotkey1.displayName) + .font(.system(size: 24, weight: .bold, design: .rounded)) + .foregroundColor(.accentColor) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(Color.white.opacity(0.1)) + .cornerRadius(8) } } @@ -148,6 +161,7 @@ struct OnboardingTutorialView: View { } } .onAppear { + hotkeyManager.startHotkeyMonitoring() animateIn() isFocused = true } @@ -156,9 +170,9 @@ struct OnboardingTutorialView: View { private func getInstructionText(for step: Int) -> String { switch step { case 1: return "Click the text area on the right" - case 2: return "Press your keyboard shortcut" + case 2: return "Press your shortcut key" case 3: return "Speak something" - case 4: return "Press your keyboard shortcut again" + case 4: return "Press your shortcut key again" default: return "" } } diff --git a/VoiceInk/Views/RecordView.swift b/VoiceInk/Views/RecordView.swift deleted file mode 100644 index d4ab5f4..0000000 --- a/VoiceInk/Views/RecordView.swift +++ /dev/null @@ -1,299 +0,0 @@ -import SwiftUI -import KeyboardShortcuts -import AppKit - -struct RecordView: View { - @EnvironmentObject var whisperState: WhisperState - @EnvironmentObject var hotkeyManager: HotkeyManager - @Environment(\.colorScheme) private var colorScheme - @ObservedObject private var mediaController = MediaController.shared - - private var hasShortcutSet: Bool { - KeyboardShortcuts.getShortcut(for: .toggleMiniRecorder) != nil - } - - var body: some View { - ScrollView(showsIndicators: false) { - mainContent - } - .background(Color(NSColor.controlBackgroundColor)) - } - - private var mainContent: some View { - VStack(spacing: 48) { - heroSection - controlsSection - } - .padding(32) - } - - private var heroSection: some View { - VStack(spacing: 20) { - AppIconView() - titleSection - } - } - - private var titleSection: some View { - VStack(spacing: 8) { - Text("VOICEINK") - .font(.system(size: 42, weight: .bold)) - - if whisperState.currentTranscriptionModel != nil { - Text("Powered by Whisper AI") - .font(.system(size: 15)) - .foregroundColor(.secondary) - } - } - } - - private var controlsSection: some View { - VStack(spacing: 32) { - compactControlsCard - instructionsCard - } - } - - private var compactControlsCard: some View { - HStack(spacing: 32) { - shortcutSection - - if hasShortcutSet { - Divider() - .frame(height: 40) - pushToTalkSection - - Divider() - .frame(height: 40) - - // Settings section - VStack(alignment: .leading, spacing: 12) { - Toggle(isOn: $whisperState.isAutoCopyEnabled) { - HStack { - Image(systemName: "doc.on.clipboard") - .foregroundColor(.secondary) - Text("Auto-copy to clipboard") - .font(.subheadline.weight(.medium)) - } - } - .toggleStyle(.switch) - - Toggle(isOn: .init( - get: { SoundManager.shared.isEnabled }, - set: { SoundManager.shared.isEnabled = $0 } - )) { - HStack { - Image(systemName: "speaker.wave.2") - .foregroundColor(.secondary) - Text("Sound feedback") - .font(.subheadline.weight(.medium)) - } - } - .toggleStyle(.switch) - - Toggle(isOn: $mediaController.isSystemMuteEnabled) { - HStack { - Image(systemName: "speaker.slash") - .foregroundColor(.secondary) - Text("Mute system audio during recording") - .font(.subheadline.weight(.medium)) - } - } - .toggleStyle(.switch) - .help("Automatically mute system audio when recording starts and restore when recording stops") - } - } - } - .padding(24) - .background(CardBackground(isSelected: false)) - } - - private var shortcutSection: some View { - VStack(spacing: 12) { - if hasShortcutSet { - if let shortcut = KeyboardShortcuts.getShortcut(for: .toggleMiniRecorder) { - KeyboardShortcutView(shortcut: shortcut) - .scaleEffect(1.2) - } - } else { - Image(systemName: "keyboard.badge.exclamationmark") - .font(.system(size: 28)) - .foregroundColor(.orange) - } - - Button(action: { - NotificationCenter.default.post( - name: .navigateToDestination, - object: nil, - userInfo: ["destination": "Settings"] - ) - }) { - Text(hasShortcutSet ? "Change" : "Set Shortcut") - .font(.subheadline.weight(.medium)) - .foregroundColor(.accentColor) - } - .buttonStyle(.plain) - } - } - - private var pushToTalkSection: some View { - VStack(alignment: .leading, spacing: 12) { - HStack { - Text("Push-to-Talk") - .font(.subheadline.weight(.medium)) - - if hotkeyManager.isPushToTalkEnabled { - SelectableKeyCapView( - text: getKeySymbol(for: hotkeyManager.pushToTalkKey), - subtext: getKeyText(for: hotkeyManager.pushToTalkKey), - isSelected: true - ) - } - } - } - } - - private func getKeySymbol(for key: HotkeyManager.PushToTalkKey) -> String { - switch key { - case .rightOption: return "⌥" - case .leftOption: return "⌥" - case .leftControl: return "⌃" - case .rightControl: return "⌃" - case .fn: return "Fn" - case .rightCommand: return "⌘" - case .rightShift: return "⇧" - } - } - - private func getKeyText(for key: HotkeyManager.PushToTalkKey) -> String { - switch key { - case .rightOption: return "Right Option" - case .leftOption: return "Left Option" - case .leftControl: return "Left Control" - case .rightControl: return "Right Control" - case .fn: return "Function" - case .rightCommand: return "Right Command" - case .rightShift: return "Right Shift" - } - } - - private var instructionsCard: some View { - VStack(alignment: .leading, spacing: 28) { - Text("How it works") - .font(.title3.weight(.bold)) - - VStack(alignment: .leading, spacing: 24) { - ForEach(getInstructions(), id: \.title) { instruction in - InstructionRow(instruction: instruction) - } - - Divider() - .padding(.vertical, 4) - - afterRecordingSection - } - } - .padding(28) - .background(CardBackground(isSelected: false)) - } - - private var afterRecordingSection: some View { - VStack(alignment: .leading, spacing: 16) { - Text("After recording") - .font(.headline) - - VStack(alignment: .leading, spacing: 12) { - if whisperState.isAutoCopyEnabled { - InfoRow(icon: "doc.on.clipboard", text: "Copied to clipboard") - } - InfoRow(icon: "text.cursor", text: "Pasted at cursor position") - } - } - } - - private func getInstructions() -> [(icon: String, title: String, description: String)] { - let keyName: String - switch hotkeyManager.pushToTalkKey { - case .rightOption: - keyName = "right Option (⌥)" - case .leftOption: - keyName = "left Option (⌥)" - case .leftControl: - keyName = "left Control (⌃)" - case .rightControl: - keyName = "right Control (⌃)" - case .fn: - keyName = "Fn" - case .rightCommand: - keyName = "right Command (⌘)" - case .rightShift: - keyName = "right Shift (⇧)" - } - - let activateDescription = hotkeyManager.isPushToTalkEnabled ? - "Hold the \(keyName) key" : - "Press your configured shortcut" - - let finishDescription = hotkeyManager.isPushToTalkEnabled ? - "Release the \(keyName) key to stop and process" : - "Press the shortcut again to stop" - - return [ - ( - icon: "mic.circle.fill", - title: "Start Recording", - description: activateDescription - ), - ( - icon: "waveform", - title: "Speak Clearly", - description: "Talk into your microphone naturally" - ), - ( - icon: "stop.circle.fill", - title: "Finish Up", - description: finishDescription - ) - ] - } -} - -// Simplified InstructionRow -struct InstructionRow: View { - let instruction: (icon: String, title: String, description: String) - - var body: some View { - HStack(alignment: .top, spacing: 16) { - Image(systemName: instruction.icon) - .font(.system(size: 20)) - .foregroundColor(.accentColor) - .frame(width: 24) - - VStack(alignment: .leading, spacing: 4) { - Text(instruction.title) - .font(.subheadline.weight(.medium)) - Text(instruction.description) - .font(.subheadline) - .foregroundColor(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - } - } -} - -// Simplified InfoRow -struct InfoRow: View { - let icon: String - let text: String - - var body: some View { - HStack(spacing: 12) { - Image(systemName: icon) - .font(.system(size: 14)) - .foregroundColor(.secondary) - Text(text) - .font(.subheadline) - .foregroundColor(.secondary) - } - } -} diff --git a/VoiceInk/Views/Settings/SettingsView.swift b/VoiceInk/Views/Settings/SettingsView.swift index f37d02c..52fe1f0 100644 --- a/VoiceInk/Views/Settings/SettingsView.swift +++ b/VoiceInk/Views/Settings/SettingsView.swift @@ -12,6 +12,7 @@ struct SettingsView: View { @EnvironmentObject private var whisperState: WhisperState @EnvironmentObject private var enhancementService: AIEnhancementService @StateObject private var deviceManager = AudioDeviceManager.shared + @ObservedObject private var mediaController = MediaController.shared @AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = true @State private var showResetOnboardingAlert = false @State private var currentShortcut = KeyboardShortcuts.getShortcut(for: .toggleMiniRecorder) @@ -19,83 +20,143 @@ struct SettingsView: View { var body: some View { ScrollView { VStack(spacing: 24) { - // Keyboard Shortcuts Section first + // Hotkey Selection Section SettingsSection( - icon: currentShortcut != nil ? "keyboard" : "keyboard.badge.exclamationmark", - title: "Keyboard Shortcuts", - subtitle: currentShortcut != nil ? "Shortcut configured" : "Shortcut required", - showWarning: currentShortcut == nil + icon: "command.circle", + title: "VoiceInk Shortcut", + subtitle: "Choose how you want to trigger VoiceInk" + ) { + VStack(alignment: .leading, spacing: 18) { + hotkeyView( + title: "Hotkey 1", + binding: $hotkeyManager.selectedHotkey1, + shortcutName: .toggleMiniRecorder + ) + + // Hotkey 2 Configuration (Conditional) + if hotkeyManager.selectedHotkey2 != .none { + Divider() + hotkeyView( + title: "Hotkey 2", + binding: $hotkeyManager.selectedHotkey2, + shortcutName: .toggleMiniRecorder2, + isRemovable: true, + onRemove: { + withAnimation { hotkeyManager.selectedHotkey2 = .none } + } + ) + } + + // "Add another hotkey" button + if hotkeyManager.selectedHotkey2 == .none { + HStack { + Spacer() + Button(action: { + withAnimation { hotkeyManager.selectedHotkey2 = .rightOption } + }) { + Label("Add another hotkey", systemImage: "plus.circle.fill") + } + .buttonStyle(.plain) + .foregroundColor(.accentColor) + } + } + + Text("Quick tap to start hands-free recording (tap again to stop). Press and hold for push-to-talk (release to stop recording).") + .font(.system(size: 12)) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 8) + } + } + + // Recording Feedback Section + SettingsSection( + icon: "speaker.wave.2.bubble.left.fill", + title: "Recording Feedback", + subtitle: "Customize audio and system feedback" + ) { + VStack(alignment: .leading, spacing: 12) { + Toggle(isOn: $whisperState.isAutoCopyEnabled) { + Text("Auto-copy to clipboard") + } + .toggleStyle(.switch) + + Toggle(isOn: .init( + get: { SoundManager.shared.isEnabled }, + set: { SoundManager.shared.isEnabled = $0 } + )) { + Text("Sound feedback") + } + .toggleStyle(.switch) + + Toggle(isOn: $mediaController.isSystemMuteEnabled) { + Text("Mute system audio during recording") + } + .toggleStyle(.switch) + .help("Automatically mute system audio when recording starts and restore when recording stops") + } + } + + // Recorder Preference Section + SettingsSection( + icon: "rectangle.on.rectangle", + title: "Recorder Style", + subtitle: "Choose your preferred recorder interface" ) { VStack(alignment: .leading, spacing: 8) { - if currentShortcut == nil { - Text("⚠️ Please set a keyboard shortcut to use VoiceInk") - .foregroundColor(.orange) - .font(.subheadline) - } + Text("Select how you want the recorder to appear on your screen.") + .settingsDescription() - HStack(alignment: .center, spacing: 16) { - if let shortcut = currentShortcut { - KeyboardShortcutView(shortcut: shortcut) - } else { - Text("Not Set") - .foregroundColor(.secondary) - .italic() - } - - Button(action: { - KeyboardShortcuts.reset(.toggleMiniRecorder) - currentShortcut = nil - }) { - Image(systemName: "arrow.counterclockwise") - .foregroundColor(.secondary) - } - .buttonStyle(.borderless) - .help("Reset Shortcut") - } - - KeyboardShortcuts.Recorder("Change Shortcut:", name: .toggleMiniRecorder) { newShortcut in - currentShortcut = newShortcut - hotkeyManager.updateShortcutStatus() - } - .controlSize(.large) - - Divider() - .padding(.vertical, 4) - - VStack(alignment: .leading, spacing: 6) { - Toggle("Enable Push-to-Talk", isOn: $hotkeyManager.isPushToTalkEnabled) - .toggleStyle(.switch) - - if hotkeyManager.isPushToTalkEnabled { - if currentShortcut == nil { - HStack(spacing: 6) { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundColor(.orange) - Text("Please set a keyboard shortcut first to use Push-to-Talk") - .settingsDescription() - .foregroundColor(.orange) - } - .padding(.vertical, 4) - } - - VStack(alignment: .leading, spacing: 12) { - Text("Choose Push-to-Talk Key") - .font(.system(size: 13, weight: .medium)) - .foregroundColor(.secondary) - - PushToTalkKeySelector(selectedKey: $hotkeyManager.pushToTalkKey) - .padding(.vertical, 4) - - Text("Quick tap the key once to start hands-free recording (tap again to stop).\nPress and hold the key for push-to-talk (release to stop recording).") - .font(.system(size: 13)) - .foregroundColor(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - .padding(.top, 4) - } + Picker("Recorder Style", selection: $whisperState.recorderType) { + Text("Notch Recorder").tag("notch") + Text("Mini Recorder").tag("mini") } + .pickerStyle(.radioGroup) + .padding(.vertical, 4) } } + + // Paste Method Section + SettingsSection( + icon: "doc.on.clipboard", + title: "Paste Method", + subtitle: "Choose how text is pasted" + ) { + VStack(alignment: .leading, spacing: 8) { + Text("Select the method used to paste text. Use AppleScript if you have a non-standard keyboard layout.") + .settingsDescription() + + Toggle("Use AppleScript Paste Method", isOn: Binding( + get: { UserDefaults.standard.bool(forKey: "UseAppleScriptPaste") }, + set: { UserDefaults.standard.set($0, forKey: "UseAppleScriptPaste") } + )) + .toggleStyle(.switch) + } + } + + // App Appearance Section + SettingsSection( + icon: "dock.rectangle", + title: "App Appearance", + subtitle: "Dock and Menu Bar options" + ) { + VStack(alignment: .leading, spacing: 8) { + Text("Choose how VoiceInk appears in your system.") + .settingsDescription() + + Toggle("Hide Dock Icon (Menu Bar Only)", isOn: $menuBarManager.isMenuBarOnly) + .toggleStyle(.switch) + } + } + + // Audio Cleanup Section + SettingsSection( + icon: "trash.circle", + title: "Audio Cleanup", + subtitle: "Manage recording storage" + ) { + AudioCleanupSettingsView() + } // Startup Section SettingsSection( @@ -130,68 +191,25 @@ struct SettingsView: View { .disabled(!updaterViewModel.canCheckForUpdates) } } - - // App Appearance Section + + // Reset Onboarding Section SettingsSection( - icon: "dock.rectangle", - title: "App Appearance", - subtitle: "Dock and Menu Bar options" + icon: "arrow.counterclockwise", + title: "Reset Onboarding", + subtitle: "View the introduction again" ) { VStack(alignment: .leading, spacing: 8) { - Text("Choose how VoiceInk appears in your system.") + Text("Reset the onboarding process to view the app introduction again.") .settingsDescription() - Toggle("Hide Dock Icon (Menu Bar Only)", isOn: $menuBarManager.isMenuBarOnly) - .toggleStyle(.switch) - } - } - - // Paste Method Section - SettingsSection( - icon: "doc.on.clipboard", - title: "Paste Method", - subtitle: "Choose how text is pasted" - ) { - VStack(alignment: .leading, spacing: 8) { - Text("Select the method used to paste text. Use AppleScript if you have a non-standard keyboard layout.") - .settingsDescription() - - Toggle("Use AppleScript Paste Method", isOn: Binding( - get: { UserDefaults.standard.bool(forKey: "UseAppleScriptPaste") }, - set: { UserDefaults.standard.set($0, forKey: "UseAppleScriptPaste") } - )) - .toggleStyle(.switch) - } - } - - // Recorder Preference Section - SettingsSection( - icon: "rectangle.on.rectangle", - title: "Recorder Style", - subtitle: "Choose your preferred recorder interface" - ) { - VStack(alignment: .leading, spacing: 8) { - Text("Select how you want the recorder to appear on your screen.") - .settingsDescription() - - Picker("Recorder Style", selection: $whisperState.recorderType) { - Text("Notch Recorder").tag("notch") - Text("Mini Recorder").tag("mini") + Button("Reset Onboarding") { + showResetOnboardingAlert = true } - .pickerStyle(.radioGroup) - .padding(.vertical, 4) + .buttonStyle(.bordered) + .controlSize(.large) } } - - // Audio Cleanup Section - SettingsSection( - icon: "trash.circle", - title: "Audio Cleanup", - subtitle: "Manage recording storage" - ) { - AudioCleanupSettingsView() - } - + // Data Management Section SettingsSection( icon: "arrow.up.arrow.down.circle", @@ -237,24 +255,6 @@ struct SettingsView: View { } } } - - // Reset Onboarding Section - SettingsSection( - icon: "arrow.counterclockwise", - title: "Reset Onboarding", - subtitle: "View the introduction again" - ) { - VStack(alignment: .leading, spacing: 8) { - Text("Reset the onboarding process to view the app introduction again.") - .settingsDescription() - - Button("Reset Onboarding") { - showResetOnboardingAlert = true - } - .buttonStyle(.bordered) - .controlSize(.large) - } - } } .padding(.horizontal, 20) .padding(.vertical, 6) @@ -270,22 +270,68 @@ struct SettingsView: View { } } - private func getPushToTalkDescription() -> String { - switch hotkeyManager.pushToTalkKey { - case .rightOption: - return "Using Right Option (⌥) key to quickly start recording. Release to stop." - case .leftOption: - return "Using Left Option (⌥) key to quickly start recording. Release to stop." - case .leftControl: - return "Using Left Control (⌃) key to quickly start recording. Release to stop." - case .rightControl: - return "Using Right Control (⌃) key to quickly start recording. Release to stop." - case .fn: - return "Using Function (Fn) key to quickly start recording. Release to stop." - case .rightCommand: - return "Using Right Command (⌘) key to quickly start recording. Release to stop." - case .rightShift: - return "Using Right Shift (⇧) key to quickly start recording. Release to stop." + @ViewBuilder + private func hotkeyView( + title: String, + binding: Binding, + shortcutName: KeyboardShortcuts.Name, + isRemovable: Bool = false, + onRemove: (() -> Void)? = nil + ) -> some View { + HStack(spacing: 12) { + Text(title) + .font(.system(size: 13, weight: .medium)) + .foregroundColor(.secondary) + + Menu { + ForEach(HotkeyManager.HotkeyOption.allCases, id: \.self) { option in + Button(action: { + binding.wrappedValue = option + }) { + HStack { + Text(option.displayName) + if binding.wrappedValue == option { + Spacer() + Image(systemName: "checkmark") + } + } + } + } + } label: { + HStack(spacing: 8) { + Text(binding.wrappedValue.displayName) + .foregroundColor(.primary) + Image(systemName: "chevron.up.chevron.down") + .font(.system(size: 10)) + .foregroundColor(.secondary) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color(NSColor.controlBackgroundColor)) + .cornerRadius(6) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(Color.secondary.opacity(0.3), lineWidth: 1) + ) + } + .menuStyle(.borderlessButton) + + if binding.wrappedValue == .custom { + KeyboardShortcuts.Recorder(for: shortcutName) + .controlSize(.small) + } + + Spacer() + + if isRemovable { + Button(action: { + onRemove?() + }) { + Image(systemName: "minus.circle.fill") + .foregroundColor(.red) + } + .buttonStyle(.plain) + } } } } @@ -354,108 +400,4 @@ extension Text { } } -struct PushToTalkKeySelector: View { - @Binding var selectedKey: HotkeyManager.PushToTalkKey - - var body: some View { - HStack(spacing: 12) { - ForEach(HotkeyManager.PushToTalkKey.allCases, id: \.self) { key in - Button(action: { - withAnimation(.spring(response: 0.2, dampingFraction: 0.6)) { - selectedKey = key - } - }) { - SelectableKeyCapView( - text: getKeySymbol(for: key), - subtext: getKeyText(for: key), - isSelected: selectedKey == key - ) - } - .buttonStyle(.plain) - } - } - } - - private func getKeySymbol(for key: HotkeyManager.PushToTalkKey) -> String { - switch key { - case .rightOption: return "⌥" - case .leftOption: return "⌥" - case .leftControl: return "⌃" - case .rightControl: return "⌃" - case .fn: return "Fn" - case .rightCommand: return "⌘" - case .rightShift: return "⇧" - } - } - - private func getKeyText(for key: HotkeyManager.PushToTalkKey) -> String { - switch key { - case .rightOption: return "Right Option" - case .leftOption: return "Left Option" - case .leftControl: return "Left Control" - case .rightControl: return "Right Control" - case .fn: return "Function" - case .rightCommand: return "Right Command" - case .rightShift: return "Right Shift" - } - } -} -struct SelectableKeyCapView: View { - let text: String - let subtext: String - let isSelected: Bool - - @Environment(\.colorScheme) private var colorScheme - - private var keyColor: Color { - if isSelected { - return colorScheme == .dark ? Color.accentColor.opacity(0.3) : Color.accentColor.opacity(0.2) - } - return colorScheme == .dark ? Color(white: 0.2) : .white - } - - var body: some View { - VStack(spacing: 4) { - Text(text) - .font(.system(size: 20, weight: .semibold, design: .rounded)) - .foregroundColor(colorScheme == .dark ? .white : .black) - .frame(width: 44, height: 44) - .background( - ZStack { - RoundedRectangle(cornerRadius: 8) - .fill(keyColor) - - // Highlight overlay - if isSelected { - RoundedRectangle(cornerRadius: 8) - .strokeBorder(Color.accentColor, lineWidth: 2) - } - - // Key surface highlight - RoundedRectangle(cornerRadius: 8) - .fill( - LinearGradient( - gradient: Gradient(colors: [ - Color.white.opacity(colorScheme == .dark ? 0.1 : 0.4), - Color.white.opacity(0) - ]), - startPoint: .top, - endPoint: .bottom - ) - ) - } - ) - .shadow( - color: Color.black.opacity(colorScheme == .dark ? 0.5 : 0.2), - radius: 2, - x: 0, - y: 1 - ) - - Text(subtext) - .font(.system(size: 11)) - .foregroundColor(.secondary) - } - } -} diff --git a/VoiceInk/VoiceInk.swift b/VoiceInk/VoiceInk.swift index 8028332..a5b36de 100644 --- a/VoiceInk/VoiceInk.swift +++ b/VoiceInk/VoiceInk.swift @@ -115,8 +115,6 @@ struct VoiceInkApp: App { .environmentObject(aiService) .environmentObject(enhancementService) .frame(minWidth: 880, minHeight: 780) - .cornerRadius(16) - .clipped() .background(WindowAccessor { window in // Ensure this is called only once or is idempotent if window.title != "VoiceInk Onboarding" { // Prevent re-configuration diff --git a/VoiceInk/WindowManager.swift b/VoiceInk/WindowManager.swift index de17ad5..65365fc 100644 --- a/VoiceInk/WindowManager.swift +++ b/VoiceInk/WindowManager.swift @@ -22,18 +22,18 @@ class WindowManager { } func configureOnboardingPanel(_ window: NSWindow) { - window.styleMask = [.borderless, .fullSizeContentView, .resizable] - window.isMovableByWindowBackground = true - window.level = .normal + window.styleMask = [.titled, .fullSizeContentView, .resizable] window.titlebarAppearsTransparent = true window.titleVisibility = .hidden + window.isMovableByWindowBackground = true + window.level = .normal window.backgroundColor = .clear window.isReleasedWhenClosed = false - window.collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle] + window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] window.title = "VoiceInk Onboarding" window.isOpaque = false window.minSize = NSSize(width: 900, height: 780) - window.orderFrontRegardless() + window.makeKeyAndOrderFront(nil) } func createMainWindow(contentView: NSView) -> NSWindow {