Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions openless-all/app/src-tauri/src/coordinator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ use crate::types::{
HotkeyStatus, HotkeyStatusState, InsertStatus, OutputLanguagePreference, PolishMode,
};
#[cfg(target_os = "windows")]
use crate::types::PasteShortcut;
#[cfg(target_os = "windows")]
use crate::windows_ime_ipc::ImeSubmitTarget;
#[cfg(target_os = "windows")]
use crate::windows_ime_session::{PreparedWindowsImeSession, WindowsImeSessionController};
Expand Down Expand Up @@ -1674,6 +1676,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 = {
Expand All @@ -1686,7 +1689,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;
Expand All @@ -1711,7 +1714,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
Expand All @@ -1731,14 +1734,15 @@ 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");
InsertStatus::Inserted
} else {
inner
.inserter
.insert_via_clipboard_fallback(polished, restore_clipboard)
.insert_via_clipboard_fallback(polished, restore_clipboard, paste_shortcut)
}
}

Expand Down
4 changes: 3 additions & 1 deletion openless-all/app/src-tauri/src/coordinator/dictation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1009,6 +1009,7 @@ pub(super) 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 = prefs.paste_shortcut;
let status = if focus_ready_for_paste {
#[cfg(target_os = "windows")]
{
Expand All @@ -1019,13 +1020,14 @@ pub(super) 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!(
Expand Down
124 changes: 103 additions & 21 deletions openless-all/app/src-tauri/src/insertion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -38,21 +38,29 @@ 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"))]
pub fn insert_via_clipboard_fallback(
&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")]
Expand All @@ -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;
}
Expand Down Expand Up @@ -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) => {
Expand All @@ -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;
}
Expand Down Expand Up @@ -302,21 +321,57 @@ 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>, 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());

// 跟原版 simulate_paste 保持同一行为:按下 modifier → 点击主键 → 反向释放 modifier。
// 任何中途失败都尽量把已经按下的 modifier 反向释放回来,避免卡键。`pressed`
// 记录已经成功按下的 modifier 数;用切片 `modifiers[..pressed]` 控制释放范围
// —— 切片自带 DoubleEndedIterator,可以放心 `.rev()`。
let mut pressed = 0usize;
let mut first_err: Option<String> = None;

for modifier in &modifiers {
if let Err(e) = enigo.key(*modifier, Direction::Press) {
first_err = Some(e.to_string());
break;
}
pressed += 1;
}

if first_err.is_none() {
if let Err(e) = enigo.key(primary, Direction::Click) {
first_err = Some(e.to_string());
}
}

for modifier in modifiers[..pressed].iter().rev() {
if let Err(e) = enigo.key(*modifier, Direction::Release) {
if first_err.is_none() {
first_err = Some(e.to_string());
}
}
}

match first_err {
Some(err) => Err(err),
None => Ok(()),
}
press_v.map_err(|e| e.to_string())?;
Ok(())
}

#[cfg(target_os = "macos")]
Expand Down Expand Up @@ -474,15 +529,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
);
}
Expand Down
50 changes: 50 additions & 0 deletions openless-all/app/src-tauri/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 对齐。
Expand Down Expand Up @@ -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")]
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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>(
Expand Down
5 changes: 5 additions & 0 deletions openless-all/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
5 changes: 5 additions & 0 deletions openless-all/app/src/i18n/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: 'ショートカットを記録',
Expand Down
Loading
Loading