feat(hotkeys): record shortcuts from native key presses
This commit is contained in:
1 parent
b7f1839d9b
commit
6b59eb12e5
11 files changed
+815
-55
No files matched your search
@@ -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)
|
||||
}
|
||||
+46
-34
@@ -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
@@ -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(),
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user