22use std:: cell:: { Cell , RefCell } ;
33use std:: collections:: { HashMap , VecDeque } ;
44use std:: ptr;
5- use std:: rc:: Rc ;
5+ use std:: rc:: { Rc , Weak } ;
66
77use dpi:: { LogicalPosition , LogicalSize } ;
88use 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 ;
2021use 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 ) ]
4984enum 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 }
0 commit comments