From 36bb006c23044f5251698d205d82e8c3ef457e60 Mon Sep 17 00:00:00 2001 From: ohah Date: Fri, 1 May 2026 16:16:02 +0900 Subject: [PATCH 1/2] fix(ime): preserve Korean IME last syllable across commits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Korean (and other CJK) IMEs on macOS can both commit a previous syllable AND start a new composition within a single keyDown — e.g. typing 'ㅏ' after '간' produces commit '가' plus new marked '나', and pressing Enter/Arrow on '안녕하세요' commits the trailing '요'. Two bugs in host_view.m caused the last syllable to be lost in these flows: 1. The `!handled` guard around the textToInsert dispatch dropped the committed text whenever the key was handled by the input field (Arrow/Enter etc.), so the last Korean syllable was discarded. Fixed by also dispatching the commit when `wasComposing` was true on entry — the text represents the user's already-typed input, not text produced by the keybinding. 2. By the time we dispatch the commit, setMarkedText: may have already placed the next composition as a selection in the input field's buffer. Inserting the committed text on top of that selection would overwrite it (losing the next character). Worked around by temporarily clearing the marked text, dispatching the commit, and re-applying the marked text afterwards. Fixes #8919 --- .../warpui/src/platform/mac/objc/host_view.m | 30 +++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/crates/warpui/src/platform/mac/objc/host_view.m b/crates/warpui/src/platform/mac/objc/host_view.m index 4d84c3948..aef5967b4 100644 --- a/crates/warpui/src/platform/mac/objc/host_view.m +++ b/crates/warpui/src/platform/mac/objc/host_view.m @@ -55,6 +55,10 @@ @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; } - (BOOL)acceptsFirstResponder { @@ -180,9 +184,29 @@ - (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. + BOOL hasNewMarked = [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); + } } return handled; @@ -471,6 +495,8 @@ - (void)setMarkedText:(id)string else markedText = [[NSMutableAttributedString alloc] initWithString:string]; + lastMarkedSelectedRange = selectedRange; + if (self.readyForWarp) { warp_marked_text_updated(self, markedText.string, selectedRange); if ([markedText length] > 0) { From 5ce847aeb2ddf30e7aa3a65776190ab251e81c5b Mon Sep 17 00:00:00 2001 From: ohah Date: Fri, 1 May 2026 16:44:34 +0900 Subject: [PATCH 2/2] fix(ime): distinguish stale markedText from a fresh composition + restore unmarkText after commit-only flows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address @oz-for-oss feedback on #9713: the previous version of this fix treated any non-empty markedText after interpretKeyEvents: as a freshly-started composition, but insertText: deliberately skips unmarkText while interpretingKeyEvents is true. So a commit-only flow (e.g. dead-key + char) leaves the previous markedText in place, and the fix would re-emit it as if it were new — and, worse, by skipping unmarkText on that branch the host_view's IME state stayed "active", breaking the next keystroke (e.g. Backspace did nothing because the input field was still in composing state). Two changes: 1. Track whether setMarkedText: was actually called during the current interpretKeyEvents: via a new BOOL `setMarkedTextDuringInterpret`, and only take the clear-commit-restore path when it is true. This distinguishes "IME just started a new composition mid-keyDown" from "IME committed but left stale markedText behind". 2. On the commit-only branch (hasNewMarked == false), explicitly call `unmarkText` so the stale markedText object and the IME-active flag are cleared — matching the original code's invariant that committing ends the composition unless a new one was started. --- .../warpui/src/platform/mac/objc/host_view.m | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/crates/warpui/src/platform/mac/objc/host_view.m b/crates/warpui/src/platform/mac/objc/host_view.m index aef5967b4..95f8b0861 100644 --- a/crates/warpui/src/platform/mac/objc/host_view.m +++ b/crates/warpui/src/platform/mac/objc/host_view.m @@ -59,6 +59,13 @@ @implementation WarpHostView { // 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 { @@ -157,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. @@ -197,7 +205,13 @@ - (BOOL)keyDownImpl:(NSEvent *)event { // 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. - BOOL hasNewMarked = [self hasMarkedText]; + // + // 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); @@ -206,6 +220,14 @@ - (BOOL)keyDownImpl:(NSEvent *)event { 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]; } } @@ -496,6 +518,9 @@ - (void)setMarkedText:(id)string markedText = [[NSMutableAttributedString alloc] initWithString:string]; lastMarkedSelectedRange = selectedRange; + if (interpretingKeyEvents) { + setMarkedTextDuringInterpret = YES; + } if (self.readyForWarp) { warp_marked_text_updated(self, markedText.string, selectedRange);