diff --git a/Package.swift b/Package.swift index 7ab7e0d..7334e31 100644 --- a/Package.swift +++ b/Package.swift @@ -5,14 +5,23 @@ import PackageDescription let package = Package( name: "teximo", + platforms: [ + .macOS(.v13) + ], targets: [ // Targets are the basic building blocks of a package, defining a module or a test suite. // Targets can depend on other targets in this package and products from dependencies. .executableTarget( name: "teximo", + path: "Sources/Teximo", + exclude: ["AppIcon.png", "Info.plist"], swiftSettings: [ .swiftLanguageVersion(.v5) ] ), + .testTarget( + name: "TeximoTests", + dependencies: ["teximo"] + ), ] ) diff --git a/Sources/Teximo/HotkeyManager.swift b/Sources/Teximo/HotkeyManager.swift index 25887b9..20bf7f6 100644 --- a/Sources/Teximo/HotkeyManager.swift +++ b/Sources/Teximo/HotkeyManager.swift @@ -1,162 +1,426 @@ import Cocoa -import Carbon + +enum HotkeyAction: CaseIterable, Hashable { + case switchLayout + case transliterateText + case toggleCase +} + +enum HotkeyInvocationPhase: Equatable { + case pressed + case released +} + +enum HotkeyInvocationSource: Equatable { + case keyed + case modifierOnly +} + +struct HotkeyInvocation: Equatable { + let action: HotkeyAction + let phase: HotkeyInvocationPhase + let source: HotkeyInvocationSource +} + +enum HotkeyInputEventKind { + case flagsChanged + case keyDown + case keyUp +} + +struct HotkeyInputEvent { + let kind: HotkeyInputEventKind + let modifiers: Set + let keyCode: UInt16? + let isRepeat: Bool +} + +enum HotkeyEventDisposition: Equatable { + case passThrough + case consume +} + +struct HotkeyProcessingResult: Equatable { + let disposition: HotkeyEventDisposition + let invocations: [HotkeyInvocation] +} + +/// Permission-free hotkey state machine used by the event tap and unit tests. +struct HotkeyEventProcessor { + private var activeKeyCodes: [HotkeyAction: UInt16] = [:] + private var activeModifierConfigs: [HotkeyAction: HotkeyConfig] = [:] + + mutating func process( + _ event: HotkeyInputEvent, + configurations: [HotkeyAction: HotkeyConfig] + ) -> HotkeyProcessingResult { + switch event.kind { + case .keyDown: + return processKeyDown(event, configurations: configurations) + case .keyUp: + return processKeyUp(event) + case .flagsChanged: + return processFlagsChanged(event, configurations: configurations) + } + } + + mutating func reset() { + activeKeyCodes.removeAll() + activeModifierConfigs.removeAll() + } + + private mutating func processKeyDown( + _ event: HotkeyInputEvent, + configurations: [HotkeyAction: HotkeyConfig] + ) -> HotkeyProcessingResult { + guard let keyCode = event.keyCode else { + return HotkeyProcessingResult(disposition: .passThrough, invocations: []) + } + + // A matched physical key remains suppressed through auto-repeat, even if + // its modifiers or saved configuration change while it is held. + if activeKeyCodes.values.contains(keyCode) { + return HotkeyProcessingResult(disposition: .consume, invocations: []) + } + + let matchingActions = HotkeyAction.allCases.filter { action in + guard let config = configurations[action], config.keyCode != nil else { return false } + return config.matches(modifiers: event.modifiers, keyCode: keyCode) + } + + guard !matchingActions.isEmpty else { + return HotkeyProcessingResult(disposition: .passThrough, invocations: []) + } + + // An auto-repeat first observed after startup is still consumed, but it + // must not synthesize a new shortcut invocation. + guard !event.isRepeat else { + return HotkeyProcessingResult(disposition: .consume, invocations: []) + } + + for action in matchingActions { + activeKeyCodes[action] = keyCode + } + + let invocations = matchingActions.map { + HotkeyInvocation(action: $0, phase: .pressed, source: .keyed) + } + return HotkeyProcessingResult(disposition: .consume, invocations: invocations) + } + + private mutating func processKeyUp(_ event: HotkeyInputEvent) -> HotkeyProcessingResult { + guard let keyCode = event.keyCode else { + return HotkeyProcessingResult(disposition: .passThrough, invocations: []) + } + + let matchingActions = activeKeyCodes.compactMap { action, activeKeyCode in + activeKeyCode == keyCode ? action : nil + } + guard !matchingActions.isEmpty else { + return HotkeyProcessingResult(disposition: .passThrough, invocations: []) + } + + for action in matchingActions { + activeKeyCodes.removeValue(forKey: action) + } + + // Keyed shortcuts invoke once on key-down. Their key-up is consumed only + // to keep the foreground application from seeing half of the shortcut. + return HotkeyProcessingResult(disposition: .consume, invocations: []) + } + + private mutating func processFlagsChanged( + _ event: HotkeyInputEvent, + configurations: [HotkeyAction: HotkeyConfig] + ) -> HotkeyProcessingResult { + var invocations: [HotkeyInvocation] = [] + + for action in HotkeyAction.allCases { + if let activeConfig = activeModifierConfigs[action] { + if configurations[action] != activeConfig { + activeModifierConfigs.removeValue(forKey: action) + } else if !activeConfig.modifiers.isSubset(of: event.modifiers) { + activeModifierConfigs.removeValue(forKey: action) + invocations.append( + HotkeyInvocation(action: action, phase: .released, source: .modifierOnly) + ) + } + } + + guard activeModifierConfigs[action] == nil, + let config = configurations[action], + config.keyCode == nil, + !config.modifiers.isEmpty, + config.matches(modifiers: event.modifiers, keyCode: nil) else { + continue + } + + activeModifierConfigs[action] = config + invocations.append( + HotkeyInvocation(action: action, phase: .pressed, source: .modifierOnly) + ) + } + + // Caps Lock is recorded as key code 57 even though macOS reports it as a + // flagsChanged event. Treat any exact keyed match here as one invocation. + var matchedKeyedAction = false + if let keyCode = event.keyCode { + for action in HotkeyAction.allCases { + guard let config = configurations[action], + config.keyCode != nil, + config.matches(modifiers: event.modifiers, keyCode: keyCode) else { + continue + } + matchedKeyedAction = true + invocations.append( + HotkeyInvocation(action: action, phase: .pressed, source: .keyed) + ) + } + } + + // Modifier transitions pass through so foreground applications never see + // an incomplete modifier sequence. A keyed flags event (notably Caps Lock) + // is safe to consume as the complete shortcut event. + return HotkeyProcessingResult( + disposition: matchedKeyedAction ? .consume : .passThrough, + invocations: invocations + ) + } +} final class HotkeyManager { - struct Callbacks { - var onCmdShift: (() -> Void)? - var onOptionShift: (() -> Void)? - var onControlShift: (() -> Void)? - } - - private var eventTap: CFMachPort? - private var runLoopSource: CFRunLoopSource? - private var globalMonitor: Any? - private var localMonitor: Any? - private var callbacks = Callbacks() - - private var currentFlags: NSEvent.ModifierFlags = [] - private var lastInvocationDate: Date = .distantPast - private let debounceInterval: TimeInterval = 0.25 - - func start(callbacks: Callbacks) { - print("[Teximo] HotkeyManager.start() called") - // Write to file immediately - let logPath = "/tmp/teximo_debug.log" - let logMessage = "[Teximo] HotkeyManager.start() called\n" - try? logMessage.write(toFile: logPath, atomically: true, encoding: .utf8) - self.callbacks = callbacks - - // Check if we have accessibility permissions first - let hasAccessibility = AccessibilityHelper.ensurePermission(promptIfNeeded: false) - print("[Teximo] HotkeyManager: Accessibility permission: \(hasAccessibility)") - let logMessage3 = "[Teximo] HotkeyManager: Accessibility permission: \(hasAccessibility)\n" - try? logMessage3.write(toFile: logPath, atomically: true, encoding: .utf8) - - guard hasAccessibility else { - print("[Teximo] HotkeyManager: No accessibility permissions, using NSEvent monitors only") - let logMessage2 = "[Teximo] HotkeyManager: No accessibility permissions, using NSEvent monitors only\n" - try? logMessage2.write(toFile: logPath, atomically: true, encoding: .utf8) - installNSEventMonitors() - return - } - - if eventTap == nil { - let mask = CGEventMask(1 << CGEventType.flagsChanged.rawValue) - print("[Teximo] HotkeyManager: Creating event tap with mask: \(mask)") - let tap = CGEvent.tapCreate( - tap: .cgSessionEventTap, - place: .headInsertEventTap, - options: .defaultTap, - eventsOfInterest: mask, - callback: { proxy, type, cgEvent, refcon in - guard type == .flagsChanged else { return Unmanaged.passUnretained(cgEvent) } - let manager = Unmanaged.fromOpaque(refcon!).takeUnretainedValue() - manager.handleFlagsChanged(cgEvent: cgEvent) - return Unmanaged.passUnretained(cgEvent) - }, - userInfo: UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()) - ) - if let tap = tap { - print("[Teximo] HotkeyManager: Event tap created successfully") - // Write to file - let logPath = "/tmp/teximo_debug.log" - let logMessage = "[Teximo] HotkeyManager: Event tap created successfully\n" - try? logMessage.write(toFile: logPath, atomically: true, encoding: .utf8) - self.eventTap = tap - let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0) - self.runLoopSource = source - CFRunLoopAddSource(CFRunLoopGetMain(), source, .commonModes) - CGEvent.tapEnable(tap: tap, enable: true) - print("[Teximo] Event tap installed") - // Write to file - let logMessage2 = "[Teximo] Event tap installed\n" - try? logMessage2.write(toFile: logPath, atomically: true, encoding: .utf8) - } else { - print("[Teximo] Event tap not available; falling back to NSEvent monitors") - // Write to file - let logPath = "/tmp/teximo_debug.log" - let logMessage = "[Teximo] Event tap not available; falling back to NSEvent monitors\n" - try? logMessage.write(toFile: logPath, atomically: true, encoding: .utf8) - installNSEventMonitors() - } - } - } - - func stop() { - if let tap = eventTap { CGEvent.tapEnable(tap: tap, enable: false) } - if let source = runLoopSource { CFRunLoopRemoveSource(CFRunLoopGetMain(), source, .commonModes) } - runLoopSource = nil - eventTap = nil - if let gm = globalMonitor { NSEvent.removeMonitor(gm) } - if let lm = localMonitor { NSEvent.removeMonitor(lm) } - globalMonitor = nil - localMonitor = nil - } - - private func handleFlagsChanged(cgEvent: CGEvent) { - let raw = UInt(cgEvent.flags.rawValue) - let masked = raw & NSEvent.ModifierFlags.deviceIndependentFlagsMask.rawValue - let flags = NSEvent.ModifierFlags(rawValue: masked) - currentFlags = flags - - let hasShift = flags.contains(NSEvent.ModifierFlags.shift) - let hasCmd = flags.contains(NSEvent.ModifierFlags.command) - let hasOpt = flags.contains(NSEvent.ModifierFlags.option) - let hasCtrl = flags.contains(NSEvent.ModifierFlags.control) - - let now = Date() - guard now.timeIntervalSince(lastInvocationDate) > debounceInterval else { return } - - if hasShift && hasCmd && !hasOpt && !hasCtrl { - lastInvocationDate = now - print("[Teximo] HotkeyManager: Triggering Cmd+Shift callback") - callbacks.onCmdShift?() - } else if hasShift && hasOpt && !hasCmd && !hasCtrl { - lastInvocationDate = now - print("[Teximo] HotkeyManager: Triggering Option+Shift callback") - callbacks.onOptionShift?() - } else if hasShift && hasCtrl && !hasCmd && !hasOpt { - lastInvocationDate = now - print("[Teximo] HotkeyManager: Triggering Control+Shift callback") - callbacks.onControlShift?() - } - } - - private func installNSEventMonitors() { - print("[Teximo] HotkeyManager: Installing NSEvent monitors") - globalMonitor = NSEvent.addGlobalMonitorForEvents(matching: .flagsChanged) { [weak self] event in - print("[Teximo] HotkeyManager: Global monitor triggered") - self?.handleFlagsChangedFromNSEvent(event: event) - } - localMonitor = NSEvent.addLocalMonitorForEvents(matching: .flagsChanged) { [weak self] event in - print("[Teximo] HotkeyManager: Local monitor triggered") - self?.handleFlagsChangedFromNSEvent(event: event) - return event - } - print("[Teximo] HotkeyManager: NSEvent monitors installed") - } - - private func handleFlagsChangedFromNSEvent(event: NSEvent) { - let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - currentFlags = flags - let hasShift = flags.contains(.shift) - let hasCmd = flags.contains(.command) - let hasOpt = flags.contains(.option) - let hasCtrl = flags.contains(.control) - let now = Date() - guard now.timeIntervalSince(lastInvocationDate) > debounceInterval else { return } - if hasShift && hasCmd && !hasOpt && !hasCtrl { - lastInvocationDate = now - print("[Teximo] HotkeyManager: Triggering Cmd+Shift callback") - callbacks.onCmdShift?() - } else if hasShift && hasOpt && !hasCmd && !hasCtrl { - lastInvocationDate = now - print("[Teximo] HotkeyManager: Triggering Option+Shift callback") - callbacks.onOptionShift?() - } else if hasShift && hasCtrl && !hasCmd && !hasOpt { - lastInvocationDate = now - print("[Teximo] HotkeyManager: Triggering Control+Shift callback") - callbacks.onControlShift?() - } - } -} \ No newline at end of file + typealias ConfigurationProvider = () -> [HotkeyAction: HotkeyConfig] + typealias InvocationHandler = (HotkeyInvocation) -> Void + + private var eventTap: CFMachPort? + private var eventRunLoop: CFRunLoop? + private var eventThread: Thread? + private var threadStopped: DispatchSemaphore? + private var configurationProvider: ConfigurationProvider? + private var invocationHandler: InvocationHandler? + private var processor = HotkeyEventProcessor() + private var suspended = false + private var generation: UInt = 0 + private let stateLock = NSLock() + + var isSuspended: Bool { + get { + stateLock.lock() + defer { stateLock.unlock() } + return suspended + } + set { + stateLock.lock() + suspended = newValue + if newValue { + processor.reset() + } + stateLock.unlock() + } + } + + @discardableResult + func start( + configurations: @escaping ConfigurationProvider, + onInvocation: @escaping InvocationHandler + ) -> Bool { + precondition(Thread.isMainThread, "HotkeyManager lifecycle must run on the main thread") + stop() + stateLock.lock() + generation &+= 1 + configurationProvider = configurations + invocationHandler = onInvocation + stateLock.unlock() + + let eventTypes: [CGEventType] = [.flagsChanged, .keyDown, .keyUp] + let eventMask = eventTypes.reduce(CGEventMask(0)) { mask, type in + mask | (CGEventMask(1) << type.rawValue) + } + + guard let tap = CGEvent.tapCreate( + tap: .cgSessionEventTap, + place: .headInsertEventTap, + options: .defaultTap, + eventsOfInterest: eventMask, + callback: { _, type, event, userInfo in + guard let userInfo else { + return Unmanaged.passUnretained(event) + } + let manager = Unmanaged + .fromOpaque(userInfo) + .takeUnretainedValue() + return manager.handle(type: type, event: event) + }, + userInfo: Unmanaged.passUnretained(self).toOpaque() + ) else { + stateLock.lock() + configurationProvider = nil + invocationHandler = nil + stateLock.unlock() + print("[Teximo] Failed to create suppressing hotkey event tap") + return false + } + + guard let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0) else { + CFMachPortInvalidate(tap) + stateLock.lock() + configurationProvider = nil + invocationHandler = nil + stateLock.unlock() + print("[Teximo] Failed to create hotkey event tap run-loop source") + return false + } + + let started = DispatchSemaphore(value: 0) + let stopped = DispatchSemaphore(value: 0) + let thread = Thread { [weak self] in + autoreleasepool { + defer { stopped.signal() } + let runLoop = CFRunLoopGetCurrent() + if let manager = self { + manager.stateLock.lock() + manager.eventRunLoop = runLoop + manager.stateLock.unlock() + } + + CFRunLoopAddSource(runLoop, source, .commonModes) + CGEvent.tapEnable(tap: tap, enable: true) + started.signal() + CFRunLoopRun() + CFRunLoopRemoveSource(runLoop, source, .commonModes) + } + } + thread.name = "Teximo Hotkey Event Tap" + + stateLock.lock() + eventTap = tap + eventThread = thread + threadStopped = stopped + stateLock.unlock() + + thread.start() + started.wait() + print("[Teximo] Suppressing hotkey event tap installed") + return true + } + + func stop() { + precondition(Thread.isMainThread, "HotkeyManager lifecycle must run on the main thread") + stateLock.lock() + let tap = eventTap + let runLoop = eventRunLoop + let thread = eventThread + let stopped = threadStopped + eventTap = nil + eventRunLoop = nil + eventThread = nil + threadStopped = nil + configurationProvider = nil + invocationHandler = nil + suspended = false + generation &+= 1 + processor.reset() + stateLock.unlock() + + if let tap { + CGEvent.tapEnable(tap: tap, enable: false) + } + if let runLoop { + CFRunLoopStop(runLoop) + CFRunLoopWakeUp(runLoop) + } + if let tap { + CFMachPortInvalidate(tap) + } + if let thread, let stopped, Thread.current !== thread { + stopped.wait() + } + } + + deinit { + stop() + } + + private func handle(type: CGEventType, event: CGEvent) -> Unmanaged? { + if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput { + stateLock.lock() + processor.reset() + if let eventTap { + CGEvent.tapEnable(tap: eventTap, enable: true) + } + stateLock.unlock() + return Unmanaged.passUnretained(event) + } + + guard event.getIntegerValueField(.eventSourceUnixProcessID) != Int64(ProcessInfo.processInfo.processIdentifier), + let kind = inputKind(for: type) else { + return Unmanaged.passUnretained(event) + } + + let input = HotkeyInputEvent( + kind: kind, + modifiers: modifierKeys(from: event.flags), + keyCode: UInt16(event.getIntegerValueField(.keyboardEventKeycode)), + isRepeat: event.getIntegerValueField(.keyboardEventAutorepeat) != 0 + ) + + stateLock.lock() + guard !suspended, let configurationProvider else { + stateLock.unlock() + return Unmanaged.passUnretained(event) + } + let result = processor.process(input, configurations: configurationProvider()) + let invocationHandler = self.invocationHandler + let invocationGeneration = generation + stateLock.unlock() + + if let invocationHandler { + for invocation in result.invocations { + DispatchQueue.main.async { [weak self] in + guard self?.canDeliverInvocation(for: invocationGeneration) == true else { + return + } + invocationHandler(invocation) + } + } + } + + return Self.tapReturnValue(for: result.disposition, event: event) + } + + static func tapReturnValue( + for disposition: HotkeyEventDisposition, + event: CGEvent + ) -> Unmanaged? { + switch disposition { + case .passThrough: + return Unmanaged.passUnretained(event) + case .consume: + return nil + } + } + + private func canDeliverInvocation(for invocationGeneration: UInt) -> Bool { + stateLock.lock() + defer { stateLock.unlock() } + return generation == invocationGeneration && !suspended && invocationHandler != nil + } + + private func inputKind(for type: CGEventType) -> HotkeyInputEventKind? { + switch type { + case .flagsChanged: return .flagsChanged + case .keyDown: return .keyDown + case .keyUp: return .keyUp + default: return nil + } + } + + private func modifierKeys(from flags: CGEventFlags) -> Set { + var modifiers = Set() + if flags.contains(.maskCommand) { modifiers.insert(.command) } + if flags.contains(.maskShift) { modifiers.insert(.shift) } + if flags.contains(.maskAlternate) { modifiers.insert(.option) } + if flags.contains(.maskControl) { modifiers.insert(.control) } + return modifiers + } +} diff --git a/Sources/Teximo/SettingsWindow.swift b/Sources/Teximo/SettingsWindow.swift index 0877479..c391361 100644 --- a/Sources/Teximo/SettingsWindow.swift +++ b/Sources/Teximo/SettingsWindow.swift @@ -18,6 +18,9 @@ class SettingsWindow: NSWindow { private var keyMonitor: Any? private var flagsMonitor: Any? private var lastModifiers: Set = [] + + var onShortcutRecordingChanged: ((Bool) -> Void)? + var onMenuBarVisibilityChanged: (() -> Void)? init() { super.init( @@ -227,6 +230,7 @@ class SettingsWindow: NSWindow { recordingFor = type previousHotkey = previousValue lastModifiers = [] + onShortcutRecordingChanged?(true) button.title = "Press keys... (Esc to cancel)" keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in self?.handleKeyPress(event, for: type, button: button) @@ -303,6 +307,7 @@ class SettingsWindow: NSWindow { } private func stopRecording() { + let wasRecording = recordingFor != nil if let monitor = keyMonitor { NSEvent.removeMonitor(monitor) keyMonitor = nil @@ -314,6 +319,9 @@ class SettingsWindow: NSWindow { recordingFor = nil previousHotkey = nil lastModifiers = [] + if wasRecording { + onShortcutRecordingChanged?(false) + } } private func getButton(for type: String) -> NSButton? { @@ -428,15 +436,17 @@ class SettingsWindow: NSWindow { caseToggleClearButton.isHidden = TeximoSettings.shared.caseToggleHotkey == nil } - var onMenuBarVisibilityChanged: (() -> Void)? - @objc private func toggleMenuBarIcon(_ sender: NSButton) { TeximoSettings.shared.showMenuBarIcon = (sender.state == .on) onMenuBarVisibilityChanged?() } @objc private func closeWindow() { - stopRecording() self.close() } + + override func close() { + stopRecording() + super.close() + } } diff --git a/Sources/Teximo/SimpleAppDelegate.swift b/Sources/Teximo/SimpleAppDelegate.swift index bff98b5..02deaf2 100644 --- a/Sources/Teximo/SimpleAppDelegate.swift +++ b/Sources/Teximo/SimpleAppDelegate.swift @@ -4,17 +4,10 @@ import ServiceManagement class SimpleAppDelegate: NSObject, NSApplicationDelegate { private var statusItem: NSStatusItem! private let statusMenu = NSMenu() - private var eventMonitor: Any? - private var transliterationTimer: Timer? - private var isSelectingText = false + private let hotkeyManager = HotkeyManager() + private var selectionEventMonitor: Any? private var permissionWindow: AccessibilityPermissionWindow? - // Track modifier state to detect releases - private var previousFlags: NSEvent.ModifierFlags = [] - private var wasLayoutSwitchPressed = false - private var wasTransliterationPressed = false - private var wasCaseTogglePressed = false - // Track press times for release-based triggers private var transliterationPressTime: Date? private var caseTogglePressTime: Date? @@ -65,6 +58,14 @@ class SimpleAppDelegate: NSObject, NSApplicationDelegate { print("[Teximo] SimpleAppDelegate applicationDidFinishLaunching - END") } + + func applicationWillTerminate(_ notification: Notification) { + hotkeyManager.stop() + if let selectionEventMonitor { + NSEvent.removeMonitor(selectionEventMonitor) + } + selectionEventMonitor = nil + } // Handle app reopen (when user clicks app icon or runs 'open' while already running) func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { @@ -142,124 +143,135 @@ class SimpleAppDelegate: NSObject, NSApplicationDelegate { private func setupHotkeyDetection() { print("[Teximo] Setting up hotkey detection") - - // Use NSEvent monitors for global hotkey detection - eventMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.flagsChanged, .keyDown]) { [weak self] event in - if event.type == .flagsChanged { - self?.handleFlagsChanged(event) - } else if event.type == .keyDown { - self?.handleKeyDown(event) + + let installed = hotkeyManager.start( + configurations: { [weak self] in + self?.currentHotkeyConfigurations() ?? [:] + }, + onInvocation: { [weak self] invocation in + self?.handleHotkeyInvocation(invocation) } + ) + + if !installed { + print("[Teximo] Global hotkeys unavailable because the event tap could not be installed") } - - // Also monitor mouse events to detect selection activity - NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDragged, .rightMouseDragged]) { [weak self] _ in - self?.lastSelectionChangeTime = Date() + + // This monitor only tracks selection activity. Hotkey matching and event + // suppression are owned exclusively by HotkeyManager. + selectionEventMonitor = NSEvent.addGlobalMonitorForEvents( + matching: [.keyDown, .leftMouseDragged, .rightMouseDragged] + ) { [weak self] event in + if event.type == .keyDown { + self?.handleKeyDown(event) + } else { + self?.lastSelectionChangeTime = Date() + } } - - if eventMonitor != nil { - print("[Teximo] Global event monitor created") - } else { - print("[Teximo] Failed to create global event monitor") + + if selectionEventMonitor == nil { + print("[Teximo] Failed to create selection activity monitor") } } - - private func handleFlagsChanged(_ event: NSEvent) { - let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - - // Get current hotkey configurations + + private func currentHotkeyConfigurations() -> [HotkeyAction: HotkeyConfig] { let settings = TeximoSettings.shared - - // Check for layout switch hotkey - if let layoutHotkey = settings.layoutSwitchHotkey { - let isLayoutSwitchPressed = layoutHotkey.matches(flags) - if !wasLayoutSwitchPressed && isLayoutSwitchPressed { - // Layout switch just pressed - trigger immediately - print("[Teximo] Layout switch hotkey detected") - let logPath = "/tmp/teximo_debug.log" - let logMessage = "[Teximo] Layout switch hotkey detected\n" - try? logMessage.write(toFile: logPath, atomically: true, encoding: .utf8) - switchLayout() - } - wasLayoutSwitchPressed = isLayoutSwitchPressed - } else { - wasLayoutSwitchPressed = false - } - - // Check for transliteration hotkey - trigger on RELEASE - if let translitHotkey = settings.transliterationHotkey { - let isTransliterationPressed = translitHotkey.matches(flags) - if !wasTransliterationPressed && isTransliterationPressed { - // Transliteration hotkey just pressed - record time - transliterationPressTime = Date() - print("[Teximo] Transliteration hotkey PRESSED") - let logPath = "/tmp/teximo_debug.log" - let logMessage = "[Teximo] Transliteration hotkey PRESSED\n" - try? logMessage.write(toFile: logPath, atomically: true, encoding: .utf8) - } - if wasTransliterationPressed && !isTransliterationPressed { - // Transliteration hotkey released - print("[Teximo] Transliteration hotkey RELEASED") - let logPath = "/tmp/teximo_debug.log" - let logMessage = "[Teximo] Transliteration hotkey RELEASED\n" - try? logMessage.write(toFile: logPath, atomically: true, encoding: .utf8) - - if let pressTime = transliterationPressTime { - let holdDuration = Date().timeIntervalSince(pressTime) - transliterationPressTime = nil - - let timeSinceSelection = Date().timeIntervalSince(lastSelectionChangeTime) - let timeSinceArrow = Date().timeIntervalSince(lastArrowKeyTime) - - print("[Teximo] Transliteration release: hold=\(holdDuration)s, sinceSelection=\(timeSinceSelection)s") - - if holdDuration < 0.5 && timeSinceSelection > 0.3 && timeSinceArrow > 0.3 { - print("[Teximo] Triggering transliteration") - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in - self?.checkAndTransliterateSelectedText() - } - } - } - } - wasTransliterationPressed = isTransliterationPressed - } else { - wasTransliterationPressed = false + var configurations: [HotkeyAction: HotkeyConfig] = [:] + if let config = settings.layoutSwitchHotkey { + configurations[.switchLayout] = config } - - // Check for case toggle hotkey - trigger on RELEASE - if let caseHotkey = settings.caseToggleHotkey { - let isCaseTogglePressed = caseHotkey.matches(flags) - if !wasCaseTogglePressed && isCaseTogglePressed { - // Case toggle hotkey just pressed - record time - caseTogglePressTime = Date() - print("[Teximo] Case toggle hotkey PRESSED") - } - if wasCaseTogglePressed && !isCaseTogglePressed { - // Case toggle hotkey released - print("[Teximo] Case toggle hotkey RELEASED") - - if let pressTime = caseTogglePressTime { - let holdDuration = Date().timeIntervalSince(pressTime) - caseTogglePressTime = nil - - let timeSinceSelection = Date().timeIntervalSince(lastSelectionChangeTime) - let timeSinceArrow = Date().timeIntervalSince(lastArrowKeyTime) - - if holdDuration < 0.5 && timeSinceSelection > 0.3 && timeSinceArrow > 0.3 { - print("[Teximo] Triggering case toggle") - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in - self?.toggleCaseOfSelectedText() - } - } - } - } - wasCaseTogglePressed = isCaseTogglePressed - } else { - wasCaseTogglePressed = false + if let config = settings.transliterationHotkey { + configurations[.transliterateText] = config + } + if let config = settings.caseToggleHotkey { + configurations[.toggleCase] = config + } + return configurations + } + + private func handleHotkeyInvocation(_ invocation: HotkeyInvocation) { + if invocation.source == .keyed { + guard invocation.phase == .pressed else { return } + triggerKeyedAction(invocation.action) + return + } + + switch (invocation.action, invocation.phase) { + case (.switchLayout, .pressed): + triggerLayoutSwitch() + case (.switchLayout, .released): + break + case (.transliterateText, .pressed): + transliterationPressTime = Date() + print("[Teximo] Transliteration hotkey PRESSED") + case (.transliterateText, .released): + handleTransliterationRelease() + case (.toggleCase, .pressed): + caseTogglePressTime = Date() + print("[Teximo] Case toggle hotkey PRESSED") + case (.toggleCase, .released): + handleCaseToggleRelease() + } + } + + private func triggerKeyedAction(_ action: HotkeyAction) { + switch action { + case .switchLayout: + triggerLayoutSwitch() + case .transliterateText: + scheduleTransliterationIfSelectionStable() + case .toggleCase: + scheduleCaseToggleIfSelectionStable() + } + } + + private func triggerLayoutSwitch() { + print("[Teximo] Layout switch hotkey detected") + switchLayout() + } + + private func handleTransliterationRelease() { + print("[Teximo] Transliteration hotkey RELEASED") + guard let pressTime = transliterationPressTime else { return } + transliterationPressTime = nil + + let holdDuration = Date().timeIntervalSince(pressTime) + print("[Teximo] Transliteration release: hold=\(holdDuration)s") + if holdDuration < 0.5 { + scheduleTransliterationIfSelectionStable() + } + } + + private func handleCaseToggleRelease() { + print("[Teximo] Case toggle hotkey RELEASED") + guard let pressTime = caseTogglePressTime else { return } + caseTogglePressTime = nil + + if Date().timeIntervalSince(pressTime) < 0.5 { + scheduleCaseToggleIfSelectionStable() + } + } + + private func scheduleTransliterationIfSelectionStable() { + let timeSinceSelection = Date().timeIntervalSince(lastSelectionChangeTime) + let timeSinceArrow = Date().timeIntervalSince(lastArrowKeyTime) + guard timeSinceSelection > 0.3, timeSinceArrow > 0.3 else { return } + + print("[Teximo] Triggering transliteration") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in + self?.checkAndTransliterateSelectedText() + } + } + + private func scheduleCaseToggleIfSelectionStable() { + let timeSinceSelection = Date().timeIntervalSince(lastSelectionChangeTime) + let timeSinceArrow = Date().timeIntervalSince(lastArrowKeyTime) + guard timeSinceSelection > 0.3, timeSinceArrow > 0.3 else { return } + + print("[Teximo] Triggering case toggle") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in + self?.toggleCaseOfSelectedText() } - - // Update previous flags - previousFlags = flags } private func handleKeyDown(_ event: NSEvent) { @@ -586,6 +598,9 @@ class SimpleAppDelegate: NSObject, NSApplicationDelegate { settingsWindow?.onMenuBarVisibilityChanged = { [weak self] in self?.updateMenuBarVisibility() } + settingsWindow?.onShortcutRecordingChanged = { [weak self] isRecording in + self?.hotkeyManager.isSuspended = isRecording + } } // If menu bar icon is hidden, need to change activation policy to show window diff --git a/Sources/Teximo/SimpleHotkeyDetector.swift b/Sources/Teximo/SimpleHotkeyDetector.swift deleted file mode 100644 index a93fbcd..0000000 --- a/Sources/Teximo/SimpleHotkeyDetector.swift +++ /dev/null @@ -1,38 +0,0 @@ -import Cocoa - -final class SimpleHotkeyDetector { - private var eventMonitor: Any? - private var onCmdShift: (() -> Void)? - - func start(onCmdShift: @escaping () -> Void) { - print("[Teximo] SimpleHotkeyDetector: Starting") - self.onCmdShift = onCmdShift - - // Use a simple approach - monitor for key down events - eventMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.keyDown, .flagsChanged]) { [weak self] event in - self?.handleEvent(event) - } - - print("[Teximo] SimpleHotkeyDetector: Started") - } - - func stop() { - if let monitor = eventMonitor { - NSEvent.removeMonitor(monitor) - eventMonitor = nil - } - } - - private func handleEvent(_ event: NSEvent) { - // Check if Cmd+Shift is pressed - let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - let hasCmd = flags.contains(.command) - let hasShift = flags.contains(.shift) - - if hasCmd && hasShift { - print("[Teximo] SimpleHotkeyDetector: Cmd+Shift detected") - onCmdShift?() - } - } -} - diff --git a/Sources/Teximo/SimpleHotkeyManager.swift b/Sources/Teximo/SimpleHotkeyManager.swift deleted file mode 100644 index cc5f16d..0000000 --- a/Sources/Teximo/SimpleHotkeyManager.swift +++ /dev/null @@ -1,133 +0,0 @@ -import Cocoa - - final class SimpleHotkeyManager { - struct Callbacks { - var onCmdShift: (() -> Void)? - var onOptionShift: (() -> Void)? - var onControlShift: (() -> Void)? - } - - private var callbacks = Callbacks() - private var eventMonitor: Any? - - func start(callbacks: Callbacks) { - print("[Teximo] SimpleHotkeyManager.start() called") - let logPath = "/tmp/teximo_debug.log" - let logMessage = "[Teximo] SimpleHotkeyManager.start() called\n" - try? logMessage.write(toFile: logPath, atomically: true, encoding: .utf8) - - self.callbacks = callbacks - - // Remove existing monitor if any - stop() - - // Check if we have accessibility permissions - let hasAccessibility = AccessibilityHelper.ensurePermission(promptIfNeeded: false) - print("[Teximo] SimpleHotkeyManager: Accessibility permission: \(hasAccessibility)") - let logMessage3 = "[Teximo] SimpleHotkeyManager: Accessibility permission: \(hasAccessibility)\n" - try? logMessage3.write(toFile: logPath, atomically: true, encoding: .utf8) - - // Use NSEvent monitors for global hotkey detection - eventMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.flagsChanged]) { [weak self] event in - print("[Teximo] SimpleHotkeyManager: Global event detected") - let logMessage = "[Teximo] SimpleHotkeyManager: Global event detected\n" - try? logMessage.write(toFile: logPath, atomically: true, encoding: .utf8) - self?.handleFlagsChanged(event) - } - - if eventMonitor != nil { - print("[Teximo] SimpleHotkeyManager: Global event monitor created") - let logMessage = "[Teximo] SimpleHotkeyManager: Global event monitor created\n" - try? logMessage.write(toFile: logPath, atomically: true, encoding: .utf8) - } else { - print("[Teximo] SimpleHotkeyManager: Failed to create global event monitor") - let logMessage = "[Teximo] SimpleHotkeyManager: Failed to create global event monitor\n" - try? logMessage.write(toFile: logPath, atomically: true, encoding: .utf8) - } - - print("[Teximo] SimpleHotkeyManager: NSEvent-based hotkey detection started") - let logMessage2 = "[Teximo] SimpleHotkeyManager: NSEvent-based hotkey detection started\n" - try? logMessage2.write(toFile: logPath, atomically: true, encoding: .utf8) - } - - func stop() { - if let monitor = eventMonitor { - NSEvent.removeMonitor(monitor) - eventMonitor = nil - } - } - - private var lastFlags: NSEvent.ModifierFlags = [] - private var lastInvocationDate: Date = .distantPast - private let debounceInterval: TimeInterval = 0.25 - - private func handleFlagsChanged(_ event: NSEvent) { - let currentFlags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - let hasShift = currentFlags.contains(.shift) - let hasCmd = currentFlags.contains(.command) - let hasOpt = currentFlags.contains(.option) - let hasCtrl = currentFlags.contains(.control) - - print("[Teximo] SimpleHotkeyManager: handleFlagsChanged - Shift: \(hasShift), Cmd: \(hasCmd), Opt: \(hasOpt), Ctrl: \(hasCtrl)") - let logPath = "/tmp/teximo_debug.log" - let logMessage = "[Teximo] SimpleHotkeyManager: handleFlagsChanged - Shift: \(hasShift), Cmd: \(hasCmd), Opt: \(hasOpt), Ctrl: \(hasCtrl)\n" - try? logMessage.write(toFile: logPath, atomically: true, encoding: .utf8) - - // Only process when keys are pressed (not released) - guard currentFlags != lastFlags else { - print("[Teximo] SimpleHotkeyManager: Flags unchanged, skipping") - let logMessage2 = "[Teximo] SimpleHotkeyManager: Flags unchanged, skipping\n" - try? logMessage2.write(toFile: logPath, atomically: true, encoding: .utf8) - return - } - - lastFlags = currentFlags - - // Check if this is a key press (keys were added) - let isKeyPress = (hasShift || hasCmd || hasOpt || hasCtrl) - print("[Teximo] SimpleHotkeyManager: isKeyPress: \(isKeyPress)") - let logMessage3 = "[Teximo] SimpleHotkeyManager: isKeyPress: \(isKeyPress)\n" - try? logMessage3.write(toFile: logPath, atomically: true, encoding: .utf8) - - if isKeyPress { - // Check for shortcuts immediately - print("[Teximo] SimpleHotkeyManager: Calling checkForShortcuts") - let logMessage4 = "[Teximo] SimpleHotkeyManager: Calling checkForShortcuts\n" - try? logMessage4.write(toFile: logPath, atomically: true, encoding: .utf8) - checkForShortcuts() - } - } - - private func checkForShortcuts() { - let currentFlags = NSEvent.modifierFlags.intersection(.deviceIndependentFlagsMask) - let hasShift = currentFlags.contains(.shift) - let hasCmd = currentFlags.contains(.command) - let hasOpt = currentFlags.contains(.option) - let hasCtrl = currentFlags.contains(.control) - - let now = Date() - guard now.timeIntervalSince(lastInvocationDate) > debounceInterval else { return } - - let logPath = "/tmp/teximo_debug.log" - - if hasShift && hasCmd && !hasOpt && !hasCtrl { - lastInvocationDate = now - print("[Teximo] SimpleHotkeyManager: Triggering Cmd+Shift callback") - let logMessage = "[Teximo] SimpleHotkeyManager: Triggering Cmd+Shift callback\n" - try? logMessage.write(toFile: logPath, atomically: true, encoding: .utf8) - callbacks.onCmdShift?() - } else if hasShift && hasOpt && !hasCmd && !hasCtrl { - lastInvocationDate = now - print("[Teximo] SimpleHotkeyManager: Triggering Option+Shift callback") - let logMessage = "[Teximo] SimpleHotkeyManager: Triggering Option+Shift callback\n" - try? logMessage.write(toFile: logPath, atomically: true, encoding: .utf8) - callbacks.onOptionShift?() - } else if hasShift && hasCtrl && !hasCmd && !hasOpt { - lastInvocationDate = now - print("[Teximo] SimpleHotkeyManager: Triggering Control+Shift callback") - let logMessage = "[Teximo] SimpleHotkeyManager: Triggering Control+Shift callback\n" - try? logMessage.write(toFile: logPath, atomically: true, encoding: .utf8) - callbacks.onControlShift?() - } - } -} diff --git a/Sources/Teximo/TeximoSettings.swift b/Sources/Teximo/TeximoSettings.swift index 5894c22..3ef3864 100644 --- a/Sources/Teximo/TeximoSettings.swift +++ b/Sources/Teximo/TeximoSettings.swift @@ -13,24 +13,9 @@ struct HotkeyConfig: Codable, Equatable { self.enabled = enabled } - // Check if NSEvent.ModifierFlags matches this config - func matches(_ flags: NSEvent.ModifierFlags) -> Bool { + func matches(modifiers eventModifiers: Set, keyCode eventKeyCode: UInt16?) -> Bool { guard enabled else { return false } - - let hasCommand = flags.contains(.command) - let hasShift = flags.contains(.shift) - let hasOption = flags.contains(.option) - let hasControl = flags.contains(.control) - - let wantCommand = modifiers.contains(.command) - let wantShift = modifiers.contains(.shift) - let wantOption = modifiers.contains(.option) - let wantControl = modifiers.contains(.control) - - return hasCommand == wantCommand && - hasShift == wantShift && - hasOption == wantOption && - hasControl == wantControl + return modifiers == eventModifiers && keyCode == eventKeyCode } var displayString: String { diff --git a/Sources/Teximo/WorkingHotkeyManager.swift b/Sources/Teximo/WorkingHotkeyManager.swift deleted file mode 100644 index c686c10..0000000 --- a/Sources/Teximo/WorkingHotkeyManager.swift +++ /dev/null @@ -1,127 +0,0 @@ -import Cocoa - -final class WorkingHotkeyManager { - struct Callbacks { - var onCmdShift: (() -> Void)? - var onOptionShift: (() -> Void)? - var onControlShift: (() -> Void)? - } - - private var callbacks = Callbacks() - private var eventMonitor: Any? - private var lastFlags: NSEvent.ModifierFlags = [] - private var lastInvocationDate: Date = .distantPast - private let debounceInterval: TimeInterval = 0.25 - - func start(callbacks: Callbacks) { - print("[Teximo] WorkingHotkeyManager.start() called") - let logPath = "/tmp/teximo_debug.log" - let logMessage = "[Teximo] WorkingHotkeyManager.start() called\n" - try? logMessage.write(toFile: logPath, atomically: true, encoding: .utf8) - - self.callbacks = callbacks - - // Remove existing monitor if any - stop() - - // Use NSEvent monitors for global hotkey detection - eventMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.flagsChanged]) { [weak self] event in - print("[Teximo] WorkingHotkeyManager: Global event detected") - let logMessage = "[Teximo] WorkingHotkeyManager: Global event detected\n" - try? logMessage.write(toFile: logPath, atomically: true, encoding: .utf8) - self?.handleFlagsChanged(event) - } - - if eventMonitor != nil { - print("[Teximo] WorkingHotkeyManager: Global event monitor created") - let logMessage = "[Teximo] WorkingHotkeyManager: Global event monitor created\n" - try? logMessage.write(toFile: logPath, atomically: true, encoding: .utf8) - } else { - print("[Teximo] WorkingHotkeyManager: Failed to create global event monitor") - let logMessage = "[Teximo] WorkingHotkeyManager: Failed to create global event monitor\n" - try? logMessage.write(toFile: logPath, atomically: true, encoding: .utf8) - } - - print("[Teximo] WorkingHotkeyManager: NSEvent-based hotkey detection started") - let logMessage2 = "[Teximo] WorkingHotkeyManager: NSEvent-based hotkey detection started\n" - try? logMessage2.write(toFile: logPath, atomically: true, encoding: .utf8) - } - - func stop() { - if let monitor = eventMonitor { - NSEvent.removeMonitor(monitor) - eventMonitor = nil - } - } - - private func handleFlagsChanged(_ event: NSEvent) { - let currentFlags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - let hasShift = currentFlags.contains(.shift) - let hasCmd = currentFlags.contains(.command) - let hasOpt = currentFlags.contains(.option) - let hasCtrl = currentFlags.contains(.control) - - print("[Teximo] WorkingHotkeyManager: handleFlagsChanged - Shift: \(hasShift), Cmd: \(hasCmd), Opt: \(hasOpt), Ctrl: \(hasCtrl)") - let logPath = "/tmp/teximo_debug.log" - let logMessage = "[Teximo] WorkingHotkeyManager: handleFlagsChanged - Shift: \(hasShift), Cmd: \(hasCmd), Opt: \(hasOpt), Ctrl: \(hasCtrl)\n" - try? logMessage.write(toFile: logPath, atomically: true, encoding: .utf8) - - // Only process when keys are pressed (not released) - guard currentFlags != lastFlags else { - print("[Teximo] WorkingHotkeyManager: Flags unchanged, skipping") - let logMessage2 = "[Teximo] WorkingHotkeyManager: Flags unchanged, skipping\n" - try? logMessage2.write(toFile: logPath, atomically: true, encoding: .utf8) - return - } - - lastFlags = currentFlags - - // Check if this is a key press (keys were added) - let isKeyPress = (hasShift || hasCmd || hasOpt || hasCtrl) - print("[Teximo] WorkingHotkeyManager: isKeyPress: \(isKeyPress)") - let logMessage3 = "[Teximo] WorkingHotkeyManager: isKeyPress: \(isKeyPress)\n" - try? logMessage3.write(toFile: logPath, atomically: true, encoding: .utf8) - - if isKeyPress { - // Check for shortcuts immediately - print("[Teximo] WorkingHotkeyManager: Calling checkForShortcuts") - let logMessage4 = "[Teximo] WorkingHotkeyManager: Calling checkForShortcuts\n" - try? logMessage4.write(toFile: logPath, atomically: true, encoding: .utf8) - checkForShortcuts() - } - } - - private func checkForShortcuts() { - let currentFlags = NSEvent.modifierFlags.intersection(.deviceIndependentFlagsMask) - let hasShift = currentFlags.contains(.shift) - let hasCmd = currentFlags.contains(.command) - let hasOpt = currentFlags.contains(.option) - let hasCtrl = currentFlags.contains(.control) - - let now = Date() - guard now.timeIntervalSince(lastInvocationDate) > debounceInterval else { return } - - let logPath = "/tmp/teximo_debug.log" - - if hasShift && hasCmd && !hasOpt && !hasCtrl { - lastInvocationDate = now - print("[Teximo] WorkingHotkeyManager: Triggering Cmd+Shift callback") - let logMessage = "[Teximo] WorkingHotkeyManager: Triggering Cmd+Shift callback\n" - try? logMessage.write(toFile: logPath, atomically: true, encoding: .utf8) - callbacks.onCmdShift?() - } else if hasShift && hasOpt && !hasCmd && !hasCtrl { - lastInvocationDate = now - print("[Teximo] WorkingHotkeyManager: Triggering Option+Shift callback") - let logMessage = "[Teximo] WorkingHotkeyManager: Triggering Option+Shift callback\n" - try? logMessage.write(toFile: logPath, atomically: true, encoding: .utf8) - callbacks.onOptionShift?() - } else if hasShift && hasCtrl && !hasCmd && !hasOpt { - lastInvocationDate = now - print("[Teximo] WorkingHotkeyManager: Triggering Control+Shift callback") - let logMessage = "[Teximo] WorkingHotkeyManager: Triggering Control+Shift callback\n" - try? logMessage.write(toFile: logPath, atomically: true, encoding: .utf8) - callbacks.onControlShift?() - } - } -} - diff --git a/Tests/TeximoTests/HotkeyEventProcessorTests.swift b/Tests/TeximoTests/HotkeyEventProcessorTests.swift new file mode 100644 index 0000000..9caa3fb --- /dev/null +++ b/Tests/TeximoTests/HotkeyEventProcessorTests.swift @@ -0,0 +1,180 @@ +import CoreGraphics +import XCTest +@testable import teximo + +final class HotkeyEventProcessorTests: XCTestCase { + private let commandSpace: [HotkeyAction: HotkeyConfig] = [ + .switchLayout: HotkeyConfig(modifiers: [.command], keyCode: 49) + ] + + func testCommandAloneDoesNotTriggerCommandSpace() { + var processor = HotkeyEventProcessor() + + let result = processor.process( + event(.flagsChanged, modifiers: [.command], keyCode: 55), + configurations: commandSpace + ) + + XCTAssertEqual(result.disposition, .passThrough) + XCTAssertTrue(result.invocations.isEmpty) + } + + func testCommandSpaceTriggersExactlyOnce() { + var processor = HotkeyEventProcessor() + + let keyDown = processor.process( + event(.keyDown, modifiers: [.command], keyCode: 49), + configurations: commandSpace + ) + let repeatedKeyDown = processor.process( + event(.keyDown, modifiers: [.command], keyCode: 49, isRepeat: true), + configurations: commandSpace + ) + let keyUp = processor.process( + event(.keyUp, modifiers: [.command], keyCode: 49), + configurations: commandSpace + ) + + XCTAssertEqual( + keyDown.invocations, + [HotkeyInvocation(action: .switchLayout, phase: .pressed, source: .keyed)] + ) + XCTAssertTrue(repeatedKeyDown.invocations.isEmpty) + XCTAssertTrue(keyUp.invocations.isEmpty) + } + + func testReleasingCommandDoesNotTrigger() { + var processor = HotkeyEventProcessor() + + let commandPressed = processor.process( + event(.flagsChanged, modifiers: [.command], keyCode: 55), + configurations: commandSpace + ) + let commandReleased = processor.process( + event(.flagsChanged, modifiers: [], keyCode: 55), + configurations: commandSpace + ) + + XCTAssertEqual(commandPressed.disposition, .passThrough) + XCTAssertTrue(commandPressed.invocations.isEmpty) + XCTAssertEqual(commandReleased.disposition, .passThrough) + XCTAssertTrue(commandReleased.invocations.isEmpty) + } + + func testUnrelatedKeyWithCommandDoesNotTrigger() { + var processor = HotkeyEventProcessor() + + let result = processor.process( + event(.keyDown, modifiers: [.command], keyCode: 0), + configurations: commandSpace + ) + + XCTAssertEqual(result.disposition, .passThrough) + XCTAssertTrue(result.invocations.isEmpty) + } + + func testSameKeyWithExtraModifierDoesNotTrigger() { + var processor = HotkeyEventProcessor() + + let result = processor.process( + event(.keyDown, modifiers: [.command, .shift], keyCode: 49), + configurations: commandSpace + ) + + XCTAssertEqual(result.disposition, .passThrough) + XCTAssertTrue(result.invocations.isEmpty) + } + + func testMatchedShortcutEventsAreConsumed() { + var processor = HotkeyEventProcessor() + + let keyDown = processor.process( + event(.keyDown, modifiers: [.command], keyCode: 49), + configurations: commandSpace + ) + let repeatedKeyDown = processor.process( + event(.keyDown, modifiers: [.command], keyCode: 49, isRepeat: true), + configurations: commandSpace + ) + let keyUp = processor.process( + event(.keyUp, modifiers: [], keyCode: 49), + configurations: commandSpace + ) + + XCTAssertEqual(keyDown.disposition, .consume) + XCTAssertEqual(repeatedKeyDown.disposition, .consume) + XCTAssertEqual(keyUp.disposition, .consume) + } + + func testConsumeDispositionReturnsNilFromEventTapAdapter() throws { + let event = try XCTUnwrap( + CGEvent(keyboardEventSource: nil, virtualKey: 49, keyDown: true) + ) + + XCTAssertNil(HotkeyManager.tapReturnValue(for: .consume, event: event)) + XCTAssertNotNil(HotkeyManager.tapReturnValue(for: .passThrough, event: event)) + } + + func testModifierOnlyShortcutStillTriggersOnPressAndRelease() { + var processor = HotkeyEventProcessor() + let configurations: [HotkeyAction: HotkeyConfig] = [ + .switchLayout: HotkeyConfig(modifiers: [.command, .shift]) + ] + + let pressed = processor.process( + event(.flagsChanged, modifiers: [.command, .shift], keyCode: 56), + configurations: configurations + ) + let unchanged = processor.process( + event(.flagsChanged, modifiers: [.command, .shift], keyCode: 55), + configurations: configurations + ) + let released = processor.process( + event(.flagsChanged, modifiers: [.command], keyCode: 56), + configurations: configurations + ) + + XCTAssertEqual(pressed.disposition, .passThrough) + XCTAssertEqual( + pressed.invocations, + [HotkeyInvocation(action: .switchLayout, phase: .pressed, source: .modifierOnly)] + ) + XCTAssertTrue(unchanged.invocations.isEmpty) + XCTAssertEqual( + released.invocations, + [HotkeyInvocation(action: .switchLayout, phase: .released, source: .modifierOnly)] + ) + } + + func testClearedModifierOnlyShortcutDoesNotTriggerOnRelease() { + var processor = HotkeyEventProcessor() + let configurations: [HotkeyAction: HotkeyConfig] = [ + .switchLayout: HotkeyConfig(modifiers: [.command, .shift]) + ] + + _ = processor.process( + event(.flagsChanged, modifiers: [.command, .shift], keyCode: 56), + configurations: configurations + ) + let releasedAfterClearing = processor.process( + event(.flagsChanged, modifiers: [.command], keyCode: 56), + configurations: [:] + ) + + XCTAssertTrue(releasedAfterClearing.invocations.isEmpty) + } + + private func event( + _ kind: HotkeyInputEventKind, + modifiers: Set, + keyCode: UInt16, + isRepeat: Bool = false + ) -> HotkeyInputEvent { + HotkeyInputEvent( + kind: kind, + modifiers: modifiers, + keyCode: keyCode, + isRepeat: isRepeat + ) + } +} diff --git a/Teximo.xcodeproj/project.pbxproj b/Teximo.xcodeproj/project.pbxproj index 502737c..3cd3f11 100644 --- a/Teximo.xcodeproj/project.pbxproj +++ b/Teximo.xcodeproj/project.pbxproj @@ -20,17 +20,13 @@ 781BD5BF9F6D9168364A8C20 /* HotkeyManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B393DBE54001F2FD04F23F07 /* HotkeyManager.swift */; }; 82511E26FB4A956AE299C45B /* AppIconGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 606BCA7E6DDAD523FD3ED921 /* AppIconGenerator.swift */; }; 8D79ACE5DE14C4572E7DE940 /* AccessibilityHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98EFA333F86EA62BB228E9B3 /* AccessibilityHelper.swift */; }; - 9733938794B5AB766A6747EF /* WorkingHotkeyManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CB974F0117D2989A1F44476 /* WorkingHotkeyManager.swift */; }; B6BD3B3D56479FA54FD38B68 /* SettingsWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07088AA567FFF8828CD0007D /* SettingsWindow.swift */; }; B7B00E98D3C23630FDF14717 /* AccessibilityPermissionWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6C8D2685726CBC257BBFA05 /* AccessibilityPermissionWindow.swift */; }; BCAB34B93D8BDF4B3944BF8B /* AppIcon.png in Resources */ = {isa = PBXBuildFile; fileRef = E518EA0FC234F6411EED1F4B /* AppIcon.png */; }; - D7D0036A43B5A151D61470D9 /* SimpleHotkeyDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 064A97C6635E16FC83598C29 /* SimpleHotkeyDetector.swift */; }; EDC581511B8EC6D69ADB87FF /* SimpleAppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D0A5EC2C5FF2CEF75110B7A /* SimpleAppDelegate.swift */; }; - F9AE8FC86F574ADB9141DC91 /* SimpleHotkeyManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6D82A1E8638FC92C1A251A2 /* SimpleHotkeyManager.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ - 064A97C6635E16FC83598C29 /* SimpleHotkeyDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleHotkeyDetector.swift; sourceTree = ""; }; 07088AA567FFF8828CD0007D /* SettingsWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsWindow.swift; sourceTree = ""; }; 0C33241A46F5460DC6F7E18B /* NotchDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchDetector.swift; sourceTree = ""; }; 37B44F63991DF2D38B5A59E2 /* TextActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextActions.swift; sourceTree = ""; }; @@ -40,8 +36,6 @@ 8D0A5EC2C5FF2CEF75110B7A /* SimpleAppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleAppDelegate.swift; sourceTree = ""; }; 98EFA333F86EA62BB228E9B3 /* AccessibilityHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessibilityHelper.swift; sourceTree = ""; }; 9B974AFB475F652B5B938348 /* SimpleLayoutSwitcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleLayoutSwitcher.swift; sourceTree = ""; }; - 9CB974F0117D2989A1F44476 /* WorkingHotkeyManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkingHotkeyManager.swift; sourceTree = ""; }; - A6D82A1E8638FC92C1A251A2 /* SimpleHotkeyManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleHotkeyManager.swift; sourceTree = ""; }; AA12020CAAF47A304C276686 /* Teximo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Teximo.app; sourceTree = BUILT_PRODUCTS_DIR; }; B393DBE54001F2FD04F23F07 /* HotkeyManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotkeyManager.swift; sourceTree = ""; }; C7ABB5C6DD5EC1A722387F84 /* LayoutEnumerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LayoutEnumerator.swift; sourceTree = ""; }; @@ -80,13 +74,10 @@ 0C33241A46F5460DC6F7E18B /* NotchDetector.swift */, 07088AA567FFF8828CD0007D /* SettingsWindow.swift */, 8D0A5EC2C5FF2CEF75110B7A /* SimpleAppDelegate.swift */, - 064A97C6635E16FC83598C29 /* SimpleHotkeyDetector.swift */, - A6D82A1E8638FC92C1A251A2 /* SimpleHotkeyManager.swift */, 9B974AFB475F652B5B938348 /* SimpleLayoutSwitcher.swift */, DE561A6FB141A0EECA4C4597 /* TeximoSettings.swift */, 37B44F63991DF2D38B5A59E2 /* TextActions.swift */, F91586DB4C4F5A786B5F2AF5 /* Transliterator.swift */, - 9CB974F0117D2989A1F44476 /* WorkingHotkeyManager.swift */, ); name = Teximo; path = Sources/Teximo; @@ -181,13 +172,10 @@ 30FE739D63B0C029125D6EE7 /* NotchDetector.swift in Sources */, B6BD3B3D56479FA54FD38B68 /* SettingsWindow.swift in Sources */, EDC581511B8EC6D69ADB87FF /* SimpleAppDelegate.swift in Sources */, - D7D0036A43B5A151D61470D9 /* SimpleHotkeyDetector.swift in Sources */, - F9AE8FC86F574ADB9141DC91 /* SimpleHotkeyManager.swift in Sources */, 6BB05F09A4141D8E33E51504 /* SimpleLayoutSwitcher.swift in Sources */, 359886F2DFBC0884202DBAB6 /* TeximoSettings.swift in Sources */, 2A51C6406D362113DABE2CF1 /* TextActions.swift in Sources */, 4C8F88D8AC11EEE74D7525A5 /* Transliterator.swift in Sources */, - 9733938794B5AB766A6747EF /* WorkingHotkeyManager.swift in Sources */, 735E7838B6BEEDA0B7EF3807 /* main.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0;