From eaca99bdf46c6498fe93f252fecb4e8e686f282e Mon Sep 17 00:00:00 2001 From: baiqing Date: Sat, 9 May 2026 13:15:33 +0800 Subject: [PATCH 1/2] fix(insertion): make Windows/Linux paste shortcut configurable (#360) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 模拟粘贴硬编码 Ctrl+V,kitty / alacritty / wezterm / gnome-terminal / foot 等 Linux 终端只接受 Ctrl+Shift+V,听写文本只剩在剪贴板里,看起来 像"什么也没插入"。issue #360 报告了 kitty 这个具体场景。 新增 `PasteShortcut` 枚举(`CtrlV` / `CtrlShiftV` / `ShiftInsert`)作为 UserPreferences 字段,串到 simulate_paste 的 enigo 路径。Settings → 录音 多了"模拟粘贴快捷键"一行,仅在非 macOS 显示(macOS 走 AX 直写 不受影响)。默认 `CtrlV` 与历史行为一致,老配置文件没有 pasteShortcut 字段时 `#[serde(default)]` 回退到默认值,不破坏既有用户。 涵盖: - types.rs:新枚举 + 字段 + UserPreferencesWire 串接 - insertion.rs:simulate_paste(shortcut),paste_keys 把枚举拆成 (modifiers, primary),按下/释放严格对称,中途出错也反向释放避免卡键 - coordinator.rs:从 prefs 读取,串到三个 insert 调用点 + Windows IME 链 - 前端 TS 类型 / Settings 选择器 / 5 种语言 i18n - 单元测试:默认 CtrlV、JSON 反序列化、paste_keys 三种映射 cargo test --lib 全部 181 通过;npm run build 干净。 --- openless-all/app/src-tauri/src/coordinator.rs | 16 ++- openless-all/app/src-tauri/src/insertion.rs | 115 +++++++++++++++--- openless-all/app/src-tauri/src/types.rs | 50 ++++++++ openless-all/app/src/i18n/en.ts | 5 + openless-all/app/src/i18n/ja.ts | 5 + openless-all/app/src/i18n/ko.ts | 5 + openless-all/app/src/i18n/zh-CN.ts | 5 + openless-all/app/src/i18n/zh-TW.ts | 5 + openless-all/app/src/lib/ipc.ts | 1 + openless-all/app/src/lib/stylePrefs.test.ts | 1 + openless-all/app/src/lib/types.ts | 11 ++ openless-all/app/src/pages/Settings.tsx | 19 +++ 12 files changed, 213 insertions(+), 25 deletions(-) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 16944c47..cf1ddfe6 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -43,7 +43,7 @@ use crate::selection::{capture_selection, SelectionContext}; use crate::types::{ CapsulePayload, CapsuleState, ChineseScriptPreference, DictationSession, HotkeyCapability, HotkeyMode, HotkeyStatus, HotkeyStatusState, InsertStatus, OutputLanguagePreference, - PolishMode, + PasteShortcut, PolishMode, }; #[cfg(target_os = "windows")] use crate::windows_ime_ipc::ImeSubmitTarget; @@ -2725,6 +2725,7 @@ 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 paste_shortcut: PasteShortcut = prefs.paste_shortcut; let status = if focus_ready_for_paste { #[cfg(target_os = "windows")] { @@ -2735,13 +2736,16 @@ async fn end_session(inner: &Arc) -> Result<(), String> { &polished, restore_clipboard, allow_non_tsf_insertion_fallback, + paste_shortcut, ime_target, ) .await } #[cfg(not(target_os = "windows"))] { - inner.inserter.insert(&polished, restore_clipboard) + inner + .inserter + .insert(&polished, restore_clipboard, paste_shortcut) } } else { log::warn!( @@ -2952,6 +2956,7 @@ async fn insert_with_windows_ime_first( polished: &str, restore_clipboard: bool, allow_non_tsf_insertion_fallback: bool, + paste_shortcut: PasteShortcut, ime_target: Option, ) -> InsertStatus { let prepared = { @@ -2964,7 +2969,7 @@ async fn insert_with_windows_ime_first( allow_non_tsf_insertion_fallback, InsertStatus::Failed, ) { - return insert_via_non_tsf_fallback(inner, polished, restore_clipboard); + return insert_via_non_tsf_fallback(inner, polished, restore_clipboard, paste_shortcut); } log::warn!("[windows-ime] non-TSF insertion fallback is disabled; failing insert"); return InsertStatus::Failed; @@ -2989,7 +2994,7 @@ async fn insert_with_windows_ime_first( if ime_status == InsertStatus::Inserted { ime_status } else if should_try_non_tsf_insertion_fallback(allow_non_tsf_insertion_fallback, ime_status) { - insert_via_non_tsf_fallback(inner, polished, restore_clipboard) + insert_via_non_tsf_fallback(inner, polished, restore_clipboard, paste_shortcut) } else { log::warn!("[windows-ime] TSF did not insert; non-TSF insertion fallback is disabled"); InsertStatus::Failed @@ -3009,6 +3014,7 @@ fn insert_via_non_tsf_fallback( inner: &Arc, polished: &str, restore_clipboard: bool, + paste_shortcut: PasteShortcut, ) -> InsertStatus { if inner.inserter.insert_via_unicode_keystrokes(polished) == InsertStatus::Inserted { log::info!("[windows-ime] TSF unavailable; inserted via Unicode SendInput"); @@ -3016,7 +3022,7 @@ fn insert_via_non_tsf_fallback( } else { inner .inserter - .insert_via_clipboard_fallback(polished, restore_clipboard) + .insert_via_clipboard_fallback(polished, restore_clipboard, paste_shortcut) } } diff --git a/openless-all/app/src-tauri/src/insertion.rs b/openless-all/app/src-tauri/src/insertion.rs index ecfc4490..e3285ed6 100644 --- a/openless-all/app/src-tauri/src/insertion.rs +++ b/openless-all/app/src-tauri/src/insertion.rs @@ -20,7 +20,7 @@ use once_cell::sync::Lazy; #[cfg(not(target_os = "macos"))] use parking_lot::Mutex; -use crate::types::InsertStatus; +use crate::types::{InsertStatus, PasteShortcut}; #[cfg(target_os = "windows")] const CLIPBOARD_RESTORE_DELAY: Duration = Duration::from_millis(750); @@ -38,12 +38,19 @@ impl TextInserter { /// Insert `text` at the current cursor position. /// `restore_clipboard_after_paste` 仅在 Windows/Linux 路径下决定 paste 之后是否恢复 /// 用户原剪贴板。macOS 走 AX 直写,参数被忽略。详见 issue #111。 + /// `paste_shortcut` 决定 Windows/Linux 上模拟按下的粘贴快捷键。详见 issue #360: + /// kitty/alacritty 等终端只接受 Ctrl+Shift+V,硬编码 Ctrl+V 会被吞掉。 #[cfg(not(target_os = "macos"))] - pub fn insert(&self, text: &str, restore_clipboard_after_paste: bool) -> InsertStatus { + pub fn insert( + &self, + text: &str, + restore_clipboard_after_paste: bool, + paste_shortcut: PasteShortcut, + ) -> InsertStatus { if text.is_empty() { return InsertStatus::CopiedFallback; } - insert_with_clipboard_restore(text, restore_clipboard_after_paste) + insert_with_clipboard_restore(text, restore_clipboard_after_paste, paste_shortcut) } #[cfg(not(target_os = "macos"))] @@ -51,8 +58,9 @@ impl TextInserter { &self, text: &str, restore_clipboard_after_paste: bool, + paste_shortcut: PasteShortcut, ) -> InsertStatus { - self.insert(text, restore_clipboard_after_paste) + self.insert(text, restore_clipboard_after_paste, paste_shortcut) } #[cfg(target_os = "windows")] @@ -70,8 +78,15 @@ impl TextInserter { } /// Insert `text` at the current cursor position. + /// macOS 走 AX 直写 / Cmd+V:`_restore_clipboard_after_paste` 与 `_paste_shortcut` + /// 仅为跨平台调用方对齐签名而存在,本路径不读它们。 #[cfg(target_os = "macos")] - pub fn insert(&self, text: &str, _restore_clipboard_after_paste: bool) -> InsertStatus { + pub fn insert( + &self, + text: &str, + _restore_clipboard_after_paste: bool, + _paste_shortcut: PasteShortcut, + ) -> InsertStatus { if text.is_empty() { return InsertStatus::CopiedFallback; } @@ -171,7 +186,11 @@ fn copy_to_clipboard_with_restore_plan(text: &str) -> Result InsertStatus { +fn insert_with_clipboard_restore( + text: &str, + restore_clipboard_after_paste: bool, + paste_shortcut: PasteShortcut, +) -> InsertStatus { let restore_plan = match copy_to_clipboard_with_restore_plan(text) { Ok(plan) => plan, Err(err) => { @@ -180,7 +199,7 @@ fn insert_with_clipboard_restore(text: &str, restore_clipboard_after_paste: bool } }; - if let Err(err) = simulate_paste() { + if let Err(err) = simulate_paste(paste_shortcut) { log::warn!("[insertion] simulated paste failed: {}", err); return InsertStatus::CopiedFallback; } @@ -302,20 +321,49 @@ fn simulate_paste() -> Result<(), String> { macos::post_cmd_v() } +/// 把用户配置的 PasteShortcut 拆成 `(modifiers, primary)`。modifier 顺序决定 enigo +/// 按下/释放顺序,跟物理键盘一致:先 Ctrl 再 Shift 再主键,释放反向。 #[cfg(not(target_os = "macos"))] -fn simulate_paste() -> Result<(), String> { - use enigo::{Direction, Enigo, Key, Keyboard, Settings}; +fn paste_keys(shortcut: PasteShortcut) -> (Vec, enigo::Key) { + use enigo::Key; + match shortcut { + PasteShortcut::CtrlV => (vec![Key::Control], Key::Unicode('v')), + PasteShortcut::CtrlShiftV => (vec![Key::Control, Key::Shift], Key::Unicode('v')), + PasteShortcut::ShiftInsert => (vec![Key::Shift], Key::Insert), + } +} + +#[cfg(not(target_os = "macos"))] +fn simulate_paste(shortcut: PasteShortcut) -> Result<(), String> { + use enigo::{Direction, Enigo, Keyboard, Settings}; + let (modifiers, primary) = paste_keys(shortcut); let mut enigo = Enigo::new(&Settings::default()).map_err(|e| e.to_string())?; - let modifier = Key::Control; - enigo - .key(modifier, Direction::Press) - .map_err(|e| e.to_string())?; - let press_v = enigo.key(Key::Unicode('v'), Direction::Click); - let release_modifier = enigo.key(modifier, Direction::Release); - if let Err(e) = release_modifier { - return Err(e.to_string()); + + // 按下顺序:所有 modifier → 主键点击; + // 释放顺序:modifier 反向释放,确保即使中途出错也尽量把按键状态还原回来。 + for modifier in &modifiers { + if let Err(e) = enigo.key(*modifier, Direction::Press) { + // 按下失败时反向释放已经按下的 modifier,避免卡键。 + for already_pressed in modifiers.iter().take_while(|m| *m != modifier).rev() { + let _ = enigo.key(*already_pressed, Direction::Release); + } + return Err(e.to_string()); + } + } + + let click_result = enigo.key(primary, Direction::Click); + + let mut release_err: Option = None; + for modifier in modifiers.iter().rev() { + if let Err(e) = enigo.key(*modifier, Direction::Release) { + release_err.get_or_insert_with(|| e.to_string()); + } + } + + click_result.map_err(|e| e.to_string())?; + if let Some(err) = release_err { + return Err(err); } - press_v.map_err(|e| e.to_string())?; Ok(()) } @@ -474,15 +522,42 @@ mod tests { assert!(!should_restore_clipboard(None, "dictated text")); } + /// issue #360: 用户配置的快捷键必须真的映射到对应按键,否则 Settings UI + /// 改了也没用。这里只检查 modifier 数量 + 主键,不依赖 enigo 内部 PartialEq。 + #[test] + #[cfg(not(target_os = "macos"))] + fn paste_keys_match_configured_shortcut() { + use enigo::Key; + + let (mods, primary) = paste_keys(PasteShortcut::CtrlV); + assert_eq!(mods.len(), 1); + assert!(matches!(mods[0], Key::Control)); + assert!(matches!(primary, Key::Unicode('v'))); + + let (mods, primary) = paste_keys(PasteShortcut::CtrlShiftV); + assert_eq!(mods.len(), 2); + assert!(matches!(mods[0], Key::Control)); + assert!(matches!(mods[1], Key::Shift)); + assert!(matches!(primary, Key::Unicode('v'))); + + let (mods, primary) = paste_keys(PasteShortcut::ShiftInsert); + assert_eq!(mods.len(), 1); + assert!(matches!(mods[0], Key::Shift)); + assert!(matches!(primary, Key::Insert)); + } + #[test] fn empty_insertions_never_touch_clipboard_or_paste_path() { let inserter = TextInserter::new(); - assert_eq!(inserter.insert("", true), InsertStatus::CopiedFallback); + assert_eq!( + inserter.insert("", true, PasteShortcut::CtrlV), + InsertStatus::CopiedFallback + ); #[cfg(not(target_os = "macos"))] { assert_eq!( - inserter.insert_via_clipboard_fallback("", true), + inserter.insert_via_clipboard_fallback("", true, PasteShortcut::CtrlV), InsertStatus::CopiedFallback ); } diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index b2578117..e77612a3 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -45,6 +45,21 @@ pub enum OutputLanguagePreference { Ko, } +/// 模拟粘贴时实际按下的快捷键。macOS 走 AX 直写 / Cmd+V,本枚举只在 +/// Windows / Linux 的 simulate_paste 路径生效。详见 issue #360:kitty 等 +/// Linux 终端只接受 Ctrl+Shift+V,硬编码 Ctrl+V 会被吞掉,听写文本只剩 +/// 在剪贴板里。默认 `CtrlV` 与历史行为一致;用户在 Settings 里改成 +/// `CtrlShiftV`(kitty/alacritty/wezterm/gnome-terminal/foot/...)或 +/// `ShiftInsert`(xterm/urxvt)后,simulate_paste 用对应组合。 +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "camelCase")] +pub enum PasteShortcut { + #[default] + CtrlV, + CtrlShiftV, + ShiftInsert, +} + /// Auto-update 渠道。决定 Settings → 关于 里展示哪一类版本信息。 /// `Stable` 沿用 `tauri-plugin-updater` 的默认 endpoints(即 `tauri.conf.json` /// 里的 `latest-{{target}}-{{arch}}.json`),与发版 pipeline 对齐。 @@ -145,6 +160,11 @@ pub struct UserPreferences { /// 关掉就把听写文本留在剪贴板,让 simulate_paste 实际没生效时用户能 Ctrl+V 找回。 /// macOS 走 AX 直写,不受这个开关影响。详见 issue #111。 pub restore_clipboard_after_paste: bool, + /// Windows / Linux 的模拟粘贴键。macOS 走 AX 直写不受影响。详见 issue #360: + /// kitty 等 Linux 终端不接受 Ctrl+V,只能配 Ctrl+Shift+V。默认 CtrlV 与历史 + /// 行为一致,不破坏既有用户。 + #[serde(default)] + pub paste_shortcut: PasteShortcut, /// Windows: 是否允许 TSF 失败后继续使用 SendInput / 粘贴类非 TSF 兜底。 /// 默认开启以保持可用性;关闭后可验证文本是否真正由 TSF 上屏。 #[serde(default = "default_true")] @@ -288,6 +308,8 @@ struct UserPreferencesWire { active_asr_provider: String, active_llm_provider: String, restore_clipboard_after_paste: bool, + #[serde(default)] + paste_shortcut: PasteShortcut, allow_non_tsf_insertion_fallback: bool, working_languages: Vec, translation_target_language: String, @@ -339,6 +361,7 @@ impl Default for UserPreferencesWire { active_asr_provider: prefs.active_asr_provider, active_llm_provider: prefs.active_llm_provider, 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, working_languages: prefs.working_languages, translation_target_language: prefs.translation_target_language, @@ -388,6 +411,7 @@ impl<'de> Deserialize<'de> for UserPreferences { active_asr_provider: wire.active_asr_provider, active_llm_provider: wire.active_llm_provider, 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, working_languages: wire.working_languages, translation_target_language: wire.translation_target_language, @@ -504,6 +528,7 @@ impl Default for UserPreferences { active_asr_provider: default_active_asr_provider(), active_llm_provider: "ark".into(), restore_clipboard_after_paste: true, + paste_shortcut: PasteShortcut::default(), allow_non_tsf_insertion_fallback: true, working_languages: default_working_languages(), translation_target_language: String::new(), @@ -1127,6 +1152,31 @@ mod tests { assert!(prefs.allow_non_tsf_insertion_fallback); } + /// issue #360: 默认值必须是 CtrlV,跟历史行为一致;老配置文件没有 + /// pasteShortcut 字段时反序列化也得回到 CtrlV,否则会把现有用户的粘贴 + /// 行为静默改掉。 + #[test] + fn paste_shortcut_defaults_to_ctrl_v() { + let prefs = UserPreferences::default(); + assert_eq!(prefs.paste_shortcut, PasteShortcut::CtrlV); + + let from_empty: UserPreferences = serde_json::from_str("{}").unwrap(); + assert_eq!(from_empty.paste_shortcut, PasteShortcut::CtrlV); + } + + #[test] + fn paste_shortcut_round_trips_explicit_values() { + for (raw, expected) in [ + ("ctrlV", PasteShortcut::CtrlV), + ("ctrlShiftV", PasteShortcut::CtrlShiftV), + ("shiftInsert", PasteShortcut::ShiftInsert), + ] { + let json = format!(r#"{{ "pasteShortcut": "{raw}" }}"#); + let prefs: UserPreferences = serde_json::from_str(&json).unwrap(); + assert_eq!(prefs.paste_shortcut, expected, "raw={raw}"); + } + } + #[test] fn legacy_custom_hotkey_without_custom_binding_is_rejected() { let result = serde_json::from_str::( diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 17feb523..5b1cf4ca 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -313,6 +313,11 @@ export const en: typeof zhCN = { muteDuringRecordingDesc: 'Temporarily mute system output during voice input, then restore the previous mute state when recording stops, is cancelled, or fails.', restoreClipboardLabel: 'Restore clipboard after insert', restoreClipboardDesc: 'Windows / Linux only: restore your original clipboard after a successful paste (default on). Turn off to keep the dictation text in the clipboard so you can manually Ctrl+V if the simulated paste did not actually land. See issue #111.', + pasteShortcutLabel: 'Simulated paste shortcut', + pasteShortcutDesc: 'Windows / Linux only: which key combo to simulate when inserting text. Terminals like kitty, alacritty, wezterm, gnome-terminal, and foot only accept Ctrl+Shift+V; xterm / urxvt accept Shift+Insert. Takes effect on your next dictation. See issue #360.', + pasteShortcutCtrlV: 'Ctrl+V (default / most apps)', + pasteShortcutCtrlShiftV: 'Ctrl+Shift+V (kitty / alacritty / wezterm / most terminals)', + pasteShortcutShiftInsert: 'Shift+Insert (xterm / urxvt)', comboRecordLabel: 'Record shortcut', comboRecordDesc: 'Click, then press your desired key combination (e.g. \u2318\u21E7D). Supports Toggle and Push-to-talk modes.', comboRecordBtn: 'Record shortcut', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index b5b3ec28..4ba52301 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -315,6 +315,11 @@ export const ja: typeof zhCN = { muteDuringRecordingDesc: '音声入力開始時にシステム出力を一時的にミュートし、停止/キャンセル/エラー後に元のミュート状態を復元。スピーカーの音がマイクに入らないようにします。', restoreClipboardLabel: '入力後にクリップボードを復元', restoreClipboardDesc: 'Windows / Linux のみ:ペースト成功後に元のクリップボード内容を復元(既定 ON)。OFF にするとディクテーションテキストがクリップボードに残り、ペーストが効かなかった場合に手動で Ctrl+V できます。詳細は issue #111。', + pasteShortcutLabel: '貼り付けショートカット', + pasteShortcutDesc: 'Windows / Linux のみ:テキスト挿入時に模擬するショートカット。kitty / alacritty / wezterm / gnome-terminal などのターミナルは Ctrl+Shift+V のみを受け付けます。xterm / urxvt は Shift+Insert。次回のディクテーションから有効。詳細は issue #360。', + pasteShortcutCtrlV: 'Ctrl+V(既定 / ほとんどのアプリ)', + pasteShortcutCtrlShiftV: 'Ctrl+Shift+V(kitty / alacritty / wezterm / ほとんどのターミナル)', + pasteShortcutShiftInsert: 'Shift+Insert(xterm / urxvt)', comboRecordLabel: 'ショートカットを記録', comboRecordDesc: 'クリック後、希望するキーの組み合わせ(例:⌘⇧D)を押してください。トグル / 押し続けの両方に対応。', comboRecordBtn: 'ショートカットを記録', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 9518e2af..9c0ee61e 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -315,6 +315,11 @@ export const ko: typeof zhCN = { muteDuringRecordingDesc: '음성 입력 시작 시 시스템 출력을 일시적으로 음소거하고, 정지/취소/오류 후 원래 음소거 상태를 복원합니다. 스피커 소리가 마이크에 들어가지 않도록 합니다.', restoreClipboardLabel: '입력 후 클립보드 복원', restoreClipboardDesc: 'Windows / Linux 만: 붙여넣기 성공 후 원래 클립보드 내용을 복원합니다(기본 ON). OFF 시 받아쓰기 텍스트가 클립보드에 남아 붙여넣기가 실패한 경우 수동으로 Ctrl+V 할 수 있습니다. issue #111 참조.', + pasteShortcutLabel: '붙여넣기 단축키', + pasteShortcutDesc: 'Windows / Linux 만: 텍스트 삽입 시 시뮬레이션할 단축키. kitty / alacritty / wezterm / gnome-terminal 같은 터미널은 Ctrl+Shift+V 만 받습니다. xterm / urxvt 는 Shift+Insert. 다음 받아쓰기부터 적용. issue #360 참조.', + pasteShortcutCtrlV: 'Ctrl+V (기본 / 대부분 앱)', + pasteShortcutCtrlShiftV: 'Ctrl+Shift+V (kitty / alacritty / wezterm / 대부분 터미널)', + pasteShortcutShiftInsert: 'Shift+Insert (xterm / urxvt)', comboRecordLabel: '단축키 녹화', comboRecordDesc: '클릭 후 원하는 단축키 조합(예: ⌘⇧D)을 누르세요. 토글 및 누르기 모드 모두 지원합니다.', comboRecordBtn: '단축키 녹화', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 265449a2..761d1453 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -311,6 +311,11 @@ export const zhCN = { muteDuringRecordingDesc: '开始语音输入时临时静音系统输出,停止、取消或出错后恢复原来的静音状态,避免扬声器声音被麦克风收进去。', restoreClipboardLabel: '插入后恢复剪贴板', restoreClipboardDesc: '仅 Windows / Linux:粘贴成功后恢复你原来的剪贴板内容(默认开)。关掉就把听写文本留在剪贴板,模拟粘贴没真正落地时可以手动 Ctrl+V 找回。详见 issue #111。', + pasteShortcutLabel: '模拟粘贴快捷键', + pasteShortcutDesc: '仅 Windows / Linux:插入文本时模拟按下的粘贴键。kitty / alacritty / wezterm / gnome-terminal 等终端只接受 Ctrl+Shift+V;xterm / urxvt 用 Shift+Insert。改完下一次听写即生效。详见 issue #360。', + pasteShortcutCtrlV: 'Ctrl+V(默认 / 多数应用)', + pasteShortcutCtrlShiftV: 'Ctrl+Shift+V(kitty / alacritty / wezterm / 多数终端)', + pasteShortcutShiftInsert: 'Shift+Insert(xterm / urxvt)', comboRecordLabel: '录制快捷键', comboRecordDesc: '点击后按下你想要的快捷键组合(如 ⌘⇧D),支持 Toggle 和 Hold 模式。', comboRecordBtn: '录制快捷键', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 21dc2d80..bf574c5e 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -320,6 +320,11 @@ export const zhTW: typeof zhCN = { muteDuringRecordingDesc: '開始語音輸入時臨時靜音系統輸出,停止、取消或出錯後恢復原來的靜音狀態,避免揚聲器聲音被麥克風收進去。', restoreClipboardLabel: '插入後恢復剪貼板', restoreClipboardDesc: '僅 Windows / Linux:粘貼成功後恢復你原來的剪貼板內容(默認開)。關掉就把聽寫文本留在剪貼板,模擬粘貼沒真正落地時可以手動 Ctrl+V 找回。詳見 issue #111。', + pasteShortcutLabel: '模擬粘貼快捷鍵', + pasteShortcutDesc: '僅 Windows / Linux:插入文本時模擬按下的粘貼鍵。kitty / alacritty / wezterm / gnome-terminal 等終端只接受 Ctrl+Shift+V;xterm / urxvt 用 Shift+Insert。改完下一次聽寫即生效。詳見 issue #360。', + pasteShortcutCtrlV: 'Ctrl+V(默認 / 多數應用)', + pasteShortcutCtrlShiftV: 'Ctrl+Shift+V(kitty / alacritty / wezterm / 多數終端)', + pasteShortcutShiftInsert: 'Shift+Insert(xterm / urxvt)', allowNonTsfFallbackLabel: '允許非 TSF 兜底', allowNonTsfFallbackDesc: '僅 Windows:TSF 直接上屏失敗後,允許改用 Unicode SendInput、快捷鍵粘貼或 WM_PASTE。關閉後可驗證是否真實使用 TSF 輸入。', historyRetentionLabel: '歷史保留天數', diff --git a/openless-all/app/src/lib/ipc.ts b/openless-all/app/src/lib/ipc.ts index 96adf2e7..64aaa3dc 100644 --- a/openless-all/app/src/lib/ipc.ts +++ b/openless-all/app/src/lib/ipc.ts @@ -55,6 +55,7 @@ const mockSettings: UserPreferences = { activeAsrProvider: 'foundry-local-whisper', activeLlmProvider: 'ark', restoreClipboardAfterPaste: true, + pasteShortcut: 'ctrlV', allowNonTsfInsertionFallback: true, workingLanguages: ['简体中文'], translationTargetLanguage: '', diff --git a/openless-all/app/src/lib/stylePrefs.test.ts b/openless-all/app/src/lib/stylePrefs.test.ts index 262e31a3..0604e071 100644 --- a/openless-all/app/src/lib/stylePrefs.test.ts +++ b/openless-all/app/src/lib/stylePrefs.test.ts @@ -22,6 +22,7 @@ const previousPrefs: UserPreferences = { activeAsrProvider: 'volcengine', activeLlmProvider: 'ark', restoreClipboardAfterPaste: true, + pasteShortcut: 'ctrlV', allowNonTsfInsertionFallback: true, workingLanguages: ['简体中文'], translationTargetLanguage: '', diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index b946ff0e..f3b63f53 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -102,6 +102,13 @@ export type QaHotkeyBinding = ShortcutBinding; /** 自定义录音组合键绑定。当 hotkey.trigger == 'custom' 时使用。 */ export type ComboBinding = ShortcutBinding; +/** 模拟粘贴时按下的快捷键。仅 Windows/Linux 生效;macOS 走 AX 直写。 + * - ctrlV : 标准粘贴(默认;大多数编辑器、浏览器、IDE) + * - ctrlShiftV : kitty / alacritty / wezterm / gnome-terminal / foot 等终端 + * - shiftInsert : xterm / urxvt 等老派 X11 终端 + * 详见 issue #360。 */ +export type PasteShortcut = 'ctrlV' | 'ctrlShiftV' | 'shiftInsert'; + export type WindowsImeInstallState = | 'installed' | 'notInstalled' @@ -130,6 +137,10 @@ export interface UserPreferences { activeLlmProvider: string; /** 仅 Windows/Linux:粘贴成功后是否恢复用户原剪贴板。默认 true。详见 issue #111。 */ restoreClipboardAfterPaste: boolean; + /** 仅 Windows/Linux:模拟粘贴时按下的快捷键。详见 issue #360:kitty/alacritty + * 等终端只接受 Ctrl+Shift+V,硬编码 Ctrl+V 会被吞掉,听写文本只剩在剪贴板里。 + * macOS 走 AX 直写不受影响。默认 'ctrlV' 与历史行为一致。 */ + pasteShortcut: PasteShortcut; /** Windows:TSF 失败后是否允许 SendInput / 粘贴类非 TSF 兜底。关闭后可验证是否真实 TSF 上屏。 */ allowNonTsfInsertionFallback: boolean; /** 用户的工作语言(多选,原生名);作为前提注入 LLM polish/translate prompt 头部。 */ diff --git a/openless-all/app/src/pages/Settings.tsx b/openless-all/app/src/pages/Settings.tsx index 95f68e60..cbe1e876 100644 --- a/openless-all/app/src/pages/Settings.tsx +++ b/openless-all/app/src/pages/Settings.tsx @@ -49,6 +49,7 @@ import type { HotkeyStatus, HotkeyTrigger, MicrophoneDevice, + PasteShortcut, PermissionStatus, WindowsImeStatus, } from '../lib/types'; @@ -268,6 +269,8 @@ function RecordingSection() { savePrefs({ ...prefs, microphoneDeviceName }); const onRestoreClipboardChange = (restoreClipboardAfterPaste: boolean) => savePrefs({ ...prefs, restoreClipboardAfterPaste }); + const onPasteShortcutChange = (pasteShortcut: PasteShortcut) => + savePrefs({ ...prefs, pasteShortcut }); const onAllowNonTsfFallbackChange = (allowNonTsfInsertionFallback: boolean) => savePrefs({ ...prefs, allowNonTsfInsertionFallback }); // 历史保留 / 对话感知 polish 上下文窗口都用裸 number input;空字符串时回滚到默认值。 @@ -434,6 +437,22 @@ function RecordingSection() { > + {capability.adapter !== 'macEventTap' && ( + + + + )} {capability.adapter === 'windowsLowLevel' && ( Date: Sat, 9 May 2026 13:24:14 +0800 Subject: [PATCH 2/2] fix(insertion): use slice index instead of TakeWhile::rev for partial release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `TakeWhile` 不实现 `DoubleEndedIterator`,所以 `.take_while(..).rev()` 在 Linux target 上编译不过(macOS host 因为整段 cfg(not(macos)) 没编译,所以 之前 cargo check 没暴露)。改用 `modifiers[..pressed].iter().rev()`:切片的 Iter 实现 DoubleEndedIterator,可以安全 `.rev()`。 顺手简化错误恢复路径:用一个 `first_err` 累加器,press → click → release 三段任何一段先报错都先记下来,最后一次性返回。语义跟原版 simulate_paste 对齐,不留卡键。 cargo test --lib 仍然 181 passed。 --- openless-all/app/src-tauri/src/insertion.rs | 37 ++++++++++++--------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/openless-all/app/src-tauri/src/insertion.rs b/openless-all/app/src-tauri/src/insertion.rs index e3285ed6..b48ece86 100644 --- a/openless-all/app/src-tauri/src/insertion.rs +++ b/openless-all/app/src-tauri/src/insertion.rs @@ -339,32 +339,39 @@ fn simulate_paste(shortcut: PasteShortcut) -> Result<(), String> { let (modifiers, primary) = paste_keys(shortcut); let mut enigo = Enigo::new(&Settings::default()).map_err(|e| e.to_string())?; - // 按下顺序:所有 modifier → 主键点击; - // 释放顺序:modifier 反向释放,确保即使中途出错也尽量把按键状态还原回来。 + // 跟原版 simulate_paste 保持同一行为:按下 modifier → 点击主键 → 反向释放 modifier。 + // 任何中途失败都尽量把已经按下的 modifier 反向释放回来,避免卡键。`pressed` + // 记录已经成功按下的 modifier 数;用切片 `modifiers[..pressed]` 控制释放范围 + // —— 切片自带 DoubleEndedIterator,可以放心 `.rev()`。 + let mut pressed = 0usize; + let mut first_err: Option = None; + for modifier in &modifiers { if let Err(e) = enigo.key(*modifier, Direction::Press) { - // 按下失败时反向释放已经按下的 modifier,避免卡键。 - for already_pressed in modifiers.iter().take_while(|m| *m != modifier).rev() { - let _ = enigo.key(*already_pressed, Direction::Release); - } - return Err(e.to_string()); + first_err = Some(e.to_string()); + break; } + pressed += 1; } - let click_result = enigo.key(primary, Direction::Click); + if first_err.is_none() { + if let Err(e) = enigo.key(primary, Direction::Click) { + first_err = Some(e.to_string()); + } + } - let mut release_err: Option = None; - for modifier in modifiers.iter().rev() { + for modifier in modifiers[..pressed].iter().rev() { if let Err(e) = enigo.key(*modifier, Direction::Release) { - release_err.get_or_insert_with(|| e.to_string()); + if first_err.is_none() { + first_err = Some(e.to_string()); + } } } - click_result.map_err(|e| e.to_string())?; - if let Some(err) = release_err { - return Err(err); + match first_err { + Some(err) => Err(err), + None => Ok(()), } - Ok(()) } #[cfg(target_os = "macos")]