From 40c804309ba1a36696f05d8444cde4ac6555a2c6 Mon Sep 17 00:00:00 2001 From: Yaomeng1749 <176285760+Yaomeng1749@users.noreply.github.com> Date: Thu, 2 Jul 2026 04:53:12 +0800 Subject: [PATCH 1/5] Fix stale primary shortcut state --- .../Fluid/Services/GlobalHotkeyManager.swift | 71 +++++++++++++++++-- .../HotkeyShortcutTests.swift | 42 +++++++++++ 2 files changed, 109 insertions(+), 4 deletions(-) diff --git a/Sources/Fluid/Services/GlobalHotkeyManager.swift b/Sources/Fluid/Services/GlobalHotkeyManager.swift index 7c9b271d..4fcffa87 100644 --- a/Sources/Fluid/Services/GlobalHotkeyManager.swift +++ b/Sources/Fluid/Services/GlobalHotkeyManager.swift @@ -347,6 +347,7 @@ final class GlobalHotkeyManager: NSObject { func updatePrimaryShortcuts(_ newShortcuts: [HotkeyShortcut]) { self.primaryShortcuts = newShortcuts + self.clearPrimaryShortcutPressState() DebugLogger.shared.info("Updated transcription hotkeys", source: "GlobalHotkeyManager") } @@ -517,8 +518,21 @@ final class GlobalHotkeyManager: NSObject { } private nonisolated func clearPrimaryShortcutPressState() { - let task = self.state.withLock { () -> Task? in - guard self.state.activePrimaryShortcutPress != nil || self.state.isKeyPressed else { return nil } + let tasks = self.state.withLock { () -> [Task] in + var tasks: [Task] = [] + + if self.state.pendingHoldModeType == .transcription { + if let task = self.state.pendingHoldModeStart { + tasks.append(task) + } + self.state.pendingHoldModeStart = nil + self.state.pendingHoldModeType = nil + } + + self.state.pressedModifierKeyCodes.removeAll() + self.state.modifierOnlyKeyDown = false + self.state.otherKeyPressedDuringModifier = false + self.state.modifierPressStartTime = nil self.state.activePrimaryShortcutPress = nil self.state.isKeyPressed = false self.state.holdModeStartTriggeredTypes.remove(.transcription) @@ -526,9 +540,15 @@ final class GlobalHotkeyManager: NSObject { self.state.automaticPressWasTargetActive.removeValue(forKey: .transcription) self.state.automaticPressStartedTypes.remove(.transcription) _ = self.state.pendingReleaseStopTokens.removeValue(forKey: .transcription) - return self.state.pendingReleaseStopTasks.removeValue(forKey: .transcription) + if let task = self.state.pendingReleaseStopTasks.removeValue(forKey: .transcription) { + tasks.append(task) + } + + return tasks + } + for task in tasks { + task.cancel() } - task?.cancel() } private func markOtherInputDuringModifierOnly() { @@ -1930,3 +1950,46 @@ final class GlobalHotkeyManager: NSObject { cleanupEventTap() } } + +#if DEBUG +extension GlobalHotkeyManager { + func debugHandlePrimaryModifierOnlyFlagsChanged(keyCode: UInt16, modifiers: NSEvent.ModifierFlags) -> Bool { + if HotkeyShortcut.modifierFlag(forKeyCode: keyCode) != nil { + self.pressedModifierKeyCodes = self.synchronizedPressedModifierKeyCodes( + changedKeyCode: keyCode, + modifiers: modifiers + ) + } + + for shortcut in self.primaryShortcuts where shortcut.isModifierOnlyShortcut { + if self.handleModifierOnlyShortcutFlagsChanged( + behavior: self.primaryModifierOnlyBehavior(for: shortcut), + keyCode: keyCode, + modifiers: modifiers + ) { + return true + } + } + + return false + } + + var debugPrimaryShortcutPressStateIsClear: Bool { + self.state.withLock { + self.state.activePrimaryShortcutPress == nil + && !self.state.isKeyPressed + && !self.state.modifierOnlyKeyDown + && !self.state.otherKeyPressedDuringModifier + && self.state.modifierPressStartTime == nil + && self.state.pendingHoldModeStart == nil + && self.state.pendingHoldModeType != .transcription + && !self.state.holdModeStartTriggeredTypes.contains(.transcription) + && self.state.automaticPressStartTimes[.transcription] == nil + && self.state.automaticPressWasTargetActive[.transcription] == nil + && !self.state.automaticPressStartedTypes.contains(.transcription) + && self.state.pendingReleaseStopTasks[.transcription] == nil + && self.state.pendingReleaseStopTokens[.transcription] == nil + } + } +} +#endif diff --git a/Tests/FluidDictationIntegrationTests/HotkeyShortcutTests.swift b/Tests/FluidDictationIntegrationTests/HotkeyShortcutTests.swift index b7e0f8ee..fd6fc40a 100644 --- a/Tests/FluidDictationIntegrationTests/HotkeyShortcutTests.swift +++ b/Tests/FluidDictationIntegrationTests/HotkeyShortcutTests.swift @@ -177,3 +177,45 @@ final class HotkeyShortcutTests: XCTestCase { try run() } } + +@MainActor +final class GlobalHotkeyManagerShortcutTests: XCTestCase { + func testUpdatingPrimaryShortcutsCancelsDeletedModifierOnlyShortcutPress() async throws { + let leftOption = HotkeyShortcut(keyCode: 58, modifierFlags: []) + let leftCommand = HotkeyShortcut(keyCode: 55, modifierFlags: []) + let startProbe = HotkeyStartProbe() + let manager = GlobalHotkeyManager( + asrService: ASRService(), + primaryShortcuts: [leftOption, leftCommand], + promptModeShortcut: HotkeyShortcut(keyCode: 12, modifierFlags: [.option]), + commandModeShortcut: nil, + rewriteModeShortcut: HotkeyShortcut(keyCode: 14, modifierFlags: [.option]), + promptModeShortcutEnabled: false, + commandModeShortcutEnabled: false, + rewriteModeShortcutEnabled: false, + startRecordingCallback: { await startProbe.recordStart() }, + isDictateRecordingProvider: { true }, + isShortcutCaptureActiveProvider: { false } + ) + manager.setHotkeyMode(.hold) + + XCTAssertTrue( + manager.debugHandlePrimaryModifierOnlyFlagsChanged(keyCode: 55, modifiers: [.command]) + ) + + manager.updatePrimaryShortcuts([leftOption]) + + try await Task.sleep(nanoseconds: 250_000_000) + XCTAssertEqual(startProbe.count, 0) + XCTAssertTrue(manager.debugPrimaryShortcutPressStateIsClear) + } +} + +@MainActor +private final class HotkeyStartProbe { + private(set) var count = 0 + + func recordStart() async { + self.count += 1 + } +} From b38e15249067372c5974d783f83a47d9e7e07487 Mon Sep 17 00:00:00 2001 From: Yaomeng1749 <176285760+Yaomeng1749@users.noreply.github.com> Date: Thu, 2 Jul 2026 05:57:27 +0800 Subject: [PATCH 2/5] Narrow primary shortcut reset scope --- .../Fluid/Services/GlobalHotkeyManager.swift | 48 +++++++++++++++---- 1 file changed, 39 insertions(+), 9 deletions(-) diff --git a/Sources/Fluid/Services/GlobalHotkeyManager.swift b/Sources/Fluid/Services/GlobalHotkeyManager.swift index 4fcffa87..beaeb2ad 100644 --- a/Sources/Fluid/Services/GlobalHotkeyManager.swift +++ b/Sources/Fluid/Services/GlobalHotkeyManager.swift @@ -346,8 +346,9 @@ final class GlobalHotkeyManager: NSObject { } func updatePrimaryShortcuts(_ newShortcuts: [HotkeyShortcut]) { + let removedShortcuts = self.primaryShortcuts.filter { !newShortcuts.contains($0) } self.primaryShortcuts = newShortcuts - self.clearPrimaryShortcutPressState() + self.clearPrimaryShortcutPressState(removedShortcuts: removedShortcuts) DebugLogger.shared.info("Updated transcription hotkeys", source: "GlobalHotkeyManager") } @@ -514,14 +515,36 @@ final class GlobalHotkeyManager: NSObject { self.eventTap = nil self.runLoopSource = nil - self.clearPrimaryShortcutPressState() + self.clearPrimaryShortcutPressState(removedShortcuts: nil) } - private nonisolated func clearPrimaryShortcutPressState() { + private nonisolated func clearPrimaryShortcutPressState(removedShortcuts: [HotkeyShortcut]? = nil) { let tasks = self.state.withLock { () -> [Task] in var tasks: [Task] = [] + let forceClear = removedShortcuts == nil + let removedShortcuts = removedShortcuts ?? [] + let removedActiveShortcut = self.state.activePrimaryShortcutPress.map { press in + removedShortcuts.contains { Self.shortcut($0, matchesPrimaryPress: press) } + } ?? false + let pressedModifierKeyCodes = HotkeyShortcut.normalizedModifierKeyCodes(from: Array(self.state.pressedModifierKeyCodes)) + let removedPendingTranscriptionModifierShortcut = self.state.pendingHoldModeType == .transcription && removedShortcuts.contains { + $0.isModifierOnlyShortcut + } + let removedModifierOnlyShortcut = removedPendingTranscriptionModifierShortcut || !pressedModifierKeyCodes.isEmpty && removedShortcuts.contains { shortcut in + shortcut.isModifierOnlyShortcut && shortcut.normalizedModifierKeyCodes == pressedModifierKeyCodes + } + let shouldClearPrimaryPress = forceClear || removedActiveShortcut || removedModifierOnlyShortcut + + guard shouldClearPrimaryPress else { return tasks } + + if forceClear || removedModifierOnlyShortcut { + self.state.pressedModifierKeyCodes.removeAll() + self.state.modifierOnlyKeyDown = false + self.state.otherKeyPressedDuringModifier = false + self.state.modifierPressStartTime = nil + } - if self.state.pendingHoldModeType == .transcription { + if self.state.pendingHoldModeType == .transcription, forceClear || removedModifierOnlyShortcut { if let task = self.state.pendingHoldModeStart { tasks.append(task) } @@ -529,11 +552,9 @@ final class GlobalHotkeyManager: NSObject { self.state.pendingHoldModeType = nil } - self.state.pressedModifierKeyCodes.removeAll() - self.state.modifierOnlyKeyDown = false - self.state.otherKeyPressedDuringModifier = false - self.state.modifierPressStartTime = nil - self.state.activePrimaryShortcutPress = nil + if forceClear || removedActiveShortcut { + self.state.activePrimaryShortcutPress = nil + } self.state.isKeyPressed = false self.state.holdModeStartTriggeredTypes.remove(.transcription) self.state.automaticPressStartTimes.removeValue(forKey: .transcription) @@ -551,6 +572,15 @@ final class GlobalHotkeyManager: NSObject { } } + private nonisolated static func shortcut(_ shortcut: HotkeyShortcut, matchesPrimaryPress press: ActivePrimaryShortcutPress) -> Bool { + switch press { + case let .keyboard(keyCode): + return !shortcut.isMouseShortcut && shortcut.keyCode == keyCode + case let .mouse(button): + return shortcut.isMouseShortcut && shortcut.mouseButton == button + } + } + private func markOtherInputDuringModifierOnly() { guard self.modifierOnlyKeyDown else { return } self.otherKeyPressedDuringModifier = true From ec6fd3d7b8043bdcb6e4f9addf17d64e58838c1b Mon Sep 17 00:00:00 2001 From: Yaomeng1749 <176285760+Yaomeng1749@users.noreply.github.com> Date: Thu, 2 Jul 2026 06:01:44 +0800 Subject: [PATCH 3/5] Satisfy shortcut reset lint --- .../Fluid/Services/GlobalHotkeyManager.swift | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/Sources/Fluid/Services/GlobalHotkeyManager.swift b/Sources/Fluid/Services/GlobalHotkeyManager.swift index beaeb2ad..ef176b6f 100644 --- a/Sources/Fluid/Services/GlobalHotkeyManager.swift +++ b/Sources/Fluid/Services/GlobalHotkeyManager.swift @@ -515,22 +515,28 @@ final class GlobalHotkeyManager: NSObject { self.eventTap = nil self.runLoopSource = nil - self.clearPrimaryShortcutPressState(removedShortcuts: nil) + self.clearPrimaryShortcutPressState() } - private nonisolated func clearPrimaryShortcutPressState(removedShortcuts: [HotkeyShortcut]? = nil) { + private nonisolated func clearPrimaryShortcutPressState() { + self.clearPrimaryShortcutPressState(removedShortcuts: [], forceClear: true) + } + + private nonisolated func clearPrimaryShortcutPressState(removedShortcuts: [HotkeyShortcut]) { + self.clearPrimaryShortcutPressState(removedShortcuts: removedShortcuts, forceClear: false) + } + + private nonisolated func clearPrimaryShortcutPressState(removedShortcuts: [HotkeyShortcut], forceClear: Bool) { let tasks = self.state.withLock { () -> [Task] in var tasks: [Task] = [] - let forceClear = removedShortcuts == nil - let removedShortcuts = removedShortcuts ?? [] let removedActiveShortcut = self.state.activePrimaryShortcutPress.map { press in removedShortcuts.contains { Self.shortcut($0, matchesPrimaryPress: press) } } ?? false let pressedModifierKeyCodes = HotkeyShortcut.normalizedModifierKeyCodes(from: Array(self.state.pressedModifierKeyCodes)) - let removedPendingTranscriptionModifierShortcut = self.state.pendingHoldModeType == .transcription && removedShortcuts.contains { + let removedPendingModShortcut = self.state.pendingHoldModeType == .transcription && removedShortcuts.contains { $0.isModifierOnlyShortcut } - let removedModifierOnlyShortcut = removedPendingTranscriptionModifierShortcut || !pressedModifierKeyCodes.isEmpty && removedShortcuts.contains { shortcut in + let removedModifierOnlyShortcut = removedPendingModShortcut || !pressedModifierKeyCodes.isEmpty && removedShortcuts.contains { shortcut in shortcut.isModifierOnlyShortcut && shortcut.normalizedModifierKeyCodes == pressedModifierKeyCodes } let shouldClearPrimaryPress = forceClear || removedActiveShortcut || removedModifierOnlyShortcut From c2caabd26d8c2a50daa6f061e78449aa233d8d5e Mon Sep 17 00:00:00 2001 From: Yaomeng1749 <176285760+Yaomeng1749@users.noreply.github.com> Date: Thu, 2 Jul 2026 06:13:46 +0800 Subject: [PATCH 4/5] Tighten removed modifier shortcut matching --- Sources/Fluid/Services/GlobalHotkeyManager.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/Fluid/Services/GlobalHotkeyManager.swift b/Sources/Fluid/Services/GlobalHotkeyManager.swift index ef176b6f..386a438f 100644 --- a/Sources/Fluid/Services/GlobalHotkeyManager.swift +++ b/Sources/Fluid/Services/GlobalHotkeyManager.swift @@ -533,9 +533,9 @@ final class GlobalHotkeyManager: NSObject { removedShortcuts.contains { Self.shortcut($0, matchesPrimaryPress: press) } } ?? false let pressedModifierKeyCodes = HotkeyShortcut.normalizedModifierKeyCodes(from: Array(self.state.pressedModifierKeyCodes)) - let removedPendingModShortcut = self.state.pendingHoldModeType == .transcription && removedShortcuts.contains { - $0.isModifierOnlyShortcut - } + let removedPendingModShortcut = pressedModifierKeyCodes.isEmpty + && self.state.pendingHoldModeType == .transcription + && removedShortcuts.contains { $0.isModifierOnlyShortcut } let removedModifierOnlyShortcut = removedPendingModShortcut || !pressedModifierKeyCodes.isEmpty && removedShortcuts.contains { shortcut in shortcut.isModifierOnlyShortcut && shortcut.normalizedModifierKeyCodes == pressedModifierKeyCodes } From 55a469d2946f3c0f8d2837476cb4aa4731afb2b0 Mon Sep 17 00:00:00 2001 From: Yaomeng1749 <176285760+Yaomeng1749@users.noreply.github.com> Date: Sun, 5 Jul 2026 16:33:02 +0800 Subject: [PATCH 5/5] Track active primary shortcut identity --- .../Fluid/Services/GlobalHotkeyManager.swift | 178 +++++++++++++++--- .../HotkeyShortcutTests.swift | 25 +++ 2 files changed, 180 insertions(+), 23 deletions(-) diff --git a/Sources/Fluid/Services/GlobalHotkeyManager.swift b/Sources/Fluid/Services/GlobalHotkeyManager.swift index 386a438f..d2d8b796 100644 --- a/Sources/Fluid/Services/GlobalHotkeyManager.swift +++ b/Sources/Fluid/Services/GlobalHotkeyManager.swift @@ -9,9 +9,121 @@ private nonisolated enum HotkeyHoldModeType: Hashable { case promptAssignment } -private nonisolated enum ActivePrimaryShortcutPress: Equatable { - case keyboard(UInt16) - case mouse(Int) +private nonisolated struct PrimaryShortcutIdentity: Equatable { + private static let relevantModifierMask: NSEvent.ModifierFlags = [.function, .command, .option, .control, .shift] + + enum Kind { + case keyboard + case mouse + } + + let kind: Kind + let keyCode: UInt16 + let mouseButton: Int? + let modifierFlagsRawValue: UInt + + init(shortcut: HotkeyShortcut) { + if shortcut.mouseButton != nil { + self.kind = .mouse + } else { + self.kind = .keyboard + } + self.keyCode = shortcut.keyCode + self.mouseButton = shortcut.mouseButton + self.modifierFlagsRawValue = shortcut.modifierFlags.intersection(Self.relevantModifierMask).rawValue + } + + static func normalizedModifierOnlyKeyCodes(for shortcut: HotkeyShortcut) -> [UInt16] { + guard shortcut.mouseButton == nil else { return [] } + + let normalized = self.normalizedModifierKeyCodes(from: shortcut.modifierKeyCodes) + if !normalized.isEmpty { + return normalized + } + + if self.modifierFlag(forKeyCode: shortcut.keyCode) != nil, + shortcut.modifierFlags.isDisjoint(with: self.relevantModifierMask) + { + return [shortcut.keyCode] + } + + return [] + } + + static func normalizedModifierKeyCodes(from modifierKeyCodes: [UInt16]) -> [UInt16] { + 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) + } + + private 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 + } + } +} + +private nonisolated enum ActivePrimaryShortcutPress { + case keyboard(PrimaryShortcutIdentity) + case mouse(PrimaryShortcutIdentity) + + init(shortcut: HotkeyShortcut) { + let identity = PrimaryShortcutIdentity(shortcut: shortcut) + switch identity.kind { + case .keyboard: + self = .keyboard(identity) + case .mouse: + self = .mouse(identity) + } + } + + var identity: PrimaryShortcutIdentity { + switch self { + case let .keyboard(identity), let .mouse(identity): + return identity + } + } + + func matchesKeyboardRelease(keyCode: UInt16) -> Bool { + guard case let .keyboard(identity) = self else { return false } + return identity.keyCode == keyCode + } + + func matchesMouseRelease(button: Int) -> Bool { + guard case let .mouse(identity) = self else { return false } + return identity.mouseButton == button + } } private final nonisolated class HotkeyState: @unchecked Sendable { @@ -530,14 +642,16 @@ final class GlobalHotkeyManager: NSObject { let tasks = self.state.withLock { () -> [Task] in var tasks: [Task] = [] let removedActiveShortcut = self.state.activePrimaryShortcutPress.map { press in - removedShortcuts.contains { Self.shortcut($0, matchesPrimaryPress: press) } + removedShortcuts.contains { + PrimaryShortcutIdentity(shortcut: $0) == press.identity + } } ?? false - let pressedModifierKeyCodes = HotkeyShortcut.normalizedModifierKeyCodes(from: Array(self.state.pressedModifierKeyCodes)) + let pressedModifierKeyCodes = PrimaryShortcutIdentity.normalizedModifierKeyCodes(from: Array(self.state.pressedModifierKeyCodes)) let removedPendingModShortcut = pressedModifierKeyCodes.isEmpty && self.state.pendingHoldModeType == .transcription - && removedShortcuts.contains { $0.isModifierOnlyShortcut } + && removedShortcuts.contains { !PrimaryShortcutIdentity.normalizedModifierOnlyKeyCodes(for: $0).isEmpty } let removedModifierOnlyShortcut = removedPendingModShortcut || !pressedModifierKeyCodes.isEmpty && removedShortcuts.contains { shortcut in - shortcut.isModifierOnlyShortcut && shortcut.normalizedModifierKeyCodes == pressedModifierKeyCodes + PrimaryShortcutIdentity.normalizedModifierOnlyKeyCodes(for: shortcut) == pressedModifierKeyCodes } let shouldClearPrimaryPress = forceClear || removedActiveShortcut || removedModifierOnlyShortcut @@ -578,15 +692,6 @@ final class GlobalHotkeyManager: NSObject { } } - private nonisolated static func shortcut(_ shortcut: HotkeyShortcut, matchesPrimaryPress press: ActivePrimaryShortcutPress) -> Bool { - switch press { - case let .keyboard(keyCode): - return !shortcut.isMouseShortcut && shortcut.keyCode == keyCode - case let .mouse(button): - return shortcut.isMouseShortcut && shortcut.mouseButton == button - } - } - private func markOtherInputDuringModifierOnly() { guard self.modifierOnlyKeyDown else { return } self.otherKeyPressedDuringModifier = true @@ -612,9 +717,19 @@ final class GlobalHotkeyManager: NSObject { } } - private func finishPrimaryShortcutPress(_ press: ActivePrimaryShortcutPress) -> Bool { + private func finishPrimaryShortcutPress(keyCode: UInt16) -> Bool { + self.state.withLock { + guard self.state.activePrimaryShortcutPress?.matchesKeyboardRelease(keyCode: keyCode) == true else { + return false + } + self.state.activePrimaryShortcutPress = nil + return true + } + } + + private func finishPrimaryShortcutPress(mouseButton: Int) -> Bool { self.state.withLock { - guard self.state.activePrimaryShortcutPress == press else { + guard self.state.activePrimaryShortcutPress?.matchesMouseRelease(button: mouseButton) == true else { return false } self.state.activePrimaryShortcutPress = nil @@ -879,7 +994,7 @@ final class GlobalHotkeyManager: NSObject { // Then check transcription hotkeys if let shortcut = self.primaryShortcuts.first(where: { $0.matches(keyCode: keyCode, modifiers: eventModifiers) }) { - guard self.beginPrimaryShortcutPress(.keyboard(shortcut.keyCode)) else { return nil } + guard self.beginPrimaryShortcutPress(ActivePrimaryShortcutPress(shortcut: shortcut)) else { return nil } self.handlePrimaryDictationTriggerDown() return nil } @@ -951,7 +1066,7 @@ final class GlobalHotkeyManager: NSObject { // Transcription key up // Note: Only check keyCode, not modifiers - user may release modifier before/with main key - if self.finishPrimaryShortcutPress(.keyboard(keyCode)) { + if self.finishPrimaryShortcutPress(keyCode: keyCode) { self.handlePrimaryDictationTriggerUp() return nil } @@ -1735,8 +1850,8 @@ final class GlobalHotkeyManager: NSObject { return true } - if self.primaryShortcuts.contains(where: { $0.matchesMouse(button: mouseButton, modifiers: eventModifiers) }) { - guard self.beginPrimaryShortcutPress(.mouse(mouseButton)) else { return true } + if let shortcut = self.primaryShortcuts.first(where: { $0.matchesMouse(button: mouseButton, modifiers: eventModifiers) }) { + guard self.beginPrimaryShortcutPress(ActivePrimaryShortcutPress(shortcut: shortcut)) else { return true } self.handlePrimaryDictationTriggerDown() return true } @@ -1757,7 +1872,7 @@ final class GlobalHotkeyManager: NSObject { return true } - guard self.finishPrimaryShortcutPress(.mouse(mouseButton)) else { return false } + guard self.finishPrimaryShortcutPress(mouseButton: mouseButton) else { return false } self.handlePrimaryDictationTriggerUp() return true } @@ -2010,6 +2125,23 @@ extension GlobalHotkeyManager { return false } + func debugHandlePrimaryShortcutKeyDown(keyCode: UInt16, modifiers: NSEvent.ModifierFlags) -> Bool { + guard let shortcut = self.primaryShortcuts.first(where: { $0.matches(keyCode: keyCode, modifiers: modifiers) }) else { + return false + } + return self.beginPrimaryShortcutPress(ActivePrimaryShortcutPress(shortcut: shortcut)) + } + + func debugHandlePrimaryShortcutKeyUp(keyCode: UInt16) -> Bool { + self.finishPrimaryShortcutPress(keyCode: keyCode) + } + + var debugHasActivePrimaryShortcutPress: Bool { + self.state.withLock { + self.state.activePrimaryShortcutPress != nil + } + } + var debugPrimaryShortcutPressStateIsClear: Bool { self.state.withLock { self.state.activePrimaryShortcutPress == nil diff --git a/Tests/FluidDictationIntegrationTests/HotkeyShortcutTests.swift b/Tests/FluidDictationIntegrationTests/HotkeyShortcutTests.swift index fd6fc40a..40de66bf 100644 --- a/Tests/FluidDictationIntegrationTests/HotkeyShortcutTests.swift +++ b/Tests/FluidDictationIntegrationTests/HotkeyShortcutTests.swift @@ -209,6 +209,31 @@ final class GlobalHotkeyManagerShortcutTests: XCTestCase { XCTAssertEqual(startProbe.count, 0) XCTAssertTrue(manager.debugPrimaryShortcutPressStateIsClear) } + + func testDeletingInactivePrimaryShortcutWithSameKeyDoesNotClearActivePress() { + let commandK = HotkeyShortcut(keyCode: 40, modifierFlags: [.command]) + let optionK = HotkeyShortcut(keyCode: 40, modifierFlags: [.option]) + let manager = GlobalHotkeyManager( + asrService: ASRService(), + primaryShortcuts: [commandK, optionK], + promptModeShortcut: HotkeyShortcut(keyCode: 12, modifierFlags: [.option]), + commandModeShortcut: nil, + rewriteModeShortcut: HotkeyShortcut(keyCode: 14, modifierFlags: [.option]), + promptModeShortcutEnabled: false, + commandModeShortcutEnabled: false, + rewriteModeShortcutEnabled: false, + isDictateRecordingProvider: { true }, + isShortcutCaptureActiveProvider: { false } + ) + + XCTAssertTrue(manager.debugHandlePrimaryShortcutKeyDown(keyCode: 40, modifiers: [.command])) + + manager.updatePrimaryShortcuts([commandK]) + + XCTAssertTrue(manager.debugHasActivePrimaryShortcutPress) + XCTAssertTrue(manager.debugHandlePrimaryShortcutKeyUp(keyCode: 40)) + XCTAssertTrue(manager.debugPrimaryShortcutPressStateIsClear) + } } @MainActor