diff --git a/crates/warpui/src/platform/mac/objc/host_view.m b/crates/warpui/src/platform/mac/objc/host_view.m index 4d84c3948..95f8b0861 100644 --- a/crates/warpui/src/platform/mac/objc/host_view.m +++ b/crates/warpui/src/platform/mac/objc/host_view.m @@ -55,6 +55,17 @@ @implementation WarpHostView { // Whether we're in the middle of a call to interpretKeyEvents. BOOL interpretingKeyEvents; + + // The selectedRange of the most recent setMarkedText: call, kept so we + // can re-dispatch the marked text after a commit (see keyDownImpl:). + NSRange lastMarkedSelectedRange; + + // True if setMarkedText: was actually called during the current + // interpretKeyEvents:. Used to distinguish a freshly-started composition + // from stale markedText that survived a commit-only flow — insertText: + // deliberately skips unmarkText while interpretingKeyEvents is true, so + // hasMarkedText alone cannot tell the two apart. + BOOL setMarkedTextDuringInterpret; } - (BOOL)acceptsFirstResponder { @@ -153,6 +164,7 @@ - (void)keyDown:(NSEvent *)event { - (BOOL)keyDownImpl:(NSEvent *)event { BOOL wasComposing = [self hasMarkedText]; [textToInsert setString:@""]; + setMarkedTextDuringInterpret = NO; // Interpret the key events here so we could check whether user is composing // text within the IME and pass the state down to the KeyDown events. @@ -180,9 +192,43 @@ - (BOOL)keyDownImpl:(NSEvent *)event { } // Dispatch TypedCharacter event after KeyDown has been dispatched. - if ([textToInsert length] > 0 && !handled) { + // If the key event committed previously-composed marked text (wasComposing), + // dispatch the committed text even when `handled` is true — it represents + // the user's already-typed input (e.g. the last Korean syllable being + // committed by an Arrow/Enter key), not text produced by the keybinding. + if ([textToInsert length] > 0 && (!handled || wasComposing)) { + // Korean (and other CJK) IMEs can both commit the previous syllable + // AND start a new composition in the same keyDown — e.g. typing 'ㅏ' + // after '간' produces commit '가' + new marked '나'. By the time we + // get here, setMarkedText: has already placed the new marked text as + // a selection in the input field's buffer. Inserting the committed + // text would then overwrite that selection (losing the next + // character). Workaround: temporarily clear the marked text, + // dispatch the commit, then re-apply the marked text. + // + // We can only treat hasMarkedText as a "freshly-started composition" + // when setMarkedText: was actually called during this keyDown. + // insertText: leaves stale markedText untouched while + // interpretingKeyEvents, so commit-only flows (e.g. dead-key + char) + // would otherwise see stale marked text and incorrectly re-emit it. + BOOL hasNewMarked = setMarkedTextDuringInterpret && [self hasMarkedText]; + if (hasNewMarked) { + warp_marked_text_cleared(self); + warp_update_ime_state(self, NO); + } warp_handle_insert_text(self, (NSString *)textToInsert); - [self unmarkText]; + if (hasNewMarked) { + warp_marked_text_updated(self, markedText.string, lastMarkedSelectedRange); + warp_update_ime_state(self, YES); + } else { + // Commit-only flow (no new composition started). End IME state + // explicitly — insertText: deliberately skipped unmarkText while + // interpretingKeyEvents was true, so the stale markedText object + // and ime_active flag would otherwise survive into the next + // keyDown and break downstream input (e.g. Backspace would be + // treated as composing-state input and not delete the character). + [self unmarkText]; + } } return handled; @@ -471,6 +517,11 @@ - (void)setMarkedText:(id)string else markedText = [[NSMutableAttributedString alloc] initWithString:string]; + lastMarkedSelectedRange = selectedRange; + if (interpretingKeyEvents) { + setMarkedTextDuringInterpret = YES; + } + if (self.readyForWarp) { warp_marked_text_updated(self, markedText.string, selectedRange); if ([markedText length] > 0) {