Merge pull request #1 from lingr25/shortcut-recorder-and-window-fixes
feat(hotkeys): 原生按键录入快捷键,并修复 Windows 窗口启动与闪烁
This commit is contained in:
22 files changed
+1808
-169
No files matched your search
@@ -4,7 +4,13 @@
|
||||
|
||||
在本轮授权内完成交付及必要验证;明确的只读或先审批要求是停止边界。已授权且范围未变的步骤不重复索取批准。发布、对外传讯及服务变更须有覆盖该动作的授权。
|
||||
|
||||
选择最窄的适用技能。一般分析直接交付结论与证据;需要持久交付物时再建立文件。不因通用模板自动执行全套测试、定时重跑、提交或发布。
|
||||
选择最窄的适用技能。一般分析直接交付结论与证据;需要持久交付物时再建立文件。不因通用模板自动执行全套测试、定时重跑或发布。
|
||||
|
||||
## 提交约定
|
||||
|
||||
- 每个会话做出代码、文档或配置更改后,必须在完成必要验证后创建 Git 提交,再向用户交付结果;除非用户明确要求暂不提交,不需再次询问是否提交。
|
||||
- 只提交本会话任务产生的更改。共享工作区中其他会话或用户的改动保持原样;同一文件混有其他改动时,按修改块选择提交,禁止直接全量暂存。
|
||||
- 提交信息应说明实际变更;交付时报告提交哈希和验证结果。提交不代表推送或发布,这些动作仍须有对应授权。
|
||||
|
||||
## 当前架构与目录
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ Mac 发行包沿用固定社区证书签名,尚未经过 Apple 公证。同一
|
||||
### 在 Windows 上
|
||||
|
||||
1. 下载 `TTREE_*_windows_x64.zip`,解压到固定目录。
|
||||
2. 运行文件夹里的 `TTREE.exe`。窗口默认隐藏,按 **Ctrl ⇧ Space** 呼出,也可使用托盘菜单。
|
||||
2. 双击文件夹里的 `TTREE.exe` 打开窗口;已运行时会唤起已有窗口。按 **Ctrl ⇧ Space** 呼出或收起,也可使用托盘菜单。开机自启时静默驻留托盘。
|
||||
3. OCR 需要系统安装相应语言的文字识别组件;缺少语言时按应用提示补充。
|
||||
|
||||
Windows 包为未使用商业证书签名的便携版,首次运行可能遇到 SmartScreen 提示。请核对下载来源及附件 `SHA256SUMS`。启用开机自启后,请保留解压目录。
|
||||
@@ -64,7 +64,7 @@ Windows 包为未使用商业证书签名的便携版,首次运行可能遇到
|
||||
| 换行 | Shift + Enter | Shift + Enter |
|
||||
| 收起窗口 | Esc | Esc |
|
||||
|
||||
AI 对话、划词翻译、划词 AI 的快捷键可在设置中配置。点按置顶,让窗口留在身边。
|
||||
在「设置 → 快捷键」点击对应字段,再按下组合键即可录入;Backspace / Delete 清空停用,Esc 取消本次录入,保存后生效。AI 对话、划词翻译、划词 AI 均可单独设置快捷键。点按置顶,让窗口留在身边。
|
||||
|
||||
## 给创作者,留好空间。
|
||||
|
||||
|
||||
@@ -47,6 +47,10 @@ pub fn on_splitter_drag(splitter_id: u8, delta_down: f64) {
|
||||
with_ctx(|ctx| ctx.ctrl.on_splitter_drag(splitter_id, delta_down));
|
||||
}
|
||||
|
||||
pub fn on_shortcut_recording(recording: bool) {
|
||||
with_ctx(|ctx| ctx.ctrl.set_shortcut_recording(recording));
|
||||
}
|
||||
|
||||
pub fn on_splitter_drag_end() {
|
||||
with_ctx(|ctx| ctx.ctrl.on_splitter_drag_end());
|
||||
}
|
||||
@@ -71,8 +75,10 @@ define_class!(
|
||||
#[unsafe(method(handleTimer:))]
|
||||
fn handle_timer(&self, _timer: &AnyObject) {
|
||||
with_ctx(|ctx| {
|
||||
let recording = crate::shortcut_recorder::is_recording(&ctx.ctrl.window);
|
||||
ctx.ctrl.set_shortcut_recording(recording);
|
||||
while let Ok(hk_ev) = GlobalHotKeyEvent::receiver().try_recv() {
|
||||
if hk_ev.state == HotKeyState::Pressed {
|
||||
if !ctx.ctrl.recording_shortcut && hk_ev.state == HotKeyState::Pressed {
|
||||
if let Some(action) = ctx.ctrl.parsed_shortcuts.action_for_id(hk_ev.id)
|
||||
{
|
||||
ctx.ctrl.handle_hotkey_action(action);
|
||||
|
||||
@@ -5,6 +5,8 @@ mod app;
|
||||
mod state;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod ui_helpers;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod shortcut_recorder;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn main() {
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
//! Native shortcut fields receive key events directly, without the text field editor.
|
||||
use std::cell::RefCell;
|
||||
|
||||
use global_hotkey::hotkey::{Code, HotKey, Modifiers};
|
||||
use objc2::rc::Retained;
|
||||
use objc2::runtime::Bool;
|
||||
use objc2::{define_class, msg_send, DefinedClass, MainThreadMarker, MainThreadOnly};
|
||||
use objc2_app_kit::{NSEvent, NSEventModifierFlags, NSFont, NSTextField, NSWindow};
|
||||
use objc2_foundation::{NSObjectProtocol, NSRect, NSString};
|
||||
use ttree_platform::shortcut_recorder::{CaptureResult, ShortcutRecorder};
|
||||
|
||||
use crate::ui_helpers::pale_ink;
|
||||
|
||||
pub struct ShortcutFieldIvars {
|
||||
recorder: RefCell<ShortcutRecorder>,
|
||||
}
|
||||
|
||||
define_class!(
|
||||
#[unsafe(super(NSTextField))]
|
||||
#[thread_kind = MainThreadOnly]
|
||||
#[ivars = ShortcutFieldIvars]
|
||||
#[name = "TTreeShortcutField"]
|
||||
pub struct ShortcutField;
|
||||
|
||||
unsafe impl NSObjectProtocol for ShortcutField {}
|
||||
|
||||
impl ShortcutField {
|
||||
#[unsafe(method(acceptsFirstResponder))]
|
||||
fn accepts_first_responder(&self) -> Bool { Bool::YES }
|
||||
|
||||
#[unsafe(method(mouseDown:))]
|
||||
fn mouse_down(&self, _event: &NSEvent) {
|
||||
if let Some(window) = self.window() {
|
||||
window.makeFirstResponder(Some(self));
|
||||
}
|
||||
}
|
||||
|
||||
#[unsafe(method(becomeFirstResponder))]
|
||||
fn become_first_responder(&self) -> Bool {
|
||||
self.ivars().recorder.borrow_mut().begin();
|
||||
self.setBackgroundColor(Some(&pale_ink(0.10)));
|
||||
crate::app::on_shortcut_recording(true);
|
||||
Bool::YES
|
||||
}
|
||||
|
||||
#[unsafe(method(resignFirstResponder))]
|
||||
fn resign_first_responder(&self) -> Bool {
|
||||
self.setBackgroundColor(Some(&pale_ink(0.045)));
|
||||
crate::app::on_shortcut_recording(false);
|
||||
Bool::YES
|
||||
}
|
||||
|
||||
#[unsafe(method(keyDown:))]
|
||||
fn key_down(&self, event: &NSEvent) { self.record(event); }
|
||||
|
||||
#[unsafe(method(performKeyEquivalent:))]
|
||||
fn perform_key_equivalent(&self, event: &NSEvent) -> Bool {
|
||||
let focused = self.window().and_then(|window| window.firstResponder())
|
||||
.is_some_and(|responder| responder.isEqual(Some(self)));
|
||||
if focused {
|
||||
self.record(event);
|
||||
Bool::YES
|
||||
} else {
|
||||
Bool::NO
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
impl ShortcutField {
|
||||
pub fn new(mtm: MainThreadMarker, frame: NSRect, value: &str) -> Retained<Self> {
|
||||
let allocated = mtm.alloc::<Self>().set_ivars(ShortcutFieldIvars {
|
||||
recorder: RefCell::new(ShortcutRecorder::default()),
|
||||
});
|
||||
let field: Retained<Self> = unsafe { msg_send![super(allocated), initWithFrame: frame] };
|
||||
field.setEditable(false);
|
||||
field.setSelectable(false);
|
||||
field.setBezeled(false);
|
||||
field.setDrawsBackground(true);
|
||||
field.setBackgroundColor(Some(&pale_ink(0.045)));
|
||||
field.setTextColor(Some(&pale_ink(0.78)));
|
||||
field.setFont(Some(&NSFont::systemFontOfSize(12.5)));
|
||||
field.setPlaceholderString(Some(&NSString::from_str("点击后按组合键")));
|
||||
field.setWantsLayer(true);
|
||||
if let Some(layer) = field.layer() {
|
||||
layer.setCornerRadius(5.0);
|
||||
}
|
||||
field.set_value(value);
|
||||
field
|
||||
}
|
||||
|
||||
pub fn set_value(&self, value: &str) {
|
||||
let display = {
|
||||
let mut recorder = self.ivars().recorder.borrow_mut();
|
||||
recorder.set(value);
|
||||
recorder.display()
|
||||
};
|
||||
self.setStringValue(&NSString::from_str(&display));
|
||||
}
|
||||
|
||||
pub fn value(&self) -> String {
|
||||
self.ivars().recorder.borrow().value().to_owned()
|
||||
}
|
||||
|
||||
fn record(&self, event: &NSEvent) {
|
||||
let Some(key) = key(event.keyCode()) else {
|
||||
return;
|
||||
};
|
||||
let flags = event.modifierFlags();
|
||||
let mut modifiers = Modifiers::empty();
|
||||
for (flag, modifier) in [
|
||||
(NSEventModifierFlags::Control, Modifiers::CONTROL),
|
||||
(NSEventModifierFlags::Option, Modifiers::ALT),
|
||||
(NSEventModifierFlags::Shift, Modifiers::SHIFT),
|
||||
(NSEventModifierFlags::Command, Modifiers::SUPER),
|
||||
] {
|
||||
if flags.contains(flag) {
|
||||
modifiers |= modifier;
|
||||
}
|
||||
}
|
||||
let (result, display) = {
|
||||
let mut recorder = self.ivars().recorder.borrow_mut();
|
||||
let result = recorder.capture(key, modifiers);
|
||||
(result, recorder.display())
|
||||
};
|
||||
self.setStringValue(&NSString::from_str(&display));
|
||||
if let Some(window) = self.window() {
|
||||
match result {
|
||||
CaptureResult::Cancelled => {
|
||||
window.makeFirstResponder(None);
|
||||
}
|
||||
CaptureResult::Navigate { backwards: true } => {
|
||||
window.selectPreviousKeyView(Some(self))
|
||||
}
|
||||
CaptureResult::Navigate { backwards: false } => {
|
||||
window.selectNextKeyView(Some(self))
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_recording(window: &NSWindow) -> bool {
|
||||
window.isKeyWindow()
|
||||
&& window.firstResponder().is_some_and(|responder| {
|
||||
responder
|
||||
.downcast_ref::<ShortcutField>()
|
||||
.is_some_and(|field| !field.isHiddenOrHasHiddenAncestor())
|
||||
})
|
||||
}
|
||||
|
||||
fn key(code: u16) -> Option<Code> {
|
||||
let token = match code {
|
||||
0 => "A",
|
||||
1 => "S",
|
||||
2 => "D",
|
||||
3 => "F",
|
||||
4 => "H",
|
||||
5 => "G",
|
||||
6 => "Z",
|
||||
7 => "X",
|
||||
8 => "C",
|
||||
9 => "V",
|
||||
11 => "B",
|
||||
12 => "Q",
|
||||
13 => "W",
|
||||
14 => "E",
|
||||
15 => "R",
|
||||
16 => "Y",
|
||||
17 => "T",
|
||||
18 => "1",
|
||||
19 => "2",
|
||||
20 => "3",
|
||||
21 => "4",
|
||||
22 => "6",
|
||||
23 => "5",
|
||||
24 => "Equal",
|
||||
25 => "9",
|
||||
26 => "7",
|
||||
27 => "Minus",
|
||||
28 => "8",
|
||||
29 => "0",
|
||||
30 => "BracketRight",
|
||||
31 => "O",
|
||||
32 => "U",
|
||||
33 => "BracketLeft",
|
||||
34 => "I",
|
||||
35 => "P",
|
||||
36 => "Enter",
|
||||
37 => "L",
|
||||
38 => "J",
|
||||
39 => "Quote",
|
||||
40 => "K",
|
||||
41 => "Semicolon",
|
||||
42 => "Backslash",
|
||||
43 => "Comma",
|
||||
44 => "Slash",
|
||||
45 => "N",
|
||||
46 => "M",
|
||||
47 => "Period",
|
||||
48 => "Tab",
|
||||
49 => "Space",
|
||||
50 => "Backquote",
|
||||
51 => "Backspace",
|
||||
53 => "Escape",
|
||||
65 => "NumpadDecimal",
|
||||
67 => "NumpadMultiply",
|
||||
69 => "NumpadAdd",
|
||||
75 => "NumpadDivide",
|
||||
76 => "NumpadEnter",
|
||||
78 => "NumpadSubtract",
|
||||
81 => "NumpadEqual",
|
||||
82 => "Numpad0",
|
||||
83 => "Numpad1",
|
||||
84 => "Numpad2",
|
||||
85 => "Numpad3",
|
||||
86 => "Numpad4",
|
||||
87 => "Numpad5",
|
||||
88 => "Numpad6",
|
||||
89 => "Numpad7",
|
||||
91 => "Numpad8",
|
||||
92 => "Numpad9",
|
||||
122 => "F1",
|
||||
120 => "F2",
|
||||
99 => "F3",
|
||||
118 => "F4",
|
||||
96 => "F5",
|
||||
97 => "F6",
|
||||
98 => "F7",
|
||||
100 => "F8",
|
||||
101 => "F9",
|
||||
109 => "F10",
|
||||
103 => "F11",
|
||||
111 => "F12",
|
||||
105 => "F13",
|
||||
107 => "F14",
|
||||
113 => "F15",
|
||||
106 => "F16",
|
||||
64 => "F17",
|
||||
79 => "F18",
|
||||
80 => "F19",
|
||||
90 => "F20",
|
||||
115 => "Home",
|
||||
116 => "PageUp",
|
||||
117 => "Delete",
|
||||
119 => "End",
|
||||
121 => "PageDown",
|
||||
123 => "Left",
|
||||
124 => "Right",
|
||||
125 => "Down",
|
||||
126 => "Up",
|
||||
_ => return None,
|
||||
};
|
||||
token.parse::<HotKey>().ok().map(|hotkey| hotkey.key)
|
||||
}
|
||||
+69
-49
@@ -3,6 +3,8 @@
|
||||
#![cfg(target_os = "macos")]
|
||||
#![allow(clippy::too_many_arguments)]
|
||||
|
||||
use crate::shortcut_recorder::ShortcutField;
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
@@ -29,7 +31,9 @@ use ttree_core::{
|
||||
WINDOW_MIN_HEIGHT, WINDOW_MIN_WIDTH,
|
||||
};
|
||||
use ttree_ocr::OcrOutcome;
|
||||
use ttree_platform::hotkey::{parse_shortcut_config, ParsedShortcuts, ShortcutAction};
|
||||
use ttree_platform::hotkey::{
|
||||
parse_shortcut_config, register_shortcuts, ParsedShortcuts, ShortcutAction,
|
||||
};
|
||||
use ttree_storage::{HistoryStore, SettingsStore};
|
||||
|
||||
use crate::ui_helpers::{
|
||||
@@ -374,15 +378,15 @@ pub struct SettingsViewWidgets {
|
||||
pub sc_head: Retained<NSTextField>,
|
||||
pub btn_reset_sc: Retained<NSButton>,
|
||||
pub sc_lbl_toggle: Retained<NSTextField>,
|
||||
pub sc_toggle_field: Retained<NSTextField>,
|
||||
pub sc_toggle_field: Retained<ShortcutField>,
|
||||
pub sc_lbl_ocr: Retained<NSTextField>,
|
||||
pub sc_ocr_field: Retained<NSTextField>,
|
||||
pub sc_ocr_field: Retained<ShortcutField>,
|
||||
pub sc_lbl_ai: Retained<NSTextField>,
|
||||
pub sc_ai_field: Retained<NSTextField>,
|
||||
pub sc_ai_field: Retained<ShortcutField>,
|
||||
pub sc_lbl_sel_trans: Retained<NSTextField>,
|
||||
pub sc_sel_trans_field: Retained<NSTextField>,
|
||||
pub sc_sel_trans_field: Retained<ShortcutField>,
|
||||
pub sc_lbl_sel_ai: Retained<NSTextField>,
|
||||
pub sc_sel_ai_field: Retained<NSTextField>,
|
||||
pub sc_sel_ai_field: Retained<ShortcutField>,
|
||||
pub save_sc_btn: Retained<NSButton>,
|
||||
pub sc_hint: Retained<NSTextField>,
|
||||
|
||||
@@ -413,6 +417,7 @@ pub struct AppController {
|
||||
pub hotkey_mgr: GlobalHotKeyManager,
|
||||
pub parsed_shortcuts: ParsedShortcuts,
|
||||
pub registered_hotkeys: Vec<HotKey>,
|
||||
pub recording_shortcut: bool,
|
||||
|
||||
pub window: Retained<NSWindow>,
|
||||
pub effect_view: Retained<NSVisualEffectView>,
|
||||
@@ -624,13 +629,15 @@ impl AppController {
|
||||
add_subview(&effect_view, &settings_ui.root);
|
||||
|
||||
let sc_cfg = settings.shortcuts();
|
||||
let parsed_shortcuts = parse_shortcut_config(&sc_cfg).unwrap_or_default();
|
||||
let mut registered_hotkeys = Vec::new();
|
||||
for hk in parsed_shortcuts.unique_hotkeys() {
|
||||
if hotkey_mgr.register(hk).is_ok() {
|
||||
registered_hotkeys.push(hk);
|
||||
}
|
||||
}
|
||||
let mut parsed_shortcuts = ParsedShortcuts::default();
|
||||
let shortcut_result = parse_shortcut_config(&sc_cfg)
|
||||
.map_err(|error| error.to_string())
|
||||
.and_then(|parsed| {
|
||||
register_shortcuts(&hotkey_mgr, &mut registered_hotkeys, &parsed)?;
|
||||
parsed_shortcuts = parsed;
|
||||
Ok(())
|
||||
});
|
||||
|
||||
let conversations = history.read_conversations().unwrap_or_default();
|
||||
let (active_conversation_id, current_messages) = match conversations.last() {
|
||||
@@ -649,6 +656,7 @@ impl AppController {
|
||||
hotkey_mgr,
|
||||
parsed_shortcuts,
|
||||
registered_hotkeys,
|
||||
recording_shortcut: false,
|
||||
window,
|
||||
effect_view,
|
||||
segmented_bg,
|
||||
@@ -701,6 +709,9 @@ impl AppController {
|
||||
ctrl.refresh_ai_header_badge();
|
||||
ctrl.refresh_conversations_popup();
|
||||
ctrl.relayout_all(win_w, win_h);
|
||||
if let Err(error) = shortcut_result {
|
||||
ctrl.set_status(&error);
|
||||
}
|
||||
ctrl
|
||||
}
|
||||
|
||||
@@ -1655,25 +1666,15 @@ impl AppController {
|
||||
action_sel,
|
||||
);
|
||||
let sc_lbl_toggle = make_label(mtm, dummy, "呼出翻译", 12.5, false, true);
|
||||
let sc_toggle_field = make_text_field(mtm, dummy, &sc_cfg.toggle, "CmdOrCtrl+Shift+Space");
|
||||
let sc_toggle_field = ShortcutField::new(mtm, dummy, &sc_cfg.toggle);
|
||||
let sc_lbl_ocr = make_label(mtm, dummy, "截图 OCR", 12.5, false, true);
|
||||
let sc_ocr_field = make_text_field(mtm, dummy, &sc_cfg.ocr, "CmdOrCtrl+Shift+S");
|
||||
let sc_ocr_field = ShortcutField::new(mtm, dummy, &sc_cfg.ocr);
|
||||
let sc_lbl_ai = make_label(mtm, dummy, "呼出 AI 对话", 12.5, false, true);
|
||||
let sc_ai_field = make_text_field(mtm, dummy, &sc_cfg.ai_dialog, "Option+Space");
|
||||
let sc_ai_field = ShortcutField::new(mtm, dummy, &sc_cfg.ai_dialog);
|
||||
let sc_lbl_sel_trans = make_label(mtm, dummy, "划词翻译", 12.5, false, true);
|
||||
let sc_sel_trans_field = make_text_field(
|
||||
mtm,
|
||||
dummy,
|
||||
&sc_cfg.selection_translate,
|
||||
"可与「呼出翻译」共用同一按键",
|
||||
);
|
||||
let sc_sel_trans_field = ShortcutField::new(mtm, dummy, &sc_cfg.selection_translate);
|
||||
let sc_lbl_sel_ai = make_label(mtm, dummy, "划词 AI 对话", 12.5, false, true);
|
||||
let sc_sel_ai_field = make_text_field(
|
||||
mtm,
|
||||
dummy,
|
||||
&sc_cfg.selection_ai_dialog,
|
||||
"可与「呼出 AI 对话」共用同一按键",
|
||||
);
|
||||
let sc_sel_ai_field = ShortcutField::new(mtm, dummy, &sc_cfg.selection_ai_dialog);
|
||||
let save_sc_btn = make_button(
|
||||
mtm,
|
||||
dummy,
|
||||
@@ -1685,7 +1686,7 @@ impl AppController {
|
||||
let sc_hint = make_wrapping_label(
|
||||
mtm,
|
||||
top_rect(sec_h, 0.0, 260.0, content_w, 60.0),
|
||||
"每个快捷键都可单独设置或留空关闭。「呼出翻译」与「划词翻译」(以及「呼出 AI 对话」与「划词 AI 对话」)支持共用同一个按键:有选中文本时自动执行划词,无选中文本则切换主窗口。",
|
||||
"点击后按组合键;Delete 清空,Esc 取消,Tab 切换。保存后生效。呼出与对应划词操作可共用快捷键:选中文本时执行划词,否则切换窗口。",
|
||||
11.5,
|
||||
true,
|
||||
);
|
||||
@@ -2668,14 +2669,11 @@ impl AppController {
|
||||
}
|
||||
ActionTag::ResetDefaultShortcuts => {
|
||||
let def = ShortcutConfig::default();
|
||||
set_field_string(&self.settings_ui.sc_toggle_field, &def.toggle);
|
||||
set_field_string(&self.settings_ui.sc_ocr_field, &def.ocr);
|
||||
set_field_string(&self.settings_ui.sc_ai_field, &def.ai_dialog);
|
||||
set_field_string(
|
||||
&self.settings_ui.sc_sel_trans_field,
|
||||
&def.selection_translate,
|
||||
);
|
||||
set_field_string(&self.settings_ui.sc_sel_ai_field, &def.selection_ai_dialog);
|
||||
self.settings_ui.sc_toggle_field.set_value(&def.toggle);
|
||||
self.settings_ui.sc_ocr_field.set_value(&def.ocr);
|
||||
self.settings_ui.sc_ai_field.set_value(&def.ai_dialog);
|
||||
self.settings_ui.sc_sel_trans_field.set_value(&def.selection_translate);
|
||||
self.settings_ui.sc_sel_ai_field.set_value(&def.selection_ai_dialog);
|
||||
self.set_status("已填入默认快捷键,请点击「保存快捷键」");
|
||||
}
|
||||
ActionTag::OpacitySliderChanged => {
|
||||
@@ -3038,6 +3036,8 @@ impl AppController {
|
||||
}
|
||||
|
||||
fn save_all_settings(&mut self) {
|
||||
self.window.makeFirstResponder(None);
|
||||
self.set_shortcut_recording(false);
|
||||
let ai_input = AiTranslationConfig {
|
||||
enabled: self.settings_ui.ai_enabled_chk.state() == NSControlStateValueOn,
|
||||
backend: self.selected_ai_trans_backend(),
|
||||
@@ -3076,24 +3076,25 @@ impl AppController {
|
||||
self.pending_clear_chat_key = false;
|
||||
|
||||
let sc_input = ShortcutConfig {
|
||||
toggle: get_field_string(&self.settings_ui.sc_toggle_field),
|
||||
ocr: get_field_string(&self.settings_ui.sc_ocr_field),
|
||||
ai_dialog: get_field_string(&self.settings_ui.sc_ai_field),
|
||||
selection_translate: get_field_string(&self.settings_ui.sc_sel_trans_field),
|
||||
selection_ai_dialog: get_field_string(&self.settings_ui.sc_sel_ai_field),
|
||||
toggle: self.settings_ui.sc_toggle_field.value(),
|
||||
ocr: self.settings_ui.sc_ocr_field.value(),
|
||||
ai_dialog: self.settings_ui.sc_ai_field.value(),
|
||||
selection_translate: self.settings_ui.sc_sel_trans_field.value(),
|
||||
selection_ai_dialog: self.settings_ui.sc_sel_ai_field.value(),
|
||||
};
|
||||
match parse_shortcut_config(&sc_input) {
|
||||
Ok(parsed) => {
|
||||
for old_hk in self.registered_hotkeys.drain(..) {
|
||||
let _ = self.hotkey_mgr.unregister(old_hk);
|
||||
}
|
||||
for hk in parsed.unique_hotkeys() {
|
||||
if self.hotkey_mgr.register(hk).is_ok() {
|
||||
self.registered_hotkeys.push(hk);
|
||||
}
|
||||
if let Err(error) =
|
||||
register_shortcuts(&self.hotkey_mgr, &mut self.registered_hotkeys, &parsed)
|
||||
{
|
||||
self.set_status(&error);
|
||||
return;
|
||||
}
|
||||
self.parsed_shortcuts = parsed;
|
||||
let _ = self.settings.save_shortcuts(&sc_input);
|
||||
if let Err(error) = self.settings.save_shortcuts(&sc_input) {
|
||||
self.set_status(&format!("保存快捷键失败: {error}"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
self.set_status(&format!("❌ {e}"));
|
||||
@@ -3584,7 +3585,26 @@ impl AppController {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_shortcut_recording(&mut self, recording: bool) {
|
||||
if self.recording_shortcut == recording {
|
||||
return;
|
||||
}
|
||||
self.recording_shortcut = recording;
|
||||
while global_hotkey::GlobalHotKeyEvent::receiver().try_recv().is_ok() {}
|
||||
let desired = if recording {
|
||||
ParsedShortcuts::default()
|
||||
} else {
|
||||
self.parsed_shortcuts.clone()
|
||||
};
|
||||
match register_shortcuts(&self.hotkey_mgr, &mut self.registered_hotkeys, &desired) {
|
||||
Ok(()) if recording => self.set_status("请按组合键;Delete 清空,Esc 取消"),
|
||||
Ok(()) => self.set_status("录入完成,保存设置后生效"),
|
||||
Err(error) => self.set_status(&error),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn on_window_did_resign_key(&mut self) {
|
||||
self.set_shortcut_recording(false);
|
||||
if !self.is_pinned && self.active_tab != TabMode::Settings && !self.is_modal_open {
|
||||
self.hide_window();
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ Windows 10/11 x64 原生 Win32 界面,支持托盘、快捷键、截图 OCR、
|
||||
|
||||
## 便携版
|
||||
|
||||
将整个 TTREE 文件夹解压到固定目录,运行 `TTREE.exe`。默认隐藏窗口,使用 `Ctrl+Shift+Space` 呼出,`Ctrl+Shift+S` 截图翻译。也可从托盘菜单打开。
|
||||
将整个 TTREE 文件夹解压到固定目录,双击 `TTREE.exe` 即可打开窗口;已经运行时会唤起已有窗口。使用 `Ctrl+Shift+Space` 呼出或收起,`Ctrl+Shift+S` 截图翻译。也可从托盘菜单打开。开机自启使用 `--hidden` 参数,静默驻留托盘。
|
||||
|
||||
开机自启记录当前可执行路径;移动目录后应重新设置。OCR 使用系统文字识别组件,缺少语言时在 Windows 中补装对应的 OCR 可选功能。本包未使用商业代码签名证书,可能触发 SmartScreen;请只从项目官方 Releases 下载并校验 SHA256SUMS。
|
||||
|
||||
|
||||
+231
-61
@@ -11,6 +11,7 @@
|
||||
#![allow(non_snake_case, clippy::too_many_arguments)]
|
||||
|
||||
mod dialogs;
|
||||
mod shortcut_recorder;
|
||||
mod ui;
|
||||
|
||||
use crate::design::{BoxRect, TranslationLayout, SIDEBAR, TOOLBAR};
|
||||
@@ -36,7 +37,9 @@ use ttree_core::{
|
||||
};
|
||||
use ttree_ocr::OcrOutcome;
|
||||
use ttree_platform::autostart;
|
||||
use ttree_platform::hotkey::{parse_shortcut_config, ParsedShortcuts, ShortcutAction};
|
||||
use ttree_platform::hotkey::{
|
||||
parse_shortcut_config, register_shortcuts, ParsedShortcuts, ShortcutAction,
|
||||
};
|
||||
use ttree_platform::selection::capture_selected_text;
|
||||
use ttree_storage::{HistoryStore, SettingsStore};
|
||||
|
||||
@@ -62,7 +65,9 @@ const WS_VSCROLL: u32 = 0x00200000;
|
||||
const WS_BORDER: u32 = 0x00800000;
|
||||
|
||||
const WS_EX_TOOLWINDOW: u32 = 0x00000080;
|
||||
const WS_EX_APPWINDOW: u32 = 0x00040000;
|
||||
const WS_EX_LAYERED: u32 = 0x00080000;
|
||||
const GWL_EXSTYLE: i32 = -20;
|
||||
|
||||
const ES_MULTILINE: u32 = 0x0004;
|
||||
const ES_AUTOVSCROLL: u32 = 0x0040;
|
||||
@@ -122,6 +127,8 @@ const HWND_NOTOPMOST: HWND = -2isize as HWND;
|
||||
const SWP_NOMOVE: u32 = 0x0002;
|
||||
const SWP_NOSIZE: u32 = 0x0001;
|
||||
const SWP_NOACTIVATE: u32 = 0x0010;
|
||||
const SWP_NOZORDER: u32 = 0x0004;
|
||||
const SWP_FRAMECHANGED: u32 = 0x0020;
|
||||
|
||||
const LWA_ALPHA: u32 = 0x00000002;
|
||||
const TRANSPARENT: i32 = 1;
|
||||
@@ -212,6 +219,7 @@ const ID_BTN_UPDATE: usize = 428;
|
||||
const ID_CHK_AUTOSTART: usize = 425;
|
||||
const ID_BTN_SAVE_SETTINGS: usize = 426;
|
||||
const ID_BTN_HYMT_DELETE: usize = 427;
|
||||
const ID_CHK_NORMAL_WINDOW: usize = 429;
|
||||
|
||||
const ID_TRAY_TRANSLATE: usize = 901;
|
||||
const ID_TRAY_OCR: usize = 902;
|
||||
@@ -523,6 +531,7 @@ struct Win32AppState {
|
||||
history: Arc<HistoryStore>,
|
||||
hotkey_mgr: GlobalHotKeyManager,
|
||||
registered_hotkeys: Vec<HotKey>,
|
||||
recording_shortcut: bool,
|
||||
parsed_shortcuts: ParsedShortcuts,
|
||||
|
||||
hwnd: HWND,
|
||||
@@ -649,6 +658,7 @@ struct Win32AppState {
|
||||
cmb_opacity: HWND,
|
||||
chk_ocr_newline: HWND,
|
||||
chk_autostart: HWND,
|
||||
chk_normal_window: HWND,
|
||||
btn_save_settings: HWND,
|
||||
lbl_settings_status: HWND,
|
||||
}
|
||||
@@ -668,6 +678,15 @@ fn with_state<F: FnOnce(&mut Win32AppState)>(f: F) {
|
||||
}
|
||||
|
||||
pub fn run() {
|
||||
let show_on_launch = crate::startup::should_show(std::env::args_os().skip(1));
|
||||
let _instance = match crate::startup::acquire(show_on_launch) {
|
||||
Ok(Some(instance)) => instance,
|
||||
Ok(None) => return,
|
||||
Err(error) => {
|
||||
crate::startup::report_error(&error);
|
||||
return;
|
||||
}
|
||||
};
|
||||
unsafe {
|
||||
let _ = SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2);
|
||||
}
|
||||
@@ -689,7 +708,7 @@ pub fn run() {
|
||||
|
||||
unsafe {
|
||||
let hinstance = GetModuleHandleW(ptr::null());
|
||||
let class_name = wide_null("TTreePureRustWin32MainClass");
|
||||
let class_name = wide_null(crate::startup::WINDOW_CLASS);
|
||||
let colors = palette();
|
||||
let bg_brush = CreateSolidBrush(colors.bg);
|
||||
let surface_brush = CreateSolidBrush(colors.surface);
|
||||
@@ -727,7 +746,13 @@ pub fn run() {
|
||||
|
||||
let title = wide_null("TTREE");
|
||||
let hwnd = CreateWindowExW(
|
||||
WS_EX_LAYERED | WS_EX_TOOLWINDOW | WS_EX_COMPOSITED,
|
||||
WS_EX_LAYERED
|
||||
| WS_EX_COMPOSITED
|
||||
| if settings.appearance().normal_window_mode {
|
||||
WS_EX_APPWINDOW
|
||||
} else {
|
||||
WS_EX_TOOLWINDOW
|
||||
},
|
||||
class_name.as_ptr(),
|
||||
title.as_ptr(),
|
||||
WS_OVERLAPPED | WS_THICKFRAME | WS_CLIPCHILDREN,
|
||||
@@ -795,6 +820,7 @@ pub fn run() {
|
||||
history,
|
||||
hotkey_mgr,
|
||||
registered_hotkeys: Vec::new(),
|
||||
recording_shortcut: false,
|
||||
parsed_shortcuts: ParsedShortcuts::default(),
|
||||
hwnd,
|
||||
font_regular,
|
||||
@@ -903,6 +929,7 @@ pub fn run() {
|
||||
cmb_opacity: ptr::null_mut(),
|
||||
chk_ocr_newline: ptr::null_mut(),
|
||||
chk_autostart: ptr::null_mut(),
|
||||
chk_normal_window: ptr::null_mut(),
|
||||
updater: Default::default(),
|
||||
update_view: Default::default(),
|
||||
btn_update: ptr::null_mut(),
|
||||
@@ -914,8 +941,11 @@ pub fn run() {
|
||||
app_state.build_child_controls(hinstance);
|
||||
SendMessageW(hwnd, 0x0127, (1 << 16) | 1, 0); // UIS_SET | UISF_HIDEFOCUS; keyboard navigation restores it.
|
||||
let sc = app_state.settings.shortcuts();
|
||||
let _ = app_state.apply_shortcuts(&sc);
|
||||
let shortcut_result = app_state.apply_shortcuts(&sc);
|
||||
app_state.populate_settings_fields();
|
||||
if let Err(error) = shortcut_result {
|
||||
set_hwnd_text(app_state.lbl_settings_status, &error);
|
||||
}
|
||||
app_state.refresh_chat_controls();
|
||||
app_state.relayout_children();
|
||||
|
||||
@@ -925,12 +955,17 @@ pub fn run() {
|
||||
WIN_CTX.with(|cell| {
|
||||
*cell.borrow_mut() = Some(app_state);
|
||||
});
|
||||
if std::env::args().any(|a| a == "--show") {
|
||||
if show_on_launch {
|
||||
with_state(|st| st.show_and_focus());
|
||||
}
|
||||
|
||||
let mut msg: MSG = std::mem::zeroed();
|
||||
while GetMessageW(&mut msg, ptr::null_mut(), 0, 0) > 0 {
|
||||
with_state(|state| {
|
||||
state.set_shortcut_recording(
|
||||
shortcut_recorder::is_field(GetFocus()) && IsWindowVisible(GetFocus()) != 0,
|
||||
);
|
||||
});
|
||||
// Let text-input subclasses handle Return before dialog navigation consumes it.
|
||||
if (msg.message == WM_KEYDOWN || msg.message == WM_CHAR)
|
||||
&& (msg.wParam == 0x0d || msg.wParam == 0x1b)
|
||||
@@ -1507,6 +1542,14 @@ impl Win32AppState {
|
||||
ID_CHK_AUTOSTART,
|
||||
false,
|
||||
);
|
||||
self.chk_normal_window = self.create_ctrl(
|
||||
hinst,
|
||||
"BUTTON",
|
||||
"普通窗口模式",
|
||||
BS_AUTOCHECKBOX,
|
||||
ID_CHK_NORMAL_WINDOW,
|
||||
false,
|
||||
);
|
||||
self.btn_update = self.create_ctrl(
|
||||
hinst,
|
||||
"BUTTON",
|
||||
@@ -1670,6 +1713,7 @@ impl Win32AppState {
|
||||
self.cmb_opacity,
|
||||
self.chk_ocr_newline,
|
||||
self.chk_autostart,
|
||||
self.chk_normal_window,
|
||||
self.btn_update,
|
||||
self.lbl_update,
|
||||
]
|
||||
@@ -1693,6 +1737,42 @@ impl Win32AppState {
|
||||
)
|
||||
}
|
||||
|
||||
unsafe fn layout_source_input(&self, source: BoxRect, has_text: bool) {
|
||||
// Commit only the final width: widening then narrowing a populated edit
|
||||
// reformats its contents twice, even when the window layout is unchanged.
|
||||
place(
|
||||
self.edit_source,
|
||||
14,
|
||||
source.y + 6,
|
||||
source.w - if has_text { 76 } else { 28 },
|
||||
(source.h - 12).max(12),
|
||||
);
|
||||
if has_text {
|
||||
place(self.btn_clear_trans, source.w - 58, source.y + 4, 44, 24);
|
||||
}
|
||||
visible(self.btn_clear_trans, has_text);
|
||||
}
|
||||
|
||||
unsafe fn refresh_source_input(&self) {
|
||||
if self.active_tab == TabMode::Translate {
|
||||
let has_text = GetWindowTextLengthW(self.edit_source) > 0;
|
||||
if shown(self.btn_clear_trans) != has_text {
|
||||
let source = self.translation_layout().source;
|
||||
self.layout_source_input(source, has_text);
|
||||
let area = RECT {
|
||||
left: scale(source.x),
|
||||
top: scale(source.y),
|
||||
right: scale(source.x + source.w),
|
||||
bottom: scale(source.y + source.h),
|
||||
};
|
||||
InvalidateRect(self.hwnd, &area, 0);
|
||||
InvalidateRect(self.edit_source, ptr::null(), 0);
|
||||
}
|
||||
}
|
||||
// Text can add/remove wrapped lines without changing the edit's bounds.
|
||||
edit_geometry(self.edit_source);
|
||||
}
|
||||
|
||||
unsafe fn relayout_children(&self) {
|
||||
let _frame = LayoutFrame::new(self.hwnd, self.hwnd);
|
||||
let (w, h) = self.client_size();
|
||||
@@ -1777,13 +1857,7 @@ impl Win32AppState {
|
||||
let l = self.translation_layout();
|
||||
bg.surface(l.source, p.surface, 0);
|
||||
bg.surface(l.languages, p.surface, 0);
|
||||
place(
|
||||
self.edit_source,
|
||||
14,
|
||||
l.source.y + 6,
|
||||
w - 28,
|
||||
(l.source.h - 12).max(12),
|
||||
);
|
||||
self.layout_source_input(l.source, GetWindowTextLengthW(self.edit_source) > 0);
|
||||
show(self.edit_source, true);
|
||||
let ly = l.languages.y + (l.languages.h - 30) / 2;
|
||||
let left = if w < 430 { 100 } else { 114 };
|
||||
@@ -1796,22 +1870,6 @@ impl Win32AppState {
|
||||
show(self.cmb_target_lang, true);
|
||||
place(self.btn_trans_now, w - 60, ly + 2, 48, 26);
|
||||
show(self.btn_trans_now, true);
|
||||
show(
|
||||
self.btn_clear_trans,
|
||||
!get_hwnd_text(self.edit_source).is_empty(),
|
||||
);
|
||||
// Keep the source clear action in its own corner, never overlapping language controls.
|
||||
if !get_hwnd_text(self.edit_source).is_empty() {
|
||||
place(self.btn_clear_trans, w - 58, l.source.y + 4, 44, 24);
|
||||
show(self.btn_clear_trans, true);
|
||||
place(
|
||||
self.edit_source,
|
||||
14,
|
||||
l.source.y + 6,
|
||||
w - 76,
|
||||
(l.source.h - 12).max(12),
|
||||
);
|
||||
}
|
||||
bg.grip(l.languages.y, w, p.line);
|
||||
bg.grip(l.web.y, w, p.line);
|
||||
place(self.lbl_web_header, 16, l.web.y + 7, w - 90, 17);
|
||||
@@ -2014,6 +2072,7 @@ impl Win32AppState {
|
||||
self.cmb_opacity,
|
||||
self.chk_ocr_newline,
|
||||
self.chk_autostart,
|
||||
self.chk_normal_window,
|
||||
self.btn_update,
|
||||
self.lbl_update,
|
||||
],
|
||||
@@ -2046,9 +2105,9 @@ impl Win32AppState {
|
||||
ID_SETTINGS_KEYS => 82 + row_h * 5 + 32 + 90,
|
||||
_ => {
|
||||
if wide {
|
||||
490
|
||||
568
|
||||
} else {
|
||||
530
|
||||
608
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -2271,16 +2330,20 @@ impl Win32AppState {
|
||||
show(self.btn_hk_reset);
|
||||
bg.label(
|
||||
BoxRect::new(x, top + card_h + 58, fw, 20),
|
||||
"留空即可停用对应快捷键",
|
||||
"点击后按组合键;Backspace / Delete 清空,Esc 取消",
|
||||
true,
|
||||
true,
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
let opacity_h = if wide { 84 } else { 112 };
|
||||
bg.card(BoxRect::new(x, top, fw, opacity_h + 286), p);
|
||||
bg.card(BoxRect::new(x, top, fw, opacity_h + 364), p);
|
||||
form_row(self.lbl_opacity, self.cmb_opacity, top + 18, 32, &mut bg);
|
||||
for (i, (c, description)) in [
|
||||
(
|
||||
self.chk_normal_window,
|
||||
"显示在任务栏和 Alt+Tab 中,切走时不收起",
|
||||
),
|
||||
(self.chk_ocr_newline, "合并识别文本中的段落换行"),
|
||||
(self.chk_autostart, "登录 Windows 时自动启动"),
|
||||
]
|
||||
@@ -2292,13 +2355,18 @@ impl Win32AppState {
|
||||
place(c, x + inset, y + 15, fw - inset * 2, 26);
|
||||
show(c);
|
||||
bg.label(
|
||||
BoxRect::new(x + inset + 25, y + 44, fw - inset * 2 - 25, 18),
|
||||
BoxRect::new(
|
||||
x + inset + if c == self.chk_normal_window { 0 } else { 25 },
|
||||
y + 44,
|
||||
fw - inset * 2 - if c == self.chk_normal_window { 0 } else { 25 },
|
||||
18,
|
||||
),
|
||||
description,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
}
|
||||
let update_y = top + opacity_h + 168;
|
||||
let update_y = top + opacity_h + 246;
|
||||
place(self.btn_update, x + inset, update_y, 140, 32);
|
||||
place(
|
||||
self.lbl_update,
|
||||
@@ -2383,6 +2451,66 @@ impl Win32AppState {
|
||||
InvalidateRect(self.btn_pin, ptr::null(), 0);
|
||||
}
|
||||
|
||||
unsafe fn apply_normal_window_mode(&self, enabled: bool) {
|
||||
let current = GetWindowLongPtrW(self.hwnd, GWL_EXSTYLE) as u32;
|
||||
let style = (current & !(WS_EX_APPWINDOW | WS_EX_TOOLWINDOW))
|
||||
| if enabled {
|
||||
WS_EX_APPWINDOW
|
||||
} else {
|
||||
WS_EX_TOOLWINDOW
|
||||
};
|
||||
if current == style {
|
||||
return;
|
||||
}
|
||||
let visible = IsWindowVisible(self.hwnd) != 0;
|
||||
let focus = GetFocus();
|
||||
// The shell updates taskbar membership when a window is hidden and shown.
|
||||
// Do not use hide_window here: switching modes must keep active requests.
|
||||
if visible {
|
||||
ShowWindow(self.hwnd, SW_HIDE);
|
||||
}
|
||||
SetWindowLongPtrW(self.hwnd, GWL_EXSTYLE, style as isize);
|
||||
SetWindowPos(
|
||||
self.hwnd,
|
||||
ptr::null_mut(),
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED,
|
||||
);
|
||||
if visible {
|
||||
ShowWindow(self.hwnd, SW_SHOW);
|
||||
SetForegroundWindow(self.hwnd);
|
||||
if !focus.is_null() {
|
||||
SetFocus(focus);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn save_normal_window_mode(&self) {
|
||||
let mut cfg = self.settings.appearance();
|
||||
let previous = cfg.normal_window_mode;
|
||||
cfg.normal_window_mode =
|
||||
SendMessageW(self.chk_normal_window, BM_GETCHECK, 0, 0) as usize == BST_CHECKED;
|
||||
if let Err(error) = self.settings.save_appearance(cfg) {
|
||||
SendMessageW(
|
||||
self.chk_normal_window,
|
||||
BM_SETCHECK,
|
||||
if previous { BST_CHECKED } else { BST_UNCHECKED },
|
||||
0,
|
||||
);
|
||||
set_hwnd_text(
|
||||
self.lbl_settings_status,
|
||||
&format!("保存窗口模式失败: {error}"),
|
||||
);
|
||||
} else {
|
||||
self.apply_normal_window_mode(cfg.normal_window_mode);
|
||||
set_hwnd_text(self.lbl_settings_status, "窗口模式已保存");
|
||||
}
|
||||
InvalidateRect(self.chk_normal_window, ptr::null(), 0);
|
||||
}
|
||||
|
||||
unsafe fn show_and_focus(&mut self) {
|
||||
ShowWindow(self.hwnd, SW_RESTORE);
|
||||
ShowWindow(self.hwnd, SW_SHOW);
|
||||
@@ -2399,6 +2527,7 @@ impl Win32AppState {
|
||||
}
|
||||
|
||||
unsafe fn hide_window(&mut self) {
|
||||
self.set_shortcut_recording(false);
|
||||
self.web_cancel.cancel_current();
|
||||
self.ai_cancel.cancel_current();
|
||||
ShowWindow(self.hwnd, SW_HIDE);
|
||||
@@ -2406,19 +2535,35 @@ impl Win32AppState {
|
||||
|
||||
fn apply_shortcuts(&mut self, cfg: &ShortcutConfig) -> Result<(), String> {
|
||||
let parsed = parse_shortcut_config(cfg).map_err(|e| e.to_string())?;
|
||||
for hk in self.registered_hotkeys.drain(..) {
|
||||
let _ = self.hotkey_mgr.unregister(hk);
|
||||
}
|
||||
for hk in parsed.unique_hotkeys() {
|
||||
self.hotkey_mgr
|
||||
.register(hk)
|
||||
.map_err(|e| format!("注册全局快捷键失败: {e}"))?;
|
||||
self.registered_hotkeys.push(hk);
|
||||
if !self.recording_shortcut {
|
||||
register_shortcuts(&self.hotkey_mgr, &mut self.registered_hotkeys, &parsed)?;
|
||||
}
|
||||
self.parsed_shortcuts = parsed;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
unsafe fn set_shortcut_recording(&mut self, recording: bool) {
|
||||
if recording == self.recording_shortcut {
|
||||
return;
|
||||
}
|
||||
self.recording_shortcut = recording;
|
||||
// Discard pending presses so recording never opens a window or starts OCR.
|
||||
while GlobalHotKeyEvent::receiver().try_recv().is_ok() {}
|
||||
let desired = if recording {
|
||||
ParsedShortcuts::default()
|
||||
} else {
|
||||
self.parsed_shortcuts.clone()
|
||||
};
|
||||
match register_shortcuts(&self.hotkey_mgr, &mut self.registered_hotkeys, &desired) {
|
||||
Ok(()) if recording => set_hwnd_text(
|
||||
self.lbl_settings_status,
|
||||
"请按组合键;Backspace / Delete 清空,Esc 取消",
|
||||
),
|
||||
Ok(()) => set_hwnd_text(self.lbl_settings_status, "录入完成,保存设置后生效"),
|
||||
Err(error) => set_hwnd_text(self.lbl_settings_status, &error),
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn handle_hotkey_action(&mut self, action: ShortcutAction) {
|
||||
match action {
|
||||
ShortcutAction::Toggle => {
|
||||
@@ -2895,11 +3040,11 @@ impl Win32AppState {
|
||||
set_hwnd_text(self.edit_chat_key, &chat_cfg.api_key);
|
||||
set_hwnd_text(self.edit_chat_model, &chat_cfg.model);
|
||||
|
||||
set_hwnd_text(self.edit_hk_toggle, &hk_cfg.toggle);
|
||||
set_hwnd_text(self.edit_hk_ocr, &hk_cfg.ocr);
|
||||
set_hwnd_text(self.edit_hk_chat, &hk_cfg.ai_dialog);
|
||||
set_hwnd_text(self.edit_hk_sel_trans, &hk_cfg.selection_translate);
|
||||
set_hwnd_text(self.edit_hk_sel_chat, &hk_cfg.selection_ai_dialog);
|
||||
shortcut_recorder::set_value(self.edit_hk_toggle, &hk_cfg.toggle);
|
||||
shortcut_recorder::set_value(self.edit_hk_ocr, &hk_cfg.ocr);
|
||||
shortcut_recorder::set_value(self.edit_hk_chat, &hk_cfg.ai_dialog);
|
||||
shortcut_recorder::set_value(self.edit_hk_sel_trans, &hk_cfg.selection_translate);
|
||||
shortcut_recorder::set_value(self.edit_hk_sel_chat, &hk_cfg.selection_ai_dialog);
|
||||
|
||||
let op_idx = if app_cfg.panel_opacity >= 0.95 {
|
||||
0
|
||||
@@ -2916,6 +3061,16 @@ impl Win32AppState {
|
||||
};
|
||||
SendMessageW(self.cmb_opacity, CB_SETCURSEL, op_idx, 0);
|
||||
|
||||
SendMessageW(
|
||||
self.chk_normal_window,
|
||||
BM_SETCHECK,
|
||||
if app_cfg.normal_window_mode {
|
||||
BST_CHECKED
|
||||
} else {
|
||||
BST_UNCHECKED
|
||||
},
|
||||
0,
|
||||
);
|
||||
SendMessageW(
|
||||
self.chk_ocr_newline,
|
||||
BM_SETCHECK,
|
||||
@@ -2946,6 +3101,8 @@ impl Win32AppState {
|
||||
}
|
||||
|
||||
unsafe fn save_settings_from_ui(&mut self) {
|
||||
SetFocus(self.btn_save_settings);
|
||||
self.set_shortcut_recording(false);
|
||||
let ai_enabled =
|
||||
SendMessageW(self.chk_ai_enabled, BM_GETCHECK, 0, 0) as usize == BST_CHECKED;
|
||||
let b_idx = (SendMessageW(self.cmb_ai_backend, CB_GETCURSEL, 0, 0) as usize)
|
||||
@@ -2991,11 +3148,11 @@ impl Win32AppState {
|
||||
}
|
||||
|
||||
let hk_cfg = ShortcutConfig {
|
||||
toggle: get_hwnd_text(self.edit_hk_toggle),
|
||||
ocr: get_hwnd_text(self.edit_hk_ocr),
|
||||
ai_dialog: get_hwnd_text(self.edit_hk_chat),
|
||||
selection_translate: get_hwnd_text(self.edit_hk_sel_trans),
|
||||
selection_ai_dialog: get_hwnd_text(self.edit_hk_sel_chat),
|
||||
toggle: shortcut_recorder::value(self.edit_hk_toggle),
|
||||
ocr: shortcut_recorder::value(self.edit_hk_ocr),
|
||||
ai_dialog: shortcut_recorder::value(self.edit_hk_chat),
|
||||
selection_translate: shortcut_recorder::value(self.edit_hk_sel_trans),
|
||||
selection_ai_dialog: shortcut_recorder::value(self.edit_hk_sel_chat),
|
||||
};
|
||||
if let Err(e) = self.apply_shortcuts(&hk_cfg) {
|
||||
set_hwnd_text(self.lbl_settings_status, &e);
|
||||
@@ -3020,8 +3177,15 @@ impl Win32AppState {
|
||||
let app_cfg = AppearanceConfig {
|
||||
panel_opacity: opacity,
|
||||
ocr_strip_newlines: ocr_strip,
|
||||
normal_window_mode: self.settings.appearance().normal_window_mode,
|
||||
};
|
||||
let _ = self.settings.save_appearance(app_cfg);
|
||||
if let Err(error) = self.settings.save_appearance(app_cfg) {
|
||||
set_hwnd_text(
|
||||
self.lbl_settings_status,
|
||||
&format!("保存通用设置失败: {error}"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
let alpha = (opacity * 255.0).round() as u8;
|
||||
SetLayeredWindowAttributes(self.hwnd, 0, alpha, LWA_ALPHA);
|
||||
|
||||
@@ -3257,7 +3421,7 @@ impl Win32AppState {
|
||||
self.update_view = view;
|
||||
}
|
||||
while let Ok(hk_ev) = GlobalHotKeyEvent::receiver().try_recv() {
|
||||
if hk_ev.state == HotKeyState::Pressed {
|
||||
if !self.recording_shortcut && hk_ev.state == HotKeyState::Pressed {
|
||||
if let Some(action) = self.parsed_shortcuts.action_for_id(hk_ev.id) {
|
||||
self.handle_hotkey_action(action);
|
||||
}
|
||||
@@ -3394,7 +3558,12 @@ unsafe extern "system" fn main_wnd_proc(
|
||||
let active_state = wparam & 0xFFFF;
|
||||
if active_state == WA_INACTIVE {
|
||||
with_state(|st| {
|
||||
if !st.pinned && !st.ocr_in_flight && st.active_tab != TabMode::Settings {
|
||||
st.set_shortcut_recording(false);
|
||||
if !st.pinned
|
||||
&& !st.settings.appearance().normal_window_mode
|
||||
&& !st.ocr_in_flight
|
||||
&& st.active_tab != TabMode::Settings
|
||||
{
|
||||
st.hide_window();
|
||||
}
|
||||
});
|
||||
@@ -3552,11 +3721,12 @@ unsafe fn handle_wm_command(st: &mut Win32AppState, id: usize, code: u16) {
|
||||
}
|
||||
ID_BTN_OCR if code == BN_CLICKED => st.trigger_ocr(),
|
||||
ID_BTN_PIN if code == BN_CLICKED => st.set_pinned(!st.pinned),
|
||||
ID_CHK_NORMAL_WINDOW if code == BN_CLICKED => st.save_normal_window_mode(),
|
||||
|
||||
ID_EDIT_SOURCE if code == EN_CHANGE => {
|
||||
st.refresh_source_input();
|
||||
if !st.suppress_source_change {
|
||||
st.pending_auto_translate = Some(Instant::now() + AUTO_TRANSLATE_DEBOUNCE);
|
||||
st.relayout_children();
|
||||
}
|
||||
}
|
||||
ID_CMB_SOURCE_LANG if code == CBN_SELCHANGE => {
|
||||
@@ -3684,11 +3854,11 @@ unsafe fn handle_wm_command(st: &mut Win32AppState, id: usize, code: u16) {
|
||||
}
|
||||
ID_BTN_HK_RESET if code == BN_CLICKED => {
|
||||
let def = ShortcutConfig::default();
|
||||
set_hwnd_text(st.edit_hk_toggle, &def.toggle);
|
||||
set_hwnd_text(st.edit_hk_ocr, &def.ocr);
|
||||
set_hwnd_text(st.edit_hk_chat, &def.ai_dialog);
|
||||
set_hwnd_text(st.edit_hk_sel_trans, &def.selection_translate);
|
||||
set_hwnd_text(st.edit_hk_sel_chat, &def.selection_ai_dialog);
|
||||
shortcut_recorder::set_value(st.edit_hk_toggle, &def.toggle);
|
||||
shortcut_recorder::set_value(st.edit_hk_ocr, &def.ocr);
|
||||
shortcut_recorder::set_value(st.edit_hk_chat, &def.ai_dialog);
|
||||
shortcut_recorder::set_value(st.edit_hk_sel_trans, &def.selection_translate);
|
||||
shortcut_recorder::set_value(st.edit_hk_sel_chat, &def.selection_ai_dialog);
|
||||
}
|
||||
ID_BTN_SAVE_SETTINGS if code == BN_CLICKED => st.save_settings_from_ui(),
|
||||
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
//! Read-only native edits that record key combinations, never shortcut syntax.
|
||||
use super::*;
|
||||
use global_hotkey::hotkey::{Code, Modifiers};
|
||||
use std::collections::HashMap;
|
||||
use ttree_platform::shortcut_recorder::{CaptureResult, ShortcutRecorder};
|
||||
|
||||
thread_local! {
|
||||
static RECORDERS: RefCell<HashMap<usize, ShortcutRecorder>> = RefCell::new(HashMap::new());
|
||||
}
|
||||
|
||||
#[link(name = "user32")]
|
||||
extern "system" {
|
||||
fn GetDlgCtrlID(hwnd: HWND) -> i32;
|
||||
fn GetKeyState(key: i32) -> i16;
|
||||
fn GetAncestor(hwnd: HWND, flags: u32) -> HWND;
|
||||
fn GetDlgItem(parent: HWND, id: i32) -> HWND;
|
||||
}
|
||||
|
||||
pub(super) unsafe fn is_field(hwnd: HWND) -> bool {
|
||||
!hwnd.is_null()
|
||||
&& matches!(
|
||||
GetDlgCtrlID(hwnd) as usize,
|
||||
ID_EDIT_HK_TOGGLE
|
||||
| ID_EDIT_HK_OCR
|
||||
| ID_EDIT_HK_CHAT
|
||||
| ID_EDIT_HK_SEL_TRANS
|
||||
| ID_EDIT_HK_SEL_CHAT
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) unsafe fn init(hwnd: HWND) {
|
||||
RECORDERS.with(|recorders| {
|
||||
recorders
|
||||
.borrow_mut()
|
||||
.insert(hwnd as usize, ShortcutRecorder::default())
|
||||
});
|
||||
SendMessageW(hwnd, 0x00cf, 1, 0); // EM_SETREADONLY: blocks IME and context-menu edits too.
|
||||
SendMessageW(
|
||||
hwnd,
|
||||
0x1501,
|
||||
1,
|
||||
wide_null("点击后按组合键").as_ptr() as LPARAM,
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) unsafe fn set_value(hwnd: HWND, spec: &str) {
|
||||
let display = RECORDERS.with(|recorders| {
|
||||
let mut recorders = recorders.borrow_mut();
|
||||
let recorder = recorders.entry(hwnd as usize).or_default();
|
||||
recorder.set(spec);
|
||||
recorder.display()
|
||||
});
|
||||
set_hwnd_text(hwnd, &display);
|
||||
}
|
||||
|
||||
pub(super) fn value(hwnd: HWND) -> String {
|
||||
RECORDERS.with(|recorders| {
|
||||
recorders
|
||||
.borrow()
|
||||
.get(&(hwnd as usize))
|
||||
.map(|recorder| recorder.value().to_owned())
|
||||
.unwrap_or_default()
|
||||
})
|
||||
}
|
||||
|
||||
unsafe fn modifiers() -> Modifiers {
|
||||
let mut mods = Modifiers::empty();
|
||||
for (vk, modifier) in [
|
||||
(0x11, Modifiers::CONTROL),
|
||||
(0x12, Modifiers::ALT),
|
||||
(0x10, Modifiers::SHIFT),
|
||||
] {
|
||||
if GetKeyState(vk) < 0 {
|
||||
mods |= modifier;
|
||||
}
|
||||
}
|
||||
if GetKeyState(0x5b) < 0 || GetKeyState(0x5c) < 0 {
|
||||
mods |= Modifiers::SUPER;
|
||||
}
|
||||
mods
|
||||
}
|
||||
|
||||
fn key(vk: usize) -> Option<Code> {
|
||||
let token = match vk {
|
||||
0x30..=0x39 | 0x41..=0x5a => char::from_u32(vk as u32)?.to_string(),
|
||||
0x70..=0x87 => format!("F{}", vk - 0x6f),
|
||||
0x60..=0x69 => format!("Numpad{}", vk - 0x60),
|
||||
_ => match vk {
|
||||
0x08 => "Backspace",
|
||||
0x09 => "Tab",
|
||||
0x0d => "Enter",
|
||||
0x1b => "Escape",
|
||||
0x20 => "Space",
|
||||
0x21 => "PageUp",
|
||||
0x22 => "PageDown",
|
||||
0x23 => "End",
|
||||
0x24 => "Home",
|
||||
0x25 => "Left",
|
||||
0x26 => "Up",
|
||||
0x27 => "Right",
|
||||
0x28 => "Down",
|
||||
0x2c => "PrintScreen",
|
||||
0x2d => "Insert",
|
||||
0x2e => "Delete",
|
||||
0x6a => "NumpadMultiply",
|
||||
0x6b => "NumpadAdd",
|
||||
0x6d => "NumpadSubtract",
|
||||
0x6e => "NumpadDecimal",
|
||||
0x6f => "NumpadDivide",
|
||||
0xba => "Semicolon",
|
||||
0xbb => "Equal",
|
||||
0xbc => "Comma",
|
||||
0xbd => "Minus",
|
||||
0xbe => "Period",
|
||||
0xbf => "Slash",
|
||||
0xc0 => "Backquote",
|
||||
0xdb => "BracketLeft",
|
||||
0xdc => "Backslash",
|
||||
0xdd => "BracketRight",
|
||||
0xde => "Quote",
|
||||
_ => return None,
|
||||
}
|
||||
.to_owned(),
|
||||
};
|
||||
token.parse::<HotKey>().ok().map(|hotkey| hotkey.key)
|
||||
}
|
||||
|
||||
pub(super) unsafe fn handle(hwnd: HWND, msg: u32, w: WPARAM, _l: LPARAM) -> Option<LRESULT> {
|
||||
if !is_field(hwnd) {
|
||||
return None;
|
||||
}
|
||||
match msg {
|
||||
WM_SETFOCUS => {
|
||||
RECORDERS.with(|recorders| {
|
||||
recorders
|
||||
.borrow_mut()
|
||||
.entry(hwnd as usize)
|
||||
.or_default()
|
||||
.begin();
|
||||
});
|
||||
with_state(|state| state.set_shortcut_recording(true));
|
||||
SendMessageW(hwnd, EM_SETSEL, 0, -1);
|
||||
}
|
||||
WM_KILLFOCUS => {
|
||||
if !is_field(w as HWND) {
|
||||
with_state(|state| state.set_shortcut_recording(false));
|
||||
}
|
||||
}
|
||||
0x0087 => return Some(4), // WM_GETDLGCODE: DLGC_WANTALLKEYS
|
||||
WM_KEYDOWN | 0x0104 => {
|
||||
// WM_SYSKEYDOWN includes Alt combinations.
|
||||
if let Some(key) = key(w) {
|
||||
let (result, display) = RECORDERS.with(|recorders| {
|
||||
let mut recorders = recorders.borrow_mut();
|
||||
let recorder = recorders.entry(hwnd as usize).or_default();
|
||||
let result = recorder.capture(key, modifiers());
|
||||
(result, recorder.display())
|
||||
});
|
||||
match result {
|
||||
CaptureResult::Changed => {
|
||||
set_hwnd_text(hwnd, &display);
|
||||
SendMessageW(hwnd, EM_SETSEL, 0, -1);
|
||||
}
|
||||
CaptureResult::Cancelled => {
|
||||
set_hwnd_text(hwnd, &display);
|
||||
SetFocus(GetAncestor(hwnd, 2)); // GA_ROOT; Esc leaves settings open.
|
||||
}
|
||||
CaptureResult::Navigate { backwards } => {
|
||||
// Layout changes the child Z-order, so follow the visible field order.
|
||||
let fields = [
|
||||
ID_EDIT_HK_TOGGLE,
|
||||
ID_EDIT_HK_OCR,
|
||||
ID_EDIT_HK_CHAT,
|
||||
ID_EDIT_HK_SEL_TRANS,
|
||||
ID_EDIT_HK_SEL_CHAT,
|
||||
ID_BTN_HK_RESET,
|
||||
];
|
||||
let index = fields
|
||||
.iter()
|
||||
.position(|&id| id == GetDlgCtrlID(hwnd) as usize)
|
||||
.unwrap_or(0);
|
||||
let offset = if backwards { fields.len() - 1 } else { 1 };
|
||||
let next = GetDlgItem(
|
||||
GetParent(hwnd),
|
||||
fields[(index + offset) % fields.len()] as i32,
|
||||
);
|
||||
if !next.is_null() {
|
||||
SetFocus(next);
|
||||
}
|
||||
}
|
||||
CaptureResult::Ignored => {}
|
||||
}
|
||||
}
|
||||
return Some(0);
|
||||
}
|
||||
WM_CHAR | 0x0106 | 0x0300..=0x0304 | 0x007b | 0x010d..=0x010f => return Some(0),
|
||||
0x0082 => {
|
||||
RECORDERS.with(|recorders| recorders.borrow_mut().remove(&(hwnd as usize)));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn maps_physical_keys_and_leaves_modifiers_unrecorded() {
|
||||
assert_eq!(key(0x53), Some(Code::KeyS));
|
||||
assert_eq!(key(0x27), Some(Code::ArrowRight));
|
||||
assert_eq!(key(0x87), Some(Code::F24));
|
||||
assert_eq!(key(0x6b), Some(Code::NumpadAdd));
|
||||
assert_eq!(key(0xbb), Some(Code::Equal));
|
||||
assert_eq!(key(0x11), None);
|
||||
assert_eq!(key(0x5b), None);
|
||||
}
|
||||
}
|
||||
+153
-23
@@ -50,6 +50,7 @@ const DT_CALCRECT: u32 = 0x400;
|
||||
const DT_NOPREFIX: u32 = 0x800;
|
||||
const DT_END_ELLIPSIS: u32 = 0x8000;
|
||||
const SRCCOPY: u32 = 0x00cc0020;
|
||||
const HALFTONE: i32 = 4;
|
||||
|
||||
#[repr(C)]
|
||||
struct PAINTSTRUCT {
|
||||
@@ -110,10 +111,11 @@ extern "system" {
|
||||
pub fn GetDpiForSystem() -> u32;
|
||||
pub fn GetWindowRect(hwnd: HWND, rect: *mut RECT) -> BOOL;
|
||||
fn GetDlgCtrlID(hwnd: HWND) -> i32;
|
||||
fn GetWindowLongPtrW(hwnd: HWND, index: i32) -> isize;
|
||||
pub fn GetWindowLongPtrW(hwnd: HWND, index: i32) -> isize;
|
||||
pub fn SetWindowLongPtrW(hwnd: HWND, index: i32, value: isize) -> isize;
|
||||
fn SetViewportOrgEx(hdc: HDC, x: i32, y: i32, old: *mut POINT) -> BOOL;
|
||||
fn RedrawWindow(hwnd: HWND, rect: *const RECT, region: HANDLE, flags: u32) -> BOOL;
|
||||
fn GetFocus() -> HWND;
|
||||
pub fn GetFocus() -> HWND;
|
||||
fn GetKeyState(key: i32) -> i16;
|
||||
pub fn GetParent(hwnd: HWND) -> HWND;
|
||||
pub fn SetParent(hwnd: HWND, parent: HWND) -> HWND;
|
||||
@@ -158,6 +160,20 @@ extern "system" {
|
||||
sy: i32,
|
||||
rop: u32,
|
||||
) -> BOOL;
|
||||
fn StretchBlt(
|
||||
dest: HDC,
|
||||
x: i32,
|
||||
y: i32,
|
||||
w: i32,
|
||||
h: i32,
|
||||
src: HDC,
|
||||
sx: i32,
|
||||
sy: i32,
|
||||
sw: i32,
|
||||
sh: i32,
|
||||
rop: u32,
|
||||
) -> BOOL;
|
||||
fn SetStretchBltMode(hdc: HDC, mode: i32) -> i32;
|
||||
}
|
||||
#[link(name = "comctl32")]
|
||||
extern "system" {
|
||||
@@ -639,15 +655,17 @@ impl Drop for LayoutFrame {
|
||||
}
|
||||
}
|
||||
}
|
||||
pub unsafe fn visible(hwnd: HWND, show: bool) {
|
||||
pub unsafe fn shown(hwnd: HWND) -> bool {
|
||||
// WM_SETREDRAW temporarily removes WS_VISIBLE from the root. Inspect each
|
||||
// child's own flag rather than IsWindowVisible's inherited visibility.
|
||||
let current = (GetWindowLongPtrW(hwnd, -16) as u32 & WS_VISIBLE) != 0;
|
||||
if current != show {
|
||||
(GetWindowLongPtrW(hwnd, -16) as u32 & WS_VISIBLE) != 0
|
||||
}
|
||||
pub unsafe fn visible(hwnd: HWND, show: bool) {
|
||||
if shown(hwnd) != show {
|
||||
ShowWindow(hwnd, if show { SW_SHOW } else { SW_HIDE });
|
||||
}
|
||||
}
|
||||
unsafe fn edit_geometry(hwnd: HWND) {
|
||||
pub unsafe fn edit_geometry(hwnd: HWND) {
|
||||
let id = GetDlgCtrlID(hwnd) as usize;
|
||||
if !matches!(
|
||||
id,
|
||||
@@ -743,6 +761,9 @@ pub unsafe fn init_control(hwnd: HWND, class: &str, id: usize) {
|
||||
if class == "COMBOBOX" { 1 } else { 0 },
|
||||
);
|
||||
theme_control(hwnd);
|
||||
if super::shortcut_recorder::is_field(hwnd) {
|
||||
super::shortcut_recorder::init(hwnd);
|
||||
}
|
||||
if class == "COMBOBOX" {
|
||||
SendMessageW(hwnd, CB_SETITEMHEIGHT, usize::MAX, scale(24) as LPARAM);
|
||||
SendMessageW(hwnd, CB_SETITEMHEIGHT, 0, scale(26) as LPARAM);
|
||||
@@ -792,28 +813,95 @@ fn px(r: BoxRect) -> RECT {
|
||||
}
|
||||
}
|
||||
unsafe fn fill(hdc: HDC, r: RECT, color: u32, radius: i32) {
|
||||
let brush = CreateSolidBrush(color);
|
||||
if radius == 0 {
|
||||
let brush = CreateSolidBrush(color);
|
||||
FillRect(hdc, &r, brush);
|
||||
DeleteObject(brush);
|
||||
} else {
|
||||
let pen = CreatePen(0, 1, color);
|
||||
let old_b = SelectObject(hdc, brush);
|
||||
let old_p = SelectObject(hdc, pen);
|
||||
RoundRect(
|
||||
hdc,
|
||||
r.left,
|
||||
r.top,
|
||||
r.right,
|
||||
r.bottom,
|
||||
scale(radius * 2),
|
||||
scale(radius * 2),
|
||||
);
|
||||
SelectObject(hdc, old_b);
|
||||
SelectObject(hdc, old_p);
|
||||
DeleteObject(pen);
|
||||
rounded(hdc, r, color, scale(radius * 2));
|
||||
}
|
||||
}
|
||||
/// Solid rounded rectangle at the caller's scale. The pen matches the brush so
|
||||
/// the outline does not introduce a second color. GDI rounds corners without
|
||||
/// anti-aliasing, so pass a supersampled rect for smooth edges.
|
||||
unsafe fn rounded(hdc: HDC, r: RECT, color: u32, diameter: i32) {
|
||||
let brush = CreateSolidBrush(color);
|
||||
let pen = CreatePen(0, 1, color);
|
||||
let old_b = SelectObject(hdc, brush);
|
||||
let old_p = SelectObject(hdc, pen);
|
||||
RoundRect(hdc, r.left, r.top, r.right, r.bottom, diameter, diameter);
|
||||
SelectObject(hdc, old_b);
|
||||
SelectObject(hdc, old_p);
|
||||
DeleteObject(pen);
|
||||
DeleteObject(brush);
|
||||
}
|
||||
/// Draws the settings switch. GDI's `RoundRect` is not anti-aliased, and at 22
|
||||
/// logical pixels tall the staircase on the pill and on the knob reads as a
|
||||
/// low-resolution control. Both shapes are rasterized together at 4x and
|
||||
/// halftone downscaled. They share one offscreen pass on purpose: downscaling
|
||||
/// them separately would blend the knob's edge with the card instead of the
|
||||
/// track and leave a halo.
|
||||
unsafe fn draw_switch(
|
||||
hdc: HDC,
|
||||
track: RECT,
|
||||
thumb: RECT,
|
||||
track_color: u32,
|
||||
thumb_color: u32,
|
||||
background: u32,
|
||||
) {
|
||||
const FACTOR: i32 = 4;
|
||||
let width = (track.right - track.left).max(1);
|
||||
let height = (track.bottom - track.top).max(1);
|
||||
let dc = CreateCompatibleDC(hdc);
|
||||
let bitmap = CreateCompatibleBitmap(hdc, width * FACTOR, height * FACTOR);
|
||||
if dc.is_null() || bitmap.is_null() {
|
||||
if !dc.is_null() {
|
||||
DeleteDC(dc);
|
||||
}
|
||||
if !bitmap.is_null() {
|
||||
DeleteObject(bitmap);
|
||||
}
|
||||
fill(hdc, track, track_color, height / 2);
|
||||
fill(hdc, thumb, thumb_color, (thumb.bottom - thumb.top) / 2);
|
||||
return;
|
||||
}
|
||||
let old_bitmap = SelectObject(dc, bitmap);
|
||||
let area = RECT {
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: width * FACTOR,
|
||||
bottom: height * FACTOR,
|
||||
};
|
||||
let backdrop = CreateSolidBrush(background);
|
||||
FillRect(dc, &area, backdrop);
|
||||
DeleteObject(backdrop);
|
||||
rounded(dc, area, track_color, height * FACTOR);
|
||||
let knob = RECT {
|
||||
left: (thumb.left - track.left) * FACTOR,
|
||||
top: (thumb.top - track.top) * FACTOR,
|
||||
right: (thumb.right - track.left) * FACTOR,
|
||||
bottom: (thumb.bottom - track.top) * FACTOR,
|
||||
};
|
||||
rounded(dc, knob, thumb_color, (thumb.bottom - thumb.top) * FACTOR);
|
||||
let mode = SetStretchBltMode(hdc, HALFTONE);
|
||||
StretchBlt(
|
||||
hdc,
|
||||
track.left,
|
||||
track.top,
|
||||
width,
|
||||
height,
|
||||
dc,
|
||||
0,
|
||||
0,
|
||||
width * FACTOR,
|
||||
height * FACTOR,
|
||||
SRCCOPY,
|
||||
);
|
||||
SetStretchBltMode(hdc, mode);
|
||||
SelectObject(dc, old_bitmap);
|
||||
DeleteObject(bitmap);
|
||||
DeleteDC(dc);
|
||||
}
|
||||
unsafe fn text(hdc: HDC, s: &str, r: RECT, font: HFONT, color: u32, flags: u32) {
|
||||
let old = SelectObject(hdc, font);
|
||||
SetBkMode(hdc, TRANSPARENT);
|
||||
@@ -1286,6 +1374,45 @@ unsafe fn draw_checkbox(hwnd: HWND, hdc: HDC) {
|
||||
fill(hdc, r, background, 0);
|
||||
let center = (r.top + r.bottom) / 2;
|
||||
let check = SendMessageW(hwnd, BM_GETCHECK, 0, 0) as usize == BST_CHECKED;
|
||||
if GetDlgCtrlID(hwnd) as usize == ID_CHK_NORMAL_WINDOW {
|
||||
let track = RECT {
|
||||
left: r.right - scale(40),
|
||||
top: center - scale(11),
|
||||
right: r.right,
|
||||
bottom: center + scale(11),
|
||||
};
|
||||
let thumb_left = track.left + scale(if check { 20 } else { 2 });
|
||||
let thumb = RECT {
|
||||
left: thumb_left,
|
||||
top: track.top + scale(2),
|
||||
right: thumb_left + scale(18),
|
||||
bottom: track.bottom - scale(2),
|
||||
};
|
||||
// `p.line` is the card border and `p.pill` is the card fill, so an off
|
||||
// track drawn with them disappears into the card it sits on.
|
||||
draw_switch(
|
||||
hdc,
|
||||
track,
|
||||
thumb,
|
||||
if check { p.ink } else { p.faint },
|
||||
p.pill,
|
||||
background,
|
||||
);
|
||||
r.right = track.left - scale(12);
|
||||
let f = VISUALS.with(|v| v.borrow().regular);
|
||||
text(
|
||||
hdc,
|
||||
&get_hwnd_text(hwnd),
|
||||
r,
|
||||
f,
|
||||
p.ink,
|
||||
DT_VCENTER | DT_SINGLELINE | DT_END_ELLIPSIS,
|
||||
);
|
||||
if GetFocus() == hwnd {
|
||||
DrawFocusRect(hdc, &r);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let box_r = RECT {
|
||||
left: scale(1),
|
||||
top: center - scale(7),
|
||||
@@ -1371,9 +1498,12 @@ unsafe extern "system" fn control_proc(
|
||||
_id: usize,
|
||||
combo: usize,
|
||||
) -> LRESULT {
|
||||
if let Some(result) = super::shortcut_recorder::handle(hwnd, msg, w, l) {
|
||||
return result;
|
||||
}
|
||||
let checkbox = matches!(
|
||||
GetDlgCtrlID(hwnd) as usize,
|
||||
ID_CHK_AI_ENABLED | ID_CHK_OCR_NEWLINE | ID_CHK_AUTOSTART
|
||||
ID_CHK_AI_ENABLED | ID_CHK_OCR_NEWLINE | ID_CHK_AUTOSTART | ID_CHK_NORMAL_WINDOW
|
||||
);
|
||||
let interactive =
|
||||
checkbox || combo != 0 || GetWindowLongPtrW(hwnd, -16) as u32 & 0xf == BS_OWNERDRAW;
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
mod app;
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
mod design;
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
mod startup;
|
||||
#[cfg(target_os = "windows")]
|
||||
fn main() {
|
||||
#[cfg(feature = "updater-smoke")]
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
//! Manual launches open the window; background launches reuse the same instance.
|
||||
|
||||
pub const WINDOW_CLASS: &str = "TTreePureRustWin32MainClass";
|
||||
|
||||
pub fn should_show(args: impl IntoIterator<Item = impl AsRef<std::ffi::OsStr>>) -> bool {
|
||||
let mut hidden = false;
|
||||
for arg in args {
|
||||
if arg.as_ref() == "--show" {
|
||||
return true;
|
||||
}
|
||||
hidden |= arg.as_ref() == "--hidden";
|
||||
}
|
||||
!hidden
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub use instance::{acquire, report_error};
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
mod instance {
|
||||
use std::{ffi::c_void, io, ptr, thread, time::Duration};
|
||||
|
||||
type Handle = *mut c_void;
|
||||
const ERROR_ALREADY_EXISTS: u32 = 183;
|
||||
|
||||
#[link(name = "kernel32")]
|
||||
extern "system" {
|
||||
fn CreateMutexW(attributes: *const c_void, owner: i32, name: *const u16) -> Handle;
|
||||
fn GetLastError() -> u32;
|
||||
fn CloseHandle(handle: Handle) -> i32;
|
||||
}
|
||||
|
||||
#[link(name = "user32")]
|
||||
extern "system" {
|
||||
fn FindWindowW(class: *const u16, title: *const u16) -> Handle;
|
||||
fn GetWindowThreadProcessId(hwnd: Handle, pid: *mut u32) -> u32;
|
||||
fn AllowSetForegroundWindow(pid: u32) -> i32;
|
||||
fn PostMessageW(hwnd: Handle, message: u32, wparam: usize, lparam: isize) -> i32;
|
||||
fn MessageBoxW(hwnd: Handle, text: *const u16, title: *const u16, flags: u32) -> i32;
|
||||
}
|
||||
|
||||
pub struct Instance(Handle);
|
||||
|
||||
impl Drop for Instance {
|
||||
fn drop(&mut self) {
|
||||
unsafe { CloseHandle(self.0) };
|
||||
}
|
||||
}
|
||||
|
||||
fn wide(text: &str) -> Vec<u16> {
|
||||
text.encode_utf16().chain(Some(0)).collect()
|
||||
}
|
||||
|
||||
fn forward(show: bool) -> io::Result<bool> {
|
||||
let class = wide(super::WINDOW_CLASS);
|
||||
unsafe {
|
||||
let hwnd = FindWindowW(class.as_ptr(), ptr::null());
|
||||
if hwnd.is_null() {
|
||||
return Ok(false);
|
||||
}
|
||||
if show {
|
||||
let mut pid = 0;
|
||||
GetWindowThreadProcessId(hwnd, &mut pid);
|
||||
AllowSetForegroundWindow(pid);
|
||||
// Reuse the tray's translation action, including for older running builds.
|
||||
// Posting waits for startup to finish before the main message loop handles it.
|
||||
if PostMessageW(hwnd, 0x0111, 901, 0) == 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn acquire(show: bool) -> io::Result<Option<Instance>> {
|
||||
// Older builds have no mutex, but can still receive the tray action.
|
||||
if forward(show)? {
|
||||
return Ok(None);
|
||||
}
|
||||
let name = wide("Local\\com.samwstu.ttree.windows");
|
||||
let (handle, error) = unsafe {
|
||||
let handle = CreateMutexW(ptr::null(), 0, name.as_ptr());
|
||||
(handle, GetLastError())
|
||||
};
|
||||
if handle.is_null() {
|
||||
return Err(io::Error::from_raw_os_error(error as i32));
|
||||
}
|
||||
let instance = Instance(handle);
|
||||
if error != ERROR_ALREADY_EXISTS {
|
||||
return Ok(Some(instance));
|
||||
}
|
||||
if !show {
|
||||
return Ok(None);
|
||||
}
|
||||
// A simultaneous launch may still be loading settings and creating its window.
|
||||
for _ in 0..100 {
|
||||
if forward(show)? {
|
||||
return Ok(None);
|
||||
}
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::TimedOut,
|
||||
"已有 TTREE 实例尚未响应,请稍后重试。",
|
||||
))
|
||||
}
|
||||
|
||||
pub fn report_error(error: &io::Error) {
|
||||
unsafe {
|
||||
MessageBoxW(
|
||||
ptr::null_mut(),
|
||||
wide(&format!("启动 TTREE 失败:{error}")).as_ptr(),
|
||||
wide("TTREE").as_ptr(),
|
||||
0x10,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn manual_launch_opens_window() {
|
||||
assert!(should_show(["TTREE.exe"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_launch_stays_hidden() {
|
||||
assert!(!should_show(["TTREE.exe", "--hidden"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_show_overrides_background_in_either_order() {
|
||||
assert!(should_show(["TTREE.exe", "--hidden", "--show"]));
|
||||
assert!(should_show(["TTREE.exe", "--show", "--hidden"]));
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Reproduce hover flicker and scroll ghosts using naturally presented screen pixels.
|
||||
"""Reproduce input/hover flicker and scroll ghosts on the native desktop.
|
||||
|
||||
Run on an unlocked Windows desktop, for example:
|
||||
python apps/windows/tests/repaint-regression.py --exe target/release/ttree-windows.exe --artifacts target/repaint-regression
|
||||
@@ -28,6 +28,13 @@ parser.add_argument(
|
||||
type=Path,
|
||||
help="Directory for screen samples and JSON result",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--input-only", action="store_true", help="Only check input repaint and clear behavior"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--window-class", default="TTreePureRustWin32MainClass",
|
||||
help="Native window class (override for an isolated test build)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
exe = args.exe.resolve()
|
||||
out = args.artifacts.resolve()
|
||||
@@ -41,6 +48,8 @@ dwm = ctypes.windll.dwmapi
|
||||
u.SetProcessDpiAwarenessContext(ctypes.c_void_p(-4))
|
||||
u.SendMessageW.argtypes = [wt.HWND, wt.UINT, wt.WPARAM, wt.LPARAM]
|
||||
u.SendMessageW.restype = ctypes.c_ssize_t
|
||||
u.SetWinEventHook.restype = wt.HANDLE
|
||||
u.UnhookWinEvent.argtypes = [wt.HANDLE]
|
||||
|
||||
|
||||
class RECT(ctypes.Structure):
|
||||
@@ -80,12 +89,12 @@ profile = Path(os.environ["APPDATA"]) / "com.samwstu.ttree/settings.json"
|
||||
before = profile.read_bytes() if profile.exists() else None
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
[str(exe)],
|
||||
[str(exe), "--hidden"],
|
||||
stdout=(out / "stdout.log").open("w"),
|
||||
stderr=(out / "stderr.log").open("w"),
|
||||
)
|
||||
app = Application(backend="win32").connect(process=proc.pid, timeout=15)
|
||||
win = app.window(class_name="TTreePureRustWin32MainClass", visible_only=False)
|
||||
win = app.window(class_name=args.window_class, visible_only=False)
|
||||
win.wait("exists", timeout=10)
|
||||
root_hwnd = win.handle
|
||||
for _ in range(6):
|
||||
@@ -105,6 +114,116 @@ try:
|
||||
r = win.rectangle()
|
||||
return ImageGrab.grab(bbox=(r.left, r.top, r.right, r.bottom))
|
||||
|
||||
# Observe actual Win32 geometry notifications, including intermediate sizes
|
||||
# that a screenshot taken after SendMessage returns would miss.
|
||||
source = c(201)
|
||||
source.set_focus()
|
||||
source.set_edit_text("输入")
|
||||
geometry_events = []
|
||||
event_callback_type = ctypes.WINFUNCTYPE(
|
||||
None, wt.HANDLE, wt.DWORD, wt.HWND, wt.LONG, wt.LONG, wt.DWORD, wt.DWORD
|
||||
)
|
||||
|
||||
@event_callback_type
|
||||
def on_geometry_event(hook, event, hwnd, object_id, child_id, thread, stamp):
|
||||
if hwnd == source.handle and object_id == 0:
|
||||
geometry_events.append(event)
|
||||
|
||||
u.SetWinEventHook.argtypes = [
|
||||
wt.DWORD, wt.DWORD, wt.HMODULE, event_callback_type,
|
||||
wt.DWORD, wt.DWORD, wt.DWORD,
|
||||
]
|
||||
hook = u.SetWinEventHook(
|
||||
0x800B, 0x800B, None, on_geometry_event, proc.pid, 0, 0
|
||||
)
|
||||
assert hook, "could not observe native source input geometry"
|
||||
|
||||
def pump_events():
|
||||
message = wt.MSG()
|
||||
while u.PeekMessageW(ctypes.byref(message), None, 0, 0, 1):
|
||||
u.TranslateMessage(ctypes.byref(message))
|
||||
u.DispatchMessageW(ctypes.byref(message))
|
||||
|
||||
try:
|
||||
time.sleep(0.05)
|
||||
pump_events()
|
||||
geometry_events.clear()
|
||||
bounds = source.rectangle()
|
||||
first_frame = shot()
|
||||
for char in "连续输入时窗口应该保持稳定 Native input stays stable 1234567890":
|
||||
u.SendMessageW(source.handle, 0x102, ord(char), 0)
|
||||
pump_events()
|
||||
assert source.rectangle() == bounds
|
||||
time.sleep(0.05)
|
||||
pump_events()
|
||||
report["typing_geometry_changes"] = len(geometry_events)
|
||||
assert not geometry_events, (
|
||||
f"typing resized the source input {len(geometry_events)} times"
|
||||
)
|
||||
geometry_events.clear()
|
||||
# Exercise the native edit replacement path used by paste/selection.
|
||||
for text in ["粘贴的内容", "replacement text", "最后一段输入"]:
|
||||
u.SendMessageW(source.handle, 0xB1, 0, -1) # EM_SETSEL
|
||||
replacement = ctypes.create_unicode_buffer(text)
|
||||
u.SendMessageW(source.handle, 0xC2, 1, ctypes.addressof(replacement))
|
||||
pump_events()
|
||||
assert source.window_text() == text and source.rectangle() == bounds
|
||||
time.sleep(0.05)
|
||||
pump_events()
|
||||
# Native edit replacements can notify client/scrollbar location changes
|
||||
# while retaining the same outer bounds (checked for every replacement).
|
||||
report["paste_native_location_notifications"] = len(geometry_events)
|
||||
first_frame.save(out / "input-first.png")
|
||||
shot().save(out / "input-last.png")
|
||||
finally:
|
||||
u.UnhookWinEvent(hook)
|
||||
report["checks"].append("continuous_input_and_paste_keep_native_geometry_stable")
|
||||
source.set_edit_text("多行内容\r\n" * 30)
|
||||
assert u.GetWindowLongW(source.handle, -16) & 0x200000
|
||||
source.set_edit_text("短文本")
|
||||
assert not u.GetWindowLongW(source.handle, -16) & 0x200000
|
||||
assert source.rectangle() == bounds
|
||||
report["checks"].append("input_scrollbar_tracks_text_overflow_without_window_layout")
|
||||
assert c(206).is_visible()
|
||||
source.set_edit_text("")
|
||||
assert not c(206).is_visible()
|
||||
assert source.rectangle().width() > bounds.width()
|
||||
source.set_edit_text("恢复输入")
|
||||
assert c(206).is_visible() and source.rectangle() == bounds
|
||||
clear_bounds = c(206).rectangle()
|
||||
u.SendMessageW(root_hwnd, 0x111, 206, 0)
|
||||
assert source.window_text() == "" and not c(206).is_visible()
|
||||
report["checks"].append("clear_action_and_empty_input_width_follow_text_state")
|
||||
# The exposed strip contains the old clear button but no blinking caret.
|
||||
# Check naturally presented pixels before requesting a reference redraw.
|
||||
natural = shot()
|
||||
u.RedrawWindow(root_hwnd, None, None, 0x585)
|
||||
reference = shot()
|
||||
window_bounds = win.rectangle()
|
||||
exposed = (
|
||||
bounds.right - window_bounds.left,
|
||||
bounds.top - window_bounds.top,
|
||||
clear_bounds.right - window_bounds.left,
|
||||
bounds.bottom - window_bounds.top,
|
||||
)
|
||||
changed = sum(
|
||||
1 for pixel in ImageChops.difference(
|
||||
natural.crop(exposed), reference.crop(exposed)
|
||||
).getdata() if max(pixel[:3]) > 8
|
||||
)
|
||||
report["cleared_input_stale_pixels"] = changed
|
||||
natural.save(out / "input-cleared.png")
|
||||
assert changed == 0, f"clearing input left {changed} stale pixels"
|
||||
report["checks"].append("clearing_input_repaints_exposed_strip_without_ghosts")
|
||||
|
||||
if args.input_only:
|
||||
report["profile_unchanged"] = (
|
||||
profile.read_bytes() if profile.exists() else None
|
||||
) == before
|
||||
assert report["profile_unchanged"]
|
||||
report["ok"] = True
|
||||
raise SystemExit(0)
|
||||
|
||||
hover = {}
|
||||
for ident in [104, 105, 103, 507]:
|
||||
r = c(ident).rectangle()
|
||||
|
||||
@@ -358,6 +358,9 @@ pub struct AppearanceConfig {
|
||||
pub panel_opacity: f64,
|
||||
#[serde(default = "default_true")]
|
||||
pub ocr_strip_newlines: bool,
|
||||
/// Windows 普通窗口:显示在任务栏与 Alt+Tab 中,失焦时保持显示。
|
||||
#[serde(default)]
|
||||
pub normal_window_mode: bool,
|
||||
}
|
||||
|
||||
fn default_panel_opacity() -> f64 {
|
||||
@@ -369,6 +372,7 @@ impl Default for AppearanceConfig {
|
||||
Self {
|
||||
panel_opacity: default_panel_opacity(),
|
||||
ocr_strip_newlines: true,
|
||||
normal_window_mode: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! macOS LaunchAgent 开机自启管理。
|
||||
//! 平台开机自启管理。
|
||||
|
||||
use auto_launch::{AutoLaunch, AutoLaunchBuilder};
|
||||
|
||||
@@ -14,10 +14,14 @@ fn build_auto_launch() -> Result<AutoLaunch, String> {
|
||||
.filter(|bundle| bundle.extension().is_some_and(|ext| ext == "app"))
|
||||
.unwrap_or(exe.as_path());
|
||||
|
||||
AutoLaunchBuilder::new()
|
||||
let mut builder = AutoLaunchBuilder::new();
|
||||
builder
|
||||
.set_app_name(APP_NAME)
|
||||
.set_app_path(&app_path.to_string_lossy())
|
||||
.set_use_launch_agent(true)
|
||||
.set_use_launch_agent(true);
|
||||
#[cfg(target_os = "windows")]
|
||||
builder.set_args(&["--hidden"]);
|
||||
builder
|
||||
.build()
|
||||
.map_err(|e| format!("初始化开机自启失败: {e}"))
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ use std::str::FromStr;
|
||||
|
||||
use anyhow::anyhow;
|
||||
use global_hotkey::hotkey::{HotKey, Modifiers};
|
||||
use global_hotkey::GlobalHotKeyManager;
|
||||
use ttree_core::ShortcutConfig;
|
||||
|
||||
const MAX_SHORTCUT_SPEC_BYTES: usize = 128;
|
||||
@@ -27,6 +28,18 @@ pub struct ParsedShortcuts {
|
||||
}
|
||||
|
||||
impl ParsedShortcuts {
|
||||
fn describe(&self, hotkey: HotKey) -> String {
|
||||
let action = match self.action_for_id(hotkey.id()) {
|
||||
Some(ShortcutAction::Toggle) => "呼出翻译",
|
||||
Some(ShortcutAction::Ocr) => "截图 OCR",
|
||||
Some(ShortcutAction::AiDialog) => "AI 对话",
|
||||
Some(ShortcutAction::SelectionTranslate) => "划词翻译",
|
||||
Some(ShortcutAction::SelectionAiDialog) => "划词对话",
|
||||
None => "全局快捷键",
|
||||
};
|
||||
format!("{action}({hotkey})")
|
||||
}
|
||||
|
||||
/// 划词动作排在对应的纯呼出动作之前,共用同一快捷键时优先走划词逻辑。
|
||||
pub fn entries(&self) -> [(ShortcutAction, &Option<HotKey>); 5] {
|
||||
[
|
||||
@@ -84,6 +97,54 @@ impl ParsedShortcuts {
|
||||
}
|
||||
}
|
||||
|
||||
/// 保留未变的快捷键;新增注册全部成功后才释放旧组合,失败则恢复原注册集合。
|
||||
pub fn register_shortcuts(
|
||||
manager: &GlobalHotKeyManager,
|
||||
registered: &mut Vec<HotKey>,
|
||||
parsed: &ParsedShortcuts,
|
||||
) -> Result<(), String> {
|
||||
let previous = registered.clone();
|
||||
if let Err(error) = sync_hotkeys(manager, registered, &parsed.unique_hotkeys(), parsed) {
|
||||
return match sync_hotkeys(manager, registered, &previous, parsed) {
|
||||
Ok(()) => Err(error),
|
||||
Err(restore_error) => Err(format!("{error};恢复原快捷键失败: {restore_error}")),
|
||||
};
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sync_hotkeys(
|
||||
manager: &GlobalHotKeyManager,
|
||||
registered: &mut Vec<HotKey>,
|
||||
desired: &[HotKey],
|
||||
parsed: &ParsedShortcuts,
|
||||
) -> Result<(), String> {
|
||||
for &hotkey in desired {
|
||||
if registered.contains(&hotkey) {
|
||||
continue;
|
||||
}
|
||||
manager.register(hotkey).map_err(|error| {
|
||||
let description = parsed.describe(hotkey);
|
||||
match error {
|
||||
global_hotkey::Error::AlreadyRegistered(_) => format!(
|
||||
"{description}已被系统或其他程序占用,请更换快捷键或退出其他 TTREE 实例。"
|
||||
),
|
||||
other => format!("注册{description}失败: {other}"),
|
||||
}
|
||||
})?;
|
||||
registered.push(hotkey);
|
||||
}
|
||||
for hotkey in registered.clone() {
|
||||
if !desired.contains(&hotkey) {
|
||||
manager
|
||||
.unregister(hotkey)
|
||||
.map_err(|error| format!("释放{}失败: {error}", parsed.describe(hotkey)))?;
|
||||
registered.retain(|existing| *existing != hotkey);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_allowed_shared_pair(a: &str, b: &str) -> bool {
|
||||
const ALLOWED: [(&str, &str); 2] = [
|
||||
("呼出翻译快捷键", "划词翻译快捷键"),
|
||||
@@ -94,19 +155,6 @@ fn is_allowed_shared_pair(a: &str, b: &str) -> bool {
|
||||
.any(|&(x, y)| (a == x && b == y) || (a == y && b == x))
|
||||
}
|
||||
|
||||
/// 将 Tauri 风格的 `CmdOrCtrl+Shift+Space` / `Option+Space` 规范化为 `global-hotkey` 支持的格式。
|
||||
fn normalize_spec(spec: &str) -> String {
|
||||
spec.trim()
|
||||
.split('+')
|
||||
.map(|part| match part.trim() {
|
||||
"CmdOrCtrl" | "CommandOrControl" | "Cmd" | "Command" => "Super",
|
||||
"Option" => "Alt",
|
||||
other => other,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("+")
|
||||
}
|
||||
|
||||
pub fn parse_shortcut_config(cfg: &ShortcutConfig) -> Result<ParsedShortcuts, anyhow::Error> {
|
||||
let parsed = ParsedShortcuts {
|
||||
toggle: parse_optional_shortcut("呼出翻译快捷键", &cfg.toggle)?,
|
||||
@@ -129,8 +177,8 @@ fn parse_optional_shortcut(label: &str, spec: &str) -> Result<Option<HotKey>, an
|
||||
if spec.trim().is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let normalized = normalize_spec(spec);
|
||||
let hotkey = HotKey::from_str(&normalized).map_err(|e| anyhow!("{label}无效: {e}"))?;
|
||||
// global-hotkey 已按平台处理 CmdOrCtrl,并支持 Cmd / Option 等别名。
|
||||
let hotkey = HotKey::from_str(spec.trim()).map_err(|e| anyhow!("{label}无效: {e}"))?;
|
||||
if !hotkey
|
||||
.mods
|
||||
.intersects(Modifiers::SUPER | Modifiers::CONTROL | Modifiers::ALT)
|
||||
@@ -145,6 +193,145 @@ fn parse_optional_shortcut(label: &str, spec: &str) -> Result<Option<HotKey>, an
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use global_hotkey::hotkey::Code;
|
||||
|
||||
fn platform_modifier() -> Modifiers {
|
||||
if cfg!(target_os = "macos") {
|
||||
Modifiers::SUPER
|
||||
} else {
|
||||
Modifiers::CONTROL
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmd_or_ctrl_aliases_follow_the_current_platform() {
|
||||
for alias in [
|
||||
"CmdOrCtrl",
|
||||
"CommandOrControl",
|
||||
"CommandOrCtrl",
|
||||
"CmdOrControl",
|
||||
"cmdorctrl",
|
||||
"COMMANDORCONTROL",
|
||||
] {
|
||||
let hk = parse_optional_shortcut("呼出翻译快捷键", &format!(" {alias} + right "))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(hk, HotKey::new(Some(platform_modifier()), Code::ArrowRight));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_shortcuts_use_the_current_platform_modifier() {
|
||||
let parsed = parse_shortcut_config(&ShortcutConfig::default()).unwrap();
|
||||
assert_eq!(
|
||||
parsed.toggle.unwrap(),
|
||||
HotKey::new(Some(platform_modifier() | Modifiers::SHIFT), Code::Space)
|
||||
);
|
||||
assert_eq!(
|
||||
parsed.ocr.unwrap(),
|
||||
HotKey::new(Some(platform_modifier() | Modifiers::SHIFT), Code::KeyS)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_command_and_option_keep_their_meaning() {
|
||||
assert_eq!(
|
||||
parse_optional_shortcut("test", "Cmd+Shift+S")
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
HotKey::new(Some(Modifiers::SUPER | Modifiers::SHIFT), Code::KeyS)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_optional_shortcut("test", "Option+Space")
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
HotKey::new(Some(Modifiers::ALT), Code::Space)
|
||||
);
|
||||
assert!(parse_optional_shortcut("test", " ").unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_conflicts_between_platform_aliases_and_explicit_modifiers() {
|
||||
let cfg = ShortcutConfig {
|
||||
toggle: "CmdOrCtrl+Shift+S".into(),
|
||||
ocr: if cfg!(target_os = "macos") {
|
||||
"Command+Shift+S".into()
|
||||
} else {
|
||||
"Ctrl+Shift+S".into()
|
||||
},
|
||||
..ShortcutConfig::default()
|
||||
};
|
||||
assert!(parse_shortcut_config(&cfg).is_err());
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
fn native_registration_preserves_existing_keys_on_conflict_and_supports_retry() {
|
||||
let manager = GlobalHotKeyManager::new().unwrap();
|
||||
let blocker = GlobalHotKeyManager::new().unwrap();
|
||||
let cfg = ShortcutConfig {
|
||||
toggle: "Ctrl+Alt+Shift+F21".into(),
|
||||
ocr: "Ctrl+Alt+Shift+F22".into(),
|
||||
..ShortcutConfig::default()
|
||||
};
|
||||
let original = parse_shortcut_config(&cfg).unwrap();
|
||||
let mut registered = Vec::new();
|
||||
register_shortcuts(&manager, &mut registered, &original).unwrap();
|
||||
let previous = registered.clone();
|
||||
|
||||
let changed = parse_shortcut_config(&ShortcutConfig {
|
||||
toggle: "Ctrl+Alt+Shift+F23".into(),
|
||||
ocr: "Ctrl+Alt+Shift+F24".into(),
|
||||
..cfg.clone()
|
||||
})
|
||||
.unwrap();
|
||||
let blocked = changed.ocr.unwrap();
|
||||
blocker.register(blocked).unwrap();
|
||||
let error = register_shortcuts(&manager, &mut registered, &changed).unwrap_err();
|
||||
assert!(error.contains("截图 OCR"), "{error}");
|
||||
assert!(error.contains("占用"), "{error}");
|
||||
assert_eq!(registered, previous);
|
||||
for hotkey in &previous {
|
||||
assert!(matches!(
|
||||
blocker.register(*hotkey),
|
||||
Err(global_hotkey::Error::AlreadyRegistered(_))
|
||||
));
|
||||
}
|
||||
// A key staged before the conflict must be released during rollback.
|
||||
blocker.register(changed.toggle.unwrap()).unwrap();
|
||||
blocker.unregister(changed.toggle.unwrap()).unwrap();
|
||||
blocker.unregister(blocked).unwrap();
|
||||
|
||||
register_shortcuts(&manager, &mut registered, &changed).unwrap();
|
||||
assert_eq!(registered, changed.unique_hotkeys());
|
||||
for hotkey in &previous {
|
||||
blocker.register(*hotkey).unwrap();
|
||||
blocker.unregister(*hotkey).unwrap();
|
||||
}
|
||||
// Repeated saves and swapping actions reuse the existing registrations.
|
||||
register_shortcuts(&manager, &mut registered, &changed).unwrap();
|
||||
let swapped = ParsedShortcuts {
|
||||
toggle: changed.ocr,
|
||||
ocr: changed.toggle,
|
||||
..ParsedShortcuts::default()
|
||||
};
|
||||
register_shortcuts(&manager, &mut registered, &swapped).unwrap();
|
||||
assert_eq!(registered.len(), 2);
|
||||
|
||||
let shared = ParsedShortcuts {
|
||||
toggle: changed.toggle,
|
||||
selection_translate: changed.toggle,
|
||||
..ParsedShortcuts::default()
|
||||
};
|
||||
register_shortcuts(&manager, &mut registered, &shared).unwrap();
|
||||
assert_eq!(registered, shared.unique_hotkeys());
|
||||
register_shortcuts(&manager, &mut registered, &ParsedShortcuts::default()).unwrap();
|
||||
assert!(registered.is_empty());
|
||||
for hotkey in changed.unique_hotkeys() {
|
||||
blocker.register(hotkey).unwrap();
|
||||
blocker.unregister(hotkey).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_default_and_shared_shortcuts() {
|
||||
|
||||
@@ -2,3 +2,4 @@
|
||||
pub mod autostart;
|
||||
pub mod hotkey;
|
||||
pub mod selection;
|
||||
pub mod shortcut_recorder;
|
||||
@@ -0,0 +1,201 @@
|
||||
//! Keyboard recording keeps persistent shortcut syntax separate from native labels.
|
||||
use global_hotkey::hotkey::{Code, HotKey, Modifiers};
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum CaptureResult {
|
||||
Changed,
|
||||
Cancelled,
|
||||
Navigate { backwards: bool },
|
||||
Ignored,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct ShortcutRecorder {
|
||||
value: String,
|
||||
original: String,
|
||||
}
|
||||
|
||||
impl ShortcutRecorder {
|
||||
pub fn set(&mut self, value: &str) {
|
||||
self.value = value.to_owned();
|
||||
}
|
||||
|
||||
pub fn value(&self) -> &str {
|
||||
&self.value
|
||||
}
|
||||
|
||||
pub fn begin(&mut self) {
|
||||
self.original = self.value.clone();
|
||||
}
|
||||
|
||||
pub fn display(&self) -> String {
|
||||
let Ok(hotkey) = self.value.parse::<HotKey>() else {
|
||||
return self.value.clone();
|
||||
};
|
||||
let mut parts = Vec::new();
|
||||
if hotkey.mods.contains(Modifiers::CONTROL) {
|
||||
parts.push("Ctrl".to_owned());
|
||||
}
|
||||
if hotkey.mods.contains(Modifiers::ALT) {
|
||||
parts.push(
|
||||
if cfg!(target_os = "macos") {
|
||||
"Option"
|
||||
} else {
|
||||
"Alt"
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
if hotkey.mods.contains(Modifiers::SHIFT) {
|
||||
parts.push("Shift".to_owned());
|
||||
}
|
||||
if hotkey.mods.contains(Modifiers::SUPER) {
|
||||
parts.push(
|
||||
if cfg!(target_os = "macos") {
|
||||
"Cmd"
|
||||
} else {
|
||||
"Win"
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
let code = hotkey.key.to_string();
|
||||
let label = code
|
||||
.strip_prefix("Key")
|
||||
.or_else(|| code.strip_prefix("Digit"))
|
||||
.or_else(|| code.strip_prefix("Arrow"))
|
||||
.unwrap_or(&code);
|
||||
parts.push(label.to_owned());
|
||||
parts.join(" + ")
|
||||
}
|
||||
|
||||
pub fn capture(&mut self, key: Code, modifiers: Modifiers) -> CaptureResult {
|
||||
let command = modifiers.intersects(Modifiers::CONTROL | Modifiers::ALT | Modifiers::SUPER);
|
||||
if !command {
|
||||
match key {
|
||||
Code::Escape if modifiers.is_empty() => {
|
||||
self.value = self.original.clone();
|
||||
return CaptureResult::Cancelled;
|
||||
}
|
||||
Code::Backspace | Code::Delete if modifiers.is_empty() => {
|
||||
self.value.clear();
|
||||
return CaptureResult::Changed;
|
||||
}
|
||||
Code::Tab => {
|
||||
return CaptureResult::Navigate {
|
||||
backwards: modifiers.contains(Modifiers::SHIFT),
|
||||
};
|
||||
}
|
||||
_ => return CaptureResult::Ignored,
|
||||
}
|
||||
}
|
||||
// Modifier-only events must never create a shortcut.
|
||||
if matches!(
|
||||
key,
|
||||
Code::ControlLeft
|
||||
| Code::ControlRight
|
||||
| Code::AltLeft
|
||||
| Code::AltRight
|
||||
| Code::ShiftLeft
|
||||
| Code::ShiftRight
|
||||
| Code::MetaLeft
|
||||
| Code::MetaRight
|
||||
) {
|
||||
return CaptureResult::Ignored;
|
||||
}
|
||||
let modifiers =
|
||||
modifiers & (Modifiers::CONTROL | Modifiers::ALT | Modifiers::SUPER | Modifiers::SHIFT);
|
||||
self.value = HotKey::new(Some(modifiers), key).into_string();
|
||||
CaptureResult::Changed
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn records_combinations_as_round_trippable_config_and_readable_labels() {
|
||||
let mut recorder = ShortcutRecorder::default();
|
||||
recorder.begin();
|
||||
assert_eq!(
|
||||
recorder.capture(Code::KeyS, Modifiers::CONTROL | Modifiers::SHIFT),
|
||||
CaptureResult::Changed
|
||||
);
|
||||
let hk: HotKey = recorder.value().parse().unwrap();
|
||||
assert_eq!(
|
||||
hk,
|
||||
HotKey::new(Some(Modifiers::CONTROL | Modifiers::SHIFT), Code::KeyS)
|
||||
);
|
||||
assert_eq!(recorder.display(), "Ctrl + Shift + S");
|
||||
recorder.capture(Code::ArrowRight, Modifiers::CONTROL);
|
||||
assert_eq!(recorder.display(), "Ctrl + Right");
|
||||
recorder.capture(Code::NumpadAdd, Modifiers::ALT);
|
||||
assert_eq!(
|
||||
recorder.value().parse::<HotKey>().unwrap().key,
|
||||
Code::NumpadAdd
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_plain_text_shift_only_and_modifier_only_keys() {
|
||||
let mut recorder = ShortcutRecorder::default();
|
||||
recorder.set("Ctrl+Right");
|
||||
for (key, modifiers) in [
|
||||
(Code::KeyA, Modifiers::empty()),
|
||||
(Code::KeyA, Modifiers::SHIFT),
|
||||
(Code::ControlLeft, Modifiers::CONTROL),
|
||||
] {
|
||||
assert_eq!(recorder.capture(key, modifiers), CaptureResult::Ignored);
|
||||
assert_eq!(recorder.value(), "Ctrl+Right");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supports_clear_cancel_and_keyboard_navigation_without_recording_them() {
|
||||
let mut recorder = ShortcutRecorder::default();
|
||||
recorder.set("Ctrl+Right");
|
||||
recorder.begin();
|
||||
assert_eq!(
|
||||
recorder.capture(Code::Backspace, Modifiers::empty()),
|
||||
CaptureResult::Changed
|
||||
);
|
||||
assert_eq!(recorder.value(), "");
|
||||
assert_eq!(
|
||||
recorder.capture(Code::Escape, Modifiers::empty()),
|
||||
CaptureResult::Cancelled
|
||||
);
|
||||
assert_eq!(recorder.value(), "Ctrl+Right");
|
||||
assert_eq!(
|
||||
recorder.capture(Code::Tab, Modifiers::empty()),
|
||||
CaptureResult::Navigate { backwards: false }
|
||||
);
|
||||
assert_eq!(
|
||||
recorder.capture(Code::Tab, Modifiers::SHIFT),
|
||||
CaptureResult::Navigate { backwards: true }
|
||||
);
|
||||
assert_eq!(
|
||||
recorder.capture(Code::Delete, Modifiers::CONTROL),
|
||||
CaptureResult::Changed
|
||||
);
|
||||
assert_eq!(
|
||||
recorder.value().parse::<HotKey>().unwrap().key,
|
||||
Code::Delete
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn displays_existing_platform_aliases_without_rewriting_the_saved_value() {
|
||||
let mut recorder = ShortcutRecorder::default();
|
||||
recorder.set("CmdOrCtrl+Shift+Space");
|
||||
assert_eq!(recorder.value(), "CmdOrCtrl+Shift+Space");
|
||||
assert_eq!(
|
||||
recorder.display(),
|
||||
if cfg!(target_os = "macos") {
|
||||
"Shift + Cmd + Space"
|
||||
} else {
|
||||
"Ctrl + Shift + Space"
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -297,6 +297,7 @@ impl SettingsStore {
|
||||
let clamped = AppearanceConfig {
|
||||
panel_opacity: AppearanceConfig::clamp_opacity(cfg.panel_opacity),
|
||||
ocr_strip_newlines: cfg.ocr_strip_newlines,
|
||||
normal_window_mode: cfg.normal_window_mode,
|
||||
};
|
||||
let mut guard = self.cache.write().expect("settings lock poisoned");
|
||||
guard.raw_map.insert(
|
||||
@@ -307,6 +308,10 @@ impl SettingsStore {
|
||||
"appearance.ocr_strip_newlines".into(),
|
||||
Value::from(clamped.ocr_strip_newlines),
|
||||
);
|
||||
guard.raw_map.insert(
|
||||
"appearance.normal_window_mode".into(),
|
||||
Value::from(clamped.normal_window_mode),
|
||||
);
|
||||
write_json_map(&self.path, &guard.raw_map)?;
|
||||
guard.appearance = clamped;
|
||||
Ok(())
|
||||
@@ -550,6 +555,12 @@ fn load_appearance(map: &Map<String, Value>) -> AppearanceConfig {
|
||||
{
|
||||
cfg.ocr_strip_newlines = v;
|
||||
}
|
||||
if let Some(v) = map
|
||||
.get("appearance.normal_window_mode")
|
||||
.and_then(Value::as_bool)
|
||||
{
|
||||
cfg.normal_window_mode = v;
|
||||
}
|
||||
cfg
|
||||
}
|
||||
|
||||
@@ -589,6 +600,29 @@ fn load_window_size(map: &Map<String, Value>) -> Option<(f64, f64)> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn normal_window_mode_defaults_off_for_existing_settings_and_survives_reload() {
|
||||
let tmp = tempfile::NamedTempFile::new().unwrap();
|
||||
fs::write(
|
||||
tmp.path(),
|
||||
r#"{"appearance.panel_opacity":0.8,"appearance.ocr_strip_newlines":false}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let store = SettingsStore::open_at(tmp.path().to_path_buf());
|
||||
assert!(!store.appearance().normal_window_mode);
|
||||
|
||||
for enabled in [true, false] {
|
||||
let mut appearance = store.appearance();
|
||||
appearance.normal_window_mode = enabled;
|
||||
store.save_appearance(appearance).unwrap();
|
||||
|
||||
let reloaded = SettingsStore::open_at(tmp.path().to_path_buf());
|
||||
assert_eq!(reloaded.appearance().normal_window_mode, enabled);
|
||||
assert_eq!(reloaded.appearance().panel_opacity, 0.8);
|
||||
assert!(!reloaded.appearance().ocr_strip_newlines);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persists_and_reloads_settings_and_api_key_in_json() {
|
||||
let tmp = tempfile::NamedTempFile::new().unwrap();
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# Shortcut Recorder Implementation Plan
|
||||
|
||||
**Goal:** Let users record shortcut combinations by pressing keys instead of typing shortcut syntax.
|
||||
|
||||
**Architecture:** Keep canonical shortcut strings in a shared recorder model, with readable platform labels for display. Native Windows and macOS controls capture keyboard events, preserve Tab navigation and cancel/clear commands, and suspend this application's global registrations while recording. Existing save, conflict validation, defaults and settings format remain in use.
|
||||
|
||||
**Tech Stack:** Rust, global-hotkey, Win32 edit subclass, AppKit NSTextField subclass.
|
||||
|
||||
## Implementation
|
||||
|
||||
1. Add `crates/ttree-platform/src/shortcut_recorder.rs` with recorder state, display formatting and capture tests for modifier-only input, combinations, clear, cancel and navigation.
|
||||
2. Add a Windows recorder adapter and connect the five shortcut fields to canonical values. Block typed/pasted text, add focus cues, and suspend/resume app hotkeys on focus changes.
|
||||
3. Add an AppKit shortcut field and connect the same interactions and suspension behavior. Preserve platform-specific input mapping.
|
||||
4. Run meaningful recorder tests, native Windows compilation and interaction checks, then build a portable Windows artifact. Report macOS verification limits accurately.
|
||||
5. Stage only this task's changes, preserve parallel window-mode edits, and commit after validation.
|
||||
|
||||
## Verification
|
||||
|
||||
- The exported staged snapshot passes all 59 workspace tests.
|
||||
- Both debug and release Windows builds pass 26 isolated keyboard interaction checks, including save, duplicate rejection, suspension and restoration of existing bindings, defaults, and preserving the user configuration.
|
||||
- The AppKit recorder module passes cross-target type checking for aarch64-apple-darwin. Full macOS application checking stops in native C dependencies on this Windows host; Mac runtime interaction remains unverified.
|
||||
- Windows x64 GUI executable and portable ZIP contents are verified against the release build.
|
||||
@@ -0,0 +1,44 @@
|
||||
# Shortcut Registration Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Fix Windows shortcut registration for `CmdOrCtrl+Right` and preserve working shortcuts when settings cannot be applied.
|
||||
|
||||
**Architecture:** Delegate shortcut aliases to global-hotkey's platform-aware parser. Share incremental registration between the native clients: keep existing bindings, register additions, release obsolete bindings only after success, and roll back a failed change. Preserve action dispatch and the existing settings format.
|
||||
|
||||
**Tech Stack:** Rust Cargo workspace, global-hotkey 0.8, Win32, AppKit.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Reproduce modifier parsing
|
||||
|
||||
- Add platform-sensitive regression tests in `crates/ttree-platform/src/hotkey.rs` for the screenshot's Right and Shift+S combinations, default shortcuts, aliases, explicit Cmd/Option, and equivalent-key conflicts.
|
||||
- Run `cargo test -p ttree-platform --locked`; confirm Windows fails because CmdOrCtrl becomes Super.
|
||||
|
||||
### Task 2: Fix parsing and registration
|
||||
|
||||
- Remove the redundant alias conversion from `crates/ttree-platform/src/hotkey.rs`; parse the trimmed input with global-hotkey.
|
||||
- Add a shared registration helper that retains unchanged keys, stages additions, reports the conflicting action/key, and restores the previous registration set after failure.
|
||||
- Add native Windows regression coverage with a second manager holding a conflicting key. Verify rollback, retry, repeated save, swapping actions, shared bindings and disabling shortcuts.
|
||||
|
||||
### Task 3: Update callers
|
||||
|
||||
- Use the helper from `apps/windows/src/app.rs` and `apps/macos/src/state.rs` when applying settings.
|
||||
- Surface startup registration errors and registration/persistence errors in settings, without changing user config locations or defaults.
|
||||
- Inspect both callers; only native Windows execution is available on this host.
|
||||
|
||||
### Task 4: Verify and deliver
|
||||
|
||||
- Run platform tests, `cargo check --workspace --locked`, `cargo test --workspace --locked`, and formatting/diff checks appropriate to the changed files.
|
||||
- Build a Windows executable when local prerequisites are available. Keep toolchains, logs and binaries under ignored `target/` and `dist/`.
|
||||
- Report the root cause, changed files, verification results and any unverified platform behavior. Do not publish or push a release.
|
||||
|
||||
## Verification results
|
||||
|
||||
- Before the parser fix, three regression tests failed on Windows: platform aliases, default modifiers, and equivalent-modifier conflicts. The actual modifier was Super rather than Control.
|
||||
- After the fix, `cargo check --workspace --locked` and `cargo test --workspace --locked` passed on native Windows (55 tests in the final run). The native registration regression covers external ownership, rollback, retry, repeated saves, action swaps, shared keys and disabling keys.
|
||||
- `cargo build -p ttree-windows --release --locked` passed with a portable Rust/MSVC toolchain under ignored `target/tools`. Changed Rust files passed rustfmt checks; `git diff --check` passed.
|
||||
- A native probe confirmed Windows rejects the old Super+Right and Super+Shift+S mappings. The corrected Control+Right mapping registered successfully, including a repeated save. Control+Shift+S was occupied while a TTREE instance was already running; that process was preserved.
|
||||
- The portable ZIP at `dist/windows/TTREE-hotkey-fixed.zip` contains the built executable and license files. ZIP structure and launching the packaged executable with `--hidden` to reuse the existing instance passed.
|
||||
- macOS callers use the same registration helper, but native macOS compilation and interaction were not available on this Windows host.
|
||||
- Existing parallel changes to Windows startup, layout, autostart and documentation were preserved. No GitHub push or release was performed.
|
||||
+84
-10
@@ -13,6 +13,8 @@ public static class TTreeSmoke {
|
||||
[DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h, out Rect rect);
|
||||
[DllImport("user32.dll")] public static extern IntPtr SendMessage(IntPtr h, uint msg, IntPtr w, IntPtr l);
|
||||
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);
|
||||
[DllImport("user32.dll")] public static extern IntPtr GetDlgItem(IntPtr h, int id);
|
||||
[DllImport("user32.dll")] public static extern bool PrintWindow(IntPtr h, IntPtr dc, uint flags);
|
||||
public struct Rect { public int Left, Top, Right, Bottom; }
|
||||
public static IntPtr Find(uint target) {
|
||||
IntPtr found = IntPtr.Zero;
|
||||
@@ -26,17 +28,56 @@ public static class TTreeSmoke {
|
||||
'@
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
New-Item -ItemType Directory -Force $Output | Out-Null
|
||||
$Process = Start-Process (Resolve-Path $Exe) -PassThru
|
||||
try {
|
||||
$Window = [IntPtr]::Zero
|
||||
$ExePath = (Resolve-Path $Exe).Path
|
||||
$Children = [System.Collections.Generic.List[System.Diagnostics.Process]]::new()
|
||||
function Start-App([string[]]$LaunchArgs = @()) {
|
||||
$Options = @{FilePath=$ExePath; PassThru=$true; WindowStyle='Hidden'}
|
||||
if ($LaunchArgs.Count) { $Options.ArgumentList = $LaunchArgs }
|
||||
$Child = Start-Process @Options
|
||||
$Children.Add($Child)
|
||||
return $Child
|
||||
}
|
||||
function Wait-Window($Child) {
|
||||
for ($i=0; $i -lt 100; $i++) {
|
||||
if ($Process.HasExited) { throw "App exited with $($Process.ExitCode)" }
|
||||
$Window = [TTreeSmoke]::Find($Process.Id)
|
||||
if ($Window -ne [IntPtr]::Zero) { break }
|
||||
if ($Child.HasExited) { throw "App exited with $($Child.ExitCode)" }
|
||||
$Found = [TTreeSmoke]::Find($Child.Id)
|
||||
if ($Found -ne [IntPtr]::Zero) { return $Found }
|
||||
Start-Sleep -Milliseconds 100
|
||||
}
|
||||
if ($Window -eq [IntPtr]::Zero) { throw 'Native window was not created' }
|
||||
throw 'Native window was not created'
|
||||
}
|
||||
function Assert-Visible([IntPtr]$Handle, [bool]$Expected, [string]$Message) {
|
||||
for ($i=0; $i -lt 50; $i++) {
|
||||
if ([TTreeSmoke]::IsWindowVisible($Handle) -eq $Expected) { return }
|
||||
Start-Sleep -Milliseconds 100
|
||||
}
|
||||
throw $Message
|
||||
}
|
||||
function Assert-Forwarded($Child) {
|
||||
if (-not $Child.WaitForExit(10000)) { throw 'Repeated launch created another resident instance' }
|
||||
if ($Child.ExitCode -ne 0) { throw "Repeated launch failed with $($Child.ExitCode)" }
|
||||
}
|
||||
function Stop-App($Child, [IntPtr]$Handle) {
|
||||
[TTreeSmoke]::SendMessage($Handle, 0x111, [IntPtr]905, [IntPtr]::Zero) | Out-Null
|
||||
if (-not $Child.WaitForExit(10000)) { throw 'App did not exit through its tray action' }
|
||||
}
|
||||
try {
|
||||
$Process = Start-App
|
||||
$Window = Wait-Window $Process
|
||||
Assert-Visible $Window $true 'Double-click launch did not show the window'
|
||||
if ([TTreeSmoke]::GetClassLongPtr($Window, -14) -eq [IntPtr]::Zero) { throw 'Embedded TTREE icon did not load' }
|
||||
# Esc is sent to the real source edit, exercising its keyboard handler.
|
||||
$SourceEdit = [TTreeSmoke]::GetDlgItem($Window, 201)
|
||||
if ($SourceEdit -eq [IntPtr]::Zero) { throw 'Source input was not created' }
|
||||
[TTreeSmoke]::SendMessage($SourceEdit, 0x100, [IntPtr]0x1b, [IntPtr]::Zero) | Out-Null
|
||||
Assert-Visible $Window $false 'Escape did not hide the window'
|
||||
Assert-Forwarded (Start-App)
|
||||
Assert-Visible $Window $true 'Repeated double-click did not reopen the existing window'
|
||||
# Relaunching while visible must keep the window open, rather than toggle it.
|
||||
Assert-Forwarded (Start-App)
|
||||
Assert-Visible $Window $true 'Repeated launch hid the visible window'
|
||||
[TTreeSmoke]::SendMessage($Window, 0x10, [IntPtr]::Zero, [IntPtr]::Zero) | Out-Null
|
||||
Assert-Visible $Window $false 'Close did not hide the window'
|
||||
[TTreeSmoke]::SendMessage($Window, 0x111, [IntPtr]901, [IntPtr]::Zero) | Out-Null
|
||||
[TTreeSmoke]::SetForegroundWindow($Window) | Out-Null
|
||||
Start-Sleep -Seconds 1
|
||||
@@ -49,11 +90,44 @@ try {
|
||||
$Bitmap = New-Object System.Drawing.Bitmap($Width, $Height)
|
||||
$Graphics = [System.Drawing.Graphics]::FromImage($Bitmap)
|
||||
try {
|
||||
$Graphics.CopyFromScreen($Rect.Left, $Rect.Top, 0, 0, $Bitmap.Size)
|
||||
# Capture this app even when Windows keeps the user's current app in front.
|
||||
$Dc = $Graphics.GetHdc()
|
||||
try {
|
||||
if (-not [TTreeSmoke]::PrintWindow($Window, $Dc, 2)) { throw 'Native window capture failed' }
|
||||
} finally { $Graphics.ReleaseHdc($Dc) }
|
||||
$Bitmap.Save((Join-Path (Resolve-Path $Output) 'windows.png'))
|
||||
} finally { $Graphics.Dispose(); $Bitmap.Dispose() }
|
||||
@{ result='passed'; pid=$Process.Id; width=$Width; height=$Height; checks=@('process alive', 'native window created', 'embedded TTREE icon loaded', 'tray action opens translation') } |
|
||||
Stop-App $Process $Window
|
||||
|
||||
$Background = Start-App @('--hidden')
|
||||
$HiddenWindow = Wait-Window $Background
|
||||
Start-Sleep -Milliseconds 500
|
||||
Assert-Visible $HiddenWindow $false 'Background launch unexpectedly showed a window'
|
||||
Assert-Forwarded (Start-App @('--hidden'))
|
||||
Assert-Visible $HiddenWindow $false 'Repeated background launch showed a window'
|
||||
Assert-Forwarded (Start-App @('--show'))
|
||||
Assert-Visible $HiddenWindow $true '--show did not activate the background instance'
|
||||
Stop-App $Background $HiddenWindow
|
||||
|
||||
# Two cold launches in quick succession must still leave exactly one resident process.
|
||||
$First = Start-App
|
||||
$Second = Start-App
|
||||
for ($i=0; $i -lt 100; $i++) {
|
||||
if ($First.HasExited -or $Second.HasExited) { break }
|
||||
Start-Sleep -Milliseconds 100
|
||||
}
|
||||
if ($First.HasExited -eq $Second.HasExited) { throw 'Simultaneous launches did not leave exactly one instance' }
|
||||
$Primary = if ($First.HasExited) { $Second } else { $First }
|
||||
$Forwarded = if ($First.HasExited) { $First } else { $Second }
|
||||
Assert-Forwarded $Forwarded
|
||||
$RaceWindow = Wait-Window $Primary
|
||||
Assert-Visible $RaceWindow $true 'Simultaneous launches did not show the window'
|
||||
Stop-App $Primary $RaceWindow
|
||||
|
||||
@{ result='passed'; pid=$Process.Id; width=$Width; height=$Height; checks=@('double-click shows window', 'embedded TTREE icon loaded', 'Escape hides window', 'repeated launch reopens same instance', 'visible relaunch stays visible', 'tray action opens translation', 'background launch stays hidden', 'background relaunch stays hidden', '--show activates existing instance', 'simultaneous launch keeps one instance') } |
|
||||
ConvertTo-Json | Set-Content (Join-Path $Output 'result.json')
|
||||
} finally {
|
||||
if (-not $Process.HasExited) { Stop-Process -Id $Process.Id -Force }
|
||||
foreach ($Child in $Children) {
|
||||
if (-not $Child.HasExited) { Stop-Process -Id $Child.Id -Force }
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user