Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 133 additions & 10 deletions MiddleDrag/Core/MouseEventGenerator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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() {
Expand All @@ -94,6 +116,8 @@ final class MouseEventGenerator: @unchecked Sendable {
// This handles the case where a second MIDDLE_DOWN arrives before the first MIDDLE_UP
if isMiddleMouseDown {
Log.warning("startDrag called while already dragging - canceling existing drag first", category: .gesture)
// Re-associate cursor from previous drag before starting new one
reassociateCursor()
Comment thread
cursor[bot] marked this conversation as resolved.
// Send mouse up for the existing drag immediately (synchronously)
let currentPos = currentMouseLocationQuartz
sendMiddleMouseUp(at: currentPos)
Expand All @@ -105,6 +129,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
}
Comment thread
NullPointerDepressiveDisorder marked this conversation as resolved.
}

// Record activity time for watchdog
activityLock.lock()
lastActivityTime = CACurrentMediaTime()
Expand Down Expand Up @@ -162,12 +205,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
Comment thread
cursor[bot] marked this conversation as resolved.
targetPos.x = max(globalBounds.minX, min(targetPos.x, globalBounds.maxX - 1))
targetPos.y = max(globalBounds.minY, min(targetPos.y, globalBounds.maxY - 1))

lastDragPosition = targetPos
Comment thread
cursor[bot] marked this conversation as resolved.

guard shouldPostEvents else { return }
guard
let event = CGEvent(
Expand All @@ -178,22 +231,34 @@ final class MouseEventGenerator: @unchecked Sendable {
)
else { return }

// Set deltas for macOS cursor movement; position field for apps that read it
// Set both integer and double delta fields — some apps (standard macOS, Blender)
// read double-precision deltas, others (Unity, Fusion 360, game engines) read
// integer deltas. The HID system populates both for real hardware events.
// Use .rounded() for integers to match hardware behavior: sub-pixel deltas
// like 0.7 should produce ±1, not 0 (truncation drops slow movements).
event.setIntegerValueField(.mouseEventDeltaX, value: Int64(smoothedDeltaX.rounded()))
event.setIntegerValueField(.mouseEventDeltaY, value: Int64(smoothedDeltaY.rounded()))
Comment thread
cursor[bot] marked this conversation as resolved.
event.setDoubleValueField(.mouseEventDeltaX, value: Double(smoothedDeltaX))
event.setDoubleValueField(.mouseEventDeltaY, value: Double(smoothedDeltaY))

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()

// Re-associate cursor so it's no longer frozen
reassociateCursor()
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated

// CRITICAL: Set isMiddleMouseDown = false SYNCHRONOUSLY to match startDrag
// This prevents race conditions with rapid start/end cycles and ensures
Expand Down Expand Up @@ -288,6 +353,9 @@ final class MouseEventGenerator: @unchecked Sendable {

// Stop watchdog since drag is being cancelled
stopWatchdog()

// Re-associate cursor so it's no longer frozen
reassociateCursor()

// CRITICAL: Set isMiddleMouseDown = false SYNCHRONOUSLY to match startDrag
// This prevents race conditions with rapid cancel/start cycles and ensures
Expand Down Expand Up @@ -315,6 +383,9 @@ final class MouseEventGenerator: @unchecked Sendable {
// Stop watchdog if running
stopWatchdog()

// Re-associate cursor so it's no longer frozen
reassociateCursor()

Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
// Atomically reset state and capture generation
let currentGeneration: UInt64 = stateLock.withLock {
_isMiddleMouseDown = false
Expand Down Expand Up @@ -342,6 +413,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..<Int(displayCount) {
union = union.union(CGDisplayBounds(displayIDs[i]))
}
let result = union == .null ? CGRect(x: 0, y: 0, width: 1920, height: 1080) : union

displayBoundsLock.lock()
_cachedDisplayBounds = result
displayBoundsLock.unlock()

return result
}

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

// CRITICAL: Verify generation still matches before clearing state
// This prevents race where a new drag started between checkForStuckDrag() and now
// CRITICAL: Verify generation, clear state, AND re-associate cursor atomically.
// If we release the lock between setting _isMiddleMouseDown = false and calling
// reassociateCursor(), a concurrent startDrag on the gesture queue could
// disassociate the cursor for a new drag, then our reassociateCursor() would
// undo it — causing double-speed movement in the new drag.
// CGAssociateMouseAndMouseCursorPosition is a fast syscall (~microseconds),
// safe to call under lock.
let releasedGeneration: UInt64? = stateLock.withLock {
Comment thread
NullPointerDepressiveDisorder marked this conversation as resolved.
guard _dragGeneration == expectedGeneration else {
// A new drag has started - don't interfere with it!
Log.info("forceReleaseDrag aborted - new drag session started (expected gen \(expectedGeneration), current \(_dragGeneration))", category: .gesture)
return nil
}
_isMiddleMouseDown = false
reassociateCursor()
return _dragGeneration
}

Expand Down
41 changes: 16 additions & 25 deletions MiddleDrag/Managers/MultitouchManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -652,42 +652,33 @@ extension MultitouchManager: DeviceMonitorDelegate {
// CGEventSource.flagsState is thread-safe and can be called from any thread
let modifierFlags = CGEventSource.flagsState(.hidSystemState)

// CRITICAL: Copy touch data synchronously during this callback.
// The touches pointer is only valid for the duration of this callback.
// The MultitouchSupport framework owns this memory and may free/reuse it
// immediately after the callback returns. We MUST copy the data before
// dispatching to the gesture queue.
// Copy touch data via raw memcpy — much cheaper than Swift Array allocation
// + map closure that was causing per-frame GC pressure and jitter at 100Hz+.
let touchCount = Int(count)
let touchDataCopy: [MTTouch]
nonisolated(unsafe) let touchesPtr: UnsafeMutableRawPointer?
if touchCount > 0 {
let touchArray = unsafe touches.bindMemory(to: MTTouch.self, capacity: touchCount)
// Create a Swift array copy of the touch data
touchDataCopy = (0..<touchCount).map { unsafe touchArray[$0] }
let byteCount = touchCount * MemoryLayout<MTTouch>.stride
let buffer = UnsafeMutableRawPointer.allocate(
byteCount: byteCount, alignment: MemoryLayout<MTTouch>.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)
}
}
}
}
Expand Down
Loading
Loading