feat: improve accidental cursor movement

see https://github.com/lwouis/alt-tab-macos/issues/4278#issuecomment-3733280737
This commit is contained in:
lwouis committed 2026-01-11 14:55:26 +01:00
1 parent 0539a3ad49
commit 74b5a119d3
8 files changed
+96 -13

No files matched your search

+4
View File
@@ -101,6 +101,7 @@
BF0C827C61136E83F43C6436 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = BF0C8913DCD0FE4C494055B6 /* InfoPlist.strings */; };
BF0C828D53D429C2B8B22391 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = BF0C883EBA8FDA9CB61C91FD /* Localizable.strings */; };
BF0C829583ED47E458528F3A /* accessibility@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = BF0C889529FF19620EDAC295 /* accessibility@2x.png */; };
BF0C82E9AB495AAAA182764A /* CursorEvents.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0C81DD40D5AAD9B7C09028 /* CursorEvents.swift */; };
BF0C832C5BF4C8E9C95F7767 /* MissionControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0C82E4DE5A28800F1DA6E6 /* MissionControl.swift */; };
BF0C83578F7292F307E30751 /* PopupButtonLikeSystemSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0C8A479D2B7FB80531F037 /* PopupButtonLikeSystemSettings.swift */; };
BF0C836CC0D9CEE5026B8EF4 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = BF0C83121AAE4437993B4FF3 /* InfoPlist.strings */; };
@@ -387,6 +388,7 @@
BF0C81BB57B297B2432C4824 /* test-release.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = "test-release.xcconfig"; sourceTree = "<group>"; };
BF0C81BF699036148BA17B7C /* jv */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = jv; path = Localizable.strings; sourceTree = "<group>"; };
BF0C81D602ED642A48D50B78 /* notarytool */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = notarytool; sourceTree = "<group>"; };
BF0C81DD40D5AAD9B7C09028 /* CursorEvents.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CursorEvents.swift; sourceTree = "<group>"; };
BF0C82009DCCA86287730F13 /* th */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = th; path = InfoPlist.strings; sourceTree = "<group>"; };
BF0C821029C1CBAC09232B0B /* nn */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = nn; path = InfoPlist.strings; sourceTree = "<group>"; };
BF0C822D3BEDAB1F1C9F775E /* ScreensEvents.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScreensEvents.swift; sourceTree = "<group>"; };
@@ -1898,6 +1900,7 @@
BF0C8643F524515224BF1618 /* KeyboardEventsTestable.swift */,
BF0C8C69316EAAF6DC9AE391 /* CliEvents.swift */,
BF0C8FAF34022E23582797F3 /* ScrollwheelEvents.swift */,
BF0C81DD40D5AAD9B7C09028 /* CursorEvents.swift */,
);
path = events;
sourceTree = "<group>";
@@ -2476,6 +2479,7 @@
BF0C8E5F4E4D5F7DAEE07C20 /* ThumbnailsPanelBackgroundView.swift in Sources */,
BF0C8AD478399D2E97AE3FB4 /* CustomRecorderControlTestable.swift in Sources */,
BF0C8A5FA46C5E74A071FF4C /* ScrollwheelEvents.swift in Sources */,
BF0C82E9AB495AAAA182764A /* CursorEvents.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
+14
View File
@@ -381,3 +381,17 @@ extension String.StringInterpolation {
}
}
}
extension CGEvent {
func toNSEvent() -> NSEvent? {
if Thread.isMainThread {
return NSEvent(cgEvent: self)
}
// conversion has to happen on the main-thread, or appkit will crash
var nsEvent: NSEvent?
DispatchQueue.main.sync {
nsEvent = NSEvent(cgEvent: self)
}
return nsEvent
}
}
+69
View File
@@ -0,0 +1,69 @@
import Cocoa
import Carbon.HIToolbox.Events
class CursorEvents {
private static var eventTap: CFMachPort!
private static var shouldBeEnabled: Bool!
static var deadZoneInitialPosition: CGPoint?
static var isAllowedToMouseHover = true
static func observe() {
observe_()
}
static func toggle(_ enabled: Bool) {
guard enabled != shouldBeEnabled else { return }
shouldBeEnabled = enabled
if !enabled {
deadZoneInitialPosition = nil
}
if let eventTap {
CGEvent.tapEnable(tap: eventTap, enable: enabled)
}
}
private static func observe_() {
let eventMask = [CGEventType.mouseMoved].reduce(CGEventMask(0), { $0 | (1 << $1.rawValue) })
// CGEvent.tapCreate returns nil if ensureAccessibilityCheckboxIsChecked() didn't pass
eventTap = CGEvent.tapCreate(
tap: .cgSessionEventTap,
place: .headInsertEventTap,
options: .defaultTap,
eventsOfInterest: eventMask,
callback: handleEvent,
userInfo: nil)
if let eventTap {
toggle(false)
let runLoopSource = CFMachPortCreateRunLoopSource(nil, eventTap, 0)
// we run on main-thread directly since all we do is check NSEvent data, which we must do on main-thread
CFRunLoopAddSource(CFRunLoopGetMain(), runLoopSource, .commonModes)
} else {
App.app.restart()
}
}
private static let handleEvent: CGEventTapCallBack = { _, type, cgEvent, _ in
if type == .mouseMoved {
updateDeadzoneSituation(cgEvent)
} else if (type == .tapDisabledByUserInput || type == .tapDisabledByTimeout) && shouldBeEnabled {
CGEvent.tapEnable(tap: eventTap!, enable: true)
}
return Unmanaged.passUnretained(cgEvent) // focused app will receive the event
}
/// when using the trackpad, the user may swipe with a slight mistake. This will create a small cursor movement
/// we ignore those, as they are not intended. Intended movements will be larger and not ignored
private static func updateDeadzoneSituation(_ cgEvent: CGEvent) {
guard let event = cgEvent.toNSEvent() else { return }
guard let deadZoneInitialPosition else {
deadZoneInitialPosition = event.locationInWindow
isAllowedToMouseHover = false
return
}
let deltaX = event.locationInWindow.x - deadZoneInitialPosition.x
let deltaY = event.locationInWindow.y - deadZoneInitialPosition.y
let d = hypot(deltaX, deltaY)
Logger.error { d }
isAllowedToMouseHover = d > 25
}
}
+1 -1
View File
@@ -28,7 +28,7 @@ class MouseEvents {
callback: handleEvent,
userInfo: nil)
if let eventTap {
MouseEvents.toggle(false)
toggle(false)
let runLoopSource = CFMachPortCreateRunLoopSource(nil, eventTap, 0)
// we run on main-thread directly since all we do is check UI coordinates, which we must do on main-thread
CFRunLoopAddSource(CFRunLoopGetMain(), runLoopSource, .commonModes)
+4 -11
View File
@@ -4,6 +4,7 @@ import Cocoa
class TrackpadEvents {
private static var eventTap: CFMachPort!
private static var shouldBeEnabled: Bool!
private static var cursorMovedDistance = CGFloat(0.0)
static func observe() {
observe_()
@@ -48,15 +49,15 @@ class TrackpadEvents {
}
private static func touchEventHandler(_ cgEvent: CGEvent) -> Bool {
guard let nsEvent = convertEvent(cgEvent) else { return false } // don't absorb the touch event
guard let nsEvent = cgEvent.toNSEvent() else { return false } // don't absorb the touch event
let touches = nsEvent.allTouches()
// Logger.error { (touches.count, touches.map { $0.phase.readable}) }
// 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) }
let requiredFingers = Preferences.nextWindowGesture.isThreeFinger() ? 3 : 4
// Logger.error("---", "activeTouches:", activeTouches.count, "all:", touches.map { $0.phase.readable }, requiredFingers)
if App.app.appIsBeingUsed {
handleEventIfAppIsBeingUsed(activeTouches, requiredFingers)
return true // absorb the touch event
@@ -74,6 +75,7 @@ class TrackpadEvents {
}
}
} else if activeTouches.count > 1 {
CursorEvents.deadZoneInitialPosition = nil
NavigationSwipeDetector.hasDetected(activeTouches)
} else {
// if activeTouches.count == 1, ignore (finger is in pointer-mode)
@@ -232,12 +234,3 @@ fileprivate func performHapticFeedback() {
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
}
+2
View File
@@ -63,6 +63,7 @@ class App: AppCenterApplication {
isFirstSummon = true
forceDoNothingOnRelease = false
MouseEvents.toggle(false)
CursorEvents.toggle(false)
ScrollwheelEvents.toggle(false)
hideThumbnailPanelWithoutChangingKeyWindow()
if !keepPreview {
@@ -314,6 +315,7 @@ extension App: NSApplicationDelegate {
self.feedbackWindow = FeedbackWindow()
KeyboardEvents.addEventHandlers()
MouseEvents.observe()
CursorEvents.observe()
TrackpadEvents.observe()
CliEvents.observe()
// login item and plist updates can be done a bit later, to accelerate launch
+1
View File
@@ -71,6 +71,7 @@ class ThumbnailsPanel: NSPanel {
alphaValue = 1
makeKeyAndOrderFront(nil)
MouseEvents.toggle(true)
CursorEvents.toggle(true)
thumbnailsView.scrollView.flashScrollers()
}
+1 -1
View File
@@ -290,7 +290,7 @@ class ScrollView: NSScrollView {
}
override func mouseMoved(with event: NSEvent) {
guard let documentView, !isCurrentlyScrolling && !ScrollwheelEvents.shouldBeEnabled else { return }
guard let documentView, !isCurrentlyScrolling && CursorEvents.isAllowedToMouseHover else { return }
let location = documentView.convert(App.app.thumbnailsPanel.mouseLocationOutsideOfEventStream, from: nil)
let newTarget = findTarget(location)
guard newTarget !== previousTarget else { return }