Skip to content

Commit f37100d

Browse files
Fix middle-drag smoothness and add compatibility for some apps (#104)
* Refactor mouse event handling to use current position instead of target position; optimize touch data copying in MultitouchManager to reduce GC pressure. * Add accumulated drag position and cursor reassociation in MouseEventGenerator - Introduced `lastDragPosition` to track the accumulated drag position for improved cursor handling in applications requiring absolute positioning. - Added `reassociateCursor` method to restore normal cursor behavior after drag operations. - Updated drag handling to use accumulated position for mouse events, enhancing compatibility with various applications. - Ensured cursor is properly reassociated after drag ends or is canceled to prevent freezing issues. * Enhance cursor re-association error handling in MouseEventGenerator; optimize touch data copying in MultitouchManager to reduce GC pressure * Refactor MouseEventGenerator to improve drag position handling and add clamping to display bounds; enhance test coverage for drag position accumulation and clamping behavior * Enhance display bounds caching and reconfiguration handling in MouseEventGenerator; ensure atomic cursor reassociation during drag release to prevent race conditions * Refactor delta field setting in MouseEventGenerator to ensure proper order of double and integer values for improved compatibility with various applications * Refactor MouseEventGenerator to ensure atomic cursor reassociation and state updates during drag cancellation, preventing race conditions and improving drag state management. * Refactor MouseEventGenerator to ensure atomic cursor reassociation and drag state updates during normal drag termination, preventing race conditions and enhancing overall drag state management. * Refactor MouseEventGenerator to enhance drag state management by ensuring atomic cancellation of existing drags, preventing race conditions during new drag initiation.
1 parent 450ded5 commit f37100d

File tree

3 files changed

+414
-51
lines changed

3 files changed

+414
-51
lines changed

MiddleDrag/Core/MouseEventGenerator.swift

Lines changed: 154 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,13 @@ final class MouseEventGenerator: @unchecked Sendable {
5555
private var previousDeltaX: CGFloat = 0
5656
private var previousDeltaY: CGFloat = 0
5757

58+
// Accumulated drag position: seeded at startDrag from the real cursor,
59+
// then advanced purely by adding deltas. Used as the event position field
60+
// (for apps like Fusion 360 that read absolute position) and as the warp
61+
// target (to move the visible cursor while disassociated).
62+
// Internal access for testability.
63+
internal var lastDragPosition: CGPoint = .zero
64+
5865
// Click deduplication: tracks last click time on the serial eventQueue
5966
// to prevent multiple performClick() calls from different code paths
6067
// (force-click conversion + gesture tap) from firing within a short window.
@@ -78,6 +85,21 @@ final class MouseEventGenerator: @unchecked Sendable {
7885
eventQueue.sync { _clickCount = 0 }
7986
}
8087

88+
// MARK: - Cursor Association
89+
90+
/// Re-associate mouse with cursor after drag ends.
91+
/// Restores normal cursor behavior and default suppression interval.
92+
private func reassociateCursor() {
93+
guard shouldPostEvents else { return }
94+
let error = CGAssociateMouseAndMouseCursorPosition(1)
95+
if error != CGError.success {
96+
Log.warning(unsafe "Failed to re-associate cursor: \(error.rawValue)", category: .gesture)
97+
}
98+
if let source = eventSource {
99+
source.localEventsSuppressionInterval = 0.25
100+
}
101+
}
102+
81103
// MARK: - Initialization
82104

83105
init() {
@@ -90,9 +112,18 @@ final class MouseEventGenerator: @unchecked Sendable {
90112
/// Start a middle mouse drag operation
91113
/// - Parameter screenPosition: Starting position (used for reference, actual position from current cursor)
92114
func startDrag(at screenPosition: CGPoint) {
93-
// CRITICAL: If already in a drag state, cancel it first to prevent stuck drags
94-
// This handles the case where a second MIDDLE_DOWN arrives before the first MIDDLE_UP
95-
if isMiddleMouseDown {
115+
// CRITICAL: If already in a drag state, cancel it first to prevent stuck drags.
116+
// Recovery must re-associate + clear state + invalidate generation atomically
117+
// so watchdog force-release cannot win a race and reassociate after we
118+
// disassociate for the new drag.
119+
let recoveredExistingDrag = stateLock.withLock {
120+
guard _isMiddleMouseDown else { return false }
121+
reassociateCursor()
122+
_isMiddleMouseDown = false
123+
_dragGeneration &+= 1 // Invalidate any watchdog release in-flight for old drag
124+
return true
125+
}
126+
if recoveredExistingDrag {
96127
Log.warning("startDrag called while already dragging - canceling existing drag first", category: .gesture)
97128
// Send mouse up for the existing drag immediately (synchronously)
98129
let currentPos = currentMouseLocationQuartz
@@ -105,6 +136,25 @@ final class MouseEventGenerator: @unchecked Sendable {
105136
previousDeltaX = 0
106137
previousDeltaY = 0
107138

139+
// Seed accumulated drag position from actual cursor
140+
lastDragPosition = quartzPos
141+
142+
// Disassociate mouse from cursor so the window server won't fight with our
143+
// position field. The cursor freezes; we warp it ourselves each frame.
144+
// This lets us set both absolute position (for Fusion 360) and deltas (for
145+
// Blender/Unity) on the same event without causing micro-stutter.
146+
if shouldPostEvents {
147+
let error = CGAssociateMouseAndMouseCursorPosition(0)
148+
if error != CGError.success {
149+
Log.warning(unsafe "Failed to disassociate cursor: \(error.rawValue)", category: .gesture)
150+
}
151+
// Zero the suppression interval so our high-frequency synthetic events
152+
// don't suppress each other (default is 0.25s which eats events)
153+
if let source = eventSource {
154+
source.localEventsSuppressionInterval = 0
155+
}
156+
}
157+
108158
// Record activity time for watchdog
109159
activityLock.lock()
110160
lastActivityTime = CACurrentMediaTime()
@@ -162,12 +212,22 @@ final class MouseEventGenerator: @unchecked Sendable {
162212
return
163213
}
164214

165-
let currentPos = currentMouseLocationQuartz
166-
let targetPos = CGPoint(
167-
x: currentPos.x + smoothedDeltaX,
168-
y: currentPos.y + smoothedDeltaY
215+
// Advance accumulated position by smoothed deltas
216+
var targetPos = CGPoint(
217+
x: lastDragPosition.x + smoothedDeltaX,
218+
y: lastDragPosition.y + smoothedDeltaY
169219
)
170220

221+
// Clamp to global display bounds to prevent the accumulated position from drifting
222+
// off-screen. Without this, dragging past a screen edge creates a dead zone
223+
// when reversing direction (the position must travel back the full overshoot
224+
// before the cursor visibly moves). CGWarpMouseCursorPosition does not clamp.
225+
let globalBounds = Self.globalDisplayBounds
226+
targetPos.x = max(globalBounds.minX, min(targetPos.x, globalBounds.maxX - 1))
227+
targetPos.y = max(globalBounds.minY, min(targetPos.y, globalBounds.maxY - 1))
228+
229+
lastDragPosition = targetPos
230+
171231
guard shouldPostEvents else { return }
172232
guard
173233
let event = CGEvent(
@@ -178,27 +238,36 @@ final class MouseEventGenerator: @unchecked Sendable {
178238
)
179239
else { return }
180240

181-
// Set deltas for macOS cursor movement; position field for apps that read it
182-
event.setDoubleValueField(.mouseEventDeltaX, value: Double(smoothedDeltaX))
183-
event.setDoubleValueField(.mouseEventDeltaY, value: Double(smoothedDeltaY))
241+
// mouseEventDeltaX/Y are effectively integral in Quartz event storage.
242+
// Writing via setDoubleValueField does not preserve fractional precision, so
243+
// emit rounded integer deltas explicitly (better than implicit truncation).
244+
event.setIntegerValueField(.mouseEventDeltaX, value: Int64(smoothedDeltaX.rounded()))
245+
event.setIntegerValueField(.mouseEventDeltaY, value: Int64(smoothedDeltaY.rounded()))
184246

185247
event.setIntegerValueField(.mouseEventButtonNumber, value: 2)
186248
event.setIntegerValueField(.eventSourceUserData, value: magicUserData)
187249
event.flags = []
188250
event.post(tap: .cghidEventTap)
251+
252+
// Warp the visible cursor to match the accumulated position.
253+
// The cursor is frozen (disassociated) so the event's position field
254+
// won't move it — we must warp explicitly.
255+
CGWarpMouseCursorPosition(targetPos)
189256
}
190-
191-
/// End the drag operation
192257
func endDrag() {
193258
guard isMiddleMouseDown else { return }
194259

195260
// Stop watchdog since drag is ending normally
196261
stopWatchdog()
197-
198-
// CRITICAL: Set isMiddleMouseDown = false SYNCHRONOUSLY to match startDrag
199-
// This prevents race conditions with rapid start/end cycles and ensures
200-
// updateDrag() stops processing immediately
201-
isMiddleMouseDown = false
262+
263+
// CRITICAL: Re-associate cursor and clear drag state atomically.
264+
// Today endDrag() is called on gestureQueue, but matching the atomic teardown
265+
// used by cancel/force paths prevents future callers from introducing races
266+
// where stale reassociation can desync cursor association from drag state.
267+
stateLock.withLock {
268+
reassociateCursor()
269+
_isMiddleMouseDown = false
270+
}
202271

203272
eventQueue.async { [weak self] in
204273
guard let self = self else { return }
@@ -289,10 +358,14 @@ final class MouseEventGenerator: @unchecked Sendable {
289358
// Stop watchdog since drag is being cancelled
290359
stopWatchdog()
291360

292-
// CRITICAL: Set isMiddleMouseDown = false SYNCHRONOUSLY to match startDrag
293-
// This prevents race conditions with rapid cancel/start cycles and ensures
294-
// updateDrag() stops processing immediately
295-
isMiddleMouseDown = false
361+
// CRITICAL: Re-associate cursor and clear drag state atomically.
362+
// If reassociateCursor() runs outside stateLock, a concurrent startDrag()
363+
// can disassociate for a new drag and then be overwritten by a stale
364+
// reassociation, leaving cursor association out of sync with drag state.
365+
stateLock.withLock {
366+
reassociateCursor()
367+
_isMiddleMouseDown = false
368+
}
296369

297370
// Asynchronously send the mouse up event and clean up state
298371
// The cleanup will happen on the event queue, ensuring proper sequencing
@@ -314,9 +387,12 @@ final class MouseEventGenerator: @unchecked Sendable {
314387

315388
// Stop watchdog if running
316389
stopWatchdog()
317-
318-
// Atomically reset state and capture generation
390+
391+
// CRITICAL: Re-associate cursor and clear drag state atomically with generation.
392+
// This mirrors forceReleaseDrag() and prevents interleaving where startDrag()
393+
// disassociates for a new session between reassociation and state update.
319394
let currentGeneration: UInt64 = stateLock.withLock {
395+
reassociateCursor()
320396
_isMiddleMouseDown = false
321397
return _dragGeneration
322398
}
@@ -342,6 +418,53 @@ final class MouseEventGenerator: @unchecked Sendable {
342418
}
343419

344420
// MARK: - Coordinate Conversion
421+
422+
/// Union of all online display rects in Quartz global coordinates.
423+
/// Cached to avoid per-frame syscalls and heap allocation at 100Hz+.
424+
/// Invalidated on display reconfiguration via CGDisplayRegisterReconfigurationCallback.
425+
/// Internal access for testability.
426+
private static let displayBoundsLock = NSLock()
427+
nonisolated(unsafe) private static var _cachedDisplayBounds: CGRect?
428+
nonisolated(unsafe) private static var _displayReconfigToken: Bool = {
429+
// Register for display changes (resolution, arrangement, connect/disconnect).
430+
// The callback invalidates the cache so the next read picks up the new geometry.
431+
CGDisplayRegisterReconfigurationCallback({ _, flags, _ in
432+
// Only invalidate after the reconfiguration completes
433+
if flags.contains(.beginConfigurationFlag) { return }
434+
MouseEventGenerator.displayBoundsLock.lock()
435+
MouseEventGenerator._cachedDisplayBounds = nil
436+
MouseEventGenerator.displayBoundsLock.unlock()
437+
}, nil)
438+
return true
439+
}()
440+
441+
internal static var globalDisplayBounds: CGRect {
442+
_ = _displayReconfigToken // Ensure callback is registered
443+
444+
displayBoundsLock.lock()
445+
if let cached = _cachedDisplayBounds {
446+
displayBoundsLock.unlock()
447+
return cached
448+
}
449+
displayBoundsLock.unlock()
450+
451+
// Compute outside lock (CGGetOnlineDisplayList is thread-safe)
452+
var displayIDs = [CGDirectDisplayID](repeating: 0, count: 16)
453+
var displayCount: UInt32 = 0
454+
CGGetOnlineDisplayList(16, &displayIDs, &displayCount)
455+
456+
var union = CGRect.null
457+
for i in 0..<Int(displayCount) {
458+
union = union.union(CGDisplayBounds(displayIDs[i]))
459+
}
460+
let result = union == .null ? CGRect(x: 0, y: 0, width: 1920, height: 1080) : union
461+
462+
displayBoundsLock.lock()
463+
_cachedDisplayBounds = result
464+
displayBoundsLock.unlock()
465+
466+
return result
467+
}
345468

346469
/// Reads mouse location via AppKit on the main thread and converts to Quartz coordinates.
347470
/// AppKit APIs like NSEvent/NSScreen are not thread-safe and must not be read off-main.
@@ -520,15 +643,20 @@ final class MouseEventGenerator: @unchecked Sendable {
520643
private func forceReleaseDrag(forGeneration expectedGeneration: UInt64) {
521644
stopWatchdogLocked()
522645

523-
// CRITICAL: Verify generation still matches before clearing state
524-
// This prevents race where a new drag started between checkForStuckDrag() and now
646+
// CRITICAL: Verify generation, clear state, AND re-associate cursor atomically.
647+
// If we release the lock between setting _isMiddleMouseDown = false and calling
648+
// reassociateCursor(), a concurrent startDrag on the gesture queue could
649+
// disassociate the cursor for a new drag, then our reassociateCursor() would
650+
// undo it — causing double-speed movement in the new drag.
651+
// CGAssociateMouseAndMouseCursorPosition is a fast syscall (~microseconds),
652+
// safe to call under lock.
525653
let releasedGeneration: UInt64? = stateLock.withLock {
526654
guard _dragGeneration == expectedGeneration else {
527-
// A new drag has started - don't interfere with it!
528655
Log.info("forceReleaseDrag aborted - new drag session started (expected gen \(expectedGeneration), current \(_dragGeneration))", category: .gesture)
529656
return nil
530657
}
531658
_isMiddleMouseDown = false
659+
reassociateCursor()
532660
return _dragGeneration
533661
}
534662

MiddleDrag/Managers/MultitouchManager.swift

Lines changed: 16 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -652,42 +652,33 @@ extension MultitouchManager: DeviceMonitorDelegate {
652652
// CGEventSource.flagsState is thread-safe and can be called from any thread
653653
let modifierFlags = CGEventSource.flagsState(.hidSystemState)
654654

655-
// CRITICAL: Copy touch data synchronously during this callback.
656655
// The touches pointer is only valid for the duration of this callback.
657-
// The MultitouchSupport framework owns this memory and may free/reuse it
658-
// immediately after the callback returns. We MUST copy the data before
659-
// dispatching to the gesture queue.
656+
// Copy touch data via raw memcpy — much cheaper than Swift Array allocation
657+
// + map closure that was causing per-frame GC pressure and jitter at 100Hz+.
660658
let touchCount = Int(count)
661-
let touchDataCopy: [MTTouch]
659+
nonisolated(unsafe) let touchesPtr: UnsafeMutableRawPointer?
662660
if touchCount > 0 {
663-
let touchArray = unsafe touches.bindMemory(to: MTTouch.self, capacity: touchCount)
664-
// Create a Swift array copy of the touch data
665-
touchDataCopy = (0..<touchCount).map { unsafe touchArray[$0] }
661+
let byteCount = touchCount * MemoryLayout<MTTouch>.stride
662+
let buffer = UnsafeMutableRawPointer.allocate(
663+
byteCount: byteCount, alignment: MemoryLayout<MTTouch>.alignment)
664+
unsafe buffer.copyMemory(from: touches, byteCount: byteCount)
665+
unsafe touchesPtr = unsafe buffer
666666
} else {
667-
touchDataCopy = []
667+
unsafe touchesPtr = nil
668668
}
669669

670-
// Gesture recognition and finger counting is done inside processTouches
671-
// State updates happen in delegate callbacks dispatched to main thread
672-
// IMPORTANT: We must call processTouches even with zero touches so the
673-
// gesture recognizer can properly end gestures via stableFrameCount logic.
674670
gestureQueue.async { [weak self] in
675-
if touchDataCopy.isEmpty {
676-
// Zero touches - still need to notify gesture recognizer so it can
677-
// properly end active gestures via stableFrameCount mechanism
671+
if let buffer = unsafe touchesPtr {
672+
defer { unsafe buffer.deallocate() }
678673
unsafe self?.gestureRecognizer.processTouches(
679-
UnsafeMutableRawPointer(bitPattern: 1)!, // Non-null placeholder, won't be dereferenced when count=0
674+
buffer, count: touchCount, timestamp: timestamp, modifierFlags: modifierFlags)
675+
} else {
676+
// Zero touches — still notify so gesture recognizer can end via stableFrameCount
677+
unsafe self?.gestureRecognizer.processTouches(
678+
UnsafeMutableRawPointer(bitPattern: 1)!,
680679
count: 0,
681680
timestamp: timestamp,
682681
modifierFlags: modifierFlags)
683-
} else {
684-
// Use withUnsafeBufferPointer to get a pointer to our copied data
685-
unsafe touchDataCopy.withUnsafeBufferPointer { buffer in
686-
guard let baseAddress = buffer.baseAddress else { return }
687-
let rawPointer = unsafe UnsafeMutableRawPointer(mutating: baseAddress)
688-
unsafe self?.gestureRecognizer.processTouches(
689-
rawPointer, count: touchCount, timestamp: timestamp, modifierFlags: modifierFlags)
690-
}
691682
}
692683
}
693684
}

0 commit comments

Comments
 (0)