From 8faa497b55ef6fe9e23f82d7438bb7f3824e6145 Mon Sep 17 00:00:00 2001 From: lightnovel0 Date: Fri, 8 May 2026 19:28:40 +0900 Subject: [PATCH 1/2] fix(insertion-windows): route per-codepoint Unicode SendInput through clipboard insert_via_unicode_keystrokes was injecting one KEYEVENTF_UNICODE SendInput per codepoint. On a Japanese host this competes with the active IME's composition state - hiragana ends up queued in the IME composition window while kanji / ascii get inserted ahead of it, so the user sees text reordered with kanji pushed to the end. Route this path through the existing clipboard+Ctrl+V fallback so IME doesn't intercept individual codepoints. Also force the return value to InsertStatus::Inserted so coordinator.rs's gating logic doesn't double-paste via the non-TSF fallback path. Splits simulate_paste into per-OS impls so the keystroke synth is isolated to Linux (enigo Ctrl+V), and Windows uses the same enigo path which is fine because Ctrl+V as a *keyboard shortcut* is not intercepted by IME composition (only KEYEVENTF_UNICODE is). --- openless-all/app/src-tauri/src/insertion.rs | 29 ++++++++++++++++----- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/openless-all/app/src-tauri/src/insertion.rs b/openless-all/app/src-tauri/src/insertion.rs index 034a8e28..1c85a3ce 100644 --- a/openless-all/app/src-tauri/src/insertion.rs +++ b/openless-all/app/src-tauri/src/insertion.rs @@ -60,13 +60,22 @@ impl TextInserter { if text.is_empty() { return InsertStatus::CopiedFallback; } - match windows_unicode::send_text(text) { - Ok(()) => InsertStatus::Inserted, - Err(err) => { - log::warn!("[insertion] Unicode SendInput failed: {err}"); - InsertStatus::CopiedFallback - } - } + // Don't actually inject Unicode keystrokes here. 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. Route through the clipboard + Ctrl+V path + // instead. + // + // Important: callers in coordinator.rs gate the non-TSF fallback on + // `== InsertStatus::Inserted`. `self.insert` returns `PasteSent` on + // Windows (we sent Ctrl+V but can't prove the target swallowed it), + // so if we returned that as-is the caller would treat it as failure + // and run the fallback path, double-pasting the text. Force + // `Inserted` here to suppress the redundant fallback. + let _ = self.insert(text, true); + InsertStatus::Inserted } /// Insert `text` at the current cursor position. @@ -290,6 +299,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 we route through + // the clipboard path instead. So this Ctrl+V path is safe to keep. use enigo::{Direction, Enigo, Key, Keyboard, Settings}; let mut enigo = Enigo::new(&Settings::default()).map_err(|e| e.to_string())?; let modifier = Key::Control; From 5805e207404039a4d35d735bf233a2771ba5f760 Mon Sep 17 00:00:00 2001 From: lightnovel0 Date: Fri, 8 May 2026 21:21:17 +0900 Subject: [PATCH 2/2] fix(insertion-windows): detach IME during Unicode SendInput instead of clipboard fallback --- openless-all/app/src-tauri/Cargo.toml | 1 + openless-all/app/src-tauri/src/insertion.rs | 81 ++++++++++++++++----- 2 files changed, 65 insertions(+), 17 deletions(-) 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 1c85a3ce..30535296 100644 --- a/openless-all/app/src-tauri/src/insertion.rs +++ b/openless-all/app/src-tauri/src/insertion.rs @@ -60,22 +60,26 @@ impl TextInserter { if text.is_empty() { return InsertStatus::CopiedFallback; } - // Don't actually inject Unicode keystrokes here. 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. Route through the clipboard + Ctrl+V path - // instead. + // 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. // - // Important: callers in coordinator.rs gate the non-TSF fallback on - // `== InsertStatus::Inserted`. `self.insert` returns `PasteSent` on - // Windows (we sent Ctrl+V but can't prove the target swallowed it), - // so if we returned that as-is the caller would treat it as failure - // and run the fallback path, double-pasting the text. Force - // `Inserted` here to suppress the redundant fallback. - let _ = self.insert(text, true); - InsertStatus::Inserted + // 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 (IME-detached) failed: {err}; \ + falling back to clipboard paste" + ); + self.insert(text, true) + } + } } /// Insert `text` at the current cursor position. @@ -303,8 +307,8 @@ fn simulate_paste() -> Result<(), String> { // 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 we route through - // the clipboard path instead. So this Ctrl+V path is safe to keep. + // 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; @@ -333,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)?; @@ -346,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