V
Vecto
Back to Home

Challenges: Event Coordinates Under Rotation

An in-depth analysis of declarative coordinate mapping and event redirection inside Apple's visual transform stack.

Coordinate Rotation Mismatch Infographic

Coordinate Transformation and Event Routing Flow

flowchart TD subgraph Physical Screen Space A[Physical Click: Mouse Down at X, Y] --> B[Mouse Drag/Hold] B --> C[Physical Release: Mouse Up at X, Y] end subgraph AppKit Window Space (Rotated -90°) D[Window intercepts Event] D -->|Applies -90° Transform Matrix| E[Correctly Translated Coordinates] end subgraph SwiftUI Gesture Engine (Failure Path) E -->|Initiates Click Gesture| F[SwiftUI Button: Pressed State] C -->|Mouse Up Event fired| G[SwiftUI Gesture Tracker] G -->|FAIL: Does not apply Rotation Matrix to Mouse Up| H[Thinks Mouse Up is at raw screen coordinates] H -->|Determines release was outside button| I[Cancels Gesture / Button Click fails] end subgraph Native AppKit Event Routing (Success Path) D -->|Custom NSWindow hitTest| J{Is leaf view an NSButton?} J -->|Yes| K[Forward raw NSEvent directly to NSButton] K --> L[Native Cocoa tracking loop handles drag/release] L -->|SUCCESS| M[Action Triggered instantly and reliably] end style A fill:#1e293b,stroke:#475569,color:#f1f5f9 style D fill:#1e1b4b,stroke:#4f46e5,color:#e0e7ff style G fill:#7f1d1d,stroke:#f87171,color:#fee2e2 style L fill:#064e3b,stroke:#34d399,color:#d1fae5

Description of the Mismatch

  1. The Core Mismatch: When AppKit rotates a view hierarchy using frameCenterRotation, it updates the drawing context. However, SwiftUI's gesture tracker tracks drag-release sequences in parallel.
  2. The MouseUp Failure: On mouseDown, coordinates are translated correctly. But on mouseUp, SwiftUI's tracking engine compares the release coordinate with the start coordinate without running it through the -90° transformation matrix.
  3. The Result: SwiftUI thinks the user moved the mouse off the button before releasing, thereby cancelling the tap gesture.
  4. The Fix: Our native hitTest override detects the click targets and routes the event directly to standard AppKit NSButton views (via NSViewRepresentable), which use native tracking loops that do not suffer from this declarative coordinate mismatch.

Why the Browser Tab is Unaffected

The Browser Tab (Tab 0) is driven by FocusableWebView (which wraps AppKit's native WKWebView). Native AppKit views do not route mouse clicks through SwiftUI's gesture engine.

When a programmatic click is dispatched via win.sendEvent(down) and win.sendEvent(up) at physical coordinates, standard AppKit window routing delivers the events directly to WKWebView's native tracking system. The web view processes the mouse down and mouse up events in its own coordinates, bypassing SwiftUI's gesture validation. Consequently, the coordinate translation mismatch on mouse-up (which is unique to SwiftUI's gesture engine) never gets triggered.

Failed Coordinate Conversion Override Attempt

What was attempted

To fix coordinate alignments and click issues without using manual event forwarding, we attempted to override the coordinate conversion functions (convert(_:from:), convert(_:to:), convert(_:from: rect), and convert(_:to: rect)) in RotatedHostingView to dynamically transform coordinate spaces during mouse event dispatch. We used a state flag set in sendEvent (EventForwardingState.isProcessingMouseEvent) to scope this custom mapping strictly to active mouse events.

Why it failed (and broke Settings tab clicks completely)

The Production Solution (AppKit Interception Code)

To bypass the SwiftUI mouse-up coordinate tracking bugs, Vecto implements a native AppKit forwarding architecture. Below is the production implementation details:

1. Recursive Hit-Testing and Class-Based Filtering

We override hitTest on RotatedContainerView to recursively inspect child view hierarchies, returning the target child view only if it is a native interactive control (like a text field, web view, or button):

class RotatedContainerView: NSView {
    override func hitTest(_ point: NSPoint) -> NSView? {
        guard let window = self.window as? RotatedWindow else {
            return super.hitTest(point)
        }
        guard let target = window.findTargetView(in: self, physicalPoint: point) else {
            return nil
        }
        var current: NSView? = target
        while let v = current {
            let name = v.className
            if name.contains("WK") || name.contains("PDF") || name.contains("Text") || name.contains("Field") || name.contains("Switch") || name.contains("Button") || name.contains("Slider") {
                return target
            }
            current = v.superview
        }
        return self.subviews.first ?? target
    }
}

2. Physical to Local Coordinate Translation

Our custom RotatedWindow overrides standard mouse event dispatching, translating physical screen space events into logical transformed coordinates, and forwarding them directly to target views via native event dispatching loops:

// Recursive coordinate lookup
func findTargetView(in view: NSView, physicalPoint: NSPoint) -> NSView? {
    let localPoint = view.convert(physicalPoint, from: view.superview)
    if !view.bounds.contains(localPoint) {
        return nil
    }
    for subview in view.subviews.reversed() {
        if let target = findTargetView(in: subview, physicalPoint: localPoint) {
            return target
        }
    }
    return view
}