Skip to content

Commit 16560cf

Browse files
committed
appkit: route IME key events through NSTextInputContext::handleEvent.
1 parent 1785b7b commit 16560cf

File tree

2 files changed

+220
-21
lines changed

2 files changed

+220
-21
lines changed

winit-appkit/src/view.rs

Lines changed: 217 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
use std::cell::{Cell, RefCell};
33
use std::collections::{HashMap, VecDeque};
44
use std::ptr;
5-
use std::rc::Rc;
5+
use std::rc::{Rc, Weak};
66

77
use dpi::{LogicalPosition, LogicalSize};
88
use objc2::rc::Retained;
@@ -17,6 +17,7 @@ use objc2_foundation::{
1717
NSArray, NSAttributedString, NSAttributedStringKey, NSCopying, NSMutableAttributedString,
1818
NSNotFound, NSObject, NSPoint, NSRange, NSRect, NSSize, NSString, NSUInteger,
1919
};
20+
use smol_str::SmolStr;
2021
use winit_core::event::{
2122
DeviceEvent, ElementState, Ime, KeyEvent, Modifiers, MouseButton, MouseScrollDelta,
2223
PointerKind, PointerSource, TouchPhase, WindowEvent,
@@ -45,6 +46,40 @@ impl Default for CursorState {
4546
}
4647
}
4748

