From 5c6b9f08922636a6cbbaf8c04c24ab5b8956bb8e Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 11 Mar 2026 23:43:55 -0700 Subject: [PATCH 01/25] Fix phantom terminal text selection --- Sources/ContentView.swift | 31 ++++++ Sources/GhosttyTerminalView.swift | 174 +++++++++++++++++++++++++----- 2 files changed, 179 insertions(+), 26 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 3dfbd0c8aa70..18b1d99238c8 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -473,6 +473,36 @@ final class FileDropOverlayView: NSView { } } + private func clearForwardedMouseDragState(reason: String) { +#if DEBUG + if let forwardedMouseDragButton { + dlog("overlay.forwardedDrag.reset reason=\(reason) button=\(String(describing: forwardedMouseDragButton))") + } +#endif + forwardedMouseDragTarget = nil + forwardedMouseDragButton = nil + } + + private func repairForwardedMouseDragStateIfNeeded(for event: NSEvent) { + if forwardedMouseDragButton != nil, + forwardedMouseDragTarget?.window == nil { + clearForwardedMouseDragState(reason: "targetDetached") + return + } + + if let eventButton = dragButton(for: event), + shouldTrackForwardedMouseDragStart(for: event.type), + forwardedMouseDragButton == eventButton { + clearForwardedMouseDragState(reason: "repeatedMouseDown") + return + } + + if forwardedMouseDragButton != nil, + NSEvent.pressedMouseButtons == 0 { + clearForwardedMouseDragState(reason: "buttonsReleased") + } + } + // MARK: Hit-testing — participation is routed by DragOverlayRoutingPolicy so // file-drop, bonsplit tab drags, and sidebar tab reorder drags cannot conflict. @@ -503,6 +533,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 diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 771cfa3878a9..618f95c0bbef 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -3352,6 +3352,37 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { private var deferredSurfaceSizeRetryQueued = false private var lastDrawableSize: CGSize = .zero private var isFindEscapeSuppressionArmed = false + private enum TrackedMouseButton: String, CaseIterable { + case left + case right + case middle + + var ghosttyButton: ghostty_input_mouse_button_e { + switch self { + case .left: + return GHOSTTY_MOUSE_LEFT + case .right: + return GHOSTTY_MOUSE_RIGHT + case .middle: + return GHOSTTY_MOUSE_MIDDLE + } + } + + var pressedMouseButtonsMask: Int { + switch self { + case .left: + return 1 << 0 + case .right: + return 1 << 1 + case .middle: + return 1 << 2 + } + } + } + private var ghosttyPressedMouseButtons: Set = [] + private var ghosttyLastMousePoint: NSPoint? + private var ghosttyLastMouseMods: ghostty_input_mods_e = GHOSTTY_MODS_NONE + private var hasDeferredMouseButtonRepair = false #if DEBUG private var lastSizeSkipSignature: String? #endif @@ -3528,6 +3559,10 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { let isAlreadyAttached = surface.isAttached(to: self) if !isSameSurface { appliedColorScheme = nil + ghosttyPressedMouseButtons.removeAll() + ghosttyLastMousePoint = nil + ghosttyLastMouseMods = GHOSTTY_MODS_NONE + hasDeferredMouseButtonRepair = false } terminalSurface = surface tabId = surface.tabId @@ -3555,7 +3590,10 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { "pending=\(String(format: "%.1fx%.1f", pendingSurfaceSize?.width ?? 0, pendingSurfaceSize?.height ?? 0))" ) #endif - guard let window else { return } + guard let window else { + scheduleGhosttyMouseButtonRepair(reason: "viewDidMoveToWindow.nil") + return + } // If the surface creation was deferred while detached, create/attach it now. terminalSurface?.attachToView(self) @@ -4260,6 +4298,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { let result = super.resignFirstResponder() if result { desiredFocus = false + scheduleGhosttyMouseButtonRepair(reason: "resignFirstResponder") } if result, let surface = surface { let now = CACurrentMediaTime() @@ -4936,6 +4975,63 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { return ghostty_input_mods_e(rawValue: mods) } + @discardableResult + private func rememberGhosttyMouseState(from event: NSEvent) -> (point: NSPoint, mods: ghostty_input_mods_e) { + let point = convert(event.locationInWindow, from: nil) + let mods = modsFromEvent(event) + ghosttyLastMousePoint = point + ghosttyLastMouseMods = mods + return (point, mods) + } + + private func repairGhosttyMouseButtonsIfNeeded( + reason: String, + forceButtons: Set = [] + ) { + guard let surface else { + ghosttyPressedMouseButtons.removeAll() + return + } + guard !ghosttyPressedMouseButtons.isEmpty else { return } + + let physicalButtons = NSEvent.pressedMouseButtons + let buttonsToRelease = ghosttyPressedMouseButtons + .filter { button in + forceButtons.contains(button) || (physicalButtons & button.pressedMouseButtonsMask) == 0 + } + .sorted { $0.pressedMouseButtonsMask < $1.pressedMouseButtonsMask } + + guard !buttonsToRelease.isEmpty else { return } + + if let point = ghosttyLastMousePoint { + ghostty_surface_mouse_pos(surface, point.x, bounds.height - point.y, ghosttyLastMouseMods) + } + +#if DEBUG + let buttonList = buttonsToRelease.map(\.rawValue).joined(separator: ",") + dlog( + "terminal.mouseRepair surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil") " + + "reason=\(reason) buttons=\(buttonList) physicalMask=\(physicalButtons)" + ) +#endif + + for button in buttonsToRelease { + _ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, button.ghosttyButton, ghosttyLastMouseMods) + ghosttyPressedMouseButtons.remove(button) + } + } + + fileprivate func scheduleGhosttyMouseButtonRepair(reason: String) { + guard !ghosttyPressedMouseButtons.isEmpty else { return } + guard !hasDeferredMouseButtonRepair else { return } + hasDeferredMouseButtonRepair = true + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.hasDeferredMouseButtonRepair = false + self.repairGhosttyMouseButtonsIfNeeded(reason: reason) + } + } + func beginFindEscapeSuppression() { isFindEscapeSuppressionArmed = true } @@ -5105,8 +5201,10 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { } override func mouseDown(with event: NSEvent) { + let mouseState = rememberGhosttyMouseState(from: event) + repairGhosttyMouseButtonsIfNeeded(reason: "mouseDown.preflight", forceButtons: Set([.left])) #if DEBUG - let debugPoint = convert(event.locationInWindow, from: nil) + let debugPoint = mouseState.point dlog("terminal.mouseDown surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil") mods=[\(debugModifierString(event.modifierFlags))] clickCount=\(event.clickCount) point=(\(String(format: "%.0f", debugPoint.x)),\(String(format: "%.0f", debugPoint.y)))") #endif // Split reparent/layout churn can suppress the later `becomeFirstResponder -> onFocus` @@ -5121,20 +5219,24 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { ) } guard let surface = surface else { return } - let point = convert(event.locationInWindow, from: nil) - ghostty_surface_mouse_pos(surface, point.x, bounds.height - point.y, modsFromEvent(event)) - _ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_LEFT, modsFromEvent(event)) + ghostty_surface_mouse_pos(surface, mouseState.point.x, bounds.height - mouseState.point.y, mouseState.mods) + _ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_LEFT, mouseState.mods) + ghosttyPressedMouseButtons.insert(.left) } override func mouseUp(with event: NSEvent) { + let mouseState = rememberGhosttyMouseState(from: event) #if DEBUG dlog("terminal.mouseUp surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil") mods=[\(debugModifierString(event.modifierFlags))]") #endif guard let surface = surface else { return } - _ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_LEFT, modsFromEvent(event)) + _ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_LEFT, mouseState.mods) + ghosttyPressedMouseButtons.remove(.left) } override func rightMouseDown(with event: NSEvent) { + let mouseState = rememberGhosttyMouseState(from: event) + repairGhosttyMouseButtonsIfNeeded(reason: "rightMouseDown.preflight", forceButtons: Set([.right])) guard let surface = surface else { return } if !ghostty_surface_mouse_captured(surface) { requestPointerFocusRecovery() @@ -5144,19 +5246,23 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { requestPointerFocusRecovery() window?.makeFirstResponder(self) - let point = convert(event.locationInWindow, from: nil) - ghostty_surface_mouse_pos(surface, point.x, bounds.height - point.y, modsFromEvent(event)) - _ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_RIGHT, modsFromEvent(event)) + ghostty_surface_mouse_pos(surface, mouseState.point.x, bounds.height - mouseState.point.y, mouseState.mods) + _ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_RIGHT, mouseState.mods) + ghosttyPressedMouseButtons.insert(.right) } override func rightMouseUp(with event: NSEvent) { + let mouseState = rememberGhosttyMouseState(from: event) guard let surface = surface else { return } - if !ghostty_surface_mouse_captured(surface) { + let mouseCaptured = ghostty_surface_mouse_captured(surface) + if mouseCaptured || ghosttyPressedMouseButtons.contains(.right) { + _ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_RIGHT, mouseState.mods) + ghosttyPressedMouseButtons.remove(.right) + } + if !mouseCaptured { super.rightMouseUp(with: event) return } - - _ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_RIGHT, modsFromEvent(event)) } override func otherMouseDown(with event: NSEvent) { @@ -5164,12 +5270,14 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { super.otherMouseDown(with: event) return } + let mouseState = rememberGhosttyMouseState(from: event) + repairGhosttyMouseButtonsIfNeeded(reason: "otherMouseDown.preflight", forceButtons: Set([.middle])) requestPointerFocusRecovery() window?.makeFirstResponder(self) guard let surface = surface else { return } - let point = convert(event.locationInWindow, from: nil) - ghostty_surface_mouse_pos(surface, point.x, bounds.height - point.y, modsFromEvent(event)) - _ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_MIDDLE, modsFromEvent(event)) + ghostty_surface_mouse_pos(surface, mouseState.point.x, bounds.height - mouseState.point.y, mouseState.mods) + _ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_MIDDLE, mouseState.mods) + ghosttyPressedMouseButtons.insert(.middle) } override func otherMouseUp(with event: NSEvent) { @@ -5177,8 +5285,10 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { super.otherMouseUp(with: event) return } + let mouseState = rememberGhosttyMouseState(from: event) guard let surface = surface else { return } - _ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_MIDDLE, modsFromEvent(event)) + _ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_MIDDLE, mouseState.mods) + ghosttyPressedMouseButtons.remove(.middle) } override func menu(for event: NSEvent) -> NSMenu? { @@ -5187,10 +5297,12 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { return nil } + let mouseState = rememberGhosttyMouseState(from: event) + repairGhosttyMouseButtonsIfNeeded(reason: "menu.preflight", forceButtons: Set([.right])) window?.makeFirstResponder(self) - let point = convert(event.locationInWindow, from: nil) - ghostty_surface_mouse_pos(surface, point.x, bounds.height - point.y, modsFromEvent(event)) - _ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_RIGHT, modsFromEvent(event)) + ghostty_surface_mouse_pos(surface, mouseState.point.x, bounds.height - mouseState.point.y, mouseState.mods) + _ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_RIGHT, mouseState.mods) + ghosttyPressedMouseButtons.insert(.right) let menu = NSMenu() if onTriggerFlash != nil { @@ -5266,18 +5378,20 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { } override func mouseMoved(with event: NSEvent) { + repairGhosttyMouseButtonsIfNeeded(reason: "mouseMoved") maybeRequestFirstResponderForMouseFocus() guard let surface = surface else { return } - let point = convert(event.locationInWindow, from: nil) - ghostty_surface_mouse_pos(surface, point.x, bounds.height - point.y, modsFromEvent(event)) + let mouseState = rememberGhosttyMouseState(from: event) + ghostty_surface_mouse_pos(surface, mouseState.point.x, bounds.height - mouseState.point.y, mouseState.mods) } override func mouseEntered(with event: NSEvent) { super.mouseEntered(with: event) + repairGhosttyMouseButtonsIfNeeded(reason: "mouseEntered") maybeRequestFirstResponderForMouseFocus() guard let surface = surface else { return } - let point = convert(event.locationInWindow, from: nil) - ghostty_surface_mouse_pos(surface, point.x, bounds.height - point.y, modsFromEvent(event)) + let mouseState = rememberGhosttyMouseState(from: event) + ghostty_surface_mouse_pos(surface, mouseState.point.x, bounds.height - mouseState.point.y, mouseState.mods) } private func maybeRequestFirstResponderForMouseFocus() { @@ -5298,21 +5412,25 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { } override func mouseExited(with event: NSEvent) { + repairGhosttyMouseButtonsIfNeeded(reason: "mouseExited") guard let surface = surface else { return } if NSEvent.pressedMouseButtons != 0 { return } - ghostty_surface_mouse_pos(surface, -1, -1, modsFromEvent(event)) + let mods = modsFromEvent(event) + ghostty_surface_mouse_pos(surface, -1, -1, mods) } override func mouseDragged(with event: NSEvent) { guard let surface = surface else { return } - let point = convert(event.locationInWindow, from: nil) - ghostty_surface_mouse_pos(surface, point.x, bounds.height - point.y, modsFromEvent(event)) + let mouseState = rememberGhosttyMouseState(from: event) + ghostty_surface_mouse_pos(surface, mouseState.point.x, bounds.height - mouseState.point.y, mouseState.mods) } override func scrollWheel(with event: NSEvent) { + repairGhosttyMouseButtonsIfNeeded(reason: "scrollWheel") guard let surface = surface else { return } + _ = rememberGhosttyMouseState(from: event) lastScrollEventTime = CACurrentMediaTime() Self.focusLog("scrollWheel: surface=\(terminalSurface?.id.uuidString ?? "nil") firstResponder=\(String(describing: window?.firstResponder))") var x = event.scrollingDeltaX @@ -6669,12 +6787,14 @@ final class GhosttySurfaceScrollView: NSView { } #endif if !visible { + surfaceView.scheduleGhosttyMouseButtonRepair(reason: "setVisibleInUI.false") // If we were focused, yield first responder. if let window, let fr = window.firstResponder as? NSView, fr === surfaceView || fr.isDescendant(of: surfaceView) { window.makeFirstResponder(nil) } } else { + surfaceView.scheduleGhosttyMouseButtonRepair(reason: "setVisibleInUI.true") applyFirstResponderIfNeeded() } } @@ -6702,8 +6822,10 @@ final class GhosttySurfaceScrollView: NSView { } #endif if active { + surfaceView.scheduleGhosttyMouseButtonRepair(reason: "setActive.true") applyFirstResponderIfNeeded() } else { + surfaceView.scheduleGhosttyMouseButtonRepair(reason: "setActive.false") resignOwnedFirstResponderIfNeeded(reason: "setActive(false)") } } From 42ce88b01903ffb210a6bc5e047d30abc7146a2c Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 12 Mar 2026 00:05:56 -0700 Subject: [PATCH 02/25] Address mouse repair review feedback --- Sources/GhosttyTerminalView.swift | 93 +++++++++++++++++++++++-------- 1 file changed, 71 insertions(+), 22 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 618f95c0bbef..fba4a65dbdeb 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -3379,10 +3379,17 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { } } } + private struct RememberedGhosttyMouseState { + let localPoint: NSPoint + let surfacePoint: NSPoint + let mods: ghostty_input_mods_e + } private var ghosttyPressedMouseButtons: Set = [] - private var ghosttyLastMousePoint: NSPoint? + private var ghosttyLastMouseSurfacePoint: NSPoint? private var ghosttyLastMouseMods: ghostty_input_mods_e = GHOSTTY_MODS_NONE private var hasDeferredMouseButtonRepair = false + private var deferredGhosttyMouseButtonRepairForceButtons: Set = [] + private var deferredGhosttyMouseButtonRepairReason: String? #if DEBUG private var lastSizeSkipSignature: String? #endif @@ -3560,9 +3567,11 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { if !isSameSurface { appliedColorScheme = nil ghosttyPressedMouseButtons.removeAll() - ghosttyLastMousePoint = nil + ghosttyLastMouseSurfacePoint = nil ghosttyLastMouseMods = GHOSTTY_MODS_NONE hasDeferredMouseButtonRepair = false + deferredGhosttyMouseButtonRepairForceButtons = [] + deferredGhosttyMouseButtonRepairReason = nil } terminalSurface = surface tabId = surface.tabId @@ -3591,7 +3600,10 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { ) #endif guard let window else { - scheduleGhosttyMouseButtonRepair(reason: "viewDidMoveToWindow.nil") + scheduleGhosttyMouseButtonRepair( + reason: "viewDidMoveToWindow.nil", + forceButtons: ghosttyPressedMouseButtons + ) return } @@ -4298,7 +4310,10 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { let result = super.resignFirstResponder() if result { desiredFocus = false - scheduleGhosttyMouseButtonRepair(reason: "resignFirstResponder") + scheduleGhosttyMouseButtonRepair( + reason: "resignFirstResponder", + forceButtons: ghosttyPressedMouseButtons + ) } if result, let surface = surface { let now = CACurrentMediaTime() @@ -4976,12 +4991,17 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { } @discardableResult - private func rememberGhosttyMouseState(from event: NSEvent) -> (point: NSPoint, mods: ghostty_input_mods_e) { - let point = convert(event.locationInWindow, from: nil) + private func rememberGhosttyMouseState(from event: NSEvent) -> RememberedGhosttyMouseState { + let localPoint = convert(event.locationInWindow, from: nil) + let surfacePoint = NSPoint(x: localPoint.x, y: bounds.height - localPoint.y) let mods = modsFromEvent(event) - ghosttyLastMousePoint = point + ghosttyLastMouseSurfacePoint = surfacePoint ghosttyLastMouseMods = mods - return (point, mods) + return RememberedGhosttyMouseState( + localPoint: localPoint, + surfacePoint: surfacePoint, + mods: mods + ) } private func repairGhosttyMouseButtonsIfNeeded( @@ -5003,8 +5023,8 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { guard !buttonsToRelease.isEmpty else { return } - if let point = ghosttyLastMousePoint { - ghostty_surface_mouse_pos(surface, point.x, bounds.height - point.y, ghosttyLastMouseMods) + if let surfacePoint = ghosttyLastMouseSurfacePoint { + ghostty_surface_mouse_pos(surface, surfacePoint.x, surfacePoint.y, ghosttyLastMouseMods) } #if DEBUG @@ -5021,17 +5041,46 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { } } - fileprivate func scheduleGhosttyMouseButtonRepair(reason: String) { + private func scheduleGhosttyMouseButtonRepair( + reason: String, + forceButtons: Set + ) { guard !ghosttyPressedMouseButtons.isEmpty else { return } + deferredGhosttyMouseButtonRepairForceButtons.formUnion(forceButtons) + deferredGhosttyMouseButtonRepairReason = { + guard let existing = deferredGhosttyMouseButtonRepairReason, + existing != reason else { + return reason + } + return "\(existing)+\(reason)" + }() guard !hasDeferredMouseButtonRepair else { return } hasDeferredMouseButtonRepair = true DispatchQueue.main.async { [weak self] in guard let self else { return } + let deferredReason = self.deferredGhosttyMouseButtonRepairReason ?? reason + let deferredForceButtons = self.deferredGhosttyMouseButtonRepairForceButtons + self.deferredGhosttyMouseButtonRepairReason = nil + self.deferredGhosttyMouseButtonRepairForceButtons = [] self.hasDeferredMouseButtonRepair = false - self.repairGhosttyMouseButtonsIfNeeded(reason: reason) + self.repairGhosttyMouseButtonsIfNeeded( + reason: deferredReason, + forceButtons: deferredForceButtons + ) } } + fileprivate func scheduleGhosttyMouseButtonRepair(reason: String) { + scheduleGhosttyMouseButtonRepair(reason: reason, forceButtons: []) + } + + fileprivate func scheduleForcedGhosttyMouseButtonRepair(reason: String) { + scheduleGhosttyMouseButtonRepair( + reason: reason, + forceButtons: ghosttyPressedMouseButtons + ) + } + func beginFindEscapeSuppression() { isFindEscapeSuppressionArmed = true } @@ -5204,7 +5253,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { let mouseState = rememberGhosttyMouseState(from: event) repairGhosttyMouseButtonsIfNeeded(reason: "mouseDown.preflight", forceButtons: Set([.left])) #if DEBUG - let debugPoint = mouseState.point + let debugPoint = mouseState.localPoint dlog("terminal.mouseDown surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil") mods=[\(debugModifierString(event.modifierFlags))] clickCount=\(event.clickCount) point=(\(String(format: "%.0f", debugPoint.x)),\(String(format: "%.0f", debugPoint.y)))") #endif // Split reparent/layout churn can suppress the later `becomeFirstResponder -> onFocus` @@ -5219,7 +5268,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { ) } guard let surface = surface else { return } - ghostty_surface_mouse_pos(surface, mouseState.point.x, bounds.height - mouseState.point.y, mouseState.mods) + ghostty_surface_mouse_pos(surface, mouseState.surfacePoint.x, mouseState.surfacePoint.y, mouseState.mods) _ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_LEFT, mouseState.mods) ghosttyPressedMouseButtons.insert(.left) } @@ -5246,7 +5295,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { requestPointerFocusRecovery() window?.makeFirstResponder(self) - ghostty_surface_mouse_pos(surface, mouseState.point.x, bounds.height - mouseState.point.y, mouseState.mods) + ghostty_surface_mouse_pos(surface, mouseState.surfacePoint.x, mouseState.surfacePoint.y, mouseState.mods) _ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_RIGHT, mouseState.mods) ghosttyPressedMouseButtons.insert(.right) } @@ -5275,7 +5324,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { requestPointerFocusRecovery() window?.makeFirstResponder(self) guard let surface = surface else { return } - ghostty_surface_mouse_pos(surface, mouseState.point.x, bounds.height - mouseState.point.y, mouseState.mods) + ghostty_surface_mouse_pos(surface, mouseState.surfacePoint.x, mouseState.surfacePoint.y, mouseState.mods) _ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_MIDDLE, mouseState.mods) ghosttyPressedMouseButtons.insert(.middle) } @@ -5300,7 +5349,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { let mouseState = rememberGhosttyMouseState(from: event) repairGhosttyMouseButtonsIfNeeded(reason: "menu.preflight", forceButtons: Set([.right])) window?.makeFirstResponder(self) - ghostty_surface_mouse_pos(surface, mouseState.point.x, bounds.height - mouseState.point.y, mouseState.mods) + ghostty_surface_mouse_pos(surface, mouseState.surfacePoint.x, mouseState.surfacePoint.y, mouseState.mods) _ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_RIGHT, mouseState.mods) ghosttyPressedMouseButtons.insert(.right) @@ -5382,7 +5431,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { maybeRequestFirstResponderForMouseFocus() guard let surface = surface else { return } let mouseState = rememberGhosttyMouseState(from: event) - ghostty_surface_mouse_pos(surface, mouseState.point.x, bounds.height - mouseState.point.y, mouseState.mods) + ghostty_surface_mouse_pos(surface, mouseState.surfacePoint.x, mouseState.surfacePoint.y, mouseState.mods) } override func mouseEntered(with event: NSEvent) { @@ -5391,7 +5440,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { maybeRequestFirstResponderForMouseFocus() guard let surface = surface else { return } let mouseState = rememberGhosttyMouseState(from: event) - ghostty_surface_mouse_pos(surface, mouseState.point.x, bounds.height - mouseState.point.y, mouseState.mods) + ghostty_surface_mouse_pos(surface, mouseState.surfacePoint.x, mouseState.surfacePoint.y, mouseState.mods) } private func maybeRequestFirstResponderForMouseFocus() { @@ -5424,7 +5473,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { override func mouseDragged(with event: NSEvent) { guard let surface = surface else { return } let mouseState = rememberGhosttyMouseState(from: event) - ghostty_surface_mouse_pos(surface, mouseState.point.x, bounds.height - mouseState.point.y, mouseState.mods) + ghostty_surface_mouse_pos(surface, mouseState.surfacePoint.x, mouseState.surfacePoint.y, mouseState.mods) } override func scrollWheel(with event: NSEvent) { @@ -6787,7 +6836,7 @@ final class GhosttySurfaceScrollView: NSView { } #endif if !visible { - surfaceView.scheduleGhosttyMouseButtonRepair(reason: "setVisibleInUI.false") + surfaceView.scheduleForcedGhosttyMouseButtonRepair(reason: "setVisibleInUI.false") // If we were focused, yield first responder. if let window, let fr = window.firstResponder as? NSView, fr === surfaceView || fr.isDescendant(of: surfaceView) { @@ -6825,7 +6874,7 @@ final class GhosttySurfaceScrollView: NSView { surfaceView.scheduleGhosttyMouseButtonRepair(reason: "setActive.true") applyFirstResponderIfNeeded() } else { - surfaceView.scheduleGhosttyMouseButtonRepair(reason: "setActive.false") + surfaceView.scheduleForcedGhosttyMouseButtonRepair(reason: "setActive.false") resignOwnedFirstResponderIfNeeded(reason: "setActive(false)") } } From c36646400ab7d6a3b68fca39520805e898f2e5cd Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 12 Mar 2026 20:22:29 -0700 Subject: [PATCH 03/25] Fix mouse repair edge cases --- Sources/ContentView.swift | 8 +- Sources/GhosttyTerminalView.swift | 122 ++++++++++++++++++++++++++---- 2 files changed, 114 insertions(+), 16 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 18b1d99238c8..9721943211ea 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -473,6 +473,11 @@ final class FileDropOverlayView: NSView { } } + private func isTrackedForwardedMouseDragEnd(for event: NSEvent) -> Bool { + guard shouldTrackForwardedMouseDragEnd(for: event.type) else { return false } + return forwardedMouseDragButton == dragButton(for: event) + } + private func clearForwardedMouseDragState(reason: String) { #if DEBUG if let forwardedMouseDragButton { @@ -498,7 +503,8 @@ final class FileDropOverlayView: NSView { } if forwardedMouseDragButton != nil, - NSEvent.pressedMouseButtons == 0 { + NSEvent.pressedMouseButtons == 0, + !isTrackedForwardedMouseDragEnd(for: event) { clearForwardedMouseDragState(reason: "buttonsReleased") } } diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index fba4a65dbdeb..41971cd82db5 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -3357,6 +3357,20 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { case right case middle + init?(mouseUpEvent: NSEvent) { + switch mouseUpEvent.type { + case .leftMouseUp: + self = .left + case .rightMouseUp: + self = .right + case .otherMouseUp: + guard mouseUpEvent.buttonNumber == 2 else { return nil } + self = .middle + default: + return nil + } + } + var ghosttyButton: ghostty_input_mouse_button_e { switch self { case .left: @@ -3385,10 +3399,13 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { let mods: ghostty_input_mods_e } private var ghosttyPressedMouseButtons: Set = [] + private var ghosttyPressedMouseButtonGenerations: [TrackedMouseButton: UInt64] = [:] + private var nextGhosttyMouseButtonGeneration: UInt64 = 0 private var ghosttyLastMouseSurfacePoint: NSPoint? private var ghosttyLastMouseMods: ghostty_input_mods_e = GHOSTTY_MODS_NONE private var hasDeferredMouseButtonRepair = false private var deferredGhosttyMouseButtonRepairForceButtons: Set = [] + private var deferredGhosttyMouseButtonRepairForceButtonGenerations: [TrackedMouseButton: UInt64] = [:] private var deferredGhosttyMouseButtonRepairReason: String? #if DEBUG private var lastSizeSkipSignature: String? @@ -3535,7 +3552,9 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { private func installEventMonitor() { guard eventMonitor == nil else { return } - eventMonitor = NSEvent.addLocalMonitorForEvents(matching: [.scrollWheel]) { [weak self] event in + eventMonitor = NSEvent.addLocalMonitorForEvents( + matching: [.scrollWheel, .leftMouseUp, .rightMouseUp, .otherMouseUp] + ) { [weak self] event in return self?.localEventHandler(event) ?? event } } @@ -3544,6 +3563,8 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { switch event.type { case .scrollWheel: return localEventScrollWheel(event) + case .leftMouseUp, .rightMouseUp, .otherMouseUp: + return localEventMouseUp(event) default: return event } @@ -3561,16 +3582,48 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { return event } + private func localEventMouseUp(_ event: NSEvent) -> NSEvent? { + guard let button = TrackedMouseButton(mouseUpEvent: event), + ghosttyPressedMouseButtons.contains(button) else { return event } + + if let eventWindow = event.window, eventWindow == window { + _ = rememberGhosttyMouseState(from: event) + } + + scheduleGhosttyMouseButtonRepair( + reason: "localMouseUp.\(button.rawValue)", + forceButtons: Set([button]) + ) + return event + } + + private func markGhosttyMouseButtonPressed(_ button: TrackedMouseButton) { + nextGhosttyMouseButtonGeneration &+= 1 + ghosttyPressedMouseButtons.insert(button) + ghosttyPressedMouseButtonGenerations[button] = nextGhosttyMouseButtonGeneration + } + + private func markGhosttyMouseButtonReleased(_ button: TrackedMouseButton) { + ghosttyPressedMouseButtons.remove(button) + ghosttyPressedMouseButtonGenerations[button] = nil + } + + private func resetGhosttyMouseButtonTracking() { + ghosttyPressedMouseButtons.removeAll() + ghosttyPressedMouseButtonGenerations.removeAll() + } + func attachSurface(_ surface: TerminalSurface) { let isSameSurface = terminalSurface === surface let isAlreadyAttached = surface.isAttached(to: self) if !isSameSurface { appliedColorScheme = nil - ghosttyPressedMouseButtons.removeAll() + resetGhosttyMouseButtonTracking() ghosttyLastMouseSurfacePoint = nil ghosttyLastMouseMods = GHOSTTY_MODS_NONE hasDeferredMouseButtonRepair = false deferredGhosttyMouseButtonRepairForceButtons = [] + deferredGhosttyMouseButtonRepairForceButtonGenerations = [:] deferredGhosttyMouseButtonRepairReason = nil } terminalSurface = surface @@ -5006,10 +5059,11 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { private func repairGhosttyMouseButtonsIfNeeded( reason: String, - forceButtons: Set = [] + forceButtons: Set = [], + forceButtonGenerations: [TrackedMouseButton: UInt64]? = nil ) { guard let surface else { - ghosttyPressedMouseButtons.removeAll() + resetGhosttyMouseButtonTracking() return } guard !ghosttyPressedMouseButtons.isEmpty else { return } @@ -5017,7 +5071,13 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { let physicalButtons = NSEvent.pressedMouseButtons let buttonsToRelease = ghosttyPressedMouseButtons .filter { button in - forceButtons.contains(button) || (physicalButtons & button.pressedMouseButtonsMask) == 0 + if forceButtons.contains(button) { + guard let forceButtonGenerations else { return true } + guard let forceGeneration = forceButtonGenerations[button] else { return false } + return ghosttyPressedMouseButtonGenerations[button] == forceGeneration + } + + return (physicalButtons & button.pressedMouseButtonsMask) == 0 } .sorted { $0.pressedMouseButtonsMask < $1.pressedMouseButtonsMask } @@ -5037,7 +5097,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { for button in buttonsToRelease { _ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, button.ghosttyButton, ghosttyLastMouseMods) - ghosttyPressedMouseButtons.remove(button) + markGhosttyMouseButtonReleased(button) } } @@ -5046,7 +5106,13 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { forceButtons: Set ) { guard !ghosttyPressedMouseButtons.isEmpty else { return } - deferredGhosttyMouseButtonRepairForceButtons.formUnion(forceButtons) + for button in forceButtons { + guard let generation = ghosttyPressedMouseButtonGenerations[button] else { continue } + deferredGhosttyMouseButtonRepairForceButtons.insert(button) + // Preserve the scheduled press generation so stale async repairs + // cannot release a newer press of the same button. + deferredGhosttyMouseButtonRepairForceButtonGenerations[button] = generation + } deferredGhosttyMouseButtonRepairReason = { guard let existing = deferredGhosttyMouseButtonRepairReason, existing != reason else { @@ -5060,12 +5126,15 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { guard let self else { return } let deferredReason = self.deferredGhosttyMouseButtonRepairReason ?? reason let deferredForceButtons = self.deferredGhosttyMouseButtonRepairForceButtons + let deferredForceButtonGenerations = self.deferredGhosttyMouseButtonRepairForceButtonGenerations self.deferredGhosttyMouseButtonRepairReason = nil self.deferredGhosttyMouseButtonRepairForceButtons = [] + self.deferredGhosttyMouseButtonRepairForceButtonGenerations = [:] self.hasDeferredMouseButtonRepair = false self.repairGhosttyMouseButtonsIfNeeded( reason: deferredReason, - forceButtons: deferredForceButtons + forceButtons: deferredForceButtons, + forceButtonGenerations: deferredForceButtonGenerations ) } } @@ -5270,7 +5339,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { guard let surface = surface else { return } ghostty_surface_mouse_pos(surface, mouseState.surfacePoint.x, mouseState.surfacePoint.y, mouseState.mods) _ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_LEFT, mouseState.mods) - ghosttyPressedMouseButtons.insert(.left) + markGhosttyMouseButtonPressed(.left) } override func mouseUp(with event: NSEvent) { @@ -5280,7 +5349,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { #endif guard let surface = surface else { return } _ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_LEFT, mouseState.mods) - ghosttyPressedMouseButtons.remove(.left) + markGhosttyMouseButtonReleased(.left) } override func rightMouseDown(with event: NSEvent) { @@ -5297,7 +5366,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { window?.makeFirstResponder(self) ghostty_surface_mouse_pos(surface, mouseState.surfacePoint.x, mouseState.surfacePoint.y, mouseState.mods) _ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_RIGHT, mouseState.mods) - ghosttyPressedMouseButtons.insert(.right) + markGhosttyMouseButtonPressed(.right) } override func rightMouseUp(with event: NSEvent) { @@ -5306,7 +5375,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { let mouseCaptured = ghostty_surface_mouse_captured(surface) if mouseCaptured || ghosttyPressedMouseButtons.contains(.right) { _ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_RIGHT, mouseState.mods) - ghosttyPressedMouseButtons.remove(.right) + markGhosttyMouseButtonReleased(.right) } if !mouseCaptured { super.rightMouseUp(with: event) @@ -5326,7 +5395,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { guard let surface = surface else { return } ghostty_surface_mouse_pos(surface, mouseState.surfacePoint.x, mouseState.surfacePoint.y, mouseState.mods) _ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_MIDDLE, mouseState.mods) - ghosttyPressedMouseButtons.insert(.middle) + markGhosttyMouseButtonPressed(.middle) } override func otherMouseUp(with event: NSEvent) { @@ -5337,7 +5406,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { let mouseState = rememberGhosttyMouseState(from: event) guard let surface = surface else { return } _ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_MIDDLE, mouseState.mods) - ghosttyPressedMouseButtons.remove(.middle) + markGhosttyMouseButtonReleased(.middle) } override func menu(for event: NSEvent) -> NSMenu? { @@ -5351,7 +5420,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { window?.makeFirstResponder(self) ghostty_surface_mouse_pos(surface, mouseState.surfacePoint.x, mouseState.surfacePoint.y, mouseState.mods) _ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_RIGHT, mouseState.mods) - ghosttyPressedMouseButtons.insert(.right) + markGhosttyMouseButtonPressed(.right) let menu = NSMenu() if onTriggerFlash != nil { @@ -5476,6 +5545,29 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { ghostty_surface_mouse_pos(surface, mouseState.surfacePoint.x, mouseState.surfacePoint.y, mouseState.mods) } + override func rightMouseDragged(with event: NSEvent) { + guard let surface = surface else { + super.rightMouseDragged(with: event) + return + } + guard ghostty_surface_mouse_captured(surface) || ghosttyPressedMouseButtons.contains(.right) else { + super.rightMouseDragged(with: event) + return + } + let mouseState = rememberGhosttyMouseState(from: event) + ghostty_surface_mouse_pos(surface, mouseState.surfacePoint.x, mouseState.surfacePoint.y, mouseState.mods) + } + + override func otherMouseDragged(with event: NSEvent) { + guard event.buttonNumber == 2 else { + super.otherMouseDragged(with: event) + return + } + guard let surface = surface else { return } + let mouseState = rememberGhosttyMouseState(from: event) + ghostty_surface_mouse_pos(surface, mouseState.surfacePoint.x, mouseState.surfacePoint.y, mouseState.mods) + } + override func scrollWheel(with event: NSEvent) { repairGhosttyMouseButtonsIfNeeded(reason: "scrollWheel") guard let surface = surface else { return } From f130fbb104e9dffc61814e88bc1395991ba0e091 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 27 Aug 2026 15:37:00 -0700 Subject: [PATCH 04/25] Harden mouse release cleanup across portal detach --- Sources/GhosttyTerminalView.swift | 45 ++++++++++++++++++++----------- 1 file changed, 30 insertions(+), 15 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 753848e5a327..ec4ba7513a11 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -7286,8 +7286,13 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { if routeInputDuringClipboardRead(event) { return true } guard hasPendingLeftMouseRelease else { return false } hasPendingLeftMouseRelease = false - guard let surface else { return false } let mouseState = rememberGhosttyMouseState(from: event) + guard let surface else { + // A portal can detach the runtime between the press and release. + // Clear our mirror even when there is no surface left to receive it. + markGhosttyMouseButtonReleased(.left) + return false + } let point = mouseState.localPoint _ = dispatchCommandClickRelease( surface: surface, @@ -8103,7 +8108,10 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { override func rightMouseUp(with event: NSEvent) { if routeInputDuringClipboardRead(event) { return } let mouseState = rememberGhosttyMouseState(from: event) - guard let surface = surface else { return } + guard let surface else { + markGhosttyMouseButtonReleased(.right) + return + } let mouseCaptured = ghostty_surface_mouse_captured(surface) if mouseCaptured || ghosttyPressedMouseButtons.contains(.right) { _ = sendGhosttyMouseButton( @@ -8153,7 +8161,10 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { } if routeInputDuringClipboardRead(event) { return } let mouseState = rememberGhosttyMouseState(from: event) - guard let surface = surface else { return } + guard let surface else { + markGhosttyMouseButtonReleased(.middle) + return + } _ = sendGhosttyMouseButton( surface, state: GHOSTTY_MOUSE_RELEASE, @@ -11083,16 +11094,18 @@ final class GhosttySurfaceScrollView: NSView { fr === surfaceView || fr.isDescendant(of: surfaceView) { window.makeFirstResponder(nil) } - } else if !wasVisible { + } else { surfaceView.scheduleGhosttyMouseButtonRepair(reason: "setVisibleInUI.true") - // Workspace/sidebar selection can make an already-sized terminal visible again - // without a portal frame delta or a focus handoff. Nudge the Metal layer with - // the portal refresh path — but on the next main-queue turn: reveals arrive - // from inside SwiftUI update/layout (updateNSView, viewDidMoveToWindow, the - // geometry-callback rebind), where a synchronous display can wedge the main - // thread in Metal against the still-open window transaction. - scheduleVisibilityRevealRefresh() - scheduleAutomaticFirstResponderApply(reason: "setVisibleInUI") + if !wasVisible { + // Workspace/sidebar selection can make an already-sized terminal visible again + // without a portal frame delta or a focus handoff. Nudge the Metal layer with + // the portal refresh path — but on the next main-queue turn: reveals arrive + // from inside SwiftUI update/layout (updateNSView, viewDidMoveToWindow, the + // geometry-callback rebind), where a synchronous display can wedge the main + // thread in Metal against the still-open window transaction. + scheduleVisibilityRevealRefresh() + scheduleAutomaticFirstResponderApply(reason: "setVisibleInUI") + } } } @@ -11136,10 +11149,12 @@ final class GhosttySurfaceScrollView: NSView { ) } #endif - if active && !wasActive { + if active { surfaceView.scheduleGhosttyMouseButtonRepair(reason: "setActive.true") - scheduleAutomaticFirstResponderApply(reason: "setActive") - } else if !active { + if !wasActive { + scheduleAutomaticFirstResponderApply(reason: "setActive") + } + } else { surfaceView.scheduleForcedGhosttyMouseButtonRepair(reason: "setActive.false") resignOwnedFirstResponderIfNeeded(reason: "setActive(false)") } From b802362f5c6e433122acf419d79149388a4d7d5a Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 27 Aug 2026 15:38:10 -0700 Subject: [PATCH 05/25] Add forwarded mouse-up regression coverage --- Sources/FileDropOverlayView.swift | 3 +- cmuxTests/FileDropOverlayViewTests.swift | 84 ++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 2 deletions(-) diff --git a/Sources/FileDropOverlayView.swift b/Sources/FileDropOverlayView.swift index aa398f5641f4..adc851dd127d 100644 --- a/Sources/FileDropOverlayView.swift +++ b/Sources/FileDropOverlayView.swift @@ -144,8 +144,7 @@ final class FileDropOverlayView: NSView { } if forwardedMouseDragButton != nil, - NSEvent.pressedMouseButtons == 0, - !isTrackedForwardedMouseDragEnd(for: event) { + NSEvent.pressedMouseButtons == 0 { clearForwardedMouseDragState(reason: "buttonsReleased") } } diff --git a/cmuxTests/FileDropOverlayViewTests.swift b/cmuxTests/FileDropOverlayViewTests.swift index 29424bc54db3..ea0f5edd6474 100644 --- a/cmuxTests/FileDropOverlayViewTests.swift +++ b/cmuxTests/FileDropOverlayViewTests.swift @@ -72,6 +72,22 @@ struct FileDropOverlayViewTests { } } + private final class MouseEventSpyView: NSView { + var mouseCalls: [String] = [] + + override func mouseDown(with event: NSEvent) { + mouseCalls.append("down") + } + + override func mouseDragged(with event: NSEvent) { + mouseCalls.append("dragged") + } + + override func mouseUp(with event: NSEvent) { + mouseCalls.append("up") + } + } + private final class MockDraggingInfo: NSObject, NSDraggingInfo { let draggingDestinationWindow: NSWindow? let draggingSourceOperationMask: NSDragOperation @@ -320,4 +336,72 @@ struct FileDropOverlayViewTests { "Rejected text drops should not be recorded as performed or receive a text-route conclude" ) } + + @Test("Forwarded mouse-up stays with the original drag target") + func forwardedMouseUpUsesOriginalTargetAfterPhysicalRelease() throws { + _ = NSApplication.shared + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 240), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + defer { + window.orderOut(nil) + window.close() + } + + let contentView = NSView(frame: NSRect(x: 0, y: 0, width: 420, height: 240)) + window.contentView = contentView + let originalTarget = MouseEventSpyView(frame: NSRect(x: 0, y: 0, width: 210, height: 240)) + let underCursorTarget = MouseEventSpyView(frame: NSRect(x: 210, y: 0, width: 210, height: 240)) + contentView.addSubview(originalTarget) + contentView.addSubview(underCursorTarget) + let overlay = FileDropOverlayView(frame: contentView.bounds) + contentView.addSubview(overlay, positioned: .above, relativeTo: nil) + + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + contentView.layoutSubtreeIfNeeded() + + let downLocation = originalTarget.convert( + NSPoint(x: originalTarget.bounds.midX, y: originalTarget.bounds.midY), + to: nil + ) + let upLocation = underCursorTarget.convert( + NSPoint(x: underCursorTarget.bounds.midX, y: underCursorTarget.bounds.midY), + to: nil + ) + let down = try #require(Self.mouseEvent(type: .leftMouseDown, location: downLocation, window: window)) + let up = try #require(Self.mouseEvent(type: .leftMouseUp, location: upLocation, window: window)) + + overlay.mouseDown(with: down) + overlay.mouseUp(with: up) + + #expect( + originalTarget.mouseCalls == ["down", "up"], + "AppKit's post-release button mask must not redirect mouse-up away from the original target" + ) + #expect(underCursorTarget.mouseCalls.isEmpty) + } + + private static func mouseEvent( + type: NSEvent.EventType, + location: NSPoint, + window: NSWindow + ) -> NSEvent? { + NSEvent.mouseEvent( + with: type, + location: location, + modifierFlags: [], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + eventNumber: 1, + clickCount: 1, + pressure: type == .leftMouseUp ? 0 : 1 + ) + } } From 44abc89f5efe09e8f691b896c1e1aae1ddbcb682 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 27 Aug 2026 15:51:49 -0700 Subject: [PATCH 06/25] Preserve forwarded drag target through mouse-up --- Sources/FileDropOverlayView.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/FileDropOverlayView.swift b/Sources/FileDropOverlayView.swift index adc851dd127d..aa398f5641f4 100644 --- a/Sources/FileDropOverlayView.swift +++ b/Sources/FileDropOverlayView.swift @@ -144,7 +144,8 @@ final class FileDropOverlayView: NSView { } if forwardedMouseDragButton != nil, - NSEvent.pressedMouseButtons == 0 { + NSEvent.pressedMouseButtons == 0, + !isTrackedForwardedMouseDragEnd(for: event) { clearForwardedMouseDragState(reason: "buttonsReleased") } } From 2f0f86dfcf5f6bd3fbd9e1c518c375cbba2e9a57 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 27 Aug 2026 16:15:55 -0700 Subject: [PATCH 07/25] Clear pending release after synthesized mouse repair --- Sources/GhosttyTerminalView.swift | 51 +++++++++++++++++++++++++------ 1 file changed, 41 insertions(+), 10 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index ec4ba7513a11..1282974317ab 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -4006,6 +4006,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { private var deferredGhosttyMouseButtonRepairForceButtonGenerations: [TrackedMouseButton: UInt64] = [:] private var deferredGhosttyMouseButtonRepairReason: String? private var hasPendingLeftMouseRelease = false + private var pendingLeftMouseReleaseGeneration: UInt64? let imageTransferPreparation: TerminalImageTransferPreparationService? #if DEBUG private var lastSizeSkipSignature: String? @@ -4250,13 +4251,23 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { return event } - private func markGhosttyMouseButtonPressed(_ button: TrackedMouseButton) { + @discardableResult + private func markGhosttyMouseButtonPressed(_ button: TrackedMouseButton) -> UInt64 { nextGhosttyMouseButtonGeneration &+= 1 ghosttyPressedMouseButtons.insert(button) ghosttyPressedMouseButtonGenerations[button] = nextGhosttyMouseButtonGeneration + return nextGhosttyMouseButtonGeneration } - private func markGhosttyMouseButtonReleased(_ button: TrackedMouseButton) { + private func markGhosttyMouseButtonReleased( + _ button: TrackedMouseButton, + expectedGeneration: UInt64 + ) { + guard ghosttyPressedMouseButtonGenerations[button] == expectedGeneration else { return } + if button == .left, pendingLeftMouseReleaseGeneration == expectedGeneration { + hasPendingLeftMouseRelease = false + pendingLeftMouseReleaseGeneration = nil + } ghosttyPressedMouseButtons.remove(button) ghosttyPressedMouseButtonGenerations[button] = nil } @@ -4264,6 +4275,8 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { private func resetGhosttyMouseButtonTracking() { ghosttyPressedMouseButtons.removeAll() ghosttyPressedMouseButtonGenerations.removeAll() + hasPendingLeftMouseRelease = false + pendingLeftMouseReleaseGeneration = nil } func attachSurface(_ surface: TerminalSurface) { @@ -6970,13 +6983,14 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { #endif for button in buttonsToRelease { + guard let generation = ghosttyPressedMouseButtonGenerations[button] else { continue } _ = sendGhosttyMouseButton( surface, state: GHOSTTY_MOUSE_RELEASE, button: button.ghosttyButton, mods: ghosttyLastMouseMods ) - markGhosttyMouseButtonReleased(button) + markGhosttyMouseButtonReleased(button, expectedGeneration: generation) } } @@ -7259,8 +7273,9 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { button: GHOSTTY_MOUSE_LEFT, mods: mouseState.mods ) - markGhosttyMouseButtonPressed(.left) + let generation = markGhosttyMouseButtonPressed(.left) hasPendingLeftMouseRelease = true + pendingLeftMouseReleaseGeneration = generation } override func mouseUp(with event: NSEvent) { @@ -7285,12 +7300,16 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { func completePendingLeftMouseRelease(with event: NSEvent) -> Bool { if routeInputDuringClipboardRead(event) { return true } guard hasPendingLeftMouseRelease else { return false } + let pendingGeneration = pendingLeftMouseReleaseGeneration hasPendingLeftMouseRelease = false + pendingLeftMouseReleaseGeneration = nil let mouseState = rememberGhosttyMouseState(from: event) guard let surface else { // A portal can detach the runtime between the press and release. // Clear our mirror even when there is no surface left to receive it. - markGhosttyMouseButtonReleased(.left) + if let pendingGeneration { + markGhosttyMouseButtonReleased(.left, expectedGeneration: pendingGeneration) + } return false } let point = mouseState.localPoint @@ -7300,7 +7319,9 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { modifierFlags: event.modifierFlags, mouseMods: mouseState.mods ) - markGhosttyMouseButtonReleased(.left) + if let pendingGeneration { + markGhosttyMouseButtonReleased(.left, expectedGeneration: pendingGeneration) + } return true } @@ -8109,18 +8130,23 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { if routeInputDuringClipboardRead(event) { return } let mouseState = rememberGhosttyMouseState(from: event) guard let surface else { - markGhosttyMouseButtonReleased(.right) + if let generation = ghosttyPressedMouseButtonGenerations[.right] { + markGhosttyMouseButtonReleased(.right, expectedGeneration: generation) + } return } let mouseCaptured = ghostty_surface_mouse_captured(surface) if mouseCaptured || ghosttyPressedMouseButtons.contains(.right) { + let generation = ghosttyPressedMouseButtonGenerations[.right] _ = sendGhosttyMouseButton( surface, state: GHOSTTY_MOUSE_RELEASE, button: GHOSTTY_MOUSE_RIGHT, mods: mouseState.mods ) - markGhosttyMouseButtonReleased(.right) + if let generation { + markGhosttyMouseButtonReleased(.right, expectedGeneration: generation) + } } if !mouseCaptured { super.rightMouseUp(with: event) @@ -8162,16 +8188,21 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { if routeInputDuringClipboardRead(event) { return } let mouseState = rememberGhosttyMouseState(from: event) guard let surface else { - markGhosttyMouseButtonReleased(.middle) + if let generation = ghosttyPressedMouseButtonGenerations[.middle] { + markGhosttyMouseButtonReleased(.middle, expectedGeneration: generation) + } return } + let generation = ghosttyPressedMouseButtonGenerations[.middle] _ = sendGhosttyMouseButton( surface, state: GHOSTTY_MOUSE_RELEASE, button: GHOSTTY_MOUSE_MIDDLE, mods: mouseState.mods ) - markGhosttyMouseButtonReleased(.middle) + if let generation { + markGhosttyMouseButtonReleased(.middle, expectedGeneration: generation) + } } override func menu(for event: NSEvent) -> NSMenu? { From a221b7fa3caef5f7785a603caf942ce12b12bf72 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 27 Aug 2026 18:01:08 -0700 Subject: [PATCH 08/25] Avoid duplicate releases after mouse repair --- Sources/GhosttyTerminalView.swift | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 1282974317ab..4d568cb7b3b7 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -8136,17 +8136,14 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { return } let mouseCaptured = ghostty_surface_mouse_captured(surface) - if mouseCaptured || ghosttyPressedMouseButtons.contains(.right) { - let generation = ghosttyPressedMouseButtonGenerations[.right] + if let generation = ghosttyPressedMouseButtonGenerations[.right] { _ = sendGhosttyMouseButton( surface, state: GHOSTTY_MOUSE_RELEASE, button: GHOSTTY_MOUSE_RIGHT, mods: mouseState.mods ) - if let generation { - markGhosttyMouseButtonReleased(.right, expectedGeneration: generation) - } + markGhosttyMouseButtonReleased(.right, expectedGeneration: generation) } if !mouseCaptured { super.rightMouseUp(with: event) @@ -8193,14 +8190,13 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { } return } - let generation = ghosttyPressedMouseButtonGenerations[.middle] - _ = sendGhosttyMouseButton( - surface, - state: GHOSTTY_MOUSE_RELEASE, - button: GHOSTTY_MOUSE_MIDDLE, - mods: mouseState.mods - ) - if let generation { + if let generation = ghosttyPressedMouseButtonGenerations[.middle] { + _ = sendGhosttyMouseButton( + surface, + state: GHOSTTY_MOUSE_RELEASE, + button: GHOSTTY_MOUSE_MIDDLE, + mods: mouseState.mods + ) markGhosttyMouseButtonReleased(.middle, expectedGeneration: generation) } } From 88d7e9c6f0f72857f5d5a741a7b40af3b85553c5 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 27 Aug 2026 18:37:03 -0700 Subject: [PATCH 09/25] Sequence mouse repair with clipboard input replay --- Sources/GhosttyNSView+ClipboardInputSequencing.swift | 8 ++++++++ Sources/GhosttyTerminalView.swift | 10 ++++++++++ Sources/TerminalClipboardInputSequencer.swift | 6 ++++++ 3 files changed, 24 insertions(+) diff --git a/Sources/GhosttyNSView+ClipboardInputSequencing.swift b/Sources/GhosttyNSView+ClipboardInputSequencing.swift index cf7fa3fa17ea..27be00f8bc51 100644 --- a/Sources/GhosttyNSView+ClipboardInputSequencing.swift +++ b/Sources/GhosttyNSView+ClipboardInputSequencing.swift @@ -68,6 +68,7 @@ extension GhosttyNSView { ) { [weak self] deferredInput in self?.replayClipboardDeferredInput(deferredInput) } + scheduleMouseRepairAfterClipboardDrainIfNeeded() } func cancelClipboardRead( @@ -82,6 +83,7 @@ extension GhosttyNSView { ) { [weak self] deferredInput in self?.replayClipboardDeferredInput(deferredInput) } + scheduleMouseRepairAfterClipboardDrainIfNeeded() } func cancelReservedClipboardRead( @@ -98,6 +100,12 @@ extension GhosttyNSView { ) { [weak self] deferredInput in self?.replayClipboardDeferredInput(deferredInput) } + scheduleMouseRepairAfterClipboardDrainIfNeeded() + } + + private func scheduleMouseRepairAfterClipboardDrainIfNeeded() { + guard !hasClipboardInputDeferral else { return } + scheduleGhosttyMouseButtonRepair(reason: "clipboardInputDrained") } func routeInputDuringClipboardRead(_ event: NSEvent) -> Bool { diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 4d568cb7b3b7..a6f7fc1b2ee5 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -4015,6 +4015,12 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { private var hasUsableFocusGeometry: Bool { bounds.width > 1 && bounds.height > 1 } + fileprivate var hasClipboardInputDeferral: Bool { + terminalClipboardInputSequencer.hasInputDeferral( + for: terminalSurface?.runtimeSurfaceGeneration ?? .max + ) + } + static func shouldRequestFirstResponderForMouseFocus( focusFollowsMouseEnabled: Bool, pressedMouseButtons: Int, @@ -4239,6 +4245,10 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { private func localEventMouseUp(_ event: NSEvent) -> NSEvent? { guard let button = TrackedMouseButton(mouseUpEvent: event), ghosttyPressedMouseButtons.contains(button) else { return event } + // The normal mouse-up path is buffered while a sequenced clipboard + // read is in flight. Let that event replay in order instead of + // synthesizing a release ahead of the queued gesture. + guard !hasClipboardInputDeferral else { return event } if let eventWindow = event.window, eventWindow == window { _ = rememberGhosttyMouseState(from: event) diff --git a/Sources/TerminalClipboardInputSequencer.swift b/Sources/TerminalClipboardInputSequencer.swift index 97956817e28e..33987fc9d4f7 100644 --- a/Sources/TerminalClipboardInputSequencer.swift +++ b/Sources/TerminalClipboardInputSequencer.swift @@ -199,6 +199,12 @@ final class TerminalClipboardInputSequencer Bool { + hasRequestInFlight(for: epoch) + } + /// Cancels a request whose native surface lifetime ended. Deferred input /// from that epoch is discarded without touching replacement-surface input. func cancelRequest( From cdf1b3451498093d7451096c926bd6a43636b620 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 27 Aug 2026 18:57:40 -0700 Subject: [PATCH 10/25] Preserve queued drag events during mouse repair --- Sources/FileDropOverlayView.swift | 10 ++++++++++ Sources/GhosttyTerminalView.swift | 1 - 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/Sources/FileDropOverlayView.swift b/Sources/FileDropOverlayView.swift index aa398f5641f4..916b2c939bab 100644 --- a/Sources/FileDropOverlayView.swift +++ b/Sources/FileDropOverlayView.swift @@ -144,12 +144,22 @@ final class FileDropOverlayView: NSView { } if forwardedMouseDragButton != nil, + !isForwardedMouseDragMotion(event.type), NSEvent.pressedMouseButtons == 0, !isTrackedForwardedMouseDragEnd(for: event) { clearForwardedMouseDragState(reason: "buttonsReleased") } } + 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. diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index a6f7fc1b2ee5..667cbd3fbf19 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -8458,7 +8458,6 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { override func mouseDragged(with event: NSEvent) { if routeInputDuringClipboardRead(event) { return } - repairGhosttyMouseButtonsIfNeeded(reason: "mouseDragged") guard let surface = surface else { return } let mouseState = rememberGhosttyMouseState(from: event) let eventPoint = mouseState.localPoint From eba1edaec54d1e3485276edf1697da67e2b51c8a Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 27 Aug 2026 19:15:34 -0700 Subject: [PATCH 11/25] Expose mouse repair seam to clipboard extension --- Sources/GhosttyTerminalView.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 667cbd3fbf19..7240932fcb33 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -4015,7 +4015,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { private var hasUsableFocusGeometry: Bool { bounds.width > 1 && bounds.height > 1 } - fileprivate var hasClipboardInputDeferral: Bool { + var hasClipboardInputDeferral: Bool { terminalClipboardInputSequencer.hasInputDeferral( for: terminalSurface?.runtimeSurfaceGeneration ?? .max ) @@ -7042,7 +7042,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { } } - fileprivate func scheduleGhosttyMouseButtonRepair(reason: String) { + func scheduleGhosttyMouseButtonRepair(reason: String) { scheduleGhosttyMouseButtonRepair(reason: reason, forceButtons: []) } From aeb81f8d3df0008bb522ab3e966a8715c64df51e Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 27 Aug 2026 20:27:38 -0700 Subject: [PATCH 12/25] Consolidate terminal mouse ownership in surface ledger --- Sources/FileDropOverlayView.swift | 110 +++-- Sources/GhosttyMouseSessionLedger.swift | 215 ++++++++++ ...osttyNSView+ClipboardInputSequencing.swift | 2 +- Sources/GhosttyTerminalView.swift | 403 ++++++++---------- cmux.xcodeproj/project.pbxproj | 8 + cmuxTests/FileDropOverlayViewTests.swift | 113 +++++ .../GhosttyMouseSessionLedgerTests.swift | 80 ++++ 7 files changed, 673 insertions(+), 258 deletions(-) create mode 100644 Sources/GhosttyMouseSessionLedger.swift create mode 100644 cmuxTests/GhosttyMouseSessionLedgerTests.swift diff --git a/Sources/FileDropOverlayView.swift b/Sources/FileDropOverlayView.swift index 916b2c939bab..4a9e633165fa 100644 --- a/Sources/FileDropOverlayView.swift +++ b/Sources/FileDropOverlayView.swift @@ -43,8 +43,19 @@ final class FileDropOverlayView: NSView { /// 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? + /// AppKit captures a separate target for each button. Keeping the captures + /// independent prevents a right/middle press from replacing an in-flight + /// left drag, which would otherwise redirect the eventual left release. + private final class ForwardedMouseDragTarget { + weak var view: NSView? + + init(view: NSView) { + self.view = view + } + } + + 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,7 +85,7 @@ final class FileDropOverlayView: NSView { required init?(coder: NSCoder) { fatalError("init(coder:) not implemented") } - private enum ForwardedMouseDragButton: Equatable { + private enum ForwardedMouseDragButton: Hashable { case left case right case other(Int) @@ -111,43 +122,55 @@ final class FileDropOverlayView: NSView { } } - private func isTrackedForwardedMouseDragEnd(for event: NSEvent) -> Bool { - guard shouldTrackForwardedMouseDragEnd(for: event.type) else { return false } - return forwardedMouseDragButton == dragButton(for: event) - } - - private func clearForwardedMouseDragState(reason: String) { + /// 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 - if let forwardedMouseDragButton { + 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=\(String(describing: forwardedMouseDragButton))" + "button=\(buttons)" ) } #endif - forwardedMouseDragTarget = nil - forwardedMouseDragButton = nil + if let button { + forwardedMouseDragTargets.removeValue(forKey: button) + } else { + forwardedMouseDragTargets.removeAll(keepingCapacity: true) + } } private func repairForwardedMouseDragStateIfNeeded(for event: NSEvent) { - if forwardedMouseDragButton != nil, - forwardedMouseDragTarget?.window == nil { - clearForwardedMouseDragState(reason: "targetDetached") - return + // 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), - forwardedMouseDragButton == eventButton { - clearForwardedMouseDragState(reason: "repeatedMouseDown") - return - } - - if forwardedMouseDragButton != nil, - !isForwardedMouseDragMotion(event.type), - NSEvent.pressedMouseButtons == 0, - !isTrackedForwardedMouseDragEnd(for: event) { - clearForwardedMouseDragState(reason: "buttonsReleased") + forwardedMouseDragTargets[eventButton] != nil { + clearForwardedMouseDragState(for: eventButton, reason: "repeatedMouseDown") } } @@ -215,13 +238,12 @@ final class FileDropOverlayView: NSView { } let target: NSView? - if let eventButton, - forwardedMouseDragButton == eventButton, - let activeTarget = forwardedMouseDragTarget, - activeTarget.window != nil { - // Preserve normal AppKit mouse-delivery semantics: once a drag starts, - // keep routing dragged/up events to the original mouseDown target. - target = activeTarget + if let eventButton, isForwardedMouseDragMotion(event.type) || shouldTrackForwardedMouseDragEnd(for: event.type) { + // Preserve normal AppKit mouse-delivery semantics: once a drag + // starts, keep routing dragged/up events to that button's original + // mouseDown target. A motion/up without a capture is discarded; + // hit-testing it as a fresh drag would invent a new gesture. + target = forwardedMouseDragTargets[eventButton]?.view } else { let point = contentView.convert(event.locationInWindow, from: nil) target = contentView.hitTest(point) @@ -229,16 +251,14 @@ final class FileDropOverlayView: NSView { guard let target, target !== self else { if shouldTrackForwardedMouseDragEnd(for: event.type), - let eventButton, - forwardedMouseDragButton == eventButton { - clearForwardedMouseDragState(reason: "targetUnavailable") + 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 { @@ -256,9 +276,15 @@ final class FileDropOverlayView: NSView { } if shouldTrackForwardedMouseDragEnd(for: event.type), - let eventButton, - forwardedMouseDragButton == eventButton { - clearForwardedMouseDragState(reason: "mouseUp") + 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..9491e02343b7 --- /dev/null +++ b/Sources/GhosttyMouseSessionLedger.swift @@ -0,0 +1,215 @@ +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 { + /// The buttons cmux forwards to Ghostty. + enum Button: String, CaseIterable, Hashable { + case left + case right + case middle + + /// Creates a tracked button for a mouse-up event, if the event is one + /// of the buttons forwarded to Ghostty. + init?(mouseUpEvent: NSEvent) { + switch mouseUpEvent.type { + case .leftMouseUp: + self = .left + case .rightMouseUp: + self = .right + case .otherMouseUp: + guard mouseUpEvent.buttonNumber == 2 else { return nil } + self = .middle + default: + return nil + } + } + + /// The corresponding libghostty button value. + var ghosttyButton: ghostty_input_mouse_button_e { + switch self { + case .left: + return GHOSTTY_MOUSE_LEFT + case .right: + return GHOSTTY_MOUSE_RIGHT + case .middle: + return GHOSTTY_MOUSE_MIDDLE + } + } + + /// The AppKit bit used when reconciling an event-stream gap. + var pressedMouseButtonsMask: Int { + switch self { + case .left: + return 1 << 0 + case .right: + return 1 << 1 + case .middle: + return 1 << 2 + } + } + + fileprivate var ordering: Int { + switch self { + case .left: return 0 + case .right: return 1 + case .middle: return 2 + } + } + } + + /// Identity of the native surface that owns a session. + struct SurfaceIdentity: Equatable { + let surfaceID: UUID + let runtimeGeneration: UInt64 + let nativeAddress: UInt + } + + /// Last pointer location and modifiers observed for the active surface. + struct PointerState { + let localPoint: NSPoint + let surfacePoint: NSPoint + let mods: ghostty_input_mods_e + } + + /// An opaque generation token for one button press. + struct Session: Equatable { + let button: Button + let generation: UInt64 + let surface: SurfaceIdentity + } + + 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