From b6bdcf6bd5cb2a236ba09297fe04d13ad4d9f719 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Tue, 27 Oct 2026 20:42:41 +0800 Subject: [PATCH] Global shortcut to show the main window (Ctrl+Alt+H) A global shortcut opens the main window from any app; pressing it again while the window is in front closes it. It is Ctrl+Alt+H by default (Control+Option+H on macOS), the same keys on all three platforms. H is the only letter that produces no character with AltGr (Ctrl+Alt on Windows and Linux) on the common European layouts, so the default does not block typing. - Settings > General > Global shortcut: record another combination, turn it off, or restore the default. A modifier is required except for F1-F12; combinations the app already uses (Ctrl/Cmd+K, +1..9, +=, ...) are rejected. The current shortcut is released while recording. - Stored per computer in app.json as "hotkey" ("" = off, missing = default); not synced. A combination another app holds is not saved; the default failing at startup is shown in the row, not as a notice. - macOS: the menu bar's "Open ThinkWatch Lite" item shows the shortcut. - Linux Wayland sessions: not supported (the plugin only speaks X11 and would report success without receiving keys); the row is disabled. Closes #377 Co-Authored-By: Claude Opus 5.5 --- src-tauri/Cargo.lock | 71 +++- src-tauri/Cargo.toml | 3 + src-tauri/src/client_prefs.rs | 13 + src-tauri/src/hotkey.rs | 447 +++++++++++++++++++++++++ src-tauri/src/lib.rs | 14 + src-tauri/src/menubar/macos.rs | 24 +- src-tauri/src/menubar/macos/preview.rs | 3 +- src-tauri/src/menubar/mod.rs | 1 + src-tauri/src/menubar/model.rs | 113 ++++++- src-tauri/src/menubar/model/tests.rs | 46 +++ src-tauri/src/prefs.rs | 14 +- src-tauri/src/settings.rs | 111 +++++- src/palette/hotkey.test.ts | 33 ++ src/palette/keys.tsx | 59 +++- src/settings/GeneralSection.i18n.ts | 31 +- src/settings/GeneralSection.tsx | 181 +++++++++- src/settings/api.ts | 28 ++ 17 files changed, 1159 insertions(+), 33 deletions(-) create mode 100644 src-tauri/src/hotkey.rs create mode 100644 src/palette/hotkey.test.ts diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 9b517054..a4795212 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -82,7 +82,7 @@ dependencies = [ "objc2-foundation", "parking_lot", "percent-encoding", - "windows-sys 0.52.0", + "windows-sys 0.60.2", "wl-clipboard-rs", "x11rb", ] @@ -1171,7 +1171,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1656,6 +1656,24 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" +[[package]] +name = "global-hotkey" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c386b0a4a70cb2d39fffd74480f985b6f0bfbcb934b6a6b6b7e630e448f242e" +dependencies = [ + "crossbeam-channel", + "keyboard-types 0.7.0", + "objc2", + "objc2-app-kit", + "once_cell", + "serde", + "thiserror 2.0.21", + "windows-sys 0.59.0", + "x11rb", + "xkeysym", +] + [[package]] name = "gobject-sys" version = "0.18.0" @@ -2333,6 +2351,17 @@ dependencies = [ "serde_json", ] +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.13.2", + "serde", + "unicode-segmentation", +] + [[package]] name = "keyboard-types" version = "0.8.3" @@ -2557,7 +2586,7 @@ dependencies = [ "crossbeam-channel", "dpi", "gtk", - "keyboard-types", + "keyboard-types 0.8.3", "objc2", "objc2-app-kit", "objc2-core-foundation", @@ -2997,7 +3026,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.45.0", + "windows-sys 0.61.2", ] [[package]] @@ -3626,7 +3655,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3682,7 +3711,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4492,9 +4521,9 @@ dependencies = [ [[package]] name = "tauri-plugin" -version = "2.6.3" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020" +checksum = "1140cf34a3b3b836a13103dcab17f18831d5cc3534cbd435dc01a5c6daa65aa2" dependencies = [ "anyhow", "glob", @@ -4598,6 +4627,21 @@ dependencies = [ "url", ] +[[package]] +name = "tauri-plugin-global-shortcut" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93ff17919fe09852d269bd37b1d3d2e993b9dbb514afe7acbf3346c1d3627e2d" +dependencies = [ + "global-hotkey", + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.21", +] + [[package]] name = "tauri-plugin-notification" version = "2.4.0" @@ -4810,7 +4854,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4864,6 +4908,7 @@ dependencies = [ "tauri-plugin-clipboard-manager", "tauri-plugin-deep-link", "tauri-plugin-dialog", + "tauri-plugin-global-shortcut", "tauri-plugin-notification", "tauri-plugin-opener", "tauri-plugin-single-instance", @@ -5891,7 +5936,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -6536,6 +6581,12 @@ dependencies = [ "rustix", ] +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + [[package]] name = "yoke" version = "0.8.3" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 7a1c4333..9fab3e1b 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -57,6 +57,9 @@ tauri = { version = "2.12.1", default-features = false, features = [ tauri-plugin-opener = "2" # 浏览器里授权完成之后,那一页把应用带回前台(`thinkwatch://`) tauri-plugin-deep-link = "2" +# 呼出快捷键(设置 → 通用,见 `hotkey.rs`):macOS 的 RegisterEventHotKey、Windows 的 +# RegisterHotKey、X11 的 XGrabKey +tauri-plugin-global-shortcut = "2" # 控制面契约来自 core,版本钉在下面的 `[workspace.dependencies]` 里。 tw-api = { workspace = true, features = ["ts"] } diff --git a/src-tauri/src/client_prefs.rs b/src-tauri/src/client_prefs.rs index a1247e45..64fdcdbb 100644 --- a/src-tauri/src/client_prefs.rs +++ b/src-tauri/src/client_prefs.rs @@ -355,6 +355,19 @@ mod tests { assert_eq!(got.zoom, crate::zoom::Zoom::default()); } + /// 呼出快捷键也是这台电脑自己的:不交上去,别处交下来的也不盖 + #[test] + fn the_hotkey_stays_on_this_computer() { + let p = Prefs { + hotkey: crate::hotkey::Setting(None), + ..Prefs::default() + }; + assert!(!general(&p).contains_key("hotkey")); + assert!(same(&Prefs::default(), &p)); + let got = overlay(&Prefs::default(), &serde_json::json!({ "hotkey": "" })); + assert_eq!(got.hotkey, crate::hotkey::Setting::default()); + } + #[test] fn sameness_only_looks_at_the_shared_settings() { let a = Prefs::default(); diff --git a/src-tauri/src/hotkey.rs b/src-tauri/src/hotkey.rs new file mode 100644 index 00000000..774c237e --- /dev/null +++ b/src-tauri/src/hotkey.rs @@ -0,0 +1,447 @@ +//! 呼出快捷键:在任意程序里按下,主界面出现在最前;主界面已经在最前时再按一次收起(lite#377)。 +//! +//! **出厂就有一个:Ctrl+Alt+H**(macOS 上是 ⌃⌥H,同样是 Control 和 Option/Alt 两个键)。 +//! 全局快捷键对所有程序生效,缺省的那个会让每一位用户在别的程序里失去这个组合,所以挑的是 +//! 最不碍事的一个:Windows 和 Linux 上 Ctrl+Alt 就是 AltGr,美式国际、匈牙利、捷克、波兰、 +//! 德语这些布局里 26 个字母只有 H 配 AltGr 不出字 —— 换成别的字母会挡住那些用户输入 `|`、 +//! `@`、`€`、`ł`。和系统、浏览器、VS Code 都不冲突;只和 JetBrains 系 IDE 的「调用层次 +//! 结构」撞,那边的用户在设置里换一个或关掉。 +//! +//! **被别的程序占着就算了**:启动时注册不上不弹通知,设置页那一行说被占用了,由用户另选。 +//! +//! **每台电脑各自的,不同步**(和界面大小一样,见 `zoom`):各台电脑的键盘布局和别的程序 +//! 占着的键不一样。 +//! +//! 走 Tauri 的 global-shortcut 插件:macOS 是 Carbon 的 `RegisterEventHotKey`(不要辅助 +//! 功能权限),Windows 是 `RegisterHotKey`,Linux 是 X11 的 `XGrabKey`。**Wayland 上用不了**: +//! 插件只会 X11,在 Wayland 会话里注册「成功」却永远收不到按键(读过 global-hotkey 0.8 +//! 的源码:连不上 X 的那个线程退出之后,注册照样回 `Ok`),所以那边直接说不支持、不注册。 + +use std::sync::Mutex; + +use tauri::Manager; +use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, Modifiers, Shortcut, ShortcutState}; + +/// 一个组合键。**主键按物理键位记**(W3C 的 `KeyboardEvent.code`:`KeyH`、`Digit1`、`F5`), +/// 不受输入法和键盘布局影响;界面录的就是它。`meta` 是 macOS 的 ⌘、别处的 Win / Super 键 +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct Combo { + pub ctrl: bool, + pub alt: bool, + pub shift: bool, + pub meta: bool, + pub code: String, +} + +/// 出厂那一个:Ctrl+Alt+H +pub fn default_combo() -> Combo { + Combo { + ctrl: true, + alt: true, + shift: false, + meta: false, + code: "KeyH".into(), + } +} + +/// 能当主键的键。修饰键、Esc(录入时用来取消)、回车、Tab、退格不在里面 +fn allowed_key(code: &str) -> bool { + let tail = |prefix: &str| code.strip_prefix(prefix); + matches!(tail("Key"), Some(c) if c.len() == 1 && c.as_bytes()[0].is_ascii_uppercase()) + || matches!(tail("Digit"), Some(c) if c.len() == 1 && c.as_bytes()[0].is_ascii_digit()) + || function_key(code) + || matches!( + code, + "Space" + | "ArrowUp" + | "ArrowDown" + | "ArrowLeft" + | "ArrowRight" + | "Home" + | "End" + | "PageUp" + | "PageDown" + | "Insert" + | "Delete" + | "Backquote" + | "Minus" + | "Equal" + | "BracketLeft" + | "BracketRight" + | "Backslash" + | "Semicolon" + | "Quote" + | "Comma" + | "Period" + | "Slash" + ) +} + +/// F1–F12 +fn function_key(code: &str) -> bool { + code.strip_prefix('F') + .and_then(|n| n.parse::().ok()) + .is_some_and(|n| (1..=12).contains(&n)) +} + +/// 录下来的组合为什么不能用 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Invalid { + /// 不是能当主键的键 + Key, + /// 没带 Ctrl、Alt、⌘ 里的任何一个(只有 Shift 不算)。F1–F12 例外 + NoModifier, +} + +impl Combo { + /// 能不能用:主键在名单里,带着 Ctrl、Alt、⌘ 之一(F1–F12 可以不带) + pub fn check(&self) -> Result<(), Invalid> { + if !allowed_key(&self.code) { + return Err(Invalid::Key); + } + if !(self.ctrl || self.alt || self.meta || function_key(&self.code)) { + return Err(Invalid::NoModifier); + } + Ok(()) + } + + /// 交给插件的那一个 + fn shortcut(&self) -> Option { + let mut mods = Modifiers::empty(); + mods.set(Modifiers::CONTROL, self.ctrl); + mods.set(Modifiers::ALT, self.alt); + mods.set(Modifiers::SHIFT, self.shift); + mods.set(Modifiers::SUPER, self.meta); + let code: Code = self.code.parse().ok()?; + Some(Shortcut::new(Some(mods), code)) + } + + /// 存进 `app.json` 的写法:`Ctrl+Alt+H`。修饰键顺序固定,字母、数字写成键上的字, + /// 别的写成 W3C 的键名(`F5`、`Space`、`Backquote`) + fn text(&self) -> String { + let mut parts: Vec<&str> = Vec::new(); + for (on, name) in [ + (self.ctrl, "Ctrl"), + (self.alt, "Alt"), + (self.shift, "Shift"), + (self.meta, "Super"), + ] { + if on { + parts.push(name); + } + } + let key = self + .code + .strip_prefix("Key") + .or_else(|| self.code.strip_prefix("Digit")) + .unwrap_or(&self.code); + parts.push(key); + parts.join("+") + } + + /// [`Combo::text`] 的反方向。写法不对、不能用的读不进来 + fn parse(s: &str) -> Option { + let mut c = Combo { + ctrl: false, + alt: false, + shift: false, + meta: false, + code: String::new(), + }; + let mut parts: Vec<&str> = s.split('+').collect(); + let key = parts.pop()?; + for m in parts { + let flag = match m { + "Ctrl" => &mut c.ctrl, + "Alt" => &mut c.alt, + "Shift" => &mut c.shift, + "Super" => &mut c.meta, + _ => return None, + }; + if std::mem::replace(flag, true) { + return None; + } + } + c.code = match key.as_bytes() { + [b] if b.is_ascii_uppercase() => format!("Key{key}"), + [b] if b.is_ascii_digit() => format!("Digit{key}"), + _ => key.to_string(), + }; + c.check().ok().map(|_| c) + } +} + +/// `app.json` 里的那一项。`None` 是关掉了 +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(try_from = "String", into = "String")] +pub struct Setting(pub Option); + +impl Default for Setting { + fn default() -> Self { + Self(Some(default_combo())) + } +} + +/// **关掉写成空串**,不是不写:不写是出厂那一个 +impl TryFrom for Setting { + type Error = String; + fn try_from(s: String) -> Result { + if s.is_empty() { + return Ok(Self(None)); + } + Combo::parse(&s) + .map(|c| Self(Some(c))) + .ok_or_else(|| format!("{s} is not a usable shortcut")) + } +} + +impl From for String { + fn from(s: Setting) -> String { + s.0.map(|c| c.text()).unwrap_or_default() + } +} + +/// 这个桌面能不能用全局快捷键。只有 Linux 的 Wayland 会话不能 +pub fn supported() -> bool { + #[cfg(target_os = "linux")] + { + let wayland = std::env::var_os("WAYLAND_DISPLAY").is_some_and(|v| !v.is_empty()) + || std::env::var("XDG_SESSION_TYPE").is_ok_and(|v| v.eq_ignore_ascii_case("wayland")); + !wayland + } + #[cfg(not(target_os = "linux"))] + true +} + +/// 现在注册着的那一个。设置里有、却注册不上(被别的程序占着)时是 `None` +static ACTIVE: Mutex> = Mutex::new(None); + +/// 现在注册着的那一个(菜单栏把它写在「打开主界面」右边) +pub fn active() -> Option { + ACTIVE.lock().unwrap().clone() +} + +/// 插件:按下时叫 [`pressed`]。**不支持的桌面不装**(见模块说明) +pub fn plugin() -> Option> { + supported().then(|| { + tauri_plugin_global_shortcut::Builder::new() + .with_handler(|app, _shortcut, event| { + if event.state == ShortcutState::Pressed { + pressed(app); + } + }) + .build() + }) +} + +/// 启动时按设置注册。注册不上(被别的程序占着)不报错,设置页那一行会说 +pub fn init(app: &tauri::AppHandle, s: &Setting) { + if let Some(c) = &s.0 + && let Err(e) = register(app, c) + { + tracing::debug!("呼出快捷键 {} 注册不上:{e}", c.text()); + } +} + +/// 换成 `next`:先放掉现在的、再注册新的;**新的注册不上就把旧的注册回去**,交回原因 +pub fn replace(app: &tauri::AppHandle, next: Option<&Combo>) -> Result<(), String> { + let was = active(); + if let Some(c) = &was { + unregister(app, c); + } + let Some(c) = next else { return Ok(()) }; + register(app, c).inspect_err(|_| { + if let Some(w) = &was { + let _ = register(app, w); + } + }) +} + +/// 设置页在录新的组合:**先放掉现在的**。不放的话按下现在这一个,系统先把它交给我们、 +/// 窗口当场收起,网页根本收不到这一下 —— 想重录同一个组合都录不进去 +pub fn pause(app: &tauri::AppHandle) { + if let Some(c) = active() { + unregister(app, &c); + } +} + +/// 录完了(录上了、取消了、页面没了):按设置注册回去。已经注册着的不动 +pub fn resume(app: &tauri::AppHandle) { + if active().is_none() { + init(app, &crate::prefs::load(&crate::data_dir()).hotkey); + } +} + +fn register(app: &tauri::AppHandle, c: &Combo) -> Result<(), String> { + let s = c.shortcut().ok_or("unknown key")?; + let gs = app.try_state::>(); + let gs = gs.ok_or("global shortcuts are not available")?; + gs.register(s).map_err(|e| e.to_string())?; + *ACTIVE.lock().unwrap() = Some(c.clone()); + Ok(()) +} + +fn unregister(app: &tauri::AppHandle, c: &Combo) { + if let Some(s) = c.shortcut() { + let _ = app.global_shortcut().unregister(s); + } + *ACTIVE.lock().unwrap() = None; +} + +/// 按下了。**主窗口在最前、有焦点时收起**(和点关闭按钮一样销毁),别的情况都叫到最前。 +/// +/// 排到主线程上:Linux 上插件是在自己的 X11 线程里叫这里的 +fn pressed(app: &tauri::AppHandle) { + let a = app.clone(); + let _ = app.run_on_main_thread(move || { + if let Some(w) = a.get_webview_window("main") { + let front = w.is_visible().unwrap_or(false) + && w.is_focused().unwrap_or(false) + && !w.is_minimized().unwrap_or(false); + if front { + let _ = w.close(); + return; + } + if w.is_minimized().unwrap_or(false) { + let _ = w.unminimize(); + } + } + if let Err(e) = crate::window::show_main_window(&a) { + tracing::warn!("呼出快捷键打不开主窗口:{e}"); + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn combo(ctrl: bool, alt: bool, shift: bool, meta: bool, code: &str) -> Combo { + Combo { + ctrl, + alt, + shift, + meta, + code: code.into(), + } + } + + /// 存成 `Ctrl+Alt+H` 这样的一串,关掉是空串,不写是出厂那一个 + #[test] + fn it_is_written_as_text_and_off_is_an_empty_string() { + let json = |s: &Setting| serde_json::to_string(s).unwrap(); + assert_eq!(json(&Setting::default()), r#""Ctrl+Alt+H""#); + assert_eq!(json(&Setting(None)), r#""""#); + let c = combo(true, false, true, true, "F5"); + assert_eq!(json(&Setting(Some(c.clone()))), r#""Ctrl+Shift+Super+F5""#); + assert_eq!( + serde_json::from_str::(r#""Ctrl+Shift+Super+F5""#).unwrap(), + Setting(Some(c)) + ); + assert_eq!( + serde_json::from_str::(r#""Alt+7""#).unwrap(), + Setting(Some(combo(false, true, false, false, "Digit7"))) + ); + assert_eq!( + serde_json::from_str::(r#""""#).unwrap(), + Setting(None) + ); + } + + /// 写坏了的、不能用的读不进来(`prefs` 那边按出厂那一个) + #[test] + fn broken_or_unusable_text_is_not_read() { + for s in [ + "H", + "Shift+H", + "Ctrl+Ctrl+H", + "Hyper+H", + "Ctrl+Escape", + "Ctrl+", + "Ctrl+F13", + ] { + assert!( + serde_json::from_str::(&format!("{s:?}")).is_err(), + "{s}" + ); + } + } + + /// 至少带 Ctrl、Alt、⌘ 之一;只有 Shift 不算;F1–F12 可以不带 + #[test] + fn a_modifier_is_required_except_for_function_keys() { + assert_eq!( + combo(false, false, true, false, "KeyH").check(), + Err(Invalid::NoModifier) + ); + assert_eq!( + combo(false, false, false, false, "Space").check(), + Err(Invalid::NoModifier) + ); + assert_eq!(combo(false, false, false, false, "F3").check(), Ok(())); + assert_eq!(combo(false, false, true, false, "F12").check(), Ok(())); + assert_eq!(combo(false, true, false, false, "Space").check(), Ok(())); + assert_eq!( + combo(true, false, false, false, "Escape").check(), + Err(Invalid::Key) + ); + assert_eq!( + combo(true, false, false, false, "ShiftLeft").check(), + Err(Invalid::Key) + ); + assert_eq!( + combo(true, false, false, false, "Enter").check(), + Err(Invalid::Key) + ); + } + + /// 每一个能录进来的键插件都认得(`Code` 按 W3C 的键名解析) + #[test] + fn every_allowed_key_becomes_a_shortcut() { + let mut codes: Vec = ('A'..='Z').map(|c| format!("Key{c}")).collect(); + codes.extend(('0'..='9').map(|c| format!("Digit{c}"))); + codes.extend((1..=12).map(|n| format!("F{n}"))); + for c in [ + "Space", + "ArrowUp", + "ArrowDown", + "ArrowLeft", + "ArrowRight", + "Home", + "End", + "PageUp", + "PageDown", + "Insert", + "Delete", + "Backquote", + "Minus", + "Equal", + "BracketLeft", + "BracketRight", + "Backslash", + "Semicolon", + "Quote", + "Comma", + "Period", + "Slash", + ] { + codes.push(c.into()); + } + for code in codes { + let c = combo(true, true, false, false, &code); + assert_eq!(c.check(), Ok(()), "{code}"); + assert!(c.shortcut().is_some(), "{code}"); + assert_eq!(Combo::parse(&c.text()), Some(c), "{code}"); + } + } + + /// 出厂那一个是 Control + Alt + H,不是 ⌘ + #[test] + fn the_default_is_control_alt_h() { + let s = default_combo().shortcut().unwrap(); + assert_eq!( + s, + Shortcut::new(Some(Modifiers::CONTROL | Modifiers::ALT), Code::KeyH) + ); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d9046e10..d05d3701 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -44,6 +44,8 @@ pub mod export; pub mod gateway; /// 升级不断线:请 core 等接替者、已 arm 的退出不停 core、`brew upgrade` 的识别,见模块头上 pub mod handover; +/// 呼出快捷键:按下叫出主界面,在最前时再按一次收起,见模块头上 +pub mod hotkey; pub mod import_link; pub mod keys; /// 一个在跑的请求的实时内容,搬给请求详情的「内容」页 @@ -192,6 +194,11 @@ pub fn run() { .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_deep_link::init()); + // 呼出快捷键。Wayland 上不装(插件只会 X11,见 `hotkey`) + let builder = match hotkey::plugin() { + Some(p) => builder.plugin(p), + None => builder, + }; // Linux 上不注册:自启项是自己写的(见 `autostart::linux`)。插件在那边 // 初始化时只是建个对象、不碰磁盘,留着它就是第二份说法 —— 它认的是另一个 // 文件名,`is_enabled` 也不看桌面有没有把它关掉 @@ -265,6 +272,9 @@ pub fn run() { settings::ui_zoom, settings::set_ui_zoom, settings::step_ui_zoom, + settings::hotkey_state, + settings::set_hotkey, + settings::set_hotkey_recording, updater::update_check, updater::update_show, updater::update_pending, @@ -324,6 +334,8 @@ pub fn run() { theme::init(&handle, saved.theme); // 界面大小也是:窗口一建出来就按它缩放(见 `window::show_main_window`) zoom::remember(saved.zoom); + // 呼出快捷键:主窗口没开、只在菜单栏跑着的时候也要能叫出来 + hotkey::init(&handle, &saved.hotkey); // 链接、开机自启、通知登记改成指向正在运行的这个 exe(见 `winreg`)。 // 要在下面判断系统通知能不能用之前 winreg::claim(&handle); @@ -644,6 +656,8 @@ pub fn run() { // 开着的导入对话框跟着窗口没了:下一条导入链接可以进来 if closing == "main" { import_link::window_destroyed(); + // 设置页录到一半窗口关了:放掉的呼出快捷键注册回去 + hotkey::resume(&app); } // **只有最后一个窗口关掉时才退回菜单栏应用。**更新窗口关掉 // 的时候主窗口可能还开着 —— 那时把 Dock 图标收掉,用户就没法 diff --git a/src-tauri/src/menubar/macos.rs b/src-tauri/src/menubar/macos.rs index bb7dad02..1adc6f01 100644 --- a/src-tauri/src/menubar/macos.rs +++ b/src-tauri/src/menubar/macos.rs @@ -692,9 +692,27 @@ fn fill_item( item.setImage(None); } } - if let Some(key) = i.key { - item.setKeyEquivalent(&NSString::from_str(key)); - item.setKeyEquivalentModifierMask(NSEventModifierFlags::Command); + // **变了才设**,没有了要清掉(呼出快捷键在设置里关掉、换掉时) + if was.is_none_or(|w| w.key != i.key) { + let (equivalent, mask) = match &i.key { + Some(k) => { + let mut mask = NSEventModifierFlags::empty(); + for (on, flag) in [ + (k.control, NSEventModifierFlags::Control), + (k.option, NSEventModifierFlags::Option), + (k.shift, NSEventModifierFlags::Shift), + (k.command, NSEventModifierFlags::Command), + ] { + if on { + mask |= flag; + } + } + (k.equivalent.as_str(), mask) + } + None => ("", NSEventModifierFlags::empty()), + }; + item.setKeyEquivalent(&NSString::from_str(equivalent)); + item.setKeyEquivalentModifierMask(mask); } let target_obj = ui.target.clone(); let target: &AnyObject = &target_obj; diff --git a/src-tauri/src/menubar/macos/preview.rs b/src-tauri/src/menubar/macos/preview.rs index 58f775bc..fe364780 100644 --- a/src-tauri/src/menubar/macos/preview.rs +++ b/src-tauri/src/menubar/macos/preview.rs @@ -240,7 +240,8 @@ fn preview_item(i: &Item, b: NSRect) { attributed(&i.title, &font, &color).drawAtPoint(NSPoint::new(TEXT_X, 3.0)); let right = i .key - .map(|k| format!("⌘{}", k.to_uppercase())) + .as_ref() + .map(|k| k.label()) .or_else(|| i.right.clone()) .or_else(|| (!i.submenu.is_empty()).then(|| "›".to_string())); if let Some(r) = right { diff --git a/src-tauri/src/menubar/mod.rs b/src-tauri/src/menubar/mod.rs index e1cff23e..04dacb0a 100644 --- a/src-tauri/src/menubar/mod.rs +++ b/src-tauri/src/menubar/mod.rs @@ -453,6 +453,7 @@ async fn collect(app: &tauri::AppHandle, state: &AppState, credits: &mut Credits .is_some_and(|n| n.mode() != notices::Mode::Off), notices: notices.map(|n| unread(&n.list())).unwrap_or_default(), update: crate::updater::pending_update(app), + hotkey: crate::hotkey::active(), connections: crate::connection::view(app) .profiles .into_iter() diff --git a/src-tauri/src/menubar/model.rs b/src-tauri/src/menubar/model.rs index 2d73a8f6..be288f7d 100644 --- a/src-tauri/src/menubar/model.rs +++ b/src-tauri/src/menubar/model.rs @@ -72,6 +72,8 @@ pub struct Snapshot { pub groups: Vec, /// 查到了、还没装的新版本 pub update: Option, + /// 注册着的呼出快捷键:写在「打开主界面」右边 + pub hotkey: Option, /// 连接列表,本机在最前。「连接」子菜单照它列 pub connections: Vec, /// 连着远程时是那台服务器的名字。跟在状态后面写:菜单里的数字、提醒都是那台的 @@ -449,6 +451,94 @@ pub struct SubItem { pub sep_before: bool, } +/// 菜单项的快捷键:NSMenuItem 的 `keyEquivalent`(小写字母、数字、符号,或功能键那几个私用区 +/// 的字)和修饰键 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Key { + pub equivalent: String, + pub control: bool, + pub option: bool, + pub shift: bool, + pub command: bool, +} + +impl Key { + fn command(equivalent: &str) -> Self { + Self { + equivalent: equivalent.into(), + control: false, + option: false, + shift: false, + command: true, + } + } + + /// 系统菜单右边画出来的样子(`⌃⌥H`、`⌘,`),修饰键按 ⌃⌥⇧⌘ 的顺序。预览图用 + pub fn label(&self) -> String { + let mut out = String::new(); + for (on, glyph) in [ + (self.control, '⌃'), + (self.option, '⌥'), + (self.shift, '⇧'), + (self.command, '⌘'), + ] { + if on { + out.push(glyph); + } + } + out.push_str(&self.equivalent.to_uppercase()); + out + } + + /// 呼出快捷键写在菜单上的样子。按物理键位记的主键换成 `keyEquivalent` 的字;换不出来的 + /// 不写 + fn of(c: &crate::hotkey::Combo) -> Option { + let code = c.code.as_str(); + let equivalent = if let Some(k) = code.strip_prefix("Key") { + k.to_ascii_lowercase() + } else if let Some(d) = code.strip_prefix("Digit") { + d.to_string() + } else if let Some(n) = code.strip_prefix('F').and_then(|n| n.parse::().ok()) { + // NSF1FunctionKey 起一个挨一个 + char::from_u32(0xF704 + n - 1)?.to_string() + } else { + let ch = match code { + "Space" => ' ', + "ArrowUp" => '\u{F700}', + "ArrowDown" => '\u{F701}', + "ArrowLeft" => '\u{F702}', + "ArrowRight" => '\u{F703}', + "Insert" => '\u{F727}', + "Delete" => '\u{F728}', + "Home" => '\u{F729}', + "End" => '\u{F72B}', + "PageUp" => '\u{F72C}', + "PageDown" => '\u{F72D}', + "Backquote" => '`', + "Minus" => '-', + "Equal" => '=', + "BracketLeft" => '[', + "BracketRight" => ']', + "Backslash" => '\\', + "Semicolon" => ';', + "Quote" => '\'', + "Comma" => ',', + "Period" => '.', + "Slash" => '/', + _ => return None, + }; + ch.to_string() + }; + Some(Self { + equivalent, + control: c.ctrl, + option: c.alt, + shift: c.shift, + command: c.meta, + }) + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct Item { /// 稳定的名字:菜单开着时按它就地更新,不重建 @@ -460,8 +550,8 @@ pub struct Item { pub right: Option, pub enabled: bool, pub action: Option, - /// 菜单开着时的快捷键(`,`、`q`),配 ⌘ - pub key: Option<&'static str>, + /// 标题右边写着的快捷键:菜单开着时按它也一样(`,`、`q` 配 ⌘;「打开主界面」是呼出快捷键) + pub key: Option, pub submenu: Vec, /// 图标用强调色(有新版本) pub accent: bool, @@ -485,8 +575,8 @@ impl Item { self.right = Some(right.into()); self } - fn key(mut self, key: &'static str) -> Self { - self.key = Some(key); + fn key(mut self, key: &str) -> Self { + self.key = Some(Key::command(key)); self } fn disabled(mut self) -> Self { @@ -709,12 +799,15 @@ fn live_count(n: usize) -> String { /// 设置、连接 ─ 退出。不在运行时:打开主界面 ─ 状态和能做的事 ─ 更新、设置、连接 ─ 退出 fn rows(s: &Snapshot) -> Vec { let mut out = vec![ - Row::Item(Item::new( - "open", - "macwindow", - tr!("打开主界面", "Open ThinkWatch Lite"), - Action::OpenMain, - )), + Row::Item(Item { + key: s.hotkey.as_ref().and_then(Key::of), + ..Item::new( + "open", + "macwindow", + tr!("打开主界面", "Open ThinkWatch Lite"), + Action::OpenMain, + ) + }), Row::Separator, ]; if !s.gateway.running() { diff --git a/src-tauri/src/menubar/model/tests.rs b/src-tauri/src/menubar/model/tests.rs index e2c49263..cd217406 100644 --- a/src-tauri/src/menubar/model/tests.rs +++ b/src-tauri/src/menubar/model/tests.rs @@ -1213,3 +1213,49 @@ fn open_is_the_first_item_once_in_every_state() { } } } + +/// 「打开主界面」右边写着注册着的呼出快捷键;关掉了、没注册上就不写。设置、退出照旧配 ⌘ +#[test] +fn the_open_item_shows_the_hotkey() { + let mut s = running(); + let (_, rows) = build(&s, Style::Full); + assert_eq!(item(&rows, "open").unwrap().key, None); + assert_eq!( + item(&rows, "settings") + .unwrap() + .key + .as_ref() + .unwrap() + .label(), + "⌘," + ); + + s.hotkey = Some(crate::hotkey::default_combo()); + let (_, rows) = build(&s, Style::Full); + let k = item(&rows, "open").unwrap().key.clone().unwrap(); + assert_eq!(k.label(), "⌃⌥H"); + assert_eq!((k.equivalent.as_str(), k.command), ("h", false)); +} + +/// 按物理键位记的主键换成 `keyEquivalent` 的字:字母小写、功能键和方向键是私用区那几个字 +#[test] +fn hotkey_keys_become_menu_key_equivalents() { + let k = |code: &str| { + let c = crate::hotkey::Combo { + ctrl: false, + alt: false, + shift: true, + meta: true, + code: code.into(), + }; + Key::of(&c).map(|k| k.equivalent) + }; + assert_eq!(k("KeyQ").as_deref(), Some("q")); + assert_eq!(k("Digit7").as_deref(), Some("7")); + assert_eq!(k("F1").as_deref(), Some("\u{F704}")); + assert_eq!(k("F12").as_deref(), Some("\u{F70F}")); + assert_eq!(k("ArrowUp").as_deref(), Some("\u{F700}")); + assert_eq!(k("Space").as_deref(), Some(" ")); + assert_eq!(k("Backslash").as_deref(), Some("\\")); + assert_eq!(k("Escape"), None); +} diff --git a/src-tauri/src/prefs.rs b/src-tauri/src/prefs.rs index 3b65cf43..98a9870c 100644 --- a/src-tauri/src/prefs.rs +++ b/src-tauri/src/prefs.rs @@ -33,6 +33,10 @@ pub struct Prefs { /// 电脑的屏幕,不同步(不在 `client_prefs` 的列表里)。没写就是 100% #[serde(default)] pub zoom: crate::zoom::Zoom, + /// 呼出快捷键(见 `hotkey`)。**每台电脑各自的**,理由同界面大小。没写就是出厂的 + /// Ctrl+Alt+H,关掉写成空串 + #[serde(default)] + pub hotkey: crate::hotkey::Setting, /// 提醒:系统通知 / 仅在应用内 / 关闭。**只有这一个,不分类。** pub notices: Mode, /// 菜单栏上显示什么:标识和数值(出厂)/ 仅标识 / 仅数值 / 不显示。 @@ -64,6 +68,7 @@ impl Default for Prefs { language: None, theme: None, zoom: crate::zoom::Zoom::default(), + hotkey: crate::hotkey::Setting::default(), notices: Mode::System, menubar: crate::menubar::Style::Full, client_locations: BTreeMap::new(), @@ -114,6 +119,7 @@ fn from_fields(fields: &Fields) -> Prefs { language: field(fields, "language").unwrap_or(d.language), theme: field(fields, "theme").unwrap_or(d.theme), zoom: field(fields, "zoom").unwrap_or(d.zoom), + hotkey: field(fields, "hotkey").unwrap_or(d.hotkey), notices: field(fields, "notices").unwrap_or(d.notices), menubar: field(fields, "menubar").unwrap_or(d.menubar), // 一个客户端一个客户端地读:读不懂的只丢那一个 @@ -310,6 +316,7 @@ mod tests { language: Some(Lang::Zh), theme: Some(Theme::Dark), zoom: crate::zoom::Zoom::try_from(125).unwrap(), + hotkey: crate::hotkey::Setting(None), notices: Mode::App, menubar: crate::menubar::Style::Numbers, client_locations: BTreeMap::from([( @@ -420,7 +427,7 @@ mod tests { let (_tmp, dir) = tmp(); std::fs::write( prefs_path(&dir), - br#"{"check_updates":"no","language":"ja","theme":1,"zoom":120,"notices":"off","menubar":"icon"}"#, + br#"{"check_updates":"no","language":"ja","theme":1,"zoom":120,"hotkey":"Shift+H","notices":"off","menubar":"icon"}"#, ) .unwrap(); let p = load(&dir); @@ -428,6 +435,11 @@ mod tests { assert_eq!(p.language, None); assert_eq!(p.theme, None); assert_eq!(p.zoom, crate::zoom::Zoom::default(), "不是那几档的按 100%"); + assert_eq!( + p.hotkey, + crate::hotkey::Setting::default(), + "不能用的按出厂那一个" + ); assert_eq!(p.notices, Mode::Off); assert_eq!(p.menubar, crate::menubar::Style::Icon); } diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 8a291af5..3418574e 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -1,12 +1,12 @@ -//! 设置页:语言、外观、界面大小、开机启动、提醒方式、菜单栏样式。 +//! 设置页:语言、外观、界面大小、呼出快捷键、开机启动、提醒方式、菜单栏样式。 use std::sync::Arc; use tauri::{Emitter, Manager}; use crate::{ - AppState, autostart, data_dir, error::Out, gateway::locate_core, i18n, menubar, notices, prefs, - theme, zoom, + AppState, autostart, data_dir, error::Out, gateway::locate_core, hotkey, i18n, menubar, + notices, prefs, theme, zoom, }; /// 这个应用自己的信息。 @@ -149,6 +149,111 @@ async fn change_zoom( saved.map(|_| now) } +/// 呼出快捷键(见 `hotkey`):设置里的、出厂的、注册上了没有、这个桌面支不支持。 +/// +/// **设置里有、却没注册上,就是被别的程序占着**:出厂的那一个在启动时注册不上不报错, +/// 设置页那一行照这个说 +#[derive(serde::Serialize)] +pub struct HotkeyView { + /// `None` 是关掉了 + combo: Option, + default: hotkey::Combo, + active: bool, + supported: bool, +} + +fn hotkey_view() -> HotkeyView { + let combo = prefs::load(&data_dir()).hotkey.0; + let active = hotkey::active(); + HotkeyView { + active: combo.is_some() && active == combo, + combo, + default: hotkey::default_combo(), + supported: hotkey::supported(), + } +} + +#[tauri::command] +pub fn hotkey_state() -> HotkeyView { + hotkey_view() +} + +/// ⌘ / Win / Super 键在这个平台上叫什么 +const META: &str = if cfg!(target_os = "macos") { + "⌘" +} else if cfg!(windows) { + "Win" +} else { + "Super" +}; + +/// 换一个(`None` 是关掉)。**先注册、注册上了才存**:被别的程序占着的组合存下来也用不了; +/// 注册不上时旧的那一个照旧注册着、设置不变。 +/// +/// 不通知同步:这是这台电脑自己的(见 `hotkey`) +#[tauri::command] +pub async fn set_hotkey(app: tauri::AppHandle, combo: Option) -> Out { + if !hotkey::supported() { + return Err(tr!( + "当前桌面环境不支持全局快捷键", + "Global shortcuts are not supported on this desktop" + ) + .into()); + } + if let Some(c) = &combo { + c.check().map_err(|e| match e { + hotkey::Invalid::Key => tr!( + "这个键不能用作快捷键", + "This key cannot be used in a shortcut" + ) + .to_string(), + hotkey::Invalid::NoModifier => tr!( + format!("须包含 Ctrl、Alt 或 {META} 中的至少一个"), + format!("The shortcut must include Ctrl, Alt or {META}") + ), + })?; + } + // 注册要在主线程上做完再回来(插件阻塞着等),不占异步线程 + let a = app.clone(); + let next = combo.clone(); + crate::clients::blocking(move || hotkey::replace(&a, next.as_ref())) + .await? + .map_err(|e| { + tracing::debug!("呼出快捷键注册不上:{e}"); + tr!( + "该组合键已被其他程序占用,请换一个", + "This shortcut is already used by another app. Choose another one." + ) + })?; + let saved = prefs::change(move |p| p.hotkey = hotkey::Setting(combo)).await; + if saved.is_err() { + // 没存上:注册的退回存着的那一个 + let a = app.clone(); + let _ = crate::clients::blocking(move || { + let _ = hotkey::replace(&a, prefs::load(&data_dir()).hotkey.0.as_ref()); + }) + .await; + } + // 菜单栏「打开主界面」右边写着它 + if let Some(state) = app.try_state::() { + state.menubar_now.notify_one(); + } + saved.map(|_| hotkey_view()) +} + +/// 设置页开始、结束录入新的组合(见 `hotkey::pause`) +#[tauri::command] +pub async fn set_hotkey_recording(app: tauri::AppHandle, on: bool) -> Out<()> { + crate::clients::blocking(move || { + if on { + hotkey::pause(&app); + } else { + hotkey::resume(&app); + } + }) + .await +} + /// 开机自启现在是开着的吗。 /// /// **默认是关的,而且这不是「还没实现」,是产品决定。**一个装完就自己 diff --git a/src/palette/hotkey.test.ts b/src/palette/hotkey.test.ts new file mode 100644 index 00000000..dc22eb8f --- /dev/null +++ b/src/palette/hotkey.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { codeKey, comboText, hotkeyCombo, isInAppCombo } from "./keys"; + +// 测试里不是任何一个平台(`platform.ts` 的 "other"),主修饰键按 Ctrl 算 +describe("呼出快捷键", () => { + it("物理键位写成键上的字", () => { + expect(codeKey("KeyH")).toBe("H"); + expect(codeKey("Digit7")).toBe("7"); + expect(codeKey("Comma")).toBe(","); + expect(codeKey("ArrowUp")).toBe("up"); + expect(codeKey("Space")).toBe("space"); + expect(codeKey("F5")).toBe("F5"); + }); + + it("修饰键按 Ctrl、Alt、Shift、Win / Super 的顺序", () => { + const c = hotkeyCombo({ ctrl: true, alt: true, shift: true, meta: true, code: "KeyH" }); + expect(c).toEqual(["ctrl", "alt", "shift", "meta", "H"]); + expect(comboText(hotkeyCombo({ ctrl: true, alt: true, shift: false, meta: false, code: "KeyH" }))).toBe( + "Ctrl+Alt+H", + ); + }); + + /** 拿去做呼出的话,界面里那个就按不到了 */ + it("界面里已有的快捷键算占用,出厂的 Ctrl+Alt+H 不算", () => { + expect(isInAppCombo(["ctrl", "K"])).toBe(true); + expect(isInAppCombo(["ctrl", "1"])).toBe(true); + expect(isInAppCombo(["ctrl", "="])).toBe(true); + expect(isInAppCombo(["ctrl", ","])).toBe(true); + expect(isInAppCombo(["ctrl", "alt", "K"])).toBe(false); + expect(isInAppCombo(["ctrl", "alt", "H"])).toBe(false); + expect(isInAppCombo(["meta", "K"])).toBe(false); + }); +}); diff --git a/src/palette/keys.tsx b/src/palette/keys.tsx index e7f71cb5..2a01a804 100644 --- a/src/palette/keys.tsx +++ b/src/palette/keys.tsx @@ -1,6 +1,6 @@ import { Kbd, KbdGroup } from "@/ui/kbd"; import { cn } from "@/lib/utils"; -import { isMac } from "@/platform"; +import { isMac, isWindows } from "@/platform"; import { SURFACES, type Surface } from "@/nav"; /** @@ -13,6 +13,9 @@ import { SURFACES, type Surface } from "@/nav"; */ export type KeyName = | "mod" + /** 呼出快捷键才用:写明是 Control(⌃)和 ⌘ / Win 键,不随平台换成主修饰键 */ + | "ctrl" + | "meta" | "alt" | "shift" | "enter" @@ -29,6 +32,8 @@ export type Combo = readonly KeyName[]; const MAC: Record = { mod: "⌘", + ctrl: "⌃", + meta: "⌘", alt: "⌥", shift: "⇧", enter: "↵", @@ -39,6 +44,8 @@ const MAC: Record = { }; const OTHER: Record = { mod: "Ctrl", + ctrl: "Ctrl", + meta: isWindows ? "Win" : "Super", alt: "Alt", shift: "Shift", enter: "Enter", @@ -73,6 +80,56 @@ export const COMBOS = { zoomReset: ["mod", "0"], } as const satisfies Record; +/** 物理键位上的那个字:`KeyH` → `H`、`Digit1` → `1`、`Comma` → `,`;方向键、空格写成键名 */ +const CODE_CHARS: Record = { + Space: "space", + ArrowUp: "up", + ArrowDown: "down", + ArrowLeft: "left", + ArrowRight: "right", + Backquote: "`", + Minus: "-", + Equal: "=", + BracketLeft: "[", + BracketRight: "]", + Backslash: "\\", + Semicolon: ";", + Quote: "'", + Comma: ",", + Period: ".", + Slash: "/", +}; + +export function codeKey(code: string): KeyName { + const ch = /^(?:Key|Digit)(.)$/.exec(code)?.[1]; + return ch ?? CODE_CHARS[code] ?? code; +} + +/** 呼出快捷键的一个组合写成键帽。macOS 按 ⌃⌥⇧⌘ 的顺序,和系统菜单一样 */ +export function hotkeyCombo(c: { ctrl: boolean; alt: boolean; shift: boolean; meta: boolean; code: string }): Combo { + const mods: KeyName[] = []; + if (c.ctrl) mods.push("ctrl"); + if (c.alt) mods.push("alt"); + if (c.shift) mods.push("shift"); + if (c.meta) mods.push("meta"); + return [...mods, codeKey(c.code)]; +} + +/** + * 这个组合是不是界面里已经有的快捷键(⌘K、⌘1…⌘9、⌘= 这些)。**拿去做呼出快捷键的话, + * 系统先把它交给呼出、界面里的那一个就再也按不到了**,所以录的时候拒掉 + */ +export function isInAppCombo(c: Combo): boolean { + const norm = (x: Combo) => + x + .map((k) => (k === "mod" ? (isMac ? "meta" : "ctrl") : k.length === 1 ? k.toUpperCase() : k)) + .sort() + .join(" "); + const want = norm(c); + const all: Combo[] = [...Object.values(COMBOS), ...DIGIT_PAGES.map((_, i) => ["mod", String(i + 1)] as const)]; + return all.some((x) => norm(x) === want); +} + /** * 主修饰键加上的这个键是不是在换界面大小:`=` 和 `+` 放大一档(按不按 Shift 都算, * 小键盘的 + 也是),`-` 缩小一档,`0` 回到 100%。判 `key` 不判 `code`:键盘布局 diff --git a/src/settings/GeneralSection.i18n.ts b/src/settings/GeneralSection.i18n.ts index c8624679..092c852b 100644 --- a/src/settings/GeneralSection.i18n.ts +++ b/src/settings/GeneralSection.i18n.ts @@ -1,7 +1,7 @@ import { messages } from "@/i18n"; import { isLinux, isWindows } from "@/platform"; -/** 设置 → 通用:语言、外观、界面大小、菜单栏、开机启动、提醒 */ +/** 设置 → 通用:语言、外观、界面大小、呼出快捷键、菜单栏、开机启动、提醒 */ export const generalText = messages( { title: "通用", @@ -22,6 +22,19 @@ export const generalText = messages( zoomKeys: (bigger: string, smaller: string, reset: string) => `按 ${bigger} 放大、${smaller} 缩小,${reset} 恢复为 100%。`, + hotkey: "呼出快捷键", + hotkeyWhat: "在任意程序中按下即可打开主界面,主界面在最前时再按一次收起。", + hotkeyUnsupported: "当前桌面环境不支持全局快捷键。", + /** 设置里有、却没注册上 */ + hotkeyTaken: "该组合键已被其他程序占用,请换一个。", + hotkeyNone: "未设置", + hotkeyPress: "请按下组合键", + hotkeyOff: "关闭", + hotkeyRestore: "恢复缺省", + hotkeyNeedModifier: `须包含 Ctrl、Alt 或 ${isWindows ? "Win" : isLinux ? "Super" : "⌘"} 中的至少一个。`, + /** 和界面里已有的快捷键相同 */ + hotkeyInApp: (combo: string) => `${combo} 已用于界面内的操作,请换一个。`, + menubar: "菜单栏", menubarWhat: "数值为今日的 token 用量与费用。", menubarFull: "标识和数值", @@ -30,6 +43,9 @@ export const generalText = messages( menubarHidden: "不显示", /** 选了不显示时替换上面那句:窗口关着时怎么回来 */ menubarHiddenWhat: "从访达或聚焦搜索再次打开 ThinkWatch Lite,即可显示主窗口。", + /** 同上,设了呼出快捷键时 */ + menubarHiddenWhatHotkey: (combo: string) => + `按 ${combo},或从访达、聚焦搜索再次打开 ThinkWatch Lite,即可显示主窗口。`, autostart: "开机启动", autostartWhat: isWindows @@ -61,6 +77,17 @@ export const generalText = messages( zoomKeys: (bigger: string, smaller: string, reset: string) => `Press ${bigger} to enlarge, ${smaller} to reduce, or ${reset} to reset to 100%.`, + hotkey: "Global shortcut", + hotkeyWhat: "Opens the main window from any app. Press it again while the window is in front to close it.", + hotkeyUnsupported: "Global shortcuts are not supported on this desktop.", + hotkeyTaken: "This shortcut is already used by another app. Choose another one.", + hotkeyNone: "Not set", + hotkeyPress: "Press a shortcut", + hotkeyOff: "Turn Off", + hotkeyRestore: "Restore Default", + hotkeyNeedModifier: `The shortcut must include Ctrl, Alt or ${isWindows ? "Win" : isLinux ? "Super" : "⌘"}.`, + hotkeyInApp: (combo: string) => `${combo} is already used in the app. Choose another one.`, + menubar: "Menu bar", menubarWhat: "The numbers are today's token usage and cost.", menubarFull: "Icon and numbers", @@ -68,6 +95,8 @@ export const generalText = messages( menubarNumbers: "Numbers only", menubarHidden: "Hidden", menubarHiddenWhat: "To show the main window, open ThinkWatch Lite again from Finder or Spotlight.", + menubarHiddenWhatHotkey: (combo: string) => + `To show the main window, press ${combo} or open ThinkWatch Lite again from Finder or Spotlight.`, autostart: "Launch at login", autostartWhat: isWindows diff --git a/src/settings/GeneralSection.tsx b/src/settings/GeneralSection.tsx index f27565c1..5a0452ab 100644 --- a/src/settings/GeneralSection.tsx +++ b/src/settings/GeneralSection.tsx @@ -1,4 +1,4 @@ -import { useEffect } from "react"; +import { useEffect, useRef, useState, type KeyboardEvent } from "react"; import { NativeSelect, NativeSelectOption } from "@/ui/native-select"; import { Reveal } from "@/ui/motion"; import { Segmented } from "@/ui/segmented"; @@ -12,11 +12,14 @@ import { invalidate, useResource } from "@/lib/resource"; import { useTauriEvent } from "@/lib/tauriEvent"; import { LANG_NAMES, setLang, useText, type Lang } from "@/i18n"; import { isMac } from "@/platform"; -import { COMBOS, comboText } from "@/palette/keys"; +import { errorText } from "@/i18n/core.i18n"; +import { COMBOS, comboText, hotkeyCombo, isInAppCombo, Keys } from "@/palette/keys"; import { APP_KEYS, settingsApi, ZOOM_STEPS, + type HotkeyCombo, + type HotkeyView, type LanguageView, type MenubarStyle, type NoticeMode, @@ -43,6 +46,7 @@ export function GeneralSection() { + {/* **只有 macOS 有这几档。**别处的通知区只认一张正方形图标(100% DPI 下 16×16),塞不下两行数字 —— 那几行在右键菜单里给;「不显示」也不给,见 @@ -192,6 +196,172 @@ function ZoomRow() { ); } +/** 录入时不当主键的键:修饰键自己按下时还在等主键 */ +const MODIFIER_CODES = new Set([ + "ShiftLeft", + "ShiftRight", + "ControlLeft", + "ControlRight", + "AltLeft", + "AltRight", + "MetaLeft", + "MetaRight", + "OSLeft", + "OSRight", + "CapsLock", + "Fn", +]); + +const FUNCTION_KEY = /^F([1-9]|1[0-2])$/; + +/** + * 呼出快捷键(`src-tauri/src/hotkey.rs`):出厂是 Ctrl+Alt+H(⌃⌥H),可以录一个别的、关掉、 + * 恢复缺省。**点一下就生效**,和这一节别的行一样。 + * + * 录入:点那个框,按下组合键;Esc 取消、点别处也取消。录入期间 Rust 先放掉现在的那一个 + * (`setHotkeyRecording`),不然按下它窗口就收起了。**先在这里挡掉两种**:没带修饰键的 + * (F1–F12 除外)、和界面里已有的快捷键相同的(拿去做呼出,界面里那个就按不到了)。 + * 被别的程序占着的由 Rust 注册时发现,原来那一个照旧。 + * + * 说明那一行在出问题时换成红字:录的不能用、注册不上,或者设置里那个启动时就被占着 + * (`active` 是 false)。 + */ +function HotkeyRow() { + const t = useText(generalText); + const r = useResource(APP_KEYS.hotkey, settingsApi.hotkey); + const { mutate, reload } = r; + const [recording, setRecording] = useState(false); + const [problem, setProblem] = useState(null); + const [pending, setPending] = useState(false); + const field = useRef(null); + const recordingNow = useRef(false); + + const record = (on: boolean) => { + if (recordingNow.current === on) return; + recordingNow.current = on; + setRecording(on); + settingsApi.setHotkeyRecording(on).catch((e: unknown) => notify.error(e)); + if (!on) void reload(); + }; + // 录到一半离开设置页:注册回去 + useEffect( + () => () => { + if (recordingNow.current) void settingsApi.setHotkeyRecording(false).catch(() => undefined); + }, + [], + ); + + // 取消录入:刚才那句「不能用」也不留 + const cancel = () => { + setProblem(null); + record(false); + }; + + const save = async (combo: HotkeyCombo | null) => { + setPending(true); + try { + mutate(await settingsApi.setHotkey(combo)); + setProblem(null); + } catch (e) { + setProblem(errorText(e)); + } finally { + setPending(false); + } + }; + + const onKeyDown = (e: KeyboardEvent) => { + if (!recording) return; + // 录入时按键都归这里:不翻页、不开命令面板、不触发按钮 + e.preventDefault(); + e.stopPropagation(); + const mods = e.ctrlKey || e.altKey || e.metaKey || e.shiftKey; + if (e.code === "Escape" && !mods) { + cancel(); + return; + } + if (MODIFIER_CODES.has(e.code)) return; + const combo: HotkeyCombo = { ctrl: e.ctrlKey, alt: e.altKey, shift: e.shiftKey, meta: e.metaKey, code: e.code }; + if (!(combo.ctrl || combo.alt || combo.meta || FUNCTION_KEY.test(combo.code))) { + setProblem(t.hotkeyNeedModifier); + return; + } + const keys = hotkeyCombo(combo); + if (isInAppCombo(keys)) { + setProblem(t.hotkeyInApp(comboText(keys))); + return; + } + record(false); + void save(combo); + }; + + const v: HotkeyView | undefined = r.data; + const taken = v !== undefined && v.combo !== null && !v.active && !recording; + const note = !v + ? undefined + : !v.supported + ? t.hotkeyUnsupported + : (problem ?? (taken ? t.hotkeyTaken : null)); + return ( + {note} + ) : ( + t.hotkeyWhat + ) + } + control={ + + {(v) => ( +
+ + +
+ )} +
+ } + /> + ); +} + /** * 菜单栏:标识和数值 / 仅标识 / 仅数值 / 不显示。数值是今日的 token 和费用。 * @@ -205,11 +375,16 @@ function MenubarRow() { const [, set] = useWrite(r, settingsApi.setMenubar); const { mutate } = r; useTauriEvent("menubar-style-changed", (e) => void mutate(e.payload)); + // 不显示时还能按呼出快捷键回来(注册着才算) + const hotkey = useResource(APP_KEYS.hotkey, settingsApi.hotkey).data; + const back = hotkey?.combo && hotkey.active ? comboText(hotkeyCombo(hotkey.combo)) : null; return ( {(style) => ( diff --git a/src/settings/api.ts b/src/settings/api.ts index 8fcdde42..536db4fb 100644 --- a/src/settings/api.ts +++ b/src/settings/api.ts @@ -34,6 +34,28 @@ export type UiZoom = (typeof ZOOM_STEPS)[number]; /** 快捷键:放大一档、缩小一档、回到 100% */ export type ZoomStep = "in" | "out" | "reset"; +/** + * 呼出快捷键的一个组合(`src-tauri/src/hotkey.rs` 的 `Combo`)。`code` 是物理键位 + * (`KeyboardEvent.code`:`KeyH`、`Digit1`、`F5`);`meta` 是 macOS 的 ⌘、别处的 Win / Super 键 + */ +export interface HotkeyCombo { + ctrl: boolean; + alt: boolean; + shift: boolean; + meta: boolean; + code: string; +} + +/** 设置里的(`null` 是关掉了)、出厂的、注册上了没有、这个桌面支不支持 */ +export interface HotkeyView { + combo: HotkeyCombo | null; + default: HotkeyCombo; + /** 设置里有、却是 false:被别的程序占着 */ + active: boolean; + /** Linux 的 Wayland 会话是 false */ + supported: boolean; +} + /** 菜单栏:标识和数值 / 仅标识 / 仅数值 / 不显示(只有 macOS 有) */ export type MenubarStyle = "full" | "icon" | "numbers" | "hidden"; @@ -58,6 +80,11 @@ export const settingsApi = { setZoom: (zoom: UiZoom) => invoke("set_ui_zoom", { zoom }), /** 新的一档由 Rust 那边算(按住快捷键时一连串的按键不会读到同一档) */ stepZoom: (step: ZoomStep) => invoke("step_ui_zoom", { step }), + hotkey: () => invoke("hotkey_state"), + /** 先注册、注册上了才存;被占着时报错,原来那一个照旧 */ + setHotkey: (combo: HotkeyCombo | null) => invoke("set_hotkey", { combo }), + /** 录入期间放掉现在的那一个,不然按下它窗口就收起了、录不进来 */ + setHotkeyRecording: (on: boolean) => invoke("set_hotkey_recording", { on }), language: () => invoke("app_language"), setLanguage: (setting: Lang | null) => invoke("set_language", { setting }), menubar: () => invoke("menubar_style"), @@ -87,6 +114,7 @@ export const settingsApi = { export const APP_KEYS = { theme: "app:theme", zoom: "app:zoom", + hotkey: "app:hotkey", language: "app:language", menubar: "app:menubar", noticeMode: "app:notice-mode",