diff --git a/Sources/FileDropOverlayMouseDragButton.swift b/Sources/FileDropOverlayMouseDragButton.swift new file mode 100644 index 000000000000..37ebfe24f5fc --- /dev/null +++ b/Sources/FileDropOverlayMouseDragButton.swift @@ -0,0 +1,6 @@ +/// Identifies the mouse button whose AppKit drag target is captured. +enum FileDropOverlayMouseDragButton: Hashable { + case left + case right + case other(Int) +} diff --git a/Sources/FileDropOverlayMouseDragTarget.swift b/Sources/FileDropOverlayMouseDragTarget.swift new file mode 100644 index 000000000000..aac803e3f42b --- /dev/null +++ b/Sources/FileDropOverlayMouseDragTarget.swift @@ -0,0 +1,10 @@ +import AppKit + +/// Weakly captures one AppKit view for the lifetime of a forwarded drag. +final class FileDropOverlayMouseDragTarget { + weak var view: NSView? + + init(view: NSView) { + self.view = view + } +} diff --git a/Sources/FileDropOverlayView.swift b/Sources/FileDropOverlayView.swift index f0ccbbac1d75..f1351010a933 100644 --- a/Sources/FileDropOverlayView.swift +++ b/Sources/FileDropOverlayView.swift @@ -40,11 +40,14 @@ extension BrowserPaneDropTargetView: FileDropPaneTarget { /// Mouse events are forwarded to the views below via a hide-send-unhide pattern so clicks, /// scrolls, and other interactions pass through normally. final class FileDropOverlayView: NSView { + private typealias ForwardedMouseDragButton = FileDropOverlayMouseDragButton + private typealias ForwardedMouseDragTarget = FileDropOverlayMouseDragTarget + /// Fallback handler when no terminal is found under the drop point. var onDrop: (([URL]) -> Bool)? private var isForwardingMouseEvent = false - private weak var forwardedMouseDragTarget: NSView? - private var forwardedMouseDragButton: ForwardedMouseDragButton? + private var forwardedMouseDragTargets: + [ForwardedMouseDragButton: ForwardedMouseDragTarget] = [:] /// The WKWebView currently receiving forwarded drag events, so we can /// synthesize draggingExited/draggingEntered as the cursor moves. weak var activeDragWebView: WKWebView? @@ -74,12 +77,6 @@ final class FileDropOverlayView: NSView { required init?(coder: NSCoder) { fatalError("init(coder:) not implemented") } - private enum ForwardedMouseDragButton: Equatable { - case left - case right - case other(Int) - } - private func dragButton(for event: NSEvent) -> ForwardedMouseDragButton? { switch event.type { case .leftMouseDown, .leftMouseUp, .leftMouseDragged: @@ -111,6 +108,67 @@ final class FileDropOverlayView: NSView { } } + /// Clears one captured target, or every capture when `button` is nil. + /// All forwarded-drag reset paths go through this method so target and + /// button ownership cannot be cleared independently. + private func clearForwardedMouseDragState( + for button: ForwardedMouseDragButton? = nil, + reason: String + ) { +#if DEBUG + let buttons: String + if let button { + buttons = String(describing: button) + } else { + buttons = forwardedMouseDragTargets.keys + .map(String.init(describing:)) + .sorted() + .joined(separator: ",") + } + if !buttons.isEmpty { + dlog( + "overlay.forwardedDrag.reset reason=\(reason) " + + "button=\(buttons)" + ) + } +#endif + if let button { + forwardedMouseDragTargets.removeValue(forKey: button) + } else { + forwardedMouseDragTargets.removeAll(keepingCapacity: true) + } + } + + private func repairForwardedMouseDragStateIfNeeded(for event: NSEvent) { + // A portal target can disappear independently for each button. Drop + // only the detached capture so another button's drag remains intact. + for button in Array(forwardedMouseDragTargets.keys) { + guard let target = forwardedMouseDragTargets[button]?.view else { + clearForwardedMouseDragState(for: button, reason: "targetDetached") + continue + } + guard target.window != nil else { + clearForwardedMouseDragState(for: button, reason: "targetDetached") + continue + } + } + + if let eventButton = dragButton(for: event), + shouldTrackForwardedMouseDragStart(for: event.type), + forwardedMouseDragTargets[eventButton] != nil { + clearForwardedMouseDragState(for: eventButton, reason: "repeatedMouseDown") + } + } + + private func isForwardedMouseDragMotion(_ eventType: NSEvent.EventType) -> Bool { + switch eventType { + case .leftMouseDragged, .rightMouseDragged, .otherMouseDragged: + return true + default: + return false + } + } + // MARK: Hit-testing — participation is routed by DragOverlayRoutingPolicy so // file-drop, bonsplit tab drags, and sidebar tab reorder drags cannot conflict. @@ -155,6 +213,7 @@ final class FileDropOverlayView: NSView { private func forwardEvent(_ event: NSEvent) { guard !isForwardingMouseEvent else { return } guard let window, let contentView = window.contentView else { return } + repairForwardedMouseDragStateIfNeeded(for: event) let eventButton = dragButton(for: event) isForwardingMouseEvent = true @@ -166,30 +225,33 @@ final class FileDropOverlayView: NSView { let target: NSView? if let eventButton, - forwardedMouseDragButton == eventButton, - let activeTarget = forwardedMouseDragTarget, + (isForwardedMouseDragMotion(event.type) + || shouldTrackForwardedMouseDragEnd(for: event.type)), + let activeTarget = forwardedMouseDragTargets[eventButton]?.view, activeTarget.window != nil { - // Preserve normal AppKit mouse-delivery semantics: once a drag starts, - // keep routing dragged/up events to the original mouseDown target. + // Preserve normal AppKit mouse-delivery semantics: once a drag + // starts, keep routing dragged/up events to that button's original + // mouseDown target. target = activeTarget } else { + // A stale file-drop pasteboard can make the overlay receive a + // dragged/up event even though its down was delivered underneath. + // Recover the normal target in that case instead of dropping the + // event and leaving the underlying selection gesture incomplete. let point = contentView.convert(event.locationInWindow, from: nil) target = contentView.hitTest(point) } guard let target, target !== self else { if shouldTrackForwardedMouseDragEnd(for: event.type), - let eventButton, - forwardedMouseDragButton == eventButton { - forwardedMouseDragTarget = nil - forwardedMouseDragButton = nil + let eventButton { + clearForwardedMouseDragState(for: eventButton, reason: "targetUnavailable") } return } if shouldTrackForwardedMouseDragStart(for: event.type), let eventButton { - forwardedMouseDragTarget = target - forwardedMouseDragButton = eventButton + forwardedMouseDragTargets[eventButton] = ForwardedMouseDragTarget(view: target) } switch event.type { @@ -207,10 +269,15 @@ final class FileDropOverlayView: NSView { } if shouldTrackForwardedMouseDragEnd(for: event.type), - let eventButton, - forwardedMouseDragButton == eventButton { - forwardedMouseDragTarget = nil - forwardedMouseDragButton = nil + let eventButton { + clearForwardedMouseDragState(for: eventButton, reason: "mouseUp") + } + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + if window == nil { + clearForwardedMouseDragState(reason: "overlayDetached") } } diff --git a/Sources/GhosttyMouseSessionLedger.swift b/Sources/GhosttyMouseSessionLedger.swift new file mode 100644 index 000000000000..f51323832b2a --- /dev/null +++ b/Sources/GhosttyMouseSessionLedger.swift @@ -0,0 +1,152 @@ +import AppKit +import GhosttyKit + +/// Owns the button sessions that a ``GhosttyNSView`` has sent to one native +/// Ghostty surface. +/// +/// A session is bound to the runtime generation and native pointer that +/// received its press. Releasing a session therefore requires the same +/// generation token; a later surface or press cannot be released by an older +/// event. Pointer coordinates are kept in the same ledger so a synthesized +/// release uses the last event belonging to that surface. +final class GhosttyMouseSessionLedger { + private(set) var activeSurface: SurfaceIdentity? + private(set) var pointerState: PointerState? + private var sessions: [Button: Session] = [:] + private var nextGeneration: UInt64 = 0 + + /// The buttons currently owned by this ledger. + var activeButtons: Set