fix(linux): add QA and translation modifier hotkey support to fcitx5 plugin

Extend the fcitx5 plugin with SetQaHotkeyRaw/SetTranslationHotkeyRaw
DBus methods and corresponding signals, so that QA panel toggle and
translation modifier hotkeys work on Linux (not just the main dictation
hotkey). Built-in Shift key translation modifier also handled by the
plugin. Document DBus security model in plugin header.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
aeoformandClaude Opus 4.7 committed 2026-05-16 09:21:38 +08:00
1 parent b349a4dcba
commit 323dbed401
3 files changed
+234 -60

No files matched your search

+4 -2
View File
@@ -1221,9 +1221,11 @@ mod platform {
fn update_modifier_shortcuts(
&self,
_qa_trigger: Option<HotkeyTrigger>,
_translation_trigger: Option<HotkeyTrigger>,
qa_trigger: Option<HotkeyTrigger>,
translation_trigger: Option<HotkeyTrigger>,
) {
crate::linux_fcitx::sync_qa_binding(qa_trigger);
crate::linux_fcitx::sync_translation_binding(translation_trigger);
}
fn reset_held_state(&self) {}
+115 -39
View File
@@ -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<crate::types::HotkeyTrigger>) {
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<crate::types::HotkeyTrigger>) {
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<crate::hotkey::HotkeyEvent>,
@@ -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}");
@@ -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 <memory>
@@ -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<KeyEvent &>(event);
// 保存当前输入上下文:快捷键按下时用户在目标 app 中,
// 此后胶囊窗口可能抢走焦点,但 commitText 仍能用此 IC 提交文字。
// IC 销毁时通过 InputContextDestroyed 事件自动清空指针(见下方)。
if (!keyEvent.isRelease()) {
savedIc_ = keyEvent.inputContext();
}
// 先检查 raw sym/states(修饰键专用路径,绕过 Key::parse 限制)
auto sym = static_cast<uint32_t>(keyEvent.key().sym());
auto states = static_cast<uint32_t>(keyEvent.key().states());
bool isPress = !keyEvent.isRelease();
// 检查听写触发键(raw + keylist 双路径)
bool dictationMatched = false;
if ((triggerRawSym_ != 0 &&
keyEvent.key().sym() == static_cast<KeySym>(triggerRawSym_) &&
keyEvent.key().states() == static_cast<KeyStates>(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<uint32_t>(hk.sym()) &&
states == static_cast<uint32_t>(hk.states()))
return true;
}
return false;
}())) {
auto sym = triggerRawSym_ != 0
dictationMatched = true;
auto dsym = triggerRawSym_ != 0
? triggerRawSym_
: static_cast<uint32_t>(triggerKeyList_[0].sym());
auto states = triggerRawStates_ != 0
auto dstates = triggerRawStates_ != 0
? triggerRawStates_
: static_cast<uint32_t>(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 销毁时自动清空指针。