From ebf66e4c52e40b2b69122486b930243802b1496e Mon Sep 17 00:00:00 2001 From: chenzhen48 Date: Wed, 10 Jun 2026 14:39:33 +0800 Subject: [PATCH] macOS: per-pane independent keyboard input source (IME) support When switching focus between split panes, each pane now remembers and restores its own macOS keyboard input source (e.g., English ABC in one pane, Chinese Pinyin in another). - Adds get_current_input_source_id() and select_input_source() to the macOS ObjC keycode bridge, with [NSThread isMainThread] guards to ensure Carbon TIS APIs are only called from the main thread. - Exposes these as Window::get_current_input_source_id() and Window::select_input_source() in the Rust FFI layer. - PaneGroupFocusState stores a HashMap mapping each pane to its last known input source ID. On focus change, it saves the departing pane's source and restores the incoming pane's source. - New panes inherit the current system input source on first focus. - Feature is macOS-only via #[cfg(target_os = "macos")]. Closes #12316 --- app/src/pane_group/focus_state.rs | 54 +++++++++++++++++++ crates/warpui/src/platform/mac/objc/keycode.m | 39 ++++++++++++++ crates/warpui/src/platform/mac/window.rs | 27 ++++++++++ 3 files changed, 120 insertions(+) diff --git a/app/src/pane_group/focus_state.rs b/app/src/pane_group/focus_state.rs index 78ccefeb5f..e2d5a39c68 100644 --- a/app/src/pane_group/focus_state.rs +++ b/app/src/pane_group/focus_state.rs @@ -1,3 +1,7 @@ +use std::collections::HashMap; + +#[cfg(target_os = "macos")] +use warpui::platform::mac::Window; use warpui::{AppContext, Entity, ModelContext, ModelHandle}; use super::pane::{PaneId, TerminalPaneId}; @@ -12,6 +16,9 @@ pub struct PaneGroupFocusState { active_session_id: Option, in_split_pane: bool, is_focused_pane_maximized: bool, + /// Per-pane keyboard input source IDs (macOS only). + /// Maps pane ID to the last known input source ID (e.g., "com.apple.keylayout.ABC"). + pane_input_sources: HashMap, } #[derive(Debug, Clone)] @@ -38,11 +45,21 @@ impl PaneGroupFocusState { active_session_id: Option, in_split_pane: bool, ) -> Self { + // Initialize the map and store the current input source for the initial pane. + let mut pane_input_sources = HashMap::new(); + #[cfg(target_os = "macos")] + { + if let Some(source_id) = Window::get_current_input_source_id() { + pane_input_sources.insert(focused_pane_id, source_id); + } + } + Self { focused_pane_id, active_session_id, in_split_pane, is_focused_pane_maximized: false, + pane_input_sources, } } @@ -93,6 +110,11 @@ impl PaneGroupFocusState { pub(super) fn set_focused_pane(&mut self, pane_id: PaneId, ctx: &mut ModelContext) { let old_focused = self.focused_pane_id; if old_focused != pane_id { + // Save the current input source for the departing pane, + // then restore the input source for the incoming pane. + self.save_input_source_for_pane(old_focused); + self.restore_input_source_for_pane(pane_id); + self.focused_pane_id = pane_id; // When focus changes, clear maximize state self.is_focused_pane_maximized = false; @@ -103,6 +125,38 @@ impl PaneGroupFocusState { } } + /// Saves the current system input source ID for the given pane. + /// On macOS, this is a no-op when called from a background thread + /// (Carbon TIS APIs require the main thread). + #[cfg(target_os = "macos")] + fn save_input_source_for_pane(&mut self, pane_id: PaneId) { + if let Some(source_id) = Window::get_current_input_source_id() { + self.pane_input_sources.insert(pane_id, source_id); + } + } + + #[cfg(not(target_os = "macos"))] + fn save_input_source_for_pane(&mut self, _pane_id: PaneId) { + // No-op on non-macOS platforms + } + + /// Restores the previously saved input source for the given pane. + /// If the pane has no saved input source (e.g., it's newly created), + /// inherits the current system input source. + /// On macOS, this is a no-op when called from a background thread + /// (Carbon TIS APIs require the main thread). + #[cfg(target_os = "macos")] + fn restore_input_source_for_pane(&mut self, pane_id: PaneId) { + if let Some(source_id) = self.pane_input_sources.get(&pane_id) { + Window::select_input_source(source_id); + } + } + + #[cfg(not(target_os = "macos"))] + fn restore_input_source_for_pane(&mut self, _pane_id: PaneId) { + // No-op on non-macOS platforms + } + /// Sets the active terminal session and emits an ActiveSessionChanged event. pub(super) fn set_active_session( &mut self, diff --git a/crates/warpui/src/platform/mac/objc/keycode.m b/crates/warpui/src/platform/mac/objc/keycode.m index 32b2dc5958..44c7c888b2 100644 --- a/crates/warpui/src/platform/mac/objc/keycode.m +++ b/crates/warpui/src/platform/mac/objc/keycode.m @@ -158,6 +158,45 @@ UniChar TranslatedUnicodeCharFromKeyCode(CFDataRef layout_data, UInt16 key_code, } } +// Returns the current keyboard input source ID (e.g., "com.apple.keylayout.ABC"), +// or nil if not on the main thread or if the input source cannot be determined. +// Carbon TIS APIs must be called from the main thread. +NSString* get_current_input_source_id(void) { + if (![NSThread isMainThread]) return nil; + TISInputSourceRef source = TISCopyCurrentKeyboardInputSource(); + if (!source) return nil; + CFStringRef source_id = TISGetInputSourceProperty(source, kTISPropertyInputSourceID); + CFStringRef result = source_id ? CFStringCreateCopy(kCFAllocatorDefault, source_id) : nil; + CFRelease(source); + return result ? (NSString*)CFBridgingRelease(result) : nil; +} + +// Selects the keyboard input source with the given source ID. +// No-ops silently if not on the main thread — Carbon TIS APIs require the main thread. +void select_input_source(NSString* source_id) { + if (![NSThread isMainThread]) return; + if (!source_id) return; + CFStringRef keys[] = { kTISPropertyInputSourceID }; + CFTypeRef values[] = { (__bridge CFStringRef)source_id }; + CFDictionaryRef dict = CFDictionaryCreate( + kCFAllocatorDefault, + (const void**)keys, + (const void**)values, + 1, + &kCFTypeDictionaryKeyCallBacks, + &kCFTypeDictionaryValueCallBacks + ); + if (!dict) return; + CFArrayRef sources = TISCreateInputSourceList(dict, false); + CFRelease(dict); + if (!sources) return; + if (CFArrayGetCount(sources) > 0) { + TISInputSourceRef source = (TISInputSourceRef)CFArrayGetValueAtIndex(sources, 0); + TISSelectInputSource(source); + } + CFRelease(sources); +} + NSArray* charToKeyCodes(NSString* keyChar) { if (keycodeDict == nil) { keycodeDict = [[NSMutableDictionary alloc] init]; diff --git a/crates/warpui/src/platform/mac/window.rs b/crates/warpui/src/platform/mac/window.rs index 34f03a7a17..871c18801e 100644 --- a/crates/warpui/src/platform/mac/window.rs +++ b/crates/warpui/src/platform/mac/window.rs @@ -476,6 +476,8 @@ extern "C" { ); fn open_url(urlString: &NSString); fn set_titlebar_height(window: &NSWindow, height: f64); + fn get_current_input_source_id() -> *mut NSString; + fn select_input_source(source_id: &NSString); } pub type FrameCaptureCallback = Box; @@ -743,6 +745,31 @@ impl Window { } } + /// Returns the current keyboard input source ID (e.g., "com.apple.keylayout.ABC"), + /// or `None` if it cannot be determined. + pub fn get_current_input_source_id() -> Option { + // SAFETY: `get_current_input_source_id` is an FFI call into `keycode.m` + // that reads the active TIS input source. + unsafe { + let ptr = get_current_input_source_id(); + if ptr.is_null() { + None + } else { + Some((*ptr).to_string()) + } + } + } + + /// Selects the keyboard input source with the given source ID. + /// The source ID is a string like "com.apple.keylayout.ABC". + pub fn select_input_source(source_id: &str) { + // SAFETY: `select_input_source` is an FFI call into `keycode.m` + // that selects the given TIS input source. + unsafe { + select_input_source(&NSString::from_str(source_id)); + } + } + pub fn open_url(url: &str) { // SAFETY: `open_url` reads the string for the duration of the call. unsafe {