Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 104 additions & 5 deletions Sources/Fluid/Services/GlobalHotkeyManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -346,7 +346,9 @@ final class GlobalHotkeyManager: NSObject {
}

func updatePrimaryShortcuts(_ newShortcuts: [HotkeyShortcut]) {
let removedShortcuts = self.primaryShortcuts.filter { !newShortcuts.contains($0) }
self.primaryShortcuts = newShortcuts
self.clearPrimaryShortcutPressState(removedShortcuts: removedShortcuts)
DebugLogger.shared.info("Updated transcription hotkeys", source: "GlobalHotkeyManager")
}

Expand Down Expand Up @@ -517,18 +519,72 @@ final class GlobalHotkeyManager: NSObject {
}

private nonisolated func clearPrimaryShortcutPressState() {
let task = self.state.withLock { () -> Task<Void, Never>? in
guard self.state.activePrimaryShortcutPress != nil || self.state.isKeyPressed else { return nil }
self.state.activePrimaryShortcutPress = nil
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<Void, Never>] in
var tasks: [Task<Void, Never>] = []
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 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
}
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, forceClear || removedModifierOnlyShortcut {
if let task = self.state.pendingHoldModeStart {
tasks.append(task)
}
self.state.pendingHoldModeStart = nil
self.state.pendingHoldModeType = nil
}

if forceClear || removedActiveShortcut {
self.state.activePrimaryShortcutPress = nil
}
self.state.isKeyPressed = false
self.state.holdModeStartTriggeredTypes.remove(.transcription)
self.state.automaticPressStartTimes.removeValue(forKey: .transcription)
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()
}
}

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
}
task?.cancel()
}

private func markOtherInputDuringModifierOnly() {
Expand Down Expand Up @@ -1930,3 +1986,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
42 changes: 42 additions & 0 deletions Tests/FluidDictationIntegrationTests/HotkeyShortcutTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Loading