diff --git a/MiddleDrag.xcodeproj/project.pbxproj b/MiddleDrag.xcodeproj/project.pbxproj index c725806..17cce9f 100644 --- a/MiddleDrag.xcodeproj/project.pbxproj +++ b/MiddleDrag.xcodeproj/project.pbxproj @@ -27,6 +27,8 @@ EC93816B2EDE493A0012FDBE /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = EC93816A2EDE493A0012FDBE /* Sentry */; }; MDT00000001 /* GestureModelsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = MDT00000003 /* GestureModelsTests.swift */; }; MDT00000002 /* TouchModelsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = MDT00000004 /* TouchModelsTests.swift */; }; + MDT00000016 /* GestureRecognizerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = MDT00000017 /* GestureRecognizerTests.swift */; }; + MDT00000018 /* GestureRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC5F1DFF2ED8E37A0053F6A9 /* GestureRecognizer.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -62,6 +64,7 @@ EC5F1E242ED8E37A0053F6A9 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; MDT00000003 /* GestureModelsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GestureModelsTests.swift; sourceTree = ""; }; MDT00000004 /* TouchModelsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TouchModelsTests.swift; sourceTree = ""; }; + MDT00000017 /* GestureRecognizerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GestureRecognizerTests.swift; sourceTree = ""; }; MDT00000005 /* MiddleDragTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MiddleDragTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -173,6 +176,7 @@ children = ( MDT00000003 /* GestureModelsTests.swift */, MDT00000004 /* TouchModelsTests.swift */, + MDT00000017 /* GestureRecognizerTests.swift */, ); path = MiddleDragTests; sourceTree = ""; @@ -325,6 +329,8 @@ files = ( MDT00000001 /* GestureModelsTests.swift in Sources */, MDT00000002 /* TouchModelsTests.swift in Sources */, + MDT00000016 /* GestureRecognizerTests.swift in Sources */, + MDT00000018 /* GestureRecognizer.swift in Sources */, EC5522602EF5441F00BBEF33 /* GestureModels.swift in Sources */, EC5522612EF5441F00BBEF33 /* TouchModels.swift in Sources */, ); diff --git a/MiddleDrag/Core/GestureRecognizer.swift b/MiddleDrag/Core/GestureRecognizer.swift index 6ad260f..650c761 100644 --- a/MiddleDrag/Core/GestureRecognizer.swift +++ b/MiddleDrag/Core/GestureRecognizer.swift @@ -1,3 +1,4 @@ +import Cocoa import CoreGraphics import Foundation @@ -36,17 +37,63 @@ class GestureRecognizer { /// - touches: Raw pointer to touch data array /// - count: Number of touches in the array /// - timestamp: Timestamp of the touch frame - func processTouches(_ touches: UnsafeMutableRawPointer, count: Int, timestamp: Double) { + /// - modifierFlags: Current modifier key flags (captured on main thread by caller) + func processTouches( + _ touches: UnsafeMutableRawPointer, count: Int, timestamp: Double, + modifierFlags: CGEventFlags + ) { let touchArray = touches.bindMemory(to: MTTouch.self, capacity: count) + // Check modifier key requirement first (if enabled) + if configuration.requireModifierKey { + let requiredFlagPresent: Bool + switch configuration.modifierKeyType { + case .shift: + requiredFlagPresent = modifierFlags.contains(.maskShift) + case .control: + requiredFlagPresent = modifierFlags.contains(.maskControl) + case .option: + requiredFlagPresent = modifierFlags.contains(.maskAlternate) + case .command: + requiredFlagPresent = modifierFlags.contains(.maskCommand) + } + + if !requiredFlagPresent { + // Required modifier not held - cancel any active gesture and return + if state != .idle { + handleGestureCancel() + } + return + } + } + // Collect only valid touching fingers (state 3 = touching down, state 4 = active) // Skip state 5 (lifting), 6 (lingering), 7 (gone) + // Apply palm rejection filters var validFingers: [MTPoint] = [] for i in 0.. configuration.maxContactSize { + continue // Skip this touch - likely a palm + } + } + + validFingers.append(position) } } diff --git a/MiddleDrag/Managers/MultitouchManager.swift b/MiddleDrag/Managers/MultitouchManager.swift index bb3a5df..8ce5219 100644 --- a/MiddleDrag/Managers/MultitouchManager.swift +++ b/MiddleDrag/Managers/MultitouchManager.swift @@ -222,10 +222,16 @@ extension MultitouchManager: DeviceMonitorDelegate { ) { guard isEnabled else { return } + // Capture modifier flags before dispatching to gesture queue + // Note: This callback runs on a framework-managed background thread, not main thread + // CGEventSource.flagsState is thread-safe and can be called from any thread + let modifierFlags = CGEventSource.flagsState(.hidSystemState) + // Gesture recognition and finger counting is done inside processTouches // State updates happen in delegate callbacks dispatched to main thread gestureQueue.async { [weak self] in - self?.gestureRecognizer.processTouches(touches, count: Int(count), timestamp: timestamp) + self?.gestureRecognizer.processTouches( + touches, count: Int(count), timestamp: timestamp, modifierFlags: modifierFlags) } } } @@ -262,7 +268,8 @@ extension MultitouchManager: GestureRecognizerDelegate { mouseGenerator.startDrag(at: mouseLocation) } - func gestureRecognizerDidUpdateDragging(_ recognizer: GestureRecognizer, with data: GestureData) { + func gestureRecognizerDidUpdateDragging(_ recognizer: GestureRecognizer, with data: GestureData) + { guard configuration.middleDragEnabled else { return } let delta = data.frameDelta(from: configuration) diff --git a/MiddleDrag/Models/GestureModels.swift b/MiddleDrag/Models/GestureModels.swift index 684ea0f..dfa3dff 100644 --- a/MiddleDrag/Models/GestureModels.swift +++ b/MiddleDrag/Models/GestureModels.swift @@ -8,7 +8,7 @@ enum GestureState { case possibleTap case dragging case waitingForRelease - + var isActive: Bool { switch self { case .dragging, .possibleTap: @@ -26,36 +26,69 @@ struct GestureConfiguration { // Sensitivity and smoothing var sensitivity: Float = 1.0 var smoothingFactor: Float = 0.3 - + // Timing thresholds var tapThreshold: Double = 0.15 // 150ms for tap detection var moveThreshold: Float = 0.015 // Movement threshold for tap vs drag - + // Finger requirements - @available(*, deprecated, message: "Always requires exactly 3 fingers now to support Mission Control") + @available( + *, deprecated, message: "Always requires exactly 3 fingers now to support Mission Control" + ) var requiresExactlyThreeFingers: Bool = true var blockSystemGestures: Bool = false - + // Feature toggles var middleDragEnabled: Bool = true // Allow disabling drag while keeping tap - + // Velocity scaling var enableVelocityBoost: Bool = true var maxVelocityBoost: Float = 2.0 - + // Performance var minimumMovementThreshold: Float = 0.5 // pixels - + + // Palm rejection - Exclusion zone + var exclusionZoneEnabled: Bool = false + var exclusionZoneSize: Float = 0.15 // Bottom 15% of trackpad (normalized 0-1) + + // Palm rejection - Modifier key + var requireModifierKey: Bool = false + var modifierKeyType: ModifierKeyType = .shift + + // Palm rejection - Contact size filter + var contactSizeFilterEnabled: Bool = false + var maxContactSize: Float = 1.5 // Maximum zTotal value to include (larger = palm) + /// Calculate effective sensitivity based on velocity func effectiveSensitivity(for velocity: MTPoint) -> Float { guard enableVelocityBoost else { return sensitivity } - + let velocityMagnitude = abs(velocity.x) + abs(velocity.y) let velocityBoost = 1.0 + min(velocityMagnitude, maxVelocityBoost) * 0.5 return sensitivity * velocityBoost } } +// MARK: - Modifier Key Type + +/// Types of modifier keys that can be required for gesture activation +enum ModifierKeyType: String, Codable, CaseIterable { + case shift + case control + case option + case command + + var displayName: String { + switch self { + case .shift: return "⇧ Shift" + case .control: return "⌃ Control" + case .option: return "⌥ Option" + case .command: return "⌘ Command" + } + } +} + // MARK: - User Preferences /// User preferences that persist across app launches @@ -64,20 +97,40 @@ struct UserPreferences: Codable { var dragSensitivity: Double = 1.0 var tapThreshold: Double = 0.15 var smoothingFactor: Double = 0.3 - @available(*, deprecated, message: "Always requires exactly 3 fingers now to support Mission Control") + @available( + *, deprecated, message: "Always requires exactly 3 fingers now to support Mission Control" + ) var requiresExactlyThreeFingers: Bool = true var blockSystemGestures: Bool = false var middleDragEnabled: Bool = true // Allow disabling drag while keeping tap - + + // Palm rejection - Exclusion zone + var exclusionZoneEnabled: Bool = false + var exclusionZoneSize: Double = 0.15 // Bottom 15% of trackpad + + // Palm rejection - Modifier key + var requireModifierKey: Bool = false + var modifierKeyType: ModifierKeyType = .shift + + // Palm rejection - Contact size filter + var contactSizeFilterEnabled: Bool = false + var maxContactSize: Double = 1.5 // Maximum contact size to include + /// Convert to GestureConfiguration var gestureConfig: GestureConfiguration { return GestureConfiguration( sensitivity: Float(dragSensitivity), smoothingFactor: Float(smoothingFactor), tapThreshold: tapThreshold, - requiresExactlyThreeFingers: true, // Always true now + requiresExactlyThreeFingers: true, // Always true now blockSystemGestures: blockSystemGestures, - middleDragEnabled: middleDragEnabled + middleDragEnabled: middleDragEnabled, + exclusionZoneEnabled: exclusionZoneEnabled, + exclusionZoneSize: Float(exclusionZoneSize), + requireModifierKey: requireModifierKey, + modifierKeyType: modifierKeyType, + contactSizeFilterEnabled: contactSizeFilterEnabled, + maxContactSize: Float(maxContactSize) ) } } diff --git a/MiddleDrag/UI/MenuBarController.swift b/MiddleDrag/UI/MenuBarController.swift index 1347c3d..58a0c5b 100644 --- a/MiddleDrag/UI/MenuBarController.swift +++ b/MiddleDrag/UI/MenuBarController.swift @@ -2,49 +2,49 @@ import Cocoa /// Manages the menu bar UI and user interactions class MenuBarController: NSObject { - + // MARK: - Properties - + private var statusItem: NSStatusItem! private weak var multitouchManager: MultitouchManager? private var preferences: UserPreferences - + // Menu item tags for easy reference private enum MenuItemTag: Int { case enabled = 1 case launchAtLogin = 2 case middleDrag = 3 } - + // MARK: - Initialization - + init(multitouchManager: MultitouchManager, preferences: UserPreferences) { self.multitouchManager = multitouchManager self.preferences = preferences super.init() - + setupStatusItem() } - + // MARK: - Setup - + private func setupStatusItem() { statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) - + if let button = statusItem.button { updateStatusIcon(enabled: multitouchManager?.isEnabled ?? false) } - + buildMenu() } - + func updateStatusIcon(enabled: Bool) { guard let button = statusItem.button else { return } - + let iconName = enabled ? "hand.raised.fingers.spread" : "hand.raised.slash" button.image = NSImage(systemSymbolName: iconName, accessibilityDescription: "MiddleDrag") button.image?.isTemplate = true - + // Animate the change NSAnimationContext.runAnimationGroup { context in context.duration = 0.2 @@ -53,38 +53,38 @@ class MenuBarController: NSObject { button.animator().alphaValue = 1.0 } } - + // MARK: - Menu Building - + func buildMenu() { let menu = NSMenu() - + // Status menu.addItem(createStatusItem()) menu.addItem(NSMenuItem.separator()) - + // Enable/Disable menu.addItem(createEnabledItem()) menu.addItem(createMiddleDragItem()) menu.addItem(NSMenuItem.separator()) - + // Settings menu.addItem(createSensitivityMenu()) menu.addItem(createAdvancedMenu()) menu.addItem(NSMenuItem.separator()) - + // App items menu.addItem(createMenuItem(title: "About MiddleDrag", action: #selector(showAbout))) menu.addItem(createLaunchAtLoginItem()) menu.addItem(NSMenuItem.separator()) - + // Actions menu.addItem(createMenuItem(title: "Quick Setup", action: #selector(showQuickSetup))) menu.addItem(createMenuItem(title: "Quit", action: #selector(quit), keyEquivalent: "q")) - + statusItem.menu = menu } - + private func createStatusItem() -> NSMenuItem { let isEnabled = multitouchManager?.isEnabled ?? false let title = isEnabled ? "MiddleDrag Active" : "MiddleDrag Disabled" @@ -92,15 +92,16 @@ class MenuBarController: NSObject { item.isEnabled = false return item } - + private func createEnabledItem() -> NSMenuItem { - let item = NSMenuItem(title: "Enabled", action: #selector(toggleEnabled), keyEquivalent: "e") + let item = NSMenuItem( + title: "Enabled", action: #selector(toggleEnabled), keyEquivalent: "e") item.target = self // IMPORTANT: Set target item.state = (multitouchManager?.isEnabled ?? false) ? .on : .off item.tag = MenuItemTag.enabled.rawValue return item } - + private func createMiddleDragItem() -> NSMenuItem { let item = NSMenuItem(title: "Drag", action: #selector(toggleMiddleDrag), keyEquivalent: "") item.target = self @@ -111,35 +112,39 @@ class MenuBarController: NSObject { item.tag = MenuItemTag.middleDrag.rawValue return item } - + private func createLaunchAtLoginItem() -> NSMenuItem { - let item = NSMenuItem(title: "Launch at Login", action: #selector(toggleLaunchAtLogin), keyEquivalent: "") + let item = NSMenuItem( + title: "Launch at Login", action: #selector(toggleLaunchAtLogin), keyEquivalent: "") item.target = self // IMPORTANT: Set target item.state = preferences.launchAtLogin ? .on : .off item.tag = MenuItemTag.launchAtLogin.rawValue return item } - - private func createMenuItem(title: String, action: Selector, keyEquivalent: String = "") -> NSMenuItem { + + private func createMenuItem(title: String, action: Selector, keyEquivalent: String = "") + -> NSMenuItem + { let item = NSMenuItem(title: title, action: action, keyEquivalent: keyEquivalent) item.target = self // IMPORTANT: Set target return item } - + private func createSensitivityMenu() -> NSMenuItem { let item = NSMenuItem(title: "Drag Sensitivity", action: nil, keyEquivalent: "") let submenu = NSMenu() - + let sensitivities: [(String, Float)] = [ ("Slow (0.5x)", 0.5), ("Precision (0.75x)", 0.75), ("Normal (1x)", 1.0), ("Fast (1.5x)", 1.5), - ("Very Fast (2x)", 2.0) + ("Very Fast (2x)", 2.0), ] - + for (title, value) in sensitivities { - let menuItem = NSMenuItem(title: title, action: #selector(setSensitivity(_:)), keyEquivalent: "") + let menuItem = NSMenuItem( + title: title, action: #selector(setSensitivity(_:)), keyEquivalent: "") menuItem.target = self // IMPORTANT: Set target menuItem.representedObject = value if abs(Float(preferences.dragSensitivity) - value) < 0.01 { @@ -147,149 +152,332 @@ class MenuBarController: NSObject { } submenu.addItem(menuItem) } - + item.submenu = submenu return item } - + private func createAdvancedMenu() -> NSMenuItem { let item = NSMenuItem(title: "Advanced", action: nil, keyEquivalent: "") let submenu = NSMenu() - + // Add advanced options - submenu.addItem(createAdvancedMenuItem( - title: "Block System Gestures", - isOn: preferences.blockSystemGestures, - action: #selector(toggleSystemGestureBlocking) - )) - + submenu.addItem( + createAdvancedMenuItem( + title: "Block System Gestures", + isOn: preferences.blockSystemGestures, + action: #selector(toggleSystemGestureBlocking) + )) + + submenu.addItem(NSMenuItem.separator()) + + // Palm Rejection section + submenu.addItem(createPalmRejectionMenu()) + submenu.addItem(NSMenuItem.separator()) - + // Telemetry section header - let telemetryHeader = NSMenuItem(title: "Help Improve MiddleDrag:", action: nil, keyEquivalent: "") + let telemetryHeader = NSMenuItem( + title: "Help Improve MiddleDrag:", action: nil, keyEquivalent: "") telemetryHeader.isEnabled = false submenu.addItem(telemetryHeader) - + // Crash reporting (only sends on crash) - submenu.addItem(createAdvancedMenuItem( - title: "Send Crash Reports", - isOn: CrashReporter.shared.isEnabled, - action: #selector(toggleCrashReporting) - )) - + submenu.addItem( + createAdvancedMenuItem( + title: "Send Crash Reports", + isOn: CrashReporter.shared.isEnabled, + action: #selector(toggleCrashReporting) + )) + // Performance monitoring (sends during use) - submenu.addItem(createAdvancedMenuItem( - title: "Send Performance Data", - isOn: CrashReporter.shared.performanceMonitoringEnabled, - action: #selector(togglePerformanceMonitoring) - )) - + submenu.addItem( + createAdvancedMenuItem( + title: "Send Performance Data", + isOn: CrashReporter.shared.performanceMonitoringEnabled, + action: #selector(togglePerformanceMonitoring) + )) + + item.submenu = submenu + return item + } + + private func createPalmRejectionMenu() -> NSMenuItem { + let item = NSMenuItem(title: "Palm Rejection", action: nil, keyEquivalent: "") + let submenu = NSMenu() + + // Exclusion Zone section + let exclusionItem = createAdvancedMenuItem( + title: "Exclusion Zone", + isOn: preferences.exclusionZoneEnabled, + action: #selector(toggleExclusionZone) + ) + submenu.addItem(exclusionItem) + + // Exclusion zone size options (only shown when enabled) + if preferences.exclusionZoneEnabled { + let sizes: [(String, Double)] = [ + ("10% (Small)", 0.10), + ("15% (Default)", 0.15), + ("20% (Medium)", 0.20), + ("25% (Large)", 0.25), + ] + + for (title, value) in sizes { + let sizeItem = NSMenuItem( + title: " \(title)", action: #selector(setExclusionZoneSize(_:)), + keyEquivalent: "") + sizeItem.target = self + sizeItem.representedObject = value + if abs(preferences.exclusionZoneSize - value) < 0.01 { + sizeItem.state = .on + } + submenu.addItem(sizeItem) + } + } + + submenu.addItem(NSMenuItem.separator()) + + // Modifier Key section + let modifierItem = createAdvancedMenuItem( + title: "Require Modifier Key", + isOn: preferences.requireModifierKey, + action: #selector(toggleRequireModifierKey) + ) + submenu.addItem(modifierItem) + + // Modifier key options (only shown when enabled) + if preferences.requireModifierKey { + for keyType in ModifierKeyType.allCases { + let keyItem = NSMenuItem( + title: " \(keyType.displayName)", action: #selector(setModifierKeyType(_:)), + keyEquivalent: "") + keyItem.target = self + keyItem.representedObject = keyType.rawValue + if preferences.modifierKeyType == keyType { + keyItem.state = .on + } + submenu.addItem(keyItem) + } + } + + submenu.addItem(NSMenuItem.separator()) + + // Contact Size Filter section + let contactSizeItem = createAdvancedMenuItem( + title: "Filter Large Contacts", + isOn: preferences.contactSizeFilterEnabled, + action: #selector(toggleContactSizeFilter) + ) + submenu.addItem(contactSizeItem) + + // Contact size threshold options (only shown when enabled) + if preferences.contactSizeFilterEnabled { + let thresholds: [(String, Double)] = [ + ("Strict (1.0)", 1.0), + ("Normal (1.5)", 1.5), + ("Lenient (2.0)", 2.0), + ] + + for (title, value) in thresholds { + let thresholdItem = NSMenuItem( + title: " \(title)", action: #selector(setContactSizeThreshold(_:)), + keyEquivalent: "") + thresholdItem.target = self + thresholdItem.representedObject = value + if abs(preferences.maxContactSize - value) < 0.01 { + thresholdItem.state = .on + } + submenu.addItem(thresholdItem) + } + } + item.submenu = submenu return item } - + private func createAdvancedMenuItem(title: String, isOn: Bool, action: Selector) -> NSMenuItem { let item = NSMenuItem(title: title, action: action, keyEquivalent: "") item.target = self // IMPORTANT: Set target item.state = isOn ? .on : .off return item } - + // MARK: - Actions - + @objc private func toggleEnabled() { multitouchManager?.toggleEnabled() let isEnabled = multitouchManager?.isEnabled ?? false - + if let item = statusItem.menu?.item(withTag: MenuItemTag.enabled.rawValue) { item.state = isEnabled ? .on : .off } - + updateStatusIcon(enabled: isEnabled) buildMenu() // Rebuild to update status text } - + @objc private func toggleMiddleDrag() { preferences.middleDragEnabled.toggle() - + var config = multitouchManager?.configuration ?? GestureConfiguration() config.middleDragEnabled = preferences.middleDragEnabled multitouchManager?.updateConfiguration(config) - + if let item = statusItem.menu?.item(withTag: MenuItemTag.middleDrag.rawValue) { item.state = preferences.middleDragEnabled ? .on : .off } - + NotificationCenter.default.post(name: .preferencesChanged, object: preferences) } - + @objc private func setSensitivity(_ sender: NSMenuItem) { guard let value = sender.representedObject as? Float else { return } - + // Update UI if let menu = sender.menu { for item in menu.items { item.state = item == sender ? .on : .off } } - + // Update preferences and manager preferences.dragSensitivity = Double(value) multitouchManager?.configuration.sensitivity = value - + // Notify delegate to save preferences NotificationCenter.default.post(name: .preferencesChanged, object: preferences) } - + @objc private func toggleSystemGestureBlocking() { preferences.blockSystemGestures.toggle() - + var config = multitouchManager?.configuration ?? GestureConfiguration() config.blockSystemGestures = preferences.blockSystemGestures multitouchManager?.updateConfiguration(config) - + buildMenu() // Rebuild to update checkmark - + if preferences.blockSystemGestures { showSystemGestureWarning() } - + + NotificationCenter.default.post(name: .preferencesChanged, object: preferences) + } + + // MARK: - Palm Rejection Actions + + @objc private func toggleExclusionZone() { + preferences.exclusionZoneEnabled.toggle() + + var config = multitouchManager?.configuration ?? GestureConfiguration() + config.exclusionZoneEnabled = preferences.exclusionZoneEnabled + config.exclusionZoneSize = Float(preferences.exclusionZoneSize) + multitouchManager?.updateConfiguration(config) + + buildMenu() + NotificationCenter.default.post(name: .preferencesChanged, object: preferences) + } + + @objc private func setExclusionZoneSize(_ sender: NSMenuItem) { + guard let value = sender.representedObject as? Double else { return } + + preferences.exclusionZoneSize = value + + var config = multitouchManager?.configuration ?? GestureConfiguration() + config.exclusionZoneSize = Float(value) + multitouchManager?.updateConfiguration(config) + + buildMenu() + NotificationCenter.default.post(name: .preferencesChanged, object: preferences) + } + + @objc private func toggleRequireModifierKey() { + preferences.requireModifierKey.toggle() + + var config = multitouchManager?.configuration ?? GestureConfiguration() + config.requireModifierKey = preferences.requireModifierKey + config.modifierKeyType = preferences.modifierKeyType + multitouchManager?.updateConfiguration(config) + + buildMenu() NotificationCenter.default.post(name: .preferencesChanged, object: preferences) } - + + @objc private func setModifierKeyType(_ sender: NSMenuItem) { + guard let rawValue = sender.representedObject as? String, + let keyType = ModifierKeyType(rawValue: rawValue) + else { return } + + preferences.modifierKeyType = keyType + + var config = multitouchManager?.configuration ?? GestureConfiguration() + config.modifierKeyType = keyType + multitouchManager?.updateConfiguration(config) + + buildMenu() + NotificationCenter.default.post(name: .preferencesChanged, object: preferences) + } + + @objc private func toggleContactSizeFilter() { + preferences.contactSizeFilterEnabled.toggle() + + var config = multitouchManager?.configuration ?? GestureConfiguration() + config.contactSizeFilterEnabled = preferences.contactSizeFilterEnabled + config.maxContactSize = Float(preferences.maxContactSize) + multitouchManager?.updateConfiguration(config) + + buildMenu() + NotificationCenter.default.post(name: .preferencesChanged, object: preferences) + } + + @objc private func setContactSizeThreshold(_ sender: NSMenuItem) { + guard let value = sender.representedObject as? Double else { return } + + preferences.maxContactSize = value + + var config = multitouchManager?.configuration ?? GestureConfiguration() + config.maxContactSize = Float(value) + multitouchManager?.updateConfiguration(config) + + buildMenu() + NotificationCenter.default.post(name: .preferencesChanged, object: preferences) + } + @objc private func toggleLaunchAtLogin() { preferences.launchAtLogin.toggle() - + if let item = statusItem.menu?.item(withTag: MenuItemTag.launchAtLogin.rawValue) { item.state = preferences.launchAtLogin ? .on : .off } - - NotificationCenter.default.post(name: .launchAtLoginChanged, object: preferences.launchAtLogin) + + NotificationCenter.default.post( + name: .launchAtLoginChanged, object: preferences.launchAtLogin) NotificationCenter.default.post(name: .preferencesChanged, object: preferences) } - + @objc private func toggleCrashReporting() { CrashReporter.shared.isEnabled.toggle() buildMenu() // Rebuild to update checkmark } - + @objc private func togglePerformanceMonitoring() { CrashReporter.shared.performanceMonitoringEnabled.toggle() buildMenu() // Rebuild to update checkmark } - + @objc private func showAbout() { AlertHelper.showAbout() } - + @objc private func showQuickSetup() { AlertHelper.showQuickSetup() } - + @objc private func quit() { NSApplication.shared.terminate(nil) } - + private func showSystemGestureWarning() { AlertHelper.showSystemGestureWarning() } diff --git a/MiddleDrag/Utilities/PreferencesManager.swift b/MiddleDrag/Utilities/PreferencesManager.swift index d7f7b85..6dbeb12 100644 --- a/MiddleDrag/Utilities/PreferencesManager.swift +++ b/MiddleDrag/Utilities/PreferencesManager.swift @@ -2,11 +2,11 @@ import Foundation /// Manages user preferences persistence class PreferencesManager { - + static let shared = PreferencesManager() - + private let userDefaults = UserDefaults.standard - + // Keys for UserDefaults private enum Keys { static let launchAtLogin = "launchAtLogin" @@ -15,12 +15,19 @@ class PreferencesManager { static let smoothingFactor = "smoothingFactor" static let blockSystemGestures = "blockSystemGestures" static let middleDragEnabled = "middleDragEnabled" + // Palm rejection keys + static let exclusionZoneEnabled = "exclusionZoneEnabled" + static let exclusionZoneSize = "exclusionZoneSize" + static let requireModifierKey = "requireModifierKey" + static let modifierKeyType = "modifierKeyType" + static let contactSizeFilterEnabled = "contactSizeFilterEnabled" + static let maxContactSize = "maxContactSize" } - + private init() { registerDefaults() } - + /// Register default values private func registerDefaults() { userDefaults.register(defaults: [ @@ -29,22 +36,39 @@ class PreferencesManager { Keys.tapThreshold: 0.15, Keys.smoothingFactor: 0.3, Keys.blockSystemGestures: false, - Keys.middleDragEnabled: true + Keys.middleDragEnabled: true, + // Palm rejection defaults + Keys.exclusionZoneEnabled: false, + Keys.exclusionZoneSize: 0.15, + Keys.requireModifierKey: false, + Keys.modifierKeyType: ModifierKeyType.shift.rawValue, + Keys.contactSizeFilterEnabled: false, + Keys.maxContactSize: 1.5, ]) } - + /// Load preferences from UserDefaults func loadPreferences() -> UserPreferences { + let modifierKeyRaw = + userDefaults.string(forKey: Keys.modifierKeyType) ?? ModifierKeyType.shift.rawValue + let modifierKey = ModifierKeyType(rawValue: modifierKeyRaw) ?? .shift + return UserPreferences( launchAtLogin: userDefaults.bool(forKey: Keys.launchAtLogin), dragSensitivity: userDefaults.double(forKey: Keys.dragSensitivity), tapThreshold: userDefaults.double(forKey: Keys.tapThreshold), smoothingFactor: userDefaults.double(forKey: Keys.smoothingFactor), blockSystemGestures: userDefaults.bool(forKey: Keys.blockSystemGestures), - middleDragEnabled: userDefaults.bool(forKey: Keys.middleDragEnabled) + middleDragEnabled: userDefaults.bool(forKey: Keys.middleDragEnabled), + exclusionZoneEnabled: userDefaults.bool(forKey: Keys.exclusionZoneEnabled), + exclusionZoneSize: userDefaults.double(forKey: Keys.exclusionZoneSize), + requireModifierKey: userDefaults.bool(forKey: Keys.requireModifierKey), + modifierKeyType: modifierKey, + contactSizeFilterEnabled: userDefaults.bool(forKey: Keys.contactSizeFilterEnabled), + maxContactSize: userDefaults.double(forKey: Keys.maxContactSize) ) } - + /// Save preferences to UserDefaults func savePreferences(_ preferences: UserPreferences) { userDefaults.set(preferences.launchAtLogin, forKey: Keys.launchAtLogin) @@ -53,5 +77,13 @@ class PreferencesManager { userDefaults.set(preferences.smoothingFactor, forKey: Keys.smoothingFactor) userDefaults.set(preferences.blockSystemGestures, forKey: Keys.blockSystemGestures) userDefaults.set(preferences.middleDragEnabled, forKey: Keys.middleDragEnabled) + // Palm rejection + userDefaults.set(preferences.exclusionZoneEnabled, forKey: Keys.exclusionZoneEnabled) + userDefaults.set(preferences.exclusionZoneSize, forKey: Keys.exclusionZoneSize) + userDefaults.set(preferences.requireModifierKey, forKey: Keys.requireModifierKey) + userDefaults.set(preferences.modifierKeyType.rawValue, forKey: Keys.modifierKeyType) + userDefaults.set( + preferences.contactSizeFilterEnabled, forKey: Keys.contactSizeFilterEnabled) + userDefaults.set(preferences.maxContactSize, forKey: Keys.maxContactSize) } } diff --git a/MiddleDragTests/GestureModelsTests.swift b/MiddleDragTests/GestureModelsTests.swift index dd4caf8..324ad9e 100644 --- a/MiddleDragTests/GestureModelsTests.swift +++ b/MiddleDragTests/GestureModelsTests.swift @@ -105,4 +105,85 @@ final class GestureModelsTests: XCTestCase { XCTAssertTrue(config.blockSystemGestures) XCTAssertFalse(config.middleDragEnabled) } + + // MARK: - Palm Rejection Configuration Tests + + func testDefaultGestureConfigurationPalmRejectionFields() { + let config = GestureConfiguration() + + // Exclusion zone defaults + XCTAssertFalse(config.exclusionZoneEnabled) + XCTAssertEqual(config.exclusionZoneSize, 0.15, accuracy: 0.001) + + // Modifier key defaults + XCTAssertFalse(config.requireModifierKey) + XCTAssertEqual(config.modifierKeyType, .shift) + + // Contact size filter defaults + XCTAssertFalse(config.contactSizeFilterEnabled) + XCTAssertEqual(config.maxContactSize, 1.5, accuracy: 0.001) + } + + func testDefaultUserPreferencesPalmRejectionFields() { + let prefs = UserPreferences() + + // Exclusion zone defaults + XCTAssertFalse(prefs.exclusionZoneEnabled) + XCTAssertEqual(prefs.exclusionZoneSize, 0.15, accuracy: 0.001) + + // Modifier key defaults + XCTAssertFalse(prefs.requireModifierKey) + XCTAssertEqual(prefs.modifierKeyType, .shift) + + // Contact size filter defaults + XCTAssertFalse(prefs.contactSizeFilterEnabled) + XCTAssertEqual(prefs.maxContactSize, 1.5, accuracy: 0.001) + } + + func testUserPreferencesToGestureConfigPalmRejectionMapping() { + var prefs = UserPreferences() + + // Set palm rejection values + prefs.exclusionZoneEnabled = true + prefs.exclusionZoneSize = 0.25 + prefs.requireModifierKey = true + prefs.modifierKeyType = .option + prefs.contactSizeFilterEnabled = true + prefs.maxContactSize = 2.5 + + let config = prefs.gestureConfig + + // Verify mapping (Double to Float conversion) + XCTAssertTrue(config.exclusionZoneEnabled) + XCTAssertEqual(config.exclusionZoneSize, 0.25, accuracy: 0.001) + XCTAssertTrue(config.requireModifierKey) + XCTAssertEqual(config.modifierKeyType, .option) + XCTAssertTrue(config.contactSizeFilterEnabled) + XCTAssertEqual(config.maxContactSize, 2.5, accuracy: 0.001) + } + + // MARK: - ModifierKeyType Tests + + func testModifierKeyTypeDisplayNames() { + XCTAssertEqual(ModifierKeyType.shift.displayName, "⇧ Shift") + XCTAssertEqual(ModifierKeyType.control.displayName, "⌃ Control") + XCTAssertEqual(ModifierKeyType.option.displayName, "⌥ Option") + XCTAssertEqual(ModifierKeyType.command.displayName, "⌘ Command") + } + + func testModifierKeyTypeAllCases() { + let allCases = ModifierKeyType.allCases + XCTAssertEqual(allCases.count, 4) + XCTAssertTrue(allCases.contains(.shift)) + XCTAssertTrue(allCases.contains(.control)) + XCTAssertTrue(allCases.contains(.option)) + XCTAssertTrue(allCases.contains(.command)) + } + + func testModifierKeyTypeRawValues() { + XCTAssertEqual(ModifierKeyType.shift.rawValue, "shift") + XCTAssertEqual(ModifierKeyType.control.rawValue, "control") + XCTAssertEqual(ModifierKeyType.option.rawValue, "option") + XCTAssertEqual(ModifierKeyType.command.rawValue, "command") + } } diff --git a/MiddleDragTests/GestureRecognizerTests.swift b/MiddleDragTests/GestureRecognizerTests.swift new file mode 100644 index 0000000..5e7de3a --- /dev/null +++ b/MiddleDragTests/GestureRecognizerTests.swift @@ -0,0 +1,471 @@ +import CoreGraphics +import XCTest + +@testable import MiddleDrag + +final class GestureRecognizerTests: XCTestCase { + + var recognizer: GestureRecognizer! + var mockDelegate: MockGestureRecognizerDelegate! + + override func setUp() { + super.setUp() + recognizer = GestureRecognizer() + mockDelegate = MockGestureRecognizerDelegate() + recognizer.delegate = mockDelegate + } + + override func tearDown() { + recognizer = nil + mockDelegate = nil + super.tearDown() + } + + // MARK: - Helper Methods + + /// Creates a valid MTTouch struct with the given position and properties + private func createTouch( + x: Float, y: Float, zTotal: Float = 0.5, state: UInt32 = 4 + ) -> MTTouch { + let position = MTPoint(x: x, y: y) + let velocity = MTPoint(x: 0, y: 0) + let normalizedVector = MTVector(position: position, velocity: velocity) + let absoluteVector = MTVector(position: position, velocity: velocity) + + return MTTouch( + frame: 0, + timestamp: 0, + pathIndex: 0, + state: state, + fingerID: 0, + handID: 0, + normalizedVector: normalizedVector, + zTotal: zTotal, + field9: 0, + angle: 0, + majorAxis: 0.1, + minorAxis: 0.1, + absoluteVector: absoluteVector, + field14: 0, + field15: 0, + zDensity: 0 + ) + } + + /// Creates touch data in memory and returns pointer + cleanup closure + private func createTouchData(touches: [MTTouch]) -> (UnsafeMutableRawPointer, Int, () -> Void) { + let count = touches.count + let pointer = UnsafeMutablePointer.allocate(capacity: count) + + for (index, touch) in touches.enumerated() { + pointer[index] = touch + } + + let rawPointer = UnsafeMutableRawPointer(pointer) + let cleanup = { pointer.deallocate() } + + return (rawPointer, count, cleanup) + } + + // MARK: - Modifier Key Requirement Tests + + func testModifierKeyRequirement_CancelsGestureWhenModifierNotHeld() { + // Configure to require shift key + recognizer.configuration.requireModifierKey = true + recognizer.configuration.modifierKeyType = .shift + + // Create 3 valid touches + let touches = [ + createTouch(x: 0.3, y: 0.5), + createTouch(x: 0.5, y: 0.5), + createTouch(x: 0.7, y: 0.5), + ] + let (pointer, count, cleanup) = createTouchData(touches: touches) + defer { cleanup() } + + // Process with shift held - gesture should start + recognizer.processTouches(pointer, count: count, timestamp: 0.0, modifierFlags: .maskShift) + XCTAssertTrue(mockDelegate.didStartCalled, "Gesture should start when modifier is held") + + // Reset delegate tracking + mockDelegate.reset() + + // Now process without shift held - should cancel + recognizer.processTouches(pointer, count: count, timestamp: 0.1, modifierFlags: []) + XCTAssertTrue( + mockDelegate.didCancelCalled || mockDelegate.didCancelDraggingCalled, + "Gesture should be cancelled when modifier is released") + } + + func testModifierKeyRequirement_AllowsGestureWhenModifierHeld() { + recognizer.configuration.requireModifierKey = true + recognizer.configuration.modifierKeyType = .option + + let touches = [ + createTouch(x: 0.3, y: 0.5), + createTouch(x: 0.5, y: 0.5), + createTouch(x: 0.7, y: 0.5), + ] + let (pointer, count, cleanup) = createTouchData(touches: touches) + defer { cleanup() } + + // Process with option key held (maskAlternate) + recognizer.processTouches( + pointer, count: count, timestamp: 0.0, modifierFlags: .maskAlternate) + + XCTAssertTrue( + mockDelegate.didStartCalled, "Gesture should start when correct modifier is held") + } + + func testModifierKeyRequirement_AllModifierTypes() { + let modifierTests: [(ModifierKeyType, CGEventFlags)] = [ + (.shift, .maskShift), + (.control, .maskControl), + (.option, .maskAlternate), + (.command, .maskCommand), + ] + + for (modifierType, eventFlag) in modifierTests { + // Reset for each test + recognizer.reset() + mockDelegate.reset() + + recognizer.configuration.requireModifierKey = true + recognizer.configuration.modifierKeyType = modifierType + + let touches = [ + createTouch(x: 0.3, y: 0.5), + createTouch(x: 0.5, y: 0.5), + createTouch(x: 0.7, y: 0.5), + ] + let (pointer, count, cleanup) = createTouchData(touches: touches) + defer { cleanup() } + + recognizer.processTouches( + pointer, count: count, timestamp: 0.0, modifierFlags: eventFlag) + + XCTAssertTrue( + mockDelegate.didStartCalled, + "Gesture should start with \(modifierType) modifier held") + } + } + + func testModifierKeyRequirement_DisabledAllowsAllGestures() { + recognizer.configuration.requireModifierKey = false // Disabled + + let touches = [ + createTouch(x: 0.3, y: 0.5), + createTouch(x: 0.5, y: 0.5), + createTouch(x: 0.7, y: 0.5), + ] + let (pointer, count, cleanup) = createTouchData(touches: touches) + defer { cleanup() } + + // Process with no modifiers + recognizer.processTouches(pointer, count: count, timestamp: 0.0, modifierFlags: []) + + XCTAssertTrue( + mockDelegate.didStartCalled, + "Gesture should start without modifier when requirement is disabled") + } + + // MARK: - Exclusion Zone Tests + + func testExclusionZone_FiltersTouchesInExclusionZone() { + recognizer.configuration.exclusionZoneEnabled = true + recognizer.configuration.exclusionZoneSize = 0.2 // Bottom 20% + + // All 3 touches are in the exclusion zone (y < 0.2) + let touches = [ + createTouch(x: 0.3, y: 0.1), // y=0.1 < 0.2, filtered + createTouch(x: 0.5, y: 0.15), // y=0.15 < 0.2, filtered + createTouch(x: 0.7, y: 0.05), // y=0.05 < 0.2, filtered + ] + let (pointer, count, cleanup) = createTouchData(touches: touches) + defer { cleanup() } + + recognizer.processTouches(pointer, count: count, timestamp: 0.0, modifierFlags: []) + + // All touches filtered = no valid 3-finger gesture + XCTAssertFalse( + mockDelegate.didStartCalled, "Gesture should not start when all touches are filtered") + } + + func testExclusionZone_AcceptsTouchesAboveZone() { + recognizer.configuration.exclusionZoneEnabled = true + recognizer.configuration.exclusionZoneSize = 0.2 + + // All 3 touches are above the exclusion zone (y >= 0.2) + let touches = [ + createTouch(x: 0.3, y: 0.5), + createTouch(x: 0.5, y: 0.6), + createTouch(x: 0.7, y: 0.7), + ] + let (pointer, count, cleanup) = createTouchData(touches: touches) + defer { cleanup() } + + recognizer.processTouches(pointer, count: count, timestamp: 0.0, modifierFlags: []) + + XCTAssertTrue( + mockDelegate.didStartCalled, + "Gesture should start when touches are above exclusion zone" + ) + } + + func testExclusionZone_DisabledAcceptsAllTouches() { + recognizer.configuration.exclusionZoneEnabled = false // Disabled + + // Touches in what would be the exclusion zone + let touches = [ + createTouch(x: 0.3, y: 0.1), + createTouch(x: 0.5, y: 0.1), + createTouch(x: 0.7, y: 0.1), + ] + let (pointer, count, cleanup) = createTouchData(touches: touches) + defer { cleanup() } + + recognizer.processTouches(pointer, count: count, timestamp: 0.0, modifierFlags: []) + + XCTAssertTrue( + mockDelegate.didStartCalled, + "Gesture should start with low Y touches when exclusion zone is disabled") + } + + // MARK: - Contact Size Filter Tests + + func testContactSizeFilter_FiltersLargeContacts() { + recognizer.configuration.contactSizeFilterEnabled = true + recognizer.configuration.maxContactSize = 1.0 + + // All contacts are too large (zTotal > 1.0) + let touches = [ + createTouch(x: 0.3, y: 0.5, zTotal: 2.0), // Too large + createTouch(x: 0.5, y: 0.5, zTotal: 1.5), // Too large + createTouch(x: 0.7, y: 0.5, zTotal: 3.0), // Too large + ] + let (pointer, count, cleanup) = createTouchData(touches: touches) + defer { cleanup() } + + recognizer.processTouches(pointer, count: count, timestamp: 0.0, modifierFlags: []) + + XCTAssertFalse( + mockDelegate.didStartCalled, + "Gesture should not start when all contacts are too large (palm rejection)") + } + + func testContactSizeFilter_AcceptsNormalContacts() { + recognizer.configuration.contactSizeFilterEnabled = true + recognizer.configuration.maxContactSize = 1.5 + + // All contacts are within acceptable range + let touches = [ + createTouch(x: 0.3, y: 0.5, zTotal: 0.5), + createTouch(x: 0.5, y: 0.5, zTotal: 0.8), + createTouch(x: 0.7, y: 0.5, zTotal: 1.0), + ] + let (pointer, count, cleanup) = createTouchData(touches: touches) + defer { cleanup() } + + recognizer.processTouches(pointer, count: count, timestamp: 0.0, modifierFlags: []) + + XCTAssertTrue( + mockDelegate.didStartCalled, + "Gesture should start when all contacts are within size limit") + } + + func testContactSizeFilter_DisabledAcceptsAllContacts() { + recognizer.configuration.contactSizeFilterEnabled = false // Disabled + + // Very large contacts that would normally be filtered + let touches = [ + createTouch(x: 0.3, y: 0.5, zTotal: 10.0), + createTouch(x: 0.5, y: 0.5, zTotal: 15.0), + createTouch(x: 0.7, y: 0.5, zTotal: 20.0), + ] + let (pointer, count, cleanup) = createTouchData(touches: touches) + defer { cleanup() } + + recognizer.processTouches(pointer, count: count, timestamp: 0.0, modifierFlags: []) + + XCTAssertTrue( + mockDelegate.didStartCalled, + "Gesture should start with large contacts when filter is disabled") + } + + // MARK: - Partial Filter Tests (Some touches filtered, some pass) + + func testExclusionZone_PartialFiltering_FiveTouchesThreePass() { + recognizer.configuration.exclusionZoneEnabled = true + recognizer.configuration.exclusionZoneSize = 0.2 // Bottom 20% + + // 5 touches: 2 in exclusion zone, 3 above - should activate gesture + let touches = [ + createTouch(x: 0.2, y: 0.1), // Filtered (y < 0.2) + createTouch(x: 0.3, y: 0.5), // Passes + createTouch(x: 0.5, y: 0.6), // Passes + createTouch(x: 0.7, y: 0.5), // Passes + createTouch(x: 0.8, y: 0.05), // Filtered (y < 0.2) + ] + let (pointer, count, cleanup) = createTouchData(touches: touches) + defer { cleanup() } + + recognizer.processTouches(pointer, count: count, timestamp: 0.0, modifierFlags: []) + + XCTAssertTrue( + mockDelegate.didStartCalled, + "Gesture should start when 5 touches detected but only 3 pass exclusion zone filter") + } + + func testContactSizeFilter_PartialFiltering_FiveTouchesThreePass() { + recognizer.configuration.contactSizeFilterEnabled = true + recognizer.configuration.maxContactSize = 1.5 + + // 5 touches: 2 too large (palm), 3 normal - should activate gesture + let touches = [ + createTouch(x: 0.2, y: 0.5, zTotal: 3.0), // Filtered (too large) + createTouch(x: 0.3, y: 0.5, zTotal: 0.5), // Passes + createTouch(x: 0.5, y: 0.5, zTotal: 0.8), // Passes + createTouch(x: 0.7, y: 0.5, zTotal: 1.0), // Passes + createTouch(x: 0.8, y: 0.5, zTotal: 5.0), // Filtered (too large) + ] + let (pointer, count, cleanup) = createTouchData(touches: touches) + defer { cleanup() } + + recognizer.processTouches(pointer, count: count, timestamp: 0.0, modifierFlags: []) + + XCTAssertTrue( + mockDelegate.didStartCalled, + "Gesture should start when 5 touches detected but only 3 pass contact size filter") + } + + func testCombinedFilters_PartialFiltering_SixTouchesThreePass() { + recognizer.configuration.exclusionZoneEnabled = true + recognizer.configuration.exclusionZoneSize = 0.2 + recognizer.configuration.contactSizeFilterEnabled = true + recognizer.configuration.maxContactSize = 1.5 + + // 6 touches: 2 in exclusion zone, 1 too large, 3 pass both - should activate + let touches = [ + createTouch(x: 0.1, y: 0.1, zTotal: 0.5), // Filtered (exclusion zone) + createTouch(x: 0.2, y: 0.5, zTotal: 3.0), // Filtered (too large) + createTouch(x: 0.3, y: 0.5, zTotal: 0.5), // Passes both + createTouch(x: 0.5, y: 0.6, zTotal: 0.8), // Passes both + createTouch(x: 0.7, y: 0.5, zTotal: 1.0), // Passes both + createTouch(x: 0.9, y: 0.05, zTotal: 0.5), // Filtered (exclusion zone) + ] + let (pointer, count, cleanup) = createTouchData(touches: touches) + defer { cleanup() } + + recognizer.processTouches(pointer, count: count, timestamp: 0.0, modifierFlags: []) + + XCTAssertTrue( + mockDelegate.didStartCalled, + "Gesture should start when 6 touches detected but only 3 pass all filters") + } + + func testPartialFiltering_InsufficientRemainingTouches() { + recognizer.configuration.exclusionZoneEnabled = true + recognizer.configuration.exclusionZoneSize = 0.2 + + // 4 touches: 2 filtered, only 2 remain - should NOT activate (need exactly 3) + let touches = [ + createTouch(x: 0.2, y: 0.1), // Filtered (exclusion zone) + createTouch(x: 0.3, y: 0.5), // Passes + createTouch(x: 0.7, y: 0.5), // Passes + createTouch(x: 0.8, y: 0.05), // Filtered (exclusion zone) + ] + let (pointer, count, cleanup) = createTouchData(touches: touches) + defer { cleanup() } + + recognizer.processTouches(pointer, count: count, timestamp: 0.0, modifierFlags: []) + + XCTAssertFalse( + mockDelegate.didStartCalled, + "Gesture should not start when only 2 touches remain after filtering") + } + + // MARK: - Combined Filter Tests + + func testCombinedFilters_AllFiltersWorking() { + recognizer.configuration.exclusionZoneEnabled = true + recognizer.configuration.exclusionZoneSize = 0.2 + recognizer.configuration.contactSizeFilterEnabled = true + recognizer.configuration.maxContactSize = 1.5 + recognizer.configuration.requireModifierKey = true + recognizer.configuration.modifierKeyType = .shift + + // Valid touches: above exclusion zone, normal size + let touches = [ + createTouch(x: 0.3, y: 0.5, zTotal: 0.5), + createTouch(x: 0.5, y: 0.5, zTotal: 0.8), + createTouch(x: 0.7, y: 0.5, zTotal: 1.0), + ] + let (pointer, count, cleanup) = createTouchData(touches: touches) + defer { cleanup() } + + // Process with shift held + recognizer.processTouches(pointer, count: count, timestamp: 0.0, modifierFlags: .maskShift) + + XCTAssertTrue( + mockDelegate.didStartCalled, "Gesture should start when all filters pass") + } +} + +// MARK: - Mock Delegate + +/// Mock delegate to track gesture recognizer callbacks +class MockGestureRecognizerDelegate: GestureRecognizerDelegate { + var didStartCalled = false + var didTapCalled = false + var didBeginDraggingCalled = false + var didUpdateDraggingCalled = false + var didEndDraggingCalled = false + var didCancelCalled = false + var didCancelDraggingCalled = false + + var lastStartPosition: MTPoint? + var lastGestureData: GestureData? + + func reset() { + didStartCalled = false + didTapCalled = false + didBeginDraggingCalled = false + didUpdateDraggingCalled = false + didEndDraggingCalled = false + didCancelCalled = false + didCancelDraggingCalled = false + lastStartPosition = nil + lastGestureData = nil + } + + func gestureRecognizerDidStart(_ recognizer: GestureRecognizer, at position: MTPoint) { + didStartCalled = true + lastStartPosition = position + } + + func gestureRecognizerDidTap(_ recognizer: GestureRecognizer) { + didTapCalled = true + } + + func gestureRecognizerDidBeginDragging(_ recognizer: GestureRecognizer) { + didBeginDraggingCalled = true + } + + func gestureRecognizerDidUpdateDragging(_ recognizer: GestureRecognizer, with data: GestureData) + { + didUpdateDraggingCalled = true + lastGestureData = data + } + + func gestureRecognizerDidEndDragging(_ recognizer: GestureRecognizer) { + didEndDraggingCalled = true + } + + func gestureRecognizerDidCancel(_ recognizer: GestureRecognizer) { + didCancelCalled = true + } + + func gestureRecognizerDidCancelDragging(_ recognizer: GestureRecognizer) { + didCancelDraggingCalled = true + } +}