49+
/// A per-queued raw-character gate used to drop stale raw KeyboardInput events.
50+
/// Each queued raw character captures an Rc<EventFilterToken> inside the runloop-dispatched
51+
/// closure; when an IME Commit for the same key event arrives, we flip `deliver = false` so the
52+
/// closure becomes a no-op.
53+
#[derive(Debug)]
54+
struct EventFilterToken {
55+
/// Whether this queued raw KeyboardInput should be delivered to the app.
56+
/// Set to `false` by `drop_conflicting_raw_characters` when an IME commit supersedes it.
57+
deliver: Cell<bool>,
58+
}
59+
60+
impl EventFilterToken {
61+
fn new() -> Self {
62+
Self { deliver: Cell::new(true) }
63+
}
64+
}
65+
66+
/// Bookkeeping for a raw-character KeyboardInput that was scheduled for dispatch.
67+
/// - `serial`: monotonically increasing key-event serial so we can match it against an IME-handled
68+
/// NSEvent in the same runloop tick.
69+
/// - `text`: the raw character payload (e.g. ".") so we can compare with a subsequent IME commit
70+
/// (e.g. "。").
71+
/// - `token`: Weak reference allowing the IME path to cancel delivery without keeping the event
72+
/// alive.
73+
#[derive(Debug)]
74+
struct PendingRawCharacter {
75+
/// Serial of the key event that produced this raw character.
76+
serial: u64,
77+
/// Raw character text captured from `KeyEvent.text`.
78+
text: SmolStr,
79+
/// Weak handle to the gate used by the dispatch closure.
80+
token: Weak<EventFilterToken>,
81+
}
82+
4883
#[derive(Debug, Eq, PartialEq, Clone, Copy, Default)]
4984
enum ImeState {
5085
#[default]
@@ -135,6 +170,14 @@ pub struct ViewState {
135170
marked_text: RefCell<Retained<NSMutableAttributedString>>,
136171
accepts_first_mouse: bool,
137172

173+
/// Monotonic counter incremented per keyDown/keyUp; groups raw text and IME handling within
174+
/// the same runloop turn.
175+
current_event_serial: Cell<u64>,
176+
/// Serial of the last `NSEvent` that was handed to `NSTextInputContext::handleEvent`.
177+
last_handled_event_serial: Cell<u64>,
178+
/// Raw-character events queued for delivery; used to drop them if an IME Commit disagrees.
179+
pending_raw_characters: RefCell<Vec<PendingRawCharacter>>,
180+
138181
/// The state of the `Option` as `Alt`.
139182
option_as_alt: Cell<OptionAsAlt>,
140183
}
@@ -395,6 +438,9 @@ define_class!(
395438

396439
// Commit only if we have marked text.
397440
if self.hasMarkedText() && self.is_ime_enabled() && !is_control {
441+
// Safety net: if a raw ReceivedCharacter from this tick exists and differs (e.g.
442+
// '.' vs '。'), drop it.
443+
self.drop_conflicting_raw_characters(&string);
398444
self.queue_event(WindowEvent::Ime(Ime::Preedit(String::new(), None)));
399445
self.queue_event(WindowEvent::Ime(Ime::Commit(string)));
400446
self.ivars().ime_state.set(ImeState::Committed);
@@ -445,6 +491,8 @@ define_class!(
445491
#[unsafe(method(keyDown:))]
446492
fn key_down(&self, event: &NSEvent) {
447493
trace_scope!("keyDown:");
494+
self.begin_key_event();
495+
let mut ime_consumed_event = false;
448496
{
449497
let mut prev_input_source = self.ivars().input_source.borrow_mut();
450498
let current_input_source = self.current_input_source();
@@ -468,8 +516,15 @@ define_class!(
468516
// `doCommandBySelector`. (doCommandBySelector means that the keyboard input
469517
// is not handled by IME and should be handled by the application)
470518
if self.ivars().ime_capabilities.get().is_some() {
471-
let events_for_nsview = NSArray::from_slice(&[&*event]);
472-
self.interpretKeyEvents(&events_for_nsview);
519+
// Route the event through `NSTextInputContext` first; this calls into
520+
// `IMKInputController`.
521+
ime_consumed_event = self.handle_text_input_event(&event);
522+
if !ime_consumed_event {
523+
// If the IME didn't take it, fall back to Cocoa's `interpretKeyEvents` to
524+
// produce `insertText:`/`doCommandBySelector:`.
525+
let events_for_nsview = NSArray::from_slice(&[&*event]);
526+
self.interpretKeyEvents(&events_for_nsview);
527+
}
473528

474529
// If the text was committed we must treat the next keyboard event as IME related.
475530
if self.ivars().ime_state.get() == ImeState::Committed {
@@ -491,30 +546,36 @@ define_class!(
491546
_ => old_ime_state != self.ivars().ime_state.get(),
492547
};
493548

494-
if !had_ime_input || self.ivars().forward_key_to_app.get() {
549+
// Only send a raw KeyboardInput if IME did not produce preedit/commit and did not
550+
// consume the event.
551+
if self.ivars().forward_key_to_app.get() || (!had_ime_input && !ime_consumed_event) {
495552
let key_event = create_key_event(&event, true, event.isARepeat());
496-
self.queue_event(WindowEvent::KeyboardInput {
497-
device_id: None,
498-
event: key_event,
499-
is_synthetic: false,
500-
});
553+
self.queue_keyboard_input_event(key_event, false);
501554
}
502555
}
503556

504557
#[unsafe(method(keyUp:))]
505558
fn key_up(&self, event: &NSEvent) {
506559
trace_scope!("keyUp:");
560+
self.begin_key_event();
561+
let mut ime_consumed_event = false;
507562

508563
let event = replace_event(event, self.option_as_alt());
509564
self.update_modifiers(&event, false);
510565

566+
if self.ivars().ime_capabilities.get().is_some() {
567+
// Let IME observe keyUp too; some IMEs compare keyDown/keyUp (e.g. Shift single-tap
568+
// detection).
569+
ime_consumed_event = self.handle_text_input_event(&event);
570+
}
571+
511572
// We want to send keyboard input when we are currently in the ground state.
512-
if matches!(self.ivars().ime_state.get(), ImeState::Ground | ImeState::Disabled) {
513-
self.queue_event(WindowEvent::KeyboardInput {
514-
device_id: None,
515-
event: create_key_event(&event, false, false),
516-
is_synthetic: false,
517-
});
573+
// If IME is idle/disabled and didn't consume keyUp, forward it to the app.
574+
if matches!(self.ivars().ime_state.get(), ImeState::Ground | ImeState::Disabled)
575+
&& !ime_consumed_event
576+
{
577+
let key_event = create_key_event(&event, false, false);
578+
self.queue_keyboard_input_event(key_event, false);
518579
}
519580
}
520581

@@ -561,11 +622,7 @@ define_class!(
561622
self.update_modifiers(&event, false);
562623
let event = create_key_event(&event, true, event.isARepeat());
563624

564-
self.queue_event(WindowEvent::KeyboardInput {
565-
device_id: None,
566-
event,
567-
is_synthetic: false,
568-
});
625+
self.queue_keyboard_input_event(event, false);
569626
}
570627

571628
// In the past (?), `mouseMoved:` events were not generated when the
@@ -812,6 +869,9 @@ impl WinitView {
812869
forward_key_to_app: Default::default(),
813870
marked_text: Default::default(),
814871
accepts_first_mouse,
872+
current_event_serial: Cell::new(0),
873+
last_handled_event_serial: Cell::new(0),
874+
pending_raw_characters: RefCell::new(Vec::new()),
815875
option_as_alt: Cell::new(option_as_alt),
816876
});
817877
let this: Retained<Self> = unsafe { msg_send![super(this), init] };
@@ -832,6 +892,135 @@ impl WinitView {
832892
});
833893
}
834894

895+
/// Queue a KeyboardInput for delivery, with an option to drop it later if an IME commit
896+
/// supersedes it.
897+
///
898+
/// Rationale: when an IME is active, macOS can generate both a raw character (e.g. '.') and an
899+
/// IME commit (e.g. '。') for the same physical key press. We tentatively enqueue the raw
900+
/// event, but guard it with a token so `drop_conflicting_raw_characters` can cancel
901+
/// delivery in the same runloop turn.
902+
fn queue_keyboard_input_event(&self, key_event: KeyEvent, is_synthetic: bool) {
903+
// Trim any stale entries whose tokens have already been dropped by dispatched closures.
904+
self.cleanup_pending_raw_characters();
905+
906+
// Associate this event with the current key event serial.
907+
let serial = self.ivars().current_event_serial.get();
908+
// Only character-bearing events participate in the safety net; non-text key events bypass
909+
// the filter.
910+
let token = key_event.text.as_ref().map(|text| {
911+
let token = Rc::new(EventFilterToken::new());
912+
self.ivars().pending_raw_characters.borrow_mut().push(PendingRawCharacter {
913+
serial,
914+
text: text.clone(),
915+
token: Rc::downgrade(&token),
916+
});
917+
token
918+
});
919+
920+
let window_event =
921+
WindowEvent::KeyboardInput { device_id: None, event: key_event, is_synthetic };
922+
let window_id = window_id(&self.window());
923+
924+
if let Some(token) = token {
925+
// Defer dispatch and drop the event if IME said to supersede it.
926+
let event_to_dispatch = window_event.clone();
927+
self.ivars().app_state.maybe_queue_with_handler(move |app, event_loop| {
928+
// The IME path may have flipped `deliver` to false.
929+
if !token.deliver.get() {
930+
return;
931+
}
932+
app.window_event(event_loop, window_id, event_to_dispatch.clone());
933+
});
934+
} else {
935+
// No text payload: dispatch as-is.
936+
let event_to_dispatch = window_event;
937+
self.ivars().app_state.maybe_queue_with_handler(move |app, event_loop| {
938+
app.window_event(event_loop, window_id, event_to_dispatch.clone());
939+
});
940+
}
941+
}
942+
943+
/// Start a new serial for the current keyDown/keyUp pair.
944+
///
945+
/// We use this to correlate raw-character events with IME handling within the same runloop
946+
/// tick.
947+
fn begin_key_event(&self) {
948+
let next = self.ivars().current_event_serial.get().wrapping_add(1);
949+
self.ivars().current_event_serial.set(next);
950+
}
951+
952+
/// Let IME observe the native NSEvent via `NSTextInputContext::handleEvent` and record the
953+
/// serial.
954+
///
955+
/// Returns true when the IME consumed the event; in that case we should suppress raw character
956+
/// delivery.
957+
fn handle_text_input_event(&self, event: &NSEvent) -> bool {
958+
let Some(input_context) = self.inputContext() else {
959+
return false;
960+
};
961+
962+
// Record which serial was seen by the IME so `drop_conflicting_raw_characters` knows what
963+
// to cancel.
964+
let serial = self.ivars().current_event_serial.get();
965+
self.ivars().last_handled_event_serial.set(serial);
966+
967+
input_context.handleEvent(event)
968+
}
969+
970+
/// Drop the most relevant queued raw-character event if its text disagrees with the IME commit.
971+
///
972+
/// Strategy:
973+
/// - Prefer the raw character queued in the same serial (same key event) as the IME-handled
974+
/// NSEvent.
975+
/// - If none is found (ordering nuances), fall back to the newest still-alive raw character.
976+
/// - If its text != `commit`, flip its token to prevent delivery.
977+
fn drop_conflicting_raw_characters(&self, commit: &str) {
978+
let serial = self.ivars().last_handled_event_serial.get();
979+
let mut pending = self.ivars().pending_raw_characters.borrow_mut();
980+
981+
let mut target: Option<(Weak<EventFilterToken>, SmolStr)> = None;
982+
983+
// Search from newest to oldest to find a match in the same serial.
984+
for entry in pending.iter().rev() {
985+
if entry.token.upgrade().is_none() {
986+
continue;
987+
}
988+
989+
if entry.serial == serial {
990+
target = Some((entry.token.clone(), entry.text.clone()));
991+
break;
992+
}
993+
}
994+
995+
// If we didn't find one in the same serial, take the newest alive entry.
996+
if target.is_none() {
997+
if let Some(entry) = pending.iter().rev().find(|entry| entry.token.upgrade().is_some())
998+
{
999+
target = Some((entry.token.clone(), entry.text.clone()));
1000+
}
1001+
}
1002+
1003+
if let Some((token, text)) = target {
1004+
if text.as_str() != commit {
1005+
if let Some(token) = token.upgrade() {
1006+
// Cancel delivery of the stale raw character.
1007+
token.deliver.set(false);
1008+
}
1009+
}
1010+
}
1011+
1012+
// GC: keep only entries whose tokens are still alive.
1013+
pending.retain(|entry| entry.token.upgrade().is_some());
1014+
}
1015+
1016+
/// Remove bookkeeping entries whose dispatch tokens have already been dropped.
1017+
fn cleanup_pending_raw_characters(&self) {
1018+
self.ivars()
1019+
.pending_raw_characters
1020+
.borrow_mut()
1021+
.retain(|entry| entry.token.upgrade().is_some());
1022+
}
1023+
8351024
fn scale_factor(&self) -> f64 {
8361025
self.window().backingScaleFactor() as f64
8371026
}
@@ -1030,7 +1219,14 @@ impl WinitView {
10301219
drop(phys_mod_state);
10311220

10321221
for event in events {
1033-
self.queue_event(event);
1222+
match event {
1223+
// Route synthesized modifier presses through the same filtering path to
1224+
// honor IME safety net.
1225+
WindowEvent::KeyboardInput { event: key_event, is_synthetic, .. } => {
1226+
self.queue_keyboard_input_event(key_event, is_synthetic);
1227+
},
1228+
other => self.queue_event(other),
1229+
}
10341230
}
10351231
}
10361232
}

winit/src/changelog/unreleased.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,9 @@ changelog entry.
276276
- On macOS, fixed redundant `SurfaceResized` event at window creation.
277277
- On macOS, don't panic on monitors with unknown bit-depths.
278278
- On macOS, fixed crash when closing the window on macOS 26+.
279+
- On macOS, route IME key events through `NSTextInputContext::handleEvent` to avoid duplicate
280+
punctuation and dead key commits produced by third-party input methods, aligning character
281+
delivery with Cocoa.
279282
- On macOS, make AppKit monitor handle tests derive their reference display IDs from
280283
`CGMainDisplayID`, preventing failures on systems where the primary monitor is not display `1`.
281284
- On Windows, account for mouse wheel lines per scroll setting for `WindowEvent::MouseWheel`.

0 commit comments

Comments
 (0)