feat(windows): 任务栏中 / 英图标右键菜单,悬浮条全屏时收起

右键任务栏图标弹出菜单:切中文 / 英文、全角标点、显示或隐藏悬浮条、打开设置;后三项经协议 v7 的 Indicator 消息交给 Server,打勾状态随 ModeSync 下发(UWP 沙箱读不到配置文件)。前台是全屏应用时悬浮条收起,退出全屏后回来。
This commit is contained in:
yenharvey
2026-09-23 12:04:54 +08:00
parent 61e89617d7
commit 5da1e0797c
24 changed files with 443 additions and 30 deletions
+1
View File
@@ -26,6 +26,7 @@
- Windows:辅码随包带了一张笔画码表,不用自己找码表也能用:按笔画补码(`h` 横 / `s` 竖 / `p` 撇 / `n` 点与捺 / `z` 折),单字取前 4 笔加末笔,词取每个字的首笔;笔顺按大陆规范(#153)
- Windows:候选窗口和悬浮条的字不再在改了显示缩放、或多显示器睡眠唤醒后变小 / 变大:缩放值改为取光标所在显示器的;缩放值变化时会记进日志(#146)
- Windows:设置程序只开一个窗口:再从悬浮条或开始菜单打开时,把已经开着的设置窗口带到前面,不再开出新的
- Windows:右键任务栏的「中 / 英」图标弹出菜单,可以切中文 / 英文、切全角标点、显示或隐藏悬浮状态条、打开设置;悬浮状态条在前台是全屏应用(游戏、全屏视频)时自动收起,退出全屏后回来
- 云联想:智谱(GLM)的模型能正常用了——之前模型的思考会把输出额度用光,云端候选一直是空的;接口返回了但正文为空时,日志里会留下接口的原始返回,方便排查
- macOS:系统设置里可以打开「用 Caps Lock 在 ABC 与青简之间切换」,轻按 Caps Lock 就切换输入法;缺省不开,青简自己的 Caps Lock 英文模式照旧(#211)
- macOS:偏好设置窗口不再比屏幕高,设置项多的页面可以上下滚动,底部的状态提示不会再被程序坞挡住
@@ -93,12 +93,18 @@ impl Router {
session,
english: self.take_pending_mode(),
input: self.input_settings(),
indicator: self.indicator_state(),
}),
ClientMessage::ImeSwitched { session } => {
tracing::debug!(?session, "切成了别的输入法");
self.handle_ime_switched();
None
}
ClientMessage::Indicator { session, command } => {
tracing::debug!(?session, ?command, "任务栏图标菜单");
self.handle_indicator(command);
None
}
ClientMessage::CloseSession { session } => {
self.sessions.remove(&session);
if self.focused == Some(session) {
+10 -1
View File
@@ -22,7 +22,7 @@ use std::time::{Duration, Instant};
use qingjian_core::Engine;
use qingjian_platform::LocalModelConfig;
use qingjian_platform::protocol::{
ClientMessage, Frame, InputSettings, ScreenRect, ServerMessage, SessionId,
ClientMessage, Frame, IndicatorState, InputSettings, ScreenRect, ServerMessage, SessionId,
};
pub use self::candidates::{CandidateSink, NoopSink, RenderSettings};
@@ -163,6 +163,15 @@ impl Router {
}
}
/// 任务栏图标右键菜单打勾用的开关状态,随 `ModeSync` 每一拍下发。
pub(super) fn indicator_state(&self) -> IndicatorState {
IndicatorState {
full_width_punctuation: self.config.full_width,
english_full_width_punctuation: self.config.english_full_width,
status_bar: self.config.status_enabled,
}
}
pub fn set_candidate_sink(&mut self, sink: Box<dyn CandidateSink>) {
sink.configure(self.config.render_settings());
self.candidates = sink;
@@ -7,6 +7,7 @@ mod event;
mod sink;
mod view;
use qingjian_platform::protocol::IndicatorCommand;
use qingjian_platform::{Config, Scheme, scheme_label};
pub use self::event::StatusEvent;
@@ -70,6 +71,21 @@ impl Router {
self.reconcile_status();
}
/// 任务栏图标右键菜单:标点与悬浮条的开关写回配置文件(热加载会再读回来),设置程序交给 UI 起。
pub(super) fn handle_indicator(&mut self, command: IndicatorCommand) {
match command {
IndicatorCommand::TogglePunctuation => {
self.handle_status_event(StatusEvent::TogglePunctuation);
}
IndicatorCommand::ToggleStatusBar => {
self.config.status_enabled = !self.config.status_enabled;
self.persist("status_bar", "enabled", self.config.status_enabled);
self.reconcile_status();
}
IndicatorCommand::OpenSettings => self.status.open_settings(),
}
}
/// 写回配置文件一个键;没有配置路径(测试)就只改内存。
fn persist(&self, section: &str, key: &str, value: impl Into<toml_edit::Value>) {
let Some(path) = self.config_path() else {
@@ -5,6 +5,9 @@ pub trait StatusSink: Send {
fn show_status(&self, view: StatusView);
fn hide_status(&self);
/// 起设置程序(任务栏图标右键菜单用;悬浮条上的齿轮在 UI 线程直接起)。
fn open_settings(&self) {}
}
/// 不画状态条的空实现。
+16 -1
View File
@@ -24,7 +24,8 @@ use windows::Win32::UI::HiDpi::{
DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2, SetProcessDpiAwarenessContext,
};
use windows::Win32::UI::WindowsAndMessaging::{
DispatchMessageW, GetMessageW, MSG, PostThreadMessageW, TranslateMessage, WM_APP,
ASFW_ANY, AllowSetForegroundWindow, DispatchMessageW, GetMessageW, MSG, PostThreadMessageW,
TranslateMessage, WM_APP,
};
use windows::core::{Error, Result};
@@ -101,6 +102,20 @@ impl StatusSink for UiHandle {
fn hide_status(&self) {
self.post(UiCommand::StatusHide);
}
fn open_settings(&self) {
open_settings();
}
}
/// 起与本 exe 同目录的设置程序。设置程序已开时由新实例把它带到前台,得先把前台权让出去。
pub(crate) fn open_settings() {
let _ = unsafe { AllowSetForegroundWindow(ASFW_ANY) };
let exe = std::env::current_exe().map(|exe| exe.with_file_name("qingjian-settings.exe"));
let spawned = exe.and_then(|exe| std::process::Command::new(exe).spawn());
if let Err(error) = spawned {
tracing::warn!(%error, "打开设置程序失败");
}
}
/// 本进程 exe 的模块句柄(注册窗口类 / 建窗口用)。
@@ -0,0 +1,78 @@
//! 前台是全屏应用(游戏、看视频、演示)时收起状态条,退出全屏再放出来。
//! 按 F11 进全屏不换前台窗口、没有事件可等,状态条显示期间每秒看一次。
use core::cell::Cell;
use windows::Win32::Foundation::{HWND, RECT};
use windows::Win32::Graphics::Gdi::{
GetMonitorInfoW, MONITOR_DEFAULTTONEAREST, MONITORINFO, MonitorFromWindow,
};
use windows::Win32::UI::WindowsAndMessaging::{
GWL_STYLE, GetForegroundWindow, GetShellWindow, GetWindowLongW, GetWindowRect, IsZoomed,
KillTimer, SW_HIDE, SW_SHOWNA, SetTimer, ShowWindow, WS_CAPTION,
};
/// 状态条窗口上的定时器编号。
pub(super) const TIMER_ID: usize = 1;
const INTERVAL_MS: u32 = 1000;
/// 显示状态条,前台正全屏就先收着;开始每秒检查。
pub(super) fn show(hwnd: HWND, hidden: &Cell<bool>) {
unsafe { SetTimer(Some(hwnd), TIMER_ID, INTERVAL_MS, None) };
hidden.set(false);
on_timer(hwnd, hidden);
if !hidden.get() {
let _ = unsafe { ShowWindow(hwnd, SW_SHOWNA) };
}
}
/// 收起状态条(青简不在前台 / 关掉了),不再检查。
pub(super) fn hide(hwnd: HWND, hidden: &Cell<bool>) {
let _ = unsafe { KillTimer(Some(hwnd), TIMER_ID) };
hidden.set(false);
let _ = unsafe { ShowWindow(hwnd, SW_HIDE) };
}
/// 每秒一次:进全屏收起,出全屏放回。
pub(super) fn on_timer(hwnd: HWND, hidden: &Cell<bool>) {
let fullscreen = foreground_is_fullscreen();
if fullscreen != hidden.get() {
hidden.set(fullscreen);
let command = if fullscreen { SW_HIDE } else { SW_SHOWNA };
let _ = unsafe { ShowWindow(hwnd, command) };
}
}
/// 前台窗口盖满了它所在的整个显示器(连任务栏一起)。带标题栏的最大化窗口不算:
/// 任务栏自动隐藏时它也盖满整屏,但用户要的是照常显示。
fn foreground_is_fullscreen() -> bool {
let hwnd = unsafe { GetForegroundWindow() };
if hwnd.is_invalid() || hwnd == unsafe { GetShellWindow() } {
return false;
}
let zoomed_with_caption = unsafe { IsZoomed(hwnd) }.as_bool()
&& unsafe { GetWindowLongW(hwnd, GWL_STYLE) } as u32 & WS_CAPTION.0 == WS_CAPTION.0;
if zoomed_with_caption {
return false;
}
let mut window = RECT::default();
let mut info = MONITORINFO {
cbSize: size_of::<MONITORINFO>() as u32,
..Default::default()
};
unsafe {
if GetWindowRect(hwnd, &mut window).is_err() {
return false;
}
let monitor = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST);
if !GetMonitorInfoW(monitor, &mut info).as_bool() {
return false;
}
}
let screen = info.rcMonitor;
window.left <= screen.left
&& window.top <= screen.top
&& window.right >= screen.right
&& window.bottom >= screen.bottom
}
+12 -5
View File
@@ -6,6 +6,7 @@
//! 一格的规格在 [`cell`],摆放与点击在 [`placement`]。
mod cell;
mod fullscreen;
mod placement;
use std::cell::{Cell, RefCell};
@@ -20,9 +21,9 @@ use windows::Win32::UI::HiDpi::{GetDpiForSystem, GetDpiForWindow};
use windows::Win32::UI::Input::KeyboardAndMouse::{DragDetect, ReleaseCapture};
use windows::Win32::UI::WindowsAndMessaging::{
CreateWindowExW, DefWindowProcW, DestroyWindow, GetCursorPos, HTCAPTION, HTCLIENT, IDC_HAND,
LoadCursorW, MA_NOACTIVATE, SW_HIDE, SW_SHOWNA, SendMessageW, ShowWindow, WM_EXITSIZEMOVE,
WM_LBUTTONDOWN, WM_MOUSEACTIVATE, WM_NCHITTEST, WM_NCLBUTTONDOWN, WNDCLASSEXW, WS_EX_LAYERED,
WS_EX_NOACTIVATE, WS_EX_TOOLWINDOW, WS_EX_TOPMOST, WS_POPUP,
LoadCursorW, MA_NOACTIVATE, SendMessageW, WM_EXITSIZEMOVE, WM_LBUTTONDOWN, WM_MOUSEACTIVATE,
WM_NCHITTEST, WM_NCLBUTTONDOWN, WM_TIMER, WNDCLASSEXW, WS_EX_LAYERED, WS_EX_NOACTIVATE,
WS_EX_TOOLWINDOW, WS_EX_TOPMOST, WS_POPUP,
};
use windows::core::{Error, PCWSTR, Result, w};
@@ -135,7 +136,7 @@ impl StatusBar {
}
pub(super) fn hide(&self) {
let _ = unsafe { ShowWindow(self.hwnd, SW_HIDE) };
fullscreen::hide(self.hwnd, &self.placement.fullscreen_hidden);
}
/// DPI 或深浅变了就重建主题。DPI 优先取所在位置显示器的,理由同候选窗口(#146)。
@@ -260,7 +261,7 @@ impl StatusBar {
None => self.render_gdi(),
};
if updated.is_ok() {
let _ = unsafe { ShowWindow(self.hwnd, SW_SHOWNA) };
fullscreen::show(self.hwnd, &self.placement.fullscreen_hidden);
} else {
self.hide();
}
@@ -426,6 +427,12 @@ unsafe extern "system" fn wndproc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam:
}
LRESULT(0)
}
WM_TIMER if wparam.0 == fullscreen::TIMER_ID => {
if let Some(placement) = placement_of(hwnd) {
fullscreen::on_timer(hwnd, &placement.fullscreen_hidden);
}
LRESULT(0)
}
WM_EXITSIZEMOVE => {
if let Some(placement) = placement_of(hwnd) {
placement.on_moved();
@@ -5,7 +5,7 @@ mod action;
use std::cell::{Cell, RefCell};
use windows::Win32::Foundation::{HWND, RECT};
use windows::Win32::UI::WindowsAndMessaging::{ASFW_ANY, AllowSetForegroundWindow, GetWindowRect};
use windows::Win32::UI::WindowsAndMessaging::GetWindowRect;
pub(super) use self::action::StatusAction;
use crate::dispatch::StatusEvent;
@@ -25,6 +25,9 @@ pub(super) struct Placement {
/// 上次画出的各格右边界(内容坐标)与动作,从左到右;点击按 x 落进哪格。
pub(super) cells: RefCell<Vec<(i32, StatusAction)>>,
/// 前台全屏、暂时收起了(见 [`super::fullscreen`])。
pub(super) fullscreen_hidden: Cell<bool>,
/// 点格 / 拖动结束回给 Router。
events: StatusEvents,
}
@@ -36,6 +39,7 @@ impl Placement {
margin: Cell::new(margin),
pos: Cell::new(None),
cells: RefCell::new(Vec::new()),
fullscreen_hidden: Cell::new(false),
events,
}
}
@@ -66,19 +70,8 @@ impl Placement {
Some(StatusAction::TogglePunctuation) => {
(self.events)(StatusEvent::TogglePunctuation);
}
Some(StatusAction::OpenSettings) => open_settings(),
Some(StatusAction::OpenSettings) => crate::ui::open_settings(),
None => {}
}
}
}
/// 起与本 exe 同目录的设置程序。
fn open_settings() {
// 设置程序已开时由新实例把它带到前台,得先把前台权让出去(点悬浮条的是 Server)。
let _ = unsafe { AllowSetForegroundWindow(ASFW_ANY) };
let exe = std::env::current_exe().map(|exe| exe.with_file_name("qingjian-settings.exe"));
let spawned = exe.and_then(|exe| std::process::Command::new(exe).spawn());
if let Err(error) = spawned {
tracing::warn!(%error, "打开设置程序失败");
}
}
@@ -1,5 +1,7 @@
//! 悬浮状态条随模式、双拼方案与开关变化。
use qingjian_platform::protocol::IndicatorState;
use crate::support::*;
#[test]
@@ -25,6 +27,11 @@ fn status_bar_mode_click_is_handed_to_dll_via_sync_mode() {
session: SESSION,
english: Some(true),
input: InputSettings::default(),
indicator: IndicatorState {
full_width_punctuation: true,
english_full_width_punctuation: false,
status_bar: true,
},
})
);
assert_eq!(
@@ -33,6 +40,11 @@ fn status_bar_mode_click_is_handed_to_dll_via_sync_mode() {
session: SESSION,
english: None,
input: InputSettings::default(),
indicator: IndicatorState {
full_width_punctuation: true,
english_full_width_punctuation: false,
status_bar: true,
},
})
);
}
@@ -65,6 +77,11 @@ fn status_bar_mode_click_is_ignored_when_builtin_english_is_off() {
english_mode: false,
..InputSettings::default()
},
indicator: IndicatorState {
full_width_punctuation: true,
english_full_width_punctuation: false,
status_bar: true,
},
})
);
}
@@ -130,3 +147,26 @@ fn status_bar_stays_hidden_when_disabled() {
assert_eq!(recorder.calls(), vec![None]);
}
#[test]
fn indicator_menu_toggles_status_bar() {
use qingjian_platform::protocol::IndicatorCommand;
let mut router = router();
let recorder = RecordingStatus::default();
router.set_status_sink(Box::new(recorder.clone()));
router.handle(ClientMessage::ModeChanged {
session: SESSION,
english: false,
});
// 任务栏图标菜单里点「悬浮状态条」:关着的打开,再点收起。
let toggle = ClientMessage::Indicator {
session: SESSION,
command: IndicatorCommand::ToggleStatusBar,
};
router.handle(toggle.clone());
assert_eq!(recorder.calls().last(), Some(&Some("中".to_owned())));
router.handle(toggle);
assert_eq!(recorder.calls().last(), Some(&None));
}
+20 -3
View File
@@ -1,8 +1,8 @@
use std::io::{Read, Write};
use qingjian_platform::protocol::{
ClientMessage, Frame, InputSettings, KeyEvent, PROTOCOL_VERSION, ScreenRect, ServerMessage,
SessionId, read_message, write_message,
ClientMessage, Frame, IndicatorCommand, InputSettings, KeyEvent, PROTOCOL_VERSION, ScreenRect,
ServerMessage, SessionId, read_message, write_message,
};
use super::{KeyReply, KeyResponse, ModeSyncReply};
@@ -168,7 +168,16 @@ impl<S: Read + Write> EngineClient<S> {
match self.call(&ClientMessage::SyncMode {
session: self.session,
})? {
ServerMessage::ModeSync { english, input, .. } => Ok(ModeSyncReply { english, input }),
ServerMessage::ModeSync {
english,
input,
indicator,
..
} => Ok(ModeSyncReply {
english,
input,
indicator,
}),
_ => Err(ClientError::Unexpected("expected mode sync")),
}
}
@@ -181,6 +190,14 @@ impl<S: Read + Write> EngineClient<S> {
})
}
/// 任务栏图标右键菜单里点的项交给 Server。不回话。
pub fn indicator(&mut self, command: IndicatorCommand) -> Result<(), ClientError> {
self.send(&ClientMessage::Indicator {
session: self.session,
command,
})
}
/// 让 Server 收起候选窗口(组句在 DLL 侧结束、Server 无从知晓时用)。不回话。
pub fn hide_candidates(&mut self) -> Result<(), ClientError> {
self.send(&ClientMessage::HideCandidates {
+4 -1
View File
@@ -1,4 +1,4 @@
use qingjian_platform::protocol::{Frame, InputSettings, KeyOutcome};
use qingjian_platform::protocol::{Frame, IndicatorState, InputSettings, KeyOutcome};
/// Server 对一次「同步中英模式」轮询的答复。
pub struct ModeSyncReply {
@@ -8,6 +8,9 @@ pub struct ModeSyncReply {
/// 当前的按键行为设置(切换键、内置英文模式)。每一拍都带,配置改了靠它生效——
/// DLL 不读配置文件,`%APPDATA%\Qingjian` 对 AppContainer 里的商店应用本来也读不到。
pub input: InputSettings,
/// 右键菜单打勾用的开关状态,同样每一拍都带。
pub indicator: IndicatorState,
}
/// Server 对一次按键的处理结果。
+8 -3
View File
@@ -6,7 +6,7 @@ use std::rc::Rc;
use windows::Win32::Foundation::{E_NOINTERFACE, POINT, RECT};
use windows::Win32::UI::TextServices::{
GUID_LBI_INPUTMODE, ITfLangBarItem_Impl, ITfLangBarItemButton, ITfLangBarItemButton_Impl,
ITfLangBarItemSink, ITfMenu, ITfSource, ITfSource_Impl, TF_LANGBARITEMINFO,
ITfLangBarItemSink, ITfMenu, ITfSource, ITfSource_Impl, TF_LANGBARITEMINFO, TF_LBI_CLK_RIGHT,
TF_LBI_STYLE_BTN_BUTTON, TfLBIClick,
};
use windows::Win32::UI::WindowsAndMessaging::HICON;
@@ -68,8 +68,13 @@ impl ITfLangBarItem_Impl for ModeButton_Impl {
}
impl ITfLangBarItemButton_Impl for ModeButton_Impl {
fn OnClick(&self, _click: TfLBIClick, _pt: &POINT, _prcarea: *const RECT) -> Result<()> {
crate::com::service::toggle_mode();
/// 左键切中英,右键弹菜单(中 / 英、全角标点、悬浮状态条、设置)。
fn OnClick(&self, click: TfLBIClick, pt: &POINT, _prcarea: *const RECT) -> Result<()> {
if click == TF_LBI_CLK_RIGHT {
crate::com::service::show_indicator_menu(*pt);
} else {
crate::com::service::toggle_mode();
}
Ok(())
}
+71
View File
@@ -0,0 +1,71 @@
//! 任务栏「中 / 英」图标的右键菜单。做法同小狼毫:`TrackPopupMenuEx` 挂在输入框所在窗口上,同步取回点的项。
use windows::Win32::Foundation::{HWND, POINT};
use windows::Win32::UI::WindowsAndMessaging::{
AppendMenuW, CreatePopupMenu, DestroyMenu, MENU_ITEM_FLAGS, MF_CHECKED, MF_GRAYED,
MF_SEPARATOR, MF_STRING, TPM_BOTTOMALIGN, TPM_NONOTIFY, TPM_RETURNCMD, TPM_RIGHTBUTTON,
TrackPopupMenuEx,
};
use windows::core::{HSTRING, PCWSTR};
use qingjian_platform::protocol::IndicatorCommand;
/// 打开菜单时的勾选状态。
pub(crate) struct MenuState {
pub(crate) english: bool,
pub(crate) english_enabled: bool,
pub(crate) full_width: bool,
pub(crate) status_bar: bool,
}
/// 点了哪一项:中 / 英 DLL 自己切,其余交给 Server。
pub(crate) enum MenuChoice {
Mode { english: bool },
Server(IndicatorCommand),
}
const ID_CHINESE: u32 = 1;
const ID_ENGLISH: u32 = 2;
const ID_PUNCTUATION: u32 = 3;
const ID_STATUS_BAR: u32 = 4;
const ID_SETTINGS: u32 = 5;
/// 在 `point`(屏幕坐标)弹出菜单,阻塞到用户点了某项或点别处关掉。
pub(crate) fn track(owner: HWND, point: POINT, state: &MenuState) -> Option<MenuChoice> {
let checked = |on: bool| if on { MF_CHECKED } else { MENU_ITEM_FLAGS(0) };
let english_flags = if state.english_enabled {
checked(state.english)
} else {
MF_GRAYED
};
let items = [
Some((ID_CHINESE, "中文", checked(!state.english))),
Some((ID_ENGLISH, "英文", english_flags)),
None,
Some((ID_PUNCTUATION, "全角标点", checked(state.full_width))),
Some((ID_STATUS_BAR, "悬浮状态条", checked(state.status_bar))),
None,
Some((ID_SETTINGS, "设置…", MENU_ITEM_FLAGS(0))),
];
let menu = unsafe { CreatePopupMenu() }.ok()?;
for item in items {
let _ = match item {
Some((id, text, flags)) => unsafe {
AppendMenuW(menu, MF_STRING | flags, id as usize, &HSTRING::from(text))
},
None => unsafe { AppendMenuW(menu, MF_SEPARATOR, 0, PCWSTR::null()) },
};
}
// 图标在任务栏上,菜单往上弹
let flags = TPM_RETURNCMD | TPM_NONOTIFY | TPM_RIGHTBUTTON | TPM_BOTTOMALIGN;
let id = unsafe { TrackPopupMenuEx(menu, flags.0, point.x, point.y, owner, None) };
let _ = unsafe { DestroyMenu(menu) };
match id.0 as u32 {
ID_CHINESE => Some(MenuChoice::Mode { english: false }),
ID_ENGLISH => Some(MenuChoice::Mode { english: true }),
ID_PUNCTUATION => Some(MenuChoice::Server(IndicatorCommand::TogglePunctuation)),
ID_STATUS_BAR => Some(MenuChoice::Server(IndicatorCommand::ToggleStatusBar)),
ID_SETTINGS => Some(MenuChoice::Server(IndicatorCommand::OpenSettings)),
_ => None,
}
}
+1
View File
@@ -5,6 +5,7 @@
mod button;
pub(crate) mod conversion;
mod icon;
pub(crate) mod menu;
mod state;
use windows::Win32::System::Variant::VARIANT;
+1
View File
@@ -163,6 +163,7 @@ fn sync_mode(context: &PollContext) {
drop(guard);
// 按键行为设置每一拍都带(DLL 不读配置文件),切换键与内置英文模式开关改完靠它生效。
super::service::on_input_settings(reply.input);
super::service::on_indicator_state(reply.indicator);
if let Some(english) = reply.english {
super::service::on_mode_sync(english);
}
+83
View File
@@ -0,0 +1,83 @@
//! 任务栏「中 / 英」图标右键菜单(见 [`crate::com::mode::menu`])落到文本服务上。
use windows::Win32::Foundation::{HWND, POINT};
use windows::Win32::System::Threading::GetCurrentThreadId;
use windows::Win32::UI::WindowsAndMessaging::{
ASFW_ANY, AllowSetForegroundWindow, GetForegroundWindow, GetWindowThreadProcessId,
};
use qingjian_platform::protocol::IndicatorCommand;
use super::TextService_Impl;
use crate::com::log::log;
use crate::com::mode::menu::{self, MenuChoice, MenuState};
impl TextService_Impl {
pub(super) fn show_indicator_menu(&self, point: POINT) {
let Some(owner) = self.menu_owner() else {
log("右键菜单:找不到本线程的窗口,不弹");
return;
};
// 悬浮条显示的是最后上报模式的那个应用;先报一次,让它与这份菜单说的是同一个应用
self.refresh_mode_indicator();
let english = self.mode_state.english();
let indicator = self.indicator_state.get();
let state = MenuState {
english,
english_enabled: self.mode_state.enabled(),
full_width: if english {
indicator.english_full_width_punctuation
} else {
indicator.full_width_punctuation
},
status_bar: indicator.status_bar,
};
match menu::track(owner, point, &state) {
Some(MenuChoice::Mode { english }) if english != self.mode_state.english() => {
self.set_english_mode(english);
}
Some(MenuChoice::Server(command)) => self.send_indicator(command),
_ => {}
}
}
fn send_indicator(&self, command: IndicatorCommand) {
if command == IndicatorCommand::OpenSettings {
// 设置程序由 Server 起;前台权在点菜单的这边,让出去它的窗口才能到前面
let _ = unsafe { AllowSetForegroundWindow(ASFW_ANY) };
}
match self.engine.borrow_mut().as_mut() {
Some(client) => {
if let Err(error) = client.indicator(command) {
log(&format!("右键菜单发给 Server 失败: {error}"));
}
}
None => log("右键菜单:没连上 Server"),
}
}
/// 菜单要挂在本线程的窗口上:先取焦点输入框所在窗口,没有再看前台窗口是不是本线程的。
fn menu_owner(&self) -> Option<HWND> {
let from_context = self
.thread_mgr
.borrow()
.as_ref()
.and_then(|thread_mgr| unsafe {
let view = thread_mgr
.GetFocus()
.ok()?
.GetTop()
.ok()?
.GetActiveView()
.ok()?;
view.GetWnd().ok()
});
from_context
.or_else(|| {
let foreground = unsafe { GetForegroundWindow() };
let thread = unsafe { GetWindowThreadProcessId(foreground, None) };
(thread == unsafe { GetCurrentThreadId() }).then_some(foreground)
})
.filter(|hwnd| !hwnd.is_invalid())
}
}
+17 -1
View File
@@ -7,6 +7,7 @@ mod display;
mod document;
mod key_sink;
mod launch;
mod menu;
mod mode;
mod next;
mod processor;
@@ -15,6 +16,7 @@ use std::cell::{Cell, RefCell};
use std::rc::Rc;
use std::time::{Duration, Instant};
use windows::Win32::Foundation::POINT;
use windows::Win32::UI::TextServices::{
ITfDisplayAttributeProvider, ITfKeyEventSink, ITfLangBarItemButton, ITfSource,
ITfTextInputProcessor, ITfThreadMgr,
@@ -22,7 +24,7 @@ use windows::Win32::UI::TextServices::{
use windows::core::{ComObject, implement};
use qingjian_platform::KeyCombo;
use qingjian_platform::protocol::InputSettings;
use qingjian_platform::protocol::{IndicatorState, InputSettings};
use super::composition::Shared;
use super::key::KeyTap;
@@ -83,6 +85,9 @@ pub struct TextService {
/// (每一拍 `SyncMode` 都带着它,见 [`TextService_Impl::apply_input_settings`])。
input_settings: Cell<Option<InputSettings>>,
/// 右键菜单打勾用的开关状态,Server 随 `SyncMode` 每一拍带下来。
indicator_state: Cell<IndicatorState>,
/// 激活后一小段时间内忽略转换模式 compartment 的变化,见 [`TextService_Impl::sync_from_conversion_mode`]。
conversion_guard_until: Cell<Option<Instant>>,
}
@@ -104,6 +109,11 @@ pub(super) fn toggle_mode() {
with_active(|service| service.set_english_mode(!service.mode_state.english()));
}
/// 右键点了语言栏的中 / 英按钮:在 `point`(屏幕坐标)弹菜单。
pub(super) fn show_indicator_menu(point: POINT) {
with_active(|service| service.show_indicator_menu(point));
}
/// 「转换模式」compartment 变了(见 [`conversion`](crate::com::mode::conversion))。
pub(super) fn on_conversion_mode_changed() {
with_active(TextService_Impl::sync_from_conversion_mode);
@@ -123,6 +133,11 @@ pub(super) fn on_input_settings(input: InputSettings) {
with_active(|service| service.apply_input_settings(input));
}
/// 轮询取回了右键菜单打勾用的开关状态。
pub(super) fn on_indicator_state(state: IndicatorState) {
with_active(|service| service.indicator_state.set(state));
}
impl TextService {
#[allow(clippy::new_without_default)] // 有 lock_module 副作用
pub fn new() -> Self {
@@ -143,6 +158,7 @@ impl TextService {
translate_combo: Cell::new(None),
switch_preserved: Cell::new(false),
input_settings: Cell::new(None),
indicator_state: Cell::new(IndicatorState::default()),
conversion_guard_until: Cell::new(None),
}
}
@@ -1,5 +1,6 @@
use serde::{Deserialize, Serialize};
use super::indicator::IndicatorCommand;
use super::key::KeyEvent;
use super::screen_rect::ScreenRect;
use super::session::SessionId;
@@ -127,6 +128,15 @@ pub enum ClientMessage {
session: SessionId,
},
/// 任务栏「中 / 英」图标右键菜单里点了一项(v7 起)。不等回话。
Indicator {
/// 会话标识。
session: SessionId,
/// 点的是哪一项。
command: IndicatorCommand,
},
/// 关闭会话,释放 Server 侧状态。
CloseSession {
/// 会话标识。
@@ -0,0 +1,29 @@
use serde::{Deserialize, Serialize};
/// 任务栏「中 / 英」图标右键菜单里要交给 Server 办的项(中 / 英切换在 DLL 侧自己做)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum IndicatorCommand {
/// 翻转当前模式的全角标点,与悬浮条上点「,。」一样。
TogglePunctuation,
/// 显示 / 隐藏悬浮状态条(`[status_bar] enabled`)。
ToggleStatusBar,
/// 打开设置程序。DLL 可能在 UWP 沙箱里起不了进程,交给 Server 起。
OpenSettings,
}
/// 右键菜单打勾用的开关状态。DLL 不读配置文件(UWP 沙箱里读不到),由 Server 随
/// [`super::ServerMessage::ModeSync`] 每一拍带下来。
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct IndicatorState {
/// 中文模式下标点转全角(`[general] full_width_punctuation`)。
pub full_width_punctuation: bool,
/// 英文模式下标点转全角(`[general] english_full_width_punctuation`)。
pub english_full_width_punctuation: bool,
/// 悬浮状态条开着(`[status_bar] enabled`)。
pub status_bar: bool,
}
+3 -1
View File
@@ -10,6 +10,7 @@
mod client;
mod codec;
mod indicator;
mod screen_rect;
mod server;
mod session;
@@ -20,7 +21,7 @@ mod session;
/// **加枚举变体不在「仍能对话」之列**:`qingjian_core::Candidate` 是线上格式的一部分(见本模块文档),
/// 给它加一个 `kind` 变体,老 DLL 解不出来会整条帧失败、按键直接放行——测试时看到的「输入法突然只出英文」
/// 就是这么来的(`unknown variant `Code``)。加变体必须同时 +1 并重装 DLL,否则连警告都不会有。
pub const PROTOCOL_VERSION: u32 = 6;
pub const PROTOCOL_VERSION: u32 = 7;
/// 从哪个协议版本起 DLL 会在 `OpenSession` 后阻塞读一条 [`ServerMessage::SessionOpened`]。
/// 门槛是固定值而不是当前版本:以后版本再升,没重启的应用里那些旧 DLL 仍在等这条回包,
@@ -33,6 +34,7 @@ pub mod key;
pub use client::ClientMessage;
pub use codec::{CodecError, DEFAULT_PIPE_NAME, read_message, write_message};
pub use frame::{Frame, PreeditKind, PreeditSegment};
pub use indicator::{IndicatorCommand, IndicatorState};
pub use key::{KeyEvent, KeyModifiers, KeyOutcome};
pub use screen_rect::ScreenRect;
pub use server::{InputSettings, ServerMessage};
@@ -1,6 +1,7 @@
use serde::{Deserialize, Serialize};
use super::frame::Frame;
use super::indicator::IndicatorState;
use super::key::KeyOutcome;
use super::session::SessionId;
use crate::config::SwitchKey;
@@ -97,6 +98,10 @@ pub enum ServerMessage {
/// 按键行为设置;老 DLL 不认识这个字段,读到时忽略(serde 默认忽略多余字段)。
#[serde(default)]
input: InputSettings,
/// 右键菜单打勾用的开关状态(v7 起)。
#[serde(default)]
indicator: IndicatorState,
},
/// 收到「翻译选中文字」快捷键:请 DLL 在读编辑会话里取当前选区,用
+1 -1
View File
@@ -136,7 +136,7 @@ Engine 侧在 `engine/rescoring/`:接了打分器就取 Viterbi 前 `RESCORE_P
(`[aux_code] disabled` 是黑名单,`[general] aux_code_key` 缺省 `;` 且校验后退回缺省、`aux_code_show` 是显示码开关);
`protocol` 模块是 Windows Server ↔ TSF DLL 的 IPC 协议类型
(`ClientMessage` / `ServerMessage` / `Frame` / `PreeditSegment`,全 serde,两端共用,见 `docs/design/architecture.md`「Windows:TSF」;
`PROTOCOL_VERSION` = 6,`PreeditKind::AuxCode` 对应 Core 的 `MarkedKind::AuxCode`,`Frame.aux_code_show` 随帧下发显示码开关)。
`PROTOCOL_VERSION` = 7(v7 加任务栏图标右键菜单的 `Indicator`),`PreeditKind::AuxCode` 对应 Core 的 `MarkedKind::AuxCode`,`Frame.aux_code_show` 随帧下发显示码开关)。
## crates/qingjian-render
+2
View File
@@ -11,6 +11,7 @@ description: 进入英文模式的方式、英文补全与拼写纠正、Space
轻按 `Caps Lock` 切换输入法,按住仍是大写锁定;这时青简自己的英文模式就用不到了。
- **Windows**:单击 `Shift`(按下即松开,不与其他键组合)在中 / 英之间切换,任务栏右下同步显示「中」或「英」,Caps Lock 亮着时显示「A」。
反向同样有效:点击任务栏的「中 / 英」图标,青简随之切换(悬浮状态条一并更新)。
右键这个图标弹出菜单:切中文 / 英文、全角标点、显示或隐藏悬浮状态条、打开设置。
`Caps Lock` 在 Windows 上只控制大小写:亮起时无论中英模式均直接输出大写英文字母(与微软拼音一致)。英文模式下 `Shift` + 字母也直接输出大写;
中文模式下 `Shift` + 字母缺省是临时打英文:拼音先上屏,这个大写字母原样交给应用;「设置 → 通用」可改成进拼音缓冲区参与匹配(见[按键](keys.md))。
@@ -43,6 +44,7 @@ description: 进入英文模式的方式、英文补全与拼写纠正、Space
按住拖动可移到屏幕任意位置,位置会被记住;外观随系统的深浅色与缩放。
它只在当前输入法为青简时显示:切换到微软拼音等其他输入法时收起,切回后再次出现;关闭某个应用不影响它。它与任务栏图标并存。
前台是全屏应用(游戏、全屏看视频、演示)时自动收起,退出全屏后回来。也可以在任务栏「中 / 英」图标的右键菜单里随时显示或隐藏它。
## 行为