diff --git a/src/logic/events/TrackpadEvents.swift b/src/logic/events/TrackpadEvents.swift index 9cc7e3b6..33f9f02e 100644 --- a/src/logic/events/TrackpadEvents.swift +++ b/src/logic/events/TrackpadEvents.swift @@ -48,88 +48,73 @@ class TrackpadEvents { } private static func touchEventHandler(_ cgEvent: CGEvent) -> Bool { - guard let nsEvent = GestureDetector.convertEvent(cgEvent) else { return false } + guard let nsEvent = convertEvent(cgEvent) else { return false } // don't absorb the touch event let touches = nsEvent.allTouches() // sometimes the os sends events with no touches; we ignore these as they could break our gesture logic - if touches.count == 0 { return false } + if touches.count == 0 { + return false + } let activeTouches = touches.filter { !$0.isResting && ($0.phase == .began || $0.phase == .moved || $0.phase == .stationary) } - // Logger.error("---", "activeTouches:", activeTouches.count, "all:", touches.map { $0.phase.readable }) let requiredFingers = Preferences.nextWindowGesture.isThreeFinger() ? 3 : 4 - // not enough fingers are down - if (!App.app.appIsBeingUsed && activeTouches.count != requiredFingers) || (App.app.appIsBeingUsed && activeTouches.count < 2) { - DispatchQueue.main.async { ScrollwheelEvents.toggle(false) } - TriggerSwipeDetector.reset() - NavigationSwipeDetector.reset() - return GestureDetector.checkForFingersUp(activeTouches.count, requiredFingers) - } - // enough fingers are down + // Logger.error("---", "activeTouches:", activeTouches.count, "all:", touches.map { $0.phase.readable }, requiredFingers) if App.app.appIsBeingUsed { - DispatchQueue.main.async { ScrollwheelEvents.toggle(true) } - if !GestureDetector.updateStartPositions(activeTouches, &NavigationSwipeDetector.startPositions) { - if let r = NavigationSwipeDetector.check(activeTouches) { return r } + handleEventIfAppIsBeingUsed(activeTouches, requiredFingers) + return true // absorb the touch event + } + return handleEventIfAppIsNotBeingUsed(touches, activeTouches, requiredFingers) // absorb or not the touch event, depending on the situation + } + + private static func handleEventIfAppIsBeingUsed(_ activeTouches: Set, _ requiredFingers: Int) { + if activeTouches.count == 0 { + NavigationSwipeDetector.reset() + if App.app.shortcutIndex == Preferences.gestureIndex && !App.app.forceDoNothingOnRelease && Preferences.shortcutStyle[App.app.shortcutIndex] == .focusOnRelease { + DispatchQueue.main.async { + ScrollwheelEvents.toggle(false) + App.app.focusTarget() + } } + } else if activeTouches.count > 1 { + NavigationSwipeDetector.hasDetected(activeTouches) } else { - if !GestureDetector.updateStartPositions(activeTouches, &TriggerSwipeDetector.startPositions) { - if let r = TriggerSwipeDetector.check(activeTouches) { return r } - } + // if activeTouches.count == 1, ignore (finger is in pointer-mode) + } + } + + private static func handleEventIfAppIsNotBeingUsed(_ touches: Set, _ activeTouches: Set, _ requiredFingers: Int) -> Bool { + if touches.count <= 1 { + NonFreshGestureDetector.reset() + return false // don't absorb the touch event + } + if NonFreshGestureDetector.hasDetected(activeTouches, requiredFingers) { + return false // don't absorb the touch event + } + if activeTouches.count != requiredFingers { + TriggerSwipeDetector.reset() + return false // don't absorb the touch event + } else { + return TriggerSwipeDetector.hasDetected(activeTouches) // absorb or not the touch event, depending on the situation } - return false } } -class GestureDetector { - static func convertEvent(_ cgEvent: CGEvent) -> NSEvent? { - var nsEvent: NSEvent? - // conversion has to happen on the main-thread, or appkit will crash - DispatchQueue.main.sync { - nsEvent = NSEvent(cgEvent: cgEvent) - } - return nsEvent +class NonFreshGestureDetector { + private static var userHasDoneAnotherGesture = false + private static var gestureTracker = GestureTracker() + + /// if the user has already used a gesture-action (e.g. 2-finger scroll), we consider this "session" invalid, until all fingers are released + /// This prevents: 4->3 trigger (System swipe already happened), or 2->3 trigger (System scroll already happened) + static func hasDetected(_ activeTouches: Set,_ requiredFingers: Int) -> Bool { + guard !userHasDoneAnotherGesture else { return true } + let new = gestureTracker.isNewGesture(activeTouches) + guard activeTouches.count != requiredFingers && !new else { return false } + let distances = gestureTracker.computeDistance(activeTouches) + userHasDoneAnotherGesture = distances.contains(where: { abs($0.x) >= TriggerSwipeDetector.MIN_SWIPE_DISTANCE || abs($0.y) >= TriggerSwipeDetector.MIN_SWIPE_DISTANCE }) + return userHasDoneAnotherGesture } - static func checkForFingersUp(_ fingersDown: Int, _ requiredFingers: Int) -> Bool { - if App.app.appIsBeingUsed && fingersDown < requiredFingers && App.app.shortcutIndex == Preferences.gestureIndex - && !App.app.forceDoNothingOnRelease && Preferences.shortcutStyle[App.app.shortcutIndex] == .focusOnRelease { - DispatchQueue.main.async { App.app.focusTarget() } - return true - } - return false - } - - static func updateStartPositions(_ activeTouches: Set, _ startPositions: inout [String: NSPoint]) -> Bool { - // if touches are new, record their startPositions - if (activeTouches.contains { startPositions["\($0.identity)"] == nil }) { - for touch in activeTouches { - startPositions["\(touch.identity)"] = touch.normalizedPosition - } - return true - } - return false - } - - static func computeAverageDistance(_ activeTouches: Set, _ startPositions: [String: NSPoint]) -> NSPoint { - var totalDelta = NSPoint(x: 0, y: 0) - for touch in activeTouches { - totalDelta = totalDelta + (touch.normalizedPosition - startPositions["\(touch.identity)"]!) - } - return totalDelta / activeTouches.count - } - - static func computeDistance(_ activeTouches: Set, _ startPositions: [String: NSPoint]) -> Array { - var deltas: Array = [] - for touch in activeTouches { - deltas.append(touch.normalizedPosition - startPositions["\(touch.identity)"]!) - } - return deltas - } - - static func blockOngoingScrolling() { - if #available(macOS 10.13, *) { - // simulate mouseWheel-stopped to end potential existing scrolling started before AltTab was opened - // this avoid the swipe to trigger a scroll on the active window and show AltTab, at the same time - CGEvent(scrollWheelEvent2Source: nil, units: .pixel, wheelCount: 1, wheel1: 0, wheel2: 0, wheel3: 0)? - .post(tap: .cghidEventTap) - } + static func reset() { + userHasDoneAnotherGesture = false + gestureTracker.reset() } } @@ -141,37 +126,31 @@ class TriggerSwipeDetector { // if the user moves too much in the vertical direction. We imitate this behavior static let MAX_SWIPE_DISTANCE_IN_WRONG_DIRECTION: Double = 0.1 // % of trackpad surface traveled - static var startPositions: [String: NSPoint] = [:] - static var swipeStillPossible = true + private static var gestureTracker = GestureTracker() + private static var swipeStillPossible = true - static func check(_ activeTouches: Set) -> Bool? { - if !App.app.appIsBeingUsed && swipeStillPossible { - let distances = GestureDetector.computeDistance(activeTouches, startPositions) - for distance in distances { - let (absX, absY) = (abs(distance.x), abs(distance.y)) - let horizontal = Preferences.nextWindowGesture.isHorizontal() - if (!(updateSwipeStillPossible(horizontal ? absY : absX) && (horizontal ? absX : absY) >= MIN_SWIPE_DISTANCE )) { - return false - } - } - reset() - DispatchQueue.main.async { - ScrollwheelEvents.toggle(true) - performHapticFeedback() - App.app.showUiOrCycleSelection(Preferences.gestureIndex, false) - } - return true + static func hasDetected(_ activeTouches: Set) -> Bool { + guard swipeStillPossible && !gestureTracker.isNewGesture(activeTouches) else { return false } + let distances = gestureTracker.computeDistance(activeTouches) + for distance in distances { + let (absX, absY) = (abs(distance.x), abs(distance.y)) + let horizontal = Preferences.nextWindowGesture.isHorizontal() + let distanceInRightDirection = horizontal ? absX : absY + let distanceInWrongDirection = horizontal ? absY : absX + swipeStillPossible = distanceInWrongDirection < MAX_SWIPE_DISTANCE_IN_WRONG_DIRECTION + guard swipeStillPossible && distanceInRightDirection >= MIN_SWIPE_DISTANCE else { return false } } - return nil - } - - static func updateSwipeStillPossible(_ distanceInWrongDirection: Double) -> Bool { - swipeStillPossible = distanceInWrongDirection < MAX_SWIPE_DISTANCE_IN_WRONG_DIRECTION - return swipeStillPossible + reset() + DispatchQueue.main.async { + ScrollwheelEvents.toggle(true) + performHapticFeedback() + App.app.showUiOrCycleSelection(Preferences.gestureIndex, false) + } + return true } static func reset() { - startPositions.removeAll(keepingCapacity: true) + gestureTracker.reset() swipeStillPossible = true } } @@ -179,43 +158,86 @@ class TriggerSwipeDetector { class NavigationSwipeDetector { static let MIN_SWIPE_DISTANCE: Double = 0.03 // % of trackpad surface traveled - static var startPositions: [String: NSPoint] = [:] + private static var gestureTracker = GestureTracker() - static func check(_ activeTouches: Set) -> Bool? { - let averageDistance = GestureDetector.computeAverageDistance(activeTouches, startPositions) + static func hasDetected(_ activeTouches: Set) { + guard !gestureTracker.isNewGesture(activeTouches) else { return } + let averageDistance = gestureTracker.computeAverageDistance(activeTouches) let (absX, absY) = (abs(averageDistance.x), abs(averageDistance.y)) let maxIsX = absX >= absY - if (maxIsX ? absX : absY) > MIN_SWIPE_DISTANCE { - maxIsX ? resetX(activeTouches) : resetY(activeTouches) - let direction: Direction = maxIsX ? (averageDistance.x < 0 ? .left : .right) : (averageDistance.y < 0 ? .down : .up) - DispatchQueue.main.async { - performHapticFeedback() - App.app.cycleSelection(direction, allowWrap: false) - } - return true + guard (maxIsX ? absX : absY) > MIN_SWIPE_DISTANCE else { return } + maxIsX ? gestureTracker.resetX(activeTouches) : gestureTracker.resetY(activeTouches) + let direction: Direction = maxIsX ? (averageDistance.x < 0 ? .left : .right) : (averageDistance.y < 0 ? .down : .up) + DispatchQueue.main.async { + performHapticFeedback() + App.app.cycleSelection(direction, allowWrap: false) } - return nil } static func reset() { + gestureTracker.reset() + } +} + +class GestureTracker { + var startPositions = [String: NSPoint]() + + @discardableResult + func isNewGesture(_ activeTouches: Set) -> Bool { + // if touches are new, record their startPositions + if (activeTouches.contains { startPositions["\($0.identity)"] == nil }) { + for touch in activeTouches { + startPositions["\(touch.identity)"] = touch.normalizedPosition + } + return true + } + return false + } + + func computeAverageDistance(_ activeTouches: Set) -> NSPoint { + var totalDelta = NSPoint(x: 0, y: 0) + for touch in activeTouches { + totalDelta = totalDelta + (touch.normalizedPosition - startPositions["\(touch.identity)"]!) + } + return totalDelta / activeTouches.count + } + + func computeDistance(_ activeTouches: Set) -> Array { + var deltas: Array = [] + for touch in activeTouches { + deltas.append(touch.normalizedPosition - startPositions["\(touch.identity)"]!) + } + return deltas + } + + func reset() { startPositions.removeAll(keepingCapacity: true) } - static func resetX(_ activeTouches: Set) { + func resetX(_ activeTouches: Set) { for touch in activeTouches { startPositions["\(touch.identity)"]!.x = touch.normalizedPosition.x } } - static func resetY(_ activeTouches: Set) { + func resetY(_ activeTouches: Set) { for touch in activeTouches { startPositions["\(touch.identity)"]!.y = touch.normalizedPosition.y } } } -func performHapticFeedback() { +fileprivate func performHapticFeedback() { if Preferences.trackpadHapticFeedbackEnabled { NSHapticFeedbackManager.defaultPerformer.perform(.generic, performanceTime: .default) } } + +fileprivate func convertEvent(_ cgEvent: CGEvent) -> NSEvent? { + var nsEvent: NSEvent? + // conversion has to happen on the main-thread, or appkit will crash + DispatchQueue.main.sync { + nsEvent = NSEvent(cgEvent: cgEvent) + } + return nsEvent +}