Skip to content
20 changes: 20 additions & 0 deletions MiddleDrag.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@
Core/GestureRecognizer.swift,
Core/MouseEventGenerator.swift,
Core/MultitouchFramework.swift,
Core/TouchCalibration.swift,
Core/TouchClassifier.swift,
Core/TouchDebugRecording.swift,
Core/TouchDeviceProviding.swift,
Managers/AccessibilityMonitor.swift,
Managers/AccessibilityWrappers.swift,
Expand All @@ -70,8 +73,10 @@
Models/GestureModels.swift,
Models/TouchModels.swift,
UI/AlertHelper.swift,
UI/CalibrationWizardController.swift,
UI/HotKeyRecorderView.swift,
UI/MenuBarController.swift,
UI/TouchDebugWindowController.swift,
Utilities/AnalyticsManager.swift,
Utilities/GlobalHotKeyManager.swift,
Utilities/LaunchAtLoginManager.swift,
Expand All @@ -90,6 +95,9 @@
MiddleDragTests/AlertHelperTests.swift,
MiddleDragTests/AnalyticsManagerTests.swift,
MiddleDragTests/DeviceMonitorTests.swift,
"MiddleDragTests/Fixtures/one-finger-with-thumb.json",
"MiddleDragTests/Fixtures/palm-rest.json",
"MiddleDragTests/Fixtures/three-finger-drag-with-thumb-click.json",
MiddleDragTests/GestureModelsTests.swift,
MiddleDragTests/GestureRecognizerTests.swift,
MiddleDragTests/GlobalHotKeyManagerTests.swift,
Expand All @@ -103,6 +111,8 @@
MiddleDragTests/PreferencesManagerTests.swift,
MiddleDragTests/ScreenHelperTests.swift,
MiddleDragTests/SystemGestureHelperTests.swift,
MiddleDragTests/TouchCalibrationTests.swift,
MiddleDragTests/TouchDebugRecordingTests.swift,
MiddleDragTests/TouchModelsTests.swift,
MiddleDragTests/WindowHelperTests.swift,
);
Expand All @@ -122,6 +132,9 @@
Core/GestureRecognizer.swift,
Core/MouseEventGenerator.swift,
Core/MultitouchFramework.swift,
Core/TouchCalibration.swift,
Core/TouchClassifier.swift,
Core/TouchDebugRecording.swift,
Core/TouchDeviceProviding.swift,
Info.plist,
Managers/AccessibilityMonitor.swift,
Expand All @@ -132,6 +145,9 @@
MiddleDragTests/AlertHelperTests.swift,
MiddleDragTests/AnalyticsManagerTests.swift,
MiddleDragTests/DeviceMonitorTests.swift,
"MiddleDragTests/Fixtures/one-finger-with-thumb.json",
"MiddleDragTests/Fixtures/palm-rest.json",
"MiddleDragTests/Fixtures/three-finger-drag-with-thumb-click.json",
MiddleDragTests/GestureModelsTests.swift,
MiddleDragTests/GestureRecognizerTests.swift,
MiddleDragTests/GlobalHotKeyManagerTests.swift,
Expand All @@ -145,13 +161,17 @@
MiddleDragTests/PreferencesManagerTests.swift,
MiddleDragTests/ScreenHelperTests.swift,
MiddleDragTests/SystemGestureHelperTests.swift,
MiddleDragTests/TouchCalibrationTests.swift,
MiddleDragTests/TouchDebugRecordingTests.swift,
MiddleDragTests/TouchModelsTests.swift,
MiddleDragTests/WindowHelperTests.swift,
Models/GestureModels.swift,
Models/TouchModels.swift,
UI/AlertHelper.swift,
UI/CalibrationWizardController.swift,
UI/HotKeyRecorderView.swift,
UI/MenuBarController.swift,
UI/TouchDebugWindowController.swift,
Utilities/AnalyticsManager.swift,
Utilities/GlobalHotKeyManager.swift,
Utilities/LaunchAtLoginManager.swift,
Expand Down
11 changes: 11 additions & 0 deletions MiddleDrag/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,11 @@ class AppDelegate: NSObject, NSApplicationDelegate {
// Configure multitouch manager (always configure, regardless of permission)
multitouchManager.updateConfiguration(preferences.gestureConfig)

// Apply persisted touch calibration if the user has run the wizard
if multitouchManager.loadPersistedTouchCalibration() {
Log.info("Touch calibration applied from disk", category: .app)
}

// Check Accessibility permission
// First check WITHOUT prompting to avoid showing dialog on every relaunch
var hasAccessibilityPermission = AXIsProcessTrusted()
Expand Down Expand Up @@ -98,6 +103,12 @@ class AppDelegate: NSObject, NSApplicationDelegate {
accessibilityMonitor?.onRevocation = { [weak self] in
Log.warning("Permission revoked - stopping multitouch manager", category: .app)
self?.multitouchManager.stop()
// stop() alone doesn't refresh the menu bar icon - unlike the
// device-connect/polling-timeout/manual-toggle paths, nothing else
// notifies MenuBarController of this state change, so it can be
// left showing the stale "enabled" icon until the next click.
self?.menuBarController?.updateStatusIcon(enabled: false)
self?.menuBarController?.buildMenu()
}

accessibilityMonitor?.onGrant = { [weak self] in
Expand Down
107 changes: 80 additions & 27 deletions MiddleDrag/Core/GestureRecognizer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ class GestureRecognizer {
// Stability tracking - prevents false gesture ends during brief state transitions
private var stableFrameCount: Int = 0

private let touchClassifier = TouchClassifier()

// Confirmation counter for 4-finger cancellation during an active drag
private var consecutiveFourFingerFrames: Int = 0

// Cooldown after 4-finger cancellation
// Prevents accidental gesture triggers when lifting one finger during Mission Control
private var isInCancellationCooldown: Bool = false
Expand All @@ -42,8 +47,6 @@ class GestureRecognizer {
_ touches: UnsafeMutableRawPointer, count: Int, timestamp: Double,
modifierFlags: CGEventFlags
) {
let touchArray = unsafe touches.bindMemory(to: MTTouch.self, capacity: count)

// Check modifier key requirement first (if enabled)
if configuration.requireModifierKey {
let requiredFlagPresent: Bool
Expand All @@ -67,48 +70,64 @@ class GestureRecognizer {
}
}

// 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..<count {
let touch = unsafe touchArray[i]
if touch.state == 3 || touch.state == 4 {
let position = touch.normalizedVector.position

// Palm rejection: Exclusion zone filter
// Skip touches in the bottom portion of trackpad (where palm rests)
if configuration.exclusionZoneEnabled {
if position.y < configuration.exclusionZoneSize {
continue // Skip this touch
}
}
let frame = touchClassifier.classify(
touches: touches,
count: count,
timestamp: timestamp,
configuration: configuration
)
processClassifiedFrame(frame, timestamp: timestamp, modifierFlags: modifierFlags)
}

// Palm rejection: Contact size filter
// Skip touches that are too large (palms have larger contact area)
if configuration.contactSizeFilterEnabled {
if touch.zTotal > configuration.maxContactSize {
continue // Skip this touch - likely a palm
}
}
func processClassifiedFrame(
_ frame: ClassifiedTouchFrame,
timestamp: Double,
modifierFlags: CGEventFlags
) {
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)
}

validFingers.append(position)
if !requiredFlagPresent {
if state != .idle {
handleGestureCancel()
}
return
}
}

let countedTouches = countableTouches(in: frame)
let validFingers = countedTouches.map { $0.sample.position }
let fingerCount = validFingers.count

// ALWAYS cancel on 4+ fingers regardless of configuration
// This ensures Mission Control and other system gestures always work
if fingerCount >= 4 {
// During an active drag, require a few consecutive frames before
// cancelling so a single-frame classification flicker (a thumb
// momentarily reading as a digit) cannot kill the drag. ~24ms is
// imperceptible for a real four-finger swipe.
if state == .dragging {
consecutiveFourFingerFrames += 1
guard consecutiveFourFingerFrames >= 3 else { return }
}
if state != .idle {
handleGestureCancel()
}
// Enter cooldown to prevent restart when finger is briefly lifted
isInCancellationCooldown = true
return
}
consecutiveFourFingerFrames = 0

// Clear cooldown when finger count drops to 0-2,
// or when finger count is 3 and we're idle (so user can start a new gesture)
Expand Down Expand Up @@ -144,6 +163,38 @@ class GestureRecognizer {
frameCount += 1
}

/// The contacts that count toward the finger total for gesture decisions.
///
/// Normally that is every digit-role and uncertain-role contact, but a thumb
/// resting, landing to click, or adjusting position must not read as a fourth
/// finger. On real hardware a thumb's size/shape flags it `uncertain` while a
/// fingertip never is (size evidence 0.000 at p95), so uncertain-role extras
/// beyond the three established digits are always forgiven. During an active
/// drag even digit-role extras are forgiven while stationary — the classifier
/// needs a few hundred milliseconds of differential motion before it can mark
/// a newcomer incidental. A genuine system gesture still cancels: its four
/// contacts are digit-role fingertips sweeping together.
private func countableTouches(in frame: ClassifiedTouchFrame) -> [ClassifiedTouch] {
let digits = frame.digitTouches
guard digits.count > 3 else { return digits }

// Digit-role contacts first, then older before younger, so the ambiguous
// newcomers (or a long-resting thumb) end up in the extras.
let ranked = digits.sorted { first, second in
let firstIsDigit = first.role == .digit
let secondIsDigit = second.role == .digit
if firstIsDigit != secondIsDigit { return firstIsDigit }
return first.features.age > second.features.age
}
let core = Array(ranked.prefix(3))
let extras = ranked.dropFirst(3)

let hasGenuineFourthDigit = extras.contains { extra in
extra.role == .digit && (state != .dragging || extra.features.speed >= 0.25)
}
return hasGenuineFourthDigit ? digits : core
}

/// Reset gesture recognition state
func reset() {
state = .idle
Expand All @@ -154,6 +205,8 @@ class GestureRecognizer {
frameCount = 0
stableFrameCount = 0
isInCancellationCooldown = false // Clear cooldown on reset
consecutiveFourFingerFrames = 0
touchClassifier.reset()
}

// MARK: - Private Methods
Expand Down
Loading