mirror of
https://github.com/qingjian-team/qingjian.git
synced 2026-11-06 16:13:55 +00:00
macOS、Windows 右键候选(macOS 也可 ⌃ 点按)弹出菜单,菜单由青简自己画: bamboo-slip 新增 PopupMenu 组件(与设置页下拉菜单同一套样式), qingjian-render 的 render_menu 出位图,壳只给不抢焦点的弹出窗、管悬停与点击。 - 置顶:给当前输入码加一条比同码短语都高的自定义短语(封顶 999),可在设置里看到、改、删;取消置顶删掉它 - 降权:记进学习数据 user-demoted.tsv,每降一次往后挪一格,词库词也能降;可取消 - 删除:原来 Shift + 数字删候选的那套,挪进菜单;Shift + 数字删候选去掉,回到普通符号 - 菜单项与动作在平台层 candidate_menu,两个壳共用;Esc 只关菜单,敲键、候选窗重画或收起时关 - Linux 候选窗由 Fcitx 画,暂无此菜单
352 lines
11 KiB
Rust
352 lines
11 KiB
Rust
//! 各模块共用的零件:造 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,
|
||
}
|
||
}
|