From ddc327fe035beb690d52a55c0d7645f0def233b8 Mon Sep 17 00:00:00 2001 From: kouphasi <89081817+kouphasi@users.noreply.github.com> Date: Wed, 25 Mar 2026 10:26:21 +0900 Subject: [PATCH] gpui: Fix Vim jj keybinding intercepting j key from IME on macOS (#52192) ## 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 - [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` --- 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(-) diff --git a/crates/gpui/src/input.rs b/crates/gpui/src/input.rs index c9c0a85cad2283c07af094e0f742c580341758ec..10ca46501d8a8206dee38e4e4a249931591ba631 100644 --- a/crates/gpui/src/input.rs +++ b/crates/gpui/src/input.rs @@ -187,4 +187,9 @@ impl InputHandler for ElementInputHandler { 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)) + } } diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index 4e0ef75536964b88b074584965b1969398a4347d..9d672ce34df061b11dce3437101afc55d2b086c7 100644 --- a/crates/gpui/src/platform.rs +++ b/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 diff --git a/crates/gpui_macos/src/platform.rs b/crates/gpui_macos/src/platform.rs index 4d30f82bc0555d38e9bbfbc3d8887806049f8314..5bae3cfb6aa73c99038e0017332a046035dc1589 100644 --- a/crates/gpui_macos/src/platform.rs +++ b/crates/gpui_macos/src/platform.rs @@ -1389,6 +1389,7 @@ unsafe fn ns_url_to_path(url: id) -> Result { #[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 { diff --git a/crates/gpui_macos/src/window.rs b/crates/gpui_macos/src/window.rs index 0bf7c0a2e7f3c28b9e2e9b7014202bb4581b7278..51758ae095ef29901dd3fbb550e9c863ceaf3762 100644 --- a/crates/gpui_macos/src/window.rs +++ b/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