diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index a84593a8..3e20f2e1 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -3700,23 +3700,23 @@ extension Notification.Name { private final class GhosttyScrollView: NSScrollView { weak var surfaceView: GhosttyNSView? + // Keep keyboard routing on the terminal surface; this wrapper is viewport plumbing. + override var acceptsFirstResponder: Bool { false } + override func scrollWheel(with event: NSEvent) { guard let surfaceView else { super.scrollWheel(with: event) return } - if let surface = surfaceView.terminalSurface?.surface, - ghostty_surface_mouse_captured(surface) { - GhosttyNSView.focusLog("GhosttyScrollView.scrollWheel: mouseCaptured -> surface scroll") - if window?.firstResponder !== surfaceView { - window?.makeFirstResponder(surfaceView) - } - surfaceView.scrollWheel(with: event) - } else { - GhosttyNSView.focusLog("GhosttyScrollView.scrollWheel: super scroll") - super.scrollWheel(with: event) + // Route wheel gestures to the terminal surface so Ghostty handles scrollback. + // Letting NSScrollView consume these events moves the wrapper viewport itself, + // which causes pane-content drift instead of terminal scrollback movement. + GhosttyNSView.focusLog("GhosttyScrollView.scrollWheel: surface scroll") + if window?.firstResponder !== surfaceView { + window?.makeFirstResponder(surfaceView) } + surfaceView.scrollWheel(with: event) } } diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index a9da122c..9bd5c215 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -6571,6 +6571,75 @@ final class CommandPaletteOverlayPromotionPolicyTests: XCTestCase { @MainActor final class GhosttySurfaceOverlayTests: XCTestCase { + private final class ScrollProbeSurfaceView: GhosttyNSView { + private(set) var scrollWheelCallCount = 0 + + override func scrollWheel(with event: NSEvent) { + scrollWheelCallCount += 1 + } + } + + func testTrackpadScrollRoutesToTerminalSurfaceAndPreservesKeyboardFocusPath() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 360, height: 240), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let surfaceView = ScrollProbeSurfaceView(frame: NSRect(x: 0, y: 0, width: 160, height: 120)) + let hostedView = GhosttySurfaceScrollView(surfaceView: surfaceView) + hostedView.frame = contentView.bounds + hostedView.autoresizingMask = [.width, .height] + contentView.addSubview(hostedView) + + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + contentView.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + guard let scrollView = hostedView.subviews.first(where: { $0 is NSScrollView }) as? NSScrollView else { + XCTFail("Expected hosted terminal scroll view") + return + } + XCTAssertFalse( + scrollView.acceptsFirstResponder, + "Host scroll view should not become first responder and steal terminal shortcuts" + ) + + _ = window.makeFirstResponder(nil) + + guard let cgEvent = CGEvent( + scrollWheelEvent2Source: nil, + units: .pixel, + wheelCount: 2, + wheel1: 0, + wheel2: -12, + wheel3: 0 + ), let scrollEvent = NSEvent(cgEvent: cgEvent) else { + XCTFail("Expected scroll wheel event") + return + } + + scrollView.scrollWheel(with: scrollEvent) + + XCTAssertEqual( + surfaceView.scrollWheelCallCount, + 1, + "Trackpad wheel events should be forwarded directly to Ghostty surface scrolling" + ) + XCTAssertTrue( + window.firstResponder === surfaceView, + "Scroll wheel handling should keep keyboard focus on terminal surface" + ) + } + func testInactiveOverlayVisibilityTracksRequestedState() { let hostedView = GhosttySurfaceScrollView( surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 80, height: 50))