Skip to content

Commit 46e93fe

Browse files
committed
iOS: Implement UITextInput so multi-stage IMEs (CJK) work
The `WinitView` on iOS previously adopted only `UIKeyInput` (plus the trait-only `UITextInputTraits`). Per Apple's docs that's enough for plain ASCII keypresses, but multi-stage input methods — Chinese pinyin, Japanese kana, Korean hangul, Vietnamese, etc. — require the view to also adopt the `UITextInput` protocol: https://developer.apple.com/documentation/uikit/uitextinput > Multi-stage input methods, such as Chinese, Japanese, Korean, and > Thai are excluded from classes that adopt only the UIKeyInput > protocol, but if a class also adopts the UITextInput protocol, > those input methods are then available. Without `UITextInput`, the iOS soft keyboard silently drops every keypress while pinyin / kana / hangul is selected, so users on the simulator or device see a completely unresponsive text input. This affects every downstream toolkit using winit on iOS; we noticed it via slint-ui/slint#4698. This is the master-branch port of the same fix applied to v0.30.x in #4592 — same logic, adapted to the `winit-uikit` crate split, `define_class!`, `#[unsafe(method_id(...))]`, and the `winit-core` event type layout. What this PR does: * Adds a new `winit-uikit/src/ime.rs` module with two small `NSObject` subclasses `WinitTextPosition` / `WinitTextRange`, each carrying a character offset (positions are opaque to UIKit, so a simple `i64` wrapper is enough), and an `ImeState` holding the current marked text + selection. * Adopts `UITextInput` on `WinitView`: - `setMarkedText:selectedRange:` → `WindowEvent::Ime(Ime::Preedit(...))` - `unmarkText` → `Ime::Commit(prev) + Preedit("", None)` (so that e.g. typing `n` and then tapping `123` keeps `n` in the field instead of silently dropping it, matching how UIKit's own `UITextField` behaves) - `replaceRange:withText:` (the typical pinyin / kana commit path) → `WindowEvent::Ime(Ime::Commit(...))` - The other 20+ required methods are minimal but correct stubs that model the document as just the current marked string — that's sufficient because committed text lives on the application side and only flows back out via the existing `Ime::Commit` path. * Implements `baseWritingDirectionForPosition:inDirection:` and `setBaseWritingDirection:forRange:` as plain selectors. The protocol marks them required at runtime even though the Rust binding for `NSWritingDirection` is gated on the `NSText` feature, so they're declared outside the `UITextInput` impl block to avoid pulling that feature in. Tested in the iPhone 17 Pro simulator with iOS 26.3 (system zh_Hans-Pinyin keyboard): * Without the patch: tapping any letter on the pinyin keyboard has no visible effect; candidate bar stays empty. * With the patch: tapping letters opens the candidate bar populated with real hanzi (e.g. tapping `n` yields 你 / 能 / 年 …), the preedit text shows up inline in the focused text input with the usual iOS highlight, and selecting a candidate (or pressing the spacebar to commit the first one) inserts the chosen hanzi into the text input. * ASCII input via `UIKeyInput.insertText:` and backspace via `deleteBackward` continue to work unchanged.
1 parent 81b2729 commit 46e93fe

3 files changed

Lines changed: 669 additions & 15 deletions

File tree

