macos: avoid replaying keys that commit preedit (#12547)

Refs #10460
Related: #12518

When an input method commits all or part of marked text during keyDown,
AppKit returns the committed text through insertText. Treat that as text
committed by the input method instead of replaying the original key
event to the terminal.

Previously this path only handled arrow-key commits specially. A
control-key shortcut that commits preedit text could still be encoded as
the original control input after composition, such as ctrl+j becoming
LF.

Send committed preedit text as a text-only event for any key that causes
the commit. Only replay arrow navigation keys that the existing Korean
IME handling expects, and keep plain left-arrow suppressed because
AppKit already leaves the caret in place.

Before:
<img width="375" height="375" alt="before"
src="https://github.com/user-attachments/assets/1073b93f-625a-4881-8f95-67adefe9d3da"
/>

After:
<img width="375" height="375" alt="after"
src="https://github.com/user-attachments/assets/3e4be2a5-4df9-4cdd-bc95-e178ca44c7e7"
/>

AI usage: OpenAI Codex helped investigate, implement, test, and refine
this change. I reviewed and tested the resulting code.
This commit is contained in:
Mitchell Hashimoto
2026-05-02 08:24:42 -07:00
committed by GitHub

View File

@@ -1138,22 +1138,25 @@ extension Ghostty {
// actually delete the prior input characters (prior to the composing).
let composing = markedText.length > 0 || markedTextBefore
// Korean IMEs on macOS may commit preedit text via insertText
// while handling an arrow key. Send that committed text separately
// before replaying arrow movement, except for plain left-arrow
// where AppKit already leaves the caret in place.
// The input method may commit all or part of the preedit text via
// insertText while handling a key that should not itself be
// encoded. Send that committed text separately, then only replay
// keys that should still affect the terminal after committing.
if markedTextBefore,
markedText.length == 0,
let list = keyTextAccumulator,
list.count > 0,
let preeditCommitArrow = preeditCommitArrowKey(translationEvent) {
list.count > 0 {
for text in list {
if Ghostty.SurfaceView.shouldSuppressComposingControlInput(
text,
composing: composing
) {
continue
}
_ = committedPreeditTextAction(action, text: text)
}
let isPlainLeftArrow = preeditCommitArrow == .arrowLeft &&
event.modifierFlags.isDisjoint(with: [.shift, .control, .option, .command])
if !isPlainLeftArrow {
if shouldReplayCommittedPreeditKey(translationEvent) {
_ = keyAction(
action,
event: event,
@@ -1436,13 +1439,17 @@ extension Ghostty {
}
}
private func preeditCommitArrowKey(_ event: NSEvent) -> Ghostty.Input.Key? {
guard let key = Ghostty.Input.Key(keyCode: event.keyCode) else { return nil }
private func shouldReplayCommittedPreeditKey(_ event: NSEvent) -> Bool {
guard let key = Ghostty.Input.Key(keyCode: event.keyCode) else { return false }
switch key {
case .arrowDown, .arrowLeft, .arrowRight, .arrowUp:
return key
case .arrowDown, .arrowRight, .arrowUp:
return true
case .arrowLeft:
// Don't replay plain left-arrow because AppKit already leaves
// the caret in place after Korean IMEs commit preedit text.
return !event.modifierFlags.isDisjoint(with: [.shift, .control, .option, .command])
default:
return nil
return false
}
}