diff --git a/openless-all/app/src-tauri/backend-tests/tests/backend_rust.rs b/openless-all/app/src-tauri/backend-tests/tests/backend_rust.rs index a3760ccb..3f3fa3f9 100644 --- a/openless-all/app/src-tauri/backend-tests/tests/backend_rust.rs +++ b/openless-all/app/src-tauri/backend-tests/tests/backend_rust.rs @@ -7,6 +7,15 @@ #![allow(dead_code, unused_variables)] +#[cfg(target_os = "windows")] +extern crate self as tauri; + +#[cfg(target_os = "windows")] +pub struct AppHandle(std::marker::PhantomData); + +#[cfg(target_os = "windows")] +pub trait Runtime {} + mod asr { pub mod local { pub mod foundry { @@ -47,3 +56,6 @@ mod recorder; mod shortcut_binding; #[path = "../../src/types.rs"] mod types; +#[cfg(target_os = "windows")] +#[path = "../../src/unicode_keystroke.rs"] +mod unicode_keystroke; diff --git a/openless-all/app/src-tauri/src/commands/settings.rs b/openless-all/app/src-tauri/src/commands/settings.rs index 03d96fb0..06e47433 100644 --- a/openless-all/app/src-tauri/src/commands/settings.rs +++ b/openless-all/app/src-tauri/src/commands/settings.rs @@ -108,8 +108,20 @@ impl SettingsWriter for Arc { } pub(crate) fn persist_settings( + coord: &T, + prefs: UserPreferences, +) -> Result<(), String> { + persist_settings_with_keyboard_apply( + coord, + prefs, + crate::windows_ime_profile::apply_windows_openless_keyboard_list_pref, + ) +} + +pub(crate) fn persist_settings_with_keyboard_apply( coord: &T, mut prefs: UserPreferences, + apply_keyboard_list: impl Fn(&UserPreferences) -> Result<(), String>, ) -> Result<(), String> { let mut previous = coord.read_settings(); sync_dictation_hotkey_legacy_fields(&mut previous); @@ -123,24 +135,68 @@ pub(crate) fn persist_settings( let open_app_changed = previous.open_app_hotkey != prefs.open_app_hotkey; let coding_agent_changed = previous.coding_agent_enabled != prefs.coding_agent_enabled || previous.coding_agent_voice_hotkey != prefs.coding_agent_voice_hotkey; + let windows_keyboard_list_changed = previous.windows_sendinput_insertion_only + != prefs.windows_sendinput_insertion_only + || previous.windows_show_openless_in_keyboard_list + != prefs.windows_show_openless_in_keyboard_list; let active_asr_provider_changed = previous.active_asr_provider != prefs.active_asr_provider; let active_asr_provider = prefs.active_asr_provider.clone(); + + if windows_keyboard_list_changed { + apply_keyboard_list(&prefs)?; + } + if active_asr_provider_changed { - coord.sync_active_asr_provider(&active_asr_provider)?; + if let Err(asr_err) = coord.sync_active_asr_provider(&active_asr_provider) { + if windows_keyboard_list_changed { + if let Err(kb_rollback_err) = apply_keyboard_list(&previous) { + return Err(format!( + "{asr_err}; additionally failed to rollback keyboard list visibility: {kb_rollback_err}" + )); + } + log::warn!( + "[windows-ime] rolled back keyboard list visibility after ASR provider sync failure" + ); + } + return Err(asr_err); + } } + if let Err(error) = coord.write_settings(prefs.clone()) { if active_asr_provider_changed { - if let Err(rollback_error) = - coord.sync_active_asr_provider(&previous.active_asr_provider) - { - coord.write_settings(prefs).map_err(|roll_forward_error| { - format!( - "{error}; additionally failed to restore active ASR provider: {rollback_error}; additionally failed to preserve active ASR provider consistency: {roll_forward_error}" - ) - })?; - } else { - return Err(error); + match coord.sync_active_asr_provider(&previous.active_asr_provider) { + Ok(()) => { + if windows_keyboard_list_changed { + if let Err(rollback_err) = apply_keyboard_list(&previous) { + return Err(format!( + "{error}; additionally failed to rollback keyboard list visibility: {rollback_err}" + )); + } + log::warn!( + "[windows-ime] rolled back keyboard list visibility after settings write failure" + ); + } + return Err(error); + } + Err(rollback_error) => { + // ASR vault 无法回滚时 roll-forward prefs;键盘列表保持新状态,避免三者分叉。 + coord.write_settings(prefs).map_err(|roll_forward_error| { + format!( + "{error}; additionally failed to restore active ASR provider: {rollback_error}; additionally failed to preserve active ASR provider consistency: {roll_forward_error}" + ) + })?; + } + } + } else if windows_keyboard_list_changed { + if let Err(rollback_err) = apply_keyboard_list(&previous) { + return Err(format!( + "{error}; additionally failed to rollback keyboard list visibility: {rollback_err}" + )); } + log::warn!( + "[windows-ime] rolled back keyboard list visibility after settings write failure" + ); + return Err(error); } else { return Err(error); } @@ -465,6 +521,213 @@ pub async fn app_check_update_with_channel( } } +#[cfg(test)] +mod persist_settings_tests { + use super::*; + use std::cell::RefCell; + + struct MockWriter { + prefs: RefCell, + write_calls: RefCell, + asr_sync_calls: RefCell>, + /// 前 N 次 write_settings 调用返回失败;0 = 从不失败。 + write_fail_count: u32, + fail_forward_asr_sync: bool, + fail_rollback_asr_sync: bool, + } + + impl MockWriter { + fn new(prefs: UserPreferences) -> Self { + Self { + prefs: RefCell::new(prefs), + write_calls: RefCell::new(0), + asr_sync_calls: RefCell::new(Vec::new()), + write_fail_count: 0, + fail_forward_asr_sync: false, + fail_rollback_asr_sync: false, + } + } + } + + impl SettingsWriter for MockWriter { + fn read_settings(&self) -> UserPreferences { + self.prefs.borrow().clone() + } + + fn write_settings(&self, prefs: UserPreferences) -> Result<(), String> { + let mut calls = self.write_calls.borrow_mut(); + *calls += 1; + if *calls <= self.write_fail_count { + return Err("write failed".into()); + } + *self.prefs.borrow_mut() = prefs; + Ok(()) + } + + fn sync_active_asr_provider(&self, provider: &str) -> Result<(), String> { + self.asr_sync_calls.borrow_mut().push(provider.to_string()); + let stored = self.prefs.borrow().active_asr_provider.clone(); + if self.fail_forward_asr_sync && provider != stored { + return Err("asr forward sync failed".into()); + } + if self.fail_rollback_asr_sync && provider == stored { + return Err("asr rollback sync failed".into()); + } + Ok(()) + } + + fn refresh_dictation_hotkey(&self) {} + fn refresh_qa_hotkey(&self) {} + fn refresh_combo_hotkey(&self) {} + fn refresh_translation_hotkey(&self) {} + fn refresh_switch_style_hotkey(&self) {} + fn refresh_open_app_hotkey(&self) {} + fn refresh_coding_agent_hotkey(&self) {} + } + + #[test] + fn keyboard_apply_failure_does_not_sync_asr_or_write_prefs() { + let writer = MockWriter::new(UserPreferences::default()); + let mut next = writer.read_settings(); + next.windows_sendinput_insertion_only = true; + next.windows_show_openless_in_keyboard_list = false; + next.active_asr_provider = "other-asr".into(); + + let result = persist_settings_with_keyboard_apply(&writer, next, |_| { + Err("apply failed".into()) + }); + + assert!(result.is_err()); + assert_eq!(*writer.write_calls.borrow(), 0); + assert!(writer.asr_sync_calls.borrow().is_empty()); + assert!(writer.read_settings().windows_show_openless_in_keyboard_list); + } + + #[test] + fn keyboard_apply_success_writes_prefs() { + let writer = MockWriter::new(UserPreferences::default()); + let mut next = writer.read_settings(); + next.windows_sendinput_insertion_only = true; + next.windows_show_openless_in_keyboard_list = false; + + let result = persist_settings_with_keyboard_apply(&writer, next.clone(), |_| Ok(())); + + assert!(result.is_ok()); + assert_eq!(*writer.write_calls.borrow(), 1); + assert!(!writer.read_settings().windows_show_openless_in_keyboard_list); + } + + #[test] + fn asr_sync_failure_rolls_back_keyboard_list() { + let writer = MockWriter { + prefs: RefCell::new(UserPreferences::default()), + write_calls: RefCell::new(0), + asr_sync_calls: RefCell::new(Vec::new()), + write_fail_count: 0, + fail_forward_asr_sync: true, + fail_rollback_asr_sync: false, + }; + let mut next = writer.read_settings(); + next.windows_sendinput_insertion_only = true; + next.windows_show_openless_in_keyboard_list = false; + next.active_asr_provider = "other-asr".into(); + + let apply_calls = RefCell::new(0); + let result = persist_settings_with_keyboard_apply(&writer, next, |_| { + *apply_calls.borrow_mut() += 1; + Ok(()) + }); + + assert!(result.is_err()); + assert_eq!(*writer.write_calls.borrow(), 0); + assert_eq!(*apply_calls.borrow(), 2); + assert!(writer.read_settings().windows_show_openless_in_keyboard_list); + } + + #[test] + fn keyboard_write_failure_rolls_back_profile_without_asr_change() { + let writer = MockWriter { + prefs: RefCell::new(UserPreferences::default()), + write_calls: RefCell::new(0), + asr_sync_calls: RefCell::new(Vec::new()), + write_fail_count: 1, + fail_forward_asr_sync: false, + fail_rollback_asr_sync: false, + }; + let mut next = writer.read_settings(); + next.windows_sendinput_insertion_only = true; + next.windows_show_openless_in_keyboard_list = false; + + let rollback_calls = RefCell::new(0); + let result = persist_settings_with_keyboard_apply(&writer, next, |prefs| { + if prefs.windows_show_openless_in_keyboard_list { + *rollback_calls.borrow_mut() += 1; + } + Ok(()) + }); + + assert!(result.is_err()); + assert_eq!(*writer.write_calls.borrow(), 1); + assert_eq!(*rollback_calls.borrow(), 1); + assert!(writer.asr_sync_calls.borrow().is_empty()); + } + + #[test] + fn keyboard_write_failure_rolls_back_profile_when_asr_rollback_succeeds() { + let writer = MockWriter { + prefs: RefCell::new(UserPreferences::default()), + write_calls: RefCell::new(0), + asr_sync_calls: RefCell::new(Vec::new()), + write_fail_count: 1, + fail_forward_asr_sync: false, + fail_rollback_asr_sync: false, + }; + let mut next = writer.read_settings(); + next.windows_sendinput_insertion_only = true; + next.windows_show_openless_in_keyboard_list = false; + next.active_asr_provider = "other-asr".into(); + + let rollback_calls = RefCell::new(0); + let result = persist_settings_with_keyboard_apply(&writer, next, |prefs| { + if prefs.windows_show_openless_in_keyboard_list { + *rollback_calls.borrow_mut() += 1; + } + Ok(()) + }); + + assert!(result.is_err()); + assert_eq!(*writer.write_calls.borrow(), 1); + assert_eq!(*rollback_calls.borrow(), 1); + } + + #[test] + fn keyboard_write_failure_keeps_new_keyboard_when_asr_roll_forward_succeeds() { + let writer = MockWriter { + prefs: RefCell::new(UserPreferences::default()), + write_calls: RefCell::new(0), + asr_sync_calls: RefCell::new(Vec::new()), + write_fail_count: 1, + fail_forward_asr_sync: false, + fail_rollback_asr_sync: true, + }; + let mut next = writer.read_settings(); + next.windows_sendinput_insertion_only = true; + next.windows_show_openless_in_keyboard_list = false; + next.active_asr_provider = "other-asr".into(); + + let apply_calls = RefCell::new(0); + let result = persist_settings_with_keyboard_apply(&writer, next.clone(), |_| { + *apply_calls.borrow_mut() += 1; + Ok(()) + }); + + assert!(result.is_ok()); + assert_eq!(*writer.write_calls.borrow(), 2); + assert_eq!(*apply_calls.borrow(), 1); + assert!(!writer.read_settings().windows_show_openless_in_keyboard_list); + } +} + #[cfg(mobile)] #[tauri::command] pub async fn app_download_and_install_android_update( diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 9218eef7..3044616c 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -1752,14 +1752,20 @@ fn should_try_non_tsf_insertion_fallback( } #[cfg(target_os = "windows")] -fn insert_via_non_tsf_fallback( +pub(super) fn insert_via_non_tsf_fallback( inner: &Arc, polished: &str, _restore_clipboard: bool, _paste_shortcut: PasteShortcut, ) -> InsertStatus { + let prefs = inner.prefs.get(); + let sendinput_options = crate::unicode_keystroke::WindowsSendInputOptions { + newline_mode: prefs.windows_sendinput_newline_mode, + }; let status = finish_non_tsf_insertion_fallback( - || inner.inserter.insert_via_unicode_keystrokes(polished), + || inner + .inserter + .insert_via_unicode_keystrokes(polished, sendinput_options), || inner.inserter.copy_fallback(polished), ); @@ -2767,7 +2773,13 @@ mod tests { #[test] fn focus_restore_failure_uses_specific_error_code_when_insert_fails() { assert_eq!( - dictation_error_code(InsertStatus::Failed, false, false, false), + dictation_error_code( + InsertStatus::Failed, + false, + false, + false, + crate::types::WindowsInsertionMode::Tsf, + ), Some("focusRestoreFailed") ); } @@ -2784,11 +2796,31 @@ mod tests { #[cfg(target_os = "windows")] fn tsf_required_failure_keeps_tsf_error_when_focus_was_ready() { assert_eq!( - dictation_error_code(InsertStatus::Failed, false, true, false), + dictation_error_code( + InsertStatus::Failed, + false, + true, + false, + crate::types::WindowsInsertionMode::Tsf, + ), Some("windowsImeTsfRequired") ); } + #[test] + fn sendinput_only_mode_skips_tsf_required_error() { + assert_eq!( + dictation_error_code( + InsertStatus::Failed, + false, + true, + false, + crate::types::WindowsInsertionMode::SendInput, + ), + None + ); + } + #[test] fn startup_race_check_treats_newer_session_as_stale() { let mut state = SessionState::default(); diff --git a/openless-all/app/src-tauri/src/coordinator/dictation.rs b/openless-all/app/src-tauri/src/coordinator/dictation.rs index 00b5beec..f5cf5aaf 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation.rs @@ -154,8 +154,22 @@ async fn run_streaming_polish( // 与用户实际看到的内容一致;(b)pr-agent #412 反馈 \"saved output diverges // from what the user actually sees\"。 let (tx, rx) = std::sync::mpsc::channel::(); + #[cfg(target_os = "windows")] + let sendinput_options = + windows_sendinput_options_from_prefs(&inner.prefs.get()); let typer_handle = tokio::task::spawn_blocking(move || { - drain_streaming_insert_deltas(rx, STREAMING_INSERT_FLUSH_INTERVAL) + #[cfg(target_os = "windows")] + { + drain_streaming_insert_deltas_with_sendinput_options( + rx, + STREAMING_INSERT_FLUSH_INTERVAL, + sendinput_options, + ) + } + #[cfg(not(target_os = "windows"))] + { + drain_streaming_insert_deltas(rx, STREAMING_INSERT_FLUSH_INTERVAL) + } }); // 3. 调流式润色,on_delta 塞 mpsc;should_cancel 检查 dictation 取消旗。 @@ -294,6 +308,25 @@ async fn run_streaming_polish( } } +#[cfg(target_os = "windows")] +fn windows_sendinput_options_from_prefs( + prefs: &crate::types::UserPreferences, +) -> crate::unicode_keystroke::WindowsSendInputOptions { + crate::unicode_keystroke::WindowsSendInputOptions { + newline_mode: prefs.windows_sendinput_newline_mode, + } +} + +#[cfg(target_os = "windows")] +fn windows_insertion_allows_streaming(mode: crate::types::WindowsInsertionMode) -> bool { + mode == crate::types::WindowsInsertionMode::SendInput +} + +#[cfg(not(target_os = "windows"))] +fn windows_insertion_allows_streaming(_mode: crate::types::WindowsInsertionMode) -> bool { + true +} + fn drain_streaming_insert_deltas( rx: std::sync::mpsc::Receiver, flush_interval: std::time::Duration, @@ -301,6 +334,17 @@ fn drain_streaming_insert_deltas( drain_streaming_insert_deltas_with(rx, flush_interval, flush_streaming_insert_buffer) } +#[cfg(target_os = "windows")] +fn drain_streaming_insert_deltas_with_sendinput_options( + rx: std::sync::mpsc::Receiver, + flush_interval: std::time::Duration, + options: crate::unicode_keystroke::WindowsSendInputOptions, +) -> (String, Option) { + drain_streaming_insert_deltas_with(rx, flush_interval, move |pending, typed| { + flush_streaming_insert_buffer_with_options(pending, typed, options) + }) +} + fn drain_streaming_insert_deltas_with( rx: std::sync::mpsc::Receiver, flush_interval: std::time::Duration, @@ -351,6 +395,17 @@ fn flush_streaming_insert_buffer(pending: &mut String, typed_text: &mut String) ) } +#[cfg(target_os = "windows")] +fn flush_streaming_insert_buffer_with_options( + pending: &mut String, + typed_text: &mut String, + options: crate::unicode_keystroke::WindowsSendInputOptions, +) -> Option { + flush_streaming_insert_buffer_with(pending, typed_text, move |text| { + crate::unicode_keystroke::type_unicode_chunk_with_options(text, options) + }) +} + fn flush_streaming_insert_buffer_with( pending: &mut String, typed_text: &mut String, @@ -443,6 +498,7 @@ fn streaming_insert_eligible( mode: PolishMode, raw_uses_llm: bool, chinese_script_preference: crate::types::ChineseScriptPreference, + windows_insertion_mode: crate::types::WindowsInsertionMode, ) -> bool { streaming_insert_enabled && !translation_active @@ -451,6 +507,7 @@ fn streaming_insert_eligible( // 没有成品可后处理(finalize_polished_text 在 already_streamed 时直接 return)。 // → 非 Auto 时关掉流式,走一次性路径,确保简/繁转换真正生效(issue #643)。 && chinese_script_preference == crate::types::ChineseScriptPreference::Auto + && windows_insertion_allows_streaming(windows_insertion_mode) } fn default_done_message(status: InsertStatus, polish_failed: bool) -> Option { @@ -1068,9 +1125,11 @@ pub(super) async fn begin_session_as( }; #[cfg(target_os = "windows")] { - let prepared = inner.windows_ime.prepare_session(); - let mut slots = inner.prepared_windows_ime_session.lock(); - store_prepared_windows_ime_session(&mut slots, current_session_id, prepared); + if inner.prefs.get().windows_insertion_mode == crate::types::WindowsInsertionMode::Tsf { + let prepared = inner.windows_ime.prepare_session(); + let mut slots = inner.prepared_windows_ime_session.lock(); + store_prepared_windows_ime_session(&mut slots, current_session_id, prepared); + } } // 翻译模式标志重置;hotkey 监听器在 Shift down 时再 set true。 inner @@ -2312,6 +2371,7 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { mode, raw_uses_llm, chinese_script_preference, + prefs.windows_insertion_mode, ); log::info!( "[coord] polish dispatch: translation={translation_active} mode={mode:?} streaming_eligible={streaming_eligible}" @@ -2419,6 +2479,7 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { let prefs = inner.prefs.get(); let restore_clipboard = prefs.restore_clipboard_after_paste; let allow_non_tsf_insertion_fallback = prefs.allow_non_tsf_insertion_fallback; + let windows_insertion_mode = prefs.windows_insertion_mode; let paste_shortcut = prefs.paste_shortcut; // 流式路径下,字符已经通过 Unicode keystroke 落到光标处,跳过 inserter.insert。 let status = if already_streamed { @@ -2441,17 +2502,41 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { if focus_ready_for_paste { #[cfg(target_os = "windows")] { - let ime_target = capture_ime_submit_target(); - insert_with_windows_ime_first( - inner, - current_session_id, - &polished, - restore_clipboard, - allow_non_tsf_insertion_fallback, - paste_shortcut, - ime_target, - ) - .await + match windows_insertion_mode { + crate::types::WindowsInsertionMode::SendInput => { + let sendinput_options = windows_sendinput_options_from_prefs(&prefs); + if allow_non_tsf_insertion_fallback { + insert_via_non_tsf_fallback( + inner, + &polished, + restore_clipboard, + paste_shortcut, + ) + } else { + inner + .inserter + .insert_via_unicode_keystrokes(&polished, sendinput_options) + } + } + crate::types::WindowsInsertionMode::Paste => inner.inserter.insert( + &polished, + restore_clipboard, + paste_shortcut, + ), + crate::types::WindowsInsertionMode::Tsf => { + let ime_target = capture_ime_submit_target(); + insert_with_windows_ime_first( + inner, + current_session_id, + &polished, + restore_clipboard, + allow_non_tsf_insertion_fallback, + paste_shortcut, + ime_target, + ) + .await + } + } } #[cfg(not(target_os = "windows"))] { @@ -2507,6 +2592,7 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { polish_error.is_some(), focus_ready_for_paste, allow_non_tsf_insertion_fallback, + windows_insertion_mode, ) .map(str::to_string); let tsf_required_insert_failed = error_code.as_deref() == Some("windowsImeTsfRequired"); @@ -2591,12 +2677,14 @@ pub(super) fn dictation_error_code( polish_failed: bool, focus_ready_for_paste: bool, allow_non_tsf_insertion_fallback: bool, + windows_insertion_mode: crate::types::WindowsInsertionMode, ) -> Option<&'static str> { if !focus_ready_for_paste && status == InsertStatus::Failed { Some("focusRestoreFailed") } else if cfg!(target_os = "windows") && focus_ready_for_paste && !allow_non_tsf_insertion_fallback + && windows_insertion_mode == crate::types::WindowsInsertionMode::Tsf && status == InsertStatus::Failed { Some("windowsImeTsfRequired") @@ -3016,9 +3104,54 @@ mod tests { PolishMode::Light, false, ChineseScriptPreference::Auto, + crate::types::WindowsInsertionMode::SendInput, )); } + #[cfg(target_os = "windows")] + #[test] + fn streaming_disabled_for_windows_tsf_insertion_mode() { + assert!(!streaming_insert_eligible( + true, + false, + PolishMode::Light, + false, + ChineseScriptPreference::Auto, + crate::types::WindowsInsertionMode::Tsf, + )); + } + + #[cfg(target_os = "windows")] + #[test] + fn streaming_disabled_for_windows_paste_insertion_mode() { + assert!(!streaming_insert_eligible( + true, + false, + PolishMode::Light, + false, + ChineseScriptPreference::Auto, + crate::types::WindowsInsertionMode::Paste, + )); + } + + #[cfg(not(target_os = "windows"))] + #[test] + fn streaming_ignores_windows_insertion_mode_on_non_windows() { + for mode in [ + crate::types::WindowsInsertionMode::Tsf, + crate::types::WindowsInsertionMode::Paste, + ] { + assert!(streaming_insert_eligible( + true, + false, + PolishMode::Light, + false, + ChineseScriptPreference::Auto, + mode, + )); + } + } + #[test] fn streaming_disabled_for_non_auto_script_so_opencc_runs() { // issue #643:非 Auto 字形(简/繁)必须走一次性路径,让 finalize 的 OpenCC 转换生效。 @@ -3031,16 +3164,18 @@ mod tests { false, PolishMode::Light, false, - pref + pref, + crate::types::WindowsInsertionMode::Tsf, )); } - // Auto 不受影响,仍可流式。 + // Auto + SendInput 仍可流式。 assert!(streaming_insert_eligible( true, false, PolishMode::Light, false, ChineseScriptPreference::Auto, + crate::types::WindowsInsertionMode::SendInput, )); } diff --git a/openless-all/app/src-tauri/src/insertion.rs b/openless-all/app/src-tauri/src/insertion.rs index 114488cd..db5468a6 100644 --- a/openless-all/app/src-tauri/src/insertion.rs +++ b/openless-all/app/src-tauri/src/insertion.rs @@ -74,17 +74,18 @@ impl TextInserter { } #[cfg(target_os = "windows")] - pub fn insert_via_unicode_keystrokes(&self, text: &str) -> InsertStatus { + pub fn insert_via_unicode_keystrokes( + &self, + text: &str, + options: crate::unicode_keystroke::WindowsSendInputOptions, + ) -> InsertStatus { 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 - } - } + map_sendinput_type_result( + text, + crate::unicode_keystroke::type_unicode_chunk_with_options(text, options), + ) } /// macOS 路径:保存原剪贴板 → 写转写文字 → post Cmd+V → 按需恢复原剪贴板。 @@ -179,6 +180,27 @@ impl Default for TextInserter { } } +#[cfg(target_os = "windows")] +fn map_sendinput_type_result( + text: &str, + result: Result, +) -> InsertStatus { + let expected = crate::unicode_keystroke::expected_sendinput_typed_chars(text); + match result { + Ok(typed_chars) if typed_chars == expected => InsertStatus::Inserted, + Ok(typed_chars) => { + log::warn!( + "[insertion] Unicode SendInput typed only {typed_chars}/{expected} chars" + ); + InsertStatus::CopiedFallback + } + Err(err) => { + log::warn!("[insertion] Unicode SendInput failed: {err}"); + InsertStatus::CopiedFallback + } + } +} + #[cfg(not(any(target_os = "android", target_os = "ios")))] #[derive(Debug)] struct ClipboardRestorePlan { @@ -454,63 +476,6 @@ fn insertion_success_status() -> InsertStatus { InsertStatus::PasteSent } -#[cfg(target_os = "windows")] -mod windows_unicode { - use std::time::Duration; - - use windows::Win32::UI::Input::KeyboardAndMouse::{ - SendInput, INPUT, INPUT_0, INPUT_KEYBOARD, KEYBDINPUT, KEYBD_EVENT_FLAGS, KEYEVENTF_KEYUP, - KEYEVENTF_UNICODE, VIRTUAL_KEY, - }; - - const SENDINPUT_CHUNK_CHARS: usize = 16; - const SENDINPUT_CHUNK_DELAY: Duration = Duration::from_millis(12); - - pub fn send_text(text: &str) -> Result<(), String> { - let mut sent_in_chunk = 0usize; - let mut chars = text.chars().peekable(); - while let Some(ch) = chars.next() { - let mut buf = [0u16; 2]; - for unit in ch.encode_utf16(&mut buf) { - send_utf16_unit(*unit, false)?; - send_utf16_unit(*unit, true)?; - } - sent_in_chunk += 1; - if sent_in_chunk >= SENDINPUT_CHUNK_CHARS && chars.peek().is_some() { - std::thread::sleep(SENDINPUT_CHUNK_DELAY); - sent_in_chunk = 0; - } - } - Ok(()) - } - - fn send_utf16_unit(unit: u16, key_up: bool) -> Result<(), String> { - let flags = if key_up { - KEYEVENTF_UNICODE | KEYEVENTF_KEYUP - } else { - KEYEVENTF_UNICODE - }; - let input = INPUT { - r#type: INPUT_KEYBOARD, - Anonymous: INPUT_0 { - ki: KEYBDINPUT { - wVk: VIRTUAL_KEY(0), - wScan: unit, - dwFlags: KEYBD_EVENT_FLAGS(flags.0), - time: 0, - dwExtraInfo: 0, - }, - }, - }; - let sent = unsafe { SendInput(&[input], std::mem::size_of::() as i32) }; - if sent == 1 { - Ok(()) - } else { - Err(std::io::Error::last_os_error().to_string()) - } - } -} - // ── macOS CGEvent paste ── // 直接调 CoreGraphics FFI 发送 Cmd+V,避开 enigo 在主线程外触发的 TSM 断言。 @@ -635,6 +600,23 @@ mod tests { assert!(matches!(primary, Key::Insert)); } + #[test] + #[cfg(target_os = "windows")] + fn crlf_sendinput_success_uses_expected_typed_count() { + let text = "a\r\nb"; + let status = map_sendinput_type_result(text, Ok(3)); + assert_eq!(status, InsertStatus::Inserted); + assert_ne!(text.chars().count(), 3); + } + + #[test] + #[cfg(target_os = "windows")] + fn crlf_sendinput_partial_mismatch_falls_back_to_clipboard() { + let text = "a\r\nb"; + let status = map_sendinput_type_result(text, Ok(text.chars().count())); + assert_eq!(status, InsertStatus::CopiedFallback); + } + #[test] fn empty_insertions_never_touch_clipboard_or_paste_path() { let inserter = TextInserter::new(); diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 8b75b5df..57c011b1 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -465,6 +465,17 @@ fn run_desktop() { init_file_logger(); log::info!("=== OpenLess 启动 ==="); + #[cfg(target_os = "windows")] + if let Err(err) = + crate::windows_ime_profile::apply_windows_openless_keyboard_list_pref( + &coordinator.prefs().get(), + ) + { + log::warn!( + "[windows-ime] apply keyboard list visibility pref on startup failed: {err}" + ); + } + // Capsule 启动时定位到屏幕底部居中并隐藏;coordinator 按需显示。 // 与 Swift `CapsuleWindowController.repositionToBottomCenter` 同语义。 if let Some(capsule) = app.get_webview_window("capsule") { diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index c63c65a1..32852626 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -77,6 +77,26 @@ pub enum PasteShortcut { ShiftInsert, } +/// Windows 听写文本插入策略。默认 TSF 输入法;SendInput 逐字模拟;Paste 走剪贴板 + 模拟粘贴键。 +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "camelCase")] +pub enum WindowsInsertionMode { + #[default] + Tsf, + SendInput, + Paste, +} + +/// Windows SendInput 路径的换行模拟方式。仅 `WindowsInsertionMode::SendInput` 生效。 +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "camelCase")] +pub enum WindowsSendInputNewlineMode { + #[default] + Enter, + ShiftEnter, + CrLf, +} + /// Auto-update 渠道。决定后台 AutoUpdateGate 拉哪条 manifest。 /// `Stable` = `latest-android-{arch}.json`(或桌面 plugin-updater 正式版 endpoints)。 /// `Beta` = `latest-android-{arch}-beta.json`(或桌面 beta endpoints)。 @@ -539,6 +559,26 @@ fn default_true() -> bool { true } +fn resolve_windows_insertion_mode( + mode: WindowsInsertionMode, + legacy_sendinput_only: bool, +) -> WindowsInsertionMode { + if mode != WindowsInsertionMode::Tsf { + mode + } else if legacy_sendinput_only { + WindowsInsertionMode::SendInput + } else { + WindowsInsertionMode::Tsf + } +} + +fn resolve_windows_sendinput_insertion_only_legacy( + mode: WindowsInsertionMode, + legacy_sendinput_only: bool, +) -> bool { + resolve_windows_insertion_mode(mode, legacy_sendinput_only) == WindowsInsertionMode::SendInput +} + #[derive(Debug, Clone, Serialize)] #[serde(default, rename_all = "camelCase")] pub struct UserPreferences { @@ -586,6 +626,26 @@ pub struct UserPreferences { /// 默认开启以保持可用性;关闭后可验证文本是否真正由 TSF 上屏。 #[serde(default = "default_true")] pub allow_non_tsf_insertion_fallback: bool, + /// Windows 听写插入策略:TSF / SendInput / 剪贴板粘贴。 + #[serde(default)] + pub windows_insertion_mode: WindowsInsertionMode, + /// Windows SendInput 路径的换行模拟方式。 + #[serde(default, rename = "windowsSendInputNewlineMode")] + pub windows_sendinput_newline_mode: WindowsSendInputNewlineMode, + /// 旧版 wire 兼容:`true` 等价于 `windows_insertion_mode = SendInput`。 + #[serde( + default, + rename = "windowsSendInputInsertionOnly", + alias = "windowsSendinputInsertionOnly" + )] + pub windows_sendinput_insertion_only: bool, + /// Windows:SendInput 模式下是否在系统键盘列表(Win+Space)中显示 OpenLess TSF 输入法。 + /// 默认 true 保持现有行为;关闭后用户级禁用语言配置文件,无需管理员权限。 + #[serde( + default = "default_true", + rename = "windowsShowOpenlessInKeyboardList" + )] + pub windows_show_openless_in_keyboard_list: bool, /// 用户的工作语言(多选,原生名)。会作为前提注入 LLM polish/translate 的 system prompt 头部, /// 让模型知道该用户在哪些语言间工作。详见 issue #4。 #[serde(default = "default_working_languages")] @@ -883,6 +943,22 @@ struct UserPreferencesWire { #[serde(default)] paste_shortcut: PasteShortcut, allow_non_tsf_insertion_fallback: bool, + #[serde(default)] + windows_insertion_mode: WindowsInsertionMode, + #[serde( + default, + rename = "windowsSendInputNewlineMode", + alias = "windowsSendinputNewlineMode" + )] + windows_sendinput_newline_mode: WindowsSendInputNewlineMode, + #[serde( + default, + rename = "windowsSendInputInsertionOnly", + alias = "windowsSendinputInsertionOnly" + )] + windows_sendinput_insertion_only: bool, + #[serde(default = "default_true", rename = "windowsShowOpenlessInKeyboardList")] + windows_show_openless_in_keyboard_list: bool, working_languages: Vec, translation_target_language: String, chinese_script_preference: ChineseScriptPreference, @@ -1004,6 +1080,10 @@ impl Default for UserPreferencesWire { restore_clipboard_after_paste: prefs.restore_clipboard_after_paste, paste_shortcut: prefs.paste_shortcut, allow_non_tsf_insertion_fallback: prefs.allow_non_tsf_insertion_fallback, + windows_insertion_mode: prefs.windows_insertion_mode, + windows_sendinput_newline_mode: prefs.windows_sendinput_newline_mode, + windows_sendinput_insertion_only: prefs.windows_sendinput_insertion_only, + windows_show_openless_in_keyboard_list: prefs.windows_show_openless_in_keyboard_list, working_languages: prefs.working_languages, translation_target_language: prefs.translation_target_language, chinese_script_preference: prefs.chinese_script_preference, @@ -1104,6 +1184,16 @@ impl<'de> Deserialize<'de> for UserPreferences { restore_clipboard_after_paste: wire.restore_clipboard_after_paste, paste_shortcut: wire.paste_shortcut, allow_non_tsf_insertion_fallback: wire.allow_non_tsf_insertion_fallback, + windows_insertion_mode: resolve_windows_insertion_mode( + wire.windows_insertion_mode, + wire.windows_sendinput_insertion_only, + ), + windows_sendinput_newline_mode: wire.windows_sendinput_newline_mode, + windows_sendinput_insertion_only: resolve_windows_sendinput_insertion_only_legacy( + wire.windows_insertion_mode, + wire.windows_sendinput_insertion_only, + ), + windows_show_openless_in_keyboard_list: wire.windows_show_openless_in_keyboard_list, working_languages: wire.working_languages, translation_target_language: wire.translation_target_language, chinese_script_preference: wire.chinese_script_preference, @@ -1847,6 +1937,10 @@ impl Default for UserPreferences { restore_clipboard_after_paste: true, paste_shortcut: PasteShortcut::default(), allow_non_tsf_insertion_fallback: true, + windows_insertion_mode: WindowsInsertionMode::default(), + windows_sendinput_newline_mode: WindowsSendInputNewlineMode::default(), + windows_sendinput_insertion_only: false, + windows_show_openless_in_keyboard_list: true, working_languages: default_working_languages(), translation_target_language: String::new(), chinese_script_preference: ChineseScriptPreference::Auto, @@ -2590,6 +2684,125 @@ mod tests { assert!(prefs.allow_non_tsf_insertion_fallback); } + #[test] + fn windows_sendinput_insertion_only_defaults_to_disabled() { + let prefs = UserPreferences::default(); + assert!(!prefs.windows_sendinput_insertion_only); + assert_eq!(prefs.windows_insertion_mode, WindowsInsertionMode::Tsf); + + let prefs: UserPreferences = serde_json::from_str("{}").unwrap(); + assert!(!prefs.windows_sendinput_insertion_only); + assert_eq!(prefs.windows_insertion_mode, WindowsInsertionMode::Tsf); + } + + #[test] + fn windows_sendinput_insertion_only_deserializes_frontend_wire_key() { + let prefs: UserPreferences = + serde_json::from_str(r#"{"windowsSendInputInsertionOnly": true}"#).unwrap(); + assert!(prefs.windows_sendinput_insertion_only); + assert_eq!(prefs.windows_insertion_mode, WindowsInsertionMode::SendInput); + } + + #[test] + fn windows_sendinput_insertion_only_deserializes_legacy_wrong_camel_key() { + let prefs: UserPreferences = + serde_json::from_str(r#"{"windowsSendinputInsertionOnly": true}"#).unwrap(); + assert!(prefs.windows_sendinput_insertion_only); + assert_eq!(prefs.windows_insertion_mode, WindowsInsertionMode::SendInput); + } + + #[test] + fn windows_insertion_mode_deserializes_explicit_paste() { + let prefs: UserPreferences = + serde_json::from_str(r#"{"windowsInsertionMode":"paste"}"#).unwrap(); + assert_eq!(prefs.windows_insertion_mode, WindowsInsertionMode::Paste); + assert!(!prefs.windows_sendinput_insertion_only); + } + + #[test] + fn windows_sendinput_newline_mode_defaults_to_enter() { + let prefs: UserPreferences = serde_json::from_str("{}").unwrap(); + assert_eq!( + prefs.windows_sendinput_newline_mode, + WindowsSendInputNewlineMode::Enter + ); + } + + #[test] + fn windows_sendinput_newline_mode_deserializes_shift_enter() { + let prefs: UserPreferences = + serde_json::from_str(r#"{"windowsSendInputNewlineMode":"shiftEnter"}"#).unwrap(); + assert_eq!( + prefs.windows_sendinput_newline_mode, + WindowsSendInputNewlineMode::ShiftEnter + ); + } + + #[test] + fn windows_sendinput_newline_mode_serializes_frontend_wire_key() { + let prefs = UserPreferences { + windows_insertion_mode: WindowsInsertionMode::SendInput, + windows_sendinput_newline_mode: WindowsSendInputNewlineMode::ShiftEnter, + ..UserPreferences::default() + }; + let json = serde_json::to_string(&prefs).unwrap(); + assert!(json.contains(r#""windowsSendInputNewlineMode":"shiftEnter""#)); + assert!(!json.contains("windowsSendinputNewlineMode")); + } + + #[test] + fn windows_sendinput_insertion_only_serializes_frontend_wire_key() { + let enabled = UserPreferences { + windows_insertion_mode: WindowsInsertionMode::SendInput, + windows_sendinput_insertion_only: true, + ..UserPreferences::default() + }; + let json = serde_json::to_string(&enabled).unwrap(); + assert!(json.contains(r#""windowsSendInputInsertionOnly":true"#)); + assert!(!json.contains("windowsSendinputInsertionOnly")); + } + + #[test] + fn windows_sendinput_insertion_only_pref_round_trips_explicit_true() { + let enabled = UserPreferences { + windows_insertion_mode: WindowsInsertionMode::SendInput, + windows_sendinput_insertion_only: true, + ..UserPreferences::default() + }; + let json = serde_json::to_string(&enabled).unwrap(); + assert!(json.contains(r#""windowsSendInputInsertionOnly":true"#)); + assert!(json.contains(r#""windowsInsertionMode":"sendInput""#)); + let restored: UserPreferences = serde_json::from_str(&json).unwrap(); + assert!(restored.windows_sendinput_insertion_only); + assert_eq!(restored.windows_insertion_mode, WindowsInsertionMode::SendInput); + } + + #[test] + fn windows_show_openless_in_keyboard_list_defaults_to_enabled() { + let prefs = UserPreferences::default(); + assert!(prefs.windows_show_openless_in_keyboard_list); + + let prefs: UserPreferences = serde_json::from_str("{}").unwrap(); + assert!(prefs.windows_show_openless_in_keyboard_list); + } + + #[test] + fn windows_show_openless_in_keyboard_list_deserializes_frontend_wire_key() { + let prefs: UserPreferences = + serde_json::from_str(r#"{"windowsShowOpenlessInKeyboardList": false}"#).unwrap(); + assert!(!prefs.windows_show_openless_in_keyboard_list); + } + + #[test] + fn windows_show_openless_in_keyboard_list_serializes_frontend_wire_key() { + let hidden = UserPreferences { + windows_show_openless_in_keyboard_list: false, + ..UserPreferences::default() + }; + let json = serde_json::to_string(&hidden).unwrap(); + assert!(json.contains(r#""windowsShowOpenlessInKeyboardList":false"#)); + } + #[test] fn missing_audio_cue_on_record_pref_defaults_to_enabled() { // 老用户的 preferences.json 没有这个字段 → 应默认开启(按下录音即提示)。 diff --git a/openless-all/app/src-tauri/src/unicode_keystroke.rs b/openless-all/app/src-tauri/src/unicode_keystroke.rs index d22fd00f..ff453400 100644 --- a/openless-all/app/src-tauri/src/unicode_keystroke.rs +++ b/openless-all/app/src-tauri/src/unicode_keystroke.rs @@ -313,11 +313,12 @@ mod macos_impl { #[cfg(target_os = "windows")] mod windows_impl { use super::{TisError, TypeError}; + use crate::types::WindowsSendInputNewlineMode; use std::time::Duration; use tauri::{AppHandle, Runtime}; use windows::Win32::UI::Input::KeyboardAndMouse::{ SendInput, INPUT, INPUT_0, INPUT_KEYBOARD, KEYBDINPUT, KEYBD_EVENT_FLAGS, KEYEVENTF_KEYUP, - KEYEVENTF_UNICODE, VIRTUAL_KEY, + KEYEVENTF_UNICODE, VIRTUAL_KEY, VK_RETURN, VK_SHIFT, VK_TAB, }; const SENDINPUT_CHUNK_CHARS: usize = 16; @@ -326,7 +327,27 @@ mod windows_impl { /// Windows / Linux 上没有 input source 概念,token 留空。Send/Sync 自动派生。 pub struct PreviousInputSource; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub struct WindowsSendInputOptions { + pub newline_mode: WindowsSendInputNewlineMode, + } + + impl Default for WindowsSendInputOptions { + fn default() -> Self { + Self { + newline_mode: WindowsSendInputNewlineMode::Enter, + } + } + } + pub fn type_unicode_chunk(text: &str) -> Result { + type_unicode_chunk_with_options(text, WindowsSendInputOptions::default()) + } + + pub fn type_unicode_chunk_with_options( + text: &str, + options: WindowsSendInputOptions, + ) -> Result { if text.is_empty() { return Ok(0); } @@ -334,17 +355,36 @@ mod windows_impl { let mut sent_in_chunk = 0usize; let mut chars = text.chars().peekable(); while let Some(ch) = chars.next() { - let mut buf = [0u16; 2]; - for unit in ch.encode_utf16(&mut buf) { - if let Err(e) = send_utf16_unit(*unit, false) { + if ch == '\r' { + continue; + } + + if ch == '\n' { + if let Err(e) = send_newline(options.newline_mode) { return Err(partial_or_original(typed_chars, e)); } - if let Err(e) = send_utf16_unit(*unit, true) { + typed_chars += 1; + sent_in_chunk += 1; + } else if ch == '\t' { + if let Err(e) = press_vk(VK_TAB) { return Err(partial_or_original(typed_chars, e)); } + typed_chars += 1; + sent_in_chunk += 1; + } else { + let mut buf = [0u16; 2]; + for unit in ch.encode_utf16(&mut buf) { + if let Err(e) = send_utf16_unit(*unit, false) { + return Err(partial_or_original(typed_chars, e)); + } + if let Err(e) = send_utf16_unit(*unit, true) { + return Err(partial_or_original(typed_chars, e)); + } + } + typed_chars += 1; + sent_in_chunk += 1; } - typed_chars += 1; - sent_in_chunk += 1; + if sent_in_chunk >= SENDINPUT_CHUNK_CHARS && chars.peek().is_some() { std::thread::sleep(SENDINPUT_CHUNK_DELAY); sent_in_chunk = 0; @@ -364,6 +404,57 @@ mod windows_impl { } } + fn send_newline(mode: WindowsSendInputNewlineMode) -> Result<(), TypeError> { + match mode { + WindowsSendInputNewlineMode::Enter => press_vk(VK_RETURN), + WindowsSendInputNewlineMode::ShiftEnter => press_shift_enter(), + WindowsSendInputNewlineMode::CrLf => { + send_utf16_unit(0x000D, false)?; + send_utf16_unit(0x000D, true)?; + send_utf16_unit(0x000A, false)?; + send_utf16_unit(0x000A, true) + } + } + } + + fn press_shift_enter() -> Result<(), TypeError> { + send_vk(VK_SHIFT, false)?; + press_vk(VK_RETURN)?; + send_vk(VK_SHIFT, true) + } + + fn press_vk(vk: VIRTUAL_KEY) -> Result<(), TypeError> { + send_vk(vk, false)?; + send_vk(vk, true) + } + + fn send_vk(vk: VIRTUAL_KEY, key_up: bool) -> Result<(), TypeError> { + let mut flags = KEYBD_EVENT_FLAGS(0); + if key_up { + flags |= KEYEVENTF_KEYUP; + } + let input = INPUT { + r#type: INPUT_KEYBOARD, + Anonymous: INPUT_0 { + ki: KEYBDINPUT { + wVk: vk, + wScan: 0, + dwFlags: flags, + time: 0, + dwExtraInfo: 0, + }, + }, + }; + let sent = unsafe { SendInput(&[input], std::mem::size_of::() as i32) }; + if sent == 1 { + Ok(()) + } else { + Err(TypeError::SendInputFailed( + std::io::Error::last_os_error().to_string(), + )) + } + } + fn send_utf16_unit(unit: u16, key_up: bool) -> Result<(), TypeError> { let flags = if key_up { KEYEVENTF_UNICODE | KEYEVENTF_KEYUP @@ -406,6 +497,24 @@ mod windows_impl { ) -> Result<(), TisError> { Ok(()) } + + #[cfg(test)] + pub(super) fn classify_sendinput_char(ch: char) -> SendInputCharKind { + match ch { + '\r' => SendInputCharKind::Skip, + '\n' => SendInputCharKind::Newline, + '\t' => SendInputCharKind::Tab, + _ => SendInputCharKind::Unicode, + } + } + + #[cfg(test)] + pub(super) enum SendInputCharKind { + Skip, + Newline, + Tab, + Unicode, + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -481,6 +590,49 @@ mod tests { fn platform_error() -> TypeError { TypeError::EnigoText("fail".into()) } + + #[test] + fn expected_sendinput_typed_chars_skips_carriage_return() { + assert_eq!(super::expected_sendinput_typed_chars("a\r\nb"), 3); + assert_eq!(super::expected_sendinput_typed_chars("hello"), 5); + assert_eq!(super::expected_sendinput_typed_chars("\r\r\n"), 1); + } + + #[cfg(target_os = "windows")] + mod windows_sendinput_char_tests { + use super::super::windows_impl::{classify_sendinput_char, SendInputCharKind}; + + #[test] + fn classify_skips_carriage_return() { + assert!(matches!( + classify_sendinput_char('\r'), + SendInputCharKind::Skip + )); + } + + #[test] + fn classify_newline_and_tab() { + assert!(matches!( + classify_sendinput_char('\n'), + SendInputCharKind::Newline + )); + assert!(matches!(classify_sendinput_char('\t'), SendInputCharKind::Tab)); + } + + #[test] + fn classify_regular_text_as_unicode() { + assert!(matches!( + classify_sendinput_char('你'), + SendInputCharKind::Unicode + )); + } + } +} + +/// Windows SendInput 路径上 `type_unicode_chunk` 计入的 typed char 数。 +/// `\r` 会被跳过(CRLF 只产生一次换行),因此不能与 `text.chars().count()` 直接比较。 +pub fn expected_sendinput_typed_chars(text: &str) -> usize { + text.chars().filter(|ch| *ch != '\r').count() } // ═══════════════════════════════════════════════════════════════════════════ @@ -495,7 +647,8 @@ pub use macos_impl::{ #[cfg(target_os = "windows")] #[allow(unused_imports)] pub use windows_impl::{ - restore_input_source, switch_to_ascii, type_unicode_chunk, PreviousInputSource, + restore_input_source, switch_to_ascii, type_unicode_chunk, type_unicode_chunk_with_options, + PreviousInputSource, WindowsSendInputOptions, }; #[cfg(target_os = "linux")] diff --git a/openless-all/app/src-tauri/src/windows_ime_profile.rs b/openless-all/app/src-tauri/src/windows_ime_profile.rs index 8ec380f2..e941e6b5 100644 --- a/openless-all/app/src-tauri/src/windows_ime_profile.rs +++ b/openless-all/app/src-tauri/src/windows_ime_profile.rs @@ -3,7 +3,7 @@ pub const OPENLESS_TSF_LANG_ID: u16 = 0x0804; pub const OPENLESS_TEXT_SERVICE_CLSID_BRACED: &str = "{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D}"; pub const OPENLESS_PROFILE_GUID_BRACED: &str = "{9B5F5E04-23F6-47DA-9A26-D221F6C3F02E}"; -use crate::types::{WindowsImeInstallState, WindowsImeStatus}; +use crate::types::{UserPreferences, WindowsImeInstallState, WindowsImeStatus}; #[cfg(target_os = "windows")] fn parse_guid(value: &str) -> WindowsImeProfileResult { @@ -122,6 +122,66 @@ pub fn get_windows_ime_status() -> WindowsImeStatus { } } +/// 根据偏好决定 OpenLess 语言配置文件是否应在用户键盘列表中启用。 +pub fn desired_openless_language_profile_enabled(prefs: &UserPreferences) -> bool { + if !prefs.windows_sendinput_insertion_only { + return true; + } + prefs.windows_show_openless_in_keyboard_list +} + +#[cfg(target_os = "windows")] +pub fn set_openless_language_profile_enabled(enabled: bool) -> WindowsImeProfileResult<()> { + windows_impl::set_openless_language_profile_enabled(enabled) +} + +#[cfg(not(target_os = "windows"))] +pub fn set_openless_language_profile_enabled(_enabled: bool) -> WindowsImeProfileResult<()> { + Err(WindowsImeProfileError::Unavailable( + "Windows TSF profiles are only available on Windows".to_string(), + )) +} + +#[cfg(target_os = "windows")] +pub fn is_openless_language_profile_enabled() -> WindowsImeProfileResult { + windows_impl::is_openless_language_profile_enabled() +} + +#[cfg(not(target_os = "windows"))] +pub fn is_openless_language_profile_enabled() -> WindowsImeProfileResult { + Err(WindowsImeProfileError::Unavailable( + "Windows TSF profiles are only available on Windows".to_string(), + )) +} + +/// 将「SendInput + 键盘列表可见性」偏好同步到当前用户的 TSF 语言配置文件。 +pub fn apply_windows_openless_keyboard_list_pref(prefs: &UserPreferences) -> Result<(), String> { + let desired = desired_openless_language_profile_enabled(prefs); + #[cfg(target_os = "windows")] + { + let status = get_windows_ime_status(); + if status.state != WindowsImeInstallState::Installed { + if desired { + return Ok(()); + } + return Err( + "OpenLess TSF IME is not installed; cannot hide it from the keyboard list" + .to_string(), + ); + } + set_openless_language_profile_enabled(desired).map_err(|err| { + let message = err.to_string(); + log::warn!("[windows-ime] apply keyboard list visibility pref failed: {message}"); + message + }) + } + #[cfg(not(target_os = "windows"))] + { + let _ = desired; + Ok(()) + } +} + #[cfg(target_os = "windows")] pub struct WindowsImeProfileManager; @@ -193,6 +253,7 @@ mod windows_impl { COINIT_APARTMENTTHREADED, }; use windows::Win32::UI::Input::KeyboardAndMouse::HKL; + use windows::Win32::Foundation::BOOL; use windows::Win32::UI::TextServices::{ CLSID_TF_InputProcessorProfiles, ITfInputProcessorProfileMgr, ITfInputProcessorProfiles, GUID_TFCAT_TIP_KEYBOARD, TF_INPUTPROCESSORPROFILE, TF_IPPMF_DONTCARECURRENTINPUTLANGUAGE, @@ -403,6 +464,35 @@ mod windows_impl { == Some(OPENLESS_PROFILE_GUID_BRACED)) } + pub fn set_openless_language_profile_enabled(enabled: bool) -> WindowsImeProfileResult<()> { + let clsid = parse_guid(OPENLESS_TEXT_SERVICE_CLSID_BRACED)?; + let profile_guid = parse_guid(OPENLESS_PROFILE_GUID_BRACED)?; + let enable_flag = BOOL::from(enabled); + + with_input_processor_profiles(|profiles| unsafe { + profiles.EnableLanguageProfile( + &clsid, + OPENLESS_TSF_LANG_ID, + &profile_guid, + enable_flag, + ) + }) + } + + pub fn is_openless_language_profile_enabled() -> WindowsImeProfileResult { + let clsid = parse_guid(OPENLESS_TEXT_SERVICE_CLSID_BRACED)?; + let profile_guid = parse_guid(OPENLESS_PROFILE_GUID_BRACED)?; + + with_input_processor_profiles(|profiles| unsafe { + let enabled = profiles.IsEnabledLanguageProfile( + &clsid, + OPENLESS_TSF_LANG_ID, + &profile_guid, + )?; + Ok(enabled.as_bool()) + }) + } + pub fn get_windows_ime_status() -> WindowsImeStatus { match inspect_windows_ime_registration() { RegistrationInspection::Installed { dll_path } => WindowsImeStatus { @@ -692,6 +782,30 @@ mod tests { ProfileRestoreDecision::KeepCurrentProfile ); } + + #[test] + fn desired_openless_language_profile_enabled_follows_sendinput_and_visibility_pref() { + let tsf_only = UserPreferences { + windows_sendinput_insertion_only: false, + windows_show_openless_in_keyboard_list: false, + ..UserPreferences::default() + }; + assert!(desired_openless_language_profile_enabled(&tsf_only)); + + let sendinput_show = UserPreferences { + windows_sendinput_insertion_only: true, + windows_show_openless_in_keyboard_list: true, + ..UserPreferences::default() + }; + assert!(desired_openless_language_profile_enabled(&sendinput_show)); + + let sendinput_hide = UserPreferences { + windows_sendinput_insertion_only: true, + windows_show_openless_in_keyboard_list: false, + ..UserPreferences::default() + }; + assert!(!desired_openless_language_profile_enabled(&sendinput_hide)); + } } #[cfg(all(test, target_os = "windows"))] diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 6a17fecf..fdcb0591 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -684,6 +684,19 @@ export const en: typeof zhCN = { comboConflict: 'This shortcut combination is not available', allowNonTsfFallbackLabel: 'Allow non-TSF fallback', allowNonTsfFallbackDesc: 'Windows: when TSF insertion fails, use paced Unicode SendInput; if that still fails, copy the text to the clipboard.', + windowsInsertionModeLabel: 'Windows insertion method', + windowsInsertionModeDesc: 'How dictation output is inserted at the cursor. Clipboard paste uses the simulated paste shortcut above and preserves line breaks.', + windowsInsertionModeTsf: 'TSF IME (default)', + windowsInsertionModeSendInput: 'SendInput keystroke simulation', + windowsInsertionModePaste: 'Clipboard paste (Ctrl+V, etc.)', + windowsSendInputNewlineModeLabel: 'SendInput newline simulation', + windowsSendInputNewlineModeDesc: 'How SendInput turns line breaks into keys. Use Shift+Enter for chat boxes; Enter for Notepad / VS Code and most editors.', + windowsSendInputNewlineModeEnter: 'Enter (most editors)', + windowsSendInputNewlineModeShiftEnter: 'Shift+Enter (chat input boxes)', + windowsSendInputNewlineModeCrLf: 'CR+LF Unicode', + windowsShowOpenlessInKeyboardListLabel: 'Show OpenLess in keyboard list', + windowsShowOpenlessInKeyboardListDesc: 'When off, Win+Space will not cycle to OpenLess. SendInput insertion is unaffected. Turn this back on to restore the entry.', + windowsShowOpenlessInKeyboardListError: 'Could not update the keyboard list: OpenLess TSF IME is not installed or the system rejected the change.', historyGroupTitle: 'History & context', historyRetentionLabel: 'History retention (days)', historyRetentionDesc: 'Entries older than this are pruned on new writes; 0 = no time-based pruning.', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index f4c25e04..54099570 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -686,6 +686,19 @@ export const ja: typeof zhCN = { comboConflict: 'このショートカットの組み合わせは使用できません', allowNonTsfFallbackLabel: '非 TSF フォールバックを許可', allowNonTsfFallbackDesc: 'Windows:TSF 入力が失敗した時は分割した Unicode SendInput を使い、それも失敗した場合はクリップボードへコピーします。', + windowsInsertionModeLabel: 'Windows 挿入方式', + windowsInsertionModeDesc: '聴写結果をカーソル位置へ挿入する方法。クリップボード貼り付けは上の「貼り付けショートカット」を使い、改行を保持します。', + windowsInsertionModeTsf: 'TSF IME(既定)', + windowsInsertionModeSendInput: 'SendInput キー入力シミュレーション', + windowsInsertionModePaste: 'クリップボード貼り付け(Ctrl+V など)', + windowsSendInputNewlineModeLabel: 'SendInput 改行シミュレーション', + windowsSendInputNewlineModeDesc: 'SendInput で改行をどのキーとして送るか。チャット入力は Shift+Enter、メモ帳 / VS Code などは Enter。', + windowsSendInputNewlineModeEnter: 'Enter(多くのエディタ)', + windowsSendInputNewlineModeShiftEnter: 'Shift+Enter(チャット入力)', + windowsSendInputNewlineModeCrLf: 'CR+LF Unicode', + windowsShowOpenlessInKeyboardListLabel: 'キーボード一覧に OpenLess を表示', + windowsShowOpenlessInKeyboardListDesc: 'オフにすると Win+Space で OpenLess に切り替わりません。SendInput 挿入には影響しません。オンに戻すと一覧に再表示されます。', + windowsShowOpenlessInKeyboardListError: 'キーボード一覧を更新できません:OpenLess TSF IME が未インストールか、システムが変更を拒否しました。', historyGroupTitle: '履歴とコンテキスト', historyRetentionLabel: '履歴保持期間(日)', historyRetentionDesc: '保持日数を超えた履歴は新規書き込み時に削除されます。0 = 時間で削除しない。', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 95b74594..eefd766b 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -686,6 +686,19 @@ export const ko: typeof zhCN = { comboConflict: '이 단축키 조합은 사용할 수 없습니다', allowNonTsfFallbackLabel: '비 TSF 폴백 허용', allowNonTsfFallbackDesc: 'Windows: TSF 입력이 실패하면 분할된 Unicode SendInput을 사용하고, 그래도 실패하면 텍스트를 클립보드에 복사합니다.', + windowsInsertionModeLabel: 'Windows 삽입 방식', + windowsInsertionModeDesc: '받아쓰기 결과를 커서 위치에 삽입하는 방법. 클립보드 붙여넣기는 위의 「붙여넣기 단축키」를 사용하며 줄바꿈을 유지합니다.', + windowsInsertionModeTsf: 'TSF 입력기(기본)', + windowsInsertionModeSendInput: 'SendInput 키 입력 시뮬레이션', + windowsInsertionModePaste: '클립보드 붙여넣기(Ctrl+V 등)', + windowsSendInputNewlineModeLabel: 'SendInput 줄바꿈 시뮬레이션', + windowsSendInputNewlineModeDesc: 'SendInput 모드에서 줄바꿈을 어떤 키로 보낼지. 채팅 입력창은 Shift+Enter, 메모장 / VS Code 등은 Enter.', + windowsSendInputNewlineModeEnter: 'Enter(대부분 편집기)', + windowsSendInputNewlineModeShiftEnter: 'Shift+Enter(채팅 입력창)', + windowsSendInputNewlineModeCrLf: 'CR+LF Unicode', + windowsShowOpenlessInKeyboardListLabel: '키보드 목록에 OpenLess 표시', + windowsShowOpenlessInKeyboardListDesc: '끄면 Win+Space로 OpenLess에 전환되지 않습니다. SendInput 삽입에는 영향 없습니다. 다시 켜면 목록에 복원됩니다.', + windowsShowOpenlessInKeyboardListError: '키보드 목록을 업데이트할 수 없습니다: OpenLess TSF IME가 설치되지 않았거나 시스템이 변경을 거부했습니다.', historyGroupTitle: '기록 및 컨텍스트', historyRetentionLabel: '기록 보관 기간(일)', historyRetentionDesc: '보관 기간을 초과한 기록은 새 항목 작성 시 정리됩니다. 0 = 시간 기반 정리 비활성화.', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 2d87e8ab..0e58a4ea 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -682,6 +682,19 @@ export const zhCN = { comboConflict: '该快捷键组合不可用', allowNonTsfFallbackLabel: '允许非 TSF 兜底', allowNonTsfFallbackDesc: 'Windows:TSF 失败时使用分批 Unicode SendInput;如果仍失败,再复制到剪贴板。', + windowsInsertionModeLabel: 'Windows 插入方式', + windowsInsertionModeDesc: '听写结果如何插入到当前光标位置。剪贴板粘贴模式使用上方「模拟粘贴快捷键」,可完整保留换行。', + windowsInsertionModeTsf: 'TSF 输入法(默认)', + windowsInsertionModeSendInput: 'SendInput 逐字模拟', + windowsInsertionModePaste: '剪贴板粘贴(Ctrl+V 等)', + windowsSendInputNewlineModeLabel: 'SendInput 换行模拟', + windowsSendInputNewlineModeDesc: 'SendInput 模式下如何把换行符模拟成按键。聊天框通常选 Shift+Enter;记事本 / VS Code 等选 Enter。', + windowsSendInputNewlineModeEnter: 'Enter(多数编辑器)', + windowsSendInputNewlineModeShiftEnter: 'Shift+Enter(聊天输入框)', + windowsSendInputNewlineModeCrLf: 'CR+LF Unicode', + windowsShowOpenlessInKeyboardListLabel: '在键盘列表中显示 OpenLess', + windowsShowOpenlessInKeyboardListDesc: '关闭后 Win+Space 切换输入法时不会出现 OpenLess;SendInput 插入不受影响。重新开启本项可恢复显示。', + windowsShowOpenlessInKeyboardListError: '无法更新键盘列表:OpenLess TSF 输入法未安装或系统拒绝操作。', historyGroupTitle: '历史与上下文', historyRetentionLabel: '历史保留天数', historyRetentionDesc: '超过保留天数的历史在写入新条目时被清理;0 = 不按时间清理。', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 4390c7d4..e0f61991 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -684,6 +684,19 @@ export const zhTW: typeof zhCN = { pasteShortcutShiftInsert: 'Shift+Insert(xterm / urxvt)', allowNonTsfFallbackLabel: '允許非 TSF 兜底', allowNonTsfFallbackDesc: 'Windows:TSF 失敗時使用分批 Unicode SendInput;如果仍失敗,再複製到剪貼簿。', + windowsInsertionModeLabel: 'Windows 插入方式', + windowsInsertionModeDesc: '聽寫結果如何插入到目前游標位置。剪貼簿貼上模式使用上方「模擬粘貼快捷鍵」,可完整保留換行。', + windowsInsertionModeTsf: 'TSF 輸入法(預設)', + windowsInsertionModeSendInput: 'SendInput 逐字模擬', + windowsInsertionModePaste: '剪貼簿貼上(Ctrl+V 等)', + windowsSendInputNewlineModeLabel: 'SendInput 換行模擬', + windowsSendInputNewlineModeDesc: 'SendInput 模式下如何把換行符模擬成按鍵。聊天框通常選 Shift+Enter;記事本 / VS Code 等選 Enter。', + windowsSendInputNewlineModeEnter: 'Enter(多數編輯器)', + windowsSendInputNewlineModeShiftEnter: 'Shift+Enter(聊天輸入框)', + windowsSendInputNewlineModeCrLf: 'CR+LF Unicode', + windowsShowOpenlessInKeyboardListLabel: '在鍵盤列表中顯示 OpenLess', + windowsShowOpenlessInKeyboardListDesc: '關閉後 Win+Space 切換輸入法時不會出現 OpenLess;SendInput 插入不受影響。重新開啟本項可恢復顯示。', + windowsShowOpenlessInKeyboardListError: '無法更新鍵盤列表:OpenLess TSF 輸入法未安裝或系統拒絕操作。', historyGroupTitle: '歷史與上下文', historyRetentionLabel: '歷史保留天數', historyRetentionDesc: '超過保留天數的歷史在寫入新條目時被清理;0 = 不按時間清理。', diff --git a/openless-all/app/src/lib/ipc/mock-data.ts b/openless-all/app/src/lib/ipc/mock-data.ts index 402f8cc8..99112c19 100644 --- a/openless-all/app/src/lib/ipc/mock-data.ts +++ b/openless-all/app/src/lib/ipc/mock-data.ts @@ -49,6 +49,10 @@ export let mockSettings: UserPreferences = { restoreClipboardAfterPaste: true, pasteShortcut: "ctrlV", allowNonTsfInsertionFallback: true, + windowsInsertionMode: "tsf", + windowsSendInputNewlineMode: "enter", + windowsSendInputInsertionOnly: false, + windowsShowOpenlessInKeyboardList: true, workingLanguages: ["简体中文"], translationTargetLanguage: "", qaHotkey: defaultQaShortcut(), diff --git a/openless-all/app/src/lib/stylePrefs.test.ts b/openless-all/app/src/lib/stylePrefs.test.ts index d049a4a5..07b95aff 100644 --- a/openless-all/app/src/lib/stylePrefs.test.ts +++ b/openless-all/app/src/lib/stylePrefs.test.ts @@ -39,6 +39,10 @@ const previousPrefs: UserPreferences = { restoreClipboardAfterPaste: true, pasteShortcut: 'ctrlV', allowNonTsfInsertionFallback: true, + windowsInsertionMode: 'tsf', + windowsSendInputNewlineMode: 'enter', + windowsSendInputInsertionOnly: false, + windowsShowOpenlessInKeyboardList: true, workingLanguages: ['简体中文'], translationTargetLanguage: '', chineseScriptPreference: 'auto', diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index 17b52bd7..1943a2fb 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -151,6 +151,12 @@ export type CodingAgentPermissionMode = * 详见 issue #360。 */ export type PasteShortcut = 'ctrlV' | 'ctrlShiftV' | 'shiftInsert'; +/** Windows 听写文本插入策略。 */ +export type WindowsInsertionMode = 'tsf' | 'sendInput' | 'paste'; + +/** Windows SendInput 路径的换行模拟方式。 */ +export type WindowsSendInputNewlineMode = 'enter' | 'shiftEnter' | 'crlf'; + export type WindowsImeInstallState = | 'installed' | 'notInstalled' @@ -268,6 +274,14 @@ export interface UserPreferences { pasteShortcut: PasteShortcut; /** Windows:TSF 失败后是否允许快捷键粘贴 / 剪贴板兜底。仅在剪贴板写失败时才再试 SendInput。关闭后可验证是否真实 TSF 上屏。 */ allowNonTsfInsertionFallback: boolean; + /** Windows:听写插入策略(TSF / SendInput / 剪贴板粘贴)。 */ + windowsInsertionMode: WindowsInsertionMode; + /** Windows SendInput 路径的换行模拟方式。 */ + windowsSendInputNewlineMode: WindowsSendInputNewlineMode; + /** 旧版兼容:`true` 等价于 `windowsInsertionMode === 'sendInput'`。 */ + windowsSendInputInsertionOnly: boolean; + /** Windows:SendInput 模式下是否在系统键盘列表(Win+Space)中显示 OpenLess。 */ + windowsShowOpenlessInKeyboardList: boolean; /** 用户的工作语言(多选,原生名);作为前提注入 LLM polish/translate prompt 头部。 */ workingLanguages: string[]; /** 翻译模式目标语言(单选,原生名);空串 = 不启用 Shift 翻译。详见 issue #4。 */ diff --git a/openless-all/app/src/pages/settings/RecordingInputSection.tsx b/openless-all/app/src/pages/settings/RecordingInputSection.tsx index c66530a9..865c9b36 100644 --- a/openless-all/app/src/pages/settings/RecordingInputSection.tsx +++ b/openless-all/app/src/pages/settings/RecordingInputSection.tsx @@ -6,6 +6,7 @@ import { useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ShortcutRecorder } from '../../components/ShortcutRecorder'; import { playRecordStartCue } from '../../lib/audioCue'; +import { emitSaved } from '../../lib/savedEvent'; import { isHotkeyModeMigrationNoticeActive } from '../../lib/hotkeyMigration'; import { isTauri, @@ -13,7 +14,15 @@ import { setDictationHotkey, } from '../../lib/ipc'; import { getPlatformCapabilities } from '../../lib/platform'; -import type { HotkeyMode, MicrophoneDevice, PasteShortcut, PlatformCapabilities } from '../../lib/types'; +import type { + HotkeyMode, + MicrophoneDevice, + PasteShortcut, + PlatformCapabilities, + UserPreferences, + WindowsInsertionMode, + WindowsSendInputNewlineMode, +} from '../../lib/types'; import { useHotkeySettings } from '../../state/HotkeySettingsContext'; import { SelectLite } from '../../components/ui/SelectLite'; import { Card, Collapsible } from '../_atoms'; @@ -39,7 +48,7 @@ async function autostartDisable(): Promise { export function RecordingInputSection() { const { t } = useTranslation(); const os = detectOS(); - const { prefs, capability, updatePrefs: savePrefs } = useHotkeySettings(); + const { prefs, capability, updatePrefs: savePrefs, refresh } = useHotkeySettings(); const [platformCaps, setPlatformCaps] = useState(null); const [microphoneDevices, setMicrophoneDevices] = useState([]); const [microphoneDevicesLoaded, setMicrophoneDevicesLoaded] = useState(false); @@ -102,6 +111,16 @@ export function RecordingInputSection() { }; }, [loadMicrophoneDevices]); + const saveKeyboardListAffectingPrefs = useCallback(async (nextPrefs: UserPreferences) => { + try { + await savePrefs(nextPrefs); + } catch (error) { + console.error('[settings] keyboard list visibility pref save failed', error); + emitSaved('failed', t('settings.recording.windowsShowOpenlessInKeyboardListError')); + await refresh(); + } + }, [savePrefs, refresh, t]); + if (!prefs || !capability) { return ( @@ -131,6 +150,17 @@ export function RecordingInputSection() { savePrefs({ ...prefs, pasteShortcut }); const onAllowNonTsfFallbackChange = (allowNonTsfInsertionFallback: boolean) => savePrefs({ ...prefs, allowNonTsfInsertionFallback }); + const onWindowsInsertionModeChange = (windowsInsertionMode: WindowsInsertionMode) => + void saveKeyboardListAffectingPrefs({ + ...prefs, + windowsInsertionMode, + windowsSendInputInsertionOnly: windowsInsertionMode === 'sendInput', + }); + const onWindowsSendInputNewlineModeChange = (windowsSendInputNewlineMode: WindowsSendInputNewlineMode) => + savePrefs({ ...prefs, windowsSendInputNewlineMode }); + const onWindowsShowOpenlessInKeyboardListChange = ( + windowsShowOpenlessInKeyboardList: boolean, + ) => void saveKeyboardListAffectingPrefs({ ...prefs, windowsShowOpenlessInKeyboardList }); const onStartMinimizedChange = (startMinimized: boolean) => savePrefs({ ...prefs, startMinimized }); const onAutoUpdateCheckChange = (autoUpdateCheck: boolean) => @@ -290,6 +320,55 @@ export function RecordingInputSection() { /> )} + {capability.adapter === 'windowsLowLevel' && ( + + onWindowsInsertionModeChange(next as WindowsInsertionMode)} + options={[ + { value: 'tsf', label: t('settings.recording.windowsInsertionModeTsf') }, + { value: 'sendInput', label: t('settings.recording.windowsInsertionModeSendInput') }, + { value: 'paste', label: t('settings.recording.windowsInsertionModePaste') }, + ]} + ariaLabel={t('settings.recording.windowsInsertionModeLabel')} + style={{ ...inputStyle, maxWidth: 260 }} + /> + + )} + {capability.adapter === 'windowsLowLevel' + && (prefs.windowsInsertionMode === 'sendInput' || prefs.windowsSendInputInsertionOnly) && ( + + onWindowsSendInputNewlineModeChange(next as WindowsSendInputNewlineMode)} + options={[ + { value: 'enter', label: t('settings.recording.windowsSendInputNewlineModeEnter') }, + { value: 'shiftEnter', label: t('settings.recording.windowsSendInputNewlineModeShiftEnter') }, + { value: 'crlf', label: t('settings.recording.windowsSendInputNewlineModeCrLf') }, + ]} + ariaLabel={t('settings.recording.windowsSendInputNewlineModeLabel')} + style={{ ...inputStyle, maxWidth: 260 }} + /> + + )} + {capability.adapter === 'windowsLowLevel' + && (prefs.windowsInsertionMode === 'sendInput' || prefs.windowsSendInputInsertionOnly) && ( + + void onWindowsShowOpenlessInKeyboardListChange(next)} + /> + + )} {capability.adapter === 'windowsLowLevel' && (