diff --git a/README.md b/README.md index 6f34710..c0d28d1 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ Windows 包为未使用商业证书签名的便携版,首次运行可能遇到 | 换行 | Shift + Enter | Shift + Enter | | 收起窗口 | Esc | Esc | -AI 对话、划词翻译、划词 AI 的快捷键可在设置中配置。点按置顶,让窗口留在身边。 +在「设置 → 快捷键」点击对应字段,再按下组合键即可录入;Backspace / Delete 清空停用,Esc 取消本次录入,保存后生效。AI 对话、划词翻译、划词 AI 均可单独设置快捷键。点按置顶,让窗口留在身边。 ## 给创作者,留好空间。 diff --git a/apps/macos/src/app.rs b/apps/macos/src/app.rs index 418d1c2..7a4409a 100644 --- a/apps/macos/src/app.rs +++ b/apps/macos/src/app.rs @@ -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); diff --git a/apps/macos/src/main.rs b/apps/macos/src/main.rs index 8cd879d..afa28b1 100644 --- a/apps/macos/src/main.rs +++ b/apps/macos/src/main.rs @@ -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() { diff --git a/apps/macos/src/shortcut_recorder.rs b/apps/macos/src/shortcut_recorder.rs new file mode 100644 index 0000000..25267c6 --- /dev/null +++ b/apps/macos/src/shortcut_recorder.rs @@ -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, +} + +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 { + let allocated = mtm.alloc::().set_ivars(ShortcutFieldIvars { + recorder: RefCell::new(ShortcutRecorder::default()), + }); + let field: Retained = 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::() + .is_some_and(|field| !field.isHiddenOrHasHiddenAncestor()) + }) +} + +fn key(code: u16) -> Option { + 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::().ok().map(|hotkey| hotkey.key) +} diff --git a/apps/macos/src/state.rs b/apps/macos/src/state.rs index 6a053ce..754dedf 100644 --- a/apps/macos/src/state.rs +++ b/apps/macos/src/state.rs @@ -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, pub btn_reset_sc: Retained, pub sc_lbl_toggle: Retained, - pub sc_toggle_field: Retained, + pub sc_toggle_field: Retained, pub sc_lbl_ocr: Retained, - pub sc_ocr_field: Retained, + pub sc_ocr_field: Retained, pub sc_lbl_ai: Retained, - pub sc_ai_field: Retained, + pub sc_ai_field: Retained, pub sc_lbl_sel_trans: Retained, - pub sc_sel_trans_field: Retained, + pub sc_sel_trans_field: Retained, pub sc_lbl_sel_ai: Retained, - pub sc_sel_ai_field: Retained, + pub sc_sel_ai_field: Retained, pub save_sc_btn: Retained, pub sc_hint: Retained, @@ -415,6 +417,7 @@ pub struct AppController { pub hotkey_mgr: GlobalHotKeyManager, pub parsed_shortcuts: ParsedShortcuts, pub registered_hotkeys: Vec, + pub recording_shortcut: bool, pub window: Retained, pub effect_view: Retained, @@ -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(); } diff --git a/apps/windows/src/app.rs b/apps/windows/src/app.rs index 0da0215..894e7d4 100644 --- a/apps/windows/src/app.rs +++ b/apps/windows/src/app.rs @@ -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, hotkey_mgr: GlobalHotKeyManager, registered_hotkeys: Vec, + 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(), diff --git a/apps/windows/src/app/shortcut_recorder.rs b/apps/windows/src/app/shortcut_recorder.rs new file mode 100644 index 0000000..ed36bdf --- /dev/null +++ b/apps/windows/src/app/shortcut_recorder.rs @@ -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> = 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 { + 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::().ok().map(|hotkey| hotkey.key) +} + +pub(super) unsafe fn handle(hwnd: HWND, msg: u32, w: WPARAM, _l: LPARAM) -> Option { + 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); + } +} diff --git a/apps/windows/src/app/ui.rs b/apps/windows/src/app/ui.rs index 91ca715..a8a2c49 100644 --- a/apps/windows/src/app/ui.rs +++ b/apps/windows/src/app/ui.rs @@ -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 diff --git a/crates/ttree-platform/src/lib.rs b/crates/ttree-platform/src/lib.rs index 7b69d31..88ec710 100644 --- a/crates/ttree-platform/src/lib.rs +++ b/crates/ttree-platform/src/lib.rs @@ -2,3 +2,4 @@ pub mod autostart; pub mod hotkey; pub mod selection; +pub mod shortcut_recorder; diff --git a/crates/ttree-platform/src/shortcut_recorder.rs b/crates/ttree-platform/src/shortcut_recorder.rs new file mode 100644 index 0000000..9077d0f --- /dev/null +++ b/crates/ttree-platform/src/shortcut_recorder.rs @@ -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::() 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::().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::().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" + } + ); + } +} diff --git a/docs/plans/2026-10-16-shortcut-recorder.md b/docs/plans/2026-10-16-shortcut-recorder.md new file mode 100644 index 0000000..1ac77ad --- /dev/null +++ b/docs/plans/2026-10-16-shortcut-recorder.md @@ -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.