diff --git a/Sources/Support/PhysicalDictationTriggerPreferences.swift b/Sources/Support/PhysicalDictationTriggerPreferences.swift index 13f0955ef..f4e08aa29 100644 --- a/Sources/Support/PhysicalDictationTriggerPreferences.swift +++ b/Sources/Support/PhysicalDictationTriggerPreferences.swift @@ -274,7 +274,7 @@ enum PhysicalDictationTriggerPreferences { static func bindingForKeyDown(keyCode: UInt32, modifierFlags: NSEvent.ModifierFlags) -> PhysicalDictationTriggerBinding { PhysicalDictationTriggerBinding( keyCode: keyCode, - modifiers: modifiers(from: modifierFlags) + modifiers: modifiers(from: modifierFlags) & ~PhysicalDictationTriggerModifiers.capsLock ) } @@ -296,7 +296,7 @@ enum PhysicalDictationTriggerPreferences { return PhysicalDictationTriggerBinding( keyCode: keyCode, - modifiers: currentModifiers & ~primaryModifier + modifiers: currentModifiers & ~primaryModifier & ~PhysicalDictationTriggerModifiers.capsLock ) } @@ -306,8 +306,10 @@ enum PhysicalDictationTriggerPreferences { modifiers: UInt32 ) -> Bool { guard !isModifierKey(binding.keyCode) else { return false } + // Caps Lock is latched, not held, so it never blocks a chord. + let relevant = PhysicalDictationTriggerModifiers.all & ~PhysicalDictationTriggerModifiers.capsLock return binding.keyCode == keyCode - && binding.modifiers == (modifiers & PhysicalDictationTriggerModifiers.all) + && (binding.modifiers & relevant) == (modifiers & relevant) } static func matchesFlagsChangedPress( @@ -417,7 +419,9 @@ enum PhysicalDictationTriggerPreferences { return false } - return (modifiers & ~primaryModifier) == binding.modifiers + // Caps Lock is latched, not held, so it never blocks a modifier chord. + let ignored = primaryModifier | PhysicalDictationTriggerModifiers.capsLock + return (modifiers & ~ignored) == (binding.modifiers & ~ignored) } private static func modifierChordEventBelongs( @@ -565,7 +569,14 @@ enum PhysicalDictationTriggerPreferences { _ binding: PhysicalDictationTriggerBinding, fallback: PhysicalDictationTriggerBinding ) -> PhysicalDictationTriggerBinding { - rejectionReason(for: binding) == nil ? binding : fallback + // Older builds saved the latched Caps Lock bit with a key. It isn't a + // held modifier, so drop it: otherwise Caps Lock itself "belongs" to + // the chord and Settings labels it "Caps". + let binding = PhysicalDictationTriggerBinding( + keyCode: binding.keyCode, + modifiers: binding.modifiers & ~PhysicalDictationTriggerModifiers.capsLock + ) + return rejectionReason(for: binding) == nil ? binding : fallback } private static func isFunctionKey(_ keyCode: UInt32) -> Bool { diff --git a/Tests/PhysicalDictationTriggerCapsLockTests.swift b/Tests/PhysicalDictationTriggerCapsLockTests.swift new file mode 100644 index 000000000..f87bc26b8 --- /dev/null +++ b/Tests/PhysicalDictationTriggerCapsLockTests.swift @@ -0,0 +1,113 @@ +import Carbon +import CoreGraphics +import Foundation + +// Caps Lock is a latched toggle, not a held modifier. With it on, every event +// tap flag set carries the capsLock bit, and the shortcuts must still fire. +func testPhysicalDictationTriggerCapsLock() { + let option = PhysicalDictationTriggerModifiers.option + let capsLock = PhysicalDictationTriggerModifiers.capsLock + let rightOption = UInt32(kVK_RightOption) + + runSuite("The Right Option dictation key still presses and releases with Caps Lock on") { + let binding = PhysicalDictationTriggerPreferences.defaultPushToTalkBinding + let pressFlags = PhysicalDictationTriggerPreferences.modifiers(from: CGEventFlags([.maskAlternate, .maskAlphaShift])) + let releaseFlags = PhysicalDictationTriggerPreferences.modifiers(from: CGEventFlags([.maskAlphaShift])) + + assertTrue( + PhysicalDictationTriggerPreferences.matchesFlagsChangedPress(binding, keyCode: rightOption, modifiers: pressFlags), + "Right Option down with Caps Lock latched should start dictation" + ) + assertTrue( + PhysicalDictationTriggerPreferences.matchesFlagsChangedRelease(binding, keyCode: rightOption, modifiers: releaseFlags), + "Right Option up with Caps Lock latched should end the hold" + ) + assertFalse( + PhysicalDictationTriggerPreferences.matchesFlagsChangedPress( + binding, + keyCode: rightOption, + modifiers: option | capsLock | PhysicalDictationTriggerModifiers.shift + ), + "a real extra modifier (Shift) still blocks the bare Right Option key" + ) + } + + runSuite("A modifier chord recorded with Caps Lock on does not need Caps Lock to fire") { + let recorded = PhysicalDictationTriggerPreferences.bindingForFlagsChanged( + keyCode: rightOption, + modifierFlags: [.option, .capsLock] + ) + assertEqual( + recorded, + PhysicalDictationTriggerBinding(keyCode: rightOption), + "recording Right Option while Caps Lock is on should save plain Right Option" + ) + } + + runSuite("Option-M and other chord shortcuts still fire with Caps Lock on") { + let meeting = PhysicalDictationTriggerPreferences.defaultMeetingBinding + let flags = PhysicalDictationTriggerPreferences.modifiers(from: CGEventFlags([.maskAlternate, .maskAlphaShift])) + + assertTrue( + PhysicalDictationTriggerPreferences.matchesKeyDown(meeting, keyCode: meeting.keyCode, modifiers: flags), + "Option-M with Caps Lock latched should still start a meeting" + ) + assertFalse( + PhysicalDictationTriggerPreferences.matchesKeyDown( + meeting, + keyCode: meeting.keyCode, + modifiers: option | capsLock | PhysicalDictationTriggerModifiers.command + ), + "a real extra modifier (Command) still keeps Option-M from matching" + ) + + let recorded = PhysicalDictationTriggerPreferences.bindingForKeyDown( + keyCode: meeting.keyCode, + modifierFlags: [.option, .capsLock] + ) + assertEqual(recorded, meeting, "recording Option-M while Caps Lock is on should save plain Option-M") + assertTrue( + PhysicalDictationTriggerPreferences.matchesKeyDown(recorded, keyCode: meeting.keyCode, modifiers: option), + "a chord recorded with Caps Lock on should fire after Caps Lock is turned off" + ) + } + + runSuite("A key saved with the Caps Lock bit by an older build loads without it") { + let suiteName = "PhysicalDictationTriggerCapsLockTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let leftOption = UInt32(kVK_Option) + // Older builds recorded the latched Caps Lock bit with the key. + PhysicalDictationTriggerPreferences.savePushToTalk( + PhysicalDictationTriggerBinding(keyCode: leftOption, modifiers: capsLock), + userDefaults: defaults + ) + let loaded = PhysicalDictationTriggerPreferences.pushToTalkBinding(userDefaults: defaults) + + assertEqual(loaded, PhysicalDictationTriggerBinding(keyCode: leftOption), "the stored Caps Lock bit should be dropped on load") + assertFalse( + PhysicalDictationTriggerPreferences.matchesFlagsChangedPress(loaded, keyCode: UInt32(kVK_CapsLock), modifiers: option | capsLock), + "pressing Caps Lock while holding Left Option must not count as the dictation key" + ) + assertTrue( + PhysicalDictationTriggerPreferences.matchesFlagsChangedPress(loaded, keyCode: leftOption, modifiers: option | capsLock), + "Left Option itself still starts dictation with Caps Lock on" + ) + assertFalse( + PhysicalDictationTriggerPreferences.displayString(for: loaded).contains("Caps"), + "Settings should not label the key with Caps" + ) + + PhysicalDictationTriggerPreferences.saveMeeting( + PhysicalDictationTriggerBinding(keyCode: UInt32(kVK_ANSI_M), modifiers: option | capsLock), + userDefaults: defaults + ) + assertEqual( + PhysicalDictationTriggerPreferences.meetingBinding(userDefaults: defaults), + PhysicalDictationTriggerPreferences.defaultMeetingBinding, + "a stored Option-Caps-M loads as plain Option-M" + ) + } +}