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.
This commit is contained in:
James Z.M. Gao
2026-09-02 20:05:50 +08:00
committed by GitHub
parent 4ddca89067
commit 5ea1b78d29

View File

@@ -129,6 +129,9 @@ pub struct ViewState {
ime_state: Cell<ImeState>, ime_state: Cell<ImeState>,
input_source: RefCell<String>, input_source: RefCell<String>,
/// True if this view was in a preedit session that will result in a commit.
pending_commit: Cell<bool>,
/// True iff the application wants IME events. /// True iff the application wants IME events.
/// ///
/// Can be set using `set_ime_allowed` /// 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. // In case the preedit was cleared, set IME into the Ground state.
self.ivars().ime_state.set(ImeState::Ground); self.ivars().ime_state.set(ImeState::Ground);
} }
self.ivars().pending_commit.set(true);
let string = string.to_string(); let string = string.to_string();
let cursor_range = if string.is_empty() { let cursor_range = if string.is_empty() {
@@ -335,7 +339,9 @@ define_class!(
#[unsafe(method_id(validAttributesForMarkedText))] #[unsafe(method_id(validAttributesForMarkedText))]
fn valid_attributes_for_marked_text(&self) -> Retained<NSArray<NSAttributedStringKey>> { fn valid_attributes_for_marked_text(&self) -> Retained<NSArray<NSAttributedStringKey>> {
let _entered = trace_span!("validAttributesForMarkedText").entered(); 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:))] #[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 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. // Clear preedit if there is marked text.
if self.hasMarkedText() && self.is_ime_enabled() && !is_control { if has_marked {
self.queue_event(WindowEvent::Ime(Ime::Preedit(String::new(), None))); 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.queue_event(WindowEvent::Ime(Ime::Commit(string)));
self.ivars().ime_state.set(ImeState::Committed); self.ivars().ime_state.set(ImeState::Committed);
} }
@@ -816,6 +832,7 @@ impl WinitView {
phys_modifiers: Default::default(), phys_modifiers: Default::default(),
ime_state: Default::default(), ime_state: Default::default(),
input_source: Default::default(), input_source: Default::default(),
pending_commit: Default::default(),
ime_capabilities: Default::default(), ime_capabilities: Default::default(),
forward_key_to_app: Default::default(), forward_key_to_app: Default::default(),
marked_text: Default::default(), marked_text: Default::default(),
@@ -971,6 +988,7 @@ impl WinitView {
} }
self.ivars().ime_capabilities.set(Some(capabilities)); self.ivars().ime_capabilities.set(Some(capabilities));
*self.ivars().marked_text.borrow_mut() = NSMutableAttributedString::new(); *self.ivars().marked_text.borrow_mut() = NSMutableAttributedString::new();
self.ivars().pending_commit.set(false);
} }
pub(super) fn disable_ime(&self) { pub(super) fn disable_ime(&self) {
// see above // see above
@@ -982,6 +1000,7 @@ impl WinitView {
// we probably don't need to do this, but again this mirrors the prior behavior of // we probably don't need to do this, but again this mirrors the prior behavior of
// `set_ime_allowed` // `set_ime_allowed`
*self.ivars().marked_text.borrow_mut() = NSMutableAttributedString::new(); *self.ivars().marked_text.borrow_mut() = NSMutableAttributedString::new();
self.ivars().pending_commit.set(false);
} }
pub(super) fn ime_capabilities(&self) -> Option<ImeCapabilities> { pub(super) fn ime_capabilities(&self) -> Option<ImeCapabilities> {