Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions MiddleDrag.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -62,6 +64,7 @@
EC5F1E242ED8E37A0053F6A9 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
MDT00000003 /* GestureModelsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GestureModelsTests.swift; sourceTree = "<group>"; };
MDT00000004 /* TouchModelsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TouchModelsTests.swift; sourceTree = "<group>"; };
MDT00000017 /* GestureRecognizerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GestureRecognizerTests.swift; sourceTree = "<group>"; };
MDT00000005 /* MiddleDragTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MiddleDragTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */

Expand Down Expand Up @@ -173,6 +176,7 @@
children = (
MDT00000003 /* GestureModelsTests.swift */,
MDT00000004 /* TouchModelsTests.swift */,
MDT00000017 /* GestureRecognizerTests.swift */,
);
path = MiddleDragTests;
sourceTree = "<group>";
Expand Down Expand Up @@ -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 */,
);
Expand Down
51 changes: 49 additions & 2 deletions MiddleDrag/Core/GestureRecognizer.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import Cocoa
import CoreGraphics
import Foundation

Expand Down Expand Up @@ -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..<count {
let touch = touchArray[i]
if touch.state == 3 || touch.state == 4 {
validFingers.append(touch.normalizedVector.position)
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
}
}

// 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
}
}
Comment thread
NullPointerDepressiveDisorder marked this conversation as resolved.

validFingers.append(position)
}
}

Expand Down
11 changes: 9 additions & 2 deletions MiddleDrag/Managers/MultitouchManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
Expand Down Expand Up @@ -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)

Expand Down
79 changes: 66 additions & 13 deletions MiddleDrag/Models/GestureModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ enum GestureState {
case possibleTap
case dragging
case waitingForRelease

var isActive: Bool {
switch self {
case .dragging, .possibleTap:
Expand All @@ -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
Expand All @@ -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)
Comment thread
NullPointerDepressiveDisorder marked this conversation as resolved.
)
}
}
Loading