diff --git a/VoiceInk/HotkeyManager.swift b/VoiceInk/HotkeyManager.swift index 2481942..3654f24 100644 --- a/VoiceInk/HotkeyManager.swift +++ b/VoiceInk/HotkeyManager.swift @@ -43,6 +43,7 @@ class HotkeyManager: ObservableObject { private var whisperState: WhisperState private var miniRecorderShortcutManager: MiniRecorderShortcutManager + private var powerModeShortcutManager: PowerModeShortcutManager // MARK: - Helper Properties private var canProcessHotkeyAction: Bool { @@ -73,7 +74,7 @@ class HotkeyManager: ObservableObject { private var shortcutCurrentKeyState = false private var lastShortcutTriggerTime: Date? private let shortcutCooldownInterval: TimeInterval = 0.5 - + enum HotkeyOption: String, CaseIterable { case none = "none" case rightOption = "rightOption" @@ -127,6 +128,7 @@ class HotkeyManager: ObservableObject { self.whisperState = whisperState self.miniRecorderShortcutManager = MiniRecorderShortcutManager(whisperState: whisperState) + self.powerModeShortcutManager = PowerModeShortcutManager(whisperState: whisperState) KeyboardShortcuts.onKeyUp(for: .pasteLastTranscription) { [weak self] in guard let self = self else { return } diff --git a/VoiceInk/PowerMode/ActiveWindowService.swift b/VoiceInk/PowerMode/ActiveWindowService.swift index c67d4d3..2f23c6d 100644 --- a/VoiceInk/PowerMode/ActiveWindowService.swift +++ b/VoiceInk/PowerMode/ActiveWindowService.swift @@ -24,7 +24,16 @@ class ActiveWindowService: ObservableObject { self.whisperState = whisperState } - func applyConfigurationForCurrentApp() async { + func applyConfiguration(powerModeId: UUID? = nil) async { + if let powerModeId = powerModeId, + let config = PowerModeManager.shared.getConfiguration(with: powerModeId) { + await MainActor.run { + PowerModeManager.shared.setActiveConfiguration(config) + } + await PowerModeSessionManager.shared.beginSession(with: config) + return + } + guard let frontmostApp = NSWorkspace.shared.frontmostApplication, let bundleIdentifier = frontmostApp.bundleIdentifier else { return @@ -60,8 +69,6 @@ class ActiveWindowService: ObservableObject { PowerModeManager.shared.setActiveConfiguration(config) } await PowerModeSessionManager.shared.beginSession(with: config) - } else { - // If no config found, keep the current active configuration (don't clear it) } } } diff --git a/VoiceInk/PowerMode/PowerModeConfig.swift b/VoiceInk/PowerMode/PowerModeConfig.swift index 9524288..0476517 100644 --- a/VoiceInk/PowerMode/PowerModeConfig.swift +++ b/VoiceInk/PowerMode/PowerModeConfig.swift @@ -1,4 +1,5 @@ import Foundation +import KeyboardShortcuts struct PowerModeConfig: Codable, Identifiable, Equatable { var id: UUID @@ -16,9 +17,10 @@ struct PowerModeConfig: Codable, Identifiable, Equatable { var isAutoSendEnabled: Bool = false var isEnabled: Bool = true var isDefault: Bool = false + var hotkeyShortcut: String? = nil enum CodingKeys: String, CodingKey { - case id, name, emoji, appConfigs, urlConfigs, isAIEnhancementEnabled, selectedPrompt, selectedLanguage, useScreenCapture, selectedAIProvider, selectedAIModel, isAutoSendEnabled, isEnabled, isDefault + case id, name, emoji, appConfigs, urlConfigs, isAIEnhancementEnabled, selectedPrompt, selectedLanguage, useScreenCapture, selectedAIProvider, selectedAIModel, isAutoSendEnabled, isEnabled, isDefault, hotkeyShortcut case selectedWhisperModel case selectedTranscriptionModelName } @@ -26,7 +28,7 @@ struct PowerModeConfig: Codable, Identifiable, Equatable { init(id: UUID = UUID(), name: String, emoji: String, appConfigs: [AppConfig]? = nil, urlConfigs: [URLConfig]? = nil, isAIEnhancementEnabled: Bool, selectedPrompt: String? = nil, selectedTranscriptionModelName: String? = nil, selectedLanguage: String? = nil, useScreenCapture: Bool = false, - selectedAIProvider: String? = nil, selectedAIModel: String? = nil, isAutoSendEnabled: Bool = false, isEnabled: Bool = true, isDefault: Bool = false) { + selectedAIProvider: String? = nil, selectedAIModel: String? = nil, isAutoSendEnabled: Bool = false, isEnabled: Bool = true, isDefault: Bool = false, hotkeyShortcut: String? = nil) { self.id = id self.name = name self.emoji = emoji @@ -42,6 +44,7 @@ struct PowerModeConfig: Codable, Identifiable, Equatable { self.selectedLanguage = selectedLanguage ?? UserDefaults.standard.string(forKey: "SelectedLanguage") ?? "en" self.isEnabled = isEnabled self.isDefault = isDefault + self.hotkeyShortcut = hotkeyShortcut } init(from decoder: Decoder) throws { @@ -60,6 +63,7 @@ struct PowerModeConfig: Codable, Identifiable, Equatable { isAutoSendEnabled = try container.decodeIfPresent(Bool.self, forKey: .isAutoSendEnabled) ?? false isEnabled = try container.decodeIfPresent(Bool.self, forKey: .isEnabled) ?? true isDefault = try container.decodeIfPresent(Bool.self, forKey: .isDefault) ?? false + hotkeyShortcut = try container.decodeIfPresent(String.self, forKey: .hotkeyShortcut) if let newModelName = try container.decodeIfPresent(String.self, forKey: .selectedTranscriptionModelName) { selectedTranscriptionModelName = newModelName @@ -87,6 +91,7 @@ struct PowerModeConfig: Codable, Identifiable, Equatable { try container.encodeIfPresent(selectedTranscriptionModelName, forKey: .selectedTranscriptionModelName) try container.encode(isEnabled, forKey: .isEnabled) try container.encode(isDefault, forKey: .isDefault) + try container.encodeIfPresent(hotkeyShortcut, forKey: .hotkeyShortcut) } @@ -155,6 +160,7 @@ class PowerModeManager: ObservableObject { if let data = try? JSONEncoder().encode(configurations) { UserDefaults.standard.set(data, forKey: configKey) } + NotificationCenter.default.post(name: NSNotification.Name("PowerModeConfigurationsDidChange"), object: nil) } func addConfiguration(_ config: PowerModeConfig) { @@ -165,6 +171,7 @@ class PowerModeManager: ObservableObject { } func removeConfiguration(with id: UUID) { + KeyboardShortcuts.setShortcut(nil, for: .powerMode(id: id)) configurations.removeAll { $0.id == id } saveConfigurations() } @@ -221,18 +228,18 @@ class PowerModeManager: ObservableObject { return configurations.contains { $0.isDefault } } - func setAsDefault(configId: UUID) { - // Clear any existing default + func setAsDefault(configId: UUID, skipSave: Bool = false) { for index in configurations.indices { configurations[index].isDefault = false } - - // Set the specified config as default + if let index = configurations.firstIndex(where: { $0.id == configId }) { configurations[index].isDefault = true } - - saveConfigurations() + + if !skipSave { + saveConfigurations() + } } func enableConfiguration(with id: UUID) { diff --git a/VoiceInk/PowerMode/PowerModeConfigView.swift b/VoiceInk/PowerMode/PowerModeConfigView.swift index 22696bc..cffc0b0 100644 --- a/VoiceInk/PowerMode/PowerModeConfigView.swift +++ b/VoiceInk/PowerMode/PowerModeConfigView.swift @@ -1,4 +1,5 @@ import SwiftUI +import KeyboardShortcuts struct ConfigurationView: View { let mode: ConfigurationMode @@ -42,6 +43,9 @@ struct ConfigurationView: View { @State private var isEditingPrompt = false @State private var selectedPromptForEdit: CustomPrompt? + // PowerMode hotkey configuration + @State private var powerModeConfigId: UUID = UUID() + private func languageSelectionDisabled() -> Bool { guard let selectedModelName = effectiveModelName, let model = whisperState.allAvailableModels.first(where: { $0.name == selectedModelName }) @@ -83,10 +87,12 @@ struct ConfigurationView: View { init(mode: ConfigurationMode, powerModeManager: PowerModeManager) { self.mode = mode self.powerModeManager = powerModeManager - + // Always fetch the most current configuration data switch mode { case .add: + let newId = UUID() + _powerModeConfigId = State(initialValue: newId) _isAIEnhancementEnabled = State(initialValue: true) _selectedPromptId = State(initialValue: nil) _selectedTranscriptionModelName = State(initialValue: nil) @@ -102,6 +108,7 @@ struct ConfigurationView: View { case .edit(let config): // Get the latest version of this config from PowerModeManager let latestConfig = powerModeManager.getConfiguration(with: config.id) ?? config + _powerModeConfigId = State(initialValue: latestConfig.id) _isAIEnhancementEnabled = State(initialValue: latestConfig.isAIEnhancementEnabled) _selectedPromptId = State(initialValue: latestConfig.selectedPrompt.flatMap { UUID(uuidString: $0) }) _selectedTranscriptionModelName = State(initialValue: latestConfig.selectedTranscriptionModelName) @@ -613,12 +620,30 @@ struct ConfigurationView: View { HStack { Toggle("Auto Send", isOn: $isAutoSendEnabled) - + InfoTip( title: "Auto Send", message: "Automatically presses the Return/Enter key after pasting text. This is useful for chat applications or forms where its not necessary to to make changes to the transcribed text" ) - + + Spacer() + } + + Divider() + + HStack { + Text("Keyboard Shortcut") + .font(.subheadline) + .foregroundColor(.secondary) + + KeyboardShortcuts.Recorder(for: .powerMode(id: powerModeConfigId)) + .controlSize(.small) + + InfoTip( + title: "Power Mode Hotkey", + message: "Assign a unique keyboard shortcut to instantly activate this Power Mode and start recording" + ) + Spacer() } } @@ -705,9 +730,13 @@ struct ConfigurationView: View { } private func getConfigForForm() -> PowerModeConfig { + let shortcut = KeyboardShortcuts.getShortcut(for: .powerMode(id: powerModeConfigId)) + let hotkeyString = shortcut != nil ? "configured" : nil + switch mode { case .add: return PowerModeConfig( + id: powerModeConfigId, name: configName, emoji: selectedEmoji, appConfigs: selectedAppConfigs.isEmpty ? nil : selectedAppConfigs, @@ -720,7 +749,8 @@ struct ConfigurationView: View { selectedAIProvider: selectedAIProvider, selectedAIModel: selectedAIModel, isAutoSendEnabled: isAutoSendEnabled, - isDefault: isDefault + isDefault: isDefault, + hotkeyShortcut: hotkeyString ) case .edit(let config): var updatedConfig = config @@ -737,6 +767,7 @@ struct ConfigurationView: View { updatedConfig.selectedAIProvider = selectedAIProvider updatedConfig.selectedAIModel = selectedAIModel updatedConfig.isDefault = isDefault + updatedConfig.hotkeyShortcut = hotkeyString return updatedConfig } } @@ -817,19 +848,17 @@ struct ConfigurationView: View { return } - // If validation passes, save the configuration + if isDefault { + powerModeManager.setAsDefault(configId: config.id, skipSave: true) + } + switch mode { case .add: powerModeManager.addConfiguration(config) case .edit: powerModeManager.updateConfiguration(config) } - - // Handle default flag separately to ensure only one config is default - if isDefault { - powerModeManager.setAsDefault(configId: config.id) - } - + presentationMode.wrappedValue.dismiss() } } diff --git a/VoiceInk/PowerMode/PowerModeSessionManager.swift b/VoiceInk/PowerMode/PowerModeSessionManager.swift index d326538..9e0462e 100644 --- a/VoiceInk/PowerMode/PowerModeSessionManager.swift +++ b/VoiceInk/PowerMode/PowerModeSessionManager.swift @@ -41,30 +41,38 @@ class PowerModeSessionManager { return } - let originalState = ApplicationState( - isEnhancementEnabled: enhancementService.isEnhancementEnabled, - useScreenCaptureContext: enhancementService.useScreenCaptureContext, - selectedPromptId: enhancementService.selectedPromptId?.uuidString, - selectedAIProvider: enhancementService.getAIService()?.selectedProvider.rawValue, - selectedAIModel: enhancementService.getAIService()?.currentModel, - selectedLanguage: UserDefaults.standard.string(forKey: "SelectedLanguage"), - transcriptionModelName: whisperState.currentTranscriptionModel?.name - ) + // Only capture baseline if NO session exists + if loadSession() == nil { + let originalState = ApplicationState( + isEnhancementEnabled: enhancementService.isEnhancementEnabled, + useScreenCaptureContext: enhancementService.useScreenCaptureContext, + selectedPromptId: enhancementService.selectedPromptId?.uuidString, + selectedAIProvider: enhancementService.getAIService()?.selectedProvider.rawValue, + selectedAIModel: enhancementService.getAIService()?.currentModel, + selectedLanguage: UserDefaults.standard.string(forKey: "SelectedLanguage"), + transcriptionModelName: whisperState.currentTranscriptionModel?.name + ) - let newSession = PowerModeSession( - id: UUID(), - startTime: Date(), - originalState: originalState - ) - saveSession(newSession) - - NotificationCenter.default.addObserver(self, selector: #selector(updateSessionSnapshot), name: .AppSettingsDidChange, object: nil) + let newSession = PowerModeSession( + id: UUID(), + startTime: Date(), + originalState: originalState + ) + saveSession(newSession) + NotificationCenter.default.addObserver(self, selector: #selector(updateSessionSnapshot), name: .AppSettingsDidChange, object: nil) + } + + // Always apply the new configuration isApplyingPowerModeConfig = true await applyConfiguration(config) isApplyingPowerModeConfig = false } + var hasActiveSession: Bool { + return loadSession() != nil + } + func endSession() async { guard let session = loadSession() else { return } diff --git a/VoiceInk/PowerMode/PowerModeShortcutManager.swift b/VoiceInk/PowerMode/PowerModeShortcutManager.swift new file mode 100644 index 0000000..44fa967 --- /dev/null +++ b/VoiceInk/PowerMode/PowerModeShortcutManager.swift @@ -0,0 +1,84 @@ +import Foundation +import KeyboardShortcuts + +@MainActor +class PowerModeShortcutManager { + private weak var whisperState: WhisperState? + private var registeredPowerModeIds: Set = [] + + init(whisperState: WhisperState) { + self.whisperState = whisperState + + setupPowerModeHotkeys() + + NotificationCenter.default.addObserver( + self, + selector: #selector(powerModeConfigurationsDidChange), + name: NSNotification.Name("PowerModeConfigurationsDidChange"), + object: nil + ) + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + @objc private func powerModeConfigurationsDidChange() { + Task { @MainActor in + setupPowerModeHotkeys() + } + } + + private func setupPowerModeHotkeys() { + let powerModesWithShortcuts = Set(PowerModeManager.shared.configurations + .filter { $0.hotkeyShortcut != nil } + .map { $0.id }) + + // Remove shortcuts for deleted or updated configs + let idsToRemove = registeredPowerModeIds.subtracting(powerModesWithShortcuts) + idsToRemove.forEach { id in + KeyboardShortcuts.setShortcut(nil, for: .powerMode(id: id)) + registeredPowerModeIds.remove(id) + } + + // Add new shortcuts + PowerModeManager.shared.configurations.forEach { config in + guard config.hotkeyShortcut != nil else { return } + guard !registeredPowerModeIds.contains(config.id) else { return } + + KeyboardShortcuts.onKeyUp(for: .powerMode(id: config.id)) { [weak self] in + guard let self = self else { return } + Task { @MainActor in + await self.handlePowerModeHotkey(powerModeId: config.id) + } + } + + registeredPowerModeIds.insert(config.id) + } + } + + private func handlePowerModeHotkey(powerModeId: UUID) async { + guard let whisperState = whisperState, + canProcessHotkeyAction(whisperState: whisperState) else { return } + + guard let config = PowerModeManager.shared.getConfiguration(with: powerModeId), + config.hotkeyShortcut != nil else { + return + } + + await whisperState.toggleMiniRecorder(powerModeId: powerModeId) + } + + private func canProcessHotkeyAction(whisperState: WhisperState) -> Bool { + whisperState.recordingState != .transcribing && + whisperState.recordingState != .enhancing && + whisperState.recordingState != .busy + } +} + +// MARK: - PowerMode Keyboard Shortcut Names +extension KeyboardShortcuts.Name { + static func powerMode(id: UUID) -> Self { + Self("powerMode_\(id.uuidString)") + } +} diff --git a/VoiceInk/Whisper/WhisperState+UI.swift b/VoiceInk/Whisper/WhisperState+UI.swift index c13d7dc..ce7c18e 100644 --- a/VoiceInk/Whisper/WhisperState+UI.swift +++ b/VoiceInk/Whisper/WhisperState+UI.swift @@ -32,10 +32,10 @@ extension WhisperState { // MARK: - Mini Recorder Management - func toggleMiniRecorder() async { + func toggleMiniRecorder(powerModeId: UUID? = nil) async { if isMiniRecorderVisible { if recordingState == .recording { - await toggleRecord() + await toggleRecord(powerModeId: powerModeId) } else { await cancelRecording() } @@ -46,7 +46,7 @@ extension WhisperState { isMiniRecorderVisible = true // This will call showRecorderPanel() via didSet } - await toggleRecord() + await toggleRecord(powerModeId: powerModeId) } } diff --git a/VoiceInk/Whisper/WhisperState.swift b/VoiceInk/Whisper/WhisperState.swift index 6d11e38..5ba216c 100644 --- a/VoiceInk/Whisper/WhisperState.swift +++ b/VoiceInk/Whisper/WhisperState.swift @@ -138,7 +138,7 @@ class WhisperState: NSObject, ObservableObject { } } - func toggleRecord() async { + func toggleRecord(powerModeId: UUID? = nil) async { if recordingState == .recording { await recorder.stopRecording() if let recordedFile { @@ -195,7 +195,14 @@ class WhisperState: NSObject, ObservableObject { self.recordingState = .recording } - await ActiveWindowService.shared.applyConfigurationForCurrentApp() + if let powerModeId = powerModeId { + await ActiveWindowService.shared.applyConfiguration(powerModeId: powerModeId) + } else { + let hasActiveSession = await PowerModeSessionManager.shared.hasActiveSession + if !hasActiveSession { + await ActiveWindowService.shared.applyConfiguration() + } + } // Load model and capture context in background without blocking Task.detached { [weak self] in