diff --git a/Sources/Find/SurfaceSearchOverlay.swift b/Sources/Find/SurfaceSearchOverlay.swift index 44b37433..17c795e6 100644 --- a/Sources/Find/SurfaceSearchOverlay.swift +++ b/Sources/Find/SurfaceSearchOverlay.swift @@ -2,6 +2,19 @@ import AppKit import Bonsplit import SwiftUI +private extension NSView { + func cmuxAncestor(of type: T.Type) -> T? { + var current: NSView? = self + while let view = current { + if let target = view as? T { + return target + } + current = view.superview + } + return nil + } +} + struct SurfaceSearchOverlay: View { let tabId: UUID let surfaceId: UUID @@ -268,6 +281,7 @@ private struct SearchTextFieldRepresentable: NSViewRepresentable { case #selector(NSResponder.cancelOperation(_:)): // Don't intercept Escape during CJK IME composition (issue #118) if textView.hasMarkedText() { return false } + control.cmuxAncestor(of: GhosttySurfaceScrollView.self)?.beginFindEscapeSuppression() parent.onEscape() return true case #selector(NSResponder.insertNewline(_:)): diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 8f946c4c..106b35a1 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -2915,6 +2915,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { private var lastScrollEventTime: CFTimeInterval = 0 private var visibleInUI: Bool = true private var pendingSurfaceSize: CGSize? + private var isFindEscapeSuppressionArmed = false #if DEBUG private var lastSizeSkipSignature: String? #endif @@ -3932,6 +3933,12 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { super.keyDown(with: event) return } + if event.keyCode != 53 { + endFindEscapeSuppression() + } + if shouldConsumeSuppressedFindEscape(event) { + return + } if handleKeyboardCopyModeIfNeeded(event, surface: surface) { keyboardCopyModeConsumedKeyUps.insert(event.keyCode) return @@ -4144,6 +4151,16 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { super.keyUp(with: event) return } + if event.keyCode != 53 { + endFindEscapeSuppression() + } + if shouldConsumeSuppressedFindEscape(event) { + endFindEscapeSuppression() + return + } + if event.keyCode == 53 { + endFindEscapeSuppression() + } if keyboardCopyModeConsumedKeyUps.remove(event.keyCode) != nil { return @@ -4196,6 +4213,21 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { return ghostty_input_mods_e(rawValue: mods) } + func beginFindEscapeSuppression() { + isFindEscapeSuppressionArmed = true + } + + private func endFindEscapeSuppression() { + isFindEscapeSuppressionArmed = false + } + + private func shouldConsumeSuppressedFindEscape(_ event: NSEvent) -> Bool { + guard event.keyCode == 53 else { return false } + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + guard flags.isEmpty else { return false } + return isFindEscapeSuppressionArmed + } + /// Get the characters for a key event with control character handling. /// When control is pressed, we get the character without the control modifier /// so Ghostty's KeyEncoder can apply its own control character encoding. @@ -5292,6 +5324,10 @@ final class GhosttySurfaceScrollView: NSView { } } + func beginFindEscapeSuppression() { + surfaceView.beginFindEscapeSuppression() + } + func setTriggerFlashHandler(_ handler: (() -> Void)?) { surfaceView.onTriggerFlash = handler } diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index ffedcee4..658ef0b0 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -8299,6 +8299,18 @@ final class GhosttySurfaceOverlayTests: XCTestCase { } } + private func findEditableTextField(in view: NSView) -> NSTextField? { + if let field = view as? NSTextField, field.isEditable { + return field + } + for subview in view.subviews { + if let field = findEditableTextField(in: subview) { + return field + } + } + return nil + } + func testTrackpadScrollRoutesToTerminalSurfaceAndPreservesKeyboardFocusPath() { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 360, height: 240), @@ -8437,6 +8449,100 @@ final class GhosttySurfaceOverlayTests: XCTestCase { XCTAssertFalse(hostedView.debugHasSearchOverlay()) } + func testEscapeDismissingFindOverlayDoesNotLeakEscapeKeyUpToTerminal() { + _ = NSApplication.shared + + let surface = TerminalSurface( + tabId: UUID(), + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: nil, + workingDirectory: nil + ) + let hostedView = surface.hostedView + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 360, height: 240), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { + GhosttyNSView.debugGhosttySurfaceKeyEventObserver = nil + window.orderOut(nil) + } + + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + hostedView.frame = contentView.bounds + hostedView.autoresizingMask = [.width, .height] + contentView.addSubview(hostedView) + + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + contentView.layoutSubtreeIfNeeded() + hostedView.setVisibleInUI(true) + hostedView.setActive(true) + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + let searchState = TerminalSurface.SearchState(needle: "") + surface.searchState = searchState + hostedView.setSearchOverlay(searchState: searchState) + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + guard let searchField = findEditableTextField(in: hostedView) else { + XCTFail("Expected mounted find text field") + return + } + window.makeFirstResponder(searchField) + + var escapeKeyUpCount = 0 + GhosttyNSView.debugGhosttySurfaceKeyEventObserver = { keyEvent in + guard keyEvent.action == GHOSTTY_ACTION_RELEASE, keyEvent.keycode == 53 else { return } + escapeKeyUpCount += 1 + } + + let timestamp = ProcessInfo.processInfo.systemUptime + guard let escapeKeyDown = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [], + timestamp: timestamp, + windowNumber: window.windowNumber, + context: nil, + characters: "\u{1b}", + charactersIgnoringModifiers: "\u{1b}", + isARepeat: false, + keyCode: 53 + ), let escapeKeyUp = NSEvent.keyEvent( + with: .keyUp, + location: .zero, + modifierFlags: [], + timestamp: timestamp + 0.001, + windowNumber: window.windowNumber, + context: nil, + characters: "\u{1b}", + charactersIgnoringModifiers: "\u{1b}", + isARepeat: false, + keyCode: 53 + ) else { + XCTFail("Failed to construct Escape key events") + return + } + + NSApp.sendEvent(escapeKeyDown) + NSApp.sendEvent(escapeKeyUp) + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + XCTAssertNil(surface.searchState, "Escape should dismiss find overlay when search text is empty") + XCTAssertEqual( + escapeKeyUpCount, + 0, + "Escape used to dismiss find overlay must not pass through to the terminal key-up path" + ) + } + func testKeyboardCopyModeIndicatorMountsAndUnmounts() { let surface = TerminalSurface( tabId: UUID(),