fix: better gesture recognition (closes #5039)

This commit is contained in:
lwouis committed 2025-11-24 11:19:42 +01:00
1 parent e477f8b3da
commit 168995493f
6 files changed
+89 -18

No files matched your search

+4
View File
@@ -152,6 +152,7 @@
BF0C8A3F32B2177AF407DC7E /* DockEvents.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0C870C14C20C936BA4AF40 /* DockEvents.swift */; };
BF0C8A408B8E1C662E457A99 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = BF0C86C535CC75FA07FAA09B /* InfoPlist.strings */; };
BF0C8A49891371E4027E0641 /* MacroPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0C8998862E0E04D60CACBC /* MacroPreferences.swift */; };
BF0C8A5FA46C5E74A071FF4C /* ScrollwheelEvents.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0C8FAF34022E23582797F3 /* ScrollwheelEvents.swift */; };
BF0C8A739A74695E60F16369 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = BF0C8062E78677398A4A217B /* InfoPlist.strings */; };
BF0C8A95AAD9DADC95887A2D /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = BF0C895A9AF79A869EE6B108 /* Localizable.strings */; };
BF0C8AC57CE5E9BBEEE2442A /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = BF0C885D34CDCE2AFC5A94F7 /* InfoPlist.strings */; };
@@ -524,6 +525,7 @@
BF0C8EC400A560FDF99F8C35 /* el */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = el; path = Localizable.strings; sourceTree = "<group>"; };
BF0C8EE17F3ABD7A6D44BFFE /* pl */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = pl; path = Localizable.strings; sourceTree = "<group>"; };
BF0C8F58B38F29B2662E6C2D /* PreferencesMigrations.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PreferencesMigrations.swift; sourceTree = "<group>"; };
BF0C8FAF34022E23582797F3 /* ScrollwheelEvents.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScrollwheelEvents.swift; sourceTree = "<group>"; };
BF0C8FB9777EE9BE74EF542B /* zh-hk */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = "zh-hk"; path = Localizable.strings; sourceTree = "<group>"; };
BF0C8FC083A2443F3EAF5DB7 /* preferences-blacklist.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "preferences-blacklist.jpg"; sourceTree = "<group>"; };
BF0C8FEE46E9A73F617EF679 /* gu */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = gu; path = Localizable.strings; sourceTree = "<group>"; };
@@ -1890,6 +1892,7 @@
BF0C8B5D1A9658E64F8ECB56 /* SystemScrollerStyleEvents.swift */,
BF0C8643F524515224BF1618 /* KeyboardEventsTestable.swift */,
BF0C8C69316EAAF6DC9AE391 /* CliEvents.swift */,
BF0C8FAF34022E23582797F3 /* ScrollwheelEvents.swift */,
);
path = events;
sourceTree = "<group>";
@@ -2466,6 +2469,7 @@
BF0C826C7F8C5F68732FD75B /* DebugMenu.swift in Sources */,
BF0C8E5F4E4D5F7DAEE07C20 /* ThumbnailsPanelBackgroundView.swift in Sources */,
BF0C8AD478399D2E97AE3FB4 /* CustomRecorderControlTestable.swift in Sources */,
BF0C8A5FA46C5E74A071FF4C /* ScrollwheelEvents.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
+13
View File
@@ -321,3 +321,16 @@ extension NSRunningApplication {
// 250ms is similar to human delay in processing changes on screen
// See https://humanbenchmark.com/tests/reactiontime
let humanPerceptionDelay = DispatchTimeInterval.milliseconds(250)
extension NSTouch.Phase {
var readable: String {
switch self {
case .began: "began"
case .moved: "moved"
case .stationary: "stationary"
case .ended: "ended"
case .cancelled: "cancelled"
default: "unknown"
}
}
}
+1
View File
@@ -10,6 +10,7 @@ class MouseEvents {
}
static func toggle(_ enabled: Bool) {
guard enabled != shouldBeEnabled else { return }
shouldBeEnabled = enabled
if let eventTap {
CGEvent.tapEnable(tap: eventTap, enable: enabled)
+46
View File
@@ -0,0 +1,46 @@
import Cocoa
fileprivate var eventTap: CFMachPort!
fileprivate var shouldBeEnabled: Bool!
class ScrollwheelEvents {
static func observe() {
observe_()
toggle(false)
}
static func toggle(_ enabled: Bool) {
guard enabled != shouldBeEnabled else { return }
shouldBeEnabled = enabled
if let eventTap {
CGEvent.tapEnable(tap: eventTap, enable: enabled)
}
}
}
private func observe_() {
// CGEvent.tapCreate returns null if ensureAccessibilityCheckboxIsChecked() didn't pass
eventTap = CGEvent.tapCreate(
tap: .cghidEventTap, // we need raw data
place: .headInsertEventTap,
options: .defaultTap,
eventsOfInterest: NSEvent.EventTypeMask.scrollWheel.rawValue,
callback: handleEvent,
userInfo: nil)
if let eventTap {
let runLoopSource = CFMachPortCreateRunLoopSource(nil, eventTap, 0)
CFRunLoopAddSource(BackgroundWork.keyboardAndTrackpadEventsThread.runLoop, runLoopSource, .commonModes)
} else {
App.app.restart()
}
}
private let handleEvent: CGEventTapCallBack = { _, type, cgEvent, _ in
if type.rawValue == NSEvent.EventType.scrollWheel.rawValue {
// block scrolling globally
return nil
} else if (type == .tapDisabledByUserInput || type == .tapDisabledByTimeout) && shouldBeEnabled {
CGEvent.tapEnable(tap: eventTap!, enable: true)
}
return Unmanaged.passUnretained(cgEvent) // focused app will receive the event
}
+24 -18
View File
@@ -8,9 +8,11 @@ class TrackpadEvents {
static func observe() {
observe_()
TrackpadEvents.toggle(Preferences.nextWindowGesture != .disabled)
ScrollwheelEvents.observe()
}
static func toggle(_ enabled: Bool) {
guard enabled != shouldBeEnabled else { return }
shouldBeEnabled = enabled
if let eventTap {
CGEvent.tapEnable(tap: eventTap, enable: enabled)
@@ -48,27 +50,28 @@ private let handleEvent: CGEventTapCallBack = { _, type, cgEvent, _ in
private func touchEventHandler(_ cgEvent: CGEvent) -> Bool {
guard let nsEvent = GestureDetector.convertEvent(cgEvent) else { return false }
// if the finger count doesn't match, we reset tracking data, and may trigger fingersUp
let touches = nsEvent.allTouches()
if touches.count == 0 { return false } // sometimes the os sends events with no touches
// sometimes the os sends events with no touches; we ignore these as they could break our gesture logic
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
if (!App.app.appIsBeingUsed && touches.count != requiredFingers) || (App.app.appIsBeingUsed && touches.count < 2) {
// 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, requiredFingers)
return GestureDetector.checkForFingersUp(activeTouches.count, requiredFingers)
}
// when the native using 3-finger swipe to shift Space, macOS will block scrolling in the background
// We imitate this behavior by sending a synthetic scrollWheel event
GestureDetector.blockOngoingScrolling()
// trigger actions if conditions are met
// enough fingers are down
if App.app.appIsBeingUsed {
if !GestureDetector.updateStartPositions(touches, &NavigationSwipeDetector.startPositions) {
if let r = NavigationSwipeDetector.check(touches) { return r }
DispatchQueue.main.async { ScrollwheelEvents.toggle(true) }
if !GestureDetector.updateStartPositions(activeTouches, &NavigationSwipeDetector.startPositions) {
if let r = NavigationSwipeDetector.check(activeTouches) { return r }
}
} else {
if !GestureDetector.updateStartPositions(touches, &TriggerSwipeDetector.startPositions) {
if let r = TriggerSwipeDetector.check(touches) { return r }
if !GestureDetector.updateStartPositions(activeTouches, &TriggerSwipeDetector.startPositions) {
if let r = TriggerSwipeDetector.check(activeTouches) { return r }
}
}
return false
@@ -84,8 +87,8 @@ class GestureDetector {
return nsEvent
}
static func checkForFingersUp(_ touches: Set<NSTouch>, _ requiredFingers: Int) -> Bool {
if App.app.appIsBeingUsed && touches.count < requiredFingers && App.app.shortcutIndex == Preferences.gestureIndex
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
@@ -152,7 +155,10 @@ class TriggerSwipeDetector {
}
}
reset()
DispatchQueue.main.async { App.app.showUiOrCycleSelection(Preferences.gestureIndex, false) }
DispatchQueue.main.async {
ScrollwheelEvents.toggle(true)
App.app.showUiOrCycleSelection(Preferences.gestureIndex, false)
}
return true
}
return nil
@@ -174,12 +180,12 @@ class NavigationSwipeDetector {
static var startPositions: [String: NSPoint] = [:]
static func check(_ touches: Set<NSTouch>) -> Bool? {
let averageDistance = GestureDetector.computeAverageDistance(touches, startPositions)
static func check(_ activeTouches: Set<NSTouch>) -> Bool? {
let averageDistance = GestureDetector.computeAverageDistance(activeTouches, startPositions)
let (absX, absY) = (abs(averageDistance.x), abs(averageDistance.y))
let maxIsX = absX >= absY
if (maxIsX ? absX : absY) > MIN_SWIPE_DISTANCE {
maxIsX ? resetX(touches) : resetY(touches)
maxIsX ? resetX(activeTouches) : resetY(activeTouches)
let direction: Direction = maxIsX ? (averageDistance.x < 0 ? .left : .right) : (averageDistance.y < 0 ? .down : .up)
DispatchQueue.main.async { App.app.cycleSelection(direction, allowWrap: false) }
return true
+1
View File
@@ -62,6 +62,7 @@ class App: AppCenterApplication {
isFirstSummon = true
forceDoNothingOnRelease = false
MouseEvents.toggle(false)
ScrollwheelEvents.toggle(false)
hideThumbnailPanelWithoutChangingKeyWindow()
if !keepPreview {
previewPanel.orderOut(nil)