Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 14 additions & 4 deletions Sources/GhosttyTerminalView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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))
}
Expand Down Expand Up @@ -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
Expand Down
74 changes: 74 additions & 0 deletions cmuxTests/CJKIMEInputTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
Loading