From 61ca4b4150eebda5ef60dd47b4bac4a2ef331d08 Mon Sep 17 00:00:00 2001 From: yenharvey Date: Wed, 4 Nov 2026 16:18:34 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=AC=A6=E5=8F=B7=E9=9D=A2=E6=9D=BF?= =?UTF-8?q?=E5=8A=A0=E3=80=8C=E7=BF=BB=E8=AF=91=E3=80=8D=E7=BB=84=EF=BC=8C?= =?UTF-8?q?=E5=88=A0=E6=8E=89=20Windows=20=E7=9A=84=E6=95=B4=E5=8F=A5?= =?UTF-8?q?=E5=80=99=E9=80=89=E7=BF=BB=E8=AF=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 整句候选翻译译的是猜出来的候选,猜错就白发;改成用户在面板的框里自己打一句、停键后再译,整句只发确认过的内容 - 翻译组排在最前,每种目标语言算一个分类([ ] 换),打拼音时按 ↑ 直接落在这组;没在打字时 [shortcut] translate_panel(Ctrl+Alt+Y / ⌃⌥Y)打开 - Core panel/translate:原文带光标,停键 800 ms、两个字以上才发,内容没变不重发,没翻出来 2 秒后自动重试一次、再失败等回车;译文按原文 + 语言缓存在内存(换提供方清空),不落盘 - 译文放进应用的 preedit:Windows 译文靠轮询送到,DLL 记着写进文档的组句文字,变了就补一次编辑会话;行内不显示拼音的设置下也照样放 - 框的排版抽成 bamboo-slip 的 TextFlow,与设置程序的 TextField 共用;面板不是 Ui 搭的,布局后按各字位置给出 PanelHit::Caret - [predict] sentence_translation 不再认(旧配置里写着不报错),两个设置程序的开关与文档一并去掉;生词本整句翻译保留 - Windows dispatch/composed/mod.rs 超了 800 行,按联想、导航、出帧拆开 - 初音主题当前分组胶囊改纯色 --- apps/android/core/src/cloud/settings.rs | 1 - apps/linux/server/src/assembly/cloud.rs | 2 +- apps/macos/src/candidates/bitmap/panel.rs | 2 + apps/macos/src/candidates/panel/mouse.rs | 6 + apps/macos/src/host/cloud/mod.rs | 22 +- apps/macos/src/host/cloud/predict_monitor.rs | 4 +- apps/macos/src/host/config/mod.rs | 1 + apps/macos/src/host/init.rs | 1 + apps/macos/src/host/mod.rs | 3 + apps/macos/src/host/presenting/panel.rs | 50 +- apps/macos/src/imk/controller/click.rs | 5 + apps/macos/src/imk/controller/display.rs | 31 +- apps/macos/src/imk/controller/mod.rs | 13 +- .../imk/controller/{panel.rs => panel/mod.rs} | 103 ++- .../src/imk/controller/panel/translate.rs | 207 ++++++ apps/macos/src/imk/mod.rs | 3 +- apps/settings/locales/de.toml | 2 - apps/settings/locales/en.toml | 4 +- apps/settings/locales/es.toml | 2 - apps/settings/locales/ja.toml | 2 - apps/settings/locales/vi.toml | 2 - apps/settings/locales/zh-Hans.toml | 4 +- apps/settings/locales/zh-Hant.toml | 4 +- apps/settings/src/docs.rs | 6 - apps/settings/src/settings/shortcuts.rs | 7 + apps/settings/src/shortcut.rs | 19 +- apps/settings/src/support/mod.rs | 10 +- apps/settings/src/view/pages/cloud.rs | 11 +- apps/settings/src/view/pages/shortcuts.rs | 9 + .../server/src/dispatch/composed/frame.rs | 269 +++++++ .../server/src/dispatch/composed/mod.rs | 697 +----------------- .../src/dispatch/composed/navigation.rs | 142 ++++ .../src/dispatch/composed/prediction.rs | 153 ++++ .../dispatch/composed/sentence_translation.rs | 21 - .../server/src/dispatch/composed/state.rs | 5 - .../server/src/dispatch/composed/tests.rs | 75 ++ apps/windows/server/src/dispatch/config.rs | 8 +- apps/windows/server/src/dispatch/key/codes.rs | 1 + .../dispatch/key/{panel.rs => panel/mod.rs} | 7 +- .../src/dispatch/key/panel/translate.rs | 163 ++++ .../server/src/dispatch/key/shortcut.rs | 1 - apps/windows/server/src/dispatch/message.rs | 20 + .../server/src/dispatch/panel/frame.rs | 5 +- apps/windows/server/src/dispatch/panel/mod.rs | 45 +- .../server/src/dispatch/panel/mouse.rs | 6 + .../windows/server/src/dispatch/reload/mod.rs | 6 +- .../server/src/dispatch/translate/mod.rs | 7 + apps/windows/server/src/ui/panel/mod.rs | 12 +- .../windows/server/tests/engine_loop/click.rs | 23 - .../windows/server/tests/engine_loop/cloud.rs | 21 - .../server/tests/engine_loop/composing.rs | 116 --- apps/windows/server/tests/engine_loop/main.rs | 1 + .../tests/engine_loop/panel_translate.rs | 112 +++ .../server/tests/engine_loop/prediction.rs | 6 +- .../server/tests/engine_loop/shortcuts.rs | 21 - apps/windows/settings/src/panel/component.rs | 3 - apps/windows/settings/src/panel/message.rs | 1 - .../windows/settings/src/panel/pages/cloud.rs | 7 - apps/windows/tsf/src/com/composition/mod.rs | 1 + .../windows/tsf/src/com/composition/shared.rs | 13 + apps/windows/tsf/src/com/key/preserved.rs | 21 +- apps/windows/tsf/src/com/poll/mod.rs | 30 + apps/windows/tsf/src/com/service/key_sink.rs | 2 + apps/windows/tsf/src/com/service/mod.rs | 4 + apps/windows/tsf/src/com/service/processor.rs | 17 +- crates/bamboo-slip/src/lib.rs | 8 +- crates/bamboo-slip/src/scene/mod.rs | 12 + crates/bamboo-slip/src/widgets/mod.rs | 2 +- .../src/widgets/text_field/flow.rs | 245 ++++++ .../bamboo-slip/src/widgets/text_field/mod.rs | 244 ++---- crates/qingjian-core/locales/en.toml | 9 + crates/qingjian-core/locales/zh-Hans.toml | 9 + crates/qingjian-core/locales/zh-Hant.toml | 9 + crates/qingjian-core/src/engine/commit/mod.rs | 2 +- crates/qingjian-core/src/engine/composing.rs | 2 +- crates/qingjian-core/src/engine/mod.rs | 16 +- .../src/engine/{panel.rs => panel/mod.rs} | 60 +- .../src/engine/panel/translate.rs | 219 ++++++ .../src/engine/prediction/mod.rs | 32 +- crates/qingjian-core/src/engine/setup.rs | 2 + crates/qingjian-core/src/engine/tests/mod.rs | 1 + .../src/engine/tests/panel_translate.rs | 170 +++++ crates/qingjian-core/src/panel/common.rs | 2 +- crates/qingjian-core/src/panel/group.rs | 4 +- crates/qingjian-core/src/panel/mod.rs | 6 +- crates/qingjian-core/src/panel/state.rs | 148 +++- .../src/panel/translate/cache.rs | 64 ++ .../src/panel/translate/draft.rs | 337 +++++++++ .../qingjian-core/src/panel/translate/job.rs | 11 + .../qingjian-core/src/panel/translate/mod.rs | 23 + .../src/panel/translate/status.rs | 16 + .../qingjian-platform/src/config/key_combo.rs | 5 + crates/qingjian-platform/src/config/mod.rs | 6 +- .../qingjian-platform/src/config/shortcut.rs | 4 + crates/qingjian-predict/src/config.rs | 4 - crates/qingjian-render/src/lib.rs | 2 +- crates/qingjian-render/src/panel/mod.rs | 5 + crates/qingjian-render/src/panel/translate.rs | 25 + .../qingjian-render/src/renderer/hit/panel.rs | 6 + .../qingjian-render/src/renderer/panel/mod.rs | 177 +++-- .../src/renderer/panel/tests.rs | 51 +- .../src/renderer/panel/translate.rs | 252 +++++++ crates/qingjian-render/themes/miku/theme.json | 2 +- docs/notes/crate-notes.md | 8 +- docs/plan/todo.md | 2 + docs/user/advanced/custom-themes.md | 2 +- docs/user/cloud/index.md | 4 +- docs/user/cloud/question-and-translate.md | 17 +- docs/user/cloud/sentence-translation.md | 12 +- docs/user/getting-started/keys.md | 7 +- docs/user/i18n/en/cloud/index.md | 4 +- .../i18n/en/cloud/question-and-translate.md | 17 +- .../i18n/en/cloud/sentence-translation.md | 12 +- docs/user/i18n/en/getting-started/keys.md | 7 +- docs/user/i18n/en/input/punctuation.md | 5 +- docs/user/i18n/en/platforms/differences.md | 2 +- docs/user/input/punctuation.md | 5 +- docs/user/platforms/differences.md | 2 +- 118 files changed, 3507 insertions(+), 1369 deletions(-) rename apps/macos/src/imk/controller/{panel.rs => panel/mod.rs} (66%) create mode 100644 apps/macos/src/imk/controller/panel/translate.rs create mode 100644 apps/windows/server/src/dispatch/composed/frame.rs create mode 100644 apps/windows/server/src/dispatch/composed/navigation.rs create mode 100644 apps/windows/server/src/dispatch/composed/prediction.rs delete mode 100644 apps/windows/server/src/dispatch/composed/sentence_translation.rs create mode 100644 apps/windows/server/src/dispatch/composed/tests.rs rename apps/windows/server/src/dispatch/key/{panel.rs => panel/mod.rs} (93%) create mode 100644 apps/windows/server/src/dispatch/key/panel/translate.rs create mode 100644 apps/windows/server/tests/engine_loop/panel_translate.rs create mode 100644 crates/bamboo-slip/src/widgets/text_field/flow.rs rename crates/qingjian-core/src/engine/{panel.rs => panel/mod.rs} (64%) create mode 100644 crates/qingjian-core/src/engine/panel/translate.rs create mode 100644 crates/qingjian-core/src/engine/tests/panel_translate.rs create mode 100644 crates/qingjian-core/src/panel/translate/cache.rs create mode 100644 crates/qingjian-core/src/panel/translate/draft.rs create mode 100644 crates/qingjian-core/src/panel/translate/job.rs create mode 100644 crates/qingjian-core/src/panel/translate/mod.rs create mode 100644 crates/qingjian-core/src/panel/translate/status.rs create mode 100644 crates/qingjian-render/src/panel/translate.rs create mode 100644 crates/qingjian-render/src/renderer/panel/translate.rs diff --git a/apps/android/core/src/cloud/settings.rs b/apps/android/core/src/cloud/settings.rs index d1e01cf5..0d102967 100644 --- a/apps/android/core/src/cloud/settings.rs +++ b/apps/android/core/src/cloud/settings.rs @@ -31,7 +31,6 @@ impl CloudSettings { api_key: Some(self.api_key.trim().to_owned()), slots: if self.predict { SLOTS } else { 0 }, sentence: self.predict, - sentence_translation: false, vocabulary_translation: false, timeout_ms: TIMEOUT_MS, debounce_ms: DEBOUNCE_MS, diff --git a/apps/linux/server/src/assembly/cloud.rs b/apps/linux/server/src/assembly/cloud.rs index e72a3e94..55b85064 100644 --- a/apps/linux/server/src/assembly/cloud.rs +++ b/apps/linux/server/src/assembly/cloud.rs @@ -8,7 +8,7 @@ use qingjian_predict::{CloudGlossFiller, CloudPredictor, PredictConfig}; /// 按 `[predict]` 接云联想、释义兜底与生词本整句翻译;关着或缺密钥就退回本地实现。 /// 用量累计到 `usage_path`(用户目录的 `api-usage.json`),设置统计页读同一份。启动与热加载共用。 pub fn attach_cloud(engine: &mut Engine, predict: &PredictConfig, usage_path: Option) { - if !predict.enabled && !predict.sentence_translation { + if !predict.enabled { engine.set_predictor(Box::new(NoPredictor)); engine.set_gloss_filler(Box::new(NoGlossFiller)); return; diff --git a/apps/macos/src/candidates/bitmap/panel.rs b/apps/macos/src/candidates/bitmap/panel.rs index 391e29af..57660488 100644 --- a/apps/macos/src/candidates/bitmap/panel.rs +++ b/apps/macos/src/candidates/bitmap/panel.rs @@ -113,6 +113,8 @@ impl PanelPainter { HitTarget::Panel(PanelHit::Cell(offset)) => Some(PanelMouse::Cell { offset, double }), HitTarget::Panel(PanelHit::Group(group)) => Some(PanelMouse::Group(group)), HitTarget::Panel(PanelHit::Section(section)) => Some(PanelMouse::Section(section)), + HitTarget::Panel(PanelHit::Candidate(index)) => Some(PanelMouse::Candidate(index)), + HitTarget::Panel(PanelHit::Caret(position)) => Some(PanelMouse::Caret(position)), HitTarget::Panel(PanelHit::Close) => Some(PanelMouse::Close), _ => None, } diff --git a/apps/macos/src/candidates/panel/mouse.rs b/apps/macos/src/candidates/panel/mouse.rs index 81e8b91e..83af43eb 100644 --- a/apps/macos/src/candidates/panel/mouse.rs +++ b/apps/macos/src/candidates/panel/mouse.rs @@ -14,6 +14,12 @@ pub enum PanelMouse { /// 滚轮 / 触控板滚了几行,正数往下。 Scroll(isize), + /// 点翻译框的原文:光标放到第几个字之后。 + Caret(usize), + + /// 点翻译框里当前一页的第几个候选:选进框里。 + Candidate(usize), + /// 点右上角的 ✕。 Close, } diff --git a/apps/macos/src/host/cloud/mod.rs b/apps/macos/src/host/cloud/mod.rs index 5d8c611e..e4172ccc 100644 --- a/apps/macos/src/host/cloud/mod.rs +++ b/apps/macos/src/host/cloud/mod.rs @@ -6,6 +6,8 @@ mod test_monitor; pub(super) use predict_monitor::PredictMonitor; pub(super) use test_monitor::CloudTestMonitor; +use std::time::Instant; + use super::*; impl Host { @@ -109,8 +111,12 @@ impl Host { } /// 定时器回调:结果到了就画上去。云端词补进第一页末尾,整句挂在 preedit 右侧。 - pub fn poll_prediction(&mut self) { - let Some(prediction) = self.engine.poll_prediction() else { + /// 符号面板的翻译框也借这个节拍防抖、收译文;返回翻译框有没有变化(变了要连 preedit 一起重画,得借文本客户端)。 + pub fn poll_prediction(&mut self) -> bool { + let prediction = self.engine.poll_prediction(); + let translated = self.engine.tick_panel_translation(Instant::now()); + let waiting = self.engine.panel_translation_pending(); + let Some(prediction) = prediction else { if self.monitor.expired() { if let Some(job) = self.candidate_translation.take() { self.engine.cancel_candidate_translation(job.sequence); @@ -119,12 +125,20 @@ impl Host { } self.monitor.stop(); } - return; + return translated; }; self.apply_prediction(prediction); - if self.candidate_translation.is_none() { + if self.candidate_translation.is_none() && !waiting { self.monitor.stop(); } + translated + } + + /// 翻译框的原文或语言变了:开始轮询,停键够久就发。 + pub fn watch_panel_translation(&mut self) { + if self.engine.panel_translation_pending() { + self.monitor.start(); + } } pub(super) fn apply_prediction(&mut self, prediction: Prediction) { diff --git a/apps/macos/src/host/cloud/predict_monitor.rs b/apps/macos/src/host/cloud/predict_monitor.rs index 85abe830..d47f86ad 100644 --- a/apps/macos/src/host/cloud/predict_monitor.rs +++ b/apps/macos/src/host/cloud/predict_monitor.rs @@ -74,7 +74,9 @@ define_class!( impl PredictTicker { #[unsafe(method(tick:))] fn tick(&self, _timer: Option<&AnyObject>) { - crate::host::with(|h| h.poll_prediction()); + if crate::host::with(|h| h.poll_prediction()).unwrap_or(false) { + crate::imk::panel_translation_changed(); + } } } diff --git a/apps/macos/src/host/config/mod.rs b/apps/macos/src/host/config/mod.rs index e34a4453..b8dced29 100644 --- a/apps/macos/src/host/config/mod.rs +++ b/apps/macos/src/host/config/mod.rs @@ -80,6 +80,7 @@ impl Host { } self.punctuation_keys = config.shortcut.toggle_punctuation; self.predict_keys = config.shortcut.predict; + self.translate_panel_keys = config.shortcut.translate_panel; self.page_size = config.general.page_size(); self.cloud_slots = config.predict.slots; self.page_keys = config.general.page_keys(); diff --git a/apps/macos/src/host/init.rs b/apps/macos/src/host/init.rs index 38993811..c9e991c9 100644 --- a/apps/macos/src/host/init.rs +++ b/apps/macos/src/host/init.rs @@ -192,6 +192,7 @@ pub fn init(mtm: MainThreadMarker, info: &BundleInfo) -> Result<(), HostError> { translate_keys: KeyCombo::TRANSLATE_DEFAULT, punctuation_keys: None, predict_keys: KeyCombo::PREDICT_DEFAULT, + translate_panel_keys: KeyCombo::TRANSLATE_PANEL_DEFAULT, manual_prediction: None, candidate_translation: None, translation: None, diff --git a/apps/macos/src/host/mod.rs b/apps/macos/src/host/mod.rs index 86bf7792..e4a11f84 100644 --- a/apps/macos/src/host/mod.rs +++ b/apps/macos/src/host/mod.rs @@ -186,6 +186,9 @@ pub struct Host { /// 组句中按需联想的组合键(配置 `[shortcut] predict`)。 pub predict_keys: KeyCombo, + /// 打开符号面板翻译组的组合键(`[shortcut] translate_panel`),缺省 ⌃⌥Y。 + pub translate_panel_keys: KeyCombo, + /// 在飞的按需联想请求序号:结果到了排在第一页最前面,自动联想的照旧补在末尾。 manual_prediction: Option, diff --git a/apps/macos/src/host/presenting/panel.rs b/apps/macos/src/host/presenting/panel.rs index 4825ad76..7ad365b4 100644 --- a/apps/macos/src/host/presenting/panel.rs +++ b/apps/macos/src/host/presenting/panel.rs @@ -1,21 +1,30 @@ -//! 符号面板的呈现:当前分类的条目排成格子,顶上两行标签,信息行放高亮条目的名字。 +//! 符号面板的呈现:当前分类的条目排成格子,顶上两行标签,信息行放高亮条目的名字; +//! 翻译组的格子区换成翻译框(原文、拼音与当前一页候选、状态)。 -use qingjian_core::GRID_ROWS; -use qingjian_render::{PanelCell, PanelFrame}; +use qingjian_core::{GRID_ROWS, PanelGroup, SymbolPanel}; +use qingjian_render::{PanelCell, PanelFrame, PanelTranslate}; use crate::candidates::Frame; use crate::host::Host; const HINT: &str = "Tab 换组 · [ ] 换类 · 双击或空格上屏"; +const TRANSLATE_HINT: &str = "Tab 换组 · [ ] 换语言 · 回车上屏"; + impl Host { - /// 面板刚打开或换了分类:格子换成当前分类的条目,高亮回到第一格。 - pub fn load_panel(&mut self) { + /// 面板刚打开或换了分类:格子换成当前分类的条目,高亮回到第一格。翻译组没有格子,会话里放拼音的候选。 + /// 返回停没停在翻译组(停在那里时壳要重查候选,见控制器的 `refresh`)。 + pub fn load_panel(&mut self) -> bool { + if self.engine.panel_translating() { + self.session.collapse(); + return true; + } let Some(section) = self.engine.symbol_panel().map(|p| p.section().clone()) else { - return; + return false; }; let columns = section.columns(); self.session.open_panel(section.items, columns); + false } /// 面板这一帧:视口里的几行格子与标签。格子不标序号,选择靠方向键加空格 / 回车。 @@ -25,8 +34,12 @@ impl Host { }; let groups = panel.groups(); let (sections, section) = panel.group_sections(); - let Some((cells, columns)) = self.session.grid_cells() else { - return; + let translating = panel.group() == PanelGroup::Translate; + let grid = self.session.grid_cells(); + let (cells, columns) = match grid { + Some(grid) => grid, + None if translating => (Vec::new(), 1), + None => return, }; let top = self.session.grid.map_or(0, |grid| grid.top()); let size = self.session.layout.page_size(); @@ -49,7 +62,8 @@ impl Host { rows: GRID_ROWS, highlight: self.session.highlighted.saturating_sub(top * size), footer: (pages > 1).then(|| format!("{}/{pages}", self.session.page + 1)), - hint: HINT.to_owned(), + hint: if translating { TRANSLATE_HINT } else { HINT }.to_owned(), + translate: translating.then(|| self.panel_translate(panel)), }; let frame = Frame { mode: self.input_mode(), @@ -58,4 +72,22 @@ impl Host { }; self.window.show(frame, self.anchor); } + + /// 翻译框:原文、还在打的拼音与它当前一页的候选。 + fn panel_translate(&self, panel: &SymbolPanel) -> PanelTranslate { + let size = self.session.layout.page_size(); + let first = self.session.page * size; + PanelTranslate { + source: panel.translate_source().to_owned(), + cursor: panel.translate_cursor(), + placeholder: panel.translate_placeholder().to_owned(), + composing: self.engine.composition().text().to_owned(), + candidates: (first..first + size) + .map_while(|index| self.session.candidate(index)) + .map(|candidate| candidate.text) + .collect(), + highlight: self.session.highlighted.saturating_sub(first), + status: panel.translate_line(), + } + } } diff --git a/apps/macos/src/imk/controller/click.rs b/apps/macos/src/imk/controller/click.rs index d4d21eae..e54a695d 100644 --- a/apps/macos/src/imk/controller/click.rs +++ b/apps/macos/src/imk/controller/click.rs @@ -59,6 +59,11 @@ pub fn commit_pending_after_switch() { }); } +/// 符号面板翻译框的译文到了或没翻出来(联想定时器回调):重画面板,译文写进应用的 preedit。 +pub fn panel_translation_changed() { + with_active(|controller, client| controller.render(client)); +} + /// 符号面板的点击回调(host 建窗口时注册)。 pub fn panel_mouse(hit: PanelMouse) { let Some(controller) = ACTIVE.with(|active| active.borrow().clone()) else { diff --git a/apps/macos/src/imk/controller/display.rs b/apps/macos/src/imk/controller/display.rs index cbaeeaaf..b118a8d8 100644 --- a/apps/macos/src/imk/controller/display.rs +++ b/apps/macos/src/imk/controller/display.rs @@ -8,7 +8,7 @@ use qingjian_core::candidate::arrows::{self, Arrow, ArrowAction}; use qingjian_platform::HighlightKeys; /// 拼音只在候选窗口显示时,组句中交给应用的 marked text。 -const HIDDEN_MARKED_TEXT: &str = "\u{200B}"; +pub(super) const HIDDEN_MARKED_TEXT: &str = "\u{200B}"; impl QingjianInputController { /// 按当前缓冲区重新查候选、更新 marked text,回到第一页并重画候选窗口。 @@ -65,12 +65,16 @@ impl QingjianInputController { }; // 配置成只在候选窗口显示拼音时,组句中放一个零宽空格当 marked text:微信 4.x 这类应用看不到 marked text // 就当输入法没在组句,自己再把字母、退格处理一遍(#815 #816) - if inline { - client.set_marked_text(&marked, cursor); - } else if marked.is_empty() { - client.set_marked_text("", 0); - } else { - client.set_marked_text(HIDDEN_MARKED_TEXT, 0); + // 符号面板的翻译组里拼音画在框里,marked text 是译文,由 render 写 + let translating = host::with(|h| h.engine.panel_translating()).unwrap_or(false); + if !translating { + if inline { + client.set_marked_text(&marked, cursor); + } else if marked.is_empty() { + client.set_marked_text("", 0); + } else { + client.set_marked_text(HIDDEN_MARKED_TEXT, 0); + } } // 先发联想再画:发出去就留好云端槽位,画出来的第一帧本地候选就已经在最终位置 if !marked.is_empty() { @@ -111,6 +115,19 @@ impl QingjianInputController { if let Some((text, cursor)) = marked { client.set_marked_text(&text, cursor); } + // 翻译组:应用里显示最近一条译文;还没有时放零宽空格,应用照样当在组句 + let translation = host::with(|h| { + h.engine + .panel_translating() + .then(|| h.engine.panel_preedit().map(str::to_owned)) + }) + .flatten(); + if let Some(translation) = translation { + match translation { + Some(text) => client.set_marked_text(&text, text.chars().count()), + None => client.set_marked_text(HIDDEN_MARKED_TEXT, 0), + } + } } /// 发一次联想请求。Secure Input 里绝不发;没接联想器时是空操作。 diff --git a/apps/macos/src/imk/controller/mod.rs b/apps/macos/src/imk/controller/mod.rs index df478af5..58b42919 100644 --- a/apps/macos/src/imk/controller/mod.rs +++ b/apps/macos/src/imk/controller/mod.rs @@ -30,7 +30,10 @@ mod trace; mod translate; mod vietnamese; -pub use click::{click, commit_pending_after_switch, menu_items, menu_pick, panel_mouse}; +pub use click::{ + click, commit_pending_after_switch, menu_items, menu_pick, panel_mouse, + panel_translation_changed, +}; define_class!( // SAFETY: @@ -351,6 +354,14 @@ impl QingjianInputController { let typed = event .charactersIgnoringModifiers() .map(|c| c.to_string().to_ascii_lowercase()); + // 翻译组快捷键(不在组句中):打开符号面板停在翻译组 + let translate_panel = host::with(|h| h.translate_panel_keys).unwrap_or_default(); + if pressed == translate_panel.modifiers + && typed.as_deref().and_then(|t| t.chars().next()) == Some(translate_panel.key) + && host::with(|h| h.engine.composition().is_empty()).unwrap_or(false) + { + return self.open_translate_panel(client); + } let combo = host::with(|h| h.translate_keys).unwrap_or_default(); if pressed == combo.modifiers && typed.as_deref().and_then(|t| t.chars().next()) == Some(combo.key) diff --git a/apps/macos/src/imk/controller/panel.rs b/apps/macos/src/imk/controller/panel/mod.rs similarity index 66% rename from apps/macos/src/imk/controller/panel.rs rename to apps/macos/src/imk/controller/panel/mod.rs index 0a2015b8..508f8e1e 100644 --- a/apps/macos/src/imk/controller/panel.rs +++ b/apps/macos/src/imk/controller/panel/mod.rs @@ -1,11 +1,14 @@ //! 符号面板的按键与点击:组句中高亮还在第一个时按 ↑ 打开(长按连发不算),开着时方向键在格子里移动、 //! Tab 换组、`[` `]` 换分类、空格 / 回车 / 双击上屏、Esc / 退格回到候选;敲字母先收起面板再照常组句。 +//! 翻译组里的按键另走 [`translate`]。 + +mod translate; use super::*; use crate::candidates::PanelMouse; /// 面板里按键归谁:`Some(吃没吃)` 是面板处理了,`None` 是面板已收起、按键照常往下走。 -type PanelKey = Option; +pub(super) type PanelKey = Option; impl QingjianInputController { /// 组句中、高亮还在第一页第一个而且这轮没动过:打开面板。返回开没开。 @@ -13,17 +16,56 @@ impl QingjianInputController { let opened = host::with(|h| { let untouched = !h.session.navigated && h.session.page == 0 && h.session.grid.is_none(); if !untouched || !h.engine.open_symbol_panel() { - return false; + return None; } - h.load_panel(); - true + Some(h.load_panel()) }) - .unwrap_or(false); - if opened { - tracing::debug!("打开符号面板"); + .flatten(); + let Some(translating) = opened else { + return false; + }; + tracing::debug!("打开符号面板"); + self.show_panel(translating, client); + true + } + + /// 翻译组快捷键:打开面板停在翻译组。翻译不可用时不吃键。 + pub(super) fn open_translate_panel(&self, client: TextClient<'_>) -> bool { + let opened = + host::with(|h| h.engine.open_translate_panel().then(|| h.load_panel())).flatten(); + let Some(translating) = opened else { + tracing::info!("translate panel unavailable: cloud translation is off"); + return false; + }; + self.show_panel(translating, client); + true + } + + /// 面板换了组或刚打开:停在翻译组要按拼音重查候选、写 preedit,别的组只重画。 + pub(super) fn show_panel(&self, translating: bool, client: TextClient<'_>) { + if translating { + host::with(|h| h.watch_panel_translation()); + self.refresh(client); + } else { self.render(client); } - opened + } + + /// 从翻译组换到别的组:应用里还是译文,换回拼音(别的组的格子不能重查,只换 marked text)。 + fn restore_marked(&self, client: TextClient<'_>) { + let marked = host::with(|h| { + let text = h.engine.composition().text().to_owned(); + (text, h.preedit_mode.inline()) + }); + if let Some((text, inline)) = marked { + if text.is_empty() { + client.set_marked_text("", 0); + } else if inline { + client.set_marked_text(&text, text.chars().count()); + } else { + client.set_marked_text(super::display::HIDDEN_MARKED_TEXT, 0); + } + } } /// 菜单里的「符号面板…」:不管在不在组句都打开,没敲字时没有搜索组。 @@ -34,16 +76,9 @@ impl QingjianInputController { return; }; let client = TextClient::new(&client); - let opened = host::with(|h| { - if !h.engine.open_symbol_panel() { - return false; - } - h.load_panel(); - true - }) - .unwrap_or(false); - if opened { - self.render(client); + let opened = host::with(|h| h.engine.open_symbol_panel().then(|| h.load_panel())).flatten(); + if let Some(translating) = opened { + self.show_panel(translating, client); } } @@ -54,6 +89,9 @@ impl QingjianInputController { pressed: Modifiers, client: TextClient<'_>, ) -> PanelKey { + if host::with(|h| h.engine.panel_translating()).unwrap_or(false) { + return self.handle_translate_key(event, key, pressed, client); + } if pressed.command || pressed.control { return Some(false); } @@ -128,26 +166,29 @@ impl QingjianInputController { } /// 换组或换分类,换了就把格子换成新分类的条目重画。 - fn switch_panel( + pub(super) fn switch_panel( &self, switch: impl FnOnce(&mut qingjian_core::SymbolPanel) -> bool, client: TextClient<'_>, ) { + let was_translating = host::with(|h| h.engine.panel_translating()).unwrap_or(false); let switched = host::with(|h| { - let switched = h.engine.symbol_panel_mut().is_some_and(switch); - if switched { - h.load_panel(); - } - switched + h.engine + .symbol_panel_mut() + .is_some_and(switch) + .then(|| h.load_panel()) }) - .unwrap_or(false); - if switched { - self.render(client); + .flatten(); + if let Some(translating) = switched { + if was_translating && !translating { + self.restore_marked(client); + } + self.show_panel(translating, client); } } /// 收起面板回到候选:拼音还在,重新查一遍。 - fn close_panel(&self, client: TextClient<'_>) { + pub(super) fn close_panel(&self, client: TextClient<'_>) { host::with(|h| h.engine.close_symbol_panel()); self.refresh(client); } @@ -179,6 +220,12 @@ impl QingjianInputController { self.render(client); } } + PanelMouse::Candidate(offset) => self.pick_into_box(offset, client), + PanelMouse::Caret(position) => { + if host::with(|h| h.engine.place_panel_cursor(position)).unwrap_or(false) { + self.render(client); + } + } PanelMouse::Close => self.close_panel(client), } } diff --git a/apps/macos/src/imk/controller/panel/translate.rs b/apps/macos/src/imk/controller/panel/translate.rs new file mode 100644 index 00000000..4366aa3b --- /dev/null +++ b/apps/macos/src/imk/controller/panel/translate.rs @@ -0,0 +1,207 @@ +//! 符号面板翻译组里的按键:字母照常打拼音,空格 / 数字 / 点候选把词选进框里(不进应用),英文模式下敲的字直接进框; +//! `[` `]` 换目标语言;打拼音时 ←→ 挪高亮、翻页键翻候选,没在打拼音时 ←→ / Home / End 挪光标、Delete 删光标后的字;回车上屏译文(还没好就马上发、再按一次),Esc 放弃,退格先删拼音再删框里的字。 +//! 译文由 [`Host::poll_prediction`](crate::host::Host::poll_prediction) 收,[`crate::imk::panel_translation_changed`] 写回应用的 preedit。 + +use objc2_app_kit::NSEvent; +use qingjian_platform::Modifiers; + +use super::PanelKey; +use crate::host; +use crate::imk::{QingjianInputController, TextClient}; + +impl QingjianInputController { + pub(super) fn handle_translate_key( + &self, + event: &NSEvent, + key: u16, + pressed: Modifiers, + client: TextClient<'_>, + ) -> PanelKey { + if pressed.command || pressed.control { + return Some(false); + } + let composing = host::with(|h| !h.engine.composition().is_empty()).unwrap_or(false); + match key { + 48 => { + let delta = if pressed.shift { -1 } else { 1 }; + self.switch_panel(|panel| panel.switch_group(delta), client); + return Some(true); + } + 53 => { + self.discard_box(client); + return Some(true); + } + 51 => { + if composing { + host::with(|h| h.engine.backspace()); + self.edited_box(client); + } else if host::with(|h| h.engine.panel_backspace()).unwrap_or(false) { + self.edited_box(client); + } else { + self.close_panel(client); + } + return Some(true); + } + 36 | 76 => { + if composing { + self.pick_highlighted_into_box(client); + } + self.commit_box(client); + return Some(true); + } + 123 | 124 => { + let delta = if key == 123 { -1 } else { 1 }; + if composing { + self.navigate_candidate(delta, client); + } else if host::with(|h| h.engine.move_panel_cursor(delta)).unwrap_or(false) { + self.render(client); + } + return Some(true); + } + // Home / End 把光标挪到最前 / 最后,Delete 删光标后一个字 + 115 | 119 if !composing => { + let position = if key == 115 { 0 } else { usize::MAX }; + if host::with(|h| h.engine.place_panel_cursor(position)).unwrap_or(false) { + self.render(client); + } + return Some(true); + } + 117 if !composing => { + if host::with(|h| h.engine.panel_delete()).unwrap_or(false) { + self.edited_box(client); + } + return Some(true); + } + 125 | 126 | 116 | 121 => { + if composing { + let delta = if matches!(key, 126 | 116) { -1 } else { 1 }; + self.turn_page(delta, client); + } + return Some(true); + } + _ => {} + } + let text = event + .characters() + .map(|t| t.to_string()) + .unwrap_or_default(); + let mut chars = text.chars(); + let (Some(c), None) = (chars.next(), chars.next()) else { + return Some(true); + }; + if c.is_control() { + return Some(true); + } + if c == '[' || c == ']' { + let step = if c == '[' { -1 } else { 1 }; + self.switch_panel(|panel| panel.switch_section(step), client); + return Some(true); + } + if host::with(|h| h.english_mode()).unwrap_or(false) { + let c = if host::with(|h| h.english_letter_uppercase()).unwrap_or(false) { + c.to_ascii_uppercase() + } else { + c.to_ascii_lowercase() + }; + host::with(|h| h.engine.push_panel_text(c.encode_utf8(&mut [0; 4]))); + self.edited_box(client); + return Some(true); + } + if composing { + if c == ' ' { + self.pick_highlighted_into_box(client); + return Some(true); + } + if let Some(digit) = c.to_digit(10).filter(|d| *d > 0) { + self.pick_into_box(digit as usize - 1, client); + return Some(true); + } + if let Some(step) = Self::page_step(c) { + self.turn_page(step, client); + return Some(true); + } + } + if c.is_ascii_alphabetic() || (composing && c == '\'') { + host::with(|h| h.engine.push(c.to_ascii_lowercase())); + self.edited_box(client); + return Some(true); + } + // 标点、数字与空格:正在打的拼音先按第一个候选选进去,再把字加在后面 + if composing { + self.pick_highlighted_into_box(client); + } + host::with(|h| h.engine.push_panel_char(c)); + self.edited_box(client); + Some(true) + } + + /// 点或按数字选当前一页第 `offset` 个候选进框。 + pub(super) fn pick_into_box(&self, offset: usize, client: TextClient<'_>) { + let picked = host::with(|h| { + let index = h.session.index_on_page(offset)?; + let candidate = h.session.candidate(index)?; + Some(h.engine.commit_to_panel(&candidate)) + }) + .flatten() + .unwrap_or(false); + if picked { + self.edited_box(client); + } + } + + fn pick_highlighted_into_box(&self, client: TextClient<'_>) { + let picked = host::with(|h| { + let candidate = h.session.candidate(h.session.highlighted)?; + Some(h.engine.commit_to_panel(&candidate)) + }) + .flatten() + .unwrap_or(false); + if picked { + self.edited_box(client); + } + } + + /// 框里或拼音变了:重查候选、重画,开始等停键。 + fn edited_box(&self, client: TextClient<'_>) { + host::with(|h| h.watch_panel_translation()); + self.refresh(client); + } + + /// 回车:译文好了就上屏并收起面板;还没好就不等停键马上发,等它回来再按一次。 + fn commit_box(&self, client: TextClient<'_>) { + let text = host::with(|h| { + let text = h.engine.take_panel_translation(); + if text.is_none() { + h.engine.hurry_panel_translation(); + h.watch_panel_translation(); + } + text + }) + .flatten(); + match text { + Some(text) => { + tracing::debug!(chars = text.chars().count(), "panel translation committed"); + client.insert_text(&text); + self.refresh(client); + } + None => self.render(client), + } + } + + /// Esc:框里有字就连拼音一起扔掉;空框时同别的组一样回到候选。 + fn discard_box(&self, client: TextClient<'_>) { + let empty = host::with(|h| { + h.engine + .symbol_panel() + .is_none_or(|panel| panel.translate_source().is_empty()) + }) + .unwrap_or(true); + if empty { + self.close_panel(client); + return; + } + host::with(|h| h.engine.clear()); + client.set_marked_text("", 0); + self.refresh(client); + } +} diff --git a/apps/macos/src/imk/mod.rs b/apps/macos/src/imk/mod.rs index 3d3a67a3..c878c4e5 100644 --- a/apps/macos/src/imk/mod.rs +++ b/apps/macos/src/imk/mod.rs @@ -12,7 +12,8 @@ use std::panic::{AssertUnwindSafe, catch_unwind}; pub use client::{TextClient, take_inserted}; pub use controller::{ - QingjianInputController, click, commit_pending_after_switch, menu_items, menu_pick, panel_mouse, + QingjianInputController, click, commit_pending_after_switch, menu_items, menu_pick, + panel_mouse, panel_translation_changed, }; /// 在 ObjC 运行时回调的边界拦住 panic。`define_class!` 生成的方法是系统直接调的,panic 穿过去整个进程就没了, diff --git a/apps/settings/locales/de.toml b/apps/settings/locales/de.toml index 83c3c95d..e00b2a4e 100644 --- a/apps/settings/locales/de.toml +++ b/apps/settings/locales/de.toml @@ -256,8 +256,6 @@ cloud_words_info = "Cloud-Wörter werden am Ende der ersten Seite angefügt" cloud_words_info_slots = "Cloud-Wörter füllen beim Eintreffen diese letzten Plätze der ersten Seite; lokale Kandidaten davor bleiben, wo sie sind" sentence = "Satzvervollständigung" sentence_info = "Zeigt nach dem Pinyin einen ganzen Satz; mit Tab übernehmen" -sentence_translation = "Satzkandidaten übersetzen" -sentence_translation_info = "Zeigt rechts neben Satzkandidaten eine Übersetzung" vocabulary_translation = "Sätze fürs Vokabelheft übersetzen" vocabulary_translation_info = "Übersetzt fertige Sätze und speichert sie im Vokabelheft" section_prediction = "Cloud-Vorhersage" diff --git a/apps/settings/locales/en.toml b/apps/settings/locales/en.toml index b135ba86..df0b3d94 100644 --- a/apps/settings/locales/en.toml +++ b/apps/settings/locales/en.toml @@ -282,8 +282,6 @@ cloud_words_info = "Cloud words are added at the end of the first page" cloud_words_info_slots = "Cloud words fill these last slots of the first page when they arrive; local candidates before them stay put" sentence = "Sentence completion" sentence_info = "Shows a full sentence after the pinyin; press Tab to accept" -sentence_translation = "Translate sentence candidates" -sentence_translation_info = "Shows a translation to the right of sentence candidates" vocabulary_translation = "Translate sentences for Vocabulary" vocabulary_translation_info = "Translates finished sentences and saves them to Vocabulary" section_prediction = "Cloud prediction" @@ -321,6 +319,7 @@ translation_info = "Hold it and press a candidate number to commit the translati translation_second_info = "When a candidate has two translations, commits the second" translate_selection_info = "Select text and press it to translate; Return replaces the text" predict_info = "Press while composing to request AI candidates" +translate_panel_info = "Press when not typing to open the Translate group of the symbol panel" section_candidates = "Candidates" reset = "Restore defaults" shortcuts = "Shortcuts" @@ -346,6 +345,7 @@ translation = "Commit first translation" translation_second = "Commit second translation" translate_selection = "Translate selected text" predict = "AI prediction on demand" +translate_panel = "Open Translate group" aux_code_key = "Trigger key" prompt_digit = "Hold modifiers and press a digit" prompt_combo = "Press modifiers + a letter" diff --git a/apps/settings/locales/es.toml b/apps/settings/locales/es.toml index b9f6a3d1..2cd66d4f 100644 --- a/apps/settings/locales/es.toml +++ b/apps/settings/locales/es.toml @@ -256,8 +256,6 @@ cloud_words_info = "Las palabras de la nube se añaden al final de la primera p cloud_words_info_slots = "Cuando llegan, las palabras de la nube ocupan estos últimos huecos de la primera página; los candidatos locales anteriores no se mueven" sentence = "Completar frases" sentence_info = "Muestra una frase completa tras el pinyin; pulsa Tab para aceptarla" -sentence_translation = "Traducir candidatos de frase" -sentence_translation_info = "Muestra una traducción a la derecha de los candidatos de frase" vocabulary_translation = "Traducir frases para el Vocabulario" vocabulary_translation_info = "Traduce las frases terminadas y las guarda en el Vocabulario" section_prediction = "Predicción en la nube" diff --git a/apps/settings/locales/ja.toml b/apps/settings/locales/ja.toml index cc5dcc8c..736b22d3 100644 --- a/apps/settings/locales/ja.toml +++ b/apps/settings/locales/ja.toml @@ -256,8 +256,6 @@ cloud_words_info = "クラウドの語を 1 ページ目の末尾に加えます cloud_words_info_slots = "クラウドの語が届いたら 1 ページ目の末尾のこの枠に入ります。前にあるローカルの候補は動きません" sentence = "文の補完" sentence_info = "ピンインの後に文全体を表示し、Tab で採用します" -sentence_translation = "文候補の翻訳" -sentence_translation_info = "文候補の右側に訳文を表示します" vocabulary_translation = "単語帳の文を翻訳" vocabulary_translation_info = "打ち終えた文を翻訳して単語帳に記録します" section_prediction = "クラウド予測" diff --git a/apps/settings/locales/vi.toml b/apps/settings/locales/vi.toml index 86eb9d94..f7ad40f6 100644 --- a/apps/settings/locales/vi.toml +++ b/apps/settings/locales/vi.toml @@ -256,8 +256,6 @@ cloud_words_info = "Từ từ đám mây được thêm vào cuối trang đầu cloud_words_info_slots = "Khi đến, từ từ đám mây điền vào mấy ô cuối của trang đầu; các gợi ý trên máy phía trước giữ nguyên" sentence = "Hoàn thành câu" sentence_info = "Hiện cả câu sau pinyin, nhấn Tab để dùng" -sentence_translation = "Dịch gợi ý câu" -sentence_translation_info = "Hiện bản dịch bên phải gợi ý câu" vocabulary_translation = "Dịch câu cho sổ từ vựng" vocabulary_translation_info = "Dịch câu đã gõ xong rồi ghi vào sổ từ vựng" section_prediction = "Gợi ý qua đám mây" diff --git a/apps/settings/locales/zh-Hans.toml b/apps/settings/locales/zh-Hans.toml index 38ecce6c..98a993aa 100644 --- a/apps/settings/locales/zh-Hans.toml +++ b/apps/settings/locales/zh-Hans.toml @@ -282,8 +282,6 @@ cloud_words_info = "云端的词补在第一页末尾" cloud_words_info_slots = "云端的词到了补进第一页末尾这几格,前面的本地候选不动" sentence = "整句补全" sentence_info = "拼音后面给出整句,按 Tab 采用" -sentence_translation = "整句候选翻译" -sentence_translation_info = "整句候选右侧显示译文" vocabulary_translation = "生词本整句翻译" vocabulary_translation_info = "把打完的句子翻译后记进生词本" section_prediction = "云联想" @@ -321,6 +319,7 @@ translation_info = "按住它再按候选序号,上屏候选右侧的译词而 translation_second_info = "候选有两个译词时上屏后一个" translate_selection_info = "选中文字再按它翻译,回车替换" predict_info = "组句时按它请求 AI 候选" +translate_panel_info = "没在打字时按它打开符号面板的翻译组" section_candidates = "候选" reset = "恢复默认" shortcuts = "快捷键" @@ -346,6 +345,7 @@ translation = "上屏第一个译词" translation_second = "上屏第二个译词" translate_selection = "翻译选中文字" predict = "按需 AI 联想" +translate_panel = "打开翻译组" aux_code_key = "触发键" prompt_digit = "按住修饰键再按个数字" prompt_combo = "按下修饰键 + 字母" diff --git a/apps/settings/locales/zh-Hant.toml b/apps/settings/locales/zh-Hant.toml index b358b1a0..3914b904 100644 --- a/apps/settings/locales/zh-Hant.toml +++ b/apps/settings/locales/zh-Hant.toml @@ -256,8 +256,6 @@ cloud_words_info = "雲端的詞補在第一頁末尾" cloud_words_info_slots = "雲端的詞到了補進第一頁末尾這幾格,前面的本地候選不動" sentence = "整句補全" sentence_info = "拼音後面給出整句,按 Tab 採用" -sentence_translation = "整句候選翻譯" -sentence_translation_info = "整句候選右側顯示譯文" vocabulary_translation = "生詞本整句翻譯" vocabulary_translation_info = "把打完的句子翻譯後記進生詞本" section_prediction = "雲聯想" @@ -295,6 +293,7 @@ translation_info = "按住它再按候選序號,上屏候選右側的譯詞而 translation_second_info = "候選有兩個譯詞時上屏後一個" translate_selection_info = "選中文字再按它翻譯,回車替換" predict_info = "組句時按它請求 AI 候選" +translate_panel_info = "沒在打字時按它打開符號面板的翻譯組" section_candidates = "候選" reset = "恢復默認" shortcuts = "快捷鍵" @@ -317,6 +316,7 @@ translation = "上屏第一個譯詞" translation_second = "上屏第二個譯詞" translate_selection = "翻譯選中文字" predict = "按需 AI 聯想" +translate_panel = "打開翻譯組" aux_code_key = "觸發鍵" prompt_digit = "按住修飾鍵再按個數字" prompt_combo = "按下修飾鍵 + 字母" diff --git a/apps/settings/src/docs.rs b/apps/settings/src/docs.rs index f505f07c..a4d5e97f 100644 --- a/apps/settings/src/docs.rs +++ b/apps/settings/src/docs.rs @@ -289,12 +289,6 @@ const LINKS: &[(PageId, &str, &str, &str)] = &[ "AI 候选词", ), (PageId::Cloud, "cloud.sentence", "cloud/predict", "整句补全"), - ( - PageId::Cloud, - "cloud.sentence_translation", - "cloud/sentence-translation", - "整句候选翻译", - ), (PageId::Cloud, "cloud.api_key", "cloud/index", "会发送什么"), (PageId::Cloud, "cloud.connection", "cloud/index", "开启"), ( diff --git a/apps/settings/src/settings/shortcuts.rs b/apps/settings/src/settings/shortcuts.rs index 00cc4a13..39ad548e 100644 --- a/apps/settings/src/settings/shortcuts.rs +++ b/apps/settings/src/settings/shortcuts.rs @@ -31,6 +31,13 @@ impl Settings { if Feature::CloudRequestControls.here() { self.write("shortcut", "predict", defaults.predict.key_string()); } + if Feature::TranslatePanel.here() { + self.write( + "shortcut", + "translate_panel", + defaults.translate_panel.key_string(), + ); + } if Feature::HighlightKeys.here() { self.write("shortcut", "highlight_keys", defaults.highlight_keys.key()); } diff --git a/apps/settings/src/shortcut.rs b/apps/settings/src/shortcut.rs index d70b7044..ca52df67 100644 --- a/apps/settings/src/shortcut.rs +++ b/apps/settings/src/shortcut.rs @@ -12,6 +12,8 @@ pub enum Shortcut { TranslationSecond, TranslateSelection, Predict, + /// 没在打字时打开符号面板的翻译组。 + TranslatePanel, /// 呼出剪贴板面板(全局快捷键)。 Clipboard, /// 辅码触发键(单个符号,不带修饰键)。 @@ -24,11 +26,12 @@ pub enum Shortcut { impl Shortcut { #[cfg(test)] - pub const ALL: [Self; 8] = [ + pub const ALL: [Self; 9] = [ Self::Translation, Self::TranslationSecond, Self::TranslateSelection, Self::Predict, + Self::TranslatePanel, Self::Clipboard, Self::AuxCodeKey, Self::CommitCandidate, @@ -41,6 +44,7 @@ impl Shortcut { Self::TranslationSecond => tr("shortcut.translation_second"), Self::TranslateSelection => tr("shortcut.translate_selection"), Self::Predict => tr("shortcut.predict"), + Self::TranslatePanel => tr("shortcut.translate_panel"), Self::Clipboard => tr("shortcut.clipboard"), Self::AuxCodeKey => tr("shortcut.aux_code_key"), Self::CommitCandidate => tr("shortcut.commit_candidate"), @@ -55,6 +59,7 @@ impl Shortcut { Self::TranslationSecond => ("shortcut", "translation_second"), Self::TranslateSelection => ("shortcut", "translate_selection"), Self::Predict => ("shortcut", "predict"), + Self::TranslatePanel => ("shortcut", "translate_panel"), Self::Clipboard => ("shortcut", "clipboard"), Self::AuxCodeKey => ("general", "aux_code_key"), Self::CommitCandidate => ("shortcut", "commit_candidate"), @@ -66,7 +71,7 @@ impl Shortcut { pub fn prompt(self) -> &'static str { match self { Self::Translation | Self::TranslationSecond => tr("shortcut.prompt_digit"), - Self::TranslateSelection | Self::Predict | Self::Clipboard => { + Self::TranslateSelection | Self::Predict | Self::TranslatePanel | Self::Clipboard => { tr("shortcut.prompt_combo") } Self::AuxCodeKey => tr("shortcut.prompt_symbol"), @@ -83,6 +88,7 @@ impl Shortcut { Self::TranslationSecond => digits(second), Self::TranslateSelection => combo_label(shortcut.translate_selection), Self::Predict => combo_label(shortcut.predict), + Self::TranslatePanel => combo_label(shortcut.translate_panel), Self::Clipboard => combo_label(shortcut.clipboard), Self::AuxCodeKey => config.general.aux_code_key().to_string(), Self::CommitCandidate => shortcut.commit_candidate.label(), @@ -108,6 +114,7 @@ impl Shortcut { fn combo(self, config: &Config) -> KeyCombo { match self { Self::Predict => config.shortcut.predict, + Self::TranslatePanel => config.shortcut.translate_panel, Self::Clipboard => config.shortcut.clipboard, _ => config.shortcut.translate_selection, } @@ -203,14 +210,18 @@ impl Shortcut { modifiers, key: key.to_ascii_lowercase(), }; - for other in [Self::TranslateSelection, Self::Predict] { + for other in [ + Self::TranslateSelection, + Self::Predict, + Self::TranslatePanel, + ] { if other.combo(config) == combo { return Err(tr_args("shortcut.clash", &[("other", &other.title())])); } } Ok(combo.key_string()) } - Self::TranslateSelection | Self::Predict => { + Self::TranslateSelection | Self::Predict | Self::TranslatePanel => { let key = match &press.key { KeyCode::Character(text) => single(text).filter(|key| { key.is_ascii_alphanumeric() || (self == Self::Predict && *key == '.') diff --git a/apps/settings/src/support/mod.rs b/apps/settings/src/support/mod.rs index db5b7b52..d27f5fa2 100644 --- a/apps/settings/src/support/mod.rs +++ b/apps/settings/src/support/mod.rs @@ -59,11 +59,14 @@ pub enum Feature { NumpadDigit, /// 双拼候选窗口的拼音行显示敲的键。 ShuangpinRawWindow, + + /// 没在打字时打开符号面板翻译组的快捷键。 + TranslatePanel, } impl Feature { #[cfg(test)] - pub const ALL: [Self; 28] = [ + pub const ALL: [Self; 29] = [ Self::AuxCode, Self::Schemes, Self::SwitchKeys, @@ -92,6 +95,7 @@ impl Feature { Self::Clipboard, Self::NumpadDigit, Self::ShuangpinRawWindow, + Self::TranslatePanel, ]; /// 当前平台显示这一项。 @@ -140,6 +144,8 @@ impl Feature { Self::ApiUsage => [Yes, Yes, Yes], Self::CloudRequestControls => [Yes, Yes, Yes], Self::VocabularyTranslation => [Yes, Yes, Yes], + // 符号面板只有 macOS 与 Windows 有 + Self::TranslatePanel => [Yes, Yes, No], Self::KeyHint => [Yes, Yes, Todo], // Fcitx 面板与应用里的行内拼音共用一串,还没分开 Self::ShowTones => [Yes, Yes, Todo], @@ -184,6 +190,7 @@ impl Feature { Self::Clipboard => "青简剪贴板", Self::NumpadDigit => "小键盘数字", Self::ShuangpinRawWindow => "双拼候选窗显示按键", + Self::TranslatePanel => "打开符号面板的翻译组", } } @@ -220,6 +227,7 @@ impl Feature { (PageId::Shortcuts, "shortcut.predict"), ], Self::VocabularyTranslation => &[(PageId::Cloud, "cloud.vocabulary_translation")], + Self::TranslatePanel => &[(PageId::Shortcuts, "shortcut.translate_panel")], Self::KeyHint => &[(PageId::Input, "input.key_hint")], Self::ShowTones => &[(PageId::Candidates, "candidates.show_tones")], Self::NumpadDigit => &[(PageId::Candidates, "candidates.numpad_digit")], diff --git a/apps/settings/src/view/pages/cloud.rs b/apps/settings/src/view/pages/cloud.rs index 41cf4c05..7663d71a 100644 --- a/apps/settings/src/view/pages/cloud.rs +++ b/apps/settings/src/view/pages/cloud.rs @@ -1,4 +1,4 @@ -//! 「云服务」页:云联想(开关、云端词格数、整句补全、整句候选翻译、生词本整句翻译),服务(接口地址、模型、密钥、测试连接)。 +//! 「云服务」页:云联想(开关、云端词格数、整句补全、生词本整句翻译),服务(接口地址、模型、密钥、测试连接)。 use bamboo_slip::taffy::NodeId; use bamboo_slip::{Button, RenderError, SettingRow, Stepper, TextField, Ui, group, section_title}; @@ -86,15 +86,6 @@ pub fn cloud(ui: &mut Ui, page: &Page) -> Result, RenderErr predict.sentence, off, )?, - toggle_info( - ui, - page, - tr("cloud.sentence_translation"), - Some(tr("cloud.sentence_translation_info")), - ("predict", "sentence_translation"), - predict.sentence_translation, - off, - )?, ]); if Feature::VocabularyTranslation.here() { rows.push(toggle_info( diff --git a/apps/settings/src/view/pages/shortcuts.rs b/apps/settings/src/view/pages/shortcuts.rs index 03c2b4fa..7756a680 100644 --- a/apps/settings/src/view/pages/shortcuts.rs +++ b/apps/settings/src/view/pages/shortcuts.rs @@ -115,6 +115,15 @@ pub fn shortcuts(ui: &mut Ui, page: &Page) -> Result, Rende false, )?); } + if Feature::TranslatePanel.here() { + rows.push(recorder( + ui, + page, + Shortcut::TranslatePanel, + tr("shortcuts.translate_panel_info"), + false, + )?); + } out.push(section_title( ui, theme, diff --git a/apps/windows/server/src/dispatch/composed/frame.rs b/apps/windows/server/src/dispatch/composed/frame.rs new file mode 100644 index 00000000..ca4b8b8f --- /dev/null +++ b/apps/windows/server/src/dispatch/composed/frame.rs @@ -0,0 +1,269 @@ +//! 按组句状态生成帧:给 DLL 的(老协议降级、输入框显示原始按键)与自绘窗的,符号面板翻译组的 preedit 是译文。 + +use qingjian_core::candidate::arrows::Focus; +use qingjian_core::{Candidate, CandidateKind, CandidateList, Cell, Engine, Grid, Query}; +use qingjian_platform::PreeditMode; +use qingjian_platform::protocol::{Frame, PROTOCOL_VERSION, PreeditKind, PreeditSegment}; + +use super::{Composed, marked_parts}; +use crate::dispatch::Router; + +impl Router { + /// 按当前状态生成一帧:翻译评审优先;没在组句给空帧;否则给高亮所在的那一页。 + /// 焦点会话的 DLL 比 Server 老时按老协议降级(见 [`Self::downgrade_for_old_dll`])。 + pub(in crate::dispatch) fn current_frame(&self) -> Frame { + let mut frame = self.raw_frame(); + self.show_typed_keys(&mut frame); + self.downgrade_for_old_dll(&mut frame); + frame + } + + /// 给 DLL 的帧只管应用输入框:双拼「输入框显示原始按键」开着时换成敲的键,拼音行由 Server 自绘照旧全拼。 + fn show_typed_keys(&self, frame: &mut Frame) { + if self.translation.is_some() || self.engine.panel_translating() { + return; + } + let Some(Composed::Candidates { + typed_keys: Some(keys), + .. + }) = &self.composed + else { + return; + }; + frame.preedit = vec![PreeditSegment { + text: keys.text.clone(), + kind: PreeditKind::Typed, + }]; + frame.cursor = keys.cursor; + } + + /// 自绘候选窗用的帧:不做老 DLL 降级,码段照常画。 + pub(in crate::dispatch) fn self_drawn_frame(&self) -> Frame { + self.raw_frame() + } + + /// 协议比 Server 老的 DLL 不认识 `AuxCode` 段与颜文字、符号两种候选,收到会整条消息解析失败: + /// 码段降级成普通拼音段,那两种候选按 emoji 发(DLL 不看候选种类,只要解得出来)。 + fn downgrade_for_old_dll(&self, frame: &mut Frame) { + if self.focused_dll_protocol() >= PROTOCOL_VERSION { + return; + } + for segment in &mut frame.preedit { + if segment.kind == PreeditKind::AuxCode { + segment.kind = PreeditKind::Typed; + } + } + for candidate in &mut frame.candidates.items { + if matches!( + candidate.kind, + CandidateKind::Kaomoji | CandidateKind::Symbol + ) { + candidate.kind = CandidateKind::Emoji; + } + } + } + + /// 焦点会话的 DLL 协议版本;没有焦点会话时按最新(不必降级)。 + pub(in crate::dispatch) fn focused_dll_protocol(&self) -> u32 { + self.focused + .and_then(|session| self.sessions.get(&session)) + .map_or(PROTOCOL_VERSION, |info| info.protocol) + } + + fn raw_frame(&self) -> Frame { + if let Some(translation) = &self.translation { + return self.translation_frame(translation); + } + let mut frame = self.composed_frame(); + frame.symbol_panel = self.panel_open(); + if frame.symbol_panel && self.engine.panel_translating() { + self.translate_preedit(&mut frame); + } + frame + } + + /// 翻译组:拼音画在面板的框里,应用里放最近一条译文;行内不显示拼音的设置也照样放,译文只能在那里看。 + fn translate_preedit(&self, frame: &mut Frame) { + let text = self.engine.panel_preedit().unwrap_or_default().to_owned(); + frame.cursor = text.chars().count(); + frame.preedit = if text.is_empty() { + Vec::new() + } else { + vec![PreeditSegment { + text, + kind: PreeditKind::Typed, + }] + }; + frame.preedit_mode = PreeditMode::Inline; + frame.window_preedit = None; + } + + fn composed_frame(&self) -> Frame { + match &self.composed { + None => Frame::default(), + Some(Composed::Vietnamese { text, candidates }) => Frame { + preedit: vec![PreeditSegment { + text: text.clone(), + kind: PreeditKind::Typed, + }], + preedit_mode: self.config.preedit, + cursor: text.chars().count(), + window_preedit: None, + candidates: CandidateList { + items: candidates.clone(), + }, + highlight: self.highlight.min(candidates.len().saturating_sub(1)), + page: 0, + page_count: 1, + layout: self.config.layout, + columns: 0, + column_ems: Vec::new(), + appearance: self.config.appearance, + aux_code_show: false, + translation_pos: false, + sentence: None, + notice: None, + symbol_panel: false, + }, + Some(Composed::Raw { text, cursor }) => Frame { + preedit: vec![PreeditSegment { + text: text.clone(), + kind: PreeditKind::Typed, + }], + preedit_mode: self.config.preedit, + cursor: *cursor, + window_preedit: None, + candidates: CandidateList { items: Vec::new() }, + highlight: usize::MAX, + page: 0, + page_count: 1, + layout: self.config.layout, + columns: 0, + column_ems: Vec::new(), + appearance: self.config.appearance, + aux_code_show: self.config.aux_code_show, + translation_pos: self.config.translation_pos, + sentence: None, + notice: self.notice.clone(), + symbol_panel: false, + }, + Some(Composed::Candidates { + preedit, + cursor, + layout, + spelling, + .. + }) => { + let page_size = self.config.page_size; + let highlight = self.highlight.min(layout.len().saturating_sub(1)); + // 高亮候选的读音切法与首选不同(`ganga` 的 尴尬 / 刚啊):拼音行按它重切 + let candidate = layout.candidate(highlight); + let followed = (spelling.spelling_of(candidate) != spelling.spelled).then(|| { + let mut followed = (**spelling).clone(); + followed.follow(candidate); + followed + }); + let query = followed.as_ref().unwrap_or(spelling); + let (preedit, cursor) = match &followed { + Some(followed) => { + let (preedit, cursor, _) = marked_parts(followed); + (preedit, cursor) + } + None => (preedit.clone(), *cursor), + }; + let window_preedit = window_parts(&self.engine, query, candidate, &preedit); + let page = highlight / page_size; + // 横排展开成矩阵时画视口里的几行(空位是占位候选),序号只标在高亮所在那一行; + // 单行时只画当前页。高亮下标跟着换成视口内的。 + let viewport = self.grid.as_ref().map(|grid| grid.rows(layout)); + // 视口首格在整份候选里的下标(单行时是页首) + let first = match &viewport { + Some(rows) => rows.start * page_size, + None => page * page_size, + }; + let (items, columns) = + match viewport { + Some(rows) => { + let mut items = Vec::new(); + for row in rows { + let mut cells = layout.page(row); + cells.resize(page_size, Cell::Empty); + items.extend(cells.into_iter().map(|cell| { + cell.candidate().cloned().unwrap_or_else(empty_cell) + })); + } + (items, page_size) + } + None => ( + layout + .page(page) + .into_iter() + .filter_map(|cell| cell.candidate().cloned()) + .collect(), + 0, + ), + }; + let mut candidates = CandidateList { items }; + self.engine.annotate(&mut candidates); + Frame { + preedit, + preedit_mode: self.config.preedit, + cursor, + window_preedit, + candidates, + // 方向键在拼音行里时候选不画高亮,空格照样上屏原来高亮的那个 + highlight: if self.config.two_area_arrows() && self.focus == Focus::Pinyin { + usize::MAX + } else { + highlight - first + }, + page, + page_count: layout.pages().max(1), + layout: self.config.layout, + columns, + column_ems: if columns > 0 { + Grid::column_ems(layout) + } else { + Vec::new() + }, + appearance: self.config.appearance, + aux_code_show: self.config.aux_code_show, + translation_pos: self.config.translation_pos, + sentence: self.sentence.clone(), + notice: self.notice.clone(), + symbol_panel: false, + } + } + } + } +} + +/// 矩阵视口里空位的占位候选:文本为空,渲染端按空格子画。 +fn empty_cell() -> Candidate { + Candidate { + text: String::new(), + kind: CandidateKind::Chinese, + syllables: Vec::new(), + reading: None, + translation: None, + aux_code: None, + } +} + +/// 候选窗口顶部的拼音行:双拼「候选窗显示按键」开着时是敲的键;带调拼音开着、高亮候选读得出声调时换成它的读音; +/// 与行内拼音相同时不另给。 +fn window_parts( + engine: &Engine, + query: &Query, + candidate: Option<&Candidate>, + preedit: &[PreeditSegment], +) -> Option, usize)>> { + let (segments, cursor) = if query.shuangpin_raw_window { + query.window_segments(None) + } else { + let toned = engine.toned_syllables(candidate?)?; + query.window_segments(Some(&toned)) + }; + let segments: Vec = segments.iter().map(Into::into).collect(); + (segments != preedit).then(|| Box::new((segments, cursor))) +} diff --git a/apps/windows/server/src/dispatch/composed/mod.rs b/apps/windows/server/src/dispatch/composed/mod.rs index b6ac8d64..535822ac 100644 --- a/apps/windows/server/src/dispatch/composed/mod.rs +++ b/apps/windows/server/src/dispatch/composed/mod.rs @@ -1,31 +1,22 @@ -//! 组句的展示状态:缓冲变化时重查候选并重建 [`Composed`],云端词异步并入,高亮 / 翻页,按状态生成给 DLL 的帧。 +//! 组句的展示状态:缓冲变化时重查候选并重建 [`Composed`];云联想在 [`prediction`],高亮 / 翻页在 [`navigation`], +//! 给 DLL 与自绘窗的帧在 [`frame`]。 -mod sentence_translation; +mod frame; +mod navigation; +mod prediction; mod state; -use qingjian_core::candidate::arrows::{Focus, Matrix}; -use qingjian_core::{ - Candidate, CandidateKind, CandidateLayout, CandidateList, Cell, CloudWord, Engine, GRID_ROWS, - Grid, Language, Query, Sense, SurroundingText, Translation, -}; -use qingjian_platform::protocol::{Frame, PROTOCOL_VERSION, PreeditKind, PreeditSegment}; +use qingjian_core::candidate::arrows::Focus; +use qingjian_core::{Candidate, CandidateLayout, Query}; +use qingjian_platform::protocol::PreeditSegment; -use self::sentence_translation::SentenceTranslation; pub(super) use self::state::{Composed, TypedKeys}; use super::Router; impl Router { /// 缓冲变化后:按 Engine 状态重建 [`Composed`],发一次云联想请求,归零高亮与整句补全。 pub(super) fn recompose(&mut self) { - let previous_sentence_translation = - self.composed.take().and_then(|composed| match composed { - Composed::Candidates { - sentence_translation, - .. - } => sentence_translation, - Composed::Raw { .. } => None, - Composed::Vietnamese { .. } => None, - }); + self.composed = None; self.highlight = 0; self.navigated = false; self.grid = None; @@ -34,7 +25,6 @@ impl Router { self.focus = Focus::default(); // 清空时也要撤掉 worker 中的待发请求,避免无用请求占掉最短间隔。 self.engine.cancel_prediction(); - self.composed = None; self.stop_rescoring(); return; } @@ -53,38 +43,12 @@ impl Router { let surrounding = self.surrounding_for_prediction(); self.engine.request_prediction(surrounding, layout.local()); } - let sentence_candidate = (self.config.sentence_translation - && self.engine.learning_language() != Language::Chinese) - .then(|| { - layout - .local() - .iter() - .find(|candidate| candidate.kind == CandidateKind::Sentence) - }) - .flatten(); - let sentence_translation = sentence_candidate.and_then(|candidate| { - if let Some(annotation) = previous_sentence_translation - && annotation.text == candidate.text - { - return Some(annotation); - } - self.engine.cancel_sentence_translation(); - self.engine - .request_sentence_translation(&candidate.text) - .map(|sequence| { - SentenceTranslation::pending(candidate.text.clone(), sequence) - }) - }); - if sentence_translation.is_none() { - self.engine.cancel_sentence_translation(); - } Composed::Candidates { preedit, cursor, typed_keys, layout, spelling, - sentence_translation, manual_sequence: None, } } @@ -99,333 +63,6 @@ impl Router { self.schedule_rescoring(); } - /// 拉一次云联想结果:云端词并进候选布局,整句补全记下;翻译评审时结果是译文。 - pub(super) fn poll_prediction(&mut self) { - if !self.engine.prediction_enabled() { - return; - } - let Some(prediction) = self.engine.poll_prediction() else { - return; - }; - if self - .candidate_translation - .as_ref() - .is_some_and(|(sequence, _, _)| *sequence == prediction.sequence) - { - let (_, candidate, independent_language) = - self.candidate_translation.take().expect("刚检查过候选译文"); - let generated = prediction.sentence.filter(|text| !text.trim().is_empty()); - let updated = if independent_language.is_some() { - generated.is_some() - } else { - generated.as_ref().is_some_and(|generated| { - self.engine - .set_generated_candidate_translation(&candidate, generated) - }) - }; - self.notice = Some(if updated { - if let Some(language) = independent_language { - self.set_independent_candidate_translation( - &candidate.text, - language, - generated.as_deref().expect("updated 已检查"), - ); - format!("已更新「{}」的中文译词", candidate.text) - } else { - self.refresh_candidate_translation(&candidate.text); - format!("已更新「{}」的译文", candidate.text) - } - } else { - "模型没有生成可用译文".to_owned() - }); - return; - } - if self.translation.is_some() { - match prediction.sentence { - Some(text) => { - if let Some(translation) = self.translation.as_mut() { - translation.result = Some(text); - } - } - None => { - tracing::info!("翻译选中文字:云端没有给出译文"); - self.translation = None; - } - } - return; - } - let is_sentence_translation = matches!( - &self.composed, - Some(Composed::Candidates { - sentence_translation: Some(annotation), - .. - }) if annotation.sequence == prediction.sequence - ); - if is_sentence_translation { - if let Some(Composed::Candidates { - sentence_translation: Some(annotation), - .. - }) = self.composed.as_mut() - { - annotation.translation = prediction.sentence.map(|text| { - Translation::new( - self.engine.learning_language(), - vec![Sense { - part_of_speech: None, - text, - reading: None, - fresh: false, - }], - ) - }); - } - return; - } - if let Some(Composed::Candidates { - layout, - manual_sequence, - .. - }) = self.composed.as_mut() - { - let manual = manual_sequence.take() == Some(prediction.sequence); - self.sentence = prediction.sentence; - if self.navigated || self.highlight >= self.config.page_size { - return; - } - let words = prediction - .words - .into_iter() - .map(CloudWord::into_candidate) - .collect(); - if manual { - layout.set_cloud_first(words); - } else { - layout.set_cloud(words); - } - } - } - - pub(super) fn cancel_prediction(&mut self) { - if let Some(Composed::Candidates { - manual_sequence, .. - }) = self.composed.as_mut() - { - *manual_sequence = None; - } - if self.engine.prediction_enabled() { - self.engine.cancel_prediction(); - } - } - - /// DLL 送来新的光标前文后重发一次云联想:作废在飞的空上下文请求,让云端拿得到 before。 - pub(super) fn refresh_prediction(&mut self) { - if !self.config.cloud_enabled { - return; - } - let Some(Composed::Candidates { - layout, - manual_sequence, - .. - }) = &self.composed - else { - return; - }; - if manual_sequence.is_some() { - self.request_manual_prediction(); - return; - } - // 手动结果已到后保持当前布局;新输入会重建状态并恢复自动联想。 - if layout.cloud_first() { - return; - } - let local = layout.local().to_vec(); - let surrounding = self.surrounding_for_prediction(); - self.engine.request_prediction(surrounding, &local); - } - - /// 给当前的本地整句候选补上已经返回的云端译文。 - /// - /// 候选布局保留查询原样;画帧、点击和译词快捷键各自拿副本时统一补这一层, - /// 才不会让同一条译文只有显示、不能上屏。 - pub(super) fn annotate_sentence_translation(&self, candidates: &mut CandidateList) { - let Some(Composed::Candidates { - sentence_translation: Some(annotation), - .. - }) = &self.composed - else { - return; - }; - let Some(translation) = &annotation.translation else { - return; - }; - for candidate in &mut candidates.items { - if candidate.kind == CandidateKind::Sentence && candidate.text == annotation.text { - candidate.translation = Some(translation.clone()); - } - } - } - - /// 显式触发时使用同一份应用上下文,并记录序号供结果布局匹配。 - pub(super) fn request_manual_prediction(&mut self) -> bool { - if !self.config.cloud_enabled { - return false; - } - let surrounding = self.surrounding_for_prediction(); - let Some(Composed::Candidates { - layout, - manual_sequence, - .. - }) = self.composed.as_mut() - else { - return false; - }; - *manual_sequence = self - .engine - .request_manual_prediction(surrounding, layout.local()); - manual_sequence.is_some() - } - - /// DLL 报来的光标前文包成云联想要的形状;没有就 `None`(Engine 只靠拼音猜)。 - pub(super) fn surrounding_for_prediction(&self) -> Option { - (!self.surrounding_before.is_empty()).then(|| SurroundingText { - before: self.surrounding_before.clone(), - after: String::new(), - }) - } - - /// 高亮移动 `delta`,夹在 `[0, 末尾]`,到页边自然换页。 - pub(super) fn move_highlight(&mut self, delta: isize) { - let count = self.candidate_count(); - if count == 0 { - self.highlight = 0; - return; - } - let next = (self.highlight as isize + delta).clamp(0, count as isize - 1) as usize; - self.navigated |= next != self.highlight; - self.highlight = next; - } - - /// 整页翻 `step`,高亮落到目标页第一个候选;矩阵展开着时一次翻一屏(与 macOS 壳的 `turn_page` 一致)。 - /// 开了「翻页键展开」(`RouterConfig::page_keys_expand`)时单行先展开,高亮移到上 / 下一行,与 `↓` 展开同一套。 - pub(super) fn page(&mut self, step: isize) { - let moved = if self.grid.is_some() { - Some(self.move_screens(step)) - } else if self.config.page_keys_expand() { - Some(self.shift_grid_rows(step)) - } else { - None - }; - if let Some(moved) = moved { - if moved { - self.engine.note_page_turn(); - } - return; - } - let count = self.candidate_count(); - if count == 0 { - self.highlight = 0; - return; - } - let page_size = self.config.page_size; - let page_count = count.div_ceil(page_size); - let current = (self.highlight / page_size) as isize; - let target = (current + step).clamp(0, page_count as isize - 1) as usize; - if target != current as usize { - self.navigated = true; - self.engine.note_page_turn(); - } - self.highlight = (target * page_size).min(count - 1); - } - - /// 往 `step` 方向还有没有页可翻。 - pub(super) fn can_page(&self, step: isize) -> bool { - let page_count = self.candidate_count().div_ceil(self.config.page_size) as isize; - let target = (self.highlight / self.config.page_size) as isize + step; - (0..page_count).contains(&target) - } - - /// 横排矩阵:上 / 下键竖着移 `delta` 行(列不变,目标格没候选就取最近的);还是单行就先从当前页展开 - /// (展开本身算变化)。返回高亮是否真的动了。 - pub(super) fn move_rows(&mut self, delta: isize) -> bool { - let expanding = self.grid.is_none(); - self.shift_grid_rows(delta) || expanding - } - - /// 矩阵里顶在第一排还往上:高亮回到第一个候选、视口滚回顶部。返回是否动了。 - pub(super) fn jump_to_first(&mut self) -> bool { - let Some(Composed::Candidates { layout, .. }) = &self.composed else { - return false; - }; - if self.grid.is_none() || self.highlight == 0 { - return false; - } - self.highlight = 0; - self.grid = Some(Grid::at(layout, 0)); - self.navigated = true; - true - } - - /// 方向键按矩阵那套走:开了「横排时 ↑ / ↓ 展开」,或者矩阵已经展开着(翻页键展开的也算,不然展开后方向键会按单行移、走出视口)。 - pub(super) fn grid_keys(&self) -> bool { - self.config.grid_keys() || self.grid.is_some() - } - - /// 给 `arrows::route` 的矩阵状态:没开「横排时展开成多行」、单行、展开后高亮在不在第一行。 - pub(super) fn matrix(&self) -> Matrix { - match self.grid { - Some(_) => Matrix::Open { - first_row: self.highlight < self.config.page_size, - }, - None if self.config.grid_keys() => Matrix::Closed, - None => Matrix::Off, - } - } - - /// 矩阵里按阅读顺序移一格(越过行尾到下一行开头),跳过空位;没展开时不动。返回是否动了。 - pub(super) fn move_cells(&mut self, delta: isize) -> bool { - let Some(Composed::Candidates { layout, .. }) = &self.composed else { - return false; - }; - let Some(mut grid) = self.grid else { - return false; - }; - let moved = grid.move_cells(layout, self.highlight, delta); - self.grid = Some(grid); - self.land(moved) - } - - /// 矩阵里整屏翻(翻页键):一次 [`GRID_ROWS`] 行。返回是否动了。 - pub(super) fn move_screens(&mut self, delta: isize) -> bool { - self.shift_grid_rows(delta * GRID_ROWS as isize) - } - - /// 收回单行(Esc 第一下),高亮留在原处。返回原来是不是展开着。 - pub(super) fn collapse_grid(&mut self) -> bool { - self.grid.take().is_some() - } - - /// 矩阵视口里竖着移 `delta` 行;没展开就从当前页展开。返回高亮是否真的动了。 - fn shift_grid_rows(&mut self, delta: isize) -> bool { - let Some(Composed::Candidates { layout, .. }) = &self.composed else { - return false; - }; - let page = self.highlight / self.config.page_size; - let mut grid = self.grid.take().unwrap_or_else(|| Grid::at(layout, page)); - let moved = grid.move_rows(layout, self.highlight, delta); - self.grid = Some(grid); - self.land(moved) - } - - /// 高亮落到 `index`(`None` 是没动)。返回是否动了。 - fn land(&mut self, index: Option) -> bool { - let Some(index) = index else { - return false; - }; - self.highlight = index; - self.navigated = true; - true - } - pub(super) fn candidate_count(&self) -> usize { match &self.composed { Some(Composed::Candidates { layout, .. }) => layout.len(), @@ -460,246 +97,6 @@ impl Router { let candidate = self.layout_candidate(index)?; Some(self.engine.commit(&candidate)) } - - /// 按当前状态生成一帧:翻译评审优先;没在组句给空帧;否则给高亮所在的那一页。 - /// 焦点会话的 DLL 比 Server 老时按老协议降级(见 [`Self::downgrade_for_old_dll`])。 - pub(super) fn current_frame(&self) -> Frame { - let mut frame = self.raw_frame(); - self.show_typed_keys(&mut frame); - self.downgrade_for_old_dll(&mut frame); - frame - } - - /// 给 DLL 的帧只管应用输入框:双拼「输入框显示原始按键」开着时换成敲的键,拼音行由 Server 自绘照旧全拼。 - fn show_typed_keys(&self, frame: &mut Frame) { - if self.translation.is_some() { - return; - } - let Some(Composed::Candidates { - typed_keys: Some(keys), - .. - }) = &self.composed - else { - return; - }; - frame.preedit = vec![PreeditSegment { - text: keys.text.clone(), - kind: PreeditKind::Typed, - }]; - frame.cursor = keys.cursor; - } - - /// 自绘候选窗用的帧:不做老 DLL 降级,码段照常画。 - pub(super) fn self_drawn_frame(&self) -> Frame { - self.raw_frame() - } - - /// 协议比 Server 老的 DLL 不认识 `AuxCode` 段与颜文字、符号两种候选,收到会整条消息解析失败: - /// 码段降级成普通拼音段,那两种候选按 emoji 发(DLL 不看候选种类,只要解得出来)。 - fn downgrade_for_old_dll(&self, frame: &mut Frame) { - if self.focused_dll_protocol() >= PROTOCOL_VERSION { - return; - } - for segment in &mut frame.preedit { - if segment.kind == PreeditKind::AuxCode { - segment.kind = PreeditKind::Typed; - } - } - for candidate in &mut frame.candidates.items { - if matches!( - candidate.kind, - CandidateKind::Kaomoji | CandidateKind::Symbol - ) { - candidate.kind = CandidateKind::Emoji; - } - } - } - - /// 焦点会话的 DLL 协议版本;没有焦点会话时按最新(不必降级)。 - pub(super) fn focused_dll_protocol(&self) -> u32 { - self.focused - .and_then(|session| self.sessions.get(&session)) - .map_or(PROTOCOL_VERSION, |info| info.protocol) - } - - fn raw_frame(&self) -> Frame { - if let Some(translation) = &self.translation { - return self.translation_frame(translation); - } - let mut frame = self.composed_frame(); - frame.symbol_panel = self.panel_open(); - frame - } - - fn composed_frame(&self) -> Frame { - match &self.composed { - None => Frame::default(), - Some(Composed::Vietnamese { text, candidates }) => Frame { - preedit: vec![PreeditSegment { - text: text.clone(), - kind: PreeditKind::Typed, - }], - preedit_mode: self.config.preedit, - cursor: text.chars().count(), - window_preedit: None, - candidates: CandidateList { - items: candidates.clone(), - }, - highlight: self.highlight.min(candidates.len().saturating_sub(1)), - page: 0, - page_count: 1, - layout: self.config.layout, - columns: 0, - column_ems: Vec::new(), - appearance: self.config.appearance, - aux_code_show: false, - translation_pos: false, - sentence: None, - notice: None, - symbol_panel: false, - }, - Some(Composed::Raw { text, cursor }) => Frame { - preedit: vec![PreeditSegment { - text: text.clone(), - kind: PreeditKind::Typed, - }], - preedit_mode: self.config.preedit, - cursor: *cursor, - window_preedit: None, - candidates: CandidateList { items: Vec::new() }, - highlight: usize::MAX, - page: 0, - page_count: 1, - layout: self.config.layout, - columns: 0, - column_ems: Vec::new(), - appearance: self.config.appearance, - aux_code_show: self.config.aux_code_show, - translation_pos: self.config.translation_pos, - sentence: None, - notice: self.notice.clone(), - symbol_panel: false, - }, - Some(Composed::Candidates { - preedit, - cursor, - layout, - spelling, - .. - }) => { - let page_size = self.config.page_size; - let highlight = self.highlight.min(layout.len().saturating_sub(1)); - // 高亮候选的读音切法与首选不同(`ganga` 的 尴尬 / 刚啊):拼音行按它重切 - let candidate = layout.candidate(highlight); - let followed = (spelling.spelling_of(candidate) != spelling.spelled).then(|| { - let mut followed = (**spelling).clone(); - followed.follow(candidate); - followed - }); - let query = followed.as_ref().unwrap_or(spelling); - let (preedit, cursor) = match &followed { - Some(followed) => { - let (preedit, cursor, _) = marked_parts(followed); - (preedit, cursor) - } - None => (preedit.clone(), *cursor), - }; - let window_preedit = window_parts(&self.engine, query, candidate, &preedit); - let page = highlight / page_size; - // 横排展开成矩阵时画视口里的几行(空位是占位候选),序号只标在高亮所在那一行; - // 单行时只画当前页。高亮下标跟着换成视口内的。 - let viewport = self.grid.as_ref().map(|grid| grid.rows(layout)); - // 视口首格在整份候选里的下标(单行时是页首) - let first = match &viewport { - Some(rows) => rows.start * page_size, - None => page * page_size, - }; - let (items, columns) = - match viewport { - Some(rows) => { - let mut items = Vec::new(); - for row in rows { - let mut cells = layout.page(row); - cells.resize(page_size, Cell::Empty); - items.extend(cells.into_iter().map(|cell| { - cell.candidate().cloned().unwrap_or_else(empty_cell) - })); - } - (items, page_size) - } - None => ( - layout - .page(page) - .into_iter() - .filter_map(|cell| cell.candidate().cloned()) - .collect(), - 0, - ), - }; - let mut candidates = CandidateList { items }; - self.engine.annotate(&mut candidates); - self.annotate_sentence_translation(&mut candidates); - Frame { - preedit, - preedit_mode: self.config.preedit, - cursor, - window_preedit, - candidates, - // 方向键在拼音行里时候选不画高亮,空格照样上屏原来高亮的那个 - highlight: if self.config.two_area_arrows() && self.focus == Focus::Pinyin { - usize::MAX - } else { - highlight - first - }, - page, - page_count: layout.pages().max(1), - layout: self.config.layout, - columns, - column_ems: if columns > 0 { - Grid::column_ems(layout) - } else { - Vec::new() - }, - appearance: self.config.appearance, - aux_code_show: self.config.aux_code_show, - translation_pos: self.config.translation_pos, - sentence: self.sentence.clone(), - notice: self.notice.clone(), - symbol_panel: false, - } - } - } - } -} - -/// 矩阵视口里空位的占位候选:文本为空,渲染端按空格子画。 -fn empty_cell() -> Candidate { - Candidate { - text: String::new(), - kind: CandidateKind::Chinese, - syllables: Vec::new(), - reading: None, - translation: None, - aux_code: None, - } -} - -/// 候选窗口顶部的拼音行:双拼「候选窗显示按键」开着时是敲的键;带调拼音开着、高亮候选读得出声调时换成它的读音; -/// 与行内拼音相同时不另给。 -fn window_parts( - engine: &Engine, - query: &Query, - candidate: Option<&Candidate>, - preedit: &[PreeditSegment], -) -> Option, usize)>> { - let (segments, cursor) = if query.shuangpin_raw_window { - query.window_segments(None) - } else { - let toned = engine.toned_syllables(candidate?)?; - query.window_segments(Some(&toned)) - }; - let segments: Vec = segments.iter().map(Into::into).collect(); - (segments != preedit).then(|| Box::new((segments, cursor))) } /// 一次查询的拼音行分段与光标,以及输入框另显示原始按键时的那一串。 @@ -714,78 +111,4 @@ pub(super) fn marked_parts(query: &Query) -> (Vec, usize, Option } #[cfg(test)] -mod tests { - use super::*; - use crate::dispatch::{Router, RouterConfig}; - use qingjian_core::Engine; - use qingjian_dictionary::Dictionary; - use qingjian_platform::protocol::{ClientMessage, KeyEvent, KeyModifiers, SessionId}; - - fn router() -> Router { - let mut router = Router::new( - Engine::new(Dictionary::parse("你\tni\t100\n").unwrap()), - RouterConfig::default(), - ); - router.handle(ClientMessage::OpenSession { - session: SessionId(1), - app: None, - protocol: PROTOCOL_VERSION, - }); - router - } - - fn compose(router: &mut Router, text: &str) { - for c in text.chars() { - router - .handle(ClientMessage::Key { - session: SessionId(1), - event: KeyEvent::new(c as u32, Some(c), KeyModifiers::default()), - }) - .unwrap(); - } - } - - /// 回归 #283:DLL 报来的光标前文要成为云联想的 `before` 上下文; - /// 没前文时不带上下文,空文本不算上下文,组句结束即作废。 - #[test] - fn surrounding_context_flows_to_prediction_and_clears_on_reset() { - let mut router = router(); - compose(&mut router, "ni"); - assert!(router.surrounding_for_prediction().is_none()); - - router.handle(ClientMessage::Surrounding { - session: SessionId(1), - text: "这个文件权限有问题,".to_owned(), - }); - let surrounding = router.surrounding_for_prediction().expect("应带上前文"); - assert_eq!(surrounding.before, "这个文件权限有问题,"); - assert_eq!(surrounding.after, ""); - - // 空文本不算上下文(Engine 只靠拼音猜,不猜错的) - router.handle(ClientMessage::Surrounding { - session: SessionId(1), - text: String::new(), - }); - assert!(router.surrounding_for_prediction().is_none()); - - // 组句结束:前文作废,等下一段组句 DLL 再送 - router.handle(ClientMessage::Surrounding { - session: SessionId(1), - text: "别的内容".to_owned(), - }); - router.stop_rescoring(); - assert!(router.surrounding_for_prediction().is_none()); - } - - /// 不是聚焦会话的前文不收,防止换应用后把 A 应用的文字带进 B 的联想。 - #[test] - fn surrounding_from_unfocused_session_is_dropped() { - let mut router = router(); - compose(&mut router, "ni"); - router.handle(ClientMessage::Surrounding { - session: SessionId(99), - text: "别的应用的前文".to_owned(), - }); - assert!(router.surrounding_for_prediction().is_none()); - } -} +mod tests; diff --git a/apps/windows/server/src/dispatch/composed/navigation.rs b/apps/windows/server/src/dispatch/composed/navigation.rs new file mode 100644 index 00000000..270528db --- /dev/null +++ b/apps/windows/server/src/dispatch/composed/navigation.rs @@ -0,0 +1,142 @@ +//! 组句中挪高亮、翻页、横排矩阵的行列移动。 + +use qingjian_core::candidate::arrows::Matrix; +use qingjian_core::{GRID_ROWS, Grid}; + +use super::Composed; +use crate::dispatch::Router; + +impl Router { + /// 高亮移动 `delta`,夹在 `[0, 末尾]`,到页边自然换页。 + pub(in crate::dispatch) fn move_highlight(&mut self, delta: isize) { + let count = self.candidate_count(); + if count == 0 { + self.highlight = 0; + return; + } + let next = (self.highlight as isize + delta).clamp(0, count as isize - 1) as usize; + self.navigated |= next != self.highlight; + self.highlight = next; + } + + /// 整页翻 `step`,高亮落到目标页第一个候选;矩阵展开着时一次翻一屏(与 macOS 壳的 `turn_page` 一致)。 + /// 开了「翻页键展开」(`RouterConfig::page_keys_expand`)时单行先展开,高亮移到上 / 下一行,与 `↓` 展开同一套。 + pub(in crate::dispatch) fn page(&mut self, step: isize) { + let moved = if self.grid.is_some() { + Some(self.move_screens(step)) + } else if self.config.page_keys_expand() { + Some(self.shift_grid_rows(step)) + } else { + None + }; + if let Some(moved) = moved { + if moved { + self.engine.note_page_turn(); + } + return; + } + let count = self.candidate_count(); + if count == 0 { + self.highlight = 0; + return; + } + let page_size = self.config.page_size; + let page_count = count.div_ceil(page_size); + let current = (self.highlight / page_size) as isize; + let target = (current + step).clamp(0, page_count as isize - 1) as usize; + if target != current as usize { + self.navigated = true; + self.engine.note_page_turn(); + } + self.highlight = (target * page_size).min(count - 1); + } + + /// 往 `step` 方向还有没有页可翻。 + pub(in crate::dispatch) fn can_page(&self, step: isize) -> bool { + let page_count = self.candidate_count().div_ceil(self.config.page_size) as isize; + let target = (self.highlight / self.config.page_size) as isize + step; + (0..page_count).contains(&target) + } + + /// 横排矩阵:上 / 下键竖着移 `delta` 行(列不变,目标格没候选就取最近的);还是单行就先从当前页展开 + /// (展开本身算变化)。返回高亮是否真的动了。 + pub(in crate::dispatch) fn move_rows(&mut self, delta: isize) -> bool { + let expanding = self.grid.is_none(); + self.shift_grid_rows(delta) || expanding + } + + /// 矩阵里顶在第一排还往上:高亮回到第一个候选、视口滚回顶部。返回是否动了。 + pub(in crate::dispatch) fn jump_to_first(&mut self) -> bool { + let Some(Composed::Candidates { layout, .. }) = &self.composed else { + return false; + }; + if self.grid.is_none() || self.highlight == 0 { + return false; + } + self.highlight = 0; + self.grid = Some(Grid::at(layout, 0)); + self.navigated = true; + true + } + + /// 方向键按矩阵那套走:开了「横排时 ↑ / ↓ 展开」,或者矩阵已经展开着(翻页键展开的也算,不然展开后方向键会按单行移、走出视口)。 + pub(in crate::dispatch) fn grid_keys(&self) -> bool { + self.config.grid_keys() || self.grid.is_some() + } + + /// 给 `arrows::route` 的矩阵状态:没开「横排时展开成多行」、单行、展开后高亮在不在第一行。 + pub(in crate::dispatch) fn matrix(&self) -> Matrix { + match self.grid { + Some(_) => Matrix::Open { + first_row: self.highlight < self.config.page_size, + }, + None if self.config.grid_keys() => Matrix::Closed, + None => Matrix::Off, + } + } + + /// 矩阵里按阅读顺序移一格(越过行尾到下一行开头),跳过空位;没展开时不动。返回是否动了。 + pub(in crate::dispatch) fn move_cells(&mut self, delta: isize) -> bool { + let Some(Composed::Candidates { layout, .. }) = &self.composed else { + return false; + }; + let Some(mut grid) = self.grid else { + return false; + }; + let moved = grid.move_cells(layout, self.highlight, delta); + self.grid = Some(grid); + self.land(moved) + } + + /// 矩阵里整屏翻(翻页键):一次 [`GRID_ROWS`] 行。返回是否动了。 + pub(in crate::dispatch) fn move_screens(&mut self, delta: isize) -> bool { + self.shift_grid_rows(delta * GRID_ROWS as isize) + } + + /// 收回单行(Esc 第一下),高亮留在原处。返回原来是不是展开着。 + pub(in crate::dispatch) fn collapse_grid(&mut self) -> bool { + self.grid.take().is_some() + } + + /// 矩阵视口里竖着移 `delta` 行;没展开就从当前页展开。返回高亮是否真的动了。 + fn shift_grid_rows(&mut self, delta: isize) -> bool { + let Some(Composed::Candidates { layout, .. }) = &self.composed else { + return false; + }; + let page = self.highlight / self.config.page_size; + let mut grid = self.grid.take().unwrap_or_else(|| Grid::at(layout, page)); + let moved = grid.move_rows(layout, self.highlight, delta); + self.grid = Some(grid); + self.land(moved) + } + + /// 高亮落到 `index`(`None` 是没动)。返回是否动了。 + fn land(&mut self, index: Option) -> bool { + let Some(index) = index else { + return false; + }; + self.highlight = index; + self.navigated = true; + true + } +} diff --git a/apps/windows/server/src/dispatch/composed/prediction.rs b/apps/windows/server/src/dispatch/composed/prediction.rs new file mode 100644 index 00000000..3117c5dc --- /dev/null +++ b/apps/windows/server/src/dispatch/composed/prediction.rs @@ -0,0 +1,153 @@ +//! 组句中的云联想:发请求、收结果并进候选布局,按需联想与光标前文。 + +use qingjian_core::{CloudWord, SurroundingText}; + +use super::Composed; +use crate::dispatch::Router; + +impl Router { + /// 拉一次云联想结果:云端词并进候选布局,整句补全记下;翻译评审时结果是译文。 + pub(in crate::dispatch) fn poll_prediction(&mut self) { + if !self.engine.prediction_enabled() { + return; + } + let Some(prediction) = self.engine.poll_prediction() else { + return; + }; + if self + .candidate_translation + .as_ref() + .is_some_and(|(sequence, _, _)| *sequence == prediction.sequence) + { + let (_, candidate, independent_language) = + self.candidate_translation.take().expect("刚检查过候选译文"); + let generated = prediction.sentence.filter(|text| !text.trim().is_empty()); + let updated = if independent_language.is_some() { + generated.is_some() + } else { + generated.as_ref().is_some_and(|generated| { + self.engine + .set_generated_candidate_translation(&candidate, generated) + }) + }; + self.notice = Some(if updated { + if let Some(language) = independent_language { + self.set_independent_candidate_translation( + &candidate.text, + language, + generated.as_deref().expect("updated 已检查"), + ); + format!("已更新「{}」的中文译词", candidate.text) + } else { + self.refresh_candidate_translation(&candidate.text); + format!("已更新「{}」的译文", candidate.text) + } + } else { + "模型没有生成可用译文".to_owned() + }); + return; + } + if self.translation.is_some() { + match prediction.sentence { + Some(text) => { + if let Some(translation) = self.translation.as_mut() { + translation.result = Some(text); + } + } + None => { + tracing::info!("翻译选中文字:云端没有给出译文"); + self.translation = None; + } + } + return; + } + if let Some(Composed::Candidates { + layout, + manual_sequence, + .. + }) = self.composed.as_mut() + { + let manual = manual_sequence.take() == Some(prediction.sequence); + self.sentence = prediction.sentence; + if self.navigated || self.highlight >= self.config.page_size { + return; + } + let words = prediction + .words + .into_iter() + .map(CloudWord::into_candidate) + .collect(); + if manual { + layout.set_cloud_first(words); + } else { + layout.set_cloud(words); + } + } + } + + pub(in crate::dispatch) fn cancel_prediction(&mut self) { + if let Some(Composed::Candidates { + manual_sequence, .. + }) = self.composed.as_mut() + { + *manual_sequence = None; + } + if self.engine.prediction_enabled() { + self.engine.cancel_prediction(); + } + } + + /// DLL 送来新的光标前文后重发一次云联想:作废在飞的空上下文请求,让云端拿得到 before。 + pub(in crate::dispatch) fn refresh_prediction(&mut self) { + if !self.config.cloud_enabled { + return; + } + let Some(Composed::Candidates { + layout, + manual_sequence, + .. + }) = &self.composed + else { + return; + }; + if manual_sequence.is_some() { + self.request_manual_prediction(); + return; + } + // 手动结果已到后保持当前布局;新输入会重建状态并恢复自动联想。 + if layout.cloud_first() { + return; + } + let local = layout.local().to_vec(); + let surrounding = self.surrounding_for_prediction(); + self.engine.request_prediction(surrounding, &local); + } + + /// 显式触发时使用同一份应用上下文,并记录序号供结果布局匹配。 + pub(in crate::dispatch) fn request_manual_prediction(&mut self) -> bool { + if !self.config.cloud_enabled { + return false; + } + let surrounding = self.surrounding_for_prediction(); + let Some(Composed::Candidates { + layout, + manual_sequence, + .. + }) = self.composed.as_mut() + else { + return false; + }; + *manual_sequence = self + .engine + .request_manual_prediction(surrounding, layout.local()); + manual_sequence.is_some() + } + + /// DLL 报来的光标前文包成云联想要的形状;没有就 `None`(Engine 只靠拼音猜)。 + pub(in crate::dispatch) fn surrounding_for_prediction(&self) -> Option { + (!self.surrounding_before.is_empty()).then(|| SurroundingText { + before: self.surrounding_before.clone(), + after: String::new(), + }) + } +} diff --git a/apps/windows/server/src/dispatch/composed/sentence_translation.rs b/apps/windows/server/src/dispatch/composed/sentence_translation.rs deleted file mode 100644 index af72ce08..00000000 --- a/apps/windows/server/src/dispatch/composed/sentence_translation.rs +++ /dev/null @@ -1,21 +0,0 @@ -//! 一条本地整句候选的异步译文。 - -use qingjian_core::Translation; - -pub(crate) struct SentenceTranslation { - pub(crate) text: String, - - pub(crate) sequence: u64, - - pub(crate) translation: Option, -} - -impl SentenceTranslation { - pub(crate) fn pending(text: String, sequence: u64) -> Self { - Self { - text, - sequence, - translation: None, - } - } -} diff --git a/apps/windows/server/src/dispatch/composed/state.rs b/apps/windows/server/src/dispatch/composed/state.rs index 7866cdef..bf2588e5 100644 --- a/apps/windows/server/src/dispatch/composed/state.rs +++ b/apps/windows/server/src/dispatch/composed/state.rs @@ -3,8 +3,6 @@ use qingjian_core::{CandidateLayout, Query}; use qingjian_platform::protocol::PreeditSegment; -use super::SentenceTranslation; - /// 当前组句缓冲对应的展示状态。缓冲变化时重建,导航只挪高亮,云端词异步并进 `layout`。 pub(crate) enum Composed { /// 越南语 Simple Telex:转换结果第一、原始按键第二,无拼音查询状态。 @@ -31,9 +29,6 @@ pub(crate) enum Composed { /// 这轮查询的结果(候选已挪进 `layout`):高亮候选的读音切法不同时拼音行按它重切,见 [`Query::follow`]。 spelling: Box, - /// 首条本地整句候选的云端译文;未返回时为 `None`。 - sentence_translation: Option, - /// 正在等待的手动请求序号,收到后消费;重建输入时清除。 manual_sequence: Option, }, diff --git a/apps/windows/server/src/dispatch/composed/tests.rs b/apps/windows/server/src/dispatch/composed/tests.rs new file mode 100644 index 00000000..617181c1 --- /dev/null +++ b/apps/windows/server/src/dispatch/composed/tests.rs @@ -0,0 +1,75 @@ +//! 组句与云联想的光标前文。 + +use crate::dispatch::{Router, RouterConfig}; +use qingjian_core::Engine; +use qingjian_dictionary::Dictionary; +use qingjian_platform::protocol::PROTOCOL_VERSION; +use qingjian_platform::protocol::{ClientMessage, KeyEvent, KeyModifiers, SessionId}; + +fn router() -> Router { + let mut router = Router::new( + Engine::new(Dictionary::parse("你\tni\t100\n").unwrap()), + RouterConfig::default(), + ); + router.handle(ClientMessage::OpenSession { + session: SessionId(1), + app: None, + protocol: PROTOCOL_VERSION, + }); + router +} + +fn compose(router: &mut Router, text: &str) { + for c in text.chars() { + router + .handle(ClientMessage::Key { + session: SessionId(1), + event: KeyEvent::new(c as u32, Some(c), KeyModifiers::default()), + }) + .unwrap(); + } +} + +/// 回归 #283:DLL 报来的光标前文要成为云联想的 `before` 上下文; +/// 没前文时不带上下文,空文本不算上下文,组句结束即作废。 +#[test] +fn surrounding_context_flows_to_prediction_and_clears_on_reset() { + let mut router = router(); + compose(&mut router, "ni"); + assert!(router.surrounding_for_prediction().is_none()); + + router.handle(ClientMessage::Surrounding { + session: SessionId(1), + text: "这个文件权限有问题,".to_owned(), + }); + let surrounding = router.surrounding_for_prediction().expect("应带上前文"); + assert_eq!(surrounding.before, "这个文件权限有问题,"); + assert_eq!(surrounding.after, ""); + + // 空文本不算上下文(Engine 只靠拼音猜,不猜错的) + router.handle(ClientMessage::Surrounding { + session: SessionId(1), + text: String::new(), + }); + assert!(router.surrounding_for_prediction().is_none()); + + // 组句结束:前文作废,等下一段组句 DLL 再送 + router.handle(ClientMessage::Surrounding { + session: SessionId(1), + text: "别的内容".to_owned(), + }); + router.stop_rescoring(); + assert!(router.surrounding_for_prediction().is_none()); +} + +/// 不是聚焦会话的前文不收,防止换应用后把 A 应用的文字带进 B 的联想。 +#[test] +fn surrounding_from_unfocused_session_is_dropped() { + let mut router = router(); + compose(&mut router, "ni"); + router.handle(ClientMessage::Surrounding { + session: SessionId(99), + text: "别的应用的前文".to_owned(), + }); + assert!(router.surrounding_for_prediction().is_none()); +} diff --git a/apps/windows/server/src/dispatch/config.rs b/apps/windows/server/src/dispatch/config.rs index d4eceff8..afbf5bb7 100644 --- a/apps/windows/server/src/dispatch/config.rs +++ b/apps/windows/server/src/dispatch/config.rs @@ -20,9 +20,6 @@ pub struct RouterConfig { /// 是否请求云端候选词和整句补全(`[predict] enabled`)。 pub cloud_enabled: bool, - /// 本地整句候选是否异步请求译文(`[predict] sentence_translation`)。 - pub sentence_translation: bool, - /// 中文模式下 Shift+字母收进组句缓冲区(`[general] shift_letter = "compose"`)。 /// 关着(缺省)时壳把大写字母交给应用,与以前一致。 pub shift_letter_compose: bool, @@ -116,6 +113,9 @@ pub struct RouterConfig { /// 「翻译选中文字」快捷键(`[shortcut] translate_selection`)。 pub translate_selection: KeyCombo, + /// 没在组句时打开符号面板翻译组的快捷键(`[shortcut] translate_panel`)。 + pub translate_panel: KeyCombo, + /// 组句中按需调用 AI 的快捷键(`[shortcut] predict`)。 pub predict: KeyCombo, @@ -199,7 +199,6 @@ impl From<&Config> for RouterConfig { page_size: config.general.page_size(), cloud_slots: config.predict.slots, cloud_enabled: config.predict.enabled, - sentence_translation: config.predict.sentence_translation, shift_letter_compose: config.general.shift_letter.compose(), numpad_digit: config.general.numpad_digit, layout: config.general.layout, @@ -236,6 +235,7 @@ impl From<&Config> for RouterConfig { (first.into(), second.into()) }, translate_selection: config.shortcut.translate_selection, + translate_panel: config.shortcut.translate_panel, predict: config.shortcut.predict, commit_keys: config.shortcut.commit_keys(), toggle_traditional: config diff --git a/apps/windows/server/src/dispatch/key/codes.rs b/apps/windows/server/src/dispatch/key/codes.rs index 5891953a..e06209ee 100644 --- a/apps/windows/server/src/dispatch/key/codes.rs +++ b/apps/windows/server/src/dispatch/key/codes.rs @@ -23,6 +23,7 @@ pub(crate) const LEFT: u32 = 0x25; pub(crate) const UP: u32 = 0x26; pub(crate) const RIGHT: u32 = 0x27; pub(crate) const DOWN: u32 = 0x28; +pub(crate) const DELETE: u32 = 0x2E; /// 翻页键对 `(上一页, 下一页)`:返回 -1 / +1。 pub(crate) fn page_key(event: &KeyEvent, page_keys: (char, char)) -> Option { diff --git a/apps/windows/server/src/dispatch/key/panel.rs b/apps/windows/server/src/dispatch/key/panel/mod.rs similarity index 93% rename from apps/windows/server/src/dispatch/key/panel.rs rename to apps/windows/server/src/dispatch/key/panel/mod.rs index 78ca37f0..07436d1c 100644 --- a/apps/windows/server/src/dispatch/key/panel.rs +++ b/apps/windows/server/src/dispatch/key/panel/mod.rs @@ -1,6 +1,8 @@ //! 符号面板开着时的按键:方向键在格子里移动、翻页键(PgUp / PgDn)整屏翻,Tab / Shift+Tab 换组, //! `[` `]` 与配置的翻页键换分类,空格 / 回车 / 数字上屏,Esc / 退格收起回到候选;敲字母先收起面板再照常组句。 -//! 与 macOS 壳的 `handle_panel_key` 对齐。 +//! 与 macOS 壳的 `handle_panel_key` 对齐。翻译组里的按键另走 [`translate`]。 + +mod translate; use qingjian_core::GRID_ROWS; use qingjian_platform::protocol::KeyEvent; @@ -14,6 +16,9 @@ impl Router { if !self.panel_open() { return None; } + if self.engine.panel_translating() { + return self.apply_translate_key(event); + } if event.modifiers.has_command_key() { return Some(Effect::Passthrough); } diff --git a/apps/windows/server/src/dispatch/key/panel/translate.rs b/apps/windows/server/src/dispatch/key/panel/translate.rs new file mode 100644 index 00000000..c5459f8f --- /dev/null +++ b/apps/windows/server/src/dispatch/key/panel/translate.rs @@ -0,0 +1,163 @@ +//! 符号面板翻译组里的按键:字母照常打拼音(交回 [`Router::apply_key`] 的组句路径),空格 / 数字把候选选进框里(不进应用), +//! 英文模式下敲的字直接进框;`[` `]` 换目标语言;打拼音时 ←→ 挪高亮、↑↓ 与翻页键翻候选, +//! 没在打拼音时 ←→ / Home / End 挪光标、Delete 删光标后的字; +//! 回车上屏译文(还没好就马上发、再按一次),Esc 放弃,退格先删拼音再删框里的字。与 macOS 壳的 `handle_translate_key` 对齐。 + +use qingjian_platform::protocol::KeyEvent; + +use crate::dispatch::Router; +use crate::dispatch::key::{Effect, codes}; + +impl Router { + pub(super) fn apply_translate_key(&mut self, event: &KeyEvent) -> Option { + if event.modifiers.has_command_key() { + return Some(Effect::Passthrough); + } + let composing = !self.engine.composition().is_empty(); + let page = self.config.page_size as isize; + match event.virtual_key { + codes::TAB => { + let delta = if event.modifiers.shift { -1 } else { 1 }; + self.switch_panel(|panel| panel.switch_group(delta)); + return Some(Effect::Navigated); + } + codes::ESCAPE => return Some(self.discard_box()), + codes::BACK => { + if composing { + self.engine.backspace(); + } else if !self.engine.panel_backspace() { + self.close_panel(); + } + return Some(Effect::Changed(None)); + } + codes::RETURN => { + if composing { + self.pick_highlighted_into_box(); + } + return Some(match self.engine.take_panel_translation() { + Some(text) => { + tracing::debug!( + chars = text.chars().count(), + "panel translation committed" + ); + self.panel = None; + Effect::Changed(Some(text)) + } + // 拼音选进了框要重建组句;没打拼音时只重画,重建会把在飞的译文请求一起作废 + None => { + self.engine.hurry_panel_translation(); + if composing { + Effect::Changed(None) + } else { + Effect::Navigated + } + } + }); + } + codes::LEFT | codes::RIGHT => { + let delta = if event.virtual_key == codes::LEFT { + -1 + } else { + 1 + }; + if composing { + return Some(self.move_box_highlight(delta)); + } + self.engine.move_panel_cursor(delta); + return Some(Effect::Navigated); + } + // Home / End 把光标挪到最前 / 最后,Delete 删光标后一个字 + codes::HOME | codes::END if !composing => { + let position = if event.virtual_key == codes::HOME { + 0 + } else { + usize::MAX + }; + self.engine.place_panel_cursor(position); + return Some(Effect::Navigated); + } + codes::DELETE if !composing => { + self.engine.panel_delete(); + return Some(Effect::Changed(None)); + } + codes::UP | codes::PRIOR => return Some(self.move_box_highlight(-page)), + codes::DOWN | codes::NEXT => return Some(self.move_box_highlight(page)), + _ => {} + } + let c = event.character.filter(|c| !c.is_control())?; + if c == '[' || c == ']' { + let step = if c == '[' { -1 } else { 1 }; + self.switch_panel(|panel| panel.switch_section(step)); + return Some(Effect::Navigated); + } + if event.modifiers.caps || event.modifiers.english_mode { + if composing { + self.pick_highlighted_into_box(); + } + self.engine.push_panel_text(c.encode_utf8(&mut [0; 4])); + return Some(Effect::Changed(None)); + } + if composing { + if c == ' ' { + self.pick_highlighted_into_box(); + return Some(Effect::Changed(None)); + } + if let Some(digit) = codes::digit(event) { + let first = self.highlight / self.config.page_size * self.config.page_size; + self.pick_into_box(first + digit - 1); + return Some(Effect::Changed(None)); + } + if let Some(step) = self.page_step(event) { + return Some(self.move_box_highlight(step * page)); + } + } + if c.is_ascii_alphabetic() || (composing && c == '\'') { + // 拼音照常组句;面板不收起 + return None; + } + // 标点、数字与空格:正在打的拼音先按高亮候选选进去,再把字加在后面 + if composing { + self.pick_highlighted_into_box(); + } + self.engine.push_panel_char(c); + Some(Effect::Changed(None)) + } + + /// 选第 `index` 个候选进框(点框里的候选也走这里)。 + pub(in crate::dispatch) fn pick_into_box(&mut self, index: usize) -> bool { + let Some(candidate) = self.layout_candidate(index) else { + return false; + }; + self.engine.commit_to_panel(&candidate) + } + + fn pick_highlighted_into_box(&mut self) { + self.pick_into_box(self.highlight); + } + + /// 高亮挪 `delta` 个,夹在候选范围里。 + fn move_box_highlight(&mut self, delta: isize) -> Effect { + let count = self.candidate_count(); + if count > 0 { + self.highlight = + (self.highlight as isize + delta).clamp(0, count as isize - 1) as usize; + self.navigated = true; + } + Effect::Navigated + } + + /// Esc:框里有字就连拼音一起扔掉;空框时同别的组一样回到候选。 + fn discard_box(&mut self) -> Effect { + let empty = self + .engine + .symbol_panel() + .is_none_or(|panel| panel.translate_source().is_empty()); + if empty { + self.close_panel(); + } else { + self.engine.clear(); + self.panel = None; + } + Effect::Changed(None) + } +} diff --git a/apps/windows/server/src/dispatch/key/shortcut.rs b/apps/windows/server/src/dispatch/key/shortcut.rs index bda169cf..974050c9 100644 --- a/apps/windows/server/src/dispatch/key/shortcut.rs +++ b/apps/windows/server/src/dispatch/key/shortcut.rs @@ -57,7 +57,6 @@ impl Router { items: vec![candidate], }; self.engine.annotate(&mut list); - self.annotate_sentence_translation(&mut list); list.items.pop() } } diff --git a/apps/windows/server/src/dispatch/message.rs b/apps/windows/server/src/dispatch/message.rs index 1c4223da..641b4ff0 100644 --- a/apps/windows/server/src/dispatch/message.rs +++ b/apps/windows/server/src/dispatch/message.rs @@ -5,6 +5,8 @@ use qingjian_platform::protocol::{ ServerMessage, SessionId, }; +use std::time::Instant; + use super::Router; use super::key::Effect; use super::session::SessionInfo; @@ -141,6 +143,22 @@ impl Router { if self.translation.is_some() { return self.handle_translation_review(session, &event); } + if self.engine.composition().is_empty() + && !self.panel_open() + && self.matches_translate_panel_combo(&event) + && self.open_translate_panel() + { + tracing::debug!(?session, "translate panel opened by shortcut"); + let shown = self.self_drawn_frame(); + self.reconcile_candidates(&shown); + return ServerMessage::KeyResult { + session, + outcome: KeyOutcome::Consumed, + commit: None, + frame: self.current_frame(), + english: None, + }; + } if self.config.cloud_enabled && self.engine.composition().is_empty() && !self.panel_open() @@ -177,6 +195,7 @@ impl Router { // 选词没选完一段拼音时 Core 把词留在组句里、上屏文本是空串:不让 DLL 结束组句 let commit = commit.filter(|text| !text.is_empty()); self.poll_prediction(); + self.engine.tick_panel_translation(Instant::now()); // 自绘窗吃未降级的帧;发给 DLL 的那份按老协议降级(见 composed 的 current_frame) let shown = self.self_drawn_frame(); self.reconcile_candidates(&shown); @@ -198,6 +217,7 @@ impl Router { } let frame = if self.focused == Some(session) { self.poll_prediction(); + self.engine.tick_panel_translation(Instant::now()); let shown = self.self_drawn_frame(); self.reconcile_candidates(&shown); self.current_frame() diff --git a/apps/windows/server/src/dispatch/panel/frame.rs b/apps/windows/server/src/dispatch/panel/frame.rs index 8369c821..991b3cb6 100644 --- a/apps/windows/server/src/dispatch/panel/frame.rs +++ b/apps/windows/server/src/dispatch/panel/frame.rs @@ -1,7 +1,7 @@ //! 面板一次要画的内容,Router 产出、UI 线程画。 use qingjian_platform::Appearance; -pub use qingjian_render::PanelCell; +pub use qingjian_render::{PanelCell, PanelTranslate}; /// 面板一帧:两行标签、视口里的格子与信息行。 #[derive(Debug, Clone, PartialEq, Eq)] @@ -31,4 +31,7 @@ pub struct PanelFrame { pub footer: Option, pub appearance: Appearance, + + /// 翻译组的翻译框;有它时 UI 不画格子。 + pub translate: Option, } diff --git a/apps/windows/server/src/dispatch/panel/mod.rs b/apps/windows/server/src/dispatch/panel/mod.rs index cb535cd6..d0a785c7 100644 --- a/apps/windows/server/src/dispatch/panel/mod.rs +++ b/apps/windows/server/src/dispatch/panel/mod.rs @@ -6,8 +6,9 @@ mod frame; mod grid; mod mouse; -use qingjian_core::SymbolPanel; +use qingjian_core::{PanelGroup, SymbolPanel}; use qingjian_platform::protocol::SessionId; +use qingjian_render::PanelTranslate; pub use self::frame::{PanelCell, PanelFrame}; pub(super) use self::grid::PanelGrid; @@ -46,12 +47,23 @@ impl Router { true } - /// 格子换成当前分类的条目,高亮回到第一格。 + /// 快捷键:没在组句时打开面板停在翻译组。翻译不可用时不开,返回 `false`。 + pub(in crate::dispatch) fn open_translate_panel(&mut self) -> bool { + if !self.engine.open_translate_panel() { + tracing::info!("translate panel unavailable: cloud translation is off"); + return false; + } + self.load_panel(); + true + } + + /// 格子换成当前分类的条目,高亮回到第一格。翻译组没有格子(空着占位),框里的候选是组句的。 fn load_panel(&mut self) { self.panel = self .engine .symbol_panel() .map(|panel| PanelGrid::new(panel.section().items.clone(), panel.section().columns())); + self.highlight = 0; } pub(super) fn panel_open(&self) -> bool { @@ -100,6 +112,15 @@ impl Router { PanelMouse::Section(section) => { self.switch_panel(|panel| panel.select_section(section)) } + PanelMouse::Caret(position) => self.engine.place_panel_cursor(position), + PanelMouse::Candidate(offset) => { + let first = self.highlight / self.config.page_size * self.config.page_size; + if !self.pick_into_box(first + offset) { + return; + } + self.recompose(); + true + } PanelMouse::Close => { self.close_panel(); self.recompose(); @@ -135,6 +156,7 @@ impl Router { let grid = self.panel.as_ref()?; let groups = panel.groups(); let (sections, section) = panel.group_sections(); + let translate = (panel.group() == PanelGroup::Translate).then(|| self.box_frame(panel)); Some(PanelFrame { groups: groups.iter().map(|g| g.label().to_owned()).collect(), group: groups.iter().position(|g| *g == panel.group()).unwrap_or(0), @@ -145,9 +167,28 @@ impl Router { highlight: grid.highlight_in_view(), footer: grid.footer(), appearance: self.config.appearance, + translate, }) } + /// 翻译框:原文、还在打的拼音与高亮所在那一页候选。 + fn box_frame(&self, panel: &SymbolPanel) -> PanelTranslate { + let size = self.config.page_size.max(1); + let first = self.highlight / size * size; + PanelTranslate { + source: panel.translate_source().to_owned(), + cursor: panel.translate_cursor(), + placeholder: panel.translate_placeholder().to_owned(), + composing: self.engine.composition().text().to_owned(), + candidates: (first..first + size) + .map_while(|index| self.layout_candidate(index)) + .map(|candidate| candidate.text) + .collect(), + highlight: self.highlight - first, + status: panel.translate_line(), + } + } + /// 面板开着时画面板(不画候选窗);没变就不重画。没有光标矩形(从菜单打开)时由 UI 摆在鼠标旁。 pub(super) fn reconcile_panel(&mut self, frame: PanelFrame) { let rect = self.last_rect; diff --git a/apps/windows/server/src/dispatch/panel/mouse.rs b/apps/windows/server/src/dispatch/panel/mouse.rs index 838c58de..acf4e295 100644 --- a/apps/windows/server/src/dispatch/panel/mouse.rs +++ b/apps/windows/server/src/dispatch/panel/mouse.rs @@ -14,6 +14,12 @@ pub enum PanelMouse { /// 滚轮滚了几行,正数往下。 Scroll(isize), + /// 点翻译框的原文:光标放到第几个字之后。 + Caret(usize), + + /// 点翻译框里当前一页的第几个候选:选进框里。 + Candidate(usize), + /// 点右上角的 ✕。 Close, } diff --git a/apps/windows/server/src/dispatch/reload/mod.rs b/apps/windows/server/src/dispatch/reload/mod.rs index 8767533b..25f252ee 100644 --- a/apps/windows/server/src/dispatch/reload/mod.rs +++ b/apps/windows/server/src/dispatch/reload/mod.rs @@ -31,9 +31,9 @@ fn mtime(path: &Path) -> Option { .ok() } -/// 按 `[predict]` 接云联想、整句候选翻译与释义兜底;关着或缺密钥就退回本地实现。启动与热加载共用。 +/// 按 `[predict]` 接云联想与释义兜底;关着或缺密钥就退回本地实现。启动与热加载共用。 pub fn attach_cloud(engine: &mut Engine, predict: &PredictConfig, usage_path: Option) { - if !predict.enabled && !predict.sentence_translation { + if !predict.enabled { tracing::info!("云联想未开启([predict] enabled = false)"); engine.set_predictor(Box::new(NoPredictor)); engine.set_gloss_filler(Box::new(NoGlossFiller)); @@ -42,7 +42,7 @@ pub fn attach_cloud(engine: &mut Engine, predict: &PredictConfig, usage_path: Op match CloudPredictor::new_with_usage(predict, usage_path.clone()) { Ok(predictor) => { engine.set_predictor(Box::new(predictor)); - tracing::info!(model = %predict.model, cloud = predict.enabled, sentence_translation = predict.sentence_translation, "云联想已接入"); + tracing::info!(model = %predict.model, "云联想已接入"); } Err(error) => { tracing::warn!(%error, "云联想接入失败(缺 API key?),退回本地候选"); diff --git a/apps/windows/server/src/dispatch/translate/mod.rs b/apps/windows/server/src/dispatch/translate/mod.rs index 78d4d026..c5978568 100644 --- a/apps/windows/server/src/dispatch/translate/mod.rs +++ b/apps/windows/server/src/dispatch/translate/mod.rs @@ -21,6 +21,13 @@ impl Router { && event.modifiers.chord() == KeyModifiers::from(combo.modifiers) } + /// 翻译组快捷键:同上,比字符与物理修饰键。 + pub(super) fn matches_translate_panel_combo(&self, event: &KeyEvent) -> bool { + let combo = self.config.translate_panel; + event.character == Some(combo.key) + && event.modifiers.chord() == KeyModifiers::from(combo.modifiers) + } + /// DLL 回来的选区:非空且云服务开着就进入评审;否则回空帧让 DLL 清掉本地翻译态。 /// 回给 DLL 的帧恒空(候选窗在 Server 自绘)。 pub(super) fn handle_selection( diff --git a/apps/windows/server/src/ui/panel/mod.rs b/apps/windows/server/src/ui/panel/mod.rs index ba976c8e..0600165a 100644 --- a/apps/windows/server/src/ui/panel/mod.rs +++ b/apps/windows/server/src/ui/panel/mod.rs @@ -31,6 +31,8 @@ static CLASS: WindowClass = WindowClass::new(); const HINT: &str = "Tab 换组 · [ ] 换类 · 双击或空格上屏"; +const TRANSLATE_HINT: &str = "Tab 换组 · [ ] 换语言 · 回车上屏"; + /// 面板上的鼠标操作交给 Router 的回调(UI 线程上调,投给工人线程)。 pub type PanelEvents = Box; @@ -216,7 +218,13 @@ fn content(frame: PanelFrame) -> qingjian_render::PanelFrame { rows: GRID_ROWS, highlight: frame.highlight, footer: frame.footer, - hint: HINT.to_owned(), + hint: if frame.translate.is_some() { + TRANSLATE_HINT + } else { + HINT + } + .to_owned(), + translate: frame.translate, } } @@ -293,6 +301,8 @@ fn click(double: bool) { Some(HitTarget::Panel(PanelHit::Cell(offset))) => PanelMouse::Cell { offset, double }, Some(HitTarget::Panel(PanelHit::Group(group))) => PanelMouse::Group(group), Some(HitTarget::Panel(PanelHit::Section(section))) => PanelMouse::Section(section), + Some(HitTarget::Panel(PanelHit::Candidate(index))) => PanelMouse::Candidate(index), + Some(HitTarget::Panel(PanelHit::Caret(position))) => PanelMouse::Caret(position), Some(HitTarget::Panel(PanelHit::Close)) => PanelMouse::Close, _ => return, }; diff --git a/apps/windows/server/tests/engine_loop/click.rs b/apps/windows/server/tests/engine_loop/click.rs index 888f7128..1bc761eb 100644 --- a/apps/windows/server/tests/engine_loop/click.rs +++ b/apps/windows/server/tests/engine_loop/click.rs @@ -2,7 +2,6 @@ use qingjian_render::HitTarget; -use crate::composing::SentenceTranslator; use crate::support::*; fn poll(router: &mut Router, session: SessionId) -> (Frame, Option) { @@ -37,28 +36,6 @@ fn clicking_a_translation_commits_that_sense() { assert_eq!(poll(&mut router, SESSION).1.as_deref(), Some("hello")); } -#[test] -fn clicking_a_sentence_translation_commits_it() { - let submitted = Arc::new(Mutex::new(Vec::new())); - let mut router = router_with(RouterConfig { - sentence_translation: true, - ..RouterConfig::default() - }); - router - .engine_mut() - .set_predictor(Box::new(SentenceTranslator::new(submitted))); - let (_, _, frame) = type_letters(&mut router, "xiangkaifa"); - let slot = slot_of(&frame, "想开发"); - router.handle_click(HitTarget::Translation { - candidate: slot as usize - 1, - sense: 0, - }); - assert_eq!( - poll(&mut router, SESSION).1.as_deref(), - Some("It seems not to work.") - ); -} - #[test] fn a_key_before_the_poll_carries_the_clicked_text() { let mut router = router(); diff --git a/apps/windows/server/tests/engine_loop/cloud.rs b/apps/windows/server/tests/engine_loop/cloud.rs index ec5a047c..9e242c11 100644 --- a/apps/windows/server/tests/engine_loop/cloud.rs +++ b/apps/windows/server/tests/engine_loop/cloud.rs @@ -433,24 +433,3 @@ fn navigation_keeps_candidate_cells_but_accepts_late_sentence_completion() { } } } - -#[test] -fn sentence_translation_alone_does_not_register_or_trigger_manual_prediction() { - let (_, cloud) = with_cloud(); - let mut router = router_with(RouterConfig { - cloud_enabled: false, - sentence_translation: true, - ..RouterConfig::default() - }); - router.engine_mut().set_predictor(Box::new(cloud.clone())); - match router.handle(ClientMessage::SyncMode { session: SESSION }) { - Some(ServerMessage::ModeSync { input, .. }) => assert!(input.predict.is_none()), - other => panic!("expected ModeSync, got {other:?}"), - } - type_letters(&mut router, "kaifa"); - assert_eq!( - press(&mut router, trigger(KeyCombo::PREDICT_DEFAULT)).0, - KeyOutcome::Passthrough - ); - assert!(cloud.submitted.lock().unwrap().is_empty()); -} diff --git a/apps/windows/server/tests/engine_loop/composing.rs b/apps/windows/server/tests/engine_loop/composing.rs index c3270b2f..f6fd0e22 100644 --- a/apps/windows/server/tests/engine_loop/composing.rs +++ b/apps/windows/server/tests/engine_loop/composing.rs @@ -1,122 +1,6 @@ //! 中文组句:出候选、选词、上屏、翻页键、直输段、失焦与学习落盘。 use crate::support::*; -use qingjian_core::{ - CandidateKind, Prediction, PredictionKind, PredictionPolicy, PredictionRequest, Predictor, -}; - -pub(crate) struct SentenceTranslator { - replies: Vec, - - submitted: Arc>>, -} - -impl SentenceTranslator { - pub(crate) fn new(submitted: Arc>>) -> Self { - Self { - replies: Vec::new(), - submitted, - } - } -} - -impl Predictor for SentenceTranslator { - fn policy(&self) -> PredictionPolicy { - PredictionPolicy { - slots: 0, - max_items: 0, - sentence: false, - ..PredictionPolicy::default() - } - } - - fn submit(&mut self, request: PredictionRequest) { - self.submitted.lock().unwrap().push(request.clone()); - if request.kind == PredictionKind::Translate { - self.replies.push(Prediction { - sequence: request.sequence, - words: Vec::new(), - sentence: Some("It seems not to work.".to_owned()), - }); - } - } - - fn poll(&mut self) -> Option { - self.replies.pop() - } -} - -#[test] -fn sentence_candidates_receive_an_async_translation_without_cloud_completion() { - let submitted = Arc::new(Mutex::new(Vec::new())); - let mut local = router_with(RouterConfig { - cloud_slots: 0, - sentence_translation: false, - ..RouterConfig::default() - }); - let (_, _, expected) = type_letters(&mut local, "xiangkaifa"); - let expected_texts = candidate_texts(&expected); - - let mut router = router_with(RouterConfig { - cloud_slots: 0, - sentence_translation: true, - ..RouterConfig::default() - }); - router - .engine_mut() - .set_predictor(Box::new(SentenceTranslator::new(submitted.clone()))); - - let (_, _, frame) = type_letters(&mut router, "xiangkaifa"); - let sentence = frame - .candidates - .items - .iter() - .find(|candidate| candidate.kind == CandidateKind::Sentence) - .expect("应有本地整句候选「想开发」"); - assert_eq!(sentence.text, "想开发"); - assert_eq!( - sentence.translation.as_ref().unwrap().senses()[0].text, - "It seems not to work." - ); - assert_eq!(candidate_texts(&frame), expected_texts); - assert_eq!(frame.candidates.items[0].text, "想开发"); - let (_, commit, _) = press(&mut router, letter(' ')); - assert_eq!(commit.as_deref(), Some("想开发")); - let requests = submitted.lock().unwrap(); - let translations: Vec<&str> = requests - .iter() - .filter(|request| request.kind == PredictionKind::Translate) - .map(|request| request.text.as_str()) - .collect(); - assert_eq!(translations.last(), Some(&"想开发")); - assert!(translations.windows(2).all(|pair| pair[0] != pair[1])); - assert_eq!(frame.sentence, None, "关闭整句补全时不应显示补全文本"); -} - -#[test] -fn disabled_sentence_translation_never_sends_a_translation_request() { - let submitted = Arc::new(Mutex::new(Vec::new())); - let mut router = router_with(RouterConfig { - cloud_enabled: true, - cloud_slots: 0, - sentence_translation: false, - ..RouterConfig::default() - }); - router - .engine_mut() - .set_predictor(Box::new(SentenceTranslator::new(submitted.clone()))); - - let _ = type_letters(&mut router, "xiangkaifa"); - - assert!( - submitted - .lock() - .unwrap() - .iter() - .all(|request| request.kind != PredictionKind::Translate) - ); -} - #[test] fn typing_pinyin_shows_candidates() { let mut router = router(); diff --git a/apps/windows/server/tests/engine_loop/main.rs b/apps/windows/server/tests/engine_loop/main.rs index 51e0a8df..149caff2 100644 --- a/apps/windows/server/tests/engine_loop/main.rs +++ b/apps/windows/server/tests/engine_loop/main.rs @@ -10,6 +10,7 @@ mod key_hint; mod menu; mod modes; mod numpad; +mod panel_translate; mod prediction; mod rescoring; mod shortcuts; diff --git a/apps/windows/server/tests/engine_loop/panel_translate.rs b/apps/windows/server/tests/engine_loop/panel_translate.rs new file mode 100644 index 00000000..5fbdc97e --- /dev/null +++ b/apps/windows/server/tests/engine_loop/panel_translate.rs @@ -0,0 +1,112 @@ +//! 符号面板翻译组:↑ 带着拼音进框、选词进框、停一下译文进应用的 preedit、回车上屏;没在打字时快捷键打开。 + +use std::time::Duration; + +use qingjian_core::{Prediction, PredictionKind, PredictionPolicy, PredictionRequest, Predictor}; +use qingjian_platform::KeyCombo; + +use crate::support::*; + +/// 翻译请求一来就回一条固定译文的假提供方。 +struct EchoTranslator { + replies: Vec, + + submitted: Arc>>, +} + +impl Predictor for EchoTranslator { + fn policy(&self) -> PredictionPolicy { + PredictionPolicy { + slots: 0, + max_items: 0, + sentence: false, + ..PredictionPolicy::default() + } + } + + fn submit(&mut self, request: PredictionRequest) { + self.submitted.lock().unwrap().push(request.clone()); + if request.kind == PredictionKind::Translate { + self.replies.push(Prediction { + sequence: request.sequence, + words: Vec::new(), + sentence: Some("Hello".to_owned()), + }); + } + } + + fn poll(&mut self) -> Option { + self.replies.pop() + } +} + +fn translating_router() -> (Router, Arc>>) { + let submitted = Arc::new(Mutex::new(Vec::new())); + let mut router = router_with(RouterConfig { + cloud_enabled: true, + ..RouterConfig::default() + }); + router.engine_mut().set_predictor(Box::new(EchoTranslator { + replies: Vec::new(), + submitted: submitted.clone(), + })); + (router, submitted) +} + +fn poll(router: &mut Router) -> Frame { + match router.handle(ClientMessage::Poll { session: SESSION }) { + Some(ServerMessage::Update { frame, .. }) => frame, + other => panic!("expected Update, got {other:?}"), + } +} + +fn translations(submitted: &Arc>>) -> Vec { + submitted + .lock() + .unwrap() + .iter() + .filter(|r| r.kind == PredictionKind::Translate) + .map(|r| r.text.clone()) + .collect() +} + +#[test] +fn up_with_pinyin_types_into_the_box_and_enter_commits_the_translation() { + let (mut router, submitted) = translating_router(); + type_letters(&mut router, "nihao"); + let (_, _, frame) = press(&mut router, function_key(0x26)); + assert!(frame.symbol_panel); + // 拼音画在框里,应用里还没有译文 + assert_eq!(preedit(&frame), ""); + let (_, commit, frame) = press(&mut router, letter(' ')); + assert_eq!(commit, None); + assert!(frame.symbol_panel); + assert!(translations(&submitted).is_empty()); + + std::thread::sleep(Duration::from_millis(900)); + poll(&mut router); + let frame = poll(&mut router); + assert_eq!(translations(&submitted), ["你好"]); + assert_eq!(preedit(&frame), "Hello"); + + let (_, commit, frame) = press(&mut router, function_key(0x0D)); + assert_eq!(commit.as_deref(), Some("Hello")); + assert!(frame.is_empty()); +} + +#[test] +fn shortcut_opens_the_box_when_not_typing() { + let (mut router, _) = translating_router(); + let combo = KeyCombo::TRANSLATE_PANEL_DEFAULT; + let event = KeyEvent::new( + combo.key.to_ascii_uppercase() as u32, + Some(combo.key), + combo.modifiers.into(), + ); + let (outcome, _, frame) = press(&mut router, event); + assert_eq!(outcome, KeyOutcome::Consumed); + assert!(frame.symbol_panel); + // Esc 空框收起 + let (_, _, frame) = press(&mut router, function_key(0x1B)); + assert!(frame.is_empty()); +} diff --git a/apps/windows/server/tests/engine_loop/prediction.rs b/apps/windows/server/tests/engine_loop/prediction.rs index 25fa52c0..c0bcd542 100644 --- a/apps/windows/server/tests/engine_loop/prediction.rs +++ b/apps/windows/server/tests/engine_loop/prediction.rs @@ -26,12 +26,11 @@ fn accept_until(listener: &TcpListener, timeout: Duration) -> Option } fn clearing_input_cancels_pending_requests(clear: impl Fn(&mut Router)) { - for sentence_translation in [false, true] { + { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); listener.set_nonblocking(true).unwrap(); let mut router = router_with(RouterConfig { cloud_enabled: true, - sentence_translation, ..RouterConfig::default() }); let predictor = CloudPredictor::new(&PredictConfig { @@ -40,7 +39,6 @@ fn clearing_input_cancels_pending_requests(clear: impl Fn(&mut Router)) { api_key: Some("test".into()), debounce_ms: 300, min_interval_ms: 3000, - sentence_translation, ..PredictConfig::default() }) .unwrap(); @@ -51,7 +49,7 @@ fn clearing_input_cancels_pending_requests(clear: impl Fn(&mut Router)) { assert!(router.engine_mut().composition().is_empty()); assert!( accept_until(&listener, Duration::from_millis(600)).is_none(), - "清空输入后不应发出联想或译文请求(整句翻译:{sentence_translation})" + "清空输入后不应发出联想请求" ); // 新输入应只等停键,不能被已取消的输入占掉 3 秒网络间隔。 diff --git a/apps/windows/server/tests/engine_loop/shortcuts.rs b/apps/windows/server/tests/engine_loop/shortcuts.rs index 2bbcd122..4b306884 100644 --- a/apps/windows/server/tests/engine_loop/shortcuts.rs +++ b/apps/windows/server/tests/engine_loop/shortcuts.rs @@ -1,6 +1,5 @@ //! 组句中「修饰键 + 数字」:译词上屏。 -use crate::composing::SentenceTranslator; use crate::support::*; #[test] @@ -17,26 +16,6 @@ fn alt_digit_commits_first_translation() { assert!(after.is_empty()); } -#[test] -fn alt_digit_commits_a_sentence_translation() { - let submitted = Arc::new(Mutex::new(Vec::new())); - let mut router = router_with(RouterConfig { - sentence_translation: true, - ..RouterConfig::default() - }); - router - .engine_mut() - .set_predictor(Box::new(SentenceTranslator::new(submitted))); - let (_, _, frame) = type_letters(&mut router, "xiangkaifa"); - let slot = slot_of(&frame, "想开发"); - let (outcome, commit, after) = press(&mut router, digit_with(slot, TRANSLATE)); - assert_eq!( - (outcome, commit.as_deref()), - (KeyOutcome::Consumed, Some("It seems not to work.")) - ); - assert!(after.is_empty()); -} - #[test] fn second_translation_key_without_second_sense_is_swallowed() { let mut router = router(); diff --git a/apps/windows/settings/src/panel/component.rs b/apps/windows/settings/src/panel/component.rs index 246ad1b0..98d735cc 100644 --- a/apps/windows/settings/src/panel/component.rs +++ b/apps/windows/settings/src/panel/component.rs @@ -214,9 +214,6 @@ impl Component for Settings { self.save("predict", "slots", slots); } Message::CloudSentence(on) => self.save("predict", "sentence", on), - Message::CloudSentenceTranslation(on) => { - self.save("predict", "sentence_translation", on) - } Message::TestConnection => { if matches!(self.cloud_status, CloudStatus::Testing) { return; diff --git a/apps/windows/settings/src/panel/message.rs b/apps/windows/settings/src/panel/message.rs index d0644479..81314a35 100644 --- a/apps/windows/settings/src/panel/message.rs +++ b/apps/windows/settings/src/panel/message.rs @@ -70,7 +70,6 @@ pub(crate) enum Message { CloudBaseUrl(String), CloudSlots(Option), CloudSentence(bool), - CloudSentenceTranslation(bool), TestConnection, CloudTestDone(Result), diff --git a/apps/windows/settings/src/panel/pages/cloud.rs b/apps/windows/settings/src/panel/pages/cloud.rs index eb13da38..55e7e8c9 100644 --- a/apps/windows/settings/src/panel/pages/cloud.rs +++ b/apps/windows/settings/src/panel/pages/cloud.rs @@ -104,13 +104,6 @@ pub(crate) fn view(settings: &Settings, context: &mut ViewContext) -> .is_on(p.sentence) .on_toggled(context.callback(Message::CloudSentence)), ), - field( - "整句候选翻译", - "本地整句候选出现后,单独把该候选文本发给服务,译文异步显示在候选右侧;不发送光标前后上下文。", - ToggleSwitch::new() - .is_on(p.sentence_translation) - .on_toggled(context.callback(Message::CloudSentenceTranslation)), - ), field( "接口地址", "", diff --git a/apps/windows/tsf/src/com/composition/mod.rs b/apps/windows/tsf/src/com/composition/mod.rs index 255635a2..aec8d9d5 100644 --- a/apps/windows/tsf/src/com/composition/mod.rs +++ b/apps/windows/tsf/src/com/composition/mod.rs @@ -56,6 +56,7 @@ pub(crate) fn apply( } else { update_preedit(shared, context, ec, preedit)?; } + shared.set_preedit(preedit); if let Some(InputContext { private, before }) = input { report_privacy(engine, private); if let Some(before) = before { diff --git a/apps/windows/tsf/src/com/composition/shared.rs b/apps/windows/tsf/src/com/composition/shared.rs index cd3b7c8c..ea63793e 100644 --- a/apps/windows/tsf/src/com/composition/shared.rs +++ b/apps/windows/tsf/src/com/composition/shared.rs @@ -13,6 +13,9 @@ pub(crate) struct Shared { /// 当前活动的组句,跨按键存活。 composition: RefCell>, + /// 最近一次写进文档的组句文字;符号面板翻译组的译文靠轮询送来,对比它才知道要不要补写。 + preedit: RefCell, + /// Server 上次回的帧非空;决定 `OnTestKeyDown` 要不要吃功能键。 composing: Cell, @@ -46,6 +49,7 @@ impl Shared { pub(crate) fn new(client: SharedClient) -> Rc { Rc::new(Self { composition: RefCell::new(None), + preedit: RefCell::new(String::new()), composing: Cell::new(false), anchor: Cell::new(None), context_reported: Cell::new(false), @@ -82,6 +86,14 @@ impl Shared { self.server_stale.replace(false) } + pub(crate) fn preedit(&self) -> String { + self.preedit.borrow().clone() + } + + pub(crate) fn set_preedit(&self, text: &str) { + text.clone_into(&mut self.preedit.borrow_mut()); + } + pub(crate) fn composing(&self) -> bool { self.composing.get() } @@ -156,6 +168,7 @@ impl Shared { /// 清掉一切本地组句状态,不碰文档。 pub(crate) fn reset(&self) { self.set_composition(None); + self.set_preedit(""); self.set_last_context(None); self.end_composing(); } diff --git a/apps/windows/tsf/src/com/key/preserved.rs b/apps/windows/tsf/src/com/key/preserved.rs index d11a8331..a1a652e0 100644 --- a/apps/windows/tsf/src/com/key/preserved.rs +++ b/apps/windows/tsf/src/com/key/preserved.rs @@ -18,6 +18,10 @@ use crate::com::log::log; /// 本保留键的标识,`OnPreservedKey` 按它认。 pub(crate) const GUID_TRANSLATE: GUID = GUID::from_u128(0x5c0a7b12_3d4e_4f60_8a91_2b3c4d5e6f70); +/// 没在组句时打开符号面板翻译组的保留键标识。 +pub(crate) const GUID_TRANSLATE_PANEL: GUID = + GUID::from_u128(0x8d3f2a64_71c5_4b9e_a0d2_6e4b1c9f3a85); + pub(crate) const GUID_PREDICT: GUID = GUID::from_u128(0xe7c13625_b92a_48f0_9e64_673c0a581bd2); /// Ctrl + Alt + Space 中英切换键的保留键标识。 @@ -45,16 +49,23 @@ pub(crate) fn unregister_traditional(keystroke: &ITfKeystrokeMgr, combo: KeyComb /// msctf.h 的 `TF_MOD_LWIN`(windows crate 没导出)。 const TF_MOD_LWIN: u32 = 0x08; -/// 读 `%APPDATA%\Qingjian\config.toml` 里的组合;读不到 / 解析失败用缺省。 -pub(crate) fn load_combo() -> KeyCombo { +/// 读 `%APPDATA%\Qingjian\config.toml` 里的「翻译选中文字」与「翻译组」两个组合;读不到 / 解析失败用缺省。 +pub(crate) fn load_combos() -> (KeyCombo, KeyCombo) { + let defaults = ( + KeyCombo::TRANSLATE_DEFAULT, + KeyCombo::TRANSLATE_PANEL_DEFAULT, + ); let Some(path) = qingjian_platform::dirs::config_path() else { - return KeyCombo::TRANSLATE_DEFAULT; + return defaults; }; match Config::load(&path) { - Ok(config) => config.shortcut.translate_selection, + Ok(config) => ( + config.shortcut.translate_selection, + config.shortcut.translate_panel, + ), Err(error) => { log(&format!("读配置取翻译快捷键失败,用缺省: {error}")); - KeyCombo::TRANSLATE_DEFAULT + defaults } } } diff --git a/apps/windows/tsf/src/com/poll/mod.rs b/apps/windows/tsf/src/com/poll/mod.rs index 7aa7d9fe..555042f7 100644 --- a/apps/windows/tsf/src/com/poll/mod.rs +++ b/apps/windows/tsf/src/com/poll/mod.rs @@ -144,6 +144,11 @@ fn poll_once(context: &PollContext) { .shared .candidate_list() .show(&frame, context.shared.last_context().as_ref()); + if frame.symbol_panel { + drop(guard); + follow_panel_preedit(context, &frame); + return; + } // 翻译评审时回空帧 = 翻译已在 Server 侧结束(云端没给译文)。 if translating && frame.is_empty() { drop(guard); @@ -162,6 +167,31 @@ fn poll_once(context: &PollContext) { } } +/// 符号面板翻译组的译文是轮询送来的:帧里的组句文字与文档里的不同就补写一次(没有按键来顺手写)。 +fn follow_panel_preedit(context: &PollContext, frame: &Frame) { + let preedit = if frame.preedit_mode.inline() { + preedit_string(frame) + } else { + String::new() + }; + if preedit == context.shared.preedit() { + return; + } + let Some(document) = context.shared.last_context() else { + return; + }; + if let Err(error) = request_update( + &document, + context.client_id, + context.engine.clone(), + context.shared.clone(), + None, + preedit, + ) { + log(&format!("panel preedit update rejected: {error}")); + } +} + /// 鼠标点候选窗口上屏:与按键结果一样经异步编辑会话写进文档,剩下的拼音接着组句。 fn commit_clicked(context: &PollContext, frame: &Frame, commit: String) { let Some(document) = context.shared.last_context() else { diff --git a/apps/windows/tsf/src/com/service/key_sink.rs b/apps/windows/tsf/src/com/service/key_sink.rs index 48a354a3..a5499988 100644 --- a/apps/windows/tsf/src/com/service/key_sink.rs +++ b/apps/windows/tsf/src/com/service/key_sink.rs @@ -87,6 +87,8 @@ impl ITfKeyEventSink_Impl for TextService_Impl { } let combo = if guid == preserved::GUID_TRANSLATE { self.translate_combo.get() + } else if guid == preserved::GUID_TRANSLATE_PANEL && !self.shared.composing() { + self.translate_panel_combo.get() } else if guid == preserved::GUID_PREDICT && self.shared.composing() { self.predict_combo.get() } else if guid == preserved::GUID_TRADITIONAL { diff --git a/apps/windows/tsf/src/com/service/mod.rs b/apps/windows/tsf/src/com/service/mod.rs index e825d7d6..1c9423d3 100644 --- a/apps/windows/tsf/src/com/service/mod.rs +++ b/apps/windows/tsf/src/com/service/mod.rs @@ -88,6 +88,9 @@ pub struct TextService { /// 登记成保留键的「翻译选中文字」组合;停用时撤掉(见 [`preserved`](crate::com::key::preserved))。 translate_combo: Cell>, + /// 登记成保留键的「翻译组」组合(没在组句时打开符号面板的翻译组)。 + translate_panel_combo: Cell>, + /// 已登记的简繁切换键,配置变化时原地替换。 traditional_combo: Cell>, @@ -206,6 +209,7 @@ impl TextService { key_tap: KeyTap::default(), profile_cookie: Cell::new(None), translate_combo: Cell::new(None), + translate_panel_combo: Cell::new(None), traditional_combo: Cell::new(None), switch_preserved: Cell::new(false), punctuation_combo: Cell::new(None), diff --git a/apps/windows/tsf/src/com/service/processor.rs b/apps/windows/tsf/src/com/service/processor.rs index 6ae9234c..d0bb1e05 100644 --- a/apps/windows/tsf/src/com/service/processor.rs +++ b/apps/windows/tsf/src/com/service/processor.rs @@ -24,7 +24,7 @@ impl ITfTextInputProcessor_Impl for TextService_Impl { let keystroke: ITfKeystrokeMgr = thread_mgr.cast()?; let sink: ITfKeyEventSink = self.to_interface(); unsafe { keystroke.AdviseKeyEventSink(tid, &sink, true)? }; - let combo = preserved::load_combo(); + let (combo, panel_combo) = preserved::load_combos(); match preserved::register( &keystroke, tid, @@ -38,6 +38,18 @@ impl ITfTextInputProcessor_Impl for TextService_Impl { } Err(error) => log(&format!("登记翻译快捷键失败: {error}")), } + if panel_combo != combo { + match preserved::register( + &keystroke, + tid, + &preserved::GUID_TRANSLATE_PANEL, + panel_combo, + "翻译组", + ) { + Ok(()) => self.translate_panel_combo.set(Some(panel_combo)), + Err(error) => log(&format!("translate panel preserved key failed: {error}")), + } + } self.client_id.set(tid); // 连不上 Server、没定时器都不致命。 @@ -122,6 +134,9 @@ impl ITfTextInputProcessor_Impl for TextService_Impl { if let Some(combo) = self.translate_combo.take() { preserved::unregister(&keystroke, &preserved::GUID_TRANSLATE, combo); } + if let Some(combo) = self.translate_panel_combo.take() { + preserved::unregister(&keystroke, &preserved::GUID_TRANSLATE_PANEL, combo); + } let _ = unsafe { keystroke.UnadviseKeyEventSink(self.client_id.get()) }; } if let Some(client) = self.engine.borrow_mut().take() { diff --git a/crates/bamboo-slip/src/lib.rs b/crates/bamboo-slip/src/lib.rs index 52326c66..9eaad319 100644 --- a/crates/bamboo-slip/src/lib.rs +++ b/crates/bamboo-slip/src/lib.rs @@ -43,10 +43,10 @@ pub use ui::{ Response, ScrollExtent, Searchable, TextInput, Ui, }; pub use widgets::{ - BarChart, Button, Checkbox, Datum, Edit, Heatmap, KeyRecorder, Menu, NavItem, PopupMenu, - ProgressBar, ScrollArea, SearchField, Segmented, Select, SettingRow, Slider, Stepper, Switch, - TextEdit, TextField, WidgetColors, WidgetTheme, dialog, group, info, info_with_link, paragraph, - section_title, tooltip, + BarChart, Button, Checkbox, Datum, Edit, Flow, FlowTarget, Heatmap, KeyRecorder, Menu, NavItem, + PopupMenu, ProgressBar, ScrollArea, SearchField, Segmented, Select, SettingRow, Slider, + Stepper, Switch, TextEdit, TextField, TextFlow, WidgetColors, WidgetTheme, dialog, group, info, + info_with_link, paragraph, section_title, tooltip, }; /// 使用方建树用的布局类型、自己画图标用的路径与混合模式,与这里是同一个 taffy、tiny-skia。 diff --git a/crates/bamboo-slip/src/scene/mod.rs b/crates/bamboo-slip/src/scene/mod.rs index d4cd4bf5..4b8456fe 100644 --- a/crates/bamboo-slip/src/scene/mod.rs +++ b/crates/bamboo-slip/src/scene/mod.rs @@ -170,6 +170,18 @@ impl Scene { } } + /// 改一个节点的布局样式(布局之后按量出的位置挪内容,再布局一次)。 + pub fn restyle( + &mut self, + node: NodeId, + edit: impl FnOnce(&mut Style), + ) -> Result<(), RenderError> { + let mut style = self.tree.style(node)?.clone(); + edit(&mut style); + self.tree.set_style(node, style)?; + Ok(()) + } + /// 把表格里的格子放到指定的行、列;`stretch` 时撑满所占的格子(横跨整行的高亮条)。 pub fn place( &mut self, diff --git a/crates/bamboo-slip/src/widgets/mod.rs b/crates/bamboo-slip/src/widgets/mod.rs index 362b9f31..47fb7348 100644 --- a/crates/bamboo-slip/src/widgets/mod.rs +++ b/crates/bamboo-slip/src/widgets/mod.rs @@ -57,6 +57,6 @@ pub use setting_row::SettingRow; pub use slider::Slider; pub use stepper::Stepper; pub use switch::Switch; -pub use text_field::{Edit, TextEdit, TextField}; +pub use text_field::{Edit, Flow, FlowTarget, TextEdit, TextField, TextFlow}; pub use theme::WidgetTheme; pub use tooltip::tooltip; diff --git a/crates/bamboo-slip/src/widgets/text_field/flow.rs b/crates/bamboo-slip/src/widgets/text_field/flow.rs new file mode 100644 index 00000000..99f7ab17 --- /dev/null +++ b/crates/bamboo-slip/src/widgets/text_field/flow.rs @@ -0,0 +1,245 @@ +//! 输入框里正文的排法,不依赖 [`crate::Ui`]:[`super::TextField`] 用它,自己摆位置的使用方(符号面板的翻译框)也能用。 +//! +//! 正文按 [`tokens`] 切成一个个单位横排(多行时一排放不下折到下一排),光标是单位之间的一条竖线,组字接在光标前、带下划线, +//! 选区铺底。点击靠 [`FlowTarget`]:点中哪个单位、点在它的哪一侧,光标就落到哪;点在行尾后面的空白落到行尾。 +//! 使用方布局之后按这些节点的位置接点击(`Ui` 用 `interact`,自己摆的用 [`crate::Scene::rect_in`])。 + +use std::ops::Range; + +use taffy::{AlignItems, FlexDirection, FlexWrap, LengthPercentageAuto, NodeId, Size, Style}; + +use super::tokens; +use crate::color::Color; +use crate::error::RenderError; +use crate::scene::{Scene, Visual}; +use crate::text::{Decoration, TextStyle}; + +/// 密文显示成的圆点。 +const BULLET: char = '•'; + +/// 一段正文怎么排、怎么画。位置都是 `text` 里的字节下标。 +pub struct TextFlow<'a> { + pub text: &'a str, + + /// 输入法正在组的字,接在光标处、带下划线。 + pub preedit: &'a str, + + pub caret: usize, + + pub selection: Range, + + /// 正文与组字都空时显示。 + pub placeholder: &'a str, + + /// 按换行分行、一排放不下就折行;单行时一排到底。 + pub multiline: bool, + + /// 有焦点才画光标与选区。 + pub focused: bool, + + /// 正文显示成圆点。 + pub secret: bool, + + pub style: TextStyle, + + pub placeholder_style: TextStyle, + + /// 光标竖线:宽、高(像素)与颜色。 + pub caret_bar: (f32, f32, Color), + + /// 选区的底。 + pub selection_paint: Visual, +} + +/// 能点的节点,按建的先后排(`Ui` 的命中顺序跟它走)。 +pub enum FlowTarget { + /// 光标那条竖线。 + Caret(NodeId), + + /// 一个单位:点在它里面横向的几分之几,光标落到 `bounds` 里最近的那个字符边界。 + Token { node: NodeId, bounds: Vec }, + + /// 一整行:点在行尾后面的空白,光标落到 `end`;`start` 是这一行开头,当标识用。 + LineEnd { + node: NodeId, + start: usize, + end: usize, + }, +} + +/// 建好的各行与能点的节点。 +pub struct Flow { + pub lines: Vec, + + pub caret: Option, + + pub targets: Vec, +} + +impl FlowTarget { + /// 点在这个单位横向 `fraction`(0 到 1)处,光标该落到哪。 + pub fn caret_at(bounds: &[usize], fraction: f32) -> usize { + let last = bounds.len().saturating_sub(1); + let index = (fraction.clamp(0.0, 1.0) * last as f32).round() as usize; + bounds[index.min(last)] + } +} + +impl TextFlow<'_> { + /// 每行一排:选区前、选区、选区后,或光标前、组字、光标、光标后。单行就是一排。 + pub fn build(&self, scene: &mut Scene) -> Result { + let mut flow = Flow { + lines: Vec::new(), + caret: None, + targets: Vec::new(), + }; + if self.text.is_empty() && self.preedit.is_empty() { + let mut pieces = Vec::new(); + if self.focused { + let caret = self.caret(scene)?; + pieces.push(caret); + flow.caret = Some(caret); + flow.targets.push(FlowTarget::Caret(caret)); + } + if !self.placeholder.is_empty() { + pieces.push(self.segment(scene, self.placeholder, &self.placeholder_style)?); + } + flow.lines.push(self.line(scene, &pieces)?); + return Ok(flow); + } + let selection = &self.selection; + let caret = self.caret; + let mut start = 0; + for line in self.text.split('\n') { + let end = start + line.len(); + let mut pieces = Vec::new(); + if !self.focused { + self.pieces(scene, start..end, &mut pieces, &mut flow.targets)?; + } else if !selection.is_empty() { + let (from, to) = ( + selection.start.clamp(start, end), + selection.end.clamp(start, end), + ); + self.pieces(scene, start..from, &mut pieces, &mut flow.targets)?; + let before = pieces.len(); + self.pieces(scene, from..to, &mut pieces, &mut flow.targets)?; + for piece in &mut pieces[before..] { + *piece = + scene.node(Style::default(), self.selection_paint.clone(), &[*piece])?; + } + self.pieces(scene, to..end, &mut pieces, &mut flow.targets)?; + } else if (start..=end).contains(&caret) { + self.pieces(scene, start..caret, &mut pieces, &mut flow.targets)?; + let underlined = self.style.decorated(Decoration::Underline); + for token in tokens(self.preedit) { + pieces.push(self.segment(scene, &self.preedit[token], &underlined)?); + } + let node = self.caret(scene)?; + flow.caret = Some(node); + flow.targets.push(FlowTarget::Caret(node)); + pieces.push(node); + self.pieces(scene, caret..end, &mut pieces, &mut flow.targets)?; + } else { + self.pieces(scene, start..end, &mut pieces, &mut flow.targets)?; + } + let node = self.line(scene, &pieces)?; + flow.targets.push(FlowTarget::LineEnd { node, start, end }); + flow.lines.push(node); + start = end + 1; + } + Ok(flow) + } + + /// `range` 那段正文按 [`tokens`] 切开,每个单位一个节点;单行的空串也建一个(各段的位置才稳定)。 + /// 单位里按字符数均分点击位置,汉字一个字一个单位所以是准的。 + fn pieces( + &self, + scene: &mut Scene, + range: Range, + out: &mut Vec, + targets: &mut Vec, + ) -> Result<(), RenderError> { + let text = &self.text[range.clone()]; + if text.is_empty() && !self.multiline { + out.push(self.segment(scene, "", &self.style)?); + return Ok(()); + } + for token in tokens(text) { + let piece = &text[token.clone()]; + let shown = if self.secret { + std::iter::repeat_n(BULLET, piece.chars().count()).collect() + } else { + piece.to_owned() + }; + let node = self.segment(scene, &shown, &self.style)?; + let at = range.start + token.start; + let bounds: Vec = piece + .char_indices() + .map(|(i, _)| at + i) + .chain(std::iter::once(range.start + token.end)) + .collect(); + targets.push(FlowTarget::Token { node, bounds }); + out.push(node); + } + Ok(()) + } + + /// 一行:各段横排,空行也有一行高;多行时放不下的折到下一排。 + fn line(&self, scene: &mut Scene, pieces: &[NodeId]) -> Result { + scene.node( + Style { + flex_direction: FlexDirection::Row, + flex_shrink: 0.0, + flex_wrap: if self.multiline { + FlexWrap::Wrap + } else { + FlexWrap::NoWrap + }, + align_items: AlignItems::CENTER, + min_size: Size { + width: LengthPercentageAuto::auto(), + height: LengthPercentageAuto::length(self.style.line_height), + }, + ..Style::default() + }, + Visual::Group, + pieces, + ) + } + + fn caret(&self, scene: &mut Scene) -> Result { + let (width, height, color) = self.caret_bar; + scene.node( + Style { + size: Size { + width: taffy::Dimension::length(width), + height: taffy::Dimension::length(height), + }, + flex_shrink: 0.0, + ..Style::default() + }, + Visual::solid(color, 0.0), + &[], + ) + } + + /// 一段文字;空串也建(占位为零宽),各段的位置才稳定。 + fn segment( + &self, + scene: &mut Scene, + text: &str, + style: &TextStyle, + ) -> Result { + scene.node( + Style { + flex_shrink: 0.0, + ..Style::default() + }, + Visual::Text { + text: text.to_owned(), + style: *style, + }, + &[], + ) + } +} diff --git a/crates/bamboo-slip/src/widgets/text_field/mod.rs b/crates/bamboo-slip/src/widgets/text_field/mod.rs index f2a05821..116aea26 100644 --- a/crates/bamboo-slip/src/widgets/text_field/mod.rs +++ b/crates/bamboo-slip/src/widgets/text_field/mod.rs @@ -6,14 +6,15 @@ //! 多行:按框宽折行;设了最多几行时超出的裁掉,内容上移到能看见光标那一行。 mod edit; +mod flow; use std::hash::Hash; use std::ops::Range; use std::sync::Arc; use taffy::{ - AlignItems, FlexDirection, FlexWrap, JustifyContent, LengthPercentageAuto, NodeId, Position, - Rect, Size, Style, + AlignItems, FlexDirection, JustifyContent, LengthPercentageAuto, NodeId, Position, Rect, Size, + Style, }; use super::WidgetTheme; @@ -22,10 +23,11 @@ use crate::animation::Easing; use crate::color::Color; use crate::error::RenderError; use crate::scene::{Icon, Visual}; -use crate::text::{Decoration, TextStyle}; +use crate::text::TextStyle; use crate::ui::{Interact, Key, KeyCode, Ui}; pub use edit::{Edit, TextEdit}; +pub use flow::{Flow, FlowTarget, TextFlow}; /// 多行框上下的留白(点)。 const MULTILINE_PADDING: f32 = 6.0; @@ -33,9 +35,6 @@ const MULTILINE_PADDING: f32 = 6.0; /// 多行框里一段连着的 ASCII 最多几个字符一个折行单位:网址这类没有空格的长串也能折开。 const MAX_WORD: usize = 16; -/// 密文显示成的圆点。 -const BULLET: char = '•'; - pub struct TextField<'a> { edit: &'a TextEdit, @@ -379,8 +378,7 @@ impl<'a> TextField<'a> { theme.text(theme.font.size - 1.0, 400, color) } - /// 每行一排:选区前、选区、选区后,或光标前、组字、光标、光标后。单行就是一排。 - /// 正文按 [`tokens`] 切开,多行时一排放不下就折到下一排;光标登记成 `key` 下的 `caret`,限高时按它挪内容。 + /// 正文各行,排法在 [`TextFlow`];光标登记成 `key` 下的 `caret`(限高时按它挪内容),单位与行尾接点击。 fn lines( &self, ui: &mut Ui, @@ -390,193 +388,57 @@ impl<'a> TextField<'a> { on_edit: &(impl Fn(Edit) -> M + Clone + 'static), ) -> Result, RenderError> { let colors = &theme.colors; - let text = self.edit.text(); - let preedit = self.edit.preedit(); - if text.is_empty() && preedit.is_empty() { - let mut pieces = Vec::new(); - if focused { - pieces.push(self.caret(ui, theme)?); - ui.interact(pieces[0], key.child("caret"), Interact::new()); - } - if !self.placeholder.is_empty() { - pieces.push(self.segment(ui, theme, self.placeholder, colors.secondary, None)?); - } - return Ok(vec![self.line(ui, theme, &pieces)?]); - } - let selection = self.edit.selection(); - let caret = self.edit.caret(); - let mut lines = Vec::new(); - let mut start = 0; - for line in text.split('\n') { - let end = start + line.len(); - let mut pieces = Vec::new(); - if !focused { - self.pieces(ui, theme, start..end, key, on_edit, &mut pieces)?; - } else if !selection.is_empty() { - let (from, to) = ( - selection.start.clamp(start, end), - selection.end.clamp(start, end), - ); - self.pieces(ui, theme, start..from, key, on_edit, &mut pieces)?; - let before = pieces.len(); - self.pieces(ui, theme, from..to, key, on_edit, &mut pieces)?; - for piece in &mut pieces[before..] { - *piece = ui.scene().node( - Style::default(), - solid(Some(colors.selected), None, theme.px(2.0)), - &[*piece], - )?; - } - self.pieces(ui, theme, to..end, key, on_edit, &mut pieces)?; - } else if (start..=end).contains(&caret) { - self.pieces(ui, theme, start..caret, key, on_edit, &mut pieces)?; - let underline = Some(Decoration::Underline); - for token in tokens(preedit) { - pieces.push(self.segment( - ui, - theme, - &preedit[token], - colors.text, - underline, - )?); - } - let node = self.caret(ui, theme)?; - ui.interact(node, key.child("caret"), Interact::new()); - pieces.push(node); - self.pieces(ui, theme, caret..end, key, on_edit, &mut pieces)?; - } else { - self.pieces(ui, theme, start..end, key, on_edit, &mut pieces)?; - } - let node = self.line(ui, theme, &pieces)?; - // 点在这一行的空白处(行尾后面):光标到行尾 - if !self.disabled { - let on_edit = on_edit.clone(); - ui.interact( - node, - key.child(("line-end", start)), - Interact::new().on_drag(move |_| Some(on_edit(Edit::Caret(end)))), - ); - } - lines.push(node); - start = end + 1; - } - Ok(lines) - } - - /// `range` 那段正文按 [`tokens`] 切开,每个单位一个节点;单行的空串也建一个(各段的位置才稳定)。 - /// 每个单位都能点:按点在它里面的横向位置,光标落到最近的字符边界上(单位里按字符数均分,汉字一个字一个单位所以是准的)。 - fn pieces( - &self, - ui: &mut Ui, - theme: &WidgetTheme, - range: Range, - key: Key, - on_edit: &(impl Fn(Edit) -> M + Clone + 'static), - out: &mut Vec, - ) -> Result<(), RenderError> { - let text = &self.edit.text()[range.clone()]; - let color = theme.colors.text; - if text.is_empty() && !self.edit.is_multiline() { - out.push(self.segment(ui, theme, "", color, None)?); - return Ok(()); - } - for token in tokens(text) { - let node = self.segment(ui, theme, &text[token.clone()], color, None)?; - if !self.disabled { - let at = range.start + token.start; - let bounds: Vec = text[token.clone()] - .char_indices() - .map(|(i, _)| at + i) - .chain(std::iter::once(range.start + token.end)) - .collect(); - let on_edit = on_edit.clone(); - ui.interact( - node, - key.child(("at", at)), - Interact::new().on_drag(move |fraction| { - let last = bounds.len() - 1; - let index = (fraction * last as f32).round() as usize; - Some(on_edit(Edit::Caret(bounds[index.min(last)]))) - }), - ); - } - out.push(node); - } - Ok(()) - } - - /// 一行:各段横排,空行也有一行高;多行框里放不下的折到下一排。 - fn line( - &self, - ui: &mut Ui, - theme: &WidgetTheme, - pieces: &[NodeId], - ) -> Result { - let height = self.style(theme, theme.colors.text).line_height; - ui.scene().node( - Style { - flex_shrink: 0.0, - flex_wrap: if self.edit.is_multiline() { - FlexWrap::Wrap - } else { - FlexWrap::NoWrap - }, - align_items: AlignItems::CENTER, - min_size: Size { - width: LengthPercentageAuto::auto(), - height: LengthPercentageAuto::length(height), - }, - ..row(theme, 0.0) - }, - Visual::Group, - pieces, - ) - } - - fn caret(&self, ui: &mut Ui, theme: &WidgetTheme) -> Result { // 熄灭的半拍只换透明色,不拿掉节点:文字不跟着挪 - let color = if ui.caret_visible() { - theme.colors.accent + let caret_color = if ui.caret_visible() { + colors.accent } else { - theme.colors.accent.transparent() + colors.accent.transparent() }; - ui.scene().node( - Style { - size: size(Some(theme.px(1.5)), Some(theme.px(16.0))), - flex_shrink: 0.0, - ..Style::default() - }, - Visual::solid(color, 0.0), - &[], - ) - } - - /// 一段文字(密文换成圆点);空串也建(占位为零宽),各段的位置才稳定。 - fn segment( - &self, - ui: &mut Ui, - theme: &WidgetTheme, - text: &str, - color: Color, - decoration: Option, - ) -> Result { - let mut style = self.style(theme, color); - if let Some(decoration) = decoration { - style = style.decorated(decoration); + let flow = TextFlow { + text: self.edit.text(), + preedit: self.edit.preedit(), + caret: self.edit.caret(), + selection: self.edit.selection(), + placeholder: self.placeholder, + multiline: self.edit.is_multiline(), + focused, + secret: self.secret, + style: self.style(theme, colors.text), + placeholder_style: self.style(theme, colors.secondary), + caret_bar: (theme.px(1.5), theme.px(16.0), caret_color), + selection_paint: solid(Some(colors.selected), None, theme.px(2.0)), } - let shown = if self.secret && decoration.is_none() && color == theme.colors.text { - std::iter::repeat_n(BULLET, text.chars().count()).collect() - } else { - text.to_owned() - }; - ui.scene().node( - Style { - flex_shrink: 0.0, - ..Style::default() - }, - Visual::Text { text: shown, style }, - &[], - ) + .build(ui.scene())?; + for target in flow.targets { + match target { + FlowTarget::Caret(node) => { + ui.interact(node, key.child("caret"), Interact::new()); + } + _ if self.disabled => {} + FlowTarget::Token { node, bounds } => { + let on_edit = on_edit.clone(); + ui.interact( + node, + key.child(("at", bounds[0])), + Interact::new().on_drag(move |fraction| { + Some(on_edit(Edit::Caret(FlowTarget::caret_at( + &bounds, fraction, + )))) + }), + ); + } + // 点在这一行的空白处(行尾后面):光标到行尾 + FlowTarget::LineEnd { node, start, end } => { + let on_edit = on_edit.clone(); + ui.interact( + node, + key.child(("line-end", start)), + Interact::new().on_drag(move |_| Some(on_edit(Edit::Caret(end)))), + ); + } + } + } + Ok(flow.lines) } } diff --git a/crates/qingjian-core/locales/en.toml b/crates/qingjian-core/locales/en.toml index 29f6352d..4dfa3f90 100644 --- a/crates/qingjian-core/locales/en.toml +++ b/crates/qingjian-core/locales/en.toml @@ -29,3 +29,12 @@ duplicate = "The code {code} already has this phrase" user_word = "Deleted the user word “{text}”" learning = "Forgot what was learned about “{text}”" nothing = "“{text}” is a dictionary word with no learning records; there's nothing to delete" + +[panel] +translate_to = "To {language}" +chinese = "Chinese" +translate_idle = "Pause typing to translate" +translate_waiting = "Translating…" +translate_ready = "Enter to insert" +translate_failed = "No translation; press Enter to retry" +translate_placeholder = "Type what you want to translate" diff --git a/crates/qingjian-core/locales/zh-Hans.toml b/crates/qingjian-core/locales/zh-Hans.toml index a5232938..f826e54a 100644 --- a/crates/qingjian-core/locales/zh-Hans.toml +++ b/crates/qingjian-core/locales/zh-Hans.toml @@ -29,3 +29,12 @@ duplicate = "输入码 {code} 下已经有这条短语" user_word = "已删除用户词「{text}」" learning = "已忘掉对「{text}」的学习记录" nothing = "「{text}」是词库里的词,也没有学习记录,没什么可删" + +[panel] +translate_to = "译成{language}" +chinese = "中文" +translate_idle = "打完停一下就翻译" +translate_waiting = "翻译中…" +translate_ready = "回车上屏" +translate_failed = "没翻译出来,按回车重试" +translate_placeholder = "在这里打要翻译的话" diff --git a/crates/qingjian-core/locales/zh-Hant.toml b/crates/qingjian-core/locales/zh-Hant.toml index 9d06891c..fc9bf699 100644 --- a/crates/qingjian-core/locales/zh-Hant.toml +++ b/crates/qingjian-core/locales/zh-Hant.toml @@ -29,3 +29,12 @@ duplicate = "輸入碼 {code} 下已經有這條短語" user_word = "已刪除用戶詞「{text}」" learning = "已忘掉對「{text}」的學習記錄" nothing = "「{text}」是詞庫裡的詞,也沒有學習記錄,沒什麼可刪" + +[panel] +translate_to = "譯成{language}" +chinese = "中文" +translate_idle = "打完停一下就翻譯" +translate_waiting = "翻譯中…" +translate_ready = "回車上屏" +translate_failed = "沒翻譯出來,按回車重試" +translate_placeholder = "在這裡打要翻譯的話" diff --git a/crates/qingjian-core/src/engine/commit/mod.rs b/crates/qingjian-core/src/engine/commit/mod.rs index cbcb24df..13956e99 100644 --- a/crates/qingjian-core/src/engine/commit/mod.rs +++ b/crates/qingjian-core/src/engine/commit/mod.rs @@ -87,7 +87,7 @@ impl Engine { /// 开了自动空格([`Self::set_auto_space`])时与上文、候选内部的中西文交界都补好空格。 /// 面板开着时上屏的是面板里的条目:记一次使用(私密输入不记),下次打开排进「常用」。 pub fn commit(&mut self, candidate: &Candidate) -> String { - if self.panel.take().is_some() && !self.private { + if self.take_panel().is_some() && !self.private { self.panel_history.record(&candidate.text); } let lead = self.auto_space_before(&candidate.text); diff --git a/crates/qingjian-core/src/engine/composing.rs b/crates/qingjian-core/src/engine/composing.rs index be430116..cc4884ec 100644 --- a/crates/qingjian-core/src/engine/composing.rs +++ b/crates/qingjian-core/src/engine/composing.rs @@ -225,7 +225,7 @@ impl Engine { pub fn clear(&mut self) { self.composition.clear(); self.held.clear(); - self.panel = None; + self.take_panel(); self.aux_code = None; self.chain.leave_buffer(); // 壳给的光标前文只对这段组句有效,下一段第一键再读 diff --git a/crates/qingjian-core/src/engine/mod.rs b/crates/qingjian-core/src/engine/mod.rs index 79a10fef..10c44841 100644 --- a/crates/qingjian-core/src/engine/mod.rs +++ b/crates/qingjian-core/src/engine/mod.rs @@ -22,7 +22,7 @@ mod learning; mod marked; mod mode_keys; mod panel; -mod prediction; +pub(crate) mod prediction; mod privacy; mod query; mod raw; @@ -79,7 +79,7 @@ use crate::correction::{Correction, TypoCosts}; use crate::emoji::EmojiTable; use crate::fuzzy::FuzzyRules; use crate::history::InputHistory; -use crate::panel::{NoPanelHistory, PanelHistory, SymbolPanel}; +use crate::panel::{NoPanelHistory, PanelGroup, PanelHistory, SymbolPanel, TranslationCache}; use crate::parser::{self, ParseError, Segmentation}; use crate::punctuation::Punctuation; use crate::sentence::{self, Interpolation, LanguageModel, NoLanguageModel, SentenceScorer}; @@ -360,6 +360,15 @@ pub struct Engine { /// 面板条目的使用记录:打开面板时排「常用」,从面板上屏时记一次;缺省不记。 panel_history: Box, + /// 面板上次关掉时停在哪组:没在组句时打开就回到那组。 + panel_group: Option, + + /// 翻译组上次选的目标语言;没选过跟学习语言走。 + panel_language: Option, + + /// 翻译组的译文缓存,换联想提供方时清空。 + panel_translations: TranslationCache, + /// 辅码态:`None` 是拼音态,`Some` 是辅码态(空串 = 刚敲下触发键、码段还没开始)。 /// 码段不进 `composition`:它与拼音分段记账、分段画(见 [`AuxSegment`])。 aux_code: Option, @@ -587,6 +596,9 @@ impl Engine { symbol_limit: 1, panel: None, panel_history: Box::new(NoPanelHistory), + panel_group: None, + panel_language: None, + panel_translations: TranslationCache::default(), aux_code: None, aux_enabled: false, aux_show: false, diff --git a/crates/qingjian-core/src/engine/panel.rs b/crates/qingjian-core/src/engine/panel/mod.rs similarity index 64% rename from crates/qingjian-core/src/engine/panel.rs rename to crates/qingjian-core/src/engine/panel/mod.rs index 57f962d3..77dcee04 100644 --- a/crates/qingjian-core/src/engine/panel.rs +++ b/crates/qingjian-core/src/engine/panel/mod.rs @@ -1,9 +1,12 @@ -//! 符号面板的开关与搜索。面板开着时缓冲区里敲的字是搜索词,选中的条目上屏时吃掉整段(见 [`Engine::commit`])。 +//! 符号面板的开关与搜索。面板开着时缓冲区里敲的字是搜索词,选中的条目上屏时吃掉整段(见 [`Engine::commit`]); +//! 翻译组的原文与翻译在 [`translate`]。 + +mod translate; use super::Engine; use crate::candidate::{Candidate, CandidateKind}; use crate::emoji::builtin; -use crate::panel::{CATALOG, KEYWORDS, SymbolPanel}; +use crate::panel::{CATALOG, KEYWORDS, PanelGroup, SymbolPanel}; /// 搜索拿前几个候选词去查关键词:再往后的词与敲的字关系就远了。 const SEARCH_WORDS: usize = 12; @@ -12,23 +15,33 @@ const SEARCH_WORDS: usize = 12; const SEARCH_LIMIT: usize = 120; impl Engine { - /// 打开面板:在组句就拿当前候选搜一遍,搜到的放在最前面一组;没在组句(从菜单打开)就没有搜索组。 + /// 打开面板:在组句就拿当前候选搜一遍,搜到的放进搜索组,停在翻译组(带着这段拼音接着打); + /// 没在组句(从菜单打开)就没有搜索组,停在上次关掉时那组。翻译不可用时没有翻译组。 /// 返回开没开(目前总是开)。 pub fn open_symbol_panel(&mut self) -> bool { - if self.composition.is_empty() { - self.panel = Some(SymbolPanel::new("", Vec::new(), self.panel_history.uses())); - return true; + let composing = !self.composition.is_empty(); + let mut panel = if composing { + let items = self + .query() + .map(|query| query.candidates.items) + .unwrap_or_default(); + let results = self.search_panel(&items); + SymbolPanel::new(self.composition.text(), results, self.panel_history.uses()) + } else { + SymbolPanel::new("", Vec::new(), self.panel_history.uses()) + }; + if self.panel_translate_available() { + panel = panel.with_translate(self.panel_language()); } - let items = self - .query() - .map(|query| query.candidates.items) - .unwrap_or_default(); - let results = self.search_panel(&items); - self.panel = Some(SymbolPanel::new( - self.composition.text(), - results, - self.panel_history.uses(), - )); + let landing = if composing { + Some(PanelGroup::Translate) + } else { + self.panel_group + }; + if let Some(group) = landing { + panel.open_on(group); + } + self.panel = Some(panel); true } @@ -41,7 +54,20 @@ impl Engine { } pub fn close_symbol_panel(&mut self) { - self.panel = None; + self.take_panel(); + } + + /// 收起面板:记下停在哪组、翻译选的语言,作废翻译框在飞的请求(免得译文回来被当成别的结果)。 + pub(in crate::engine) fn take_panel(&mut self) -> Option { + let panel = self.panel.take()?; + self.panel_group = Some(panel.group()); + if let Some(language) = panel.target_language() { + self.panel_language = Some(language); + } + if let Some(sequence) = panel.draft().in_flight() { + self.pending_predictions.remove(&sequence); + } + Some(panel) } /// 候选里已经配好的 emoji / 颜文字 / 符号先收,再拿前几个词查关键词与三张内置表。 diff --git a/crates/qingjian-core/src/engine/panel/translate.rs b/crates/qingjian-core/src/engine/panel/translate.rs new file mode 100644 index 00000000..1c0adac0 --- /dev/null +++ b/crates/qingjian-core/src/engine/panel/translate.rs @@ -0,0 +1,219 @@ +//! 面板翻译组:框里打字、选词进原文,停键后发翻译,回车把译文交给应用。 +//! 原文与进度在 [`crate::panel::SymbolPanel`] 里,这里管请求、缓存与上屏;壳每拍调 [`Engine::tick_panel_translation`]。 + +use std::time::Instant; + +use crate::Language; +use crate::candidate::Candidate; +use crate::engine::Engine; +use crate::panel::{PanelGroup, TranslateStatus}; + +impl Engine { + /// 翻译组能不能用:接了翻译提供方、没在私密输入。 + pub(super) fn panel_translate_available(&self) -> bool { + self.translation_enabled() && !self.private + } + + /// 翻译组缺省的目标语言:上次选的,没选过跟学习语言走(学习语言是中文或关掉时用英语)。 + pub(super) fn panel_language(&self) -> Language { + self.panel_language + .unwrap_or(match self.translator.language() { + Language::Chinese => Language::English, + language => language, + }) + } + + /// 快捷键:打开面板并停在翻译组。翻译不可用时不开,返回 `false`。 + pub fn open_translate_panel(&mut self) -> bool { + if !self.panel_translate_available() { + return false; + } + self.open_symbol_panel(); + self.panel + .as_mut() + .is_some_and(|panel| panel.open_on(PanelGroup::Translate)) + } + + /// 面板开着而且停在翻译组:这时敲的字进框、选的词进原文,不进应用。 + pub fn panel_translating(&self) -> bool { + self.panel + .as_ref() + .is_some_and(|panel| panel.group() == PanelGroup::Translate) + } + + /// 直接往框里加字(英文模式下敲的字母、空格)。不在翻译组返回 `false`。 + pub fn push_panel_text(&mut self, text: &str) -> bool { + if !self.panel_translating() { + return false; + } + if let Some(panel) = self.panel.as_mut() { + panel.draft_mut().push(text, Instant::now()); + } + true + } + + /// 框里敲的标点:中文标点开着时按组句外的规则换成全角。不在翻译组返回 `false`。 + pub fn push_panel_char(&mut self, c: char) -> bool { + let converted = if self.full_width_punctuation { + self.punctuation.convert(c) + } else { + None + }; + match converted { + Some(text) => self.push_panel_text(text), + None => self.push_panel_text(c.encode_utf8(&mut [0; 4])), + } + } + + /// 删光标前一个字;框里没字时返回 `false`(壳照别的组收起面板),光标在最前面时什么都不做、返回 `true`。 + pub fn panel_backspace(&mut self) -> bool { + if !self.panel_translating() { + return false; + } + let Some(panel) = self.panel.as_mut() else { + return false; + }; + if panel.translate_source().is_empty() { + return false; + } + panel.draft_mut().pop(Instant::now()); + true + } + + /// Delete:删光标后一个字。返回删没删。 + pub fn panel_delete(&mut self) -> bool { + self.panel_translating() + && self + .panel + .as_mut() + .is_some_and(|panel| panel.draft_mut().delete(Instant::now())) + } + + /// 光标左右挪 `delta` 个字(←→)。返回挪没挪。 + pub fn move_panel_cursor(&mut self, delta: isize) -> bool { + self.panel_translating() + && self + .panel + .as_mut() + .is_some_and(|panel| panel.draft_mut().move_cursor(delta)) + } + + /// 光标放到第 `position` 个字之后(点框里的字、Home / End);超出就到最后。返回挪没挪。 + pub fn place_panel_cursor(&mut self, position: usize) -> bool { + self.panel_translating() + && self + .panel + .as_mut() + .is_some_and(|panel| panel.draft_mut().place_cursor(position)) + } + + /// 在翻译组里选词:学习照常记,选出的字进框里的原文而不是交给应用。不在翻译组返回 `false`。 + pub fn commit_to_panel(&mut self, candidate: &Candidate) -> bool { + if !self.panel_translating() { + return false; + } + let mut panel = self.panel.take().expect("刚看过面板开着"); + // 框里的字不在应用里:自动空格与退格撤销都不能拿它当上文 + self.recent_commits.clear(); + let text = self.commit(candidate); + self.recent_commits.clear(); + self.chain.reset(); + panel.draft_mut().push(&text, Instant::now()); + self.panel = Some(panel); + true + } + + /// 回车时译文还没好:不等停键,下一拍就发。 + pub fn hurry_panel_translation(&mut self) { + if let Some(panel) = self.panel.as_mut() { + panel.draft_mut().hurry(); + } + } + + /// 回车上屏:译文对得上框里的原文与语言就收起面板、清掉拼音,返回译文;还没好返回 `None`。 + pub fn take_panel_translation(&mut self) -> Option { + let panel = self + .panel + .as_ref() + .filter(|p| p.group() == PanelGroup::Translate)?; + let text = panel.draft().ready(panel.target_language()?)?.to_owned(); + self.clear(); + self.recent_commits.clear(); + self.chain.reset(); + Some(text) + } + + /// 应用的 preedit 里该显示什么:翻译组里最近一条译文。不在翻译组或还没有译文时为 `None`。 + pub fn panel_preedit(&self) -> Option<&str> { + let panel = self.panel.as_ref()?; + (panel.group() == PanelGroup::Translate) + .then(|| panel.translation()) + .flatten() + } + + /// 翻译组还在等停键或等译文:壳要接着轮询([`Self::tick_panel_translation`])。 + pub fn panel_translation_pending(&self) -> bool { + self.panel_translating() + && self.panel.as_ref().is_some_and(|panel| { + panel.translate_status() == TranslateStatus::Waiting + || panel.draft().retry_pending() + }) + } + + /// 每拍调一次(先 [`Self::poll_prediction`]):停键够久就查缓存或发请求。返回翻译框有没有变化要重画。 + pub fn tick_panel_translation(&mut self, now: Instant) -> bool { + if !self.panel_translating() { + return false; + } + let Some(panel) = self.panel.as_mut() else { + return false; + }; + let Some(chosen) = panel.target_language() else { + return false; + }; + // 上层清过全部联想(拼音删光时会):在飞的请求没了,重发 + if let Some(sequence) = panel.draft().in_flight() + && !self.pending_predictions.contains_key(&sequence) + { + panel.draft_mut().forget_request(); + } + if let Some(job) = panel.draft().due(now, chosen) { + if let Some(text) = self.panel_translations.get(&job) { + panel.draft_mut().resolve(job, text.to_owned()); + } else { + let sent = self.submit_panel_translation(&job.source, job.language); + let draft = self.panel.as_mut().expect("发请求不动面板").draft_mut(); + match sent { + Some(sequence) => draft.sent(sequence, job), + None => draft.fail(job, now), + } + } + } + self.panel + .as_mut() + .is_some_and(|panel| panel.draft_mut().take_unseen()) + } + + /// [`Self::poll_prediction`] 收到的结果是翻译框发的就记下(进缓存),返回 `true`,不再交给壳。 + pub(in crate::engine) fn answer_panel_translation( + &mut self, + sequence: u64, + sentence: &Option, + ) -> bool { + let Some(panel) = self.panel.as_mut() else { + return false; + }; + let Some((job, text)) = + panel + .draft_mut() + .answer(sequence, sentence.clone(), Instant::now()) + else { + return false; + }; + match text { + Some(text) => self.panel_translations.insert(job, text), + None => tracing::info!("panel translation: no result from provider"), + } + true + } +} diff --git a/crates/qingjian-core/src/engine/prediction/mod.rs b/crates/qingjian-core/src/engine/prediction/mod.rs index cd0458c0..49e2c015 100644 --- a/crates/qingjian-core/src/engine/prediction/mod.rs +++ b/crates/qingjian-core/src/engine/prediction/mod.rs @@ -218,7 +218,7 @@ impl Engine { /// (`translation_target`)。提供方不可用、私密输入中、文字为空时不发,返回 `None`; /// 译文从 [`Self::poll_prediction`] 的 `sentence` 里出。不进学习、不动缓冲区。 pub fn request_translation(&mut self, text: &str) -> Option { - self.submit_translation(text, true) + self.submit_translation_kind(text, true, PredictionKind::Translate, None) } /// 为候选词按当前学习语言重新生成一条译文。结果在 `sentence` 字段返回。 @@ -228,6 +228,7 @@ impl Engine { restored.as_deref().unwrap_or(text), true, PredictionKind::CandidateTranslation, + None, ) } @@ -272,20 +273,22 @@ impl Engine { Some(sequence) } - /// 为当前整句候选请求译文。和组句联想一样等停键,避免输入变化时重复发出。 - pub fn request_sentence_translation(&mut self, text: &str) -> Option { - self.submit_translation(text, false) - } - - fn submit_translation(&mut self, text: &str, manual: bool) -> Option { - self.submit_translation_kind(text, manual, PredictionKind::Translate) + /// 面板翻译组:目标语言由用户选定(外文原文已在框里换成中文),与「翻译选中文字」走同一个提供方。 + pub(crate) fn submit_panel_translation( + &mut self, + text: &str, + language: crate::Language, + ) -> Option { + self.submit_translation_kind(text, true, PredictionKind::Translate, Some(language)) } + /// `target` 为 `None` 时按学习语言定:候选译文就是学习语言,其余按原文是不是外文换(`translation_target`)。 fn submit_translation_kind( &mut self, text: &str, manual: bool, kind: PredictionKind, + target: Option, ) -> Option { let candidate = kind == PredictionKind::CandidateTranslation; let text = if manual && !candidate { @@ -322,10 +325,10 @@ impl Engine { max_items: 1, want_sentence: true, text: text.to_owned(), - target_language: if kind == PredictionKind::CandidateTranslation { - learning.code() - } else { - translation_target(text, learning).code() + target_language: match target { + Some(language) => language.code(), + None if kind == PredictionKind::CandidateTranslation => learning.code(), + None => translation_target(text, learning).code(), } .to_owned(), }; @@ -372,7 +375,7 @@ impl Engine { .retain(|_, pending| is_translation(pending.kind)); } - /// 作废当前整句候选翻译;候选变化、清空输入或关闭功能时调用。 + /// 作废在等的文字翻译(翻译选中文字、移动端的翻译框);清空或关掉翻译框时调。 pub fn cancel_sentence_translation(&mut self) { self.pending_predictions .retain(|_, pending| pending.kind != PredictionKind::Translate); @@ -393,6 +396,9 @@ impl Engine { .or_else(|| self.predictor.poll()) { if let Some(pending) = self.pending_predictions.remove(&prediction.sequence) { + if self.answer_panel_translation(prediction.sequence, &prediction.sentence) { + continue; + } // 问字的答案、翻译的译文和敲的拼音本来就对不上,只有组句联想的云端词要校验 if pending.kind == PredictionKind::Question { let guess = &pending.question_guess; diff --git a/crates/qingjian-core/src/engine/setup.rs b/crates/qingjian-core/src/engine/setup.rs index 0548e92b..1a75fac1 100644 --- a/crates/qingjian-core/src/engine/setup.rs +++ b/crates/qingjian-core/src/engine/setup.rs @@ -366,6 +366,7 @@ impl Engine { /// 设置显式选区翻译的提供方,独立于自动联想;取消后旧结果不会上屏。 pub fn set_translation_predictor(&mut self, predictor: Option>) { self.cancel_prediction(); + self.panel_translations.clear(); self.translation_predictor = predictor; } @@ -377,6 +378,7 @@ impl Engine { /// 运行时换掉 Predictor(菜单开关云联想 / 配置热加载);正在等的联想一并作废。 pub fn set_predictor(&mut self, predictor: Box) { self.cancel_prediction(); + self.panel_translations.clear(); self.predictor = predictor; } diff --git a/crates/qingjian-core/src/engine/tests/mod.rs b/crates/qingjian-core/src/engine/tests/mod.rs index 838b5171..8b30231e 100644 --- a/crates/qingjian-core/src/engine/tests/mod.rs +++ b/crates/qingjian-core/src/engine/tests/mod.rs @@ -18,6 +18,7 @@ mod lookup; mod nine_key; mod p2c_placement; mod panel; +mod panel_translate; mod polyphone; mod privacy; mod raw; diff --git a/crates/qingjian-core/src/engine/tests/panel_translate.rs b/crates/qingjian-core/src/engine/tests/panel_translate.rs new file mode 100644 index 00000000..78bc6d3d --- /dev/null +++ b/crates/qingjian-core/src/engine/tests/panel_translate.rs @@ -0,0 +1,170 @@ +//! 面板翻译组:打拼音选词进框、停键后发一次、结果进缓存、回车上屏。 + +use super::*; +use crate::panel::{PanelGroup, TranslateStatus}; +use std::time::{Duration, Instant}; + +/// 记下请求、等测试塞结果的假翻译提供方。 +#[derive(Clone, Default)] +struct Queue { + submitted: Arc>>, + replies: Arc>>, +} + +impl Predictor for Queue { + fn policy(&self) -> PredictionPolicy { + PredictionPolicy { + automatic: false, + before: 0, + after: 0, + slots: 0, + max_items: 0, + sentence: false, + } + } + + fn submit(&mut self, request: PredictionRequest) { + self.submitted.lock().unwrap().push(request); + } + + fn poll(&mut self) -> Option { + self.replies.lock().unwrap().pop() + } +} + +impl Queue { + fn reply_last(&self, text: &str) { + let sequence = self.submitted.lock().unwrap().last().unwrap().sequence; + self.replies.lock().unwrap().push(Prediction { + sequence, + words: Vec::new(), + sentence: Some(text.to_owned()), + }); + } + + fn sent(&self) -> Vec<(String, String)> { + self.submitted + .lock() + .unwrap() + .iter() + .map(|r| (r.text.clone(), r.target_language.clone())) + .collect() + } +} + +fn engine(queue: &Queue) -> Engine { + let dictionary = Dictionary::parse("你好\tni hao\t100\n世界\tshi jie\t100\n").unwrap(); + Engine::new(dictionary).with_predictor(Box::new(queue.clone())) +} + +/// 选第一个候选进框。 +fn pick(engine: &mut Engine) { + let first = engine.query().unwrap().candidates.items[0].clone(); + assert!(engine.commit_to_panel(&first)); +} + +#[test] +fn typed_words_go_into_the_box_and_are_translated_after_a_pause() { + let queue = Queue::default(); + let mut engine = engine(&queue); + engine.set_input("nihao"); + assert!(engine.open_symbol_panel()); + assert_eq!( + engine.symbol_panel().unwrap().group(), + PanelGroup::Translate + ); + pick(&mut engine); + assert!(engine.composition().is_empty()); + assert_eq!(engine.symbol_panel().unwrap().translate_source(), "你好"); + + // 刚选完不发,停够了才发,发过不重发 + let now = Instant::now(); + assert!(!engine.tick_panel_translation(now)); + assert!(queue.sent().is_empty()); + let later = now + Duration::from_secs(1); + engine.tick_panel_translation(later); + assert_eq!(queue.sent(), [("你好".to_owned(), "en".to_owned())]); + engine.tick_panel_translation(later); + assert_eq!(queue.sent().len(), 1); + assert!(engine.panel_translation_pending()); + + queue.reply_last("Hello"); + // 翻译框的结果不交给壳 + assert_eq!(engine.poll_prediction(), None); + assert!(engine.tick_panel_translation(later)); + assert_eq!(engine.panel_preedit(), Some("Hello")); + assert_eq!( + engine.symbol_panel().unwrap().translate_status(), + TranslateStatus::Ready + ); + + // 再加两个字、删回去:缓存里有,不重发 + engine.set_input("shijie"); + pick(&mut engine); + assert_eq!( + engine.symbol_panel().unwrap().translate_source(), + "你好世界" + ); + assert_eq!(engine.panel_preedit(), Some("Hello")); + assert!(engine.panel_backspace()); + assert!(engine.panel_backspace()); + engine.tick_panel_translation(later + Duration::from_secs(1)); + assert_eq!(queue.sent().len(), 1); + + assert_eq!(engine.take_panel_translation().as_deref(), Some("Hello")); + assert!(engine.symbol_panel().is_none()); +} + +#[test] +fn switching_language_translates_at_once_and_is_remembered() { + let queue = Queue::default(); + let mut engine = engine(&queue); + engine.set_input("nihao"); + engine.open_symbol_panel(); + pick(&mut engine); + let later = Instant::now() + Duration::from_secs(1); + engine.tick_panel_translation(later); + assert!(engine.symbol_panel_mut().unwrap().switch_section(1)); + engine.tick_panel_translation(later); + assert_eq!(queue.sent()[1], ("你好".to_owned(), "ja".to_owned())); + // 回车时日语译文还没到:不上屏 + assert_eq!(engine.take_panel_translation(), None); + engine.close_symbol_panel(); + + // 从菜单打开回到翻译组,语言还是日语 + assert!(engine.open_symbol_panel()); + let panel = engine.symbol_panel().unwrap(); + assert_eq!(panel.group(), PanelGroup::Translate); + assert_eq!(panel.target_language(), Some(Language::Japanese)); + assert_eq!(panel.translate_source(), ""); +} + +#[test] +fn foreign_text_is_translated_into_chinese_and_closing_drops_the_request() { + let queue = Queue::default(); + let mut engine = engine(&queue); + assert!(engine.open_translate_panel()); + assert!(engine.push_panel_text("good morning")); + let later = Instant::now() + Duration::from_secs(1); + engine.tick_panel_translation(later); + assert_eq!( + queue.sent()[0], + ("good morning".to_owned(), "zh".to_owned()) + ); + engine.close_symbol_panel(); + // 关掉后结果回来也不当成别的东西交给壳 + queue.reply_last("早上好"); + assert_eq!(engine.poll_prediction(), None); +} + +#[test] +fn without_a_provider_there_is_no_translate_group() { + let dictionary = Dictionary::parse("你好\tni hao\t100\n").unwrap(); + let mut engine = Engine::new(dictionary); + assert!(!engine.open_translate_panel()); + engine.set_input("nihao"); + engine.open_symbol_panel(); + let panel = engine.symbol_panel().unwrap(); + assert!(!panel.groups().contains(&PanelGroup::Translate)); + assert_eq!(panel.target_language(), None); +} diff --git a/crates/qingjian-core/src/panel/common.rs b/crates/qingjian-core/src/panel/common.rs index 1abe8b90..791667fa 100644 --- a/crates/qingjian-core/src/panel/common.rs +++ b/crates/qingjian-core/src/panel/common.rs @@ -42,7 +42,7 @@ fn belongs(group: PanelGroup, text: &str) -> bool { let wanted = match group { PanelGroup::Symbols => CandidateKind::Symbol, PanelGroup::Emoji => CandidateKind::Emoji, - PanelGroup::Search => return false, + PanelGroup::Search | PanelGroup::Translate => return false, }; let probe = Candidate { text: text.to_owned(), diff --git a/crates/qingjian-core/src/panel/group.rs b/crates/qingjian-core/src/panel/group.rs index 0c511e8a..f5d94f4a 100644 --- a/crates/qingjian-core/src/panel/group.rs +++ b/crates/qingjian-core/src/panel/group.rs @@ -1,8 +1,9 @@ //! 面板的分组。 -/// 面板的三组分类,按这个顺序排;搜索组只在敲的字搜得到东西时才有。 +/// 面板的几组,按这个顺序排;翻译组只在翻译可用时有,搜索组只在敲的字搜得到东西时才有。 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum PanelGroup { + Translate, Search, Symbols, Emoji, @@ -11,6 +12,7 @@ pub enum PanelGroup { impl PanelGroup { pub fn label(self) -> &'static str { match self { + Self::Translate => "翻译", Self::Search => "搜索", Self::Symbols => "符号", Self::Emoji => "表情", diff --git a/crates/qingjian-core/src/panel/mod.rs b/crates/qingjian-core/src/panel/mod.rs index 35ccf70a..36c99dda 100644 --- a/crates/qingjian-core/src/panel/mod.rs +++ b/crates/qingjian-core/src/panel/mod.rs @@ -1,4 +1,5 @@ -//! 符号面板:组句中按 ↑(高亮还在第一个、没动过)打开,按「搜索 / 符号 / 表情」三组分类列出符号、emoji 与颜文字。 +//! 符号面板:组句中按 ↑(高亮还在第一个、没动过)打开,按「搜索 / 符号 / 表情」三组分类列出符号、emoji 与颜文字, +//! 另有「翻译」组:框里打一句,译文进应用的 preedit(`translate` 子模块)。 //! 数据编进程序(`crates/qingjian-core/data/panel/`,`tools/corpus/symbol_panel.py` 生成);搜索拿当前候选词查 CLDR 中文关键词。 //! 面板只管内容与分类切换,格子里怎么移动高亮交给 [`crate::Grid`],与横排矩阵同一套。 @@ -8,6 +9,7 @@ mod group; mod history; mod section; mod state; +mod translate; mod usage; pub(crate) use catalog::{CATALOG, KEYWORDS}; @@ -16,6 +18,8 @@ pub use group::PanelGroup; pub use history::{NoPanelHistory, PanelHistory}; pub use section::{PANEL_COLUMNS, PanelSection}; pub use state::SymbolPanel; +pub use translate::TranslateStatus; +pub(crate) use translate::TranslationCache; pub use usage::PanelUse; /// 内置的全部分类(不含搜索组),按面板里的顺序;移动端的符号 / 表情面板直接拿去排格子。 diff --git a/crates/qingjian-core/src/panel/state.rs b/crates/qingjian-core/src/panel/state.rs index bb5383b9..74515e2d 100644 --- a/crates/qingjian-core/src/panel/state.rs +++ b/crates/qingjian-core/src/panel/state.rs @@ -1,14 +1,23 @@ //! 打开着的面板:搜索结果(有的话)加内置分类,记着当前分类与每组上次停在哪个分类。 //! 符号组与表情组的「常用」打开时按用户用过的重排([`common_items`]),其余分类原样用内置的。 +//! 翻译组排最前,每种目标语言算一个分类(没有条目),框里的原文与译文在 [`TranslateDraft`]。 use std::collections::HashMap; use super::common::COMMON; +use super::translate::{TARGET_LANGUAGES, TranslateDraft, TranslateStatus}; use super::{CATALOG, PanelGroup, PanelSection, PanelUse, common_items}; -use crate::candidate::{Candidate, CandidateKind}; +use crate::candidate::{Candidate, CandidateKind, Language, learning_languages}; +use crate::i18n::{tr, tr_args}; #[derive(Debug, Clone)] pub struct SymbolPanel { + /// 翻译组:[`TARGET_LANGUAGES`] 每种一个分类;翻译不可用时为空。 + translate: Vec, + + /// 翻译框里的原文与翻译进度。 + draft: TranslateDraft, + /// 搜索结果组,没搜到东西时为 `None`。 search: Option, @@ -31,6 +40,8 @@ impl SymbolPanel { items: results, }); Self { + translate: Vec::new(), + draft: TranslateDraft::default(), search, common: ranked_common(uses), current: 0, @@ -38,10 +49,111 @@ impl SymbolPanel { } } - /// 全部分类:搜索结果在前,然后是内置的(「常用」换成重排过的)。 - fn sections(&self) -> impl Iterator { - self.search + /// 加上翻译组,目标语言缺省是 `chosen`;还停在原来那组。 + pub(crate) fn with_translate(mut self, chosen: Language) -> Self { + let index = TARGET_LANGUAGES .iter() + .position(|l| *l == chosen) + .unwrap_or(0); + self.translate = TARGET_LANGUAGES + .iter() + .map(|language| PanelSection { + group: PanelGroup::Translate, + name: language_name(*language).to_owned(), + items: Vec::new(), + }) + .collect(); + self.current += self.translate.len(); + self.last.insert(PanelGroup::Translate, index); + self + } + + /// 换到 `group`(落在那组上次停的分类);面板里没有这组就不动。返回停没停在那组。 + pub(crate) fn open_on(&mut self, group: PanelGroup) -> bool { + if self.group() == group { + return true; + } + let Some(index) = self.groups().iter().position(|g| *g == group) else { + return false; + }; + self.select_group(index) + } + + /// 翻译组选的目标语言:在翻译组就是当前分类,不在就是上次停的;没有翻译组时为 `None`。 + pub fn target_language(&self) -> Option { + if self.translate.is_empty() { + return None; + } + let index = if self.group() == PanelGroup::Translate { + self.current + } else { + self.last.get(&PanelGroup::Translate).copied().unwrap_or(0) + }; + TARGET_LANGUAGES.get(index).copied() + } + + /// 翻译框里的原文(不含还在打的拼音)。 + pub fn translate_source(&self) -> &str { + self.draft.source() + } + + /// 光标在原文里的位置(第几个字之后);还在打的拼音画在这里,选的词插在这里。 + pub fn translate_cursor(&self) -> usize { + self.draft.cursor() + } + + /// 最近一条译文,原文改了还没译好时也先留着,这次没翻出来就没有;壳把它放进应用的 preedit。 + pub fn translation(&self) -> Option<&str> { + self.draft.translation(self.target_language()?) + } + + pub fn translate_status(&self) -> TranslateStatus { + self.target_language() + .map_or(TranslateStatus::Idle, |language| { + self.draft.status(language) + }) + } + + /// 翻译框底下那一行:实际译成什么语言(外文原文是中文)与进度,随界面语言。 + pub fn translate_line(&self) -> String { + let Some(chosen) = self.target_language() else { + return String::new(); + }; + let language = self.draft.job(chosen).map_or(chosen, |job| job.language); + let name = match language { + Language::Chinese => tr("panel.chinese"), + language => language_name(language), + }; + let state = match self.translate_status() { + TranslateStatus::Idle => tr("panel.translate_idle"), + TranslateStatus::Waiting => tr("panel.translate_waiting"), + TranslateStatus::Ready => tr("panel.translate_ready"), + TranslateStatus::Failed => tr("panel.translate_failed"), + }; + format!( + "{} · {state}", + tr_args("panel.translate_to", &[("language", &name)]) + ) + } + + /// 框里没字时的提示,随界面语言。 + pub fn translate_placeholder(&self) -> &'static str { + tr("panel.translate_placeholder") + } + + pub(crate) fn draft(&self) -> &TranslateDraft { + &self.draft + } + + pub(crate) fn draft_mut(&mut self) -> &mut TranslateDraft { + &mut self.draft + } + + /// 全部分类:翻译组在最前,然后是搜索结果,再是内置的(「常用」换成重排过的)。 + fn sections(&self) -> impl Iterator { + self.translate + .iter() + .chain(self.search.iter()) .chain(CATALOG.sections.iter().map(|builtin| { self.common .iter() @@ -51,7 +163,7 @@ impl SymbolPanel { } fn len(&self) -> usize { - usize::from(self.search.is_some()) + CATALOG.sections.len() + self.translate.len() + usize::from(self.search.is_some()) + CATALOG.sections.len() } pub fn section(&self) -> &PanelSection { @@ -146,6 +258,14 @@ impl SymbolPanel { } } +/// 语言在界面上的名字(随界面语言)。 +fn language_name(language: Language) -> &'static str { + learning_languages() + .into_iter() + .find(|(code, _)| *code == language.code()) + .map_or(language.code(), |(_, name)| name) +} + /// 符号组与表情组的「常用」按 `uses` 重排;目录里没有的条目(带肤色的 emoji)现做一个候选。 fn ranked_common(uses: &[PanelUse]) -> Vec { CATALOG @@ -225,6 +345,24 @@ mod tests { assert_eq!(panel.groups(), [PanelGroup::Symbols, PanelGroup::Emoji]); } + #[test] + fn translate_group_comes_first_and_its_sections_are_languages() { + let mut panel = + SymbolPanel::new("nihao", vec![item("👋")], &[]).with_translate(Language::Japanese); + assert_eq!(panel.group(), PanelGroup::Search); + assert_eq!(panel.target_language(), Some(Language::Japanese)); + assert!(panel.open_on(PanelGroup::Translate)); + assert_eq!(panel.groups()[0], PanelGroup::Translate); + assert_eq!(panel.target_language(), Some(Language::Japanese)); + assert!(panel.switch_section(1)); + assert_eq!(panel.target_language(), Some(Language::Spanish)); + // 去别的组再回来,语言还是刚才换的 + assert!(panel.switch_group(1)); + assert_eq!(panel.target_language(), Some(Language::Spanish)); + assert!(panel.open_on(PanelGroup::Translate)); + assert_eq!(panel.group_sections().1, 2); + } + #[test] fn common_sections_put_used_items_first() { let uses = [ diff --git a/crates/qingjian-core/src/panel/translate/cache.rs b/crates/qingjian-core/src/panel/translate/cache.rs new file mode 100644 index 00000000..1b44d28a --- /dev/null +++ b/crates/qingjian-core/src/panel/translate/cache.rs @@ -0,0 +1,64 @@ +//! 翻译组的译文缓存:删了又打回去、换个语言再换回来都不重发。只在内存里,换模型时清空; +//! 框里是用户自己打的句子,不落盘。 + +use std::collections::{HashMap, VecDeque}; + +use super::TranslateJob; + +/// 最多记几条,超了丢最早的。 +const CAPACITY: usize = 300; + +#[derive(Debug, Default)] +pub(crate) struct TranslationCache { + entries: HashMap, + + /// 放进来的先后,满了从头丢。 + order: VecDeque, +} + +impl TranslationCache { + pub(crate) fn get(&self, job: &TranslateJob) -> Option<&str> { + self.entries.get(job).map(String::as_str) + } + + pub(crate) fn insert(&mut self, job: TranslateJob, text: String) { + if self.entries.insert(job.clone(), text).is_some() { + return; + } + self.order.push_back(job); + if self.order.len() > CAPACITY + && let Some(oldest) = self.order.pop_front() + { + self.entries.remove(&oldest); + } + } + + pub(crate) fn clear(&mut self) { + self.entries.clear(); + self.order.clear(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Language; + + fn job(source: &str) -> TranslateJob { + TranslateJob { + source: source.to_owned(), + language: Language::English, + } + } + + #[test] + fn drops_the_oldest_when_full() { + let mut cache = TranslationCache::default(); + for i in 0..=CAPACITY { + cache.insert(job(&i.to_string()), i.to_string()); + } + assert_eq!(cache.get(&job("0")), None); + assert_eq!(cache.get(&job("1")), Some("1")); + assert_eq!(cache.entries.len(), CAPACITY); + } +} diff --git a/crates/qingjian-core/src/panel/translate/draft.rs b/crates/qingjian-core/src/panel/translate/draft.rs new file mode 100644 index 00000000..05fa1798 --- /dev/null +++ b/crates/qingjian-core/src/panel/translate/draft.rs @@ -0,0 +1,337 @@ +//! 翻译框里的原文和它的翻译进度:原文带一个光标,像记事本那样在光标处插字、删字; +//! 停键满 [`DEBOUNCE`] 才发,原文或目标语言没变不重发,没翻出来的过 [`RETRY_AFTER`] 自动重试一次; +//! 新译文回来前先留着上一条,免得应用里的 preedit 一闪一闪。 + +use std::time::{Duration, Instant}; + +use super::{TranslateJob, TranslateStatus}; +use crate::Language; +use crate::engine::prediction::translation_target; + +/// 停键这么久才发请求:打字过程中不一键一发。 +pub(crate) const DEBOUNCE: Duration = Duration::from_millis(800); + +/// 没翻出来(多半是网络抖了一下)过这么久自动再发一次;再失败就等用户按回车。 +pub(crate) const RETRY_AFTER: Duration = Duration::from_secs(2); + +/// 原文少于这么多字不发:一个字翻出来没多大用,还白费一次请求。 +const MIN_CHARS: usize = 2; + +/// 一次没翻出来的内容。 +#[derive(Debug, Clone)] +struct Failure { + job: TranslateJob, + + at: Instant, + + /// 自动重试过了:再失败就不再自己发。 + retried: bool, +} + +#[derive(Debug, Clone, Default)] +pub(crate) struct TranslateDraft { + source: String, + + /// 光标在原文里的位置(第几个字之后)。 + cursor: usize, + + /// 最后一次改原文的时刻;离现在不满 [`DEBOUNCE`] 就先不发。 + edited: Option, + + /// 在飞的请求序号与内容。 + request: Option<(u64, TranslateJob)>, + + /// 最近一次拿到的译文与它对应的内容。 + result: Option<(TranslateJob, String)>, + + /// 最近一次没翻出来的内容。 + failed: Option, + + /// 译文或失败有了新消息、壳还没重画。 + unseen: bool, +} + +impl TranslateDraft { + pub(crate) fn source(&self) -> &str { + &self.source + } + + pub(crate) fn cursor(&self) -> usize { + self.cursor + } + + /// 光标处的字节位置。 + fn byte_at(&self, chars: usize) -> usize { + self.source + .char_indices() + .nth(chars) + .map_or(self.source.len(), |(i, _)| i) + } + + fn char_count(&self) -> usize { + self.source.chars().count() + } + + /// 在光标处插字,光标挪到插入的字后面。 + pub(crate) fn push(&mut self, text: &str, now: Instant) { + if text.is_empty() { + return; + } + let at = self.byte_at(self.cursor); + self.source.insert_str(at, text); + self.cursor += text.chars().count(); + self.edited = Some(now); + } + + /// 删掉光标前一个字。光标在最前面时返回 `false`。 + pub(crate) fn pop(&mut self, now: Instant) -> bool { + if self.cursor == 0 { + return false; + } + let at = self.byte_at(self.cursor - 1); + self.source.remove(at); + self.cursor -= 1; + self.edited = Some(now); + true + } + + /// 删掉光标后一个字(Delete)。光标在最后时返回 `false`。 + pub(crate) fn delete(&mut self, now: Instant) -> bool { + if self.cursor >= self.char_count() { + return false; + } + let at = self.byte_at(self.cursor); + self.source.remove(at); + self.edited = Some(now); + true + } + + /// 光标挪到第 `position` 个字之后(超出就到最后)。返回挪没挪。 + pub(crate) fn place_cursor(&mut self, position: usize) -> bool { + let position = position.min(self.char_count()); + let moved = position != self.cursor; + self.cursor = position; + moved + } + + /// 光标左右挪 `delta` 个字。返回挪没挪。 + pub(crate) fn move_cursor(&mut self, delta: isize) -> bool { + let position = (self.cursor as isize + delta).max(0) as usize; + self.place_cursor(position) + } + + /// 不等停键,下一次 [`Self::due`] 就发;没翻出来的也重新发(回车时译文还没到)。 + pub(crate) fn hurry(&mut self) { + self.edited = None; + self.failed = None; + } + + /// 框里的原文按 `chosen` 该怎么译;字太少返回 `None`。 + pub(crate) fn job(&self, chosen: Language) -> Option { + let source = self.source.trim(); + if source.chars().count() < MIN_CHARS { + return None; + } + Some(TranslateJob { + source: source.to_owned(), + language: translation_target(source, chosen), + }) + } + + /// 这份内容没翻出来、而且不再自动重试。 + fn gave_up(&self, job: &TranslateJob, now: Instant) -> bool { + self.failed.as_ref().is_some_and(|failure| { + failure.job == *job && (failure.retried || now.duration_since(failure.at) < RETRY_AFTER) + }) + } + + /// 到时候该发的请求:停键够久、而且这份内容没译过、没在飞,没翻出来的只自动重试一次。 + pub(crate) fn due(&self, now: Instant, chosen: Language) -> Option { + if self + .edited + .is_some_and(|edited| now.duration_since(edited) < DEBOUNCE) + { + return None; + } + let job = self.job(chosen)?; + let settled = self.result.as_ref().map(|(j, _)| j) == Some(&job) + || self.request.as_ref().map(|(_, j)| j) == Some(&job) + || self.gave_up(&job, now); + (!settled).then_some(job) + } + + pub(crate) fn sent(&mut self, sequence: u64, job: TranslateJob) { + if let Some(failure) = self.failed.as_mut().filter(|f| f.job == job) { + failure.retried = true; + } + self.request = Some((sequence, job)); + } + + pub(crate) fn resolve(&mut self, job: TranslateJob, text: String) { + if self.failed.as_ref().is_some_and(|f| f.job == job) { + self.failed = None; + } + self.result = Some((job, text)); + self.unseen = true; + } + + pub(crate) fn fail(&mut self, job: TranslateJob, now: Instant) { + let retried = self + .failed + .as_ref() + .is_some_and(|f| f.job == job && f.retried); + self.failed = Some(Failure { + job, + at: now, + retried, + }); + self.unseen = true; + } + + /// 没翻出来、还会自动再试一次:壳要接着轮询。 + pub(crate) fn retry_pending(&self) -> bool { + self.failed.as_ref().is_some_and(|failure| !failure.retried) + } + + /// 上次问过之后有没有新消息要重画。 + pub(crate) fn take_unseen(&mut self) -> bool { + std::mem::take(&mut self.unseen) + } + + /// 在飞的请求序号。 + pub(crate) fn in_flight(&self) -> Option { + self.request.as_ref().map(|(sequence, _)| *sequence) + } + + /// 在飞的请求被作废了(上层清掉了全部联想):忘掉它,下一次 [`Self::due`] 重发。 + pub(crate) fn forget_request(&mut self) { + self.request = None; + } + + /// 收到序号 `sequence` 的结果:是这个框发的就记下并返回对应的内容,不是返回 `None`。 + pub(crate) fn answer( + &mut self, + sequence: u64, + text: Option, + now: Instant, + ) -> Option<(TranslateJob, Option)> { + if self.in_flight() != Some(sequence) { + return None; + } + let (_, job) = self.request.take()?; + match text.filter(|t| !t.trim().is_empty()) { + Some(text) => { + self.resolve(job.clone(), text.clone()); + Some((job, Some(text))) + } + None => { + self.fail(job.clone(), now); + Some((job, None)) + } + } + } + + /// 要放进应用 preedit 的译文:最近一条,原文改了还在等新的时先留着;原文删光或这次没翻出来就不显示,免得对不上。 + pub(crate) fn translation(&self, chosen: Language) -> Option<&str> { + if self.source.trim().is_empty() || self.status(chosen) == TranslateStatus::Failed { + return None; + } + self.result.as_ref().map(|(_, text)| text.as_str()) + } + + /// 对得上框里原文与 `chosen` 的译文。 + pub(crate) fn ready(&self, chosen: Language) -> Option<&str> { + let job = self.job(chosen)?; + self.result + .as_ref() + .filter(|(j, _)| *j == job) + .map(|(_, text)| text.as_str()) + } + + pub(crate) fn status(&self, chosen: Language) -> TranslateStatus { + let Some(job) = self.job(chosen) else { + return TranslateStatus::Idle; + }; + if self.ready(chosen).is_some() { + TranslateStatus::Ready + } else if self.request.as_ref().is_some_and(|(_, j)| *j == job) { + TranslateStatus::Waiting + } else if self.failed.as_ref().is_some_and(|f| f.job == job) { + TranslateStatus::Failed + } else { + TranslateStatus::Waiting + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn waits_for_a_pause_and_does_not_resend_the_same_text() { + let start = Instant::now(); + let mut draft = TranslateDraft::default(); + draft.push("你", start); + assert_eq!(draft.due(start + DEBOUNCE, Language::English), None); + draft.push("好", start); + assert_eq!(draft.status(Language::English), TranslateStatus::Waiting); + assert_eq!(draft.due(start, Language::English), None); + let job = draft.due(start + DEBOUNCE, Language::English).unwrap(); + assert_eq!(job.source, "你好"); + draft.sent(7, job.clone()); + assert_eq!(draft.due(start + DEBOUNCE, Language::English), None); + // 别人的结果不算 + assert_eq!(draft.answer(6, Some("x".to_owned()), start), None); + draft.answer(7, Some("Hello".to_owned()), start); + assert_eq!(draft.ready(Language::English), Some("Hello")); + assert_eq!(draft.due(start + DEBOUNCE, Language::English), None); + // 换了语言要重发,旧译文先留着 + assert!(draft.due(start + DEBOUNCE, Language::Japanese).is_some()); + assert_eq!(draft.translation(Language::Japanese), Some("Hello")); + assert_eq!(draft.status(Language::Japanese), TranslateStatus::Waiting); + } + + #[test] + fn foreign_text_goes_back_to_chinese_and_a_failure_is_retried_once() { + let start = Instant::now(); + let mut draft = TranslateDraft::default(); + draft.push("good morning", start); + let job = draft.due(start + DEBOUNCE, Language::English).unwrap(); + assert_eq!(job.language, Language::Chinese); + draft.sent(1, job.clone()); + draft.answer(1, None, start); + assert_eq!(draft.status(Language::English), TranslateStatus::Failed); + assert_eq!(draft.due(start + DEBOUNCE, Language::English), None); + // 过一会儿自己重试一次,再失败就不再发 + let later = start + RETRY_AFTER; + assert_eq!(draft.due(later, Language::English), Some(job.clone())); + draft.sent(2, job.clone()); + assert_eq!(draft.status(Language::English), TranslateStatus::Waiting); + draft.answer(2, None, later); + assert_eq!(draft.due(later + RETRY_AFTER, Language::English), None); + // 回车重新发 + draft.hurry(); + assert_eq!(draft.due(later, Language::English), Some(job)); + } + + #[test] + fn edits_happen_at_the_cursor() { + let now = Instant::now(); + let mut draft = TranslateDraft::default(); + draft.push("你世界", now); + assert!(draft.place_cursor(1)); + draft.push("好", now); + assert_eq!((draft.source(), draft.cursor()), ("你好世界", 2)); + assert!(draft.delete(now)); + assert_eq!(draft.source(), "你好界"); + assert!(draft.pop(now)); + assert_eq!((draft.source(), draft.cursor()), ("你界", 1)); + assert!(draft.move_cursor(-5)); + assert!(!draft.pop(now)); + assert!(!draft.place_cursor(0)); + assert!(draft.place_cursor(99)); + assert_eq!(draft.cursor(), 2); + assert!(!draft.delete(now)); + } +} diff --git a/crates/qingjian-core/src/panel/translate/job.rs b/crates/qingjian-core/src/panel/translate/job.rs new file mode 100644 index 00000000..0bd44ca0 --- /dev/null +++ b/crates/qingjian-core/src/panel/translate/job.rs @@ -0,0 +1,11 @@ +//! 一次翻译要做的事。 + +use crate::Language; + +/// 原文与实际的目标语言(外文原文是中文);请求、译文、缓存都按它对账。 +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub(crate) struct TranslateJob { + pub(crate) source: String, + + pub(crate) language: Language, +} diff --git a/crates/qingjian-core/src/panel/translate/mod.rs b/crates/qingjian-core/src/panel/translate/mod.rs new file mode 100644 index 00000000..acb0c87f --- /dev/null +++ b/crates/qingjian-core/src/panel/translate/mod.rs @@ -0,0 +1,23 @@ +//! 面板的「翻译」组:框里攒着用户打的原文,停一会儿才去翻译,回来的译文由壳显示在应用的 preedit 里。 +//! 目标语言就是这一组的分类([`super::SymbolPanel`] 里 `[` `]` 换);原文是外文时照「翻译选中文字」的判断改译回中文。 + +mod cache; +mod draft; +mod job; +mod status; + +pub(crate) use cache::TranslationCache; +pub(crate) use draft::TranslateDraft; +pub(crate) use job::TranslateJob; +pub use status::TranslateStatus; + +use crate::Language; + +/// 翻译组列出的目标语言,按这个顺序;与学习语言的选项一致。 +pub(crate) const TARGET_LANGUAGES: [Language; 5] = [ + Language::English, + Language::Japanese, + Language::Spanish, + Language::Vietnamese, + Language::German, +]; diff --git a/crates/qingjian-core/src/panel/translate/status.rs b/crates/qingjian-core/src/panel/translate/status.rs new file mode 100644 index 00000000..08b9d01f --- /dev/null +++ b/crates/qingjian-core/src/panel/translate/status.rs @@ -0,0 +1,16 @@ +//! 翻译组当前的状态,壳拿去写提示行。 + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TranslateStatus { + /// 框里没字,或者字太少还不值得翻译。 + Idle, + + /// 等停键或等译文回来。 + Waiting, + + /// 译文对得上框里的原文与目标语言,回车就上屏。 + Ready, + + /// 云端没给出译文,或者翻译不可用(云服务关了、私密输入中)。 + Failed, +} diff --git a/crates/qingjian-platform/src/config/key_combo.rs b/crates/qingjian-platform/src/config/key_combo.rs index 9011027b..01bfb509 100644 --- a/crates/qingjian-platform/src/config/key_combo.rs +++ b/crates/qingjian-platform/src/config/key_combo.rs @@ -45,6 +45,11 @@ impl KeyCombo { ..Self::TRANSLATE_DEFAULT }; + pub const TRANSLATE_PANEL_DEFAULT: Self = Self { + key: 'y', + ..Self::TRANSLATE_DEFAULT + }; + pub const TRANSLATE_DEFAULT: Self = Self { modifiers: Modifiers { option: true, diff --git a/crates/qingjian-platform/src/config/mod.rs b/crates/qingjian-platform/src/config/mod.rs index 66f4d020..5db20aa9 100644 --- a/crates/qingjian-platform/src/config/mod.rs +++ b/crates/qingjian-platform/src/config/mod.rs @@ -202,6 +202,8 @@ translation_second = "shift+option" # 把应用里选中的文字译成学习语言(要开着云服务):译文先出现在候选窗口,回车替换选中的文字,Esc 保留原文 # 修饰键 + 一个字母或数字,任意组合;避开 ⌘T 这类应用常用键 translate_selection = "control+option+t" +# 没在打字时打开符号面板的「翻译」组(要开着云服务):框里打一句,译文停顿后出现在光标处,回车上屏 +translate_panel = "control+option+y" "# }; } @@ -236,6 +238,8 @@ translation_second = "shift+ctrl" translate_selection = "ctrl+alt+t" # Windows 组句中按需调用 AI predict = "ctrl+alt+j" +# 没在打字时打开符号面板的「翻译」组(要开着云服务):框里打一句,译文停顿后出现在光标处,回车上屏 +translate_panel = "ctrl+alt+y" "# }; } @@ -459,8 +463,6 @@ lookahead = 32 slots = 2 # 组句中除了词候选还要不要整句补全(preedit 右侧,Tab 接受) sentence = true -# 本地整句候选右侧是否显示服务返回的译文;仅发送该候选文本,不发送光标前后上下文 -sentence_translation = false # 上屏的整句(含分几次打完的一句)翻成学习语言,记进生词本的时间线;只发那一句,不发光标前后的文字 vocabulary_translation = true diff --git a/crates/qingjian-platform/src/config/shortcut.rs b/crates/qingjian-platform/src/config/shortcut.rs index 8c9f9705..3efb5886 100644 --- a/crates/qingjian-platform/src/config/shortcut.rs +++ b/crates/qingjian-platform/src/config/shortcut.rs @@ -44,6 +44,9 @@ pub struct ShortcutConfig { /// 组句中按需调用 AI(缺省 Ctrl+Alt+J,macOS 是 ⌃⌥J)。 pub predict: KeyCombo, + /// 没在打字时打开符号面板的翻译组(缺省 Ctrl+Alt+Y,macOS 是 ⌃⌥Y;需要云服务开着)。 + pub translate_panel: KeyCombo, + /// 呼出剪贴板面板(实验功能,Windows 用),见 [`super::ClipboardConfig`]。 pub clipboard: KeyCombo, @@ -88,6 +91,7 @@ impl Default for ShortcutConfig { translation_second, translate_selection, predict: KeyCombo::PREDICT_DEFAULT, + translate_panel: KeyCombo::TRANSLATE_PANEL_DEFAULT, clipboard: KeyCombo::CLIPBOARD_DEFAULT, toggle_traditional: None, commit_candidate: CommitKey::SPACE, diff --git a/crates/qingjian-predict/src/config.rs b/crates/qingjian-predict/src/config.rs index fb12e04f..2bd49351 100644 --- a/crates/qingjian-predict/src/config.rs +++ b/crates/qingjian-predict/src/config.rs @@ -44,9 +44,6 @@ pub struct PredictConfig { /// 组句中要不要整句补全。 pub sentence: bool, - /// 给本地整句候选自动请求译文,并显示在候选右侧。 - pub sentence_translation: bool, - /// 上屏的整句翻成学习语言,记进生词本的时间线(只发那一句)。 pub vocabulary_translation: bool, @@ -72,7 +69,6 @@ impl Default for PredictConfig { lookahead: 32, slots: 2, sentence: true, - sentence_translation: false, vocabulary_translation: true, reasoning_effort: "none".to_owned(), } diff --git a/crates/qingjian-render/src/lib.rs b/crates/qingjian-render/src/lib.rs index ee51e437..2042bd19 100644 --- a/crates/qingjian-render/src/lib.rs +++ b/crates/qingjian-render/src/lib.rs @@ -26,7 +26,7 @@ pub use frame::{Frame, Mode, Preedit, PreeditSegment, PreeditStyle, Row, Tone}; pub use keyboard::{Keyboard, Keycap, KeycapState, Root}; pub use layout::Layout; pub use material::{Material, MaterialBacking}; -pub use panel::{PanelCell, PanelFrame}; +pub use panel::{PanelCell, PanelFrame, PanelTranslate}; pub use renderer::{ HitRegion, HitTarget, PanelHit, Rendered, RenderedStatus, Renderer, StatusCell, }; diff --git a/crates/qingjian-render/src/panel/mod.rs b/crates/qingjian-render/src/panel/mod.rs index d01f9813..832b9819 100644 --- a/crates/qingjian-render/src/panel/mod.rs +++ b/crates/qingjian-render/src/panel/mod.rs @@ -2,8 +2,10 @@ //! [`crate::Renderer::render_panel`] 按主题的 `panel` 一节画。 mod cell; +mod translate; pub use cell::PanelCell; +pub use translate::PanelTranslate; #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct PanelFrame { @@ -36,4 +38,7 @@ pub struct PanelFrame { /// 顶上一行右边的按键提示。 pub hint: String, + + /// 翻译组:有它时格子区换成翻译框,`cells` 与 `footer` 不画。 + pub translate: Option, } diff --git a/crates/qingjian-render/src/panel/translate.rs b/crates/qingjian-render/src/panel/translate.rs new file mode 100644 index 00000000..1bcaaf7f --- /dev/null +++ b/crates/qingjian-render/src/panel/translate.rs @@ -0,0 +1,25 @@ +//! 翻译组的翻译框:框里的原文、还在打的拼音与它的候选、底下一行状态。 + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct PanelTranslate { + /// 框里已经打好的原文。 + pub source: String, + + /// 光标在原文里的位置(第几个字之后);`composing` 画在这里。 + pub cursor: usize, + + /// 框里没字时显示的提示。 + pub placeholder: String, + + /// 还在打的拼音(组句中的原样),带下划线画在光标处;没在打时为空。 + pub composing: String, + + /// 拼音当前一页的候选。 + pub candidates: Vec, + + /// 高亮候选在 `candidates` 里的下标。 + pub highlight: usize, + + /// 底下一行:目标语言与翻译进度(「译成英语 · 翻译中…」)。 + pub status: String, +} diff --git a/crates/qingjian-render/src/renderer/hit/panel.rs b/crates/qingjian-render/src/renderer/hit/panel.rs index 65e1d1b8..8c3ee57d 100644 --- a/crates/qingjian-render/src/renderer/hit/panel.rs +++ b/crates/qingjian-render/src/renderer/hit/panel.rs @@ -11,6 +11,12 @@ pub enum PanelHit { /// 视口里第 `n` 格(行优先,空格子不算点中)。 Cell(usize), + /// 翻译框的原文里点中的位置:光标该放到第 `n` 个字之后。 + Caret(usize), + + /// 翻译框里第 `n` 个候选。 + Candidate(usize), + /// 右上角的关闭按钮。 Close, } diff --git a/crates/qingjian-render/src/renderer/panel/mod.rs b/crates/qingjian-render/src/renderer/panel/mod.rs index a51e6952..57a61b33 100644 --- a/crates/qingjian-render/src/renderer/panel/mod.rs +++ b/crates/qingjian-render/src/renderer/panel/mod.rs @@ -1,8 +1,9 @@ //! 符号面板:顶上分组胶囊(当前组实心)、按键提示与关闭按钮,下一行分类标签(当前分类加下划线,放不下时两头画「‹」「›」), -//! 中间等宽格子(条目居中、高亮格铺底),底下信息行(高亮条目、它的名字与行号)。 +//! 中间等宽格子(条目居中、高亮格铺底),底下信息行(高亮条目、它的名字与行号);翻译组的格子区换成文本区(见 [`translate`])。 //! 排法固定、尺寸与内容无关(换组换分类窗口不跳),字体、尺寸与颜色取主题的 `panel` 一节;各块的矩形就是点击区域。 mod tabs; +mod translate; use bamboo_slip::taffy::{Dimension, LengthPercentageAuto, NodeId, Position, Rect, Size, Style}; use bamboo_slip::{BoxPaint, Color, FontSpec, RenderError, Scene, TextStyle, Visual}; @@ -109,6 +110,16 @@ impl Area { /// 绝对定位在 `area` 的节点。 fn placed(scene: &mut Scene, area: Area, visual: Visual) -> Result { + placed_with(scene, area, visual, &[]) +} + +/// 绝对定位在 `area`、带子节点的节点。 +fn placed_with( + scene: &mut Scene, + area: Area, + visual: Visual, + children: &[NodeId], +) -> Result { let style = Style { position: Position::Absolute, inset: Rect { @@ -123,7 +134,7 @@ fn placed(scene: &mut Scene, area: Area, visual: Visual) -> Result= FULL_COLUMNS { - &spec.item_font - } else { - &spec.wide_item_font - }; - let item = m.style(theme.font_ref(item_font), &spec.text); - let inset = m.px(CELL_INSET); - let cell_radius = m.px(spec.cell_radius); - for (offset, entry) in frame.cells.iter().enumerate() { - let Some(entry) = entry else { - continue; + let mut text_box = None; + if let Some(translate) = &frame.translate { + let area = Area::new(left, grid_top, right - left, cell * rows as f32); + let styles = translate::Styles { + text: current, + label, + hint, }; - let column = offset % columns; - let row = offset / columns; - if row >= rows { - break; + text_box = Some(translate::draw( + &mut canvas, + &m, + text, + translate, + &styles, + area, + info_top, + )?); + } else { + // 格子:列数少的(颜文字)格子拉宽、用小一号的字,放不下的截尾(信息行里看全文) + let columns = frame.columns.clamp(1, FULL_COLUMNS); + let cell_width = (grid_width - cell_gap * (columns - 1) as f32) / columns as f32; + let item_font = if columns >= FULL_COLUMNS { + &spec.item_font + } else { + &spec.wide_item_font + }; + let item = m.style(theme.font_ref(item_font), &spec.text); + let inset = m.px(CELL_INSET); + let cell_radius = m.px(spec.cell_radius); + for (offset, entry) in frame.cells.iter().enumerate() { + let Some(entry) = entry else { + continue; + }; + let column = offset % columns; + let row = offset / columns; + if row >= rows { + break; + } + let area = Area::new( + left + (cell_width + cell_gap) * column as f32, + grid_top + cell * row as f32, + cell_width, + cell, + ); + if offset == frame.highlight { + canvas.add(area, m.fill(&spec.highlight, cell_radius))?; + } + let size = text.measure(&entry.text, &item); + let width = size.width.min(area.width - inset * 2.0); + let at = ( + area.x + (area.width - width) / 2.0, + area.y + (area.height - size.height) / 2.0, + ); + canvas.text(&entry.text, &item, at, (width + 1.0, size.height))?; + canvas + .hits + .push(area.hit(HitTarget::Panel(PanelHit::Cell(offset)))); } - let area = Area::new( - left + (cell_width + cell_gap) * column as f32, - grid_top + cell * row as f32, - cell_width, - cell, - ); - if offset == frame.highlight { - canvas.add(area, m.fill(&spec.highlight, cell_radius))?; - } - let size = text.measure(&entry.text, &item); - let width = size.width.min(area.width - inset * 2.0); - let at = ( - area.x + (area.width - width) / 2.0, - area.y + (area.height - size.height) / 2.0, - ); - canvas.text(&entry.text, &item, at, (width + 1.0, size.height))?; - canvas - .hits - .push(area.hit(HitTarget::Panel(PanelHit::Cell(offset)))); - } - // 信息行:左边是高亮条目与它的名字,右边是第几行 - let gap = m.px(GAP); - let mut budget = right - left; - if let Some(footer) = frame.footer.as_deref() { - let width = text.measure(footer, &hint).width.min(budget); - let top = info_top + (line - hint_line) / 2.0; - canvas.text( - footer, - &hint, - (right - width, top), - (width + 1.0, hint_line), - )?; - budget -= width + gap; - } - if let Some(Some(entry)) = frame.cells.get(frame.highlight) { - let entry_width = text - .measure(&entry.text, ¤t) - .width - .min(budget.max(0.0)); - let size = (entry_width + 1.0, line); - canvas.text(&entry.text, ¤t, (left, info_top), size)?; - budget -= entry_width + gap; - if let Some(name) = entry.name.as_deref() - && budget > 0.0 - { - let width = text.measure(name, &label).width.min(budget); - let at = (left + entry_width + gap, info_top); - canvas.text(name, &label, at, (width + 1.0, line))?; + // 信息行:左边是高亮条目与它的名字,右边是第几行 + let gap = m.px(GAP); + let mut budget = right - left; + if let Some(footer) = frame.footer.as_deref() { + let width = text.measure(footer, &hint).width.min(budget); + let top = info_top + (line - hint_line) / 2.0; + canvas.text( + footer, + &hint, + (right - width, top), + (width + 1.0, hint_line), + )?; + budget -= width + gap; + } + if let Some(Some(entry)) = frame.cells.get(frame.highlight) { + let entry_width = text + .measure(&entry.text, ¤t) + .width + .min(budget.max(0.0)); + let size = (entry_width + 1.0, line); + canvas.text(&entry.text, ¤t, (left, info_top), size)?; + budget -= entry_width + gap; + if let Some(name) = entry.name.as_deref() + && budget > 0.0 + { + let width = text.measure(name, &label).width.min(budget); + let at = (left + entry_width + gap, info_top); + canvas.text(name, &label, at, (width + 1.0, line))?; + } } } let Canvas { mut scene, nodes, - hits, + mut hits, } = canvas; let root_style = Style { size: Size { @@ -438,6 +468,15 @@ impl Renderer { .collect(); scene.set_effects(root, effects); scene.layout(root, self.stage.text())?; + if let Some(text_box) = &text_box { + if text_box.follow_caret(&mut scene, root)? { + scene.layout(root, self.stage.text())?; + } + // 原文里的点击排在最前(各块不重叠,排前只为保险) + let mut regions = text_box.hits(&scene, root)?; + regions.append(&mut hits); + hits = regions; + } let painted = self.stage.rasterize(&scene, root, content, scale)?; Ok(Rendered::new(painted, hits)) } diff --git a/crates/qingjian-render/src/renderer/panel/tests.rs b/crates/qingjian-render/src/renderer/panel/tests.rs index f825ee0c..1a1d5022 100644 --- a/crates/qingjian-render/src/renderer/panel/tests.rs +++ b/crates/qingjian-render/src/renderer/panel/tests.rs @@ -1,4 +1,4 @@ -use crate::panel::{PanelCell, PanelFrame}; +use crate::panel::{PanelCell, PanelFrame, PanelTranslate}; use crate::renderer::{HitTarget, PanelHit, Renderer}; use crate::theme::Theme; use bamboo_slip::FontLibrary; @@ -22,6 +22,7 @@ fn frame(sections: usize, cells: usize, columns: usize) -> PanelFrame { highlight: 0, footer: Some("1/3".into()), hint: "Tab 换组".into(), + translate: None, } } @@ -81,3 +82,51 @@ fn size_does_not_depend_on_content_and_hits_cover_every_part() { 2 ); } + +#[test] +fn translate_box_keeps_the_size_and_hits_its_candidates() { + let Ok(library) = FontLibrary::system("zh-CN") else { + return; + }; + let mut renderer = Renderer::new(library); + let theme = Theme::light(); + let grid = renderer + .render_panel(&frame(5, 60, 10), &theme, 2.0) + .unwrap(); + let boxed = PanelFrame { + translate: Some(PanelTranslate { + source: "今天天气很好,我们去公园散步吧。".repeat(20), + cursor: 3, + placeholder: String::new(), + composing: "nihao".into(), + candidates: vec!["你好".into(), "拟好".into(), "泥".into()], + highlight: 1, + status: "译成英语 · 翻译中…".into(), + }), + ..frame(5, 0, 10) + }; + let boxed = renderer.render_panel(&boxed, &theme, 2.0).unwrap(); + assert_eq!( + (grid.content_width, grid.content_height), + (boxed.content_width, boxed.content_height) + ); + let candidates: Vec = boxed + .hits + .iter() + .filter_map(|hit| match hit.target { + HitTarget::Panel(PanelHit::Candidate(i)) => Some(i), + _ => None, + }) + .collect(); + assert_eq!(candidates, [0, 1, 2]); + // 原文能点:落到各个字的边界上,光标在前面时还看得见开头 + let carets: Vec = boxed + .hits + .iter() + .filter_map(|hit| match hit.target { + HitTarget::Panel(PanelHit::Caret(i)) => Some(i), + _ => None, + }) + .collect(); + assert!(carets.contains(&0) && carets.contains(&4)); +} diff --git a/crates/qingjian-render/src/renderer/panel/translate.rs b/crates/qingjian-render/src/renderer/panel/translate.rs new file mode 100644 index 00000000..6a705211 --- /dev/null +++ b/crates/qingjian-render/src/renderer/panel/translate.rs @@ -0,0 +1,252 @@ +//! 翻译组的格子区:上面是原文编辑区(bamboo-slip 的 [`TextFlow`] 排:折行、光标、还在打的拼音带下划线嵌在光标处), +//! 放不下时往上滚到能看见光标;下面一行是拼音的候选(高亮候选铺底),信息行写目标语言与翻译进度。 +//! 占的高度与格子区一样,换组窗口不跳。原文里点哪里光标放到哪里([`PanelHit::Caret`]),要布局之后才知道位置,见 [`TextBox`]。 + +use bamboo_slip::taffy::{FlexDirection, LengthPercentageAuto, NodeId, Rect, Style}; +use bamboo_slip::{FlowTarget, RenderError, Scene, TextFlow, TextPainter, TextStyle, Visual}; + +use super::{Area, Canvas, Metrics, placed_with}; +use crate::panel::PanelTranslate; +use crate::renderer::{HitRegion, HitTarget, PanelHit}; + +/// 候选之间、序号与候选之间的间距(点)。 +const CANDIDATE_GAP: f32 = 6.0; + +/// 候选文字两边留白(点)。 +const CANDIDATE_PADDING: f32 = 8.0; + +/// 原文区与候选行之间的间距(点)。 +const SOURCE_GAP: f32 = 6.0; + +/// 光标竖线的宽(点)。 +const CARET_WIDTH: f32 = 1.5; + +/// 画翻译框要用的几种字。 +pub(super) struct Styles { + /// 正文色的标签字:提示行。 + pub(super) text: TextStyle, + + /// 次要色的标签字:候选序号。 + pub(super) label: TextStyle, + + /// 提示色的小字:空框提示。 + pub(super) hint: TextStyle, +} + +/// 原文编辑区布局之后要补的事:滚到光标、按各个字的位置给出点击区域。 +pub(super) struct TextBox { + /// 裁剪用的视口。 + view: NodeId, + + /// 视口里的各行,滚动时整体上移。 + content: NodeId, + + caret: Option, + + targets: Vec, + + /// 原文:点击给的字节下标按它换算成第几个字之后。 + source: String, +} + +pub(super) fn draw( + canvas: &mut Canvas, + m: &Metrics, + text: &mut TextPainter, + frame: &PanelTranslate, + styles: &Styles, + area: Area, + info_top: f32, +) -> Result { + let spec = &m.theme.file().panel; + let item = m.style(m.theme.font_ref(&spec.wide_item_font), &spec.text); + let row_height = m.px(spec.cell); + let row_top = area.y + area.height - row_height; + + // 原文 + let source_area = Area::new( + area.x, + area.y, + area.width, + (row_top - m.px(SOURCE_GAP) - area.y).max(0.0), + ); + let caret = frame + .source + .char_indices() + .nth(frame.cursor) + .map_or(frame.source.len(), |(i, _)| i); + let flow = TextFlow { + text: &frame.source, + preedit: &frame.composing, + caret, + selection: caret..caret, + placeholder: &frame.placeholder, + multiline: true, + focused: true, + secret: false, + style: item, + placeholder_style: styles.hint, + caret_bar: (m.px(CARET_WIDTH), item.line_height, item.color), + selection_paint: Visual::Group, + } + .build(&mut canvas.scene)?; + let content = canvas.scene.node( + Style { + flex_direction: FlexDirection::Column, + flex_shrink: 0.0, + size: bamboo_slip::taffy::Size { + width: bamboo_slip::taffy::Dimension::length(source_area.width), + height: bamboo_slip::taffy::Dimension::auto(), + }, + ..Style::default() + }, + Visual::Group, + &flow.lines, + )?; + let view = placed_with(&mut canvas.scene, source_area, Visual::Group, &[content])?; + canvas.scene.set_clip(view, 0.0); + canvas.nodes.push(view); + + // 候选 + let padding = m.px(CANDIDATE_PADDING); + let gap = m.px(CANDIDATE_GAP); + let radius = m.px(spec.cell_radius); + let label_line = text.measure("国", &styles.label).height; + let index_width = text.measure("9", &styles.label).width; + let mut x = area.x; + for (i, candidate) in frame.candidates.iter().enumerate() { + let word = text.measure(candidate, &item); + let width = padding * 2.0 + index_width + gap / 2.0 + word.width; + if x + width > area.x + area.width { + break; + } + let cell = Area::new(x, row_top, width, row_height); + if i == frame.highlight { + canvas.add(cell, m.fill(&spec.highlight, radius))?; + } + let number = (i + 1).to_string(); + let number_top = row_top + (row_height - label_line) / 2.0; + canvas.text( + &number, + &styles.label, + (x + padding, number_top), + (index_width + 1.0, label_line), + )?; + let at = ( + x + padding + index_width + gap / 2.0, + row_top + (row_height - word.height) / 2.0, + ); + canvas.text(candidate, &item, at, (word.width + 1.0, word.height))?; + canvas + .hits + .push(cell.hit(HitTarget::Panel(PanelHit::Candidate(i)))); + x += width + gap; + } + + // 信息行:目标语言与进度 + let width = text + .measure(&frame.status, &styles.text) + .width + .min(area.width); + canvas.text( + &frame.status, + &styles.text, + (area.x, info_top), + (width + 1.0, label_line), + )?; + Ok(TextBox { + view, + content, + caret: flow.caret, + targets: flow.targets, + source: frame.source.clone(), + }) +} + +impl TextBox { + /// 光标跑到视口下面了:内容上移到正好露出它。返回挪没挪(挪了要再布局一次)。 + pub(super) fn follow_caret( + &self, + scene: &mut Scene, + root: NodeId, + ) -> Result { + let Some(caret) = self.caret else { + return Ok(false); + }; + let (_, caret_top, _, caret_height) = scene.rect_in(caret, root)?; + let (_, view_top, _, view_height) = scene.rect_in(self.view, root)?; + let lift = (caret_top + caret_height - (view_top + view_height)).max(0.0); + if lift <= 0.0 { + return Ok(false); + } + scene.restyle(self.content, |style| { + style.margin = Rect { + top: LengthPercentageAuto::length(-lift), + ..Rect::zero() + }; + })?; + Ok(true) + } + + /// 原文里的点击区域:每个字左右两半各落到它前后的边界上,行尾后面的空白落到行尾,最后一行下面落到末尾; + /// 只给视口里看得见的部分。 + pub(super) fn hits(&self, scene: &Scene, root: NodeId) -> Result, RenderError> { + let view = scene.rect_in(self.view, root)?; + let clip = |(x, y, width, height): (f32, f32, f32, f32)| { + let top = y.max(view.1); + let bottom = (y + height).min(view.1 + view.3); + (bottom > top).then_some((x, top, width, bottom - top)) + }; + let caret_at = |byte: usize| { + let position = self.source[..byte.min(self.source.len())].chars().count(); + HitTarget::Panel(PanelHit::Caret(position)) + }; + let mut regions = Vec::new(); + let mut line_ends = Vec::new(); + for target in &self.targets { + match target { + FlowTarget::Token { node, bounds } => { + let Some((x, y, width, height)) = clip(scene.rect_in(*node, root)?) else { + continue; + }; + let slices = bounds.len().saturating_sub(1).max(1) as f32; + let step = width / slices; + for (j, bound) in bounds.iter().enumerate() { + let left = (x + step * (j as f32 - 0.5)).max(x); + let right = (x + step * (j as f32 + 0.5)).min(x + width); + if right > left { + regions.push(HitRegion { + x: left, + y, + width: right - left, + height, + target: caret_at(*bound), + }); + } + } + } + FlowTarget::LineEnd { node, end, .. } => { + if let Some((x, y, width, height)) = clip(scene.rect_in(*node, root)?) { + line_ends.push(HitRegion { + x, + y, + width, + height, + target: caret_at(*end), + }); + } + } + FlowTarget::Caret(_) => {} + } + } + regions.append(&mut line_ends); + regions.push(HitRegion { + x: view.0, + y: view.1, + width: view.2, + height: view.3, + target: caret_at(self.source.len()), + }); + Ok(regions) + } +} diff --git a/crates/qingjian-render/themes/miku/theme.json b/crates/qingjian-render/themes/miku/theme.json index 17983d25..19672a7f 100644 --- a/crates/qingjian-render/themes/miku/theme.json +++ b/crates/qingjian-render/themes/miku/theme.json @@ -242,7 +242,7 @@ "padding": 14, "radius": 0, "cell_radius": 8, "background": { "image": "images/panel-vertical.svg", "slice": [20, 50, 16, 14] }, "text": "@ink", "label": "@gloss", "hint": "@faint", - "pill_fill": { "linear": 100, "stops": ["@cyan", "@pink"] }, "pill_text": "#ffffff", + "pill_fill": "@cyan", "pill_text": "#ffffff", "underline_color": "@pink", "divider": "@divider", "highlight": { "linear": 100, "stops": ["#92e3e9", "#a9ebef"] }, "close_fill": "@accent", "close_icon": "@icon", diff --git a/docs/notes/crate-notes.md b/docs/notes/crate-notes.md index ea819798..b8866ace 100644 --- a/docs/notes/crate-notes.md +++ b/docs/notes/crate-notes.md @@ -58,9 +58,13 @@ macOS 右键候选菜单可能按“deactivate → 查询菜单项 → 点击” Windows 的三态模式经 `ModeChanged` / `ModeSync` 同步 `english` 与可选 `vietnamese`:新 Server 给悬浮状态条显示「越」,新 DLL 给 `GUID_LBI_INPUTMODE` 任务栏按钮显示「越」和独立 V 图标;旧 Server 不带 `vietnamese` 时 DLL 保留本地第三输入法状态,旧 DLL 会忽略新字段。按应用记忆模式时 Server 同时保存中英与越南语状态。 符号面板在 `panel/`:`SymbolPanel` 管分组与分类切换,`Engine::open_symbol_panel` 拿当前前 12 个候选词查 `keywords.tsv`、emoji 表与内置符号 / 颜文字表当搜索结果; 条目是 `Emoji` / `Kaomoji` / `Symbol` 种类、`reading` 为中文名的候选,上屏走 `commit`(没有音节,吃掉整段)并关掉面板。格子移动复用 `Grid`。 -macOS 面板一律系统绘制(青简渲染器还没有面板),`candidates/view/panel.rs`;开着时云端词与模型重排不往格子里写。 +两端面板都由 qingjian-render 的 `render_panel` 画(壳各持一个渲染器,候选窗选「系统绘制」时也照样画);开着时云端词与模型重排不往格子里写。 +「翻译」组排在最前,每种目标语言算一个分类(`[` `]` 换语言),原文、光标与进度在 `panel/translate/` 的 `TranslateDraft`:停键 800 ms、两个字以上才发, +原文 + 实际目标语言没变不重发,没翻出来的 2 秒后自动重试一次、再失败等回车;框的排版用 bamboo-slip 的 `TextFlow`(与 `TextField` 共用),点击按布局后各字的位置给出 `PanelHit::Caret`;请求走 `submit_panel_translation`(与翻译选中文字同一个提供方),结果在 `poll_prediction` 里截下不交给壳, +进 `TranslationCache`(内存,300 条,换提供方清空)。壳每拍调 `tick_panel_translation`,`panel_preedit` 是要放进应用 preedit 的译文; +`commit_to_panel` 选词时照常记学习、清掉最近上屏(框里的字不在应用里)。打拼音时打开落在翻译组,没在打字时回到上次关掉时那组,语言也记着。 一行几格由 `PanelSection::columns` 按最宽条目定(`PANEL_COLUMNS` = 10,颜文字 6 或 4)。Windows 的面板状态在 Server 的 `dispatch/panel/`(格子 `PanelGrid`,按键 `dispatch/key/panel.rs`), -窗口在 `ui/panel/`,用 Direct2D DC 渲染目标画在分层窗口合成器的 DIB 上(GDI 画不出彩色 emoji);渲染目标是软件的,硬件类型要起 D3D 设备,显卡驱动初始化就常驻二三十 MB。没在组句时从菜单打开:协议 `Frame::symbol_panel` 让空缓冲也不算空帧, +窗口在 `ui/panel/`,渲染器出的位图贴分层窗口。翻译组的译文靠轮询送到:DLL 记着写进文档的组句文字,轮询帧带 `symbol_panel` 且文字变了就补一次编辑会话;翻译组快捷键在 DLL 登记成保留键(读配置)。没在组句时从菜单打开:协议 `Frame::symbol_panel` 让空缓冲也不算空帧, `IndicatorState::symbol_panel` 随 `ModeSync` 让 DLL 进组句态吃键;老 DLL(协议 < 9)收到的颜文字 / 符号候选降级成 `Emoji`。 双拼解码由 `Engine::decode` 传入当前模糊音规则:先取普通合法音节,失败时才保留能由模糊音补成合法音节的声韵组合(如 `suang` → `shuang`)。查询沿用带代价的模糊音展开,上屏沿用原读音与按键的消耗换算;不修改无模糊音时的 `Scheme::decode` 行为。 `Engine::decode_readings` 在开了 `[general] shuangpin_full_pinyin` 时给出一段键的多条读法(`shuangpin::Layout::decode_mixed`):纯双拼、整段全拼、以及按音节边界交替的读法, diff --git a/docs/plan/todo.md b/docs/plan/todo.md index d65babe0..7eae1576 100644 --- a/docs/plan/todo.md +++ b/docs/plan/todo.md @@ -128,6 +128,8 @@ (防回环),顺带刷指示器 + 上报 Server 让悬浮状态条也同步。纯 DLL 改动、无新协议。 - [~] **符号面板**:代码完成,待真机测(2026-10-13)。2026-11-04 起两端都由 qingjian-render 画(主题 `panel` 一节),待真机验; 按键与鼠标同 macOS,入口在悬浮状态条「符」格与托盘中 / 英图标的右键菜单。 + 2026-11-04 加「翻译」组(两端,Core `panel/translate`):框里打一句、停键后译文进 preedit、回车上屏,`[shortcut] translate_panel` 快捷键, + 同时删掉 Windows 的整句候选翻译(`[predict] sentence_translation` 不再认);待真机验,Windows 上还要看轮询补写 preedit 在各应用里稳不稳。 - [ ] 发版:换 **Certum 开源代码签名证书**重签(开发全程自签 + 本机受信任根,见 `installer/sign-local.ps1`)、 `windows-v<版本>` 标签与 CI。 - [ ] **本地整句模型上 Windows**:Server 已接(`dispatch/rescore/`,CPU 推理,设置「云服务」页有开关,安装包带 `data\model`),待真机验:每次重排的耗时(前文 + 几条路径一次前向,CPU 上可能几十到一百多毫秒,超了就缩前文长度)、模型加载时间; diff --git a/docs/user/advanced/custom-themes.md b/docs/user/advanced/custom-themes.md index 344f8e1b..993801ce 100644 --- a/docs/user/advanced/custom-themes.md +++ b/docs/user/advanced/custom-themes.md @@ -249,7 +249,7 @@ Windows 屏幕右下角的悬浮状态条也可以按主题画,写法与候选 "panel": { "background": { "image": "images/panel-bg.svg", "slice": [24, 24, 24, 24] }, "highlight": "@accent", - "pill_fill": { "linear": 90, "stops": ["@cyan", "@pink"] }, + "pill_fill": "@cyan", "pill_text": "#ffffff", "radius": 14 } diff --git a/docs/user/cloud/index.md b/docs/user/cloud/index.md index dea3d5ca..dbffae30 100644 --- a/docs/user/cloud/index.md +++ b/docs/user/cloud/index.md @@ -16,7 +16,7 @@ description: 可选、缺省关闭的 AI 服务:能做什么、怎么开启与 | [翻译选中的文字](question-and-translate.md#翻译选中的文字) | 把应用里选中的文字译成学习语言或译回中文 | macOS、Windows、Linux | | [自动补充译词](sentence-translation.md#自动补充译词) | 释义表里没有译词的词,补一条译词 | macOS、Windows | | [生词本整句翻译](sentence-translation.md#生词本整句翻译) | 把打完的整句翻译后记进生词本 | macOS、Windows | -| [整句候选翻译](sentence-translation.md#整句候选翻译) | 在整句候选右侧显示一条译文 | Windows | +| [符号面板里翻译](question-and-translate.md#在符号面板里翻译) | 在框里打一句,停一下译文出现在光标处 | macOS、Windows | Android 的 AI 功能见 [Android:工具与 AI](../platforms/android-tools.md)。 @@ -41,7 +41,7 @@ Linux 上也可以直接编辑配置文件开启,见 [直接编辑配置文件 - **翻译选中的文字**:选中的文字(最多 500 字),只在按了翻译快捷键时读取并发送。 - **自动补充译词**:缺译词的那一个词本身。 - **生词本整句翻译**:上屏的整句(两个字以上)或分几次打完的一句话(四个汉字以上),攒几句一起发送;同一句只发一次。 -- **整句候选翻译**:当前一条整句候选与目标语言;不发送拼音、应用里的文字或其他候选。 +- **符号面板里翻译**:框里打好的那句话与目标语言,停键后才发;不发送拼音与应用里的文字。 密码框与应用标为私密的输入框中一律不发送。Linux 的 Fcitx4 只能认出密码框,认不出其他私密输入框,使用时请留意。 关闭 AI 服务后不再发送任何请求。 diff --git a/docs/user/cloud/question-and-translate.md b/docs/user/cloud/question-and-translate.md index 94b1699e..65a7680d 100644 --- a/docs/user/cloud/question-and-translate.md +++ b/docs/user/cloud/question-and-translate.md @@ -1,7 +1,7 @@ --- title: 问字与翻译 order: 2 -description: 用拼音提问「三个木是什么字」,以及把应用中选中的文字译为学习语言或译回中文。均需开启 AI 服务。 +description: 用拼音提问「三个木是什么字」,把应用中选中的文字译为学习语言或译回中文,以及在符号面板的翻译组里边打边译。均需开启 AI 服务。 --- 需先开启 AI 服务,见 [AI 服务](index.md)。 @@ -43,3 +43,18 @@ Linux 上的限制: - 从较早版本升级上来、配置文件里写着旧翻译快捷键的,安装更新不会改掉它,需自己改成 `Alt + Shift + T`,见 [直接编辑配置文件](../advanced/config-file.md#AI-服务)。 Android 在系统的文字选择菜单里点「青简翻译」,见 [Android:工具与 AI](../platforms/android-tools.md#翻译选中的文字)。 + +## 在符号面板里翻译 + +macOS 与 Windows 的[符号面板](../input/punctuation.md#符号面板)有一组「翻译」:先在框里打一句中文,停一下,译文就出现在应用的光标处,按 `Enter` 上屏。 + +- **打开**:打拼音时按 `↑` 打开面板,直接停在翻译组,刚敲的拼音带进框里接着打。没在打字时按 `⌃⌥Y`(Windows:`Ctrl + Alt + Y`);从菜单或悬浮条打开面板时回到上次用的那组。 +- **框里打字**:照常打拼音,拼音显示在框里的光标处,`Space`、数字键或点框里的候选把词选进光标处,字不进应用;标点照常转全角。英文模式下敲的字直接进框。 +- **改框里的字**:和记事本一样,鼠标点哪里光标就到哪里;没在打拼音时 `←` `→`、`Home`、`End` 挪光标,`Backspace` 删光标前的字,`Delete` 删光标后的字。字多了框会往上滚,光标所在那行总能看见。 +- **译成什么**:`[` `]` 换目标语言(英语、日语、西班牙语、越南语、德语),缺省是学习语言,换过的记住。框里打的是外文时译回中文,判断方法同上一节。 +- **什么时候发送**:停键约 0.8 秒、框里有两个字以上才发送,内容没变不重发。删了又打回去、换个语言再换回来,用本机记着的译文,不重复发送;这些记录只在内存里,关掉输入法就没了。 +- **上屏与放弃**:译文对上框里的内容后按 `Enter` 上屏;还没到时按 `Enter` 会马上发送,到了再按一次。`Esc` 清掉框里的字与应用里的译文;框里没字时 `Esc` 回到候选。 +- **没翻译出来**(多半是网络不稳):过 2 秒自动再发一次,还不行底下一行显示「按回车重试」,按 `Enter` 重新发送。 + +快捷键可在「设置 → 快捷键」修改;Windows 上改完要切到其他输入法再切回青简(或重开应用)才生效。AI 服务关着、在密码框或私密输入框里时,面板里没有翻译组。 +有的应用(部分网页编辑器、终端)显示光标处的未上屏文字不好,译文长时可能显得乱,可在框底下一行看进度。 diff --git a/docs/user/cloud/sentence-translation.md b/docs/user/cloud/sentence-translation.md index 8201b766..f995787d 100644 --- a/docs/user/cloud/sentence-translation.md +++ b/docs/user/cloud/sentence-translation.md @@ -1,10 +1,10 @@ --- title: AI 补译 order: 3 -description: 用 AI 补全译词与整句译文:自动补充缺失的译词、把整句翻译后记进生词本、在整句候选旁显示译文(Windows)。 +description: 用 AI 补全译词与整句译文:自动补充缺失的译词、把整句翻译后记进生词本。 --- -随包的释义表只有词的译词。开启 AI 服务后,下面三项用它补上缺的部分,见 [AI 服务](index.md)。 +随包的释义表只有词的译词。开启 AI 服务后,下面两项用它补上缺的部分,见 [AI 服务](index.md)。想把自己打的一句话翻译出来,用符号面板的[翻译组](question-and-translate.md#在符号面板里翻译)。 ## 自动补充译词 @@ -18,11 +18,3 @@ macOS 与 Windows 上,释义表里没有译词的词,上屏后单独询问 - 计入的是一次上屏的整句(两个字以上),以及分几次上屏、写完时有四个汉字以上的一句话。 - 同一句只发一次。不想发送整句的话关掉这一项,云联想等其他功能照常。Linux 暂不提供。 - -## 整句候选翻译 - -目前仅 Windows 提供。「设置 → 云服务」打开「整句候选翻译」后,本地整句候选(一串拼音转成的一句中文)右侧显示一条译文。它不改变候选排序,也不产生 AI 候选词或整句补全。 - -- 可单击译文上屏,也可按 `Ctrl + 数字` 上屏对应候选的译文。 -- 每次只发送当前这一条整句候选与目标语言,不发送拼音、应用里的文字或其他候选;密码框和私密输入框中不发送。 -- 输入继续变化时,旧请求的结果丢弃;上屏、删空拼音或按 `Esc` 时,还没发出的请求撤销。 diff --git a/docs/user/getting-started/keys.md b/docs/user/getting-started/keys.md index 7f833fc7..f087afa9 100644 --- a/docs/user/getting-started/keys.md +++ b/docs/user/getting-started/keys.md @@ -59,7 +59,7 @@ Windows 上小键盘数字缺省也是选候选;在「设置 → 候选」把 | 操作 | 按键 | |---|---| | 移动 | `↑` `↓` `←` `→` | -| 换「搜索 / 符号 / 表情」组 | `Tab` / `⇧ + Tab` | +| 换「翻译 / 搜索 / 符号 / 表情」组 | `Tab` / `⇧ + Tab` | | 换分类 | `[` `]` | | 翻屏 | `PageUp` / `PageDown` | | 上屏 | `Space`、`Enter`、数字键或双击 | @@ -67,6 +67,10 @@ Windows 上小键盘数字缺省也是选候选;在「设置 → 候选」把 敲字母则收起面板接着打字。详见 [标点、符号与表情](../input/punctuation.md#符号面板)。 +翻译组里按键不同:字母照常打拼音,`Space` / 数字键把词选进框里,`[` `]` 换目标语言,`Enter` 上屏译文,`Esc` 清掉框里的字(框里没字时回到候选),`Backspace` 先删拼音再删光标前的字。 +打拼音时 `←` `→` 挪候选高亮;没在打拼音时 `←` `→`、`Home`、`End` 挪框里的光标,`Delete` 删光标后的字,鼠标点框里的字也能放光标。 +详见 [在符号面板里翻译](../cloud/question-and-translate.md#在符号面板里翻译)。 + ## 英文模式下 | 操作 | macOS / Windows / Linux | @@ -96,6 +100,7 @@ Windows 上小键盘数字缺省也是选候选;在「设置 → 候选」把 | 翻译结果:替换选区 | `Enter` / `Space` / `1` | 同左 | 同左 | | 翻译结果:保留原文 | `Esc` | `Esc` | `Esc` | | 打拼音时手动要 AI 候选 | `⌃⌥J` | `Ctrl + Alt + J` | `Ctrl + Alt + J` | +| 没在打字时打开符号面板的翻译组 | `⌃⌥Y` | `Ctrl + Alt + Y` | — | | 问字 | `u` 再输入拼音 | 同左 | 同左 | ## 前缀 diff --git a/docs/user/i18n/en/cloud/index.md b/docs/user/i18n/en/cloud/index.md index 26499457..71f7e06a 100644 --- a/docs/user/i18n/en/cloud/index.md +++ b/docs/user/i18n/en/cloud/index.md @@ -16,7 +16,7 @@ Data goes straight from your device to the provider you entered and never passes | [Translating selected text](question-and-translate.md#Translating-selected-text) | Translates text selected in an app into your learning language or back into Chinese | macOS, Windows, Linux | | [Filling in glosses automatically](sentence-translation.md#Filling-in-glosses-automatically) | Adds a gloss for words that have none in the glossary | macOS, Windows | | [Sentence translation for the vocabulary book](sentence-translation.md#Sentence-translation-for-the-vocabulary-book) | Translates finished sentences and records them in the vocabulary book | macOS, Windows | -| [Translating sentence candidates](sentence-translation.md#Translating-sentence-candidates) | Shows a translation to the right of the sentence candidate | Windows | +| [Translating in the symbol panel](question-and-translate.md#Translating-in-the-symbol-panel) | Type a sentence in a box; after a pause the translation appears at the cursor | macOS, Windows | For AI features on Android, see [Android: tools and AI](../platforms/android-tools.md). @@ -41,7 +41,7 @@ On Linux you can also turn them on by editing the configuration file directly; s - **Translating selected text**: the selected text (up to 500 characters), read and sent only when you press the translation shortcut. - **Filling in glosses automatically**: the one word missing a gloss. - **Sentence translation for the vocabulary book**: committed sentences (two characters or more) or sentences finished in several parts (four or more Chinese characters), sent a few at a time; each sentence is sent only once. -- **Translating sentence candidates**: the current sentence candidate and the target language; no pinyin, no text from the app and no other candidates. +- **Translating in the symbol panel**: the sentence typed in the box and the target language, sent after you pause; no pinyin and no text from the app. Nothing is ever sent from password fields or input fields an app marks as private. On Linux, Fcitx4 can only recognize password fields, not other private input fields, so take care when using it. Once AI services are turned off, no more requests are sent. diff --git a/docs/user/i18n/en/cloud/question-and-translate.md b/docs/user/i18n/en/cloud/question-and-translate.md index 4b459bc3..970ff1e1 100644 --- a/docs/user/i18n/en/cloud/question-and-translate.md +++ b/docs/user/i18n/en/cloud/question-and-translate.md @@ -1,7 +1,7 @@ --- title: Asking and translating order: 2 -description: "Asking a question in pinyin, such as which character is made of three 木, and translating text selected in an app into your learning language or back into Chinese. Both require AI services." +description: "Asking a question in pinyin, such as which character is made of three 木, translating text selected in an app into your learning language or back into Chinese, and translating as you type in the Translate group of the symbol panel. All require AI services." --- AI services must be turned on first; see [AI services](index.md). @@ -43,3 +43,18 @@ Limitations on Linux: - If you upgraded from an earlier version and your configuration file still has the old translation shortcut, installing the update doesn't change it; change it to `Alt + Shift + T` yourself, see [Editing the configuration file](../advanced/config-file.md#AI-services). On Android, tap 青简翻译 (Qingjian Translate) in the system's text selection menu; see [Android: tools and AI](../platforms/android-tools.md#Translating-selected-text). + +## Translating in the symbol panel + +On macOS and Windows the [symbol panel](../input/punctuation.md#Symbol-panel) has a 翻译 (Translate) group: type a sentence of Chinese in its box, pause, and the translation appears at the cursor in the app; press `Enter` to commit it. + +- **Opening**: while typing pinyin, press `↑` to open the panel; it lands on the Translate group and carries the pinyin into the box. When not typing, press `⌃⌥Y` (Windows: `Ctrl + Alt + Y`). Opening the panel from the menu or the status bar returns to the group you used last. +- **Typing in the box**: type pinyin as usual; it shows at the cursor in the box, and `Space`, a number key or clicking a candidate in the box puts the word at the cursor, not into the app. Punctuation is converted to full width as usual. In English mode, what you type goes straight into the box. +- **Editing the box**: like Notepad, clicking places the cursor; when not typing pinyin, `←` `→`, `Home` and `End` move it, `Backspace` deletes the character before it and `Delete` the one after. When the text grows, the box scrolls so the cursor line stays visible. +- **Target language**: `[` `]` switch the target language (English, Japanese, Spanish, Vietnamese, German). It starts as your learning language and the last one you chose is remembered. If the box holds foreign text, it is translated back into Chinese, decided the same way as in the previous section. +- **When it's sent**: only after you pause for about 0.8 seconds with two or more characters in the box, and not again if nothing changed. Deleting and retyping, or switching to another language and back, reuses translations remembered on your device instead of sending again; they're kept in memory only and are gone when the input method quits. +- **Committing and discarding**: once the translation matches the box, `Enter` commits it; pressing `Enter` before it arrives sends right away, and you press it again when it's there. `Esc` clears the box and the translation in the app; with an empty box, `Esc` goes back to the candidates. +- **When no translation comes back** (usually a network hiccup): it's sent again automatically after 2 seconds; if that fails too, the bottom line says to press Enter to retry, and `Enter` sends it again. + +The shortcut can be changed under 设置 → 快捷键 (Settings → Shortcuts); on Windows, switch to another input method and back to Qingjian (or reopen the app) for the change to take effect. With AI services off, or in a password or private input field, the panel has no Translate group. +Some apps (certain web editors, terminals) don't display uncommitted text at the cursor well, and a long translation may look messy; the line at the bottom of the box shows the progress. diff --git a/docs/user/i18n/en/cloud/sentence-translation.md b/docs/user/i18n/en/cloud/sentence-translation.md index 58500239..19789e4f 100644 --- a/docs/user/i18n/en/cloud/sentence-translation.md +++ b/docs/user/i18n/en/cloud/sentence-translation.md @@ -1,10 +1,10 @@ --- title: AI translation order: 3 -description: "Using AI to fill in glosses and sentence translations: filling in missing glosses automatically, translating sentences into the vocabulary book, and showing a translation beside sentence candidates (Windows)." +description: "Using AI to fill in glosses and sentence translations: filling in missing glosses automatically and translating sentences into the vocabulary book." --- -The bundled glossary only has glosses for words. With AI services on, the three options below fill in what's missing; see [AI services](index.md). +The bundled glossary only has glosses for words. With AI services on, the two options below fill in what's missing; see [AI services](index.md). To translate a sentence you type yourself, use the [Translate group](question-and-translate.md#Translating-in-the-symbol-panel) of the symbol panel. ## Filling in glosses automatically @@ -18,11 +18,3 @@ finished sentences are collected and sent a few at a time for translation; the t - What counts is a sentence committed in one go (two characters or more), and a sentence committed in several parts that has four or more Chinese characters when finished. - Each sentence is sent only once. If you don't want sentences sent, turn this option off; cloud suggestions and the other features keep working. Not yet available on Linux. - -## Translating sentence candidates - -Currently Windows only. With 整句候选翻译 (Translate sentence candidates) on under 设置 → 云服务 (Settings → Cloud services), a translation appears to the right of the local sentence candidate (a sentence of Chinese converted from a string of pinyin). It doesn't change the candidate order and doesn't produce AI candidate words or sentence completions. - -- Click the translation to commit it, or press `Ctrl + number` to commit the translation of that candidate. -- Only the current sentence candidate and the target language are sent each time; no pinyin, no text from the app and no other candidates. Nothing is sent from password fields or private input fields. -- When the input keeps changing, results of old requests are discarded; committing, deleting all the pinyin or pressing `Esc` cancels requests not yet sent. diff --git a/docs/user/i18n/en/getting-started/keys.md b/docs/user/i18n/en/getting-started/keys.md index 6a6eccf7..aaf93214 100644 --- a/docs/user/i18n/en/getting-started/keys.md +++ b/docs/user/i18n/en/getting-started/keys.md @@ -56,7 +56,7 @@ A few keys differ with Dachen Zhuyin and Wubi; see [Pinyin schemes and fuzzy pin | Action | Keys | |---|---| | Move | `↑` `↓` `←` `→` | -| Switch between the Search / Symbols / Emoji groups | `Tab` / `⇧ + Tab` | +| Switch between the Translate / Search / Symbols / Emoji groups | `Tab` / `⇧ + Tab` | | Switch category | `[` `]` | | Scroll a screen | `PageUp` / `PageDown` | | Commit | `Space`, `Enter`, a number key or double-click | @@ -64,6 +64,10 @@ A few keys differ with Dachen Zhuyin and Wubi; see [Pinyin schemes and fuzzy pin Typing a letter closes the panel and continues typing. Details in [Punctuation, symbols and emoji](../input/punctuation.md#Symbol-panel). +The Translate group works differently: letters type pinyin as usual, `Space` / number keys put words into the box, `[` `]` switch the target language, `Enter` commits the translation, `Esc` clears the box (with an empty box it goes back to the candidates), and `Backspace` deletes pinyin first, then the character before the cursor. +While typing pinyin, `←` `→` move the candidate highlight; otherwise `←` `→`, `Home` and `End` move the cursor in the box, `Delete` removes the character after it, and clicking in the box places the cursor. +Details in [Translating in the symbol panel](../cloud/question-and-translate.md#Translating-in-the-symbol-panel). + ## In English mode | Action | macOS / Windows / Linux | @@ -93,6 +97,7 @@ All of these require AI services to be turned on first; see [AI services](../clo | Translation result: replace the selection | `Enter` / `Space` / `1` | Same | Same | | Translation result: keep the original | `Esc` | `Esc` | `Esc` | | Ask for AI candidates manually while typing pinyin | `⌃⌥J` | `Ctrl + Alt + J` | `Ctrl + Alt + J` | +| Open the Translate group of the symbol panel when not typing | `⌃⌥Y` | `Ctrl + Alt + Y` | — | | Ask about a character | `u`, then type pinyin | Same | Same | ## Prefixes diff --git a/docs/user/i18n/en/input/punctuation.md b/docs/user/i18n/en/input/punctuation.md index 219d7104..78f40e88 100644 --- a/docs/user/i18n/en/input/punctuation.md +++ b/docs/user/i18n/en/input/punctuation.md @@ -33,14 +33,15 @@ Either way: ## Symbol panel macOS and Windows have a symbol panel. Press `↑` after typing (while the highlight is still on the first candidate and hasn't moved) to open it. When you're not typing: on macOS, choose 符号面板… (Symbol panel…) in the input method menu in the menu bar; -on Windows, click the 符 cell on the floating status bar, or right-click the 中 / 英 icon on the taskbar and choose 符号面板…. The panel appears next to the mouse (without the Search group in this case). It has three groups: +on Windows, click the 符 cell on the floating status bar, or right-click the 中 / 英 icon on the taskbar and choose 符号面板…. The panel appears next to the mouse (without the Search group in this case). It has these groups: +- **Translate**: present when AI services are on. Pressing `↑` while typing lands on this group: type a sentence in the box and the translation appears at the cursor in the app; see [Translating in the symbol panel](../cloud/question-and-translate.md#Translating-in-the-symbol-panel). Press `Tab` to get to the Search group. - **Search**: uses the pinyin you just typed as the search term: `ku` gives 😢 😭 (T_T), `jiantou` gives every arrow, `alpha` gives α. The group is absent when nothing is found. - **Symbols**: common, Chinese and English punctuation, math, arrows, Greek letters, numbering, currency and units, fractions, superscripts and subscripts, decorative, geometric and box drawing, Hanyu Pinyin (with tones), Zhuyin, hiragana, katakana, IPA, Latin letters, vertical punctuation, full-width letters, mathematical letters. - **Emoji**: common emoji, kaomoji, and all emoji grouped by category (smileys, people, animals and nature, food, travel and places, activities, objects, symbols, flags). `Tab` switches group, `[` `]` switch category, arrow keys move, the mouse wheel or trackpad scrolls a row at a time, and `Space` / `Enter` or a double-click commits; the pinyin you just typed is cleared after committing. `Esc` or the ✕ at the top right returns to the candidates. -The bottom row shows the Chinese name of the highlighted item (😂 笑哭了, € 欧元符号). The panel currently uses the system style and doesn't follow the theme. Emoji that are too new can't be displayed on older systems, so the panel only goes up to Emoji 15.1. +The bottom row shows the Chinese name of the highlighted item (😂 笑哭了, € 欧元符号). The panel follows the current theme. Emoji that are too new can't be displayed on older systems, so the panel only goes up to Emoji 15.1. ## Emoji, kaomoji and symbols after candidates diff --git a/docs/user/i18n/en/platforms/differences.md b/docs/user/i18n/en/platforms/differences.md index 0a1b61c3..44a8ae34 100644 --- a/docs/user/i18n/en/platforms/differences.md +++ b/docs/user/i18n/en/platforms/differences.md @@ -74,7 +74,7 @@ The macOS and Windows versions are in beta with nearly the same features; keys f | Quick translation bar | — | — | — | ✓ | | Filling in glosses automatically | ✓ | ✓ | ✓ | ✓ | | Sentence translation for the vocabulary book | ✓ | ✓ | ✓ | — | -| Translating sentence candidates | — | ✓ | — | — | +| Translating in the symbol panel | ✓ | ✓ | — | — | | AI usage statistics | ✓ | ✓ | ✓ | — | Installation and platform-specific features: [Installation](../getting-started/install.md) (macOS, Windows), [Linux](linux.md), [Android](android.md). diff --git a/docs/user/input/punctuation.md b/docs/user/input/punctuation.md index 7ccf60cb..ee75dc57 100644 --- a/docs/user/input/punctuation.md +++ b/docs/user/input/punctuation.md @@ -33,14 +33,15 @@ description: 中文标点与全角半角的切换、打拼音时敲标点的两 ## 符号面板 macOS 与 Windows 有符号面板。打字后按 `↑`(高亮还在第一个、没动过时)打开。没在打字时:macOS 点菜单栏输入法菜单里的「符号面板…」, -Windows 点悬浮状态条上的「符」格,或右键任务栏的「中 / 英」图标选「符号面板…」,面板出现在鼠标旁(这时没有搜索组)。面板分三组: +Windows 点悬浮状态条上的「符」格,或右键任务栏的「中 / 英」图标选「符号面板…」,面板出现在鼠标旁(这时没有搜索组)。面板分几组: +- **翻译**:开着 AI 服务时有。打字时按 `↑` 停在这一组,在框里打一句,译文出现在应用的光标处,见 [在符号面板里翻译](../cloud/question-and-translate.md#在符号面板里翻译)。想搜符号就按 `Tab` 换到搜索组。 - **搜索**:把刚敲的拼音当搜索词,`ku` 出 😢 😭 (T_T),`jiantou` 出所有箭头,`alpha` 出 α;没搜到时没有这一组。 - **符号**:常用、中英文标点、数学、箭头、希腊字母、序号、货币单位、分数、上下标、装饰、几何制表、汉语拼音(带声调)、注音、平假名、片假名、国际音标、拉丁字母、竖排标点、全角字母、数学字母。 - **表情**:常用 emoji、颜文字,以及按类别分组的全部 emoji(笑脸、人物、动物自然、食物、旅行地点、活动、物品、标志、旗帜)。 `Tab` 换组、`[` `]` 换分类、方向键移动,鼠标滚轮或触控板逐行滚动,`Space` / `Enter` 或双击上屏,上屏后刚敲的拼音一并清掉;`Esc` 或右上角 ✕ 回到候选。 -底下一行显示高亮条目的中文名(😂 笑哭了、€ 欧元符号)。面板目前按系统样式显示,不随主题变。太新的 emoji 旧系统显示不出来,面板里只收到 Emoji 15.1。 +底下一行显示高亮条目的中文名(😂 笑哭了、€ 欧元符号)。面板跟随当前主题。太新的 emoji 旧系统显示不出来,面板里只收到 Emoji 15.1。 ## 候选后面的 emoji、颜文字与符号 diff --git a/docs/user/platforms/differences.md b/docs/user/platforms/differences.md index 853f4497..617cd227 100644 --- a/docs/user/platforms/differences.md +++ b/docs/user/platforms/differences.md @@ -75,7 +75,7 @@ macOS 与 Windows 版是测试版,功能基本相同,按键按各自系统 | 快捷翻译条 | — | — | — | ✓ | | 自动补充译词 | ✓ | ✓ | ✓ | ✓ | | 生词本整句翻译 | ✓ | ✓ | ✓ | — | -| 整句候选翻译 | — | ✓ | — | — | +| 符号面板里翻译 | ✓ | ✓ | — | — | | AI 用量统计 | ✓ | ✓ | ✓ | — | 各平台的安装与独有功能:[安装](../getting-started/install.md)(macOS、Windows)、[Linux](linux.md)、[Android](android.md)。