winit-uikit/src/ime.rs

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
//! Helpers for the iOS `UITextInput` protocol implementation.
2+
//!
3+
//! UIKit requires that anything which can be the target of a multi-stage
4+
//! (CJK) input method adopt `UITextInput`, which talks in terms of opaque
5+
//! `UITextPosition` / `UITextRange` objects. iOS will call back into our view
6+
//! with these objects, so we need concrete subclasses we can downcast and read
7+
//! offsets from.
8+
//!
9+
//! We treat the "document" that `UITextInput` sees as just the current marked
10+
//! (preedit) text — outside of an active composition we report an empty
11+
//! document. The committed text lives on the application side; we only forward
12+
//! `Ime::Preedit` / `Ime::Commit` events.
13+
14+
use std::cell::Cell;
15+
16+
use objc2::rc::Retained;
17+
use objc2::runtime::NSObjectProtocol;
18+
use objc2::{DefinedClass, MainThreadMarker, MainThreadOnly, define_class, msg_send};
19+
use objc2_foundation::NSObject;
20+
use objc2_ui_kit::{UITextPosition, UITextRange};
21+
22+
/// State that the view tracks for an active IME composition.
23+
///
24+
/// `marked_text` is the current preedit string. `selected_range` is the
25+
/// selection inside that preedit string (start/end in UTF-8 byte offsets,
26+
/// matching what `Ime::Preedit` expects).
27+
#[derive(Default)]
28+
pub(crate) struct ImeState {
29+
pub(crate) marked_text: String,
30+
pub(crate) selected_range: (usize, usize),
31+
}
32+
33+
impl ImeState {
34+
pub(crate) fn is_marked(&self) -> bool {
35+
!self.marked_text.is_empty()
36+
}
37+
38+
pub(crate) fn marked_len_chars(&self) -> usize {
39+
self.marked_text.chars().count()
40+
}
41+
}
42+
43+
pub(crate) struct WinitTextPositionState {
44+
offset: Cell<i64>,
45+
}
46+
47+
define_class!(
48+
/// `UITextPosition` subclass carrying a character offset into the marked text.
49+
#[unsafe(super(UITextPosition, NSObject))]
50+
#[thread_kind = MainThreadOnly]
51+
#[name = "WinitTextPosition"]
52+
#[ivars = WinitTextPositionState]
53+
pub(crate) struct WinitTextPosition;
54+
);
55+
56+
impl WinitTextPosition {
57+
pub(crate) fn new(mtm: MainThreadMarker, offset: i64) -> Retained<Self> {
58+
let this = mtm.alloc().set_ivars(WinitTextPositionState { offset: Cell::new(offset) });
59+
unsafe { msg_send![super(this), init] }
60+
}
61+
62+
pub(crate) fn offset(&self) -> i64 {
63+
self.ivars().offset.get()
64+
}
65+
}
66+
67+
pub(crate) struct WinitTextRangeState {
68+
start: Cell<i64>,
69+
end: Cell<i64>,
70+
}
71+
72+
define_class!(
73+
/// `UITextRange` subclass holding a `[start, end)` interval of character
74+
/// offsets into the marked text.
75+
#[unsafe(super(UITextRange, NSObject))]
76+
#[thread_kind = MainThreadOnly]
77+
#[name = "WinitTextRange"]
78+
#[ivars = WinitTextRangeState]
79+
pub(crate) struct WinitTextRange;
80+
81+
impl WinitTextRange {
82+
#[unsafe(method(isEmpty))]
83+
fn is_empty(&self) -> bool {
84+
self.ivars().start.get() == self.ivars().end.get()
85+
}
86+
87+
#[unsafe(method_id(start))]
88+
fn start_position(&self) -> Retained<UITextPosition> {
89+
let mtm = MainThreadMarker::new().expect("WinitTextRange used off the main thread");
90+
let p = WinitTextPosition::new(mtm, self.ivars().start.get());
91+
Retained::into_super(p)
92+
}
93+
94+
#[unsafe(method_id(end))]
95+
fn end_position(&self) -> Retained<UITextPosition> {
96+
let mtm = MainThreadMarker::new().expect("WinitTextRange used off the main thread");
97+
let p = WinitTextPosition::new(mtm, self.ivars().end.get());
98+
Retained::into_super(p)
99+
}
100+
}
101+
);
102+
103+
impl WinitTextRange {
104+
pub(crate) fn new(mtm: MainThreadMarker, start: i64, end: i64) -> Retained<Self> {
105+
let this = mtm
106+
.alloc()
107+
.set_ivars(WinitTextRangeState { start: Cell::new(start), end: Cell::new(end) });
108+
unsafe { msg_send![super(this), init] }
109+
}
110+
111+
pub(crate) fn start_offset(&self) -> i64 {
112+
self.ivars().start.get()
113+
}
114+
115+
pub(crate) fn end_offset(&self) -> i64 {
116+
self.ivars().end.get()
117+
}
118+
}
119+
120+
unsafe impl NSObjectProtocol for WinitTextPosition {}
121+
unsafe impl NSObjectProtocol for WinitTextRange {}

winit-uikit/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@
102102

103103
mod app_state;
104104
mod event_loop;
105+
mod ime;
105106
mod monitor;
106107
mod view;
107108
mod view_controller;

0 commit comments

Comments
 (0)