diff --git a/Fluid.xcodeproj/project.pbxproj b/Fluid.xcodeproj/project.pbxproj index 30da8f2d..bd4c873b 100644 --- a/Fluid.xcodeproj/project.pbxproj +++ b/Fluid.xcodeproj/project.pbxproj @@ -13,6 +13,7 @@ 7C3697892ED70F9C005874CE /* DynamicNotchKit in Frameworks */ = {isa = PBXBuildFile; productRef = 7C3697882ED70F9C005874CE /* DynamicNotchKit */; }; 7C5AF14B2F15041600DE21B0 /* MediaRemoteAdapter in Frameworks */ = {isa = PBXBuildFile; productRef = 7C5AF14A2F15041600DE21B0 /* MediaRemoteAdapter */; }; 7CDB0A2D2F3C4D5600FB7CAD /* DictationE2ETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CDB0A292F3C4D5600FB7CAD /* DictationE2ETests.swift */; }; + 7CFA0E022F3C4D5600FB7CAD /* GlobalHotkeyManagerSystemShortcutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CFA0E012F3C4D5600FB7CAD /* GlobalHotkeyManagerSystemShortcutTests.swift */; }; 7CDB0A2E2F3C4D5600FB7CAD /* AudioFixtureLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CDB0A2A2F3C4D5600FB7CAD /* AudioFixtureLoader.swift */; }; 7CDB0A2F2F3C4D5600FB7CAD /* dictation_fixture.wav in Resources */ = {isa = PBXBuildFile; fileRef = 7CDB0A2B2F3C4D5600FB7CAD /* dictation_fixture.wav */; }; 7CDB0A302F3C4D5600FB7CAD /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7CDB0A2C2F3C4D5600FB7CAD /* XCTest.framework */; }; @@ -33,6 +34,7 @@ 7C078D8F2E3B339200FB7CAC /* FluidVoice Debug.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "FluidVoice Debug.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 7CDB0A202F3C4D5600FB7CAD /* FluidDictationIntegrationTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = FluidDictationIntegrationTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 7CDB0A292F3C4D5600FB7CAD /* DictationE2ETests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DictationE2ETests.swift; sourceTree = ""; }; + 7CFA0E012F3C4D5600FB7CAD /* GlobalHotkeyManagerSystemShortcutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalHotkeyManagerSystemShortcutTests.swift; sourceTree = ""; }; 7CDB0A2A2F3C4D5600FB7CAD /* AudioFixtureLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioFixtureLoader.swift; sourceTree = ""; }; 7CDB0A2B2F3C4D5600FB7CAD /* dictation_fixture.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; path = dictation_fixture.wav; sourceTree = ""; }; 7CDB0A2C2F3C4D5600FB7CAD /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = Platforms/MacOSX.platform/Developer/Library/Frameworks/XCTest.framework; sourceTree = DEVELOPER_DIR; }; @@ -103,6 +105,7 @@ 7CDB0A262F3C4D5600FB7CAD /* Helpers */, 7CDB0A272F3C4D5600FB7CAD /* Resources */, 7CDB0A292F3C4D5600FB7CAD /* DictationE2ETests.swift */, + 7CFA0E012F3C4D5600FB7CAD /* GlobalHotkeyManagerSystemShortcutTests.swift */, ); path = FluidDictationIntegrationTests; sourceTree = ""; @@ -258,6 +261,7 @@ files = ( 7CDB0A2E2F3C4D5600FB7CAD /* AudioFixtureLoader.swift in Sources */, 7CDB0A2D2F3C4D5600FB7CAD /* DictationE2ETests.swift in Sources */, + 7CFA0E022F3C4D5600FB7CAD /* GlobalHotkeyManagerSystemShortcutTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Sources/Fluid/Services/GlobalHotkeyManager.swift b/Sources/Fluid/Services/GlobalHotkeyManager.swift index 3befca62..1f7ba56f 100644 --- a/Sources/Fluid/Services/GlobalHotkeyManager.swift +++ b/Sources/Fluid/Services/GlobalHotkeyManager.swift @@ -493,40 +493,170 @@ final class GlobalHotkeyManager: NSObject { self.runLoopSource = nil } + /// Virtual key codes (`kVK_*`) for the system shortcuts we fast-path in `handleKeyEvent`. + private enum SystemShortcutKeyCode { + static let tab: UInt16 = 48 // kVK_Tab + static let space: UInt16 = 49 // kVK_Space + static let grave: UInt16 = 50 // kVK_ANSI_Grave (`) + } + + /// Returns `true` for Command-modified *candidate* system switching shortcuts + /// (Cmd+Tab, Cmd+Shift+Tab, Cmd+Space, Cmd+`) that may be passed straight through + /// without running the hotkey-matching machinery. + /// + /// The session event tap fires for every key event system-wide, so doing the full + /// modifier-tracking and shortcut-matching pass on these high-frequency switching + /// shortcuts adds perceptible latency to app switching and Spotlight. That latency + /// compounds when VoiceOver is also intercepting the same events. + /// + /// This decision is *candidate-only*: the shortcut recorder accepts arbitrary keyDown + /// combinations, so a user can bind one of these combos as a FluidVoice shortcut. Use + /// `shouldFastPathSystemShortcut(type:flags:keyCode:configuredShortcuts:)` for the real + /// gate — it suppresses the fast path when the combo collides with a configured shortcut. + nonisolated static func isSystemShortcutPassthrough( + type: CGEventType, + flags: CGEventFlags, + keyCode: UInt16 + ) -> Bool { + guard type == .keyDown || type == .keyUp else { return false } + guard flags.contains(.maskCommand) else { return false } + switch keyCode { + case SystemShortcutKeyCode.tab, SystemShortcutKeyCode.space, SystemShortcutKeyCode.grave: + return true + default: + return false + } + } + + /// Maps `CGEventFlags` to the `NSEvent.ModifierFlags` used for shortcut matching. + nonisolated static func eventModifierFlags(from flags: CGEventFlags) -> NSEvent.ModifierFlags { + var modifiers: NSEvent.ModifierFlags = [] + if flags.contains(.maskSecondaryFn) { modifiers.insert(.function) } + if flags.contains(.maskCommand) { modifiers.insert(.command) } + if flags.contains(.maskAlternate) { modifiers.insert(.option) } + if flags.contains(.maskControl) { modifiers.insert(.control) } + if flags.contains(.maskShift) { modifiers.insert(.shift) } + return modifiers + } + + /// Returns `true` only when a candidate system switching shortcut should actually be + /// fast-pathed — i.e. it is a candidate (`isSystemShortcutPassthrough`) AND it does not + /// collide with any currently-configured (and enabled) FluidVoice shortcut. + /// + /// If the incoming combo matches a configured FluidVoice shortcut, the fast path is + /// suppressed so the event falls through to normal matching and the user's shortcut still + /// fires. The common (non-conflicting) case keeps the latency win. + nonisolated static func shouldFastPathSystemShortcut( + type: CGEventType, + flags: CGEventFlags, + keyCode: UInt16, + configuredShortcuts: [(shortcut: HotkeyShortcut, isEnabled: Bool)] + ) -> Bool { + guard Self.isSystemShortcutPassthrough(type: type, flags: flags, keyCode: keyCode) else { + return false + } + + let modifiers = Self.eventModifierFlags(from: flags) + return !configuredShortcuts.contains { configured in + configured.isEnabled && + configured.shortcut.matches(keyCode: keyCode, modifiers: modifiers) + } + } + + /// Final gate for the fast path, layering the keyUp release-safety check on top of the + /// flag-based collision check. + /// + /// `shouldFastPathSystemShortcut` compares the event's *current* modifier flags against the + /// configured shortcuts, which is correct for keyDown. A keyUp, however, can arrive with fewer + /// modifiers than its keyDown (the user lets go of an extra modifier first, e.g. Shift before + /// Tab in a saved Cmd+Shift+Tab shortcut while Command is still held). The flag-based check then + /// no longer recognizes the configured shortcut, so on its own it would fast-path the release and + /// the keyUp handlers would never clear `is*KeyPressed` or stop the recording. `hasActivePress` + /// reports whether that key code currently has an in-flight press the keyUp handlers must finish; + /// when it does, the release is never fast-pathed regardless of the current modifier flags. + nonisolated static func shouldFastPathSystemShortcut( + type: CGEventType, + flags: CGEventFlags, + keyCode: UInt16, + configuredShortcuts: [(shortcut: HotkeyShortcut, isEnabled: Bool)], + hasActivePress: Bool + ) -> Bool { + guard Self.shouldFastPathSystemShortcut( + type: type, + flags: flags, + keyCode: keyCode, + configuredShortcuts: configuredShortcuts + ) else { + return false + } + // A release with an in-flight press for this key code must reach the keyUp handlers. + if type == .keyUp, hasActivePress { return false } + return true + } + + /// When a modifier-only FluidVoice shortcut key (e.g. a Command-based one) is being held and a + /// *different* key is pressed, record the combo and cancel any pending hold-mode start. The user + /// is performing a key combination (e.g. Cmd+Tab), not holding the modifier on its own, so the + /// pending mode start must be cancelled. + private func cancelPendingHoldModeForComboKeyPress() { + guard self.modifierOnlyKeyDown else { return } + self.otherKeyPressedDuringModifier = true + // Cancel any pending hold mode start (user is doing a key combo, not just modifier) + if let pending = self.pendingHoldModeStart { + pending.cancel() + self.pendingHoldModeStart = nil + self.pendingHoldModeType = nil + DebugLogger.shared.info("Another key pressed - cancelled pending hold mode start", source: "GlobalHotkeyManager") + } + } + // swiftlint:disable cyclomatic_complexity function_body_length private func handleKeyEvent(proxy: CGEventTapProxy, type: CGEventType, event: CGEvent) -> Unmanaged? { if let tapRecoveryResult = self.handleTapDisableEvent(type: type, event: event) { return tapRecoveryResult } + let keyCode = UInt16(event.getIntegerValueField(.keyboardEventKeycode)) + let flags = event.flags + + // Fast path: pass system switching shortcuts straight through before any matching + // work, so app switching / Spotlight stay snappy (especially with VoiceOver active). + // Suppressed when the combo collides with a configured FluidVoice shortcut (e.g. the + // user bound Cmd+Space), so the user's shortcut still fires instead of being shadowed. + // + // The flag-based collision check is correct for keyDown, but a keyUp can carry different + // modifiers than its keyDown (e.g. Shift released before Tab while Command is still held + // for a saved Cmd+Shift+Tab). So on keyUp we additionally suppress the fast path whenever + // that key code has an in-flight press the keyUp handlers below must finish, so the release + // is never stripped before it can clear is*KeyPressed and stop recording. + if Self.shouldFastPathSystemShortcut( + type: type, + flags: flags, + keyCode: keyCode, + configuredShortcuts: self.configuredShortcutsForSystemPassthroughConflict(), + hasActivePress: self.hasActivePressNeedingReleaseHandling(forKeyCode: keyCode) + ) { + // Still record the combo + cancel any pending modifier-only hold start before passing + // through: if a Command-based modifier-only shortcut is held, this Cmd+Tab/Space/` press + // must not look like a clean modifier hold (which would otherwise mis-trigger that mode + // on release or after the hold delay while the user is just switching apps). + if type == .keyDown { + self.cancelPendingHoldModeForComboKeyPress() + } + return Unmanaged.passUnretained(event) + } + if self.isShortcutCaptureActiveProvider?() ?? false { self.resetModifierOnlyShortcutTracking() return Unmanaged.passUnretained(event) } - let keyCode = UInt16(event.getIntegerValueField(.keyboardEventKeycode)) - let flags = event.flags - - var eventModifiers: NSEvent.ModifierFlags = [] - if flags.contains(.maskSecondaryFn) { eventModifiers.insert(.function) } - if flags.contains(.maskCommand) { eventModifiers.insert(.command) } - if flags.contains(.maskAlternate) { eventModifiers.insert(.option) } - if flags.contains(.maskControl) { eventModifiers.insert(.control) } - if flags.contains(.maskShift) { eventModifiers.insert(.shift) } + let eventModifiers = Self.eventModifierFlags(from: flags) switch type { case .keyDown: // If a modifier-only shortcut key is being held and this is a different key, mark it - if self.modifierOnlyKeyDown { - self.otherKeyPressedDuringModifier = true - // Cancel any pending hold mode start (user is doing a key combo, not just modifier) - if let pending = self.pendingHoldModeStart { - pending.cancel() - self.pendingHoldModeStart = nil - self.pendingHoldModeType = nil - DebugLogger.shared.info("Another key pressed - cancelled pending hold mode start", source: "GlobalHotkeyManager") - } - } + self.cancelPendingHoldModeForComboKeyPress() // Observe post-transcription edits (do not consume the event). Task { @@ -1693,6 +1823,57 @@ final class GlobalHotkeyManager: NSObject { } } + /// The set of currently-configured FluidVoice shortcuts (with their enabled state) that the + /// system-shortcut fast path must not silently shadow. A combo that matches an enabled entry + /// here is NOT fast-pathed, so the user's bound shortcut still fires (see + /// `shouldFastPathSystemShortcut`). The main dictation, cancel, and prompt-assignment + /// shortcuts are always active; the prompt/command/rewrite mode shortcuts honor their + /// enabled flags so a disabled mode shortcut does not block the fast path. + private func configuredShortcutsForSystemPassthroughConflict() -> [(shortcut: HotkeyShortcut, isEnabled: Bool)] { + var shortcuts: [(shortcut: HotkeyShortcut, isEnabled: Bool)] = [ + (self.shortcut, true), + (self.promptModeShortcut, self.promptModeShortcutEnabled), + (self.commandModeShortcut, self.commandModeShortcutEnabled), + (self.rewriteModeShortcut, self.rewriteModeShortcutEnabled), + (SettingsStore.shared.cancelRecordingHotkeyShortcut, true), + ] + + shortcuts.append(contentsOf: self.promptShortcutAssignments.map { + (shortcut: $0.shortcut, isEnabled: true) + }) + + return shortcuts + } + + /// Returns `true` if a configured shortcut whose key code is `keyCode` currently has an + /// in-flight press whose release must be handled by the `keyUp` handlers in `handleKeyEvent`. + /// + /// The flag-based conflict check in `shouldFastPathSystemShortcut` is correct for `keyDown`, + /// but a `keyUp` can arrive with different modifier flags than the `keyDown` did (e.g. the user + /// releases Shift before Tab while Command is still held for a saved Cmd+Shift+Tab shortcut). + /// At that point the flag-based check no longer matches the configured shortcut, so without this + /// guard the release would be fast-pathed and the `keyUp` handlers below would never clear the + /// `is*KeyPressed` state or stop the recording. Those handlers match releases by key code only + /// (independent of modifiers), so this mirrors them: if the matching press flag is set, the + /// release is not eligible for the fast path. + private func hasActivePressNeedingReleaseHandling(forKeyCode keyCode: UInt16) -> Bool { + if self.isKeyPressed, keyCode == self.shortcut.keyCode { return true } + if self.isPromptModeKeyPressed, self.promptModeShortcutEnabled, keyCode == self.promptModeShortcut.keyCode { + return true + } + if self.isCommandModeKeyPressed, self.commandModeShortcutEnabled, keyCode == self.commandModeShortcut.keyCode { + return true + } + if self.isRewriteKeyPressed, self.rewriteModeShortcutEnabled, keyCode == self.rewriteModeShortcut.keyCode { + return true + } + if self.isPromptAssignmentKeyPressed, + self.promptShortcutAssignments.contains(where: { $0.shortcut.keyCode == keyCode }) { + return true + } + return false + } + private func matchesShortcut(keyCode: UInt16, modifiers: NSEvent.ModifierFlags) -> Bool { let relevantModifiers: NSEvent.ModifierFlags = modifiers.intersection([.function, .command, .option, .control, .shift]) let shortcutModifiers = self.shortcut.modifierFlags.intersection([.function, .command, .option, .control, .shift]) diff --git a/Sources/Fluid/Views/BottomOverlayView.swift b/Sources/Fluid/Views/BottomOverlayView.swift index 85594c90..b43238a8 100644 --- a/Sources/Fluid/Views/BottomOverlayView.swift +++ b/Sources/Fluid/Views/BottomOverlayView.swift @@ -256,6 +256,11 @@ final class BottomOverlayWindowController { panel.hidesOnDeactivate = false panel.animationBehavior = .none + // Focus stealing is already prevented by the .nonactivatingPanel style mask; we do + // NOT suppress the panel's accessibility element, because this panel hosts interactive + // controls (mode/prompt/actions/settings chips, AI-failure Retry/Dismiss). VoiceOver + // visibility of the passive HUD is handled per-subview inside BottomOverlayView. + let contentView = BottomOverlayView() let hostingView = BottomOverlayHostingView(rootView: contentView) @@ -528,6 +533,10 @@ final class BottomOverlayPromptMenuController { panel.hidesOnDeactivate = false panel.animationBehavior = .none + // Focus stealing is already prevented by the .nonactivatingPanel style mask; we do NOT + // suppress the panel's accessibility element, because this menu panel hosts interactive + // selection rows that VoiceOver users must be able to reach. + let contentView = BottomOverlayPromptMenuView( promptMode: self.resolvedPromptMode(), maxWidth: self.menuMaxWidth, @@ -805,6 +814,10 @@ final class BottomOverlayModeMenuController { panel.hidesOnDeactivate = false panel.animationBehavior = .none + // Focus stealing is already prevented by the .nonactivatingPanel style mask; we do NOT + // suppress the panel's accessibility element, because this menu panel hosts interactive + // selection rows that VoiceOver users must be able to reach. + let contentView = BottomOverlayModeMenuView( maxWidth: self.menuMaxWidth, onHoverChanged: { [weak self] hovering in @@ -1069,6 +1082,10 @@ final class BottomOverlayActionsMenuController { panel.hidesOnDeactivate = false panel.animationBehavior = .none + // Focus stealing is already prevented by the .nonactivatingPanel style mask; we do NOT + // suppress the panel's accessibility element, because this menu panel hosts interactive + // selection rows that VoiceOver users must be able to reach. + let contentView = BottomOverlayActionsMenuView( maxWidth: self.menuMaxWidth, onHoverChanged: { [weak self] hovering in @@ -2582,6 +2599,7 @@ struct BottomOverlayView: View { } .buttonStyle(.plain) .help(help) + .accessibilityLabel(Text(help)) } private var aiProcessingFailureView: some View { @@ -2671,6 +2689,8 @@ struct BottomOverlayView: View { } } } + // Passive transcription preview: hidden from VoiceOver. + .accessibilityHidden(true) } } else { Color.clear @@ -2702,6 +2722,8 @@ struct BottomOverlayView: View { .truncationMode(.head) .frame(maxWidth: .infinity, alignment: .leading) .padding(.vertical, max(2, self.transcriptionVerticalPadding - 1)) + // Passive transcription preview: hidden from VoiceOver. + .accessibilityHidden(true) } else { Text(previewText) .font(.system(size: self.layout.transFontSize, weight: .medium)) @@ -2712,6 +2734,8 @@ struct BottomOverlayView: View { .fixedSize(horizontal: false, vertical: true) .frame(width: self.previewMaxWidth, alignment: .leading) .padding(.vertical, self.transcriptionVerticalPadding) + // Passive transcription preview: hidden from VoiceOver. + .accessibilityHidden(true) } } } else if self.shouldShowProcessingStatus { @@ -2794,6 +2818,8 @@ struct BottomOverlayView: View { } } } + // Passive waveform + mode-label row: hidden from VoiceOver. + .accessibilityHidden(true) } .padding(.horizontal, self.layout.hPadding) .padding(.vertical, self.layout.vPadding) @@ -2983,6 +3009,10 @@ struct BottomOverlayView: View { self.isHoveringActionsChip = false self.isHoveringSettingsChip = false } + // The interactive controls (mode/prompt/actions/settings chips and the AI-failure + // Retry/Dismiss buttons) stay in the accessibility tree so VoiceOver users can reach + // them; only the passive HUD content (transcription preview, waveform/mode row) is + // hidden from VoiceOver below, so it is not announced or focused while dictating. // TODO: Add tap-to-expand for command mode history (future enhancement) // .contentShape(Rectangle()) // .onTapGesture { diff --git a/Tests/FluidDictationIntegrationTests/GlobalHotkeyManagerSystemShortcutTests.swift b/Tests/FluidDictationIntegrationTests/GlobalHotkeyManagerSystemShortcutTests.swift new file mode 100644 index 00000000..630e76a1 --- /dev/null +++ b/Tests/FluidDictationIntegrationTests/GlobalHotkeyManagerSystemShortcutTests.swift @@ -0,0 +1,242 @@ +import AppKit +import CoreGraphics +import XCTest + +@testable import FluidVoice_Debug + +/// Regression coverage for the system-shortcut fast path in `GlobalHotkeyManager`. +/// +/// The event tap fires for every key event system-wide, so Command-modified switching +/// shortcuts (Cmd+Tab, Cmd+Shift+Tab, Cmd+Space, Cmd+`) must be passed straight through +/// before any matching work, otherwise app switching / Spotlight pick up perceptible +/// latency (which compounds when VoiceOver is also intercepting the same events). +/// +/// The fast path must NOT shadow a user's saved shortcut: the recorder accepts arbitrary +/// keyDown combos, so a user can bind e.g. Cmd+Space as a FluidVoice shortcut. The +/// `shouldFastPathSystemShortcut` tests cover that a configured combo falls through to +/// normal matching while the latency win is preserved for the non-conflicting case. +final class GlobalHotkeyManagerSystemShortcutTests: XCTestCase { + // Virtual key codes (kVK_*). + private let tab: UInt16 = 48 + private let space: UInt16 = 49 + private let grave: UInt16 = 50 + private let escape: UInt16 = 53 + + func testCommandSystemShortcutsArePassedThrough() { + for keyCode in [self.tab, self.space, self.grave] { + XCTAssertTrue( + GlobalHotkeyManager.isSystemShortcutPassthrough( + type: .keyDown, flags: .maskCommand, keyCode: keyCode + ), + "Cmd + keyCode \(keyCode) keyDown should be passed straight through" + ) + XCTAssertTrue( + GlobalHotkeyManager.isSystemShortcutPassthrough( + type: .keyUp, flags: .maskCommand, keyCode: keyCode + ), + "Cmd + keyCode \(keyCode) keyUp should be passed straight through" + ) + } + } + + func testCommandShiftTabIsPassedThrough() { + // Cmd+Shift+Tab (reverse app switch): extra modifiers must not defeat the fast path. + XCTAssertTrue( + GlobalHotkeyManager.isSystemShortcutPassthrough( + type: .keyDown, flags: [.maskCommand, .maskShift], keyCode: self.tab + ) + ) + } + + func testNonCommandKeysAreNotPassedThrough() { + // The same keys without Command must still reach the matching machinery. + for keyCode in [self.tab, self.space, self.grave] { + XCTAssertFalse( + GlobalHotkeyManager.isSystemShortcutPassthrough( + type: .keyDown, flags: [], keyCode: keyCode + ) + ) + } + } + + func testUnrelatedCommandShortcutIsNotPassedThrough() { + // e.g. Cmd+Escape is not one of the fast-pathed switching shortcuts. + XCTAssertFalse( + GlobalHotkeyManager.isSystemShortcutPassthrough( + type: .keyDown, flags: .maskCommand, keyCode: self.escape + ) + ) + } + + func testNonKeyEventsAreNotPassedThrough() { + // flagsChanged carries the Command press itself and must flow through normal + // handling so modifier tracking stays consistent. + XCTAssertFalse( + GlobalHotkeyManager.isSystemShortcutPassthrough( + type: .flagsChanged, flags: .maskCommand, keyCode: self.tab + ) + ) + } + + // MARK: - Conflict-aware fast path (shouldFastPathSystemShortcut) + + func testConfiguredCommandSpaceIsNotFastPathed() { + // A user who bound Cmd+Space as a FluidVoice shortcut must keep their shortcut: the + // fast path is suppressed for BOTH keyDown and keyUp so the event reaches normal + // matching (and keyUp release handling) instead of being passed straight through. + let configured: [(shortcut: HotkeyShortcut, isEnabled: Bool)] = [ + (HotkeyShortcut(keyCode: self.space, modifierFlags: [.command]), true), + ] + + XCTAssertFalse( + GlobalHotkeyManager.shouldFastPathSystemShortcut( + type: .keyDown, flags: .maskCommand, keyCode: self.space, configuredShortcuts: configured + ), + "A configured Cmd+Space must not be fast-pathed on keyDown" + ) + XCTAssertFalse( + GlobalHotkeyManager.shouldFastPathSystemShortcut( + type: .keyUp, flags: .maskCommand, keyCode: self.space, configuredShortcuts: configured + ), + "A configured Cmd+Space must not be fast-pathed on keyUp" + ) + } + + func testDisabledConfiguredCommandSpaceStillFastPaths() { + // A disabled mode shortcut bound to Cmd+Space must not block the fast path. + let configured: [(shortcut: HotkeyShortcut, isEnabled: Bool)] = [ + (HotkeyShortcut(keyCode: self.space, modifierFlags: [.command]), false), + ] + + XCTAssertTrue( + GlobalHotkeyManager.shouldFastPathSystemShortcut( + type: .keyDown, flags: .maskCommand, keyCode: self.space, configuredShortcuts: configured + ) + ) + } + + func testNonConflictingSystemShortcutStillFastPaths() { + // The latency win is preserved for the common case: a configured shortcut on a + // different combo (Cmd+`) must not stop Cmd+Space / Cmd+Tab from fast-pathing. + let configured: [(shortcut: HotkeyShortcut, isEnabled: Bool)] = [ + (HotkeyShortcut(keyCode: self.grave, modifierFlags: [.command]), true), + ] + + for keyCode in [self.tab, self.space] { + XCTAssertTrue( + GlobalHotkeyManager.shouldFastPathSystemShortcut( + type: .keyDown, flags: .maskCommand, keyCode: keyCode, configuredShortcuts: configured + ), + "Cmd + keyCode \(keyCode) should still fast-path when only Cmd+` is configured" + ) + } + + // With no configured shortcuts at all, every candidate fast-paths. + XCTAssertTrue( + GlobalHotkeyManager.shouldFastPathSystemShortcut( + type: .keyDown, flags: .maskCommand, keyCode: self.space, configuredShortcuts: [] + ) + ) + } + + func testConfiguredNonSystemShortcutIsNeverFastPathed() { + // Even if a configured shortcut matches the incoming combo, a non-candidate combo + // (Cmd+Escape) is never fast-pathed — the fast path is only for switching shortcuts. + let configured: [(shortcut: HotkeyShortcut, isEnabled: Bool)] = [ + (HotkeyShortcut(keyCode: self.escape, modifierFlags: [.command]), true), + ] + + XCTAssertFalse( + GlobalHotkeyManager.shouldFastPathSystemShortcut( + type: .keyDown, flags: .maskCommand, keyCode: self.escape, configuredShortcuts: configured + ) + ) + } + + func testEventModifierFlagsMapping() { + let modifiers = GlobalHotkeyManager.eventModifierFlags(from: [.maskCommand, .maskShift]) + XCTAssertTrue(modifiers.contains(.command)) + XCTAssertTrue(modifiers.contains(.shift)) + XCTAssertFalse(modifiers.contains(.option)) + XCTAssertFalse(modifiers.contains(.control)) + } + + // MARK: - Release safety for saved shortcuts (hasActivePress overload) + + func testSavedComboReleaseWithReducedModifiersIsNotFastPathed() { + // Saved shortcut is Cmd+Shift+Tab. The user releases Shift first, so the Tab keyUp + // arrives as Cmd-only. The flag-based collision check no longer sees the configured + // shortcut, but an in-flight press exists, so the release MUST NOT be fast-pathed — + // otherwise the keyUp handlers never clear is*KeyPressed or stop the recording. + let configured: [(shortcut: HotkeyShortcut, isEnabled: Bool)] = [ + (HotkeyShortcut(keyCode: self.tab, modifierFlags: [.command, .shift]), true), + ] + + // Documents the underlying hazard: the flag-based check alone would fast-path this + // release because the current flags (Cmd only) no longer match Cmd+Shift+Tab. + XCTAssertTrue( + GlobalHotkeyManager.shouldFastPathSystemShortcut( + type: .keyUp, flags: .maskCommand, keyCode: self.tab, configuredShortcuts: configured + ), + "Flag-based check is expected to miss the saved combo on a reduced-modifier release" + ) + + // The release-safe overload suppresses it because a press is in flight. + XCTAssertFalse( + GlobalHotkeyManager.shouldFastPathSystemShortcut( + type: .keyUp, + flags: .maskCommand, + keyCode: self.tab, + configuredShortcuts: configured, + hasActivePress: true + ), + "A release with an in-flight press must not be fast-pathed" + ) + } + + func testSavedOptionSpaceReleaseWithReducedModifiersIsNotFastPathed() { + // Saved shortcut is Cmd+Option+Space. Option released first, Space keyUp arrives as + // Cmd-only while a press is in flight: the release must reach the keyUp handlers. + let configured: [(shortcut: HotkeyShortcut, isEnabled: Bool)] = [ + (HotkeyShortcut(keyCode: self.space, modifierFlags: [.command, .option]), true), + ] + + XCTAssertFalse( + GlobalHotkeyManager.shouldFastPathSystemShortcut( + type: .keyUp, + flags: .maskCommand, + keyCode: self.space, + configuredShortcuts: configured, + hasActivePress: true + ) + ) + } + + func testReleaseWithoutActivePressStillFastPaths() { + // No in-flight press (e.g. a genuine system Cmd+Tab the user never bound): the keyUp + // keeps the latency win and is fast-pathed. + XCTAssertTrue( + GlobalHotkeyManager.shouldFastPathSystemShortcut( + type: .keyUp, + flags: .maskCommand, + keyCode: self.tab, + configuredShortcuts: [], + hasActivePress: false + ) + ) + } + + func testKeyDownIgnoresActivePressGuard() { + // The release guard is keyUp-only: a keyDown is unaffected by hasActivePress and still + // fast-paths a non-conflicting candidate. + XCTAssertTrue( + GlobalHotkeyManager.shouldFastPathSystemShortcut( + type: .keyDown, + flags: .maskCommand, + keyCode: self.tab, + configuredShortcuts: [], + hasActivePress: true + ) + ) + } +}