Files
qingjian/apps/windows/server/tests/engine_loop/support.rs
T
yenharvey b6319013e8 feat: 候选右键菜单:置顶、降权、删除(#749)
macOS、Windows 右键候选(macOS 也可 ⌃ 点按)弹出菜单,菜单由青简自己画:
bamboo-slip 新增 PopupMenu 组件(与设置页下拉菜单同一套样式),
qingjian-render 的 render_menu 出位图,壳只给不抢焦点的弹出窗、管悬停与点击。

- 置顶:给当前输入码加一条比同码短语都高的自定义短语(封顶 999),可在设置里看到、改、删;取消置顶删掉它
- 降权:记进学习数据 user-demoted.tsv,每降一次往后挪一格,词库词也能降;可取消
- 删除:原来 Shift + 数字删候选的那套,挪进菜单;Shift + 数字删候选去掉,回到普通符号
- 菜单项与动作在平台层 candidate_menu,两个壳共用;Esc 只关菜单,敲键、候选窗重画或收起时关
- Linux 候选窗由 Fcitx 画,暂无此菜单
2026-10-22 14:39:00 +08:00

352 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 各模块共用的零件:造 Router、造按键、拆回话、假的状态条与打分器。
pub use std::path::PathBuf;
pub use std::sync::{Arc, Mutex};
pub use qingjian_core::sentence::SentenceScorer;
pub use qingjian_core::{Language, ModeKeys, ShuangpinScheme};
pub use qingjian_platform::protocol::{
ClientMessage, Frame, KeyEvent, KeyModifiers, KeyOutcome, PROTOCOL_VERSION, ScreenRect,
ServerMessage, SessionId,
};
pub use qingjian_platform::{
AppsConfig, DEFAULT_ENGLISH_CANDIDATES_ON_WINDOWS, HighlightKeys, PreeditMode, Scheme,
};
pub use qingjian_render::Mode;
pub use qingjian_windows_server::dispatch::{
CandidateMenu, CandidateMouse, CandidateSink, KeyHintContent, KeyHintView, PanelFrame,
RenderSettings, StatusEvent, StatusSink, StatusView,
};
pub use qingjian_windows_server::{AssemblySpec, Router, RouterConfig, assembly};
pub const SESSION: SessionId = SessionId(1);
/// Caps Lock 亮着。
pub const CAPS: KeyModifiers = KeyModifiers {
ctrl: false,
shift: false,
alt: false,
win: false,
caps: true,
english_mode: false,
};
/// 持久英文模式(Caps 灭)。
pub const ENGLISH: KeyModifiers = KeyModifiers {
ctrl: false,
shift: false,
alt: false,
win: false,
caps: false,
english_mode: true,
};
/// 样例词库装一个 Router,开好一个会话。
pub fn router() -> Router {
router_with(RouterConfig::default())
}
/// 会话开在记事本里(Windows 缺省白名单里有它,英文模式给候选)。
pub fn router_with(config: RouterConfig) -> Router {
router_in_app_with(config, "notepad.exe")
}
/// 在某个应用(宿主 exe 名)里开会话。
pub fn router_in_app(app: &str) -> Router {
router_in_app_with(RouterConfig::default(), app)
}
/// 白名单固定用 Windows 缺省那份:`RouterConfig::default()` 的名单跟着编译平台走,本机跑测试时是 macOS 的。
fn router_in_app_with(config: RouterConfig, app: &str) -> Router {
let config = RouterConfig {
apps: AppsConfig::with_english_candidates_on(DEFAULT_ENGLISH_CANDIDATES_ON_WINDOWS),
..config
};
router_in(config, Some(app.to_owned()))
}
/// `?` 开着当问字入口的 Router(配置 `[shortcut] question_mark`,缺省关)。
pub fn router_asking() -> Router {
router_asking_with(RouterConfig::default())
}
pub fn router_asking_with(config: RouterConfig) -> Router {
let mut router = router_with(config);
router.engine_mut().set_mode_keys(ModeKeys {
question_mark: true,
..ModeKeys::default()
});
router
}
pub fn router_in(config: RouterConfig, app: Option<String>) -> Router {
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../..");
let dict = root.join("assets/sample/dict.tsv");
let glossary = root.join("assets/sample/glossary-en.tsv");
let mut engine = assembly::assemble(&AssemblySpec {
glossary: Some((Language::English, glossary)),
english: Some(root.join("assets/sample/english.tsv")),
..AssemblySpec::new(dict)
})
.expect("assemble engine from sample data");
// 与 main.rs 一样,双拼方案是启动时直接设给 Engine 的。
engine.set_shuangpin(config.scheme.shuangpin());
let mut router = Router::new(engine, config);
// 协议版本与 Server 一致:开会话时把按键行为设置回一次(DLL 不读配置文件,靠它拿切换键)。
open_session(&mut router, SESSION, app);
router
}
/// 开一个会话并吃掉 Server 回的按键行为设置。
pub fn open_session(router: &mut Router, session: SessionId, app: Option<String>) {
match router.handle(ClientMessage::OpenSession {
session,
app,
protocol: PROTOCOL_VERSION,
}) {
Some(ServerMessage::SessionOpened { .. }) => {}
other => panic!("expected SessionOpened, got {other:?}"),
}
}
pub fn letter(c: char) -> KeyEvent {
letter_with(c, Default::default())
}
/// `c` 的大小写就是 DLL 按 Shift 解析出的字符。
pub fn letter_with(c: char, modifiers: KeyModifiers) -> KeyEvent {
KeyEvent::new(c.to_ascii_uppercase() as u32, Some(c), modifiers)
}
pub fn press(router: &mut Router, event: KeyEvent) -> (KeyOutcome, Option<String>, Frame) {
key_result(router.handle(ClientMessage::Key {
session: SESSION,
event,
}))
}
/// 英文模式下敲一串字母,返回最后一次的处理结果。
pub fn type_english(router: &mut Router, text: &str) -> (KeyOutcome, Option<String>, Frame) {
let mut last = None;
for c in text.chars() {
last = Some(press(router, letter_with(c, ENGLISH)));
}
last.expect("typed at least one letter")
}
pub fn candidate_texts(frame: &Frame) -> Vec<&str> {
frame
.candidates
.items
.iter()
.map(|c| c.text.as_str())
.collect()
}
pub fn digit(n: u32) -> KeyEvent {
digit_with(n, Default::default())
}
/// 数字键 1–9;`character` 按 DLL 的解析:按着 Shift 是上档字符。
pub fn digit_with(n: u32, modifiers: KeyModifiers) -> KeyEvent {
let c = if modifiers.shift {
b")!@#$%^&*("[n as usize] as char
} else {
char::from_digit(n, 10).unwrap()
};
KeyEvent::new(0x30 + n, Some(c), modifiers)
}
pub const SHIFT: KeyModifiers = KeyModifiers {
shift: true,
..ALT_OFF
};
pub const ALT_OFF: KeyModifiers = KeyModifiers {
ctrl: false,
shift: false,
alt: false,
win: false,
caps: false,
english_mode: false,
};
/// 两个平台的缺省快捷键都没用 Win,拿来测「没配到的修饰键归应用」。
pub const WIN: KeyModifiers = KeyModifiers {
win: true,
..ALT_OFF
};
/// 平台缺省的译词键:macOS 是 Alt,Windows 是 Ctrl(Alt 被系统菜单截走)。
#[cfg(not(windows))]
pub const TRANSLATE: KeyModifiers = KeyModifiers {
alt: true,
..ALT_OFF
};
#[cfg(windows)]
pub const TRANSLATE: KeyModifiers = KeyModifiers {
ctrl: true,
..ALT_OFF
};
pub const TRANSLATE_SECOND: KeyModifiers = KeyModifiers {
shift: true,
..TRANSLATE
};
/// 当前页里 `text` 排第几(1 起)。
pub fn slot_of(frame: &Frame, text: &str) -> u32 {
let position = candidate_texts(frame)
.iter()
.position(|t| *t == text)
.unwrap_or_else(|| panic!("{text} 应在当前页:{:?}", candidate_texts(frame)));
position as u32 + 1
}
/// 带字符的按键(标点等),虚拟键码随便给一个 OEM 键。
pub fn punct(c: char) -> KeyEvent {
KeyEvent::new(0xBE, Some(c), Default::default())
}
pub fn key_result(message: Option<ServerMessage>) -> (KeyOutcome, Option<String>, Frame) {
match message {
Some(ServerMessage::KeyResult {
outcome,
commit,
frame,
..
}) => (outcome, commit, frame),
other => panic!("expected KeyResult, got {other:?}"),
}
}
/// 中文模式下敲一串字母,返回最后一次的处理结果。
pub fn type_letters(router: &mut Router, text: &str) -> (KeyOutcome, Option<String>, Frame) {
let mut last = None;
for c in text.chars() {
last = Some(key_result(router.handle(ClientMessage::Key {
session: SESSION,
event: letter(c),
})));
}
last.expect("typed at least one letter")
}
pub fn preedit(frame: &Frame) -> String {
frame.preedit.iter().map(|s| s.text.as_str()).collect()
}
/// 记录状态条调用:`Some(模式格文字)` 是显示、`None` 是收起。
#[derive(Clone, Default)]
pub struct RecordingStatus(pub Arc<Mutex<Vec<Option<String>>>>);
impl RecordingStatus {
pub fn calls(&self) -> Vec<Option<String>> {
self.0.lock().unwrap().clone()
}
}
impl StatusSink for RecordingStatus {
fn show_status(&self, view: StatusView) {
let label = match (view.english, view.scheme) {
(true, _) => "英".to_owned(),
(false, Some(scheme)) => format!("中 · {scheme}"),
(false, None) => "中".to_owned(),
};
self.0.lock().unwrap().push(Some(label));
}
fn hide_status(&self) {
self.0.lock().unwrap().push(None);
}
}
pub fn function_key(virtual_key: u32) -> KeyEvent {
KeyEvent::new(virtual_key, None, Default::default())
}
/// 假打分器:偏爱某个文本,其余都给低分(与 Core 的重打分测试同款)。
pub struct Prefers(pub &'static str);
impl SentenceScorer for Prefers {
fn score(&self, _context: &str, _keys: &str, texts: &[&str]) -> Vec<f64> {
texts
.iter()
.map(|t| if *t == self.0 { -1.0 } else { -20.0 })
.collect()
}
}
/// 接了假模型的 Router:本地整句模型在壳里是异步接法,按键先按词级出候选,停顿后 tick 才换。
pub fn router_with_scorer(preferred: &'static str) -> Router {
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../..");
let mut engine = assembly::assemble(&AssemblySpec::new(root.join("assets/sample/dict.tsv")))
.expect("assemble engine from sample data");
engine.set_async_sentence_scorer(Some(Box::new(Prefers(preferred))));
let mut router = Router::new(engine, RouterConfig::default());
router.handle(ClientMessage::OpenSession {
session: SESSION,
app: None,
protocol: PROTOCOL_VERSION,
});
router
}
/// 一直 tick 到首选变成 `text` 或等满 `timeout`;返回最后一帧。
pub fn tick_until_first(router: &mut Router, text: &str, timeout: std::time::Duration) -> Frame {
let started = std::time::Instant::now();
loop {
std::thread::sleep(router.next_tick().min(std::time::Duration::from_millis(20)));
router.tick();
let frame = match router.handle(ClientMessage::Poll { session: SESSION }) {
Some(ServerMessage::Update { frame, .. }) => frame,
other => panic!("expected Update, got {other:?}"),
};
if candidate_texts(&frame).first() == Some(&text) || started.elapsed() > timeout {
return frame;
}
}
}
pub fn press_in(router: &mut Router, session: SessionId, event: KeyEvent) {
let _ = router.handle(ClientMessage::Key { session, event });
}
/// 记录自绘候选窗收到的帧、输入状态、键位提示与右键菜单。
#[derive(Clone, Default)]
pub struct RecordingCandidates(
pub Arc<Mutex<Vec<Frame>>>,
pub Arc<Mutex<Vec<Mode>>>,
pub Arc<Mutex<Vec<Option<KeyHintView>>>>,
pub Arc<Mutex<Vec<CandidateMenu>>>,
);
impl CandidateSink for RecordingCandidates {
fn show(&self, frame: Frame, mode: Mode, _rect: ScreenRect, key_hint: Option<KeyHintView>) {
self.0.lock().unwrap().push(frame);
self.1.lock().unwrap().push(mode);
self.2.lock().unwrap().push(key_hint);
}
fn hide(&self) {}
fn show_panel(&self, _frame: PanelFrame, _rect: Option<ScreenRect>) {}
fn hide_panel(&self) {}
fn configure(&self, _settings: RenderSettings) {}
fn show_menu(&self, menu: CandidateMenu) {
self.3.lock().unwrap().push(menu);
}
}
pub fn rect() -> ScreenRect {
ScreenRect {
left: 0,
top: 0,
right: 100,
bottom: 20,
}
}