diff --git a/openless-all/app/src-tauri/src/hotkey.rs b/openless-all/app/src-tauri/src/hotkey.rs index 944c21f9..6075f8e4 100644 --- a/openless-all/app/src-tauri/src/hotkey.rs +++ b/openless-all/app/src-tauri/src/hotkey.rs @@ -1221,9 +1221,11 @@ mod platform { fn update_modifier_shortcuts( &self, - _qa_trigger: Option, - _translation_trigger: Option, + qa_trigger: Option, + translation_trigger: Option, ) { + crate::linux_fcitx::sync_qa_binding(qa_trigger); + crate::linux_fcitx::sync_translation_binding(translation_trigger); } fn reset_held_state(&self) {} diff --git a/openless-all/app/src-tauri/src/linux_fcitx.rs b/openless-all/app/src-tauri/src/linux_fcitx.rs index a02d7827..1351b75d 100644 --- a/openless-all/app/src-tauri/src/linux_fcitx.rs +++ b/openless-all/app/src-tauri/src/linux_fcitx.rs @@ -56,39 +56,108 @@ pub fn set_hotkey_raw(sym: u32, states: u32) -> Result<(), String> { Ok(()) } -/// X11 keysym 值(用于 SetHotkeyRaw,绕过 Key::parse 的修饰键限制)。 +/// 通过 fcitx5 插件设置 QA 面板快捷键 sym + states。 +pub fn set_qa_hotkey_raw(sym: u32, states: u32) -> Result<(), String> { + let conn = dbus::blocking::Connection::new_session() + .map_err(|e| format!("dbus session: {e}"))?; + let msg = dbus::Message::new_method_call(DEST, PATH, IFACE, "SetQaHotkeyRaw") + .map_err(|e| format!("build msg: {e}"))? + .append2(sym, states); + conn.send_with_reply_and_block(msg, TIMEOUT) + .map_err(|e| format!("SetQaHotkeyRaw: {e}"))?; + Ok(()) +} + +/// 通过 fcitx5 插件设置翻译模式修饰键 sym + states。 +pub fn set_translation_hotkey_raw(sym: u32, states: u32) -> Result<(), String> { + let conn = dbus::blocking::Connection::new_session() + .map_err(|e| format!("dbus session: {e}"))?; + let msg = dbus::Message::new_method_call(DEST, PATH, IFACE, "SetTranslationHotkeyRaw") + .map_err(|e| format!("build msg: {e}"))? + .append2(sym, states); + conn.send_with_reply_and_block(msg, TIMEOUT) + .map_err(|e| format!("SetTranslationHotkeyRaw: {e}"))?; + Ok(()) +} + +/// X11 keysym 值(用于 SetHotkeyRaw / SetQaHotkeyRaw / SetTranslationHotkeyRaw, +/// 绕过 Key::parse 的修饰键限制)。 const KEYSYM_CONTROL_R: u32 = 0xffe4; const KEYSYM_CONTROL_L: u32 = 0xffe3; const KEYSYM_ALT_R: u32 = 0xffea; const KEYSYM_ALT_L: u32 = 0xffe9; const KEYSYM_SUPER_R: u32 = 0xffec; const KEYSYM_SUPER_L: u32 = 0xffeb; +const KEYSYM_SHIFT_R: u32 = 0xffe2; +const KEYSYM_SHIFT_L: u32 = 0xffe1; -/// 将 OpenLess 的热键绑定同步到 fcitx5 插件。 -/// -/// 把 `HotkeyTrigger`(如 RightControl)转换为 fcitx5 keysym, -/// 通过 `SetHotkeyRaw` 配置插件(绕过 fcitx5 Key 类对纯修饰键的限制)。 +/// 将 HotkeyTrigger 转换为 X11 keysym。 +fn trigger_to_keysym(trigger: crate::types::HotkeyTrigger) -> u32 { + match trigger { + crate::types::HotkeyTrigger::RightControl => KEYSYM_CONTROL_R, + crate::types::HotkeyTrigger::LeftControl => KEYSYM_CONTROL_L, + crate::types::HotkeyTrigger::RightOption | crate::types::HotkeyTrigger::RightAlt => KEYSYM_ALT_R, + crate::types::HotkeyTrigger::LeftOption => KEYSYM_ALT_L, + crate::types::HotkeyTrigger::RightCommand => KEYSYM_SUPER_R, + crate::types::HotkeyTrigger::Fn => KEYSYM_SUPER_L, + crate::types::HotkeyTrigger::Custom => unreachable!(), + } +} + +fn trigger_name(trigger: crate::types::HotkeyTrigger) -> &'static str { + match trigger { + crate::types::HotkeyTrigger::RightControl => "Control_R", + crate::types::HotkeyTrigger::LeftControl => "Control_L", + crate::types::HotkeyTrigger::RightOption | crate::types::HotkeyTrigger::RightAlt => "Alt_R", + crate::types::HotkeyTrigger::LeftOption => "Alt_L", + crate::types::HotkeyTrigger::RightCommand => "Super_R", + crate::types::HotkeyTrigger::Fn => "Super_L", + crate::types::HotkeyTrigger::Custom => unreachable!(), + } +} + +/// 将 OpenLess 的主听写热键绑定同步到 fcitx5 插件。 pub fn sync_binding_to_plugin(binding: &crate::types::HotkeyBinding) { if binding.trigger == crate::types::HotkeyTrigger::Custom { return; } - let (sym, name) = match binding.trigger { - crate::types::HotkeyTrigger::RightControl => (KEYSYM_CONTROL_R, "Control_R"), - crate::types::HotkeyTrigger::LeftControl => (KEYSYM_CONTROL_L, "Control_L"), - crate::types::HotkeyTrigger::RightOption | crate::types::HotkeyTrigger::RightAlt => { - (KEYSYM_ALT_R, "Alt_R") - } - crate::types::HotkeyTrigger::LeftOption => (KEYSYM_ALT_L, "Alt_L"), - crate::types::HotkeyTrigger::RightCommand => (KEYSYM_SUPER_R, "Super_R"), - crate::types::HotkeyTrigger::Fn => (KEYSYM_SUPER_L, "Super_L"), - crate::types::HotkeyTrigger::Custom => unreachable!(), - }; + let sym = trigger_to_keysym(binding.trigger); + let name = trigger_name(binding.trigger); match set_hotkey_raw(sym, 0) { Ok(()) => log::info!("[fcitx] Synced hotkey {name} (sym={sym}) to plugin via SetHotkeyRaw"), Err(e) => log::warn!("[fcitx] Failed to sync hotkey to plugin: {e}"), } } +/// 将 QA 面板快捷键同步到 fcitx5 插件。 +pub fn sync_qa_binding(trigger: Option) { + let Some(trigger) = trigger else { + // 无 QA 快捷键时清空插件端配置 + let _ = set_qa_hotkey_raw(0, 0); + return; + }; + let sym = trigger_to_keysym(trigger); + let name = trigger_name(trigger); + match set_qa_hotkey_raw(sym, 0) { + Ok(()) => log::info!("[fcitx] Synced QA hotkey {name} (sym={sym}) to plugin via SetQaHotkeyRaw"), + Err(e) => log::warn!("[fcitx] Failed to sync QA hotkey to plugin: {e}"), + } +} + +/// 将翻译模式快捷键同步到 fcitx5 插件。 +pub fn sync_translation_binding(trigger: Option) { + let Some(trigger) = trigger else { + let _ = set_translation_hotkey_raw(0, 0); + return; + }; + let sym = trigger_to_keysym(trigger); + let name = trigger_name(trigger); + match set_translation_hotkey_raw(sym, 0) { + Ok(()) => log::info!("[fcitx] Synced translation hotkey {name} (sym={sym}) to plugin via SetTranslationHotkeyRaw"), + Err(e) => log::warn!("[fcitx] Failed to sync translation hotkey to plugin: {e}"), + } +} + /// 快速检查 fcitx5 OpenLess 插件是否可用(DBus 对象存在)。 pub fn available() -> bool { let conn = match dbus::blocking::Connection::new_session() { @@ -110,10 +179,6 @@ pub fn available() -> bool { /// 本函数将此信号转发为 `HotkeyEvent::Pressed` / `Released` 到协调器事件通道。 /// /// 后台线程在 `tx` 全部 drop(协调器关闭)或 DBus 连接断开时自动退出。 -/// -/// # 调用时机 -/// -/// Linux 热键源:通过 DBus 监听 fcitx5 插件的 DictationKeyEvent 信号。 #[cfg(target_os = "linux")] pub fn start_dictation_signal_listener( tx: std::sync::mpsc::Sender, @@ -131,10 +196,10 @@ pub fn start_dictation_signal_listener( } }; + // 同时监听所有三个信号 let rule = match dbus::message::MatchRule::parse( "type='signal',\ - interface='org.fcitx.Fcitx.OpenLess1',\ - member='DictationKeyEvent'", + interface='org.fcitx.Fcitx.OpenLess1'", ) { Ok(r) => r, Err(e) => { @@ -143,24 +208,35 @@ pub fn start_dictation_signal_listener( } }; - // 信号参数: (sym: u32, states: u32, is_press: bool) - let match_result = conn.add_match(rule, move |args: (u32, u32, bool), _conn, _msg| { - let (_sym, _states, is_press) = args; + let tx2 = tx.clone(); + let _match = match conn.add_match(rule, move |args: (u32, u32, bool), _conn, msg| { + let (sym, states, is_press) = args; + let member = msg.member(); + let member_str: String = member.as_ref().map(|m| m.to_string()).unwrap_or_default(); log::debug!( - "[fcitx-hotkey] DictationKeyEvent: sym={}, states={}, isPress={}", - _sym, - _states, - is_press, + "[fcitx-hotkey] Signal {}: sym={}, states={}, isPress={}", + member_str, sym, states, is_press, ); - let event = if is_press { - crate::hotkey::HotkeyEvent::Pressed - } else { - crate::hotkey::HotkeyEvent::Released - }; - let _ = tx.send(event); - true // 保持匹配活跃 - }); - let _match = match match_result { + if let Some(member) = member { + if member == "DictationKeyEvent" { + let event = if is_press { + crate::hotkey::HotkeyEvent::Pressed + } else { + crate::hotkey::HotkeyEvent::Released + }; + let _ = tx.send(event); + } else if member == "QaShortcutEvent" { + if is_press { + let _ = tx2.send(crate::hotkey::HotkeyEvent::QaShortcutPressed); + } + } else if member == "TranslationModifierEvent" { + if is_press { + let _ = tx2.send(crate::hotkey::HotkeyEvent::TranslationModifierPressed); + } + } + } + true + }) { Ok(m) => m, Err(e) => { log::warn!("[fcitx-hotkey] Failed to add match: {e}"); @@ -168,7 +244,7 @@ pub fn start_dictation_signal_listener( } }; - log::info!("[fcitx-hotkey] Listening for DictationKeyEvent signals"); + log::info!("[fcitx-hotkey] Listening for OpenLess1 signals"); loop { if let Err(e) = conn.process(Duration::from_millis(500)) { log::warn!("[fcitx-hotkey] DBus process error: {e}"); diff --git a/openless-all/scripts/linux-fcitx5-plugin/openless.cpp b/openless-all/scripts/linux-fcitx5-plugin/openless.cpp index 94bc67d5..82800369 100644 --- a/openless-all/scripts/linux-fcitx5-plugin/openless.cpp +++ b/openless-all/scripts/linux-fcitx5-plugin/openless.cpp @@ -7,13 +7,18 @@ * * DBus 接口: org.fcitx.Fcitx.OpenLess1 (对象路径 /openless) * 方法: - * CommitText(s: text) — 将文字提交到当前焦点输入上下文 - * SetHotkey(as: keys) — 设置听写触发快捷键 (Key::parse 格式) - * SetHotkeyRaw(uu: sym, states) — 直接设 sym+states (不走 parse) + * CommitText(s: text) — 将文字提交到当前焦点输入上下文 + * 安全性:本接口在会话总线(session bus)上对同用户 + * 所有进程开放,此为 fcitx5/IBus 体系的标准安全模型 + * (非特权进程隔离)。 + * SetHotkey(as: keys) — 设置听写触发快捷键 (Key::parse 格式) + * SetHotkeyRaw(uu: sym, states) — 直接设听写触发 sym+states (不走 parse) + * SetQaHotkeyRaw(uu: sym, states) — 直接设 QA 面板触发 sym+states + * SetTranslationHotkeyRaw(uu: sym, states) — 直接设翻译模式触发 sym+states * 信号: - * DictationKeyEvent(uu: sym, states) — 热键被按下 - * - * 后续: 当需要 IBus 引擎兼容时 (GNOME),另行实现 org.freedesktop.IBus.Engine。 + * DictationKeyEvent(uub: sym, states, isPress) — 听写热键按下/抬起 + * QaShortcutEvent(uub: sym, states, isPress) — QA 快捷键按下/抬起 + * TranslationModifierEvent(uub: sym, states, isPress) — 翻译修饰键按下/抬起 */ #include @@ -57,6 +62,10 @@ public: : instance_(instance), triggerRawSym_(0), triggerRawStates_(0), + qaRawSym_(0), + qaRawStates_(0), + translationRawSym_(0), + translationRawStates_(0), savedIc_(nullptr) { // 1. 读取配置 @@ -91,34 +100,73 @@ public: auto &keyEvent = static_cast(event); // 保存当前输入上下文:快捷键按下时用户在目标 app 中, // 此后胶囊窗口可能抢走焦点,但 commitText 仍能用此 IC 提交文字。 - // IC 销毁时通过 InputContextDestroyed 事件自动清空指针(见下方)。 if (!keyEvent.isRelease()) { savedIc_ = keyEvent.inputContext(); } - // 先检查 raw sym/states(修饰键专用路径,绕过 Key::parse 限制) + + auto sym = static_cast(keyEvent.key().sym()); + auto states = static_cast(keyEvent.key().states()); + bool isPress = !keyEvent.isRelease(); + + // 检查听写触发键(raw + keylist 双路径) + bool dictationMatched = false; if ((triggerRawSym_ != 0 && - keyEvent.key().sym() == static_cast(triggerRawSym_) && - keyEvent.key().states() == static_cast(triggerRawStates_)) || + sym == triggerRawSym_ && + states == triggerRawStates_) || (triggerRawSym_ == 0 && [&]() { for (const auto &hk : triggerKeyList_) { - if (keyEvent.key().sym() == hk.sym() && - keyEvent.key().states() == hk.states()) + if (sym == static_cast(hk.sym()) && + states == static_cast(hk.states())) return true; } return false; }())) { - auto sym = triggerRawSym_ != 0 + dictationMatched = true; + auto dsym = triggerRawSym_ != 0 ? triggerRawSym_ : static_cast(triggerKeyList_[0].sym()); - auto states = triggerRawStates_ != 0 + auto dstates = triggerRawStates_ != 0 ? triggerRawStates_ : static_cast(triggerKeyList_[0].states()); - bool isPress = !keyEvent.isRelease(); FCITX_LOGC(openless, Debug) - << "Dictation hotkey: sym=" - << sym << " states=" << states + << "Dictation hotkey: sym=" << dsym + << " states=" << dstates << " isPress=" << isPress; - dictationKeyEvent(sym, states, isPress); + dictationKeyEvent(dsym, dstates, isPress); + keyEvent.filterAndAccept(); + return; + } + + // 检查 QA 快捷键 + if (qaRawSym_ != 0 && + sym == qaRawSym_ && + states == qaRawStates_) { + FCITX_LOGC(openless, Debug) + << "QA shortcut: sym=" << qaRawSym_ + << " states=" << qaRawStates_ + << " isPress=" << isPress; + qaShortcutEvent(qaRawSym_, qaRawStates_, isPress); + keyEvent.filterAndAccept(); + return; + } + + // 检查翻译模式修饰键(自定义 + 内置 Shift) + bool translationMatched = false; + if (translationRawSym_ != 0 && + sym == translationRawSym_ && + states == translationRawStates_) { + translationMatched = true; + } + // 内置 Shift 修饰键 + if (sym == 0xffe1 || sym == 0xffe2) { + translationMatched = true; + } + if (translationMatched) { + FCITX_LOGC(openless, Debug) + << "Translation modifier: sym=" << sym + << " states=" << states + << " isPress=" << isPress; + translationModifierEvent(sym, states, isPress); keyEvent.filterAndAccept(); return; } @@ -217,16 +265,44 @@ public: rebuildTriggerKeys(); } + void setQaHotkeyRaw(uint32_t sym, uint32_t states) { + qaRawSym_ = sym; + qaRawStates_ = states; + RawConfig raw; + readAsIni(raw, configFile()); + raw.setValueByPath("QaRawSym", std::to_string(sym)); + raw.setValueByPath("QaRawStates", std::to_string(states)); + safeSaveAsIni(raw, configFile()); + FCITX_LOGC(openless, Info) + << "SetQaHotkeyRaw: sym=" << sym << " states=" << states; + } + + void setTranslationHotkeyRaw(uint32_t sym, uint32_t states) { + translationRawSym_ = sym; + translationRawStates_ = states; + RawConfig raw; + readAsIni(raw, configFile()); + raw.setValueByPath("TranslationRawSym", std::to_string(sym)); + raw.setValueByPath("TranslationRawStates", std::to_string(states)); + safeSaveAsIni(raw, configFile()); + FCITX_LOGC(openless, Info) + << "SetTranslationHotkeyRaw: sym=" << sym << " states=" << states; + } + FCITX_OBJECT_VTABLE_METHOD(commitText, "CommitText", "s", ""); FCITX_OBJECT_VTABLE_METHOD(setHotkey, "SetHotkey", "as", ""); FCITX_OBJECT_VTABLE_METHOD(setHotkeyRaw, "SetHotkeyRaw", "uu", ""); + FCITX_OBJECT_VTABLE_METHOD(setQaHotkeyRaw, "SetQaHotkeyRaw", "uu", ""); + FCITX_OBJECT_VTABLE_METHOD(setTranslationHotkeyRaw, "SetTranslationHotkeyRaw", "uu", ""); FCITX_OBJECT_VTABLE_SIGNAL(dictationKeyEvent, "DictationKeyEvent", "uub"); + FCITX_OBJECT_VTABLE_SIGNAL(qaShortcutEvent, "QaShortcutEvent", "uub"); + FCITX_OBJECT_VTABLE_SIGNAL(translationModifierEvent, "TranslationModifierEvent", "uub"); Instance *instance() { return instance_; } void reloadConfig() override { readAsIni(config_, configFile()); - // 加载原始 sym/states(由 SetHotkeyRaw 写入的持久化键值) + // 加载原始 sym/states(由 SetHotkeyRaw / SetQaHotkeyRaw / SetTranslationHotkeyRaw 写入的持久化键值) RawConfig raw; readAsIni(raw, configFile()); { @@ -237,6 +313,22 @@ public: auto *v = raw.valueByPath("TriggerRawStates"); triggerRawStates_ = v ? std::stoul(*v, nullptr, 0) : 0; } + { + auto *v = raw.valueByPath("QaRawSym"); + qaRawSym_ = v ? std::stoul(*v, nullptr, 0) : 0; + } + { + auto *v = raw.valueByPath("QaRawStates"); + qaRawStates_ = v ? std::stoul(*v, nullptr, 0) : 0; + } + { + auto *v = raw.valueByPath("TranslationRawSym"); + translationRawSym_ = v ? std::stoul(*v, nullptr, 0) : 0; + } + { + auto *v = raw.valueByPath("TranslationRawStates"); + translationRawStates_ = v ? std::stoul(*v, nullptr, 0) : 0; + } rebuildTriggerKeys(); } @@ -264,6 +356,10 @@ private: KeyList triggerKeyList_; uint32_t triggerRawSym_; uint32_t triggerRawStates_; + uint32_t qaRawSym_; + uint32_t qaRawStates_; + uint32_t translationRawSym_; + uint32_t translationRawStates_; /// 快捷键按下时保存的输入上下文指针,用于 commitText 在失焦后仍能提交文字。 /// 事件处理线程和 DBus 处理线程都是 fcitx5 主事件循环,无竞态。 /// 通过 InputContextDestroyed 事件监听 IC 销毁时自动清空指针。