gpui: Fix Vim jj keybinding intercepting j key from IME on macOS (#52192)

kouphasi created

## Fixes
Closes #28174 
Closes  #30393
Closes #36610
Closes #38616

Updates #31819
Updates #21136

## Context

There was an issue where CJK input could not be entered correctly when
certain keys used during IME composition conflicted with custom
keybindings while the CJK IME was enabled.

(In particular, in Vim mode, the `jj` keybinding in INSERT MODE could
not be entered correctly.)

This issue had already been fixed on Windows, but not on macOS (and
judging from the code, it is likely also present on Linux, although this
has not been verified).

In this change, we aligned the behavior with Windows.

Currently, on Windows, when the CJK ( or other ) IME is enabled, IME
input is prioritized.
Following this behavior, we updated macOS to also prioritize IME input.

| Input source | Source type | ASCII-capable | Result | Behavior |
|---|---|---|---|---|
| Japanese Hiragana | `KeyboardInputMode` | `false` | `true` | IME
handles key first |
| Japanese Romaji | `KeyboardInputMode` | `true` | `false` | Keybinding
matching first |
| Korean | `KeyboardInputMode` | `false` | `true` | IME handles key
first |
| Chinese Pinyin | `KeyboardInputMode` | `false` | `true` | IME handles
key first |
| Armenian | `KeyboardLayout` | `false` | `false` | Keybinding matching
first |
| Ukrainian | `KeyboardLayout` | `false` | `false` | Keybinding matching
first |
| English (ABC) | `KeyboardLayout` | `true` | `false` | Keybinding
matching first |


(Since similar approaches are used in tools like Vim and Neovim, we
consider this policy to be appropriate.)



## Videos
### before fix

https://github.com/user-attachments/assets/9b204220-d0de-4819-8dc4-00ba85169ef6

### after fix


https://github.com/user-attachments/assets/045cc808-8d29-42d2-9de5-f903fcb602db


## How to Review

This change modifies the gpui layer rather than Zed itself, so please
verify the behavior at the gpui level, not just within Zed.

## Self-Review Checklist

<!-- Check before requesting review: -->
- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

Release Notes:

- Fixed IME composition on macOS in Vim insert mode with multi-stroke keybindings like `jj`

Change summary

crates/gpui/src/input.rs          |  5 ++
crates/gpui/src/platform.rs       | 19 +++++++++
crates/gpui_macos/src/platform.rs |  4 +
crates/gpui_macos/src/window.rs   | 67 ++++++++++++++++++++++++++++++++
4 files changed, 94 insertions(+), 1 deletion(-)

Detailed changes

crates/gpui/src/input.rs 🔗

@@ -187,4 +187,9 @@ impl<V: EntityInputHandler> InputHandler for ElementInputHandler<V> {
         self.view
             .update(cx, |view, cx| view.accepts_text_input(window, cx))
     }
+
+    fn prefers_ime_for_printable_keys(&mut self, window: &mut Window, cx: &mut App) -> bool {
+        self.view
+            .update(cx, |view, cx| view.accepts_text_input(window, cx))
+    }
 }

crates/gpui/src/platform.rs 🔗

@@ -1242,6 +1242,13 @@ impl PlatformInputHandler {
             .update(|window, cx| self.handler.accepts_text_input(window, cx))
             .unwrap_or(true)
     }
+
+    #[allow(dead_code)]
+    pub fn query_prefers_ime_for_printable_keys(&mut self) -> bool {
+        self.cx
+            .update(|window, cx| self.handler.prefers_ime_for_printable_keys(window, cx))
+            .unwrap_or(false)
+    }
 }
 
 /// A struct representing a selection in a text buffer, in UTF16 characters.
@@ -1355,6 +1362,18 @@ pub trait InputHandler: 'static {
     fn accepts_text_input(&mut self, _window: &mut Window, _cx: &mut App) -> bool {
         true
     }
+
+    /// Returns whether printable keys should be routed to the IME before keybinding
+    /// matching when a non-ASCII input source (e.g. Japanese, Korean, Chinese IME)
+    /// is active. This prevents multi-stroke keybindings like `jj` from intercepting
+    /// keys that the IME should compose.
+    ///
+    /// Defaults to `false`. The editor overrides this based on whether it expects
+    /// character input (e.g. Vim insert mode returns `true`, normal mode returns `false`).
+    /// The terminal keeps the default `false` so that raw keys reach the terminal process.
+    fn prefers_ime_for_printable_keys(&mut self, _window: &mut Window, _cx: &mut App) -> bool {
+        false
+    }
 }
 
 /// The variables that can be configured when creating a new window

crates/gpui_macos/src/platform.rs 🔗

@@ -1389,6 +1389,7 @@ unsafe fn ns_url_to_path(url: id) -> Result<PathBuf> {
 #[link(name = "Carbon", kind = "framework")]
 unsafe extern "C" {
     pub(super) fn TISCopyCurrentKeyboardLayoutInputSource() -> *mut Object;
+    pub(super) fn TISCopyCurrentKeyboardInputSource() -> *mut Object;
     pub(super) fn TISGetInputSourceProperty(
         inputSource: *mut Object,
         propertyKey: *const c_void,
@@ -1410,6 +1411,9 @@ unsafe extern "C" {
     pub(super) static kTISPropertyUnicodeKeyLayoutData: CFStringRef;
     pub(super) static kTISPropertyInputSourceID: CFStringRef;
     pub(super) static kTISPropertyLocalizedName: CFStringRef;
+    pub(super) static kTISPropertyInputSourceIsASCIICapable: CFStringRef;
+    pub(super) static kTISPropertyInputSourceType: CFStringRef;
+    pub(super) static kTISTypeKeyboardInputMode: CFStringRef;
 }
 
 mod security {

crates/gpui_macos/src/window.rs 🔗

@@ -1,5 +1,7 @@
 use crate::{
-    BoolExt, DisplayLink, MacDisplay, NSRange, NSStringExt, events::platform_input_from_native,
+    BoolExt, DisplayLink, MacDisplay, NSRange, NSStringExt, TISCopyCurrentKeyboardInputSource,
+    TISGetInputSourceProperty, events::platform_input_from_native,
+    kTISPropertyInputSourceIsASCIICapable, kTISPropertyInputSourceType, kTISTypeKeyboardInputMode,
     ns_string, renderer,
 };
 #[cfg(any(test, feature = "test-support"))]
@@ -34,6 +36,9 @@ use gpui::{
 #[cfg(any(test, feature = "test-support"))]
 use image::RgbaImage;
 
+use core_foundation::base::{CFRelease, CFTypeRef};
+use core_foundation_sys::base::CFEqual;
+use core_foundation_sys::number::{CFBooleanGetValue, CFBooleanRef};
 use core_graphics::display::{CGDirectDisplayID, CGPoint, CGRect};
 use ctor::ctor;
 use futures::channel::oneshot;
@@ -1782,6 +1787,45 @@ extern "C" fn handle_key_up(this: &Object, _: Sel, native_event: id) {
 //   - in vim mode `option-4`  should go to end of line (same as $)
 //  Japanese (Romaji) layout:
 //   - type `a i left down up enter enter` should create an unmarked text "愛"
+//   - In vim mode with `jj` bound to `vim::NormalBefore` in insert mode, typing 'j i' with
+//     Japanese IME should produce "じ" (ji), not "jい"
+
+/// Returns true if the current keyboard input source is a composition-based IME
+/// (e.g. Japanese Hiragana, Korean, Chinese Pinyin) that produces non-ASCII output.
+///
+/// This checks two properties:
+/// 1. The source type is `kTISTypeKeyboardInputMode` (an IME input mode, not a plain
+///    keyboard layout). This excludes non-ASCII layouts like Armenian and Ukrainian
+///    that map keys directly without composition.
+/// 2. The source is not ASCII-capable, which excludes modes like Japanese Romaji that
+///    produce ASCII characters and should allow multi-stroke keybindings like `jj`.
+unsafe fn is_ime_input_source_active() -> bool {
+    unsafe {
+        let source = TISCopyCurrentKeyboardInputSource();
+        if source.is_null() {
+            return false;
+        }
+
+        let source_type =
+            TISGetInputSourceProperty(source, kTISPropertyInputSourceType as *const c_void);
+        let is_input_mode = !source_type.is_null()
+            && CFEqual(
+                source_type as CFTypeRef,
+                kTISTypeKeyboardInputMode as CFTypeRef,
+            ) != 0;
+
+        let is_ascii = TISGetInputSourceProperty(
+            source,
+            kTISPropertyInputSourceIsASCIICapable as *const c_void,
+        );
+        let is_ascii_capable = !is_ascii.is_null() && CFBooleanGetValue(is_ascii as CFBooleanRef);
+
+        CFRelease(source as CFTypeRef);
+
+        is_input_mode && !is_ascii_capable
+    }
+}
+
 extern "C" fn handle_key_event(this: &Object, native_event: id, key_equivalent: bool) -> BOOL {
     let window_state = unsafe { get_window_state(this) };
     let mut lock = window_state.as_ref().lock();
@@ -1833,7 +1877,28 @@ extern "C" fn handle_key_event(this: &Object, native_event: id, key_equivalent:
             // and keys with function, as the input handler swallows them.
             // and keys with platform (Cmd), so that Cmd+key events (e.g. Cmd+`) are not
             // consumed by the IME on non-QWERTY / dead-key layouts.
+            // We also send printable keys to the IME first when an IME input source (e.g. Japanese,
+            // Korean, Chinese) is active and the input handler accepts text input. This prevents
+            // multi-stroke keybindings like `jj` from intercepting keys that the IME should compose
+            // (e.g. typing 'ji' should produce 'じ', not 'jい'). If the IME doesn't handle the key,
+            // it calls `doCommandBySelector:` which routes it back to keybinding matching.
+            let is_ime_printable_key = !is_composing
+                && key_down_event
+                    .keystroke
+                    .key_char
+                    .as_ref()
+                    .is_some_and(|key_char| key_char.chars().all(|c| !c.is_control()))
+                && !key_down_event.keystroke.modifiers.control
+                && !key_down_event.keystroke.modifiers.function
+                && !key_down_event.keystroke.modifiers.platform
+                && unsafe { is_ime_input_source_active() }
+                && with_input_handler(this, |input_handler| {
+                    input_handler.query_prefers_ime_for_printable_keys()
+                })
+                .unwrap_or(false);
+
             if is_composing
+                || is_ime_printable_key
                 || (key_down_event.keystroke.key_char.is_none()
                     && !key_down_event.keystroke.modifiers.control
                     && !key_down_event.keystroke.modifiers.function