mirror of
https://github.com/qingjian-team/qingjian.git
synced 2026-11-06 16:13:55 +00:00
feat(windows): 任务栏中 / 英图标右键菜单,悬浮条全屏时收起
右键任务栏图标弹出菜单:切中文 / 英文、全角标点、显示或隐藏悬浮条、打开设置;后三项经协议 v7 的 Indicator 消息交给 Server,打勾状态随 ModeSync 下发(UWP 沙箱读不到配置文件)。前台是全屏应用时悬浮条收起,退出全屏后回来。
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
|
||||
/// 不画状态条的空实现。
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 对一次按键的处理结果。
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
mod button;
|
||||
pub(crate) mod conversion;
|
||||
mod icon;
|
||||
pub(crate) mod menu;
|
||||
mod state;
|
||||
|
||||
use windows::Win32::System::Variant::VARIANT;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 在读编辑会话里取当前选区,用
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
按住拖动可移到屏幕任意位置,位置会被记住;外观随系统的深浅色与缩放。
|
||||
它只在当前输入法为青简时显示:切换到微软拼音等其他输入法时收起,切回后再次出现;关闭某个应用不影响它。它与任务栏图标并存。
|
||||
前台是全屏应用(游戏、全屏看视频、演示)时自动收起,退出全屏后回来。也可以在任务栏「中 / 英」图标的右键菜单里随时显示或隐藏它。
|
||||
|
||||
## 行为
|
||||
|
||||
|
||||
Reference in New Issue
Block a user