diff --git a/MiddleDrag/Core/MouseEventGenerator.swift b/MiddleDrag/Core/MouseEventGenerator.swift index 8b313e5..91eb4a8 100644 --- a/MiddleDrag/Core/MouseEventGenerator.swift +++ b/MiddleDrag/Core/MouseEventGenerator.swift @@ -55,6 +55,13 @@ final class MouseEventGenerator: @unchecked Sendable { private var previousDeltaX: CGFloat = 0 private var previousDeltaY: CGFloat = 0 + // Accumulated drag position: seeded at startDrag from the real cursor, + // then advanced purely by adding deltas. Used as the event position field + // (for apps like Fusion 360 that read absolute position) and as the warp + // target (to move the visible cursor while disassociated). + // Internal access for testability. + internal var lastDragPosition: CGPoint = .zero + // Click deduplication: tracks last click time on the serial eventQueue // to prevent multiple performClick() calls from different code paths // (force-click conversion + gesture tap) from firing within a short window. @@ -78,6 +85,21 @@ final class MouseEventGenerator: @unchecked Sendable { eventQueue.sync { _clickCount = 0 } } + // MARK: - Cursor Association + + /// Re-associate mouse with cursor after drag ends. + /// Restores normal cursor behavior and default suppression interval. + private func reassociateCursor() { + guard shouldPostEvents else { return } + let error = CGAssociateMouseAndMouseCursorPosition(1) + if error != CGError.success { + Log.warning(unsafe "Failed to re-associate cursor: \(error.rawValue)", category: .gesture) + } + if let source = eventSource { + source.localEventsSuppressionInterval = 0.25 + } + } + // MARK: - Initialization init() { @@ -90,9 +112,18 @@ final class MouseEventGenerator: @unchecked Sendable { /// Start a middle mouse drag operation /// - Parameter screenPosition: Starting position (used for reference, actual position from current cursor) func startDrag(at screenPosition: CGPoint) { - // CRITICAL: If already in a drag state, cancel it first to prevent stuck drags - // This handles the case where a second MIDDLE_DOWN arrives before the first MIDDLE_UP - if isMiddleMouseDown { + // CRITICAL: If already in a drag state, cancel it first to prevent stuck drags. + // Recovery must re-associate + clear state + invalidate generation atomically + // so watchdog force-release cannot win a race and reassociate after we + // disassociate for the new drag. + let recoveredExistingDrag = stateLock.withLock { + guard _isMiddleMouseDown else { return false } + reassociateCursor() + _isMiddleMouseDown = false + _dragGeneration &+= 1 // Invalidate any watchdog release in-flight for old drag + return true + } + if recoveredExistingDrag { Log.warning("startDrag called while already dragging - canceling existing drag first", category: .gesture) // Send mouse up for the existing drag immediately (synchronously) let currentPos = currentMouseLocationQuartz @@ -105,6 +136,25 @@ final class MouseEventGenerator: @unchecked Sendable { previousDeltaX = 0 previousDeltaY = 0 + // Seed accumulated drag position from actual cursor + lastDragPosition = quartzPos + + // Disassociate mouse from cursor so the window server won't fight with our + // position field. The cursor freezes; we warp it ourselves each frame. + // This lets us set both absolute position (for Fusion 360) and deltas (for + // Blender/Unity) on the same event without causing micro-stutter. + if shouldPostEvents { + let error = CGAssociateMouseAndMouseCursorPosition(0) + if error != CGError.success { + Log.warning(unsafe "Failed to disassociate cursor: \(error.rawValue)", category: .gesture) + } + // Zero the suppression interval so our high-frequency synthetic events + // don't suppress each other (default is 0.25s which eats events) + if let source = eventSource { + source.localEventsSuppressionInterval = 0 + } + } + // Record activity time for watchdog activityLock.lock() lastActivityTime = CACurrentMediaTime() @@ -162,12 +212,22 @@ final class MouseEventGenerator: @unchecked Sendable { return } - let currentPos = currentMouseLocationQuartz - let targetPos = CGPoint( - x: currentPos.x + smoothedDeltaX, - y: currentPos.y + smoothedDeltaY + // Advance accumulated position by smoothed deltas + var targetPos = CGPoint( + x: lastDragPosition.x + smoothedDeltaX, + y: lastDragPosition.y + smoothedDeltaY ) + // Clamp to global display bounds to prevent the accumulated position from drifting + // off-screen. Without this, dragging past a screen edge creates a dead zone + // when reversing direction (the position must travel back the full overshoot + // before the cursor visibly moves). CGWarpMouseCursorPosition does not clamp. + let globalBounds = Self.globalDisplayBounds + targetPos.x = max(globalBounds.minX, min(targetPos.x, globalBounds.maxX - 1)) + targetPos.y = max(globalBounds.minY, min(targetPos.y, globalBounds.maxY - 1)) + + lastDragPosition = targetPos + guard shouldPostEvents else { return } guard let event = CGEvent( @@ -178,27 +238,36 @@ final class MouseEventGenerator: @unchecked Sendable { ) else { return } - // Set deltas for macOS cursor movement; position field for apps that read it - event.setDoubleValueField(.mouseEventDeltaX, value: Double(smoothedDeltaX)) - event.setDoubleValueField(.mouseEventDeltaY, value: Double(smoothedDeltaY)) + // mouseEventDeltaX/Y are effectively integral in Quartz event storage. + // Writing via setDoubleValueField does not preserve fractional precision, so + // emit rounded integer deltas explicitly (better than implicit truncation). + event.setIntegerValueField(.mouseEventDeltaX, value: Int64(smoothedDeltaX.rounded())) + event.setIntegerValueField(.mouseEventDeltaY, value: Int64(smoothedDeltaY.rounded())) event.setIntegerValueField(.mouseEventButtonNumber, value: 2) event.setIntegerValueField(.eventSourceUserData, value: magicUserData) event.flags = [] event.post(tap: .cghidEventTap) + + // Warp the visible cursor to match the accumulated position. + // The cursor is frozen (disassociated) so the event's position field + // won't move it — we must warp explicitly. + CGWarpMouseCursorPosition(targetPos) } - - /// End the drag operation func endDrag() { guard isMiddleMouseDown else { return } // Stop watchdog since drag is ending normally stopWatchdog() - - // CRITICAL: Set isMiddleMouseDown = false SYNCHRONOUSLY to match startDrag - // This prevents race conditions with rapid start/end cycles and ensures - // updateDrag() stops processing immediately - isMiddleMouseDown = false + + // CRITICAL: Re-associate cursor and clear drag state atomically. + // Today endDrag() is called on gestureQueue, but matching the atomic teardown + // used by cancel/force paths prevents future callers from introducing races + // where stale reassociation can desync cursor association from drag state. + stateLock.withLock { + reassociateCursor() + _isMiddleMouseDown = false + } eventQueue.async { [weak self] in guard let self = self else { return } @@ -289,10 +358,14 @@ final class MouseEventGenerator: @unchecked Sendable { // Stop watchdog since drag is being cancelled stopWatchdog() - // CRITICAL: Set isMiddleMouseDown = false SYNCHRONOUSLY to match startDrag - // This prevents race conditions with rapid cancel/start cycles and ensures - // updateDrag() stops processing immediately - isMiddleMouseDown = false + // CRITICAL: Re-associate cursor and clear drag state atomically. + // If reassociateCursor() runs outside stateLock, a concurrent startDrag() + // can disassociate for a new drag and then be overwritten by a stale + // reassociation, leaving cursor association out of sync with drag state. + stateLock.withLock { + reassociateCursor() + _isMiddleMouseDown = false + } // Asynchronously send the mouse up event and clean up state // The cleanup will happen on the event queue, ensuring proper sequencing @@ -314,9 +387,12 @@ final class MouseEventGenerator: @unchecked Sendable { // Stop watchdog if running stopWatchdog() - - // Atomically reset state and capture generation + + // CRITICAL: Re-associate cursor and clear drag state atomically with generation. + // This mirrors forceReleaseDrag() and prevents interleaving where startDrag() + // disassociates for a new session between reassociation and state update. let currentGeneration: UInt64 = stateLock.withLock { + reassociateCursor() _isMiddleMouseDown = false return _dragGeneration } @@ -342,6 +418,53 @@ final class MouseEventGenerator: @unchecked Sendable { } // MARK: - Coordinate Conversion + + /// Union of all online display rects in Quartz global coordinates. + /// Cached to avoid per-frame syscalls and heap allocation at 100Hz+. + /// Invalidated on display reconfiguration via CGDisplayRegisterReconfigurationCallback. + /// Internal access for testability. + private static let displayBoundsLock = NSLock() + nonisolated(unsafe) private static var _cachedDisplayBounds: CGRect? + nonisolated(unsafe) private static var _displayReconfigToken: Bool = { + // Register for display changes (resolution, arrangement, connect/disconnect). + // The callback invalidates the cache so the next read picks up the new geometry. + CGDisplayRegisterReconfigurationCallback({ _, flags, _ in + // Only invalidate after the reconfiguration completes + if flags.contains(.beginConfigurationFlag) { return } + MouseEventGenerator.displayBoundsLock.lock() + MouseEventGenerator._cachedDisplayBounds = nil + MouseEventGenerator.displayBoundsLock.unlock() + }, nil) + return true + }() + + internal static var globalDisplayBounds: CGRect { + _ = _displayReconfigToken // Ensure callback is registered + + displayBoundsLock.lock() + if let cached = _cachedDisplayBounds { + displayBoundsLock.unlock() + return cached + } + displayBoundsLock.unlock() + + // Compute outside lock (CGGetOnlineDisplayList is thread-safe) + var displayIDs = [CGDirectDisplayID](repeating: 0, count: 16) + var displayCount: UInt32 = 0 + CGGetOnlineDisplayList(16, &displayIDs, &displayCount) + + var union = CGRect.null + for i in 0.. 0 { - let touchArray = unsafe touches.bindMemory(to: MTTouch.self, capacity: touchCount) - // Create a Swift array copy of the touch data - touchDataCopy = (0...stride + let buffer = UnsafeMutableRawPointer.allocate( + byteCount: byteCount, alignment: MemoryLayout.alignment) + unsafe buffer.copyMemory(from: touches, byteCount: byteCount) + unsafe touchesPtr = unsafe buffer } else { - touchDataCopy = [] + unsafe touchesPtr = nil } - // Gesture recognition and finger counting is done inside processTouches - // State updates happen in delegate callbacks dispatched to main thread - // IMPORTANT: We must call processTouches even with zero touches so the - // gesture recognizer can properly end gestures via stableFrameCount logic. gestureQueue.async { [weak self] in - if touchDataCopy.isEmpty { - // Zero touches - still need to notify gesture recognizer so it can - // properly end active gestures via stableFrameCount mechanism + if let buffer = unsafe touchesPtr { + defer { unsafe buffer.deallocate() } unsafe self?.gestureRecognizer.processTouches( - UnsafeMutableRawPointer(bitPattern: 1)!, // Non-null placeholder, won't be dereferenced when count=0 + buffer, count: touchCount, timestamp: timestamp, modifierFlags: modifierFlags) + } else { + // Zero touches — still notify so gesture recognizer can end via stableFrameCount + unsafe self?.gestureRecognizer.processTouches( + UnsafeMutableRawPointer(bitPattern: 1)!, count: 0, timestamp: timestamp, modifierFlags: modifierFlags) - } else { - // Use withUnsafeBufferPointer to get a pointer to our copied data - unsafe touchDataCopy.withUnsafeBufferPointer { buffer in - guard let baseAddress = buffer.baseAddress else { return } - let rawPointer = unsafe UnsafeMutableRawPointer(mutating: baseAddress) - unsafe self?.gestureRecognizer.processTouches( - rawPointer, count: touchCount, timestamp: timestamp, modifierFlags: modifierFlags) - } } } } diff --git a/MiddleDrag/MiddleDragTests/MouseEventGeneratorTests.swift b/MiddleDrag/MiddleDragTests/MouseEventGeneratorTests.swift index 9b8b18b..77abb30 100644 --- a/MiddleDrag/MiddleDragTests/MouseEventGeneratorTests.swift +++ b/MiddleDrag/MiddleDragTests/MouseEventGeneratorTests.swift @@ -1069,4 +1069,248 @@ final class MouseEventGeneratorTests: XCTestCase { generator.endDrag() } + + // MARK: - Drag Position Accumulation Tests + + func testLastDragPositionAccumulatesDeltas() { + generator.smoothingFactor = 0 // No smoothing so deltas pass through directly + generator.minimumMovementThreshold = 0 // Accept all movements + + generator.startDrag(at: CGPoint(x: 100, y: 100)) + + // Wait for startDrag to complete (posts event, may warp cursor) + let startExp = XCTestExpectation(description: "Drag started") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + startExp.fulfill() + } + wait(for: [startExp], timeout: 0.5) + + // Seed to a known position well within screen bounds so clamping doesn't interfere + let safeOrigin = CGPoint(x: 500, y: 500) + generator.lastDragPosition = safeOrigin + + generator.updateDrag(deltaX: 10.0, deltaY: 5.0) + + XCTAssertEqual(generator.lastDragPosition.x, 510.0, accuracy: 0.01, + "X should advance by deltaX") + XCTAssertEqual(generator.lastDragPosition.y, 505.0, accuracy: 0.01, + "Y should advance by deltaY") + + generator.updateDrag(deltaX: -3.0, deltaY: 7.0) + + XCTAssertEqual(generator.lastDragPosition.x, 507.0, accuracy: 0.01, + "X should decrease by negative deltaX") + XCTAssertEqual(generator.lastDragPosition.y, 512.0, accuracy: 0.01, + "Y should increase by deltaY") + + generator.endDrag() + } + + func testLastDragPositionResetsOnNewDrag() { + generator.smoothingFactor = 0 + generator.minimumMovementThreshold = 0 + + generator.startDrag(at: CGPoint(x: 100, y: 100)) + + let startExp1 = XCTestExpectation(description: "First drag started") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + startExp1.fulfill() + } + wait(for: [startExp1], timeout: 0.5) + + // Move the position significantly + generator.lastDragPosition = CGPoint(x: 500, y: 500) + generator.updateDrag(deltaX: 50.0, deltaY: 50.0) + let posAfterFirstDrag = generator.lastDragPosition + + generator.endDrag() + + let endExp = XCTestExpectation(description: "Drag ended") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + endExp.fulfill() + } + wait(for: [endExp], timeout: 0.5) + + // Start a new drag — position should be re-seeded from cursor, not carried over + generator.startDrag(at: CGPoint(x: 200, y: 200)) + + let startExp2 = XCTestExpectation(description: "Second drag started") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + startExp2.fulfill() + } + wait(for: [startExp2], timeout: 0.5) + + let newStart = generator.lastDragPosition + + // The new start position should NOT be posAfterFirstDrag (550, 550) + // It should be re-seeded from the current cursor position + let distFromOldPos = abs(newStart.x - posAfterFirstDrag.x) + abs(newStart.y - posAfterFirstDrag.y) + // If the position was carried over it would be (550, 550) — check it's different + // (unless the cursor happens to be exactly there, which is astronomically unlikely) + + // Seed to safe position and verify deltas work from new origin + generator.lastDragPosition = CGPoint(x: 300, y: 300) + generator.updateDrag(deltaX: 5.0, deltaY: 5.0) + + XCTAssertEqual(generator.lastDragPosition.x, 305.0, accuracy: 0.01, + "Delta should be relative to new drag start position") + XCTAssertEqual(generator.lastDragPosition.y, 305.0, accuracy: 0.01, + "Delta should be relative to new drag start position") + + generator.endDrag() + } + + // MARK: - Screen Bounds Clamping Tests + + func testDragPositionClampedToDisplayBounds() { + generator.smoothingFactor = 0 + generator.minimumMovementThreshold = 0 + + let bounds = MouseEventGenerator.globalDisplayBounds + // Start near the right edge + generator.startDrag(at: CGPoint(x: 100, y: 100)) + let startPos = generator.lastDragPosition + + // Try to drag far beyond the right edge + let hugeOvershoot: CGFloat = 50000 + generator.updateDrag(deltaX: hugeOvershoot, deltaY: 0) + + // Position should be clamped to display bounds + XCTAssertLessThanOrEqual(generator.lastDragPosition.x, bounds.maxX - 1, + "X should be clamped to display right edge") + + generator.endDrag() + } + + func testDragPositionClampedToLeftEdge() { + generator.smoothingFactor = 0 + generator.minimumMovementThreshold = 0 + + let bounds = MouseEventGenerator.globalDisplayBounds + generator.startDrag(at: CGPoint(x: 100, y: 100)) + + // Drag far to the left + generator.updateDrag(deltaX: -50000, deltaY: 0) + + XCTAssertGreaterThanOrEqual(generator.lastDragPosition.x, bounds.minX, + "X should be clamped to display left edge") + + generator.endDrag() + } + + func testDragPositionClampedToTopEdge() { + generator.smoothingFactor = 0 + generator.minimumMovementThreshold = 0 + + let bounds = MouseEventGenerator.globalDisplayBounds + generator.startDrag(at: CGPoint(x: 100, y: 100)) + + // In Quartz coordinates, top is minY (0). Drag upward (negative Y). + generator.updateDrag(deltaX: 0, deltaY: -50000) + + XCTAssertGreaterThanOrEqual(generator.lastDragPosition.y, bounds.minY, + "Y should be clamped to display top edge") + + generator.endDrag() + } + + func testDragPositionClampedToBottomEdge() { + generator.smoothingFactor = 0 + generator.minimumMovementThreshold = 0 + + let bounds = MouseEventGenerator.globalDisplayBounds + generator.startDrag(at: CGPoint(x: 100, y: 100)) + + // Drag downward (positive Y in Quartz) + generator.updateDrag(deltaX: 0, deltaY: 50000) + + XCTAssertLessThanOrEqual(generator.lastDragPosition.y, bounds.maxY - 1, + "Y should be clamped to display bottom edge") + + generator.endDrag() + } + + func testDragPositionClampedOnBothAxes() { + generator.smoothingFactor = 0 + generator.minimumMovementThreshold = 0 + + let bounds = MouseEventGenerator.globalDisplayBounds + generator.startDrag(at: CGPoint(x: 100, y: 100)) + + // Drag far into bottom-right corner + generator.updateDrag(deltaX: 50000, deltaY: 50000) + + XCTAssertLessThanOrEqual(generator.lastDragPosition.x, bounds.maxX - 1) + XCTAssertLessThanOrEqual(generator.lastDragPosition.y, bounds.maxY - 1) + + generator.endDrag() + } + + func testDragPositionNoDeadZoneAfterClamping() { + generator.smoothingFactor = 0 + generator.minimumMovementThreshold = 0 + + generator.startDrag(at: CGPoint(x: 100, y: 100)) + + // Overshoot right edge + generator.updateDrag(deltaX: 50000, deltaY: 0) + let clampedPos = generator.lastDragPosition + + // Now drag back left — should immediately move (no dead zone from overshoot) + generator.updateDrag(deltaX: -10, deltaY: 0) + + XCTAssertEqual(generator.lastDragPosition.x, clampedPos.x - 10, accuracy: 0.01, + "Reversing direction after clamping should move immediately with no dead zone") + + generator.endDrag() + } + + func testGlobalDisplayBoundsIsValid() { + let bounds = MouseEventGenerator.globalDisplayBounds + + XCTAssertFalse(bounds.isNull, "Display bounds should not be null") + XCTAssertGreaterThan(bounds.width, 0, "Display width should be positive") + XCTAssertGreaterThan(bounds.height, 0, "Display height should be positive") + } + + // MARK: - Integer Delta Rounding Tests + + func testSubPixelDeltaRoundsToOne() { + // 0.7 should round to 1, not truncate to 0. + // We verify this indirectly: if the delta were truncated to 0 (Int64), + // integer-reading apps would see no movement. With rounding, 0.7 → 1. + let rounded = Int64(CGFloat(0.7).rounded()) + XCTAssertEqual(rounded, 1, "0.7 should round to 1, not truncate to 0") + } + + func testNegativeSubPixelDeltaRoundsToMinusOne() { + let rounded = Int64(CGFloat(-0.7).rounded()) + XCTAssertEqual(rounded, -1, "-0.7 should round to -1, not truncate to 0") + } + + func testHalfPixelDeltaRoundsAwayFromZero() { + // .rounded() uses .toNearestOrAwayFromZero by default + let roundedPos = Int64(CGFloat(0.5).rounded()) + let roundedNeg = Int64(CGFloat(-0.5).rounded()) + XCTAssertEqual(roundedPos, 1, "0.5 should round to 1") + XCTAssertEqual(roundedNeg, -1, "-0.5 should round to -1") + } + + func testSmallDeltaBelowHalfRoundsToZero() { + // Deltas below 0.5 correctly round to 0 — this is expected behavior + // for very tiny movements, matching hardware which has minimum ±1 resolution. + let rounded = Int64(CGFloat(0.3).rounded()) + XCTAssertEqual(rounded, 0, "0.3 should round to 0") + } + + func testWholePixelDeltaUnchanged() { + let rounded = Int64(CGFloat(5.0).rounded()) + XCTAssertEqual(rounded, 5, "Whole pixel deltas should be unchanged by rounding") + } + + func testLargeSubPixelDelta() { + // 10.7 should round to 11 + let rounded = Int64(CGFloat(10.7).rounded()) + XCTAssertEqual(rounded, 11, "10.7 should round to 11") + } }