diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index fa51c0be54bc..0b2c955f1f42 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -4817,7 +4817,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { // Check if this event matches a Ghostty keybinding. let bindingFlags: ghostty_binding_flags_e? = { var keyEvent = ghosttyKeyEvent(for: event, surface: surface) - let text = event.characters ?? "" + let text = textForKeyEvent(event).flatMap { shouldSendText($0) ? $0 : nil } ?? "" var flags = ghostty_binding_flags_e(0) let isBinding = text.withCString { ptr in keyEvent.text = ptr @@ -5204,6 +5204,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { ) #endif } else { + keyEvent.consumed_mods = GHOSTTY_MODS_NONE keyEvent.text = nil #if DEBUG let ghosttySendStart = ProcessInfo.processInfo.systemUptime @@ -5249,6 +5250,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { ) #endif } else { + keyEvent.consumed_mods = GHOSTTY_MODS_NONE keyEvent.text = nil #if DEBUG let ghosttySendStart = ProcessInfo.processInfo.systemUptime @@ -5264,6 +5266,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { #endif } } else { + keyEvent.consumed_mods = GHOSTTY_MODS_NONE keyEvent.text = nil #if DEBUG let ghosttySendStart = ProcessInfo.processInfo.systemUptime @@ -5417,7 +5420,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { // If we have a single control character, return the character without // the control modifier so Ghostty's KeyEncoder can handle it. - if scalar.value < 0x20 { + if isControlCharacterScalar(scalar) { if flags.contains(.control) { return event.characters(byApplyingModifiers: event.modifierFlags.subtracting(.control)) } @@ -5454,9 +5457,16 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { return scalar.value } + private func isControlCharacterScalar(_ scalar: UnicodeScalar) -> Bool { + scalar.value < 0x20 || scalar.value == 0x7F + } + private func shouldSendText(_ text: String) -> Bool { - guard let first = text.utf8.first else { return false } - return first >= 0x20 + guard !text.isEmpty else { return false } + if text.count == 1, let scalar = text.unicodeScalars.first { + return !isControlCharacterScalar(scalar) + } + return true } /// If AppKit consumed Shift+Space for IME/input-source switching, interpretKeyEvents diff --git a/cmuxTests/CJKIMEInputTests.swift b/cmuxTests/CJKIMEInputTests.swift index e67e12aed885..849c2616aa1f 100644 --- a/cmuxTests/CJKIMEInputTests.swift +++ b/cmuxTests/CJKIMEInputTests.swift @@ -993,3 +993,77 @@ final class GhosttyBackquoteRegressionTests: XCTestCase { XCTAssertEqual(pressUnshiftedCodepoint, "`".unicodeScalars.first?.value) } } + +@MainActor +final class GhosttyOptionDeleteRegressionTests: XCTestCase { + func testOptionDeletePreservesAltAsModifierForWordDelete() { + _ = 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)) + + var pressEvent: ghostty_input_key_s? + GhosttyNSView.debugGhosttySurfaceKeyEventObserver = { keyEvent in + guard keyEvent.action == GHOSTTY_ACTION_PRESS, keyEvent.keycode == 51 else { return } + pressEvent = keyEvent + } + + let sent = hostedView.debugSendSyntheticKeyPressAndReleaseForUITest( + characters: "\u{7F}", + charactersIgnoringModifiers: "\u{7F}", + keyCode: 51, + modifierFlags: [.option] + ) + XCTAssertTrue(sent, "Expected synthetic Option+Delete event to be dispatched") + + guard let pressEvent else { + XCTFail("Expected to capture Option+Delete key event") + return + } + + XCTAssertEqual(pressEvent.action, GHOSTTY_ACTION_PRESS) + XCTAssertEqual(pressEvent.keycode, 51) + XCTAssertEqual( + pressEvent.mods.rawValue & GHOSTTY_MODS_ALT.rawValue, + GHOSTTY_MODS_ALT.rawValue, + "Option+Delete should preserve Alt on the raw key event" + ) + XCTAssertEqual( + pressEvent.consumed_mods.rawValue, + GHOSTTY_MODS_NONE.rawValue, + "Non-printing delete should not consume Option as text input" + ) + XCTAssertNil(pressEvent.text, "Delete should be encoded as a key event, not forwarded as DEL text") + } +}