Fix Cmd+F Escape passthrough into terminal (#918)

* Fix find-bar Escape passthrough to terminal

* Keep find Escape suppression armed until key-up
This commit is contained in:
Lawrence Chen 2026-03-04 19:26:05 -08:00 committed by GitHub
parent 6fe1410918
commit 604ba6fcab
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 156 additions and 0 deletions

View file

@ -2,6 +2,19 @@ import AppKit
import Bonsplit
import SwiftUI
private extension NSView {
func cmuxAncestor<T: NSView>(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(_:)):

View file

@ -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
}

View file

@ -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(),