mirror of
https://github.com/qingjian-team/qingjian.git
synced 2026-11-06 00:14:40 +00:00
feat: 符号面板加「翻译」组,删掉 Windows 的整句候选翻译
- 整句候选翻译译的是猜出来的候选,猜错就白发;改成用户在面板的框里自己打一句、停键后再译,整句只发确认过的内容 - 翻译组排在最前,每种目标语言算一个分类([ ] 换),打拼音时按 ↑ 直接落在这组;没在打字时 [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 行,按联想、导航、出帧拆开 - 初音主题当前分组胶囊改纯色
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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<PathBuf>) {
|
||||
if !predict.enabled && !predict.sentence_translation {
|
||||
if !predict.enabled {
|
||||
engine.set_predictor(Box::new(NoPredictor));
|
||||
engine.set_gloss_filler(Box::new(NoGlossFiller));
|
||||
return;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -14,6 +14,12 @@ pub enum PanelMouse {
|
||||
/// 滚轮 / 触控板滚了几行,正数往下。
|
||||
Scroll(isize),
|
||||
|
||||
/// 点翻译框的原文:光标放到第几个字之后。
|
||||
Caret(usize),
|
||||
|
||||
/// 点翻译框里当前一页的第几个候选:选进框里。
|
||||
Candidate(usize),
|
||||
|
||||
/// 点右上角的 ✕。
|
||||
Close,
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<u64>,
|
||||
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 里绝不发;没接联想器时是空操作。
|
||||
|
||||
@@ -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)
|
||||
|
||||
+75
-28
@@ -1,11 +1,14 @@
|
||||
//! 符号面板的按键与点击:组句中高亮还在第一个时按 ↑ 打开(长按连发不算),开着时方向键在格子里移动、
|
||||
//! Tab 换组、`[` `]` 换分类、空格 / 回车 / 双击上屏、Esc / 退格回到候选;敲字母先收起面板再照常组句。
|
||||
//! 翻译组里的按键另走 [`translate`]。
|
||||
|
||||
mod translate;
|
||||
|
||||
use super::*;
|
||||
use crate::candidates::PanelMouse;
|
||||
|
||||
/// 面板里按键归谁:`Some(吃没吃)` 是面板处理了,`None` 是面板已收起、按键照常往下走。
|
||||
type PanelKey = Option<bool>;
|
||||
pub(super) type PanelKey = Option<bool>;
|
||||
|
||||
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),
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 穿过去整个进程就没了,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 = "クラウド予測"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 = "按下修饰键 + 字母"
|
||||
|
||||
@@ -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 = "按下修飾鍵 + 字母"
|
||||
|
||||
@@ -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", "开启"),
|
||||
(
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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 == '.')
|
||||
|
||||
@@ -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")],
|
||||
|
||||
@@ -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<Message>, page: &Page) -> Result<Vec<NodeId>, 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(
|
||||
|
||||
@@ -115,6 +115,15 @@ pub fn shortcuts(ui: &mut Ui<Message>, page: &Page) -> Result<Vec<NodeId>, 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,
|
||||
|
||||
@@ -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<Box<(Vec<PreeditSegment>, 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<PreeditSegment> = segments.iter().map(Into::into).collect();
|
||||
(segments != preedit).then(|| Box::new((segments, cursor)))
|
||||
}
|
||||
@@ -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<SurroundingText> {
|
||||
(!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<usize>) -> 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<Box<(Vec<PreeditSegment>, 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<PreeditSegment> = 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<PreeditSegment>, 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;
|
||||
|
||||
@@ -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<usize>) -> bool {
|
||||
let Some(index) = index else {
|
||||
return false;
|
||||
};
|
||||
self.highlight = index;
|
||||
self.navigated = true;
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -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<SurroundingText> {
|
||||
(!self.surrounding_before.is_empty()).then(|| SurroundingText {
|
||||
before: self.surrounding_before.clone(),
|
||||
after: String::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
//! 一条本地整句候选的异步译文。
|
||||
|
||||
use qingjian_core::Translation;
|
||||
|
||||
pub(crate) struct SentenceTranslation {
|
||||
pub(crate) text: String,
|
||||
|
||||
pub(crate) sequence: u64,
|
||||
|
||||
pub(crate) translation: Option<Translation>,
|
||||
}
|
||||
|
||||
impl SentenceTranslation {
|
||||
pub(crate) fn pending(text: String, sequence: u64) -> Self {
|
||||
Self {
|
||||
text,
|
||||
sequence,
|
||||
translation: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Query>,
|
||||
|
||||
/// 首条本地整句候选的云端译文;未返回时为 `None`。
|
||||
sentence_translation: Option<SentenceTranslation>,
|
||||
|
||||
/// 正在等待的手动请求序号,收到后消费;重建输入时清除。
|
||||
manual_sequence: Option<u64>,
|
||||
},
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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<isize> {
|
||||
|
||||
+6
-1
@@ -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);
|
||||
}
|
||||
@@ -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<Effect> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -57,7 +57,6 @@ impl Router {
|
||||
items: vec![candidate],
|
||||
};
|
||||
self.engine.annotate(&mut list);
|
||||
self.annotate_sentence_translation(&mut list);
|
||||
list.items.pop()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<String>,
|
||||
|
||||
pub appearance: Appearance,
|
||||
|
||||
/// 翻译组的翻译框;有它时 UI 不画格子。
|
||||
pub translate: Option<PanelTranslate>,
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -14,6 +14,12 @@ pub enum PanelMouse {
|
||||
/// 滚轮滚了几行,正数往下。
|
||||
Scroll(isize),
|
||||
|
||||
/// 点翻译框的原文:光标放到第几个字之后。
|
||||
Caret(usize),
|
||||
|
||||
/// 点翻译框里当前一页的第几个候选:选进框里。
|
||||
Candidate(usize),
|
||||
|
||||
/// 点右上角的 ✕。
|
||||
Close,
|
||||
}
|
||||
|
||||
@@ -31,9 +31,9 @@ fn mtime(path: &Path) -> Option<SystemTime> {
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// 按 `[predict]` 接云联想、整句候选翻译与释义兜底;关着或缺密钥就退回本地实现。启动与热加载共用。
|
||||
/// 按 `[predict]` 接云联想与释义兜底;关着或缺密钥就退回本地实现。启动与热加载共用。
|
||||
pub fn attach_cloud(engine: &mut Engine, predict: &PredictConfig, usage_path: Option<PathBuf>) {
|
||||
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?),退回本地候选");
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -31,6 +31,8 @@ static CLASS: WindowClass = WindowClass::new();
|
||||
|
||||
const HINT: &str = "Tab 换组 · [ ] 换类 · 双击或空格上屏";
|
||||
|
||||
const TRANSLATE_HINT: &str = "Tab 换组 · [ ] 换语言 · 回车上屏";
|
||||
|
||||
/// 面板上的鼠标操作交给 Router 的回调(UI 线程上调,投给工人线程)。
|
||||
pub type PanelEvents = Box<dyn Fn(PanelMouse) + Send>;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
use qingjian_render::HitTarget;
|
||||
|
||||
use crate::composing::SentenceTranslator;
|
||||
use crate::support::*;
|
||||
|
||||
fn poll(router: &mut Router, session: SessionId) -> (Frame, Option<String>) {
|
||||
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -1,122 +1,6 @@
|
||||
//! 中文组句:出候选、选词、上屏、翻页键、直输段、失焦与学习落盘。
|
||||
|
||||
use crate::support::*;
|
||||
use qingjian_core::{
|
||||
CandidateKind, Prediction, PredictionKind, PredictionPolicy, PredictionRequest, Predictor,
|
||||
};
|
||||
|
||||
pub(crate) struct SentenceTranslator {
|
||||
replies: Vec<Prediction>,
|
||||
|
||||
submitted: Arc<Mutex<Vec<PredictionRequest>>>,
|
||||
}
|
||||
|
||||
impl SentenceTranslator {
|
||||
pub(crate) fn new(submitted: Arc<Mutex<Vec<PredictionRequest>>>) -> 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<Prediction> {
|
||||
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();
|
||||
|
||||
@@ -10,6 +10,7 @@ mod key_hint;
|
||||
mod menu;
|
||||
mod modes;
|
||||
mod numpad;
|
||||
mod panel_translate;
|
||||
mod prediction;
|
||||
mod rescoring;
|
||||
mod shortcuts;
|
||||
|
||||
@@ -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<Prediction>,
|
||||
|
||||
submitted: Arc<Mutex<Vec<PredictionRequest>>>,
|
||||
}
|
||||
|
||||
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<Prediction> {
|
||||
self.replies.pop()
|
||||
}
|
||||
}
|
||||
|
||||
fn translating_router() -> (Router, Arc<Mutex<Vec<PredictionRequest>>>) {
|
||||
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<Mutex<Vec<PredictionRequest>>>) -> Vec<String> {
|
||||
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());
|
||||
}
|
||||
@@ -26,12 +26,11 @@ fn accept_until(listener: &TcpListener, timeout: Duration) -> Option<TcpStream>
|
||||
}
|
||||
|
||||
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 秒网络间隔。
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -70,7 +70,6 @@ pub(crate) enum Message {
|
||||
CloudBaseUrl(String),
|
||||
CloudSlots(Option<f64>),
|
||||
CloudSentence(bool),
|
||||
CloudSentenceTranslation(bool),
|
||||
TestConnection,
|
||||
CloudTestDone(Result<String, String>),
|
||||
|
||||
|
||||
@@ -104,13 +104,6 @@ pub(crate) fn view(settings: &Settings, context: &mut ViewContext<Settings>) ->
|
||||
.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(
|
||||
"接口地址",
|
||||
"",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -13,6 +13,9 @@ pub(crate) struct Shared {
|
||||
/// 当前活动的组句,跨按键存活。
|
||||
composition: RefCell<Option<ITfComposition>>,
|
||||
|
||||
/// 最近一次写进文档的组句文字;符号面板翻译组的译文靠轮询送来,对比它才知道要不要补写。
|
||||
preedit: RefCell<String>,
|
||||
|
||||
/// Server 上次回的帧非空;决定 `OnTestKeyDown` 要不要吃功能键。
|
||||
composing: Cell<bool>,
|
||||
|
||||
@@ -46,6 +49,7 @@ impl Shared {
|
||||
pub(crate) fn new(client: SharedClient) -> Rc<Self> {
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -88,6 +88,9 @@ pub struct TextService {
|
||||
/// 登记成保留键的「翻译选中文字」组合;停用时撤掉(见 [`preserved`](crate::com::key::preserved))。
|
||||
translate_combo: Cell<Option<KeyCombo>>,
|
||||
|
||||
/// 登记成保留键的「翻译组」组合(没在组句时打开符号面板的翻译组)。
|
||||
translate_panel_combo: Cell<Option<KeyCombo>>,
|
||||
|
||||
/// 已登记的简繁切换键,配置变化时原地替换。
|
||||
traditional_combo: Cell<Option<KeyCombo>>,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<usize>,
|
||||
|
||||
/// 正文与组字都空时显示。
|
||||
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<usize> },
|
||||
|
||||
/// 一整行:点在行尾后面的空白,光标落到 `end`;`start` 是这一行开头,当标识用。
|
||||
LineEnd {
|
||||
node: NodeId,
|
||||
start: usize,
|
||||
end: usize,
|
||||
},
|
||||
}
|
||||
|
||||
/// 建好的各行与能点的节点。
|
||||
pub struct Flow {
|
||||
pub lines: Vec<NodeId>,
|
||||
|
||||
pub caret: Option<NodeId>,
|
||||
|
||||
pub targets: Vec<FlowTarget>,
|
||||
}
|
||||
|
||||
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<Flow, RenderError> {
|
||||
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<usize>,
|
||||
out: &mut Vec<NodeId>,
|
||||
targets: &mut Vec<FlowTarget>,
|
||||
) -> 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<usize> = 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<NodeId, RenderError> {
|
||||
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<NodeId, RenderError> {
|
||||
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<NodeId, RenderError> {
|
||||
scene.node(
|
||||
Style {
|
||||
flex_shrink: 0.0,
|
||||
..Style::default()
|
||||
},
|
||||
Visual::Text {
|
||||
text: text.to_owned(),
|
||||
style: *style,
|
||||
},
|
||||
&[],
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<M: Clone + 'static>(
|
||||
&self,
|
||||
ui: &mut Ui<M>,
|
||||
@@ -390,193 +388,57 @@ impl<'a> TextField<'a> {
|
||||
on_edit: &(impl Fn(Edit) -> M + Clone + 'static),
|
||||
) -> Result<Vec<NodeId>, 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<M: Clone + 'static>(
|
||||
&self,
|
||||
ui: &mut Ui<M>,
|
||||
theme: &WidgetTheme,
|
||||
range: Range<usize>,
|
||||
key: Key,
|
||||
on_edit: &(impl Fn(Edit) -> M + Clone + 'static),
|
||||
out: &mut Vec<NodeId>,
|
||||
) -> 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<usize> = 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<M>(
|
||||
&self,
|
||||
ui: &mut Ui<M>,
|
||||
theme: &WidgetTheme,
|
||||
pieces: &[NodeId],
|
||||
) -> Result<NodeId, RenderError> {
|
||||
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<M>(&self, ui: &mut Ui<M>, theme: &WidgetTheme) -> Result<NodeId, RenderError> {
|
||||
// 熄灭的半拍只换透明色,不拿掉节点:文字不跟着挪
|
||||
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<M>(
|
||||
&self,
|
||||
ui: &mut Ui<M>,
|
||||
theme: &WidgetTheme,
|
||||
text: &str,
|
||||
color: Color,
|
||||
decoration: Option<Decoration>,
|
||||
) -> Result<NodeId, RenderError> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 = "在这里打要翻译的话"
|
||||
|
||||
@@ -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 = "在這裡打要翻譯的話"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
// 壳给的光标前文只对这段组句有效,下一段第一键再读
|
||||
|
||||
@@ -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<dyn PanelHistory>,
|
||||
|
||||
/// 面板上次关掉时停在哪组:没在组句时打开就回到那组。
|
||||
panel_group: Option<PanelGroup>,
|
||||
|
||||
/// 翻译组上次选的目标语言;没选过跟学习语言走。
|
||||
panel_language: Option<Language>,
|
||||
|
||||
/// 翻译组的译文缓存,换联想提供方时清空。
|
||||
panel_translations: TranslationCache,
|
||||
|
||||
/// 辅码态:`None` 是拼音态,`Some` 是辅码态(空串 = 刚敲下触发键、码段还没开始)。
|
||||
/// 码段不进 `composition`:它与拼音分段记账、分段画(见 [`AuxSegment`])。
|
||||
aux_code: Option<String>,
|
||||
@@ -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,
|
||||
|
||||
+43
-17
@@ -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<SymbolPanel> {
|
||||
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 / 颜文字 / 符号先收,再拿前几个词查关键词与三张内置表。
|
||||
@@ -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<String> {
|
||||
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<String>,
|
||||
) -> 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
|
||||
}
|
||||
}
|
||||
@@ -218,7 +218,7 @@ impl Engine {
|
||||
/// (`translation_target`)。提供方不可用、私密输入中、文字为空时不发,返回 `None`;
|
||||
/// 译文从 [`Self::poll_prediction`] 的 `sentence` 里出。不进学习、不动缓冲区。
|
||||
pub fn request_translation(&mut self, text: &str) -> Option<u64> {
|
||||
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<u64> {
|
||||
self.submit_translation(text, false)
|
||||
}
|
||||
|
||||
fn submit_translation(&mut self, text: &str, manual: bool) -> Option<u64> {
|
||||
self.submit_translation_kind(text, manual, PredictionKind::Translate)
|
||||
/// 面板翻译组:目标语言由用户选定(外文原文已在框里换成中文),与「翻译选中文字」走同一个提供方。
|
||||
pub(crate) fn submit_panel_translation(
|
||||
&mut self,
|
||||
text: &str,
|
||||
language: crate::Language,
|
||||
) -> Option<u64> {
|
||||
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<crate::Language>,
|
||||
) -> Option<u64> {
|
||||
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;
|
||||
|
||||
@@ -366,6 +366,7 @@ impl Engine {
|
||||
/// 设置显式选区翻译的提供方,独立于自动联想;取消后旧结果不会上屏。
|
||||
pub fn set_translation_predictor(&mut self, predictor: Option<Box<dyn Predictor>>) {
|
||||
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<dyn Predictor>) {
|
||||
self.cancel_prediction();
|
||||
self.panel_translations.clear();
|
||||
self.predictor = predictor;
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ mod lookup;
|
||||
mod nine_key;
|
||||
mod p2c_placement;
|
||||
mod panel;
|
||||
mod panel_translate;
|
||||
mod polyphone;
|
||||
mod privacy;
|
||||
mod raw;
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
//! 面板翻译组:打拼音选词进框、停键后发一次、结果进缓存、回车上屏。
|
||||
|
||||
use super::*;
|
||||
use crate::panel::{PanelGroup, TranslateStatus};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// 记下请求、等测试塞结果的假翻译提供方。
|
||||
#[derive(Clone, Default)]
|
||||
struct Queue {
|
||||
submitted: Arc<Mutex<Vec<PredictionRequest>>>,
|
||||
replies: Arc<Mutex<Vec<Prediction>>>,
|
||||
}
|
||||
|
||||
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<Prediction> {
|
||||
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);
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 => "表情",
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// 内置的全部分类(不含搜索组),按面板里的顺序;移动端的符号 / 表情面板直接拿去排格子。
|
||||
|
||||
@@ -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<PanelSection>,
|
||||
|
||||
/// 翻译框里的原文与翻译进度。
|
||||
draft: TranslateDraft,
|
||||
|
||||
/// 搜索结果组,没搜到东西时为 `None`。
|
||||
search: Option<PanelSection>,
|
||||
|
||||
@@ -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<Item = &PanelSection> {
|
||||
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<Language> {
|
||||
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<Item = &PanelSection> {
|
||||
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<PanelSection> {
|
||||
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 = [
|
||||
|
||||
@@ -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<TranslateJob, String>,
|
||||
|
||||
/// 放进来的先后,满了从头丢。
|
||||
order: VecDeque<TranslateJob>,
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<Instant>,
|
||||
|
||||
/// 在飞的请求序号与内容。
|
||||
request: Option<(u64, TranslateJob)>,
|
||||
|
||||
/// 最近一次拿到的译文与它对应的内容。
|
||||
result: Option<(TranslateJob, String)>,
|
||||
|
||||
/// 最近一次没翻出来的内容。
|
||||
failed: Option<Failure>,
|
||||
|
||||
/// 译文或失败有了新消息、壳还没重画。
|
||||
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<TranslateJob> {
|
||||
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<TranslateJob> {
|
||||
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<u64> {
|
||||
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<String>,
|
||||
now: Instant,
|
||||
) -> Option<(TranslateJob, Option<String>)> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
@@ -0,0 +1,16 @@
|
||||
//! 翻译组当前的状态,壳拿去写提示行。
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TranslateStatus {
|
||||
/// 框里没字,或者字太少还不值得翻译。
|
||||
Idle,
|
||||
|
||||
/// 等停键或等译文回来。
|
||||
Waiting,
|
||||
|
||||
/// 译文对得上框里的原文与目标语言,回车就上屏。
|
||||
Ready,
|
||||
|
||||
/// 云端没给出译文,或者翻译不可用(云服务关了、私密输入中)。
|
||||
Failed,
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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<PanelTranslate>,
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
|
||||
/// 高亮候选在 `candidates` 里的下标。
|
||||
pub highlight: usize,
|
||||
|
||||
/// 底下一行:目标语言与翻译进度(「译成英语 · 翻译中…」)。
|
||||
pub status: String,
|
||||
}
|
||||
@@ -11,6 +11,12 @@ pub enum PanelHit {
|
||||
/// 视口里第 `n` 格(行优先,空格子不算点中)。
|
||||
Cell(usize),
|
||||
|
||||
/// 翻译框的原文里点中的位置:光标该放到第 `n` 个字之后。
|
||||
Caret(usize),
|
||||
|
||||
/// 翻译框里第 `n` 个候选。
|
||||
Candidate(usize),
|
||||
|
||||
/// 右上角的关闭按钮。
|
||||
Close,
|
||||
}
|
||||
|
||||
@@ -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<NodeId, RenderError> {
|
||||
placed_with(scene, area, visual, &[])
|
||||
}
|
||||
|
||||
/// 绝对定位在 `area`、带子节点的节点。
|
||||
fn placed_with(
|
||||
scene: &mut Scene,
|
||||
area: Area,
|
||||
visual: Visual,
|
||||
children: &[NodeId],
|
||||
) -> Result<NodeId, RenderError> {
|
||||
let style = Style {
|
||||
position: Position::Absolute,
|
||||
inset: Rect {
|
||||
@@ -123,7 +134,7 @@ fn placed(scene: &mut Scene, area: Area, visual: Visual) -> Result<NodeId, Rende
|
||||
},
|
||||
..Style::default()
|
||||
};
|
||||
scene.node(style, visual, &[])
|
||||
scene.node(style, visual, children)
|
||||
}
|
||||
|
||||
/// 建场景时收集子节点与点击区域。
|
||||
@@ -342,82 +353,101 @@ impl Renderer {
|
||||
Visual::solid(theme.color(&spec.divider), 0.0),
|
||||
)?;
|
||||
|
||||
// 格子:列数少的(颜文字)格子拉宽、用小一号的字,放不下的截尾(信息行里看全文)
|
||||
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 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))
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user