Skip to content
2 changes: 1 addition & 1 deletion Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14710,7 +14710,7 @@ private extension NSWindow {
// process it. Cmd-based shortcuts should still work during composition since
// Cmd is never part of IME input sequences.
if ghosttyView.hasMarkedText(), !event.modifierFlags.intersection(.deviceIndependentFlagsMask).contains(.command) {
return cmux_performKeyEquivalent(with: event)
return false
}

let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
Expand Down
121 changes: 117 additions & 4 deletions Sources/GhosttyNSView+IMEComposition.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import AppKit
import Carbon.HIToolbox

extension GhosttyNSView {
// Issue #4093 is specifically Korean 2-Set. Other Korean layouts should be
// validated separately before this allow-list is broadened.
private static let korean2SetInputSourceIDs: Set<String> = [
"com.apple.inputmethod.Korean.2SetKorean",
]
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/// Clamps AppKit's marked-text selection into the active preedit buffer.
func normalizedMarkedSelectionRange(_ range: NSRange, markedLength: Int) -> NSRange {
guard markedLength > 0 else {
Expand Down Expand Up @@ -33,7 +40,9 @@ extension GhosttyNSView {
func shouldSuppressGhosttyKeyForwardingAfterIMEHandling(
before: (text: String, selection: NSRange),
after: (text: String, selection: NSRange),
accumulatedText: [String]
accumulatedText: [String],
event: NSEvent? = nil,
inputSourceId: String? = nil
) -> Bool {
guard accumulatedText.isEmpty else { return false }

Expand All @@ -45,7 +54,107 @@ extension GhosttyNSView {
return true
}

return before.selection != after.selection
if before.selection != after.selection {
return !shouldForwardKoreanMarkedSelectionArrowToTerminal(
event: event,
inputSourceId: inputSourceId
)
}

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

private func shouldForwardKoreanMarkedSelectionArrowToTerminal(
event: NSEvent?,
inputSourceId: String?
) -> Bool {
guard let event else { return false }
guard isKorean2SetInputSource(inputSourceId) else { return false }
guard hasOnlyPlainTextInputModifiers(event) else { return false }

switch Int(event.keyCode) {
case kVK_LeftArrow, kVK_RightArrow:
return true
default:
return false
}
}

private func isKorean2SetInputSource(_ inputSourceId: String?) -> Bool {
guard let inputSourceId else { return false }
return Self.korean2SetInputSourceIDs.contains(inputSourceId)
}

private func isInputMethodSource(_ inputSourceId: String?) -> Bool {
guard let inputSourceId else { return false }
return inputSourceId.range(
of: ".inputmethod.",
options: .caseInsensitive,
locale: Locale(identifier: "en_US_POSIX")
) != nil
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

private func isBopomofoInputSource(_ inputSourceId: String?) -> Bool {
guard let inputSourceId else { return false }
let comparisonLocale = Locale(identifier: "en_US_POSIX")
return inputSourceId.range(of: "Zhuyin", options: .caseInsensitive, locale: comparisonLocale) != nil
|| inputSourceId.range(of: "Bopomofo", options: .caseInsensitive, locale: comparisonLocale) != nil
}
Comment thread
austinywang marked this conversation as resolved.
Comment thread
austinywang marked this conversation as resolved.
Comment thread
austinywang marked this conversation as resolved.

private func hasOnlyPlainTextInputModifiers(_ event: NSEvent) -> Bool {
let flags = event.modifierFlags
.intersection(.deviceIndependentFlagsMask)
.subtracting([.numericPad, .function, .capsLock])
return flags.isEmpty
}

/// Returns true for active-composition command keys that belong to AppKit's
/// text input manager even when marked text itself does not change.
private func shouldKeepIMECompositionCommandInsideTextInput(_ event: NSEvent) -> Bool {
guard hasOnlyTextInputCommandModifiers(event) 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
}
}

private func hasOnlyTextInputCommandModifiers(_ event: NSEvent) -> Bool {
let flags = event.modifierFlags
.intersection(.deviceIndependentFlagsMask)
.subtracting([.numericPad, .function, .capsLock])
return flags.isEmpty || flags == [.shift]
}

func shouldBufferBopomofoInsertedPreedit(_ text: String, inputSourceId: String? = nil) -> Bool {
guard !text.isEmpty else { return false }
guard isBopomofoInputSource(inputSourceId ?? KeyboardLayout.id) else { return false }
return text.unicodeScalars.allSatisfy(isBopomofoPreeditScalar)
}

private func isBopomofoPreeditScalar(_ scalar: UnicodeScalar) -> Bool {
switch scalar.value {
case 0x3100...0x312F, 0x31A0...0x31BF:
return true
case 0x02C7, 0x02C9, 0x02CA, 0x02CB, 0x02D9:
return true
Comment thread
cursor[bot] marked this conversation as resolved.
default:
return false
}
}

#if DEBUG
Expand All @@ -54,12 +163,16 @@ extension GhosttyNSView {
markedSelectionBefore: NSRange,
markedTextAfter: String,
markedSelectionAfter: NSRange,
accumulatedText: [String]
accumulatedText: [String],
event: NSEvent? = nil,
inputSourceId: String? = nil
) -> Bool {
shouldSuppressGhosttyKeyForwardingAfterIMEHandling(
before: (markedTextBefore, markedSelectionBefore),
after: (markedTextAfter, markedSelectionAfter),
accumulatedText: accumulatedText
accumulatedText: accumulatedText,
event: event,
inputSourceId: inputSourceId
)
}
#endif
Expand Down
53 changes: 44 additions & 9 deletions Sources/GhosttyTerminalView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7709,14 +7709,9 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
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).
// We only check when not already in a preedit state.
let keyboardIdBefore: String? = if (!markedTextBefore) {
KeyboardLayout.id
} else {
nil
}
// Capture the keyboard layout ID before interpretation so the IME
// forwarding decision uses the source that saw this key.
let keyboardIdBefore = KeyboardLayout.id

// Let the input system handle the event (for IME, dead keys, etc.)
#if DEBUG
Expand Down Expand Up @@ -7758,12 +7753,20 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {

let accumulatedText = keyTextAccumulator ?? []
if shouldSuppressGhosttyKeyForwardingAfterIMEHandling(
before: markedStateBefore, after: (markedText.string, markedSelectedRange), accumulatedText: accumulatedText
before: markedStateBefore,
after: (markedText.string, markedSelectedRange),
accumulatedText: accumulatedText,
event: event,
inputSourceId: keyboardIdBefore
Comment thread
coderabbitai[bot] marked this conversation as resolved.
) {
imeConsumedKeyUps.insert(event.keyCode)
return
}

// A forwarded keyDown owns its keyUp. Clear any stale IME suppression
// entry left by an earlier suppressed repeat for the same physical key.
imeConsumedKeyUps.remove(event.keyCode)

// Build the key event
var keyEvent = ghostty_input_key_s()
keyEvent.action = action
Expand Down Expand Up @@ -13204,6 +13207,12 @@ extension GhosttyNSView: NSTextInputClient {
return
}

if keyTextAccumulator != nil,
shouldBufferBopomofoInsertedPreedit(chars) {
insertBopomofoPreeditText(chars, replacementRange: replacementRange)
return
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

// Clear marked text since we're inserting
unmarkText()

Expand Down Expand Up @@ -13254,6 +13263,32 @@ extension GhosttyNSView: NSTextInputClient {
preserveLiteralEscape: !isExternalCommittedText
)
}

private func insertBopomofoPreeditText(_ chars: String, replacementRange: NSRange) {
let effectiveRange = effectiveBopomofoPreeditReplacementRange(replacementRange)
if let range = Range(effectiveRange, in: markedText.string) {
let insertionLocation = effectiveRange.location + (chars as NSString).length
let next = markedText.string.replacingCharacters(in: range, with: chars)
markedText = NSMutableAttributedString(string: next)
markedSelectedRange = normalizedMarkedSelectionRange(
NSRange(location: insertionLocation, length: 0),
markedLength: markedText.length
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return
}

markedText.append(NSAttributedString(string: chars))
markedSelectedRange = normalizedMarkedSelectionRange(
NSRange(location: markedText.length, length: 0),
markedLength: markedText.length
)
}

private func effectiveBopomofoPreeditReplacementRange(_ replacementRange: NSRange) -> NSRange {
guard replacementRange.location == NSNotFound else { return replacementRange }
guard markedText.length > 0 else { return NSRange(location: 0, length: 0) }
return normalizedMarkedSelectionRange(markedSelectedRange, markedLength: markedText.length)
}
}

// MARK: - SwiftUI Wrapper
Expand Down
Loading
Loading