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