@@ -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
0 commit comments