diff --git a/openless-all/app/src-tauri/Cargo.toml b/openless-all/app/src-tauri/Cargo.toml index ec2d958f..f65b53a7 100644 --- a/openless-all/app/src-tauri/Cargo.toml +++ b/openless-all/app/src-tauri/Cargo.toml @@ -91,6 +91,7 @@ windows = { version = "0.58", features = [ "Win32_System_Ole", "Win32_System_Registry", "Win32_System_Threading", + "Win32_UI_Input_Ime", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_Shell", "Win32_UI_TextServices", diff --git a/openless-all/app/src-tauri/src/insertion.rs b/openless-all/app/src-tauri/src/insertion.rs index 034a8e28..30535296 100644 --- a/openless-all/app/src-tauri/src/insertion.rs +++ b/openless-all/app/src-tauri/src/insertion.rs @@ -60,11 +60,24 @@ impl TextInserter { if text.is_empty() { return InsertStatus::CopiedFallback; } - match windows_unicode::send_text(text) { + // Per-codepoint KEYEVENTF_UNICODE SendInput on a Japanese host + // competes with the IME's composition state (ATOK / Microsoft IME), + // so hiragana ends up queued in the IME composition window while + // kanji / ascii get inserted ahead of it — the user sees text + // reordered with kanji pushed to the end. + // + // Fix: detach the foreground window's IME context for the duration + // of the synthetic keystrokes, then restore it. The IME never sees + // the synthesized input so it cannot re-order it, and the user's + // IME state (ATOK conversion mode etc) is left intact. + match windows_unicode::send_text_with_ime_detached(text) { Ok(()) => InsertStatus::Inserted, Err(err) => { - log::warn!("[insertion] Unicode SendInput failed: {err}"); - InsertStatus::CopiedFallback + log::warn!( + "[insertion] Unicode SendInput (IME-detached) failed: {err}; \ + falling back to clipboard paste" + ); + self.insert(text, true) } } } @@ -290,6 +303,12 @@ fn simulate_paste() -> Result<(), String> { #[cfg(not(target_os = "macos"))] fn simulate_paste() -> Result<(), String> { + // Synthesize Ctrl+V (Cmd+V on macOS is handled separately above). + // Note: Ctrl+V as a *keyboard accelerator* does NOT compete with IME + // composition state — the IME treats it as a shortcut, not as text input. + // The IME-vs-text-injection bug specifically affects KEYEVENTF_UNICODE + // SendInput in `insert_via_unicode_keystrokes`, which now detaches the + // foreground window's IME context for the duration of the keystrokes. use enigo::{Direction, Enigo, Key, Keyboard, Settings}; let mut enigo = Enigo::new(&Settings::default()).map_err(|e| e.to_string())?; let modifier = Key::Control; @@ -318,11 +337,14 @@ fn insertion_success_status() -> InsertStatus { #[cfg(target_os = "windows")] mod windows_unicode { + use windows::Win32::UI::Input::Ime::{ImmAssociateContext, ImmGetContext, ImmReleaseContext}; use windows::Win32::UI::Input::KeyboardAndMouse::{ SendInput, INPUT, INPUT_0, INPUT_KEYBOARD, KEYBDINPUT, KEYBD_EVENT_FLAGS, KEYEVENTF_KEYUP, KEYEVENTF_UNICODE, VIRTUAL_KEY, }; + use windows::Win32::UI::WindowsAndMessaging::GetForegroundWindow; + #[allow(dead_code)] pub fn send_text(text: &str) -> Result<(), String> { for unit in text.encode_utf16() { send_utf16_unit(unit, false)?; @@ -331,6 +353,46 @@ mod windows_unicode { Ok(()) } + /// Send `text` as a stream of `KEYEVENTF_UNICODE` keystrokes, with the + /// foreground window's IME temporarily detached so the active IME + /// (ATOK / Microsoft IME / 等) cannot intercept and re-order the input. + /// + /// Restoration is best-effort: if `ImmAssociateContext(hwnd, original)` + /// fails we still release the IMC handle and surface a warning. The + /// keystrokes themselves having gone through is the property the caller + /// cares about. + pub fn send_text_with_ime_detached(text: &str) -> Result<(), String> { + let hwnd = unsafe { GetForegroundWindow() }; + if hwnd.0.is_null() { + // No foreground window we can touch; just send the keystrokes. + return send_text(text); + } + + let original_imc = unsafe { ImmGetContext(hwnd) }; + // ImmGetContext can return NULL on windows that never had an IME + // context (e.g. games, some elevated processes). In that case there + // is nothing to detach and nothing to restore. + let detached = !original_imc.0.is_null(); + + if detached { + // Detach: associate a NULL HIMC so the IME stops receiving input + // for this window during our SendInput burst. + let _ = unsafe { ImmAssociateContext(hwnd, windows::Win32::UI::Input::Ime::HIMC(std::ptr::null_mut())) }; + } + + let result = send_text(text); + + if detached { + // Re-associate the original IMC. If this fails we log but don't + // override the send_text result — the user's input did go in. + let _ = unsafe { ImmAssociateContext(hwnd, original_imc) }; + // Always release the handle we got from ImmGetContext. + let _ = unsafe { ImmReleaseContext(hwnd, original_imc) }; + } + + result + } + fn send_utf16_unit(unit: u16, key_up: bool) -> Result<(), String> { let flags = if key_up { KEYEVENTF_UNICODE | KEYEVENTF_KEYUP