fix: avoid unintentional trackpad trigger (closes #4278)

This commit is contained in:
lwouis committed 2026-01-11 14:55:26 +01:00
1 parent a06afd4bd2
commit 0539a3ad49
1 file changed
+132 -110
+132 -110
View File
@@ -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<NSTouch>, _ 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<NSTouch>, _ activeTouches: Set<NSTouch>, _ 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<NSTouch>,_ 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<NSTouch>, _ 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<NSTouch>, _ 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<NSTouch>, _ startPositions: [String: NSPoint]) -> Array<NSPoint> {
var deltas: Array<NSPoint> = []
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<NSTouch>) -> 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<NSTouch>) -> 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<NSTouch>) -> Bool? {
let averageDistance = GestureDetector.computeAverageDistance(activeTouches, startPositions)
static func hasDetected(_ activeTouches: Set<NSTouch>) {
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<NSTouch>) -> 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<NSTouch>) -> 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<NSTouch>) -> Array<NSPoint> {
var deltas: Array<NSPoint> = []
for touch in activeTouches {
deltas.append(touch.normalizedPosition - startPositions["\(touch.identity)"]!)
}
return deltas
}
func reset() {
startPositions.removeAll(keepingCapacity: true)
}
static func resetX(_ activeTouches: Set<NSTouch>) {
func resetX(_ activeTouches: Set<NSTouch>) {
for touch in activeTouches {
startPositions["\(touch.identity)"]!.x = touch.normalizedPosition.x
}
}
static func resetY(_ activeTouches: Set<NSTouch>) {
func resetY(_ activeTouches: Set<NSTouch>) {
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
}