diff --git a/alt-tab-macos.xcodeproj/project.pbxproj b/alt-tab-macos.xcodeproj/project.pbxproj index a3cc9eb3..9ad5f980 100644 --- a/alt-tab-macos.xcodeproj/project.pbxproj +++ b/alt-tab-macos.xcodeproj/project.pbxproj @@ -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 = ""; }; BF0C8EE17F3ABD7A6D44BFFE /* pl */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = pl; path = Localizable.strings; sourceTree = ""; }; BF0C8F58B38F29B2662E6C2D /* PreferencesMigrations.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PreferencesMigrations.swift; sourceTree = ""; }; + BF0C8FAF34022E23582797F3 /* ScrollwheelEvents.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScrollwheelEvents.swift; sourceTree = ""; }; BF0C8FB9777EE9BE74EF542B /* zh-hk */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = "zh-hk"; path = Localizable.strings; sourceTree = ""; }; BF0C8FC083A2443F3EAF5DB7 /* preferences-blacklist.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "preferences-blacklist.jpg"; sourceTree = ""; }; BF0C8FEE46E9A73F617EF679 /* gu */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = gu; path = Localizable.strings; sourceTree = ""; }; @@ -1890,6 +1892,7 @@ BF0C8B5D1A9658E64F8ECB56 /* SystemScrollerStyleEvents.swift */, BF0C8643F524515224BF1618 /* KeyboardEventsTestable.swift */, BF0C8C69316EAAF6DC9AE391 /* CliEvents.swift */, + BF0C8FAF34022E23582797F3 /* ScrollwheelEvents.swift */, ); path = events; sourceTree = ""; @@ -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; }; diff --git a/src/api-wrappers/HelperExtensions.swift b/src/api-wrappers/HelperExtensions.swift index 02f18658..c593b593 100644 --- a/src/api-wrappers/HelperExtensions.swift +++ b/src/api-wrappers/HelperExtensions.swift @@ -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" + } + } +} diff --git a/src/logic/events/MouseEvents.swift b/src/logic/events/MouseEvents.swift index 9feae7f0..e55abd0a 100644 --- a/src/logic/events/MouseEvents.swift +++ b/src/logic/events/MouseEvents.swift @@ -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) diff --git a/src/logic/events/ScrollwheelEvents.swift b/src/logic/events/ScrollwheelEvents.swift new file mode 100644 index 00000000..764754da --- /dev/null +++ b/src/logic/events/ScrollwheelEvents.swift @@ -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 +} diff --git a/src/logic/events/TrackpadEvents.swift b/src/logic/events/TrackpadEvents.swift index fc3def68..d9429b47 100644 --- a/src/logic/events/TrackpadEvents.swift +++ b/src/logic/events/TrackpadEvents.swift @@ -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, _ 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) -> Bool? { - let averageDistance = GestureDetector.computeAverageDistance(touches, startPositions) + static func check(_ activeTouches: Set) -> 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 diff --git a/src/ui/App.swift b/src/ui/App.swift index a9e63a25..0033f10f 100644 --- a/src/ui/App.swift +++ b/src/ui/App.swift @@ -62,6 +62,7 @@ class App: AppCenterApplication { isFirstSummon = true forceDoNothingOnRelease = false MouseEvents.toggle(false) + ScrollwheelEvents.toggle(false) hideThumbnailPanelWithoutChangingKeyWindow() if !keepPreview { previewPanel.orderOut(nil)