fix(insertion): make Windows/Linux paste shortcut configurable (#360)
模拟粘贴硬编码 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 干净。
This commit is contained in:
1 parent
15442b5aad
commit
eaca99bdf4
12 files changed
+214
-26
No files matched your search
@@ -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<Inner>) -> 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<Inner>) -> 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<ImeSubmitTarget>,
|
||||
) -> 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<Inner>,
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ClipboardRestorePla
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn insert_with_clipboard_restore(text: &str, restore_clipboard_after_paste: bool) -> 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};
|
||||
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());
|
||||
fn paste_keys(shortcut: PasteShortcut) -> (Vec<enigo::Key>, 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())?;
|
||||
|
||||
// 按下顺序:所有 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<String> = 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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
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::<UserPreferences>(
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'ショートカットを記録',
|
||||
|
||||
@@ -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: '단축키 녹화',
|
||||
|
||||
@@ -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: '录制快捷键',
|
||||
|
||||
@@ -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: '歷史保留天數',
|
||||
|
||||
@@ -55,6 +55,7 @@ const mockSettings: UserPreferences = {
|
||||
activeAsrProvider: 'foundry-local-whisper',
|
||||
activeLlmProvider: 'ark',
|
||||
restoreClipboardAfterPaste: true,
|
||||
pasteShortcut: 'ctrlV',
|
||||
allowNonTsfInsertionFallback: true,
|
||||
workingLanguages: ['简体中文'],
|
||||
translationTargetLanguage: '',
|
||||
|
||||
@@ -22,6 +22,7 @@ const previousPrefs: UserPreferences = {
|
||||
activeAsrProvider: 'volcengine',
|
||||
activeLlmProvider: 'ark',
|
||||
restoreClipboardAfterPaste: true,
|
||||
pasteShortcut: 'ctrlV',
|
||||
allowNonTsfInsertionFallback: true,
|
||||
workingLanguages: ['简体中文'],
|
||||
translationTargetLanguage: '',
|
||||
|
||||
@@ -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 头部。 */
|
||||
|
||||
@@ -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() {
|
||||
>
|
||||
<Toggle on={prefs.restoreClipboardAfterPaste} onToggle={onRestoreClipboardChange} />
|
||||
</SettingRow>
|
||||
{capability.adapter !== 'macEventTap' && (
|
||||
<SettingRow
|
||||
label={t('settings.recording.pasteShortcutLabel')}
|
||||
desc={t('settings.recording.pasteShortcutDesc')}
|
||||
>
|
||||
<select
|
||||
value={prefs.pasteShortcut}
|
||||
onChange={e => onPasteShortcutChange(e.target.value as PasteShortcut)}
|
||||
style={{ ...inputStyle, maxWidth: 220 }}
|
||||
>
|
||||
<option value="ctrlV">{t('settings.recording.pasteShortcutCtrlV')}</option>
|
||||
<option value="ctrlShiftV">{t('settings.recording.pasteShortcutCtrlShiftV')}</option>
|
||||
<option value="shiftInsert">{t('settings.recording.pasteShortcutShiftInsert')}</option>
|
||||
</select>
|
||||
</SettingRow>
|
||||
)}
|
||||
{capability.adapter === 'windowsLowLevel' && (
|
||||
<SettingRow
|
||||
label={t('settings.recording.allowNonTsfFallbackLabel')}
|
||||
|
||||
Reference in new issue
Block a user