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:
parent
6fe1410918
commit
604ba6fcab
3 changed files with 156 additions and 0 deletions
|
|
@ -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(_:)):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue