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:
yenharvey
2026-11-04 16:18:39 +08:00
parent cd56d700a8
commit 61ca4b4150
118 changed files with 3507 additions and 1369 deletions
-1
View File
@@ -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,
+1 -1
View File
@@ -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,
}
+6
View File
@@ -14,6 +14,12 @@ pub enum PanelMouse {
/// 滚轮 / 触控板滚了几行,正数往下。
Scroll(isize),
/// 点翻译框的原文:光标放到第几个字之后。
Caret(usize),
/// 点翻译框里当前一页的第几个候选:选进框里。
Candidate(usize),
/// 点右上角的 ✕。
Close,
}
+18 -4
View File
@@ -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) {
+3 -1
View File
@@ -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();
}
}
}
+1
View File
@@ -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();
+1
View File
@@ -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,
+3
View File
@@ -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>,
+41 -9
View File
@@ -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(),
}
}
}
+5
View File
@@ -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 {
+24 -7
View File
@@ -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 里绝不发;没接联想器时是空操作。
+12 -1
View File
@@ -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)
@@ -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);
}
}
+2 -1
View File
@@ -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 穿过去整个进程就没了,
-2
View File
@@ -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"
+2 -2
View File
@@ -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"
-2
View File
@@ -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"
-2
View File
@@ -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 = "クラウド予測"
-2
View File
@@ -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"
+2 -2
View File
@@ -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 = "按下修饰键 + 字母"
+2 -2
View File
@@ -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 = "按下修飾鍵 + 字母"
-6
View File
@@ -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", "开启"),
(
+7
View File
@@ -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());
}
+15 -4
View File
@@ -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 == '.')
+9 -1
View File
@@ -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 -10
View File
@@ -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)))
}
+10 -687
View File
@@ -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());
}
+4 -4
View File
@@ -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> {
@@ -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>,
}
+43 -2
View File
@@ -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(
+11 -1
View File
@@ -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();
}
+16 -5
View File
@@ -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
}
}
}
+30
View File
@@ -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 {
+4
View File
@@ -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),
+16 -1
View File
@@ -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() {
+4 -4
View File
@@ -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。
+12
View File
@@ -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,
+1 -1
View File
@@ -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,
},
&[],
)
}
}
+53 -191
View File
@@ -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)
}
}
+9
View File
@@ -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);
+1 -1
View File
@@ -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();
// 壳给的光标前文只对这段组句有效,下一段第一键再读
+14 -2
View File
@@ -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,
@@ -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;
+2
View File
@@ -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);
}
+1 -1
View File
@@ -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(),
+3 -1
View File
@@ -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 => "表情",
+5 -1
View File
@@ -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;
/// 内置的全部分类(不含搜索组),按面板里的顺序;移动端的符号 / 表情面板直接拿去排格子。
+143 -5
View File
@@ -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,
+4 -2
View File
@@ -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,
-4
View File
@@ -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(),
}
+1 -1
View File
@@ -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,
};
+5
View File
@@ -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,
}
+108 -69
View File
@@ -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, &current)
.width
.min(budget.max(0.0));
let size = (entry_width + 1.0, line);
canvas.text(&entry.text, &current, (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, &current)
.width
.min(budget.max(0.0));
let size = (entry_width + 1.0, line);
canvas.text(&entry.text, &current, (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