feat(hotkeys): record shortcuts from native key presses

This commit is contained in:
lingr25 committed 2026-10-16 12:46:28 +09:00
1 parent b7f1839d9b
commit 6b59eb12e5
11 files changed
+815 -55

No files matched your search

+1 -1
View File
@@ -64,7 +64,7 @@ Windows 包为未使用商业证书签名的便携版,首次运行可能遇到
| 换行 | Shift + Enter | Shift + Enter |
| 收起窗口 | Esc | Esc |
AI 对话、划词翻译、划词 AI 的快捷键可在设置中配置。点按置顶,让窗口留在身边。
在「设置 → 快捷键」点击对应字段,再按下组合键即可录入;Backspace / Delete 清空停用,Esc 取消本次录入,保存后生效。AI 对话、划词翻译、划词 AI 均可单独设置快捷键。点按置顶,让窗口留在身边。
## 给创作者,留好空间。
+7 -1
View File
@@ -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);
+2
View File
@@ -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() {
+256
View File
@@ -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)
}
+46 -34
View File
@@ -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};
@@ -376,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>,
@@ -415,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>,
@@ -653,6 +656,7 @@ impl AppController {
hotkey_mgr,
parsed_shortcuts,
registered_hotkeys,
recording_shortcut: false,
window,
effect_view,
segmented_bg,
@@ -1662,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,
@@ -1692,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,
);
@@ -2675,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 => {
@@ -3045,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(),
@@ -3083,11 +3076,11 @@ 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) => {
@@ -3592,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();
}
+54 -18
View File
@@ -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};
@@ -525,6 +526,7 @@ struct Win32AppState {
history: Arc<HistoryStore>,
hotkey_mgr: GlobalHotKeyManager,
registered_hotkeys: Vec<HotKey>,
recording_shortcut: bool,
parsed_shortcuts: ParsedShortcuts,
hwnd: HWND,
@@ -806,6 +808,7 @@ pub fn run() {
history,
hotkey_mgr,
registered_hotkeys: Vec::new(),
recording_shortcut: false,
parsed_shortcuts: ParsedShortcuts::default(),
hwnd,
font_regular,
@@ -945,6 +948,11 @@ pub fn run() {
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)
@@ -2299,7 +2307,7 @@ impl Win32AppState {
show(self.btn_hk_reset);
bg.label(
BoxRect::new(x, top + card_h + 58, fw, 20),
"留空即可停用对应快捷键",
"点击后按组合键;Backspace / Delete 清空,Esc 取消",
true,
true,
);
@@ -2427,6 +2435,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);
@@ -2434,11 +2443,35 @@ impl Win32AppState {
fn apply_shortcuts(&mut self, cfg: &ShortcutConfig) -> Result<(), String> {
let parsed = parse_shortcut_config(cfg).map_err(|e| e.to_string())?;
register_shortcuts(&self.hotkey_mgr, &mut self.registered_hotkeys, &parsed)?;
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 => {
@@ -2915,11 +2948,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
@@ -2966,6 +2999,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)
@@ -3011,11 +3046,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);
@@ -3277,7 +3312,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);
}
@@ -3414,6 +3449,7 @@ unsafe extern "system" fn main_wnd_proc(
let active_state = wparam & 0xFFFF;
if active_state == WA_INACTIVE {
with_state(|st| {
st.set_shortcut_recording(false);
if !st.pinned && !st.ocr_in_flight && st.active_tab != TabMode::Settings {
st.hide_window();
}
@@ -3704,11 +3740,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(),
+218
View File
@@ -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);
}
}
+7 -1
View File
@@ -113,7 +113,7 @@ extern "system" {
fn GetWindowLongPtrW(hwnd: HWND, index: i32) -> 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;
@@ -745,6 +745,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);
@@ -1373,6 +1376,9 @@ 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
+1
View File
@@ -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"
}
);
}
}
@@ -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.