From 0b308977998dab921c4d6d151e8942d95dd789aa Mon Sep 17 00:00:00 2001 From: altic-dev Date: Tue, 7 Apr 2026 18:14:43 -0700 Subject: [PATCH 1/5] changed paste method naming --- Sources/Fluid/Persistence/SettingsStore.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/Fluid/Persistence/SettingsStore.swift b/Sources/Fluid/Persistence/SettingsStore.swift index 7f9859b8..f4deb986 100644 --- a/Sources/Fluid/Persistence/SettingsStore.swift +++ b/Sources/Fluid/Persistence/SettingsStore.swift @@ -3626,16 +3626,16 @@ extension SettingsStore { var displayName: String { switch self { case .standard: - return "Experimental Direct Typing" + return "Clipboard Free Insert" case .reliablePaste: - return "Reliable Paste" + return "Clipboard Paste" } } var description: String { switch self { case .standard: - return "Tries to avoid clipboard changes by typing directly when possible. Usually a bit slower, and may fail or behave inconsistently in some apps." + return "Tries to insert text without changing the clipboard. Usually a bit slower, and may fail or behave inconsistently in some apps." case .reliablePaste: return "Usually faster and works best across browsers and desktop apps. Uses a temporary clipboard paste, so clipboard history apps may briefly record dictated text." } From 70f773952c67d9aa614b9c935ca4f129dc286f81 Mon Sep 17 00:00:00 2001 From: altic-dev Date: Tue, 7 Apr 2026 19:37:31 -0700 Subject: [PATCH 2/5] Fix shortcut capture and modifier-only hotkeys --- Sources/Fluid/ContentView.swift | 199 +++++--- Sources/Fluid/Models/HotkeyShortcut.swift | 121 ++++- .../Fluid/Services/GlobalHotkeyManager.swift | 454 +++++++++--------- Sources/Fluid/UI/SettingsView.swift | 126 +++-- 4 files changed, 541 insertions(+), 359 deletions(-) diff --git a/Sources/Fluid/ContentView.swift b/Sources/Fluid/ContentView.swift index 1eb4835b..04812dd8 100644 --- a/Sources/Fluid/ContentView.swift +++ b/Sources/Fluid/ContentView.swift @@ -29,6 +29,38 @@ enum SidebarItem: Hashable { case rewriteMode } +enum ShortcutRecordingTarget: String, Hashable { + case primaryDictation + case secondaryDictation + case command + case edit + case cancel + + var title: String { + switch self { + case .primaryDictation: + return "Primary Dictation Shortcut" + case .secondaryDictation: + return "Secondary Dictation Shortcut" + case .command: + return "Command Mode" + case .edit: + return "Edit Mode" + case .cancel: + return "Cancel Recording" + } + } + + var enablesFeatureOnAssignment: Bool { + switch self { + case .secondaryDictation, .command, .edit: + return true + case .primaryDictation, .cancel: + return false + } + } +} + // MARK: - Minimal FluidAudio ASR Service (finalized text, macOS) // MARK: - Saved Provider Model @@ -89,11 +121,9 @@ struct ContentView: View { @State private var promptModeOverrideText: String? // System prompt text to use when in prompt mode @State private var activeDictationShortcutSlot: SettingsStore.DictationShortcutSlot? = nil @State private var activeRecordingMode: ActiveRecordingMode = .none - @State private var isRecordingShortcut = false - @State private var isRecordingPromptModeShortcut = false - @State private var isRecordingCommandModeShortcut = false - @State private var isRecordingRewriteShortcut = false - @State private var isRecordingCancelShortcut = false + @State private var activeShortcutRecordingTarget: ShortcutRecordingTarget? = nil + @State private var currentRecordingModifierKeyCodes: Set = [] + @State private var pendingModifierKeyCodes: Set = [] @State private var pendingModifierFlags: NSEvent.ModifierFlags = [] @State private var pendingModifierKeyCode: UInt16? @State private var pendingModifierOnly = false @@ -135,6 +165,10 @@ struct ContentView: View { private let hasAutoRestartedForAccessibilityKey = "FluidVoice_HasAutoRestartedForAccessibility" @State private var accessibilityPollingTask: Task? + private var isRecordingAnyShortcutCapture: Bool { + self.activeShortcutRecordingTarget != nil + } + // MARK: - Voice Recognition Model Management // Models scoped by provider (name -> [models]) @@ -367,12 +401,8 @@ struct ContentView: View { NSEvent.addLocalMonitorForEvents(matching: [.keyDown, .flagsChanged]) { event in let eventModifiers = event.modifierFlags.intersection([.function, .command, .option, .control, .shift]) - let isRecordingAnyShortcut = self.isRecordingShortcut || - self.isRecordingPromptModeShortcut || - self.isRecordingCommandModeShortcut || - self.isRecordingRewriteShortcut || - self.isRecordingCancelShortcut - let recordingTarget = self.currentShortcutRecordingTarget() + let isRecordingAnyShortcut = self.isRecordingAnyShortcutCapture + let recordingTarget = self.activeShortcutRecordingTarget if event.type == .keyDown { guard isRecordingAnyShortcut else { @@ -387,7 +417,7 @@ struct ContentView: View { } let keyCode = event.keyCode - if keyCode == 53 && !self.isRecordingCancelShortcut { + if keyCode == 53 && recordingTarget != .cancel { DebugLogger.shared.debug("NSEvent monitor: Escape pressed, cancelling shortcut recording", source: "ContentView") self.clearShortcutRecordingMode() return nil @@ -420,9 +450,15 @@ struct ContentView: View { return event } + let changedModifierFlag = HotkeyShortcut.modifierFlag(forKeyCode: event.keyCode) + if eventModifiers.isEmpty { if self.pendingModifierOnly, let modifierKeyCode = pendingModifierKeyCode { - let newShortcut = HotkeyShortcut(keyCode: modifierKeyCode, modifierFlags: []) + let newShortcut = HotkeyShortcut( + keyCode: modifierKeyCode, + modifierFlags: self.pendingModifierFlags, + modifierKeyCodes: Array(self.pendingModifierKeyCodes) + ) DebugLogger.shared.debug("NSEvent monitor: Recording modifier-only shortcut: \(newShortcut.displayString)", source: "ContentView") if let recordingTarget, @@ -448,24 +484,22 @@ struct ContentView: View { return nil } - // Modifiers are currently pressed - var actualKeyCode = event.keyCode - if eventModifiers.contains(.function) { - actualKeyCode = 63 // fn key - } else if eventModifiers.contains(.command) { - actualKeyCode = (event.keyCode == 55) ? 55 : 54 // 55 = left cmd, 54 = right cmd - } else if eventModifiers.contains(.option) { - actualKeyCode = (event.keyCode == 58) ? 58 : 61 // 58 = left opt, 61 = right opt - } else if eventModifiers.contains(.control) { - actualKeyCode = (event.keyCode == 59) ? 59 : 62 // 59 = left ctrl, 62 = right ctrl - } else if eventModifiers.contains(.shift) { - actualKeyCode = (event.keyCode == 56) ? 56 : 60 // 56 = left shift, 60 = right shift + // Keep the actual changed modifier key as the trigger key and preserve + // the full pressed modifier set until the combo is finalized. + if let changedModifierFlag { + let isRelease = self.currentRecordingModifierKeyCodes.contains(event.keyCode) + + if isRelease { + self.currentRecordingModifierKeyCodes.remove(event.keyCode) + } else if eventModifiers.contains(changedModifierFlag) { + self.currentRecordingModifierKeyCodes.insert(event.keyCode) + self.pendingModifierKeyCodes.insert(event.keyCode) + self.pendingModifierFlags = self.pendingModifierFlags.union(eventModifiers) + self.pendingModifierKeyCode = event.keyCode + self.pendingModifierOnly = true + DebugLogger.shared.debug("NSEvent monitor: Modifier key pressed during recording, pending modifiers: \(self.pendingModifierFlags)", source: "ContentView") + } } - - self.pendingModifierFlags = eventModifiers - self.pendingModifierKeyCode = actualKeyCode - self.pendingModifierOnly = true - DebugLogger.shared.debug("NSEvent monitor: Modifier key pressed during recording, pending modifiers: \(self.pendingModifierFlags)", source: "ContentView") return nil } @@ -492,7 +526,9 @@ struct ContentView: View { self.hotkeyManager?.updatePromptModeShortcutEnabled(newValue) if !newValue { - self.isRecordingPromptModeShortcut = false + if self.activeShortcutRecordingTarget == .secondaryDictation { + self.clearShortcutRecordingMode() + } if self.activeRecordingMode == .promptMode { if self.asr.isRunning { @@ -508,7 +544,9 @@ struct ContentView: View { self.hotkeyManager?.updateCommandModeShortcutEnabled(newValue) if !newValue { - self.isRecordingCommandModeShortcut = false + if self.activeShortcutRecordingTarget == .command { + self.clearShortcutRecordingMode() + } if self.activeRecordingMode == .command { if self.asr.isRunning { @@ -524,7 +562,9 @@ struct ContentView: View { self.hotkeyManager?.updateRewriteModeShortcutEnabled(newValue) if !newValue { - self.isRecordingRewriteShortcut = false + if self.activeShortcutRecordingTarget == .edit { + self.clearShortcutRecordingMode() + } if self.activeRecordingMode == .edit { if self.asr.isRunning { @@ -738,40 +778,25 @@ struct ContentView: View { } private func resetPendingShortcutState() { + self.currentRecordingModifierKeyCodes = [] + self.pendingModifierKeyCodes = [] self.pendingModifierFlags = [] self.pendingModifierKeyCode = nil self.pendingModifierOnly = false } - private enum ShortcutRecordingTarget { - case primaryDictation - case secondaryDictation - case command - case edit - case cancel - } - - private func currentShortcutRecordingTarget() -> ShortcutRecordingTarget? { - if self.isRecordingShortcut { return .primaryDictation } - if self.isRecordingPromptModeShortcut { return .secondaryDictation } - if self.isRecordingCommandModeShortcut { return .command } - if self.isRecordingRewriteShortcut { return .edit } - if self.isRecordingCancelShortcut { return .cancel } - return nil - } - private func shortcutConflictMessage(for shortcut: HotkeyShortcut, target: ShortcutRecordingTarget) -> String? { - let configuredShortcuts: [(ShortcutRecordingTarget, String, HotkeyShortcut)] = [ - (.primaryDictation, "Primary Dictation Shortcut", self.hotkeyShortcut), - (.secondaryDictation, "Secondary Dictation Shortcut", self.promptModeHotkeyShortcut), - (.command, "Command Mode", self.commandModeHotkeyShortcut), - (.edit, "Edit Mode", self.rewriteModeHotkeyShortcut), - (.cancel, "Cancel Recording", self.cancelRecordingHotkeyShortcut), + let configuredShortcuts: [(ShortcutRecordingTarget, HotkeyShortcut)] = [ + (.primaryDictation, self.hotkeyShortcut), + (.secondaryDictation, self.promptModeHotkeyShortcut), + (.command, self.commandModeHotkeyShortcut), + (.edit, self.rewriteModeHotkeyShortcut), + (.cancel, self.cancelRecordingHotkeyShortcut), ] - for (otherTarget, title, configuredShortcut) in configuredShortcuts where otherTarget != target { + for (otherTarget, configuredShortcut) in configuredShortcuts where otherTarget != target { if configuredShortcut == shortcut { - return "Duplicate with \(title)" + return "Duplicate with \(otherTarget.title)" } } @@ -779,40 +804,66 @@ struct ContentView: View { } private func assignRecordedShortcut(_ shortcut: HotkeyShortcut, to target: ShortcutRecordingTarget) { + self.applyRecordedShortcut(shortcut, to: target) + if target.enablesFeatureOnAssignment { + self.setShortcutTargetEnabled(true, for: target) + } + self.setShortcutRecording(false, for: target) + } + + private func applyRecordedShortcut(_ shortcut: HotkeyShortcut, to target: ShortcutRecordingTarget) { switch target { case .primaryDictation: self.hotkeyShortcut = shortcut SettingsStore.shared.hotkeyShortcut = shortcut self.hotkeyManager?.updateShortcut(shortcut) - self.isRecordingShortcut = false case .secondaryDictation: self.promptModeHotkeyShortcut = shortcut SettingsStore.shared.promptModeHotkeyShortcut = shortcut self.hotkeyManager?.updatePromptModeShortcut(shortcut) - self.isRecordingPromptModeShortcut = false case .command: self.commandModeHotkeyShortcut = shortcut SettingsStore.shared.commandModeHotkeyShortcut = shortcut self.hotkeyManager?.updateCommandModeShortcut(shortcut) - self.isRecordingCommandModeShortcut = false case .edit: self.rewriteModeHotkeyShortcut = shortcut SettingsStore.shared.rewriteModeHotkeyShortcut = shortcut self.hotkeyManager?.updateRewriteModeShortcut(shortcut) - self.isRecordingRewriteShortcut = false case .cancel: self.cancelRecordingHotkeyShortcut = shortcut SettingsStore.shared.cancelRecordingHotkeyShortcut = shortcut - self.isRecordingCancelShortcut = false + } + } + + private func setShortcutTargetEnabled(_ enabled: Bool, for target: ShortcutRecordingTarget) { + switch target { + case .secondaryDictation: + self.isPromptModeShortcutEnabled = enabled + SettingsStore.shared.promptModeShortcutEnabled = enabled + self.hotkeyManager?.updatePromptModeShortcutEnabled(enabled) + case .command: + self.isCommandModeShortcutEnabled = enabled + SettingsStore.shared.commandModeShortcutEnabled = enabled + self.hotkeyManager?.updateCommandModeShortcutEnabled(enabled) + case .edit: + self.isRewriteModeShortcutEnabled = enabled + SettingsStore.shared.rewriteModeShortcutEnabled = enabled + self.hotkeyManager?.updateRewriteModeShortcutEnabled(enabled) + case .primaryDictation, .cancel: + break + } + } + + private func setShortcutRecording(_ isRecording: Bool, for target: ShortcutRecordingTarget) { + if isRecording { + self.activeShortcutRecordingTarget = target + } else if self.activeShortcutRecordingTarget == target { + self.activeShortcutRecordingTarget = nil } } private func clearShortcutRecordingMode() { - self.isRecordingShortcut = false - self.isRecordingPromptModeShortcut = false - self.isRecordingCommandModeShortcut = false - self.isRecordingRewriteShortcut = false - self.isRecordingCancelShortcut = false + self.activeShortcutRecordingTarget = nil self.shortcutRecordingMessage = nil self.resetPendingShortcutState() } @@ -1157,17 +1208,13 @@ struct ContentView: View { outputDevices: self.$outputDevices, accessibilityEnabled: self.$accessibilityEnabled, hotkeyShortcut: self.$hotkeyShortcut, - isRecordingShortcut: self.$isRecordingShortcut, + activeShortcutRecordingTarget: self.$activeShortcutRecordingTarget, shortcutRecordingMessage: self.$shortcutRecordingMessage, promptModeShortcut: self.$promptModeHotkeyShortcut, - isRecordingPromptModeShortcut: self.$isRecordingPromptModeShortcut, promptModeShortcutEnabled: self.$isPromptModeShortcutEnabled, commandModeShortcut: self.$commandModeHotkeyShortcut, - isRecordingCommandModeShortcut: self.$isRecordingCommandModeShortcut, rewriteShortcut: self.$rewriteModeHotkeyShortcut, - isRecordingRewriteShortcut: self.$isRecordingRewriteShortcut, cancelRecordingShortcut: self.$cancelRecordingHotkeyShortcut, - isRecordingCancelShortcut: self.$isRecordingCancelShortcut, commandModeShortcutEnabled: self.$isCommandModeShortcutEnabled, rewriteShortcutEnabled: self.$isRewriteModeShortcutEnabled, hotkeyManagerInitialized: self.$hotkeyManagerInitialized, @@ -2511,11 +2558,7 @@ struct ContentView: View { self.activeRecordingMode == .edit }, isShortcutCaptureActiveProvider: { - self.isRecordingShortcut || - self.isRecordingPromptModeShortcut || - self.isRecordingCommandModeShortcut || - self.isRecordingRewriteShortcut || - self.isRecordingCancelShortcut + self.isRecordingAnyShortcutCapture } ) diff --git a/Sources/Fluid/Models/HotkeyShortcut.swift b/Sources/Fluid/Models/HotkeyShortcut.swift index 92130f9d..64155959 100644 --- a/Sources/Fluid/Models/HotkeyShortcut.swift +++ b/Sources/Fluid/Models/HotkeyShortcut.swift @@ -4,9 +4,16 @@ import Foundation struct HotkeyShortcut: Codable, Equatable { var keyCode: UInt16 var modifierFlags: NSEvent.ModifierFlags - enum CodingKeys: String, CodingKey { case keyCode, modifierFlagsRawValue } + var modifierKeyCodes: [UInt16] + enum CodingKeys: String, CodingKey { case keyCode, modifierFlagsRawValue, modifierKeyCodes } var displayString: String { + let modifierKeyCodes = self.normalizedModifierKeyCodes + let modifierParts = modifierKeyCodes.compactMap(Self.keyCodeToString) + if !modifierParts.isEmpty { + return modifierParts.joined(separator: " + ") + } + var parts: [String] = [] if self.modifierFlags.contains(.function) { parts.append("🌐") } if self.modifierFlags.contains(.command) { parts.append("⌘") } @@ -96,29 +103,135 @@ struct HotkeyShortcut: Codable, Equatable { default: return nil } } + + init(keyCode: UInt16, modifierFlags: NSEvent.ModifierFlags, modifierKeyCodes: [UInt16] = []) { + let normalizedModifierKeyCodes = Self.normalizedModifierKeyCodes(from: modifierKeyCodes) + if !normalizedModifierKeyCodes.isEmpty { + self.modifierKeyCodes = normalizedModifierKeyCodes + self.keyCode = normalizedModifierKeyCodes.first ?? keyCode + + let combinedFlags = normalizedModifierKeyCodes.reduce(into: NSEvent.ModifierFlags()) { flags, modifierKeyCode in + if let flag = Self.modifierFlag(forKeyCode: modifierKeyCode) { + flags.insert(flag) + } + } + if let triggerFlag = Self.modifierFlag(forKeyCode: self.keyCode) { + self.modifierFlags = combinedFlags.subtracting(triggerFlag) + } else { + self.modifierFlags = modifierFlags.intersection(Self.relevantModifierMask) + } + } else { + self.keyCode = keyCode + self.modifierFlags = modifierFlags + self.modifierKeyCodes = [] + } + } } extension HotkeyShortcut { - private static let relevantModifierMask: NSEvent.ModifierFlags = [.function, .command, .option, .control, .shift] + static let relevantModifierMask: NSEvent.ModifierFlags = [.function, .command, .option, .control, .shift] + + static func modifierFlag(forKeyCode keyCode: UInt16) -> NSEvent.ModifierFlags? { + switch keyCode { + case 63: + return .function + case 54, 55: + return .command + case 58, 61: + return .option + case 59, 62: + return .control + case 56, 60: + return .shift + default: + return nil + } + } + + private static func modifierSortPriority(forKeyCode keyCode: UInt16) -> Int? { + switch keyCode { + case 63: return 0 + case 55: return 1 + case 54: return 2 + case 58: return 3 + case 61: return 4 + case 59: return 5 + case 62: return 6 + case 56: return 7 + case 60: return 8 + default: return nil + } + } + + static func normalizedModifierKeyCodes(from modifierKeyCodes: [UInt16]) -> [UInt16] { + let normalized = Array(Set(modifierKeyCodes)).compactMap { keyCode -> (UInt16, Int)? in + guard let priority = Self.modifierSortPriority(forKeyCode: keyCode) else { return nil } + return (keyCode, priority) + } + .sorted { lhs, rhs in + lhs.1 < rhs.1 + } + .map(\.0) + + return normalized + } var relevantModifierFlags: NSEvent.ModifierFlags { self.modifierFlags.intersection(Self.relevantModifierMask) } + var normalizedModifierKeyCodes: [UInt16] { + let normalized = Self.normalizedModifierKeyCodes(from: self.modifierKeyCodes) + if !normalized.isEmpty { return normalized } + + if self.modifierTriggerFlag != nil, self.relevantModifierFlags.isEmpty { + return [self.keyCode] + } + + return [] + } + + var modifierTriggerFlag: NSEvent.ModifierFlags? { + Self.modifierFlag(forKeyCode: self.keyCode) + } + + var isModifierOnlyShortcut: Bool { + self.modifierTriggerFlag != nil + } + + var expectedModifierFlags: NSEvent.ModifierFlags? { + guard let triggerFlag = self.modifierTriggerFlag else { return nil } + return self.relevantModifierFlags.union(triggerFlag) + } + func matches(keyCode: UInt16, modifiers: NSEvent.ModifierFlags) -> Bool { keyCode == self.keyCode && modifiers.intersection(Self.relevantModifierMask) == self.relevantModifierFlags } + static func == (lhs: HotkeyShortcut, rhs: HotkeyShortcut) -> Bool { + let lhsModifierKeyCodes = lhs.normalizedModifierKeyCodes + let rhsModifierKeyCodes = rhs.normalizedModifierKeyCodes + if !lhsModifierKeyCodes.isEmpty, !rhsModifierKeyCodes.isEmpty { + return lhsModifierKeyCodes == rhsModifierKeyCodes + } + + return lhs.keyCode == rhs.keyCode && lhs.relevantModifierFlags == rhs.relevantModifierFlags + } + init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) - self.keyCode = try c.decode(UInt16.self, forKey: .keyCode) + let keyCode = try c.decode(UInt16.self, forKey: .keyCode) let raw = try c.decode(UInt.self, forKey: .modifierFlagsRawValue) - self.modifierFlags = NSEvent.ModifierFlags(rawValue: raw) + let modifierKeyCodes = try c.decodeIfPresent([UInt16].self, forKey: .modifierKeyCodes) ?? [] + self.init(keyCode: keyCode, modifierFlags: NSEvent.ModifierFlags(rawValue: raw), modifierKeyCodes: modifierKeyCodes) } func encode(to encoder: Encoder) throws { var c = encoder.container(keyedBy: CodingKeys.self) try c.encode(self.keyCode, forKey: .keyCode) try c.encode(self.modifierFlags.rawValue, forKey: .modifierFlagsRawValue) + if !self.normalizedModifierKeyCodes.isEmpty { + try c.encode(self.normalizedModifierKeyCodes, forKey: .modifierKeyCodes) + } } } diff --git a/Sources/Fluid/Services/GlobalHotkeyManager.swift b/Sources/Fluid/Services/GlobalHotkeyManager.swift index 38b156cd..f6063b9a 100644 --- a/Sources/Fluid/Services/GlobalHotkeyManager.swift +++ b/Sources/Fluid/Services/GlobalHotkeyManager.swift @@ -14,6 +14,7 @@ private final class HotkeyState: @unchecked Sendable { var isPromptModeKeyPressed = false var isCommandModeKeyPressed = false var isRewriteKeyPressed = false + var pressedModifierKeyCodes: Set = [] var modifierOnlyKeyDown = false var otherKeyPressedDuringModifier = false var modifierPressStartTime: Date? @@ -54,6 +55,20 @@ final class GlobalHotkeyManager: NSObject { private var cancelCallback: (() -> Bool)? // Returns true if handled private var pressAndHoldMode: Bool = SettingsStore.shared.pressAndHoldMode + private struct ModifierOnlyShortcutBehavior { + let shortcut: HotkeyShortcut + let isEnabled: Bool + let holdModeType: HotkeyHoldModeType + let holdStartCancelledMessage: String + let holdStartMessage: String + let holdReleaseMessage: String + let toggleIgnoredMessage: String + let isModeKeyPressed: () -> Bool + let setModeKeyPressed: (Bool) -> Void + let onHoldStart: () -> Void + let onToggleRelease: () -> Void + } + private nonisolated var isKeyPressed: Bool { get { self.state.withLock { self.state.isKeyPressed } } set { self.state.withLock { self.state.isKeyPressed = newValue } } @@ -74,6 +89,11 @@ final class GlobalHotkeyManager: NSObject { set { self.state.withLock { self.state.isRewriteKeyPressed = newValue } } } + private nonisolated var pressedModifierKeyCodes: Set { + get { self.state.withLock { self.state.pressedModifierKeyCodes } } + set { self.state.withLock { self.state.pressedModifierKeyCodes = newValue } } + } + /// Modifier-only shortcut tracking: detect if another key was pressed during modifier hold private nonisolated var modifierOnlyKeyDown: Bool { get { self.state.withLock { self.state.modifierOnlyKeyDown } } @@ -536,65 +556,31 @@ final class GlobalHotkeyManager: NSObject { } case .flagsChanged: - let isModifierPressed = flags.contains(.maskSecondaryFn) - || flags.contains(.maskCommand) - || flags.contains(.maskAlternate) - || flags.contains(.maskControl) - || flags.contains(.maskShift) - - // Check prompt mode shortcut (if it's a modifier-only shortcut) - if self.handlePromptModeFlagsChanged(keyCode: keyCode, isModifierPressed: isModifierPressed) { return nil } - - // Check command mode shortcut (if it's a modifier-only shortcut) - if self.commandModeShortcutEnabled, self.commandModeShortcut.modifierFlags.isEmpty, keyCode == self.commandModeShortcut.keyCode { - if isModifierPressed { - // Modifier pressed down - self.modifierOnlyKeyDown = true - self.otherKeyPressedDuringModifier = false - self.modifierPressStartTime = Date() - - if self.pressAndHoldMode { - if !self.isCommandModeKeyPressed { - self.isCommandModeKeyPressed = true - // Delay start by 150ms to detect if this is a key combo - self.pendingHoldModeStart?.cancel() - self.pendingHoldModeType = .commandMode - self.pendingHoldModeStart = Task { @MainActor [weak self] in - try? await Task.sleep(nanoseconds: 150_000_000) // 150ms - guard let self = self, !Task.isCancelled else { return } - guard self.isCommandModeKeyPressed, !self.otherKeyPressedDuringModifier else { - DebugLogger.shared.debug("Command mode hold start cancelled - key combo detected", source: "GlobalHotkeyManager") - return - } - DebugLogger.shared.info("Command mode modifier held (hold mode) - starting after delay", source: "GlobalHotkeyManager") - self.triggerCommandMode() - } - } - } - // Toggle mode: do NOT trigger yet, wait for release + if HotkeyShortcut.modifierFlag(forKeyCode: keyCode) != nil { + var pressedModifierKeyCodes = self.pressedModifierKeyCodes + if pressedModifierKeyCodes.contains(keyCode) { + pressedModifierKeyCodes.remove(keyCode) } else { - // Modifier released - let wasCleanPress = !self.otherKeyPressedDuringModifier - self.modifierOnlyKeyDown = false - self.otherKeyPressedDuringModifier = false - self.modifierPressStartTime = nil + pressedModifierKeyCodes.insert(keyCode) + } + self.pressedModifierKeyCodes = pressedModifierKeyCodes + } - if self.pressAndHoldMode { - // Cancel pending start if not yet triggered - self.pendingHoldModeStart?.cancel() - self.pendingHoldModeStart = nil - self.pendingHoldModeType = nil - - if self.isCommandModeKeyPressed { - self.isCommandModeKeyPressed = false - // Only stop if recording actually started - if self.asrService.isRunning { - DebugLogger.shared.info("Command mode modifier released (hold mode) - stopping", source: "GlobalHotkeyManager") - self.stopRecordingIfNeeded() - } - } - } else if wasCleanPress { - // Toggle mode: only trigger on release if no other key was pressed + if self.handlePromptModeFlagsChanged(keyCode: keyCode, modifiers: eventModifiers) { return nil } + + if self.handleModifierOnlyShortcutFlagsChanged( + behavior: .init( + shortcut: self.commandModeShortcut, + isEnabled: self.commandModeShortcutEnabled, + holdModeType: .commandMode, + holdStartCancelledMessage: "Command mode hold start cancelled - key combo detected", + holdStartMessage: "Command mode modifier held (hold mode) - starting after delay", + holdReleaseMessage: "Command mode modifier released (hold mode) - stopping", + toggleIgnoredMessage: "Command mode modifier released but another key was pressed - ignoring", + isModeKeyPressed: { self.isCommandModeKeyPressed }, + setModeKeyPressed: { self.isCommandModeKeyPressed = $0 }, + onHoldStart: { self.triggerCommandMode() }, + onToggleRelease: { if self.asrService.isRunning { if self.isCommandRecordingProvider?() ?? false { DebugLogger.shared.info("Command mode modifier released (toggle, same mode) - stopping", source: "GlobalHotkeyManager") @@ -607,144 +593,56 @@ final class GlobalHotkeyManager: NSObject { DebugLogger.shared.info("Command mode modifier released (toggle) - starting", source: "GlobalHotkeyManager") self.triggerCommandMode() } - } else { - DebugLogger.shared.debug("Command mode modifier released but another key was pressed - ignoring", source: "GlobalHotkeyManager") } - } - return nil - } - - // Check rewrite mode shortcut (if it's a modifier-only shortcut - actual modifier keys only) - // Note: Regular keys with no modifiers are handled in keyDown, not flagsChanged - // Only handle actual modifier keys (Command, Option, Control, Shift, Function) here - if self.rewriteModeShortcutEnabled, self.rewriteModeShortcut.modifierFlags.isEmpty { - // Check if this is an actual modifier key (not a regular key) - let isModifierKey = keyCode == 54 || keyCode == 55 || // Command keys - keyCode == 58 || keyCode == 61 || // Option keys - keyCode == 59 || keyCode == 62 || // Control keys - keyCode == 56 || keyCode == 60 || // Shift keys - keyCode == 63 // Function key - - if isModifierKey, keyCode == self.rewriteModeShortcut.keyCode { - if isModifierPressed { - // Modifier pressed down - self.modifierOnlyKeyDown = true - self.otherKeyPressedDuringModifier = false - self.modifierPressStartTime = Date() - - if self.pressAndHoldMode { - if !self.isRewriteKeyPressed { - self.isRewriteKeyPressed = true - // Delay start by 150ms to detect if this is a key combo - self.pendingHoldModeStart?.cancel() - self.pendingHoldModeType = .rewriteMode - self.pendingHoldModeStart = Task { @MainActor [weak self] in - try? await Task.sleep(nanoseconds: 150_000_000) // 150ms - guard let self = self, !Task.isCancelled else { return } - guard self.isRewriteKeyPressed, !self.otherKeyPressedDuringModifier else { - DebugLogger.shared.debug("Rewrite mode hold start cancelled - key combo detected", source: "GlobalHotkeyManager") - return - } - DebugLogger.shared.info("Rewrite mode modifier held (hold mode) - starting after delay", source: "GlobalHotkeyManager") - self.triggerRewriteMode() - } - } - } - // Toggle mode: do NOT trigger yet, wait for release - } else { - // Modifier released - let wasCleanPress = !self.otherKeyPressedDuringModifier - self.modifierOnlyKeyDown = false - self.otherKeyPressedDuringModifier = false - self.modifierPressStartTime = nil - - if self.pressAndHoldMode { - // Cancel pending start if not yet triggered - self.pendingHoldModeStart?.cancel() - self.pendingHoldModeStart = nil - self.pendingHoldModeType = nil - - if self.isRewriteKeyPressed { - self.isRewriteKeyPressed = false - // Only stop if recording actually started - if self.asrService.isRunning { - DebugLogger.shared.info("Rewrite mode modifier released (hold mode) - stopping", source: "GlobalHotkeyManager") - self.stopRecordingIfNeeded() - } - } - } else if wasCleanPress { - // Toggle mode: only trigger on release if no other key was pressed - if self.asrService.isRunning { - if self.isRewriteRecordingProvider?() ?? false { - DebugLogger.shared.info("Rewrite mode modifier released (toggle, same mode) - stopping", source: "GlobalHotkeyManager") - self.stopRecordingIfNeeded() - } else { - DebugLogger.shared.info("Rewrite mode modifier released (toggle, switch mode) - switching", source: "GlobalHotkeyManager") - self.triggerRewriteMode() - } + ), + keyCode: keyCode, + modifiers: eventModifiers + ) { return nil } + + if self.handleModifierOnlyShortcutFlagsChanged( + behavior: .init( + shortcut: self.rewriteModeShortcut, + isEnabled: self.rewriteModeShortcutEnabled, + holdModeType: .rewriteMode, + holdStartCancelledMessage: "Rewrite mode hold start cancelled - key combo detected", + holdStartMessage: "Rewrite mode modifier held (hold mode) - starting after delay", + holdReleaseMessage: "Rewrite mode modifier released (hold mode) - stopping", + toggleIgnoredMessage: "Rewrite mode modifier released but another key was pressed - ignoring", + isModeKeyPressed: { self.isRewriteKeyPressed }, + setModeKeyPressed: { self.isRewriteKeyPressed = $0 }, + onHoldStart: { self.triggerRewriteMode() }, + onToggleRelease: { + if self.asrService.isRunning { + if self.isRewriteRecordingProvider?() ?? false { + DebugLogger.shared.info("Rewrite mode modifier released (toggle, same mode) - stopping", source: "GlobalHotkeyManager") + self.stopRecordingIfNeeded() } else { - DebugLogger.shared.info("Rewrite mode modifier released (toggle) - starting", source: "GlobalHotkeyManager") + DebugLogger.shared.info("Rewrite mode modifier released (toggle, switch mode) - switching", source: "GlobalHotkeyManager") self.triggerRewriteMode() } } else { - DebugLogger.shared.debug("Rewrite mode modifier released but another key was pressed - ignoring", source: "GlobalHotkeyManager") - } - } - return nil - } - } - - // Check transcription shortcut (if it's a modifier-only shortcut) - guard self.shortcut.modifierFlags.isEmpty else { break } - - if keyCode == self.shortcut.keyCode { - if isModifierPressed { - // Modifier pressed down - self.modifierOnlyKeyDown = true - self.otherKeyPressedDuringModifier = false - self.modifierPressStartTime = Date() - - if self.pressAndHoldMode { - if !self.isKeyPressed { - self.isKeyPressed = true - // Delay start by 150ms to detect if this is a key combo - self.pendingHoldModeStart?.cancel() - self.pendingHoldModeType = .transcription - self.pendingHoldModeStart = Task { @MainActor [weak self] in - try? await Task.sleep(nanoseconds: 150_000_000) // 150ms - guard let self = self, !Task.isCancelled else { return } - guard self.isKeyPressed, !self.otherKeyPressedDuringModifier else { - DebugLogger.shared.debug("Transcription hold start cancelled - key combo detected", source: "GlobalHotkeyManager") - return - } - DebugLogger.shared.info("Transcription modifier held (hold mode) - starting after delay", source: "GlobalHotkeyManager") - self.startRecordingIfNeeded() - } + DebugLogger.shared.info("Rewrite mode modifier released (toggle) - starting", source: "GlobalHotkeyManager") + self.triggerRewriteMode() } } - // Toggle mode: do NOT trigger yet, wait for release - } else { - // Modifier released - let wasCleanPress = !self.otherKeyPressedDuringModifier - self.modifierOnlyKeyDown = false - self.otherKeyPressedDuringModifier = false - self.modifierPressStartTime = nil - - if self.pressAndHoldMode { - // Cancel pending start if not yet triggered - self.pendingHoldModeStart?.cancel() - self.pendingHoldModeStart = nil - self.pendingHoldModeType = nil - - if self.isKeyPressed { - self.isKeyPressed = false - // Only stop if recording actually started - if self.asrService.isRunning { - self.stopRecordingIfNeeded() - } - } - } else if wasCleanPress { - // Toggle mode: only trigger on release if no other key was pressed + ), + keyCode: keyCode, + modifiers: eventModifiers + ) { return nil } + + if self.handleModifierOnlyShortcutFlagsChanged( + behavior: .init( + shortcut: self.shortcut, + isEnabled: true, + holdModeType: .transcription, + holdStartCancelledMessage: "Transcription hold start cancelled - key combo detected", + holdStartMessage: "Transcription modifier held (hold mode) - starting after delay", + holdReleaseMessage: "Transcription modifier released (hold mode) - stopping", + toggleIgnoredMessage: "Transcription modifier released but another key was pressed - ignoring", + isModeKeyPressed: { self.isKeyPressed }, + setModeKeyPressed: { self.isKeyPressed = $0 }, + onHoldStart: { self.startRecordingIfNeeded() }, + onToggleRelease: { if self.asrService.isRunning { let isSameMode = self.isDictateRecordingProvider?() ?? false DebugLogger.shared.info( @@ -763,12 +661,11 @@ final class GlobalHotkeyManager: NSObject { ) self.triggerDictationMode() } - } else { - DebugLogger.shared.debug("Transcription modifier released but another key was pressed - ignoring", source: "GlobalHotkeyManager") } - } - return nil - } + ), + keyCode: keyCode, + modifiers: eventModifiers + ) { return nil } default: break @@ -834,58 +731,163 @@ final class GlobalHotkeyManager: NSObject { return true } - private func handlePromptModeFlagsChanged(keyCode: UInt16, isModifierPressed: Bool) -> Bool { - guard self.promptModeShortcutEnabled, self.promptModeShortcut.modifierFlags.isEmpty, - keyCode == self.promptModeShortcut.keyCode else { return false } - if isModifierPressed { - self.modifierOnlyKeyDown = true - self.otherKeyPressedDuringModifier = false - self.modifierPressStartTime = Date() - if self.pressAndHoldMode, !self.isPromptModeKeyPressed { - self.isPromptModeKeyPressed = true - self.pendingHoldModeStart?.cancel() - self.pendingHoldModeType = .promptMode - self.pendingHoldModeStart = Task { @MainActor [weak self] in - try? await Task.sleep(nanoseconds: 150_000_000) - guard let self = self, !Task.isCancelled else { return } - guard self.isPromptModeKeyPressed, !self.otherKeyPressedDuringModifier else { - DebugLogger.shared.debug("Prompt mode hold start cancelled - key combo detected", source: "GlobalHotkeyManager") - return + private func handlePromptModeFlagsChanged(keyCode: UInt16, modifiers: NSEvent.ModifierFlags) -> Bool { + self.handleModifierOnlyShortcutFlagsChanged( + behavior: .init( + shortcut: self.promptModeShortcut, + isEnabled: self.promptModeShortcutEnabled, + holdModeType: .promptMode, + holdStartCancelledMessage: "Prompt mode hold start cancelled - key combo detected", + holdStartMessage: "Prompt mode modifier held (hold mode) - starting after delay", + holdReleaseMessage: "Prompt mode modifier released (hold mode) - stopping", + toggleIgnoredMessage: "Prompt mode modifier released but another key was pressed - ignoring", + isModeKeyPressed: { self.isPromptModeKeyPressed }, + setModeKeyPressed: { self.isPromptModeKeyPressed = $0 }, + onHoldStart: { self.triggerPromptMode() }, + onToggleRelease: { + if self.asrService.isRunning { + if self.isPromptModeRecordingProvider?() ?? false { + DebugLogger.shared.info("Prompt mode modifier released (toggle, same mode) - stopping", source: "GlobalHotkeyManager") + self.stopRecordingIfNeeded() + } else { + DebugLogger.shared.info("Prompt mode modifier released (toggle, switch mode) - switching", source: "GlobalHotkeyManager") + self.triggerPromptMode() + } + } else { + DebugLogger.shared.info("Prompt mode modifier released (toggle) - starting", source: "GlobalHotkeyManager") + self.triggerPromptMode() + } + } + ), + keyCode: keyCode, + modifiers: modifiers + ) + } + + private func handleModifierOnlyShortcutFlagsChanged( + behavior: ModifierOnlyShortcutBehavior, + keyCode: UInt16, + modifiers: NSEvent.ModifierFlags + ) -> Bool { + let shortcut = behavior.shortcut + guard behavior.isEnabled, shortcut.isModifierOnlyShortcut else { return false } + + let relevantModifiers = modifiers.intersection(HotkeyShortcut.relevantModifierMask) + let expectedModifierKeyCodes = shortcut.normalizedModifierKeyCodes + if !expectedModifierKeyCodes.isEmpty { + let pressedModifierKeyCodes = HotkeyShortcut.normalizedModifierKeyCodes(from: Array(self.pressedModifierKeyCodes)) + if pressedModifierKeyCodes == expectedModifierKeyCodes { + self.modifierOnlyKeyDown = true + self.otherKeyPressedDuringModifier = false + self.modifierPressStartTime = Date() + + if self.pressAndHoldMode, !behavior.isModeKeyPressed() { + behavior.setModeKeyPressed(true) + self.pendingHoldModeStart?.cancel() + self.pendingHoldModeType = behavior.holdModeType + self.pendingHoldModeStart = Task { @MainActor [weak self] in + try? await Task.sleep(nanoseconds: 150_000_000) + guard let self = self, !Task.isCancelled else { return } + guard behavior.isModeKeyPressed(), !self.otherKeyPressedDuringModifier else { + DebugLogger.shared.debug(behavior.holdStartCancelledMessage, source: "GlobalHotkeyManager") + return + } + DebugLogger.shared.info(behavior.holdStartMessage, source: "GlobalHotkeyManager") + behavior.onHoldStart() } - DebugLogger.shared.info("Prompt mode modifier held (hold mode) - starting after delay", source: "GlobalHotkeyManager") - self.triggerPromptMode() } + return true } - } else { + + guard self.modifierOnlyKeyDown || behavior.isModeKeyPressed(), + expectedModifierKeyCodes.contains(keyCode), + !pressedModifierKeyCodes.contains(keyCode) + else { + return false + } + let wasCleanPress = !self.otherKeyPressedDuringModifier self.modifierOnlyKeyDown = false self.otherKeyPressedDuringModifier = false self.modifierPressStartTime = nil + if self.pressAndHoldMode { self.pendingHoldModeStart?.cancel() self.pendingHoldModeStart = nil self.pendingHoldModeType = nil - if self.isPromptModeKeyPressed { - self.isPromptModeKeyPressed = false + + if behavior.isModeKeyPressed() { + behavior.setModeKeyPressed(false) if self.asrService.isRunning { - DebugLogger.shared.info("Prompt mode modifier released (hold mode) - stopping", source: "GlobalHotkeyManager") + DebugLogger.shared.info(behavior.holdReleaseMessage, source: "GlobalHotkeyManager") self.stopRecordingIfNeeded() } } } else if wasCleanPress { - if self.asrService.isRunning { - if self.isPromptModeRecordingProvider?() ?? false { - DebugLogger.shared.info("Prompt mode modifier released (toggle, same mode) - stopping", source: "GlobalHotkeyManager") - self.stopRecordingIfNeeded() - } else { - DebugLogger.shared.info("Prompt mode modifier released (toggle, switch mode) - switching", source: "GlobalHotkeyManager") - self.triggerPromptMode() + behavior.onToggleRelease() + } else { + DebugLogger.shared.debug(behavior.toggleIgnoredMessage, source: "GlobalHotkeyManager") + } + return true + } + + guard let expectedPressedModifiers = shortcut.expectedModifierFlags, + let triggerFlag = shortcut.modifierTriggerFlag + else { + return false + } + + if relevantModifiers == expectedPressedModifiers { + self.modifierOnlyKeyDown = true + self.otherKeyPressedDuringModifier = false + self.modifierPressStartTime = Date() + + if self.pressAndHoldMode, !behavior.isModeKeyPressed() { + behavior.setModeKeyPressed(true) + self.pendingHoldModeStart?.cancel() + self.pendingHoldModeType = behavior.holdModeType + self.pendingHoldModeStart = Task { @MainActor [weak self] in + try? await Task.sleep(nanoseconds: 150_000_000) + guard let self = self, !Task.isCancelled else { return } + guard behavior.isModeKeyPressed(), !self.otherKeyPressedDuringModifier else { + DebugLogger.shared.debug(behavior.holdStartCancelledMessage, source: "GlobalHotkeyManager") + return } - } else { - DebugLogger.shared.info("Prompt mode modifier released (toggle) - starting", source: "GlobalHotkeyManager") - self.triggerPromptMode() + DebugLogger.shared.info(behavior.holdStartMessage, source: "GlobalHotkeyManager") + behavior.onHoldStart() + } + } + return true + } + + guard self.modifierOnlyKeyDown || behavior.isModeKeyPressed(), + keyCode == shortcut.keyCode, + !relevantModifiers.contains(triggerFlag) + else { + return false + } + + let wasCleanPress = !self.otherKeyPressedDuringModifier + self.modifierOnlyKeyDown = false + self.otherKeyPressedDuringModifier = false + self.modifierPressStartTime = nil + + if self.pressAndHoldMode { + self.pendingHoldModeStart?.cancel() + self.pendingHoldModeStart = nil + self.pendingHoldModeType = nil + + if behavior.isModeKeyPressed() { + behavior.setModeKeyPressed(false) + if self.asrService.isRunning { + DebugLogger.shared.info(behavior.holdReleaseMessage, source: "GlobalHotkeyManager") + self.stopRecordingIfNeeded() } } + } else if wasCleanPress { + behavior.onToggleRelease() + } else { + DebugLogger.shared.debug(behavior.toggleIgnoredMessage, source: "GlobalHotkeyManager") } return true } diff --git a/Sources/Fluid/UI/SettingsView.swift b/Sources/Fluid/UI/SettingsView.swift index 14a6804d..22d09f88 100644 --- a/Sources/Fluid/UI/SettingsView.swift +++ b/Sources/Fluid/UI/SettingsView.swift @@ -12,6 +12,13 @@ import SwiftUI import UniformTypeIdentifiers struct SettingsView: View { + private struct ShortcutRowContent { + let icon: String + let iconColor: Color + let title: String + let description: String + } + @EnvironmentObject var appServices: AppServices private var asr: ASRService { self.appServices.asr @@ -27,17 +34,13 @@ struct SettingsView: View { @Binding var outputDevices: [AudioDevice.Device] @Binding var accessibilityEnabled: Bool @Binding var hotkeyShortcut: HotkeyShortcut - @Binding var isRecordingShortcut: Bool + @Binding var activeShortcutRecordingTarget: ShortcutRecordingTarget? @Binding var shortcutRecordingMessage: String? @Binding var promptModeShortcut: HotkeyShortcut - @Binding var isRecordingPromptModeShortcut: Bool @Binding var promptModeShortcutEnabled: Bool @Binding var commandModeShortcut: HotkeyShortcut - @Binding var isRecordingCommandModeShortcut: Bool @Binding var rewriteShortcut: HotkeyShortcut - @Binding var isRecordingRewriteShortcut: Bool @Binding var cancelRecordingShortcut: HotkeyShortcut - @Binding var isRecordingCancelShortcut: Bool @Binding var commandModeShortcutEnabled: Bool @Binding var rewriteShortcutEnabled: Bool @Binding var hotkeyManagerInitialized: Bool @@ -68,6 +71,14 @@ struct SettingsView: View { let revealAppInFinder: () -> Void let openApplicationsFolder: () -> Void + private var isRecordingAnyShortcut: Bool { + self.activeShortcutRecordingTarget != nil + } + + private func isRecording(_ target: ShortcutRecordingTarget) -> Bool { + self.activeShortcutRecordingTarget == target + } + private var analyticsToggleBinding: Binding { Binding( get: { @@ -571,7 +582,7 @@ struct SettingsView: View { Spacer() if self.accessibilityEnabled { - if self.isRecordingShortcut || self.isRecordingPromptModeShortcut || self.isRecordingCommandModeShortcut || self.isRecordingRewriteShortcut || self.isRecordingCancelShortcut { + if self.isRecordingAnyShortcut { Text("Recording…") .font(.caption.weight(.semibold)) .foregroundStyle(.orange) @@ -594,7 +605,7 @@ struct SettingsView: View { if self.accessibilityEnabled { VStack(alignment: .leading, spacing: 12) { - if self.isRecordingShortcut || self.isRecordingPromptModeShortcut || self.isRecordingCommandModeShortcut || self.isRecordingRewriteShortcut || self.isRecordingCancelShortcut { + if self.isRecordingAnyShortcut { HStack(spacing: 8) { Image(systemName: "hand.point.up.left.fill") .foregroundStyle(.orange) @@ -625,35 +636,41 @@ struct SettingsView: View { .foregroundStyle(.tertiary) self.shortcutRow( - icon: "mic.fill", - iconColor: .secondary, - title: "Primary Dictation Shortcut", - description: "Defaults to raw transcription, but can use Off, Default, or any custom prompt.", + content: .init( + icon: "mic.fill", + iconColor: .secondary, + title: "Primary Dictation Shortcut", + description: "Defaults to raw transcription, but can use Off, Default, or any custom prompt." + ), shortcut: self.hotkeyShortcut, - isRecording: self.isRecordingShortcut, - recordingMessage: self.isRecordingShortcut ? self.shortcutRecordingMessage : nil, + isRecording: self.isRecording(.primaryDictation), + isAnyRecordingActive: self.isRecordingAnyShortcut, + recordingMessage: self.isRecording(.primaryDictation) ? self.shortcutRecordingMessage : nil, onChangePressed: { DebugLogger.shared.debug("Starting to record new transcribe shortcut", source: "SettingsView") self.shortcutRecordingMessage = nil - self.isRecordingShortcut = true + self.activeShortcutRecordingTarget = .primaryDictation } ) self.dictationPromptPicker(for: .primary) Divider().opacity(0.2).padding(.vertical, 4) self.shortcutRow( - icon: "text.bubble.fill", - iconColor: .secondary, - title: "Secondary Dictation Shortcut", - description: "Defaults to Default cleanup, but can use Off, Default, or any custom prompt.", + content: .init( + icon: "text.bubble.fill", + iconColor: .secondary, + title: "Secondary Dictation Shortcut", + description: "Defaults to Default cleanup, but can use Off, Default, or any custom prompt." + ), shortcut: self.promptModeShortcut, - isRecording: self.isRecordingPromptModeShortcut, - recordingMessage: self.isRecordingPromptModeShortcut ? self.shortcutRecordingMessage : nil, + isRecording: self.isRecording(.secondaryDictation), + isAnyRecordingActive: self.isRecordingAnyShortcut, + recordingMessage: self.isRecording(.secondaryDictation) ? self.shortcutRecordingMessage : nil, isEnabled: self.$promptModeShortcutEnabled, onChangePressed: { DebugLogger.shared.debug("Starting to record new prompt mode shortcut", source: "SettingsView") self.shortcutRecordingMessage = nil - self.isRecordingPromptModeShortcut = true + self.activeShortcutRecordingTarget = .secondaryDictation } ) @@ -664,51 +681,60 @@ struct SettingsView: View { Divider().opacity(0.2).padding(.vertical, 4) self.shortcutRow( - icon: "terminal.fill", - iconColor: .secondary, - title: "Command Mode", - description: "Execute voice commands", + content: .init( + icon: "terminal.fill", + iconColor: .secondary, + title: "Command Mode", + description: "Execute voice commands" + ), shortcut: self.commandModeShortcut, - isRecording: self.isRecordingCommandModeShortcut, - recordingMessage: self.isRecordingCommandModeShortcut ? self.shortcutRecordingMessage : nil, + isRecording: self.isRecording(.command), + isAnyRecordingActive: self.isRecordingAnyShortcut, + recordingMessage: self.isRecording(.command) ? self.shortcutRecordingMessage : nil, isEnabled: self.$commandModeShortcutEnabled, onChangePressed: { DebugLogger.shared.debug("Starting to record new command mode shortcut", source: "SettingsView") self.shortcutRecordingMessage = nil - self.isRecordingCommandModeShortcut = true + self.activeShortcutRecordingTarget = .command } ) Divider().opacity(0.2).padding(.vertical, 4) self.shortcutRow( - icon: "pencil.and.outline", - iconColor: .secondary, - title: "Edit Mode", - description: "Select text and speak how to edit, or generate new content", + content: .init( + icon: "pencil.and.outline", + iconColor: .secondary, + title: "Edit Mode", + description: "Select text and speak how to edit, or generate new content" + ), shortcut: self.rewriteShortcut, - isRecording: self.isRecordingRewriteShortcut, - recordingMessage: self.isRecordingRewriteShortcut ? self.shortcutRecordingMessage : nil, + isRecording: self.isRecording(.edit), + isAnyRecordingActive: self.isRecordingAnyShortcut, + recordingMessage: self.isRecording(.edit) ? self.shortcutRecordingMessage : nil, isEnabled: self.$rewriteShortcutEnabled, onChangePressed: { DebugLogger.shared.debug("Starting to record new write mode shortcut", source: "SettingsView") self.shortcutRecordingMessage = nil - self.isRecordingRewriteShortcut = true + self.activeShortcutRecordingTarget = .edit } ) Divider().opacity(0.2).padding(.vertical, 4) self.shortcutRow( - icon: "xmark.circle.fill", - iconColor: .secondary, - title: "Cancel Recording", - description: "Cancel the current recording or dismiss the active recording overlay", + content: .init( + icon: "xmark.circle.fill", + iconColor: .secondary, + title: "Cancel Recording", + description: "Cancel the current recording or dismiss the active recording overlay" + ), shortcut: self.cancelRecordingShortcut, - isRecording: self.isRecordingCancelShortcut, - recordingMessage: self.isRecordingCancelShortcut ? self.shortcutRecordingMessage : nil, + isRecording: self.isRecording(.cancel), + isAnyRecordingActive: self.isRecordingAnyShortcut, + recordingMessage: self.isRecording(.cancel) ? self.shortcutRecordingMessage : nil, onChangePressed: { DebugLogger.shared.debug("Starting to record new cancel shortcut", source: "SettingsView") self.shortcutRecordingMessage = nil - self.isRecordingCancelShortcut = true + self.activeShortcutRecordingTarget = .cancel } ) } @@ -1688,12 +1714,10 @@ struct SettingsView: View { @ViewBuilder private func shortcutRow( - icon: String, - iconColor: Color, - title: String, - description: String, + content: ShortcutRowContent, shortcut: HotkeyShortcut, isRecording: Bool, + isAnyRecordingActive: Bool, recordingMessage: String? = nil, isEnabled: Binding? = nil, onChangePressed: @escaping () -> Void @@ -1702,14 +1726,14 @@ struct SettingsView: View { VStack(alignment: .leading, spacing: 6) { HStack(spacing: 10) { - Image(systemName: icon) - .foregroundStyle(iconColor) + Image(systemName: content.icon) + .foregroundStyle(content.iconColor) .frame(width: 20) VStack(alignment: .leading, spacing: 1) { - Text(title) + Text(content.title) .font(.body) - Text(description) + Text(content.description) .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) @@ -1759,7 +1783,7 @@ struct SettingsView: View { } .buttonStyle(.bordered) .controlSize(.small) - .disabled(isRecording || !enabledValue) + .disabled(isAnyRecordingActive || !enabledValue) if isRecording, let recordingMessage, !recordingMessage.isEmpty { Text(recordingMessage) From 02813185d2bbacf2ce869a17719ab9bb563e80c1 Mon Sep 17 00:00:00 2001 From: altic-dev Date: Tue, 7 Apr 2026 21:02:32 -0700 Subject: [PATCH 3/5] Reset stale modifier hotkey state --- Sources/Fluid/ContentView.swift | 3 +++ .../Fluid/Services/GlobalHotkeyManager.swift | 17 +++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/Sources/Fluid/ContentView.swift b/Sources/Fluid/ContentView.swift index 04812dd8..b6ff8c17 100644 --- a/Sources/Fluid/ContentView.swift +++ b/Sources/Fluid/ContentView.swift @@ -521,6 +521,9 @@ struct ContentView: View { .onChange(of: self.selectedProviderID) { _, newValue in SettingsStore.shared.selectedProviderID = newValue } + .onChange(of: self.activeShortcutRecordingTarget) { _, _ in + self.hotkeyManager?.resetModifierOnlyShortcutTracking() + } .onChange(of: self.isPromptModeShortcutEnabled) { newValue in SettingsStore.shared.promptModeShortcutEnabled = newValue self.hotkeyManager?.updatePromptModeShortcutEnabled(newValue) diff --git a/Sources/Fluid/Services/GlobalHotkeyManager.swift b/Sources/Fluid/Services/GlobalHotkeyManager.swift index f6063b9a..9bc554b1 100644 --- a/Sources/Fluid/Services/GlobalHotkeyManager.swift +++ b/Sources/Fluid/Services/GlobalHotkeyManager.swift @@ -358,6 +358,7 @@ final class GlobalHotkeyManager: NSObject { } if self.isShortcutCaptureActiveProvider?() ?? false { + self.resetModifierOnlyShortcutTracking() return Unmanaged.passUnretained(event) } @@ -684,6 +685,7 @@ final class GlobalHotkeyManager: NSObject { let reason = (type == .tapDisabledByTimeout) ? "timeout" : "user input" DebugLogger.shared.warning("Event tap disabled by \(reason) — attempting immediate re-enable", source: "GlobalHotkeyManager") + self.resetModifierOnlyShortcutTracking() if let tap = self.eventTap { CGEvent.tapEnable(tap: tap, enable: true) @@ -697,6 +699,20 @@ final class GlobalHotkeyManager: NSObject { return Unmanaged.passUnretained(event) } + func resetModifierOnlyShortcutTracking() { + self.pressedModifierKeyCodes = [] + self.modifierOnlyKeyDown = false + self.otherKeyPressedDuringModifier = false + self.modifierPressStartTime = nil + self.pendingHoldModeStart?.cancel() + self.pendingHoldModeStart = nil + self.pendingHoldModeType = nil + self.isKeyPressed = false + self.isPromptModeKeyPressed = false + self.isCommandModeKeyPressed = false + self.isRewriteKeyPressed = false + } + private func handlePromptModeKeyDown(keyCode: UInt16, modifiers: NSEvent.ModifierFlags) -> Bool { guard self.promptModeShortcutEnabled, self.matchesPromptModeShortcut(keyCode: keyCode, modifiers: modifiers) else { return false } if self.pressAndHoldMode { @@ -1096,6 +1112,7 @@ final class GlobalHotkeyManager: NSObject { self.initializationTask?.cancel() self.healthCheckTask?.cancel() + self.resetModifierOnlyShortcutTracking() self.isInitialized = false self.initializeWithDelay() } From 81339b29fc317c0c49ffd86af2552fc9ee5222aa Mon Sep 17 00:00:00 2001 From: altic-dev Date: Tue, 7 Apr 2026 21:28:41 -0700 Subject: [PATCH 4/5] dynamicNotch url change to remote fork for future changes --- Fluid.xcodeproj/project.pbxproj | 6 +++--- .../xcshareddata/swiftpm/Package.resolved | 6 +++--- Package.resolved | 6 +++--- Package.swift | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/Fluid.xcodeproj/project.pbxproj b/Fluid.xcodeproj/project.pbxproj index 90579898..30da8f2d 100644 --- a/Fluid.xcodeproj/project.pbxproj +++ b/Fluid.xcodeproj/project.pbxproj @@ -613,10 +613,10 @@ }; 7C3697872ED70F9C005874CE /* XCRemoteSwiftPackageReference "DynamicNotchKit" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/MrKai77/DynamicNotchKit"; + repositoryURL = "https://github.com/altic-dev/DynamicNotchKit.git"; requirement = { - kind = upToNextMajorVersion; - minimumVersion = 1.0.0; + branch = main; + kind = branch; }; }; 7C5AF1492F15041600DE21B0 /* XCRemoteSwiftPackageReference "mediaremote-adapter" */ = { diff --git a/Fluid.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Fluid.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index ae75139d..9c4cd8b0 100644 --- a/Fluid.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Fluid.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -13,10 +13,10 @@ { "identity" : "dynamicnotchkit", "kind" : "remoteSourceControl", - "location" : "https://github.com/MrKai77/DynamicNotchKit", + "location" : "https://github.com/altic-dev/DynamicNotchKit.git", "state" : { - "revision" : "3c405930fd4d9f8498303683f32492c26d3a9b2b", - "version" : "1.0.0" + "branch" : "main", + "revision" : "cd0b3e52d537db115ad3a9d89601f20e0bee8d27" } }, { diff --git a/Package.resolved b/Package.resolved index 20857902..1e2aa041 100644 --- a/Package.resolved +++ b/Package.resolved @@ -12,10 +12,10 @@ { "identity" : "dynamicnotchkit", "kind" : "remoteSourceControl", - "location" : "https://github.com/MrKai77/DynamicNotchKit", + "location" : "https://github.com/altic-dev/DynamicNotchKit.git", "state" : { - "revision" : "3c405930fd4d9f8498303683f32492c26d3a9b2b", - "version" : "1.0.0" + "branch" : "main", + "revision" : "cd0b3e52d537db115ad3a9d89601f20e0bee8d27" } }, { diff --git a/Package.swift b/Package.swift index ecf1e4eb..836ecaa1 100644 --- a/Package.swift +++ b/Package.swift @@ -12,7 +12,7 @@ let package = Package( .package(url: "https://github.com/mxcl/AppUpdater.git", from: "1.0.0"), .package(url: "https://github.com/altic-dev/FluidAudio.git", branch: "B/cohere-coreml-asr"), .package(url: "https://github.com/mxcl/PromiseKit", from: "6.0.0"), - .package(url: "https://github.com/MrKai77/DynamicNotchKit", from: "1.0.0"), + .package(url: "https://github.com/altic-dev/DynamicNotchKit.git", branch: "main"), .package(url: "https://github.com/exPHAT/SwiftWhisper.git", branch: "master"), .package(url: "https://github.com/PostHog/posthog-ios.git", from: "3.0.0"), ], From 543eecd665ca3ad3020495b597843dc68a90088a Mon Sep 17 00:00:00 2001 From: altic-dev Date: Tue, 7 Apr 2026 21:52:41 -0700 Subject: [PATCH 5/5] Harden modifier-only hotkey state tracking --- .../Fluid/Services/GlobalHotkeyManager.swift | 110 ++++++++++++++++-- 1 file changed, 100 insertions(+), 10 deletions(-) diff --git a/Sources/Fluid/Services/GlobalHotkeyManager.swift b/Sources/Fluid/Services/GlobalHotkeyManager.swift index 9bc554b1..4b7423cc 100644 --- a/Sources/Fluid/Services/GlobalHotkeyManager.swift +++ b/Sources/Fluid/Services/GlobalHotkeyManager.swift @@ -69,6 +69,12 @@ final class GlobalHotkeyManager: NSObject { let onToggleRelease: () -> Void } + enum ModifierTrackingResetReason { + case shortcutCapture + case tapDisabled + case reinitialize + } + private nonisolated var isKeyPressed: Bool { get { self.state.withLock { self.state.isKeyPressed } } set { self.state.withLock { self.state.isKeyPressed = newValue } } @@ -558,13 +564,10 @@ final class GlobalHotkeyManager: NSObject { case .flagsChanged: if HotkeyShortcut.modifierFlag(forKeyCode: keyCode) != nil { - var pressedModifierKeyCodes = self.pressedModifierKeyCodes - if pressedModifierKeyCodes.contains(keyCode) { - pressedModifierKeyCodes.remove(keyCode) - } else { - pressedModifierKeyCodes.insert(keyCode) - } - self.pressedModifierKeyCodes = pressedModifierKeyCodes + self.pressedModifierKeyCodes = self.synchronizedPressedModifierKeyCodes( + changedKeyCode: keyCode, + modifiers: eventModifiers + ) } if self.handlePromptModeFlagsChanged(keyCode: keyCode, modifiers: eventModifiers) { return nil } @@ -685,7 +688,7 @@ final class GlobalHotkeyManager: NSObject { let reason = (type == .tapDisabledByTimeout) ? "timeout" : "user input" DebugLogger.shared.warning("Event tap disabled by \(reason) — attempting immediate re-enable", source: "GlobalHotkeyManager") - self.resetModifierOnlyShortcutTracking() + self.resetModifierOnlyShortcutTracking(reason: .tapDisabled) if let tap = self.eventTap { CGEvent.tapEnable(tap: tap, enable: true) @@ -699,7 +702,62 @@ final class GlobalHotkeyManager: NSObject { return Unmanaged.passUnretained(event) } - func resetModifierOnlyShortcutTracking() { + private func synchronizedPressedModifierKeyCodes( + changedKeyCode: UInt16, + modifiers: NSEvent.ModifierFlags + ) -> Set { + guard let changedFlag = HotkeyShortcut.modifierFlag(forKeyCode: changedKeyCode) else { + return self.pressedModifierKeyCodes + } + + let activeModifiers = modifiers.intersection(HotkeyShortcut.relevantModifierMask) + let activeModifierGroups: [(NSEvent.ModifierFlags, [UInt16])] = [ + (.function, [63]), + (.command, [55, 54]), + (.option, [58, 61]), + (.control, [59, 62]), + (.shift, [56, 60]), + ] + + var synchronizedKeyCodes = Set() + + for (flag, keyCodes) in activeModifierGroups where activeModifiers.contains(flag) { + let livePressedKeyCodes = keyCodes.filter { + CGEventSource.keyState(.combinedSessionState, key: CGKeyCode($0)) + } + + if !livePressedKeyCodes.isEmpty { + synchronizedKeyCodes.formUnion(livePressedKeyCodes) + continue + } + + // If the changed modifier family is active but the live key-state query did not yet + // reflect it, trust the current event's key code for this transition. + if flag == changedFlag { + synchronizedKeyCodes.insert(changedKeyCode) + } + } + + return synchronizedKeyCodes + } + + private func cancelPendingModifierOnlyHoldStart( + for behavior: ModifierOnlyShortcutBehavior, + message: String + ) { + guard self.pendingHoldModeType == behavior.holdModeType else { return } + self.otherKeyPressedDuringModifier = true + self.pendingHoldModeStart?.cancel() + self.pendingHoldModeStart = nil + self.pendingHoldModeType = nil + DebugLogger.shared.info(message, source: "GlobalHotkeyManager") + } + + func resetModifierOnlyShortcutTracking(reason: ModifierTrackingResetReason = .shortcutCapture) { + let shouldStopActiveHold = self.pressAndHoldMode + && self.asrService.isRunning + && (self.isKeyPressed || self.isPromptModeKeyPressed || self.isCommandModeKeyPressed || self.isRewriteKeyPressed) + self.pressedModifierKeyCodes = [] self.modifierOnlyKeyDown = false self.otherKeyPressedDuringModifier = false @@ -711,6 +769,18 @@ final class GlobalHotkeyManager: NSObject { self.isPromptModeKeyPressed = false self.isCommandModeKeyPressed = false self.isRewriteKeyPressed = false + + if shouldStopActiveHold { + switch reason { + case .shortcutCapture: + DebugLogger.shared.debug("Shortcut capture active - stopping active hold recording before reset", source: "GlobalHotkeyManager") + case .tapDisabled: + DebugLogger.shared.warning("Event tap disabled during active hold - stopping recording before reset", source: "GlobalHotkeyManager") + case .reinitialize: + DebugLogger.shared.info("Hotkey manager reinitializing - stopping active hold recording before reset", source: "GlobalHotkeyManager") + } + self.stopRecordingIfNeeded() + } } private func handlePromptModeKeyDown(keyCode: UInt16, modifiers: NSEvent.ModifierFlags) -> Bool { @@ -815,6 +885,16 @@ final class GlobalHotkeyManager: NSObject { return true } + if self.modifierOnlyKeyDown || behavior.isModeKeyPressed() { + let extraModifierKeyCodes = pressedModifierKeyCodes.filter { !expectedModifierKeyCodes.contains($0) } + if !extraModifierKeyCodes.isEmpty { + self.cancelPendingModifierOnlyHoldStart( + for: behavior, + message: "\(behavior.holdStartCancelledMessage) - extra modifier pressed" + ) + } + } + guard self.modifierOnlyKeyDown || behavior.isModeKeyPressed(), expectedModifierKeyCodes.contains(keyCode), !pressedModifierKeyCodes.contains(keyCode) @@ -876,6 +956,16 @@ final class GlobalHotkeyManager: NSObject { return true } + if self.modifierOnlyKeyDown || behavior.isModeKeyPressed() { + let unexpectedModifiers = relevantModifiers.subtracting(expectedPressedModifiers) + if !unexpectedModifiers.isEmpty { + self.cancelPendingModifierOnlyHoldStart( + for: behavior, + message: "\(behavior.holdStartCancelledMessage) - extra modifier pressed" + ) + } + } + guard self.modifierOnlyKeyDown || behavior.isModeKeyPressed(), keyCode == shortcut.keyCode, !relevantModifiers.contains(triggerFlag) @@ -1112,7 +1202,7 @@ final class GlobalHotkeyManager: NSObject { self.initializationTask?.cancel() self.healthCheckTask?.cancel() - self.resetModifierOnlyShortcutTracking() + self.resetModifierOnlyShortcutTracking(reason: .reinitialize) self.isInitialized = false self.initializeWithDelay() }