Skip to content
Merged
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
8 changes: 0 additions & 8 deletions Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14323,14 +14323,6 @@ private extension NSWindow {
}

if let ghosttyView = firstResponderGhosttyView {
if ghosttyView.shouldRouteTextInputKeyEquivalentToKeyDown(event) {
ghosttyView.keyDown(with: event)
#if DEBUG
cmuxDebugLog(" → terminal text-input key equivalent routed to keyDown")
#endif
return true
}

// If the IME is composing and the key has no Cmd modifier, don't intercept —
// let it flow through normal AppKit event dispatch so the input method can
// process it. Cmd-based shortcuts should still work during composition since
Expand Down
139 changes: 5 additions & 134 deletions Sources/GhosttyNSView+IMEComposition.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import AppKit
import Carbon.HIToolbox

extension GhosttyNSView {
/// Clamps AppKit's marked-text selection into the active preedit buffer.
Expand Down Expand Up @@ -34,141 +33,19 @@ extension GhosttyNSView {
func shouldSuppressGhosttyKeyForwardingAfterIMEHandling(
before: (text: String, selection: NSRange),
after: (text: String, selection: NSRange),
accumulatedText: [String],
event: NSEvent? = nil,
textInputHandledEvent: Bool = false,
inputSourceId: String? = nil
accumulatedText: [String]
) -> Bool {
guard accumulatedText.isEmpty else { return false }

let hadMarkedTextBefore = !before.text.isEmpty
let hasMarkedTextAfter = !after.text.isEmpty
guard hadMarkedTextBefore || hasMarkedTextAfter else {
// Some IMEs, including Traditional Chinese Zhuyin, can handle a
// command key against their private preedit buffer before they call
// setMarkedText on the client. Keep handled no-output input-method
// events out of the terminal so keys such as Down can open
// candidates instead of moving the shell cursor.
guard textInputHandledEvent, isInputMethodSource(inputSourceId) else { return false }
return !shouldAllowDeferredNumpadIMEFallback(event)
}
guard hadMarkedTextBefore || hasMarkedTextAfter else { return false }

if before.text != after.text {
return true
}

if before.selection != after.selection {
return true
}

guard let event, isInputMethodSource(inputSourceId) else {
return false
}
return shouldKeepIMECompositionCommandInsideTextInput(event)
}

func isInputMethodSource(_ sourceId: String?) -> Bool {
guard let sourceId else { return false }
return sourceId.localizedCaseInsensitiveContains("inputmethod")
}

func shouldAllowDeferredNumpadIMEFallback(_ event: NSEvent?) -> Bool {
guard let event,
let text = event.characters,
!text.isEmpty,
text.allSatisfy(\.isNumber) else {
return false
}
let flags = event.modifierFlags
.intersection(.deviceIndependentFlagsMask)
.subtracting([.function, .capsLock])
return flags == [.numericPad]
}

func isTraditionalZhuyinInputSource(_ sourceId: String?) -> Bool {
guard let sourceId else { return false }
return sourceId.localizedCaseInsensitiveContains("TCIM.Zhuyin")
}

func shouldOpenZhuyinCandidatesWithSyntheticSpace(
event: NSEvent,
inputSourceId: String?,
markedTextBefore: Bool,
before: (text: String, selection: NSRange),
after: (text: String, selection: NSRange),
accumulatedText: [String],
commandSelector: Selector?,
candidateOpenAlreadyRequested: Bool
) -> Bool {
guard !candidateOpenAlreadyRequested,
markedTextBefore,
accumulatedText.isEmpty,
isTraditionalZhuyinInputSource(inputSourceId),
Int(event.keyCode) == kVK_DownArrow,
commandSelector == #selector(NSResponder.moveDown(_:)),
before.text == after.text,
before.selection == after.selection else {
return false
}
return true
}

func shouldRememberZhuyinCandidateInteraction(
event: NSEvent,
inputSourceId: String?,
markedTextBefore: Bool,
accumulatedText: [String]
) -> Bool {
guard markedTextBefore,
accumulatedText.isEmpty,
isTraditionalZhuyinInputSource(inputSourceId) else {
return false
}

let flags = event.modifierFlags
.intersection(.deviceIndependentFlagsMask)
.subtracting([.numericPad, .function, .capsLock])
guard flags.isEmpty || flags == [.shift] else { return false }

switch Int(event.keyCode) {
case kVK_DownArrow, kVK_UpArrow, kVK_PageUp, kVK_PageDown, kVK_Space:
return true
default:
return false
}
}

/// Returns true when a window-level key-equivalent probe should re-enter
/// the terminal's keyDown path so AppKit's text input context sees the key
/// before terminal bindings or cursor escape sequences do.
func shouldRouteTextInputKeyEquivalentToKeyDown(_ event: NSEvent) -> Bool {
shouldRouteTextInputKeyEquivalentToKeyDown(event, inputSourceId: nil)
}

func shouldRouteTextInputKeyEquivalentToKeyDown(_ event: NSEvent, inputSourceId: String?) -> Bool {
guard event.type == .keyDown else { return false }
guard shouldKeepIMECompositionCommandInsideTextInput(event) else { return false }
if hasMarkedText() { return true }
return isInputMethodSource(inputSourceId ?? KeyboardLayout.id)
}

/// Returns true for active-composition command keys that belong to AppKit's
/// text input manager even when marked text itself does not change.
func shouldKeepIMECompositionCommandInsideTextInput(_ event: NSEvent) -> Bool {
let flags = event.modifierFlags
.intersection(.deviceIndependentFlagsMask)
.subtracting([.numericPad, .function, .capsLock])
guard flags.isEmpty || flags == [.shift] else { return false }

switch Int(event.keyCode) {
case kVK_LeftArrow, kVK_RightArrow, kVK_UpArrow, kVK_DownArrow,
kVK_PageUp, kVK_PageDown, kVK_Home, kVK_End,
kVK_Space, kVK_Return, kVK_ANSI_KeypadEnter, kVK_Escape,
kVK_Tab, kVK_Delete, kVK_ForwardDelete:
return true
default:
return false
}
return before.selection != after.selection
}

#if DEBUG
Expand All @@ -177,18 +54,12 @@ extension GhosttyNSView {
markedSelectionBefore: NSRange,
markedTextAfter: String,
markedSelectionAfter: NSRange,
accumulatedText: [String],
event: NSEvent? = nil,
textInputHandledEvent: Bool = false,
inputSourceId: String? = nil
accumulatedText: [String]
) -> Bool {
shouldSuppressGhosttyKeyForwardingAfterIMEHandling(
before: (markedTextBefore, markedSelectionBefore),
after: (markedTextAfter, markedSelectionAfter),
accumulatedText: accumulatedText,
event: event,
textInputHandledEvent: textInputHandledEvent,
inputSourceId: inputSourceId
accumulatedText: accumulatedText
)
}
#endif
Expand Down
128 changes: 18 additions & 110 deletions Sources/GhosttyTerminalView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7030,8 +7030,6 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
if result {
desiredFocus = false
terminalSurface?.recordExternalFocusState(false)
imeSuppressedKeyUpKeyCodes.removeAll()
zhuyinCandidateOpenRequested = false
}
if result, let surface = surface {
let now = CACurrentMediaTime()
Expand All @@ -7049,9 +7047,6 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
private var lastPerformKeyEvent: TimeInterval?
private(set) var externalCommittedTextDepth = 0
var numpadIMECommitDeduplicator = NumpadIMECommitDeduplicator()
private var imeSuppressedKeyUpKeyCodes: Set<UInt16> = []
private var textInputCommandSelectorDuringKeyDown: Selector?
private var zhuyinCandidateOpenRequested = false
private struct SelectionSnapshot {
let range: NSRange
let string: String
Expand All @@ -7060,22 +7055,11 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {

#if DEBUG
// Test-only accessors for keyTextAccumulator to verify CJK IME composition behavior.
func setKeyTextAccumulatorForTesting(_ value: [String]?) { keyTextAccumulator = value }
var keyTextAccumulatorForTesting: [String]? { keyTextAccumulator }
static var debugTextInputEventHandler: ((GhosttyNSView, NSEvent) -> Bool)?

func setIMETransientStateForTesting(
suppressedKeyUpKeyCodes: Set<UInt16>,
zhuyinCandidateOpenRequested: Bool
) {
imeSuppressedKeyUpKeyCodes = suppressedKeyUpKeyCodes
self.zhuyinCandidateOpenRequested = zhuyinCandidateOpenRequested
}
var imeSuppressedKeyUpKeyCodesForTesting: Set<UInt16> {
imeSuppressedKeyUpKeyCodes
func setKeyTextAccumulatorForTesting(_ value: [String]?) {
keyTextAccumulator = value
}
var zhuyinCandidateOpenRequestedForTesting: Bool {
zhuyinCandidateOpenRequested
var keyTextAccumulatorForTesting: [String]? {
keyTextAccumulator
}
func shouldSuppressShiftSpaceFallbackTextForTesting(event: NSEvent, markedTextBefore: Bool) -> Bool {
shouldSuppressShiftSpaceFallbackText(event: event, markedTextBefore: markedTextBefore)
Expand All @@ -7095,15 +7079,6 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {

// Prevents NSBeep for unimplemented actions from interpretKeyEvents
override func doCommand(by selector: Selector) {
textInputCommandSelectorDuringKeyDown = selector
#if DEBUG
if hasMarkedText() {
cmuxDebugLog(
"ime.doCommand selector=\(NSStringFromSelector(selector)) " +
"markedLength=\(markedText.length)"
)
}
#endif
// Intentionally empty - prevents system beep on unhandled key commands
}

Expand Down Expand Up @@ -7482,21 +7457,25 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
// Set up text accumulator for interpretKeyEvents
keyTextAccumulator = []
defer { keyTextAccumulator = nil }
textInputCommandSelectorDuringKeyDown = nil

let markedTextBefore = markedText.length > 0
let markedStateBefore = (markedText.string, markedSelectedRange)

// Capture the keyboard layout ID before interpretation so we can
// detect if an IME changed it (e.g. toggling input methods).
let keyboardIdBefore = KeyboardLayout.id
// We only check when not already in a preedit state.
let keyboardIdBefore: String? = if (!markedTextBefore) {
KeyboardLayout.id
} else {
nil
}

// Let the input system handle the event (for IME, dead keys, etc.)
#if DEBUG
let interpretTimingStart = CmuxTypingTiming.start()
let interpretPhaseStart = ProcessInfo.processInfo.systemUptime
#endif
let textInputHandledEvent = handleTextInputKeyEvent(translationEvent)
interpretKeyEvents([translationEvent])
#if DEBUG
interpretMs = (ProcessInfo.processInfo.systemUptime - interpretPhaseStart) * 1000.0
CmuxTypingTiming.logDuration(
Expand Down Expand Up @@ -7528,46 +7507,10 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
syncPreeditMs = (ProcessInfo.processInfo.systemUptime - syncPreeditStart) * 1000.0
#endif

var accumulatedText = keyTextAccumulator ?? []
var markedStateAfter = (markedText.string, markedSelectedRange)
if shouldOpenZhuyinCandidatesWithSyntheticSpace(
event: translationEvent,
inputSourceId: keyboardIdBefore,
markedTextBefore: markedTextBefore,
before: markedStateBefore,
after: markedStateAfter,
accumulatedText: accumulatedText,
commandSelector: textInputCommandSelectorDuringKeyDown,
candidateOpenAlreadyRequested: zhuyinCandidateOpenRequested
) {
zhuyinCandidateOpenRequested = true
textInputCommandSelectorDuringKeyDown = nil
_ = handleTextInputKeyEvent(zhuyinCandidateOpenSpaceEvent(from: translationEvent))
syncPreedit(clearIfNeeded: markedTextBefore)
accumulatedText = keyTextAccumulator ?? []
markedStateAfter = (markedText.string, markedSelectedRange)
} else if shouldRememberZhuyinCandidateInteraction(
event: translationEvent,
inputSourceId: keyboardIdBefore,
markedTextBefore: markedTextBefore,
accumulatedText: accumulatedText
) {
zhuyinCandidateOpenRequested = true
} else if markedTextBefore, isTraditionalZhuyinInputSource(keyboardIdBefore) {
zhuyinCandidateOpenRequested = false
}

let accumulatedText = keyTextAccumulator ?? []
if shouldSuppressGhosttyKeyForwardingAfterIMEHandling(
before: markedStateBefore,
after: markedStateAfter,
accumulatedText: accumulatedText,
event: translationEvent,
textInputHandledEvent: textInputHandledEvent,
inputSourceId: keyboardIdBefore
) {
imeSuppressedKeyUpKeyCodes.insert(event.keyCode)
return
}
before: markedStateBefore, after: (markedText.string, markedSelectedRange), accumulatedText: accumulatedText
) { return }

// Build the key event
var keyEvent = ghostty_input_key_s()
Expand Down Expand Up @@ -7749,35 +7692,6 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
// Rendering is driven by Ghostty's wakeups/renderer.
}

@discardableResult
private func handleTextInputKeyEvent(_ event: NSEvent) -> Bool {
#if DEBUG
if let debugTextInputEventHandler = Self.debugTextInputEventHandler {
return debugTextInputEventHandler(self, event)
}
#endif
guard let inputContext else {
interpretKeyEvents([event])
return false
}
return inputContext.handleEvent(event)
}

private func zhuyinCandidateOpenSpaceEvent(from event: NSEvent) -> NSEvent {
NSEvent.keyEvent(
with: event.type,
location: event.locationInWindow,
modifierFlags: event.modifierFlags.subtracting([.shift, .numericPad, .function]),
timestamp: event.timestamp,
windowNumber: event.windowNumber,
context: nil,
characters: " ",
charactersIgnoringModifiers: " ",
isARepeat: event.isARepeat,
keyCode: UInt16(kVK_Space)
) ?? event
}

@discardableResult
private func sendGhosttyKey(_ surface: ghostty_surface_t, _ keyEvent: ghostty_input_key_s) -> Bool {
#if DEBUG
Expand Down Expand Up @@ -7810,10 +7724,6 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
#endif

override func keyUp(with event: NSEvent) {
if imeSuppressedKeyUpKeyCodes.remove(event.keyCode) != nil {
return
}

guard let surface = ensureSurfaceReadyForInput() else {
super.keyUp(with: event)
return
Expand Down Expand Up @@ -12834,8 +12744,8 @@ extension GhosttyNSView: NSTextInputClient {
}

func unmarkText() {
let hadMarkedText = markedText.length > 0
#if DEBUG
let hadMarkedText = markedText.length > 0
let typingTimingStart = CmuxTypingTiming.start()
defer {
CmuxTypingTiming.logDuration(
Expand All @@ -12845,11 +12755,9 @@ extension GhosttyNSView: NSTextInputClient {
)
}
#endif
markedText.mutableString.setString("")
markedSelectedRange = NSRange(location: NSNotFound, length: 0)
zhuyinCandidateOpenRequested = false

if hadMarkedText {
if markedText.length > 0 {
markedText.mutableString.setString("")
markedSelectedRange = NSRange(location: NSNotFound, length: 0)
syncPreedit()
invalidateTextInputCoordinates(selectionChanged: true)
}
Expand Down
Loading
Loading