From 5ea1b78d29e6b98c330a7f586a9e3268e51b8081 Mon Sep 17 00:00:00 2001 From: "James Z.M. Gao" Date: Wed, 2 Sep 2026 20:05:50 +0800 Subject: [PATCH] fix(macos): NSTextInputClient IME commit flow Three fixes for NSTextInputClient protocol implementation: 1. validAttributesForMarkedText now returns standard attributes (NSUnderlineStyle, NSMarkedClauseSegment) instead of an empty array. Some IMEs (e.g. LogInputMac3) require non-empty return to enter composition mode. 2. insertText:replacementRange: no longer gates commit on hasMarkedText(). Some IMEs call unmarkText before insertText, which clears the marked text and caused all commits to be silently discarded. 3. Added pending_commit flag to distinguish genuine composition commits from regular typing during IME mode (spaces, English chars), which should go through keyboard input. Fixes #3925. --- winit-appkit/src/view.rs | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/winit-appkit/src/view.rs b/winit-appkit/src/view.rs index 74cd8c2e4..b730f8535 100644 --- a/winit-appkit/src/view.rs +++ b/winit-appkit/src/view.rs @@ -129,6 +129,9 @@ pub struct ViewState { ime_state: Cell, input_source: RefCell, + /// True if this view was in a preedit session that will result in a commit. + pending_commit: Cell, + /// True iff the application wants IME events. /// /// Can be set using `set_ime_allowed` @@ -292,6 +295,7 @@ define_class!( // In case the preedit was cleared, set IME into the Ground state. self.ivars().ime_state.set(ImeState::Ground); } + self.ivars().pending_commit.set(true); let string = string.to_string(); let cursor_range = if string.is_empty() { @@ -335,7 +339,9 @@ define_class!( #[unsafe(method_id(validAttributesForMarkedText))] fn valid_attributes_for_marked_text(&self) -> Retained> { let _entered = trace_span!("validAttributesForMarkedText").entered(); - NSArray::new() + let underline_style = NSString::from_str("NSUnderlineStyle"); + let marked_clause = NSString::from_str("NSMarkedClauseSegment"); + NSArray::from_slice(&[&*underline_style, &*marked_clause]) } #[unsafe(method_id(attributedSubstringForProposedRange:actualRange:))] @@ -389,10 +395,20 @@ define_class!( }; let is_control = string.chars().next().is_some_and(|c| c.is_control()); + let has_marked = self.hasMarkedText(); + let pending_commit = self.ivars().pending_commit.get(); + let ime_enabled = self.is_ime_enabled(); - // Commit only if we have marked text. - if self.hasMarkedText() && self.is_ime_enabled() && !is_control { + // Clear preedit if there is marked text. + if has_marked { self.queue_event(WindowEvent::Ime(Ime::Preedit(String::new(), None))); + } + + // Only commit via IME if there was a real composition session. + // Some IMEs send insertText for all typing (e.g. spaces, English chars) + // which should go through keyboard input instead of paste. + if pending_commit && ime_enabled && !is_control { + self.ivars().pending_commit.set(false); self.queue_event(WindowEvent::Ime(Ime::Commit(string))); self.ivars().ime_state.set(ImeState::Committed); } @@ -816,6 +832,7 @@ impl WinitView { phys_modifiers: Default::default(), ime_state: Default::default(), input_source: Default::default(), + pending_commit: Default::default(), ime_capabilities: Default::default(), forward_key_to_app: Default::default(), marked_text: Default::default(), @@ -971,6 +988,7 @@ impl WinitView { } self.ivars().ime_capabilities.set(Some(capabilities)); *self.ivars().marked_text.borrow_mut() = NSMutableAttributedString::new(); + self.ivars().pending_commit.set(false); } pub(super) fn disable_ime(&self) { // see above @@ -982,6 +1000,7 @@ impl WinitView { // we probably don't need to do this, but again this mirrors the prior behavior of // `set_ime_allowed` *self.ivars().marked_text.borrow_mut() = NSMutableAttributedString::new(); + self.ivars().pending_commit.set(false); } pub(super) fn ime_capabilities(&self) -> Option {