diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 441d2bb..23e99c0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6,6 +6,7 @@ use tauri::{ tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, AppHandle, Manager, WebviewUrl, WebviewWindowBuilder, }; +use tauri_plugin_global_shortcut::{GlobalShortcutExt, ShortcutState}; mod commands; @@ -13,6 +14,114 @@ struct CtrlCState { last_press: Option, } +struct OpenHotkey(Mutex>); + +struct PopupCombo { + ctrl: bool, + shift: bool, + alt: bool, + meta: bool, + key: rdev::Key, +} + +struct PopupHotkey(Mutex>); + +fn default_popup_combo() -> PopupCombo { + parse_popup_combo(DEFAULT_POPUP_HOTKEY).expect("valid default popup hotkey") +} + +fn key_from_str(s: &str) -> Option { + use rdev::Key; + match s.to_uppercase().as_str() { + "A" => Some(Key::KeyA), + "B" => Some(Key::KeyB), + "C" => Some(Key::KeyC), + "D" => Some(Key::KeyD), + "E" => Some(Key::KeyE), + "F" => Some(Key::KeyF), + "G" => Some(Key::KeyG), + "H" => Some(Key::KeyH), + "I" => Some(Key::KeyI), + "J" => Some(Key::KeyJ), + "K" => Some(Key::KeyK), + "L" => Some(Key::KeyL), + "M" => Some(Key::KeyM), + "N" => Some(Key::KeyN), + "O" => Some(Key::KeyO), + "P" => Some(Key::KeyP), + "Q" => Some(Key::KeyQ), + "R" => Some(Key::KeyR), + "S" => Some(Key::KeyS), + "T" => Some(Key::KeyT), + "U" => Some(Key::KeyU), + "V" => Some(Key::KeyV), + "W" => Some(Key::KeyW), + "X" => Some(Key::KeyX), + "Y" => Some(Key::KeyY), + "Z" => Some(Key::KeyZ), + "0" => Some(Key::Num0), + "1" => Some(Key::Num1), + "2" => Some(Key::Num2), + "3" => Some(Key::Num3), + "4" => Some(Key::Num4), + "5" => Some(Key::Num5), + "6" => Some(Key::Num6), + "7" => Some(Key::Num7), + "8" => Some(Key::Num8), + "9" => Some(Key::Num9), + "F1" => Some(Key::F1), + "F2" => Some(Key::F2), + "F3" => Some(Key::F3), + "F4" => Some(Key::F4), + "F5" => Some(Key::F5), + "F6" => Some(Key::F6), + "F7" => Some(Key::F7), + "F8" => Some(Key::F8), + "F9" => Some(Key::F9), + "F10" => Some(Key::F10), + "F11" => Some(Key::F11), + "F12" => Some(Key::F12), + _ => None, + } +} + +fn parse_popup_combo(accel: &str) -> Option { + let mut combo = PopupCombo { + ctrl: false, + shift: false, + alt: false, + meta: false, + key: rdev::Key::KeyC, + }; + let mut has_key = false; + + for part in accel.split('+') { + match part.trim() { + "Ctrl" | "Control" => combo.ctrl = true, + "Shift" => combo.shift = true, + "Alt" => combo.alt = true, + "Super" | "Meta" | "Cmd" | "Command" => combo.meta = true, + "" => {} + other => { + combo.key = key_from_str(other)?; + has_key = true; + } + } + } + + let has_modifier = combo.ctrl || combo.shift || combo.alt || combo.meta; + if has_key && has_modifier { + Some(combo) + } else { + None + } +} + +const DEFAULT_OPEN_HOTKEY: &str = "Ctrl+Shift+T"; +#[cfg(target_os = "macos")] +const DEFAULT_POPUP_HOTKEY: &str = "Super+C"; +#[cfg(not(target_os = "macos"))] +const DEFAULT_POPUP_HOTKEY: &str = "Ctrl+C"; const DOUBLE_PRESS_TIMEOUT_MS: u128 = 500; const POPUP_WIDTH: f64 = 380.0; const POPUP_HEIGHT: f64 = 280.0; @@ -35,6 +144,125 @@ fn show_and_focus_window(app: &AppHandle, label: &str) { } } +fn focus_main_input(app: &AppHandle) { + if let Some(window) = app.get_webview_window("main") { + let _ = window + .emit("focus-input", ()) + .inspect_err(|e| log::warn!("Failed to emit focus-input: {e}")); + } +} + +fn toggle_main_window(app: &AppHandle) { + if let Some(window) = app.get_webview_window("main") { + let visible = window.is_visible().unwrap_or(false); + let focused = window.is_focused().unwrap_or(false); + + if visible && focused { + let _ = window + .hide() + .inspect_err(|e| log::warn!("Failed to hide main window: {e}")); + + #[cfg(target_os = "windows")] + let _ = window.set_skip_taskbar(true); + } else { + show_and_focus_window(app, "main"); + focus_main_input(app); + } + } +} + +fn read_open_hotkey(app: &AppHandle) -> String { + let path = match app.path().app_config_dir() { + Ok(dir) => dir.join("settings.json"), + Err(_) => return DEFAULT_OPEN_HOTKEY.to_string(), + }; + + let content = match std::fs::read_to_string(path) { + Ok(c) => c, + Err(_) => return DEFAULT_OPEN_HOTKEY.to_string(), + }; + + serde_json::from_str::(&content) + .ok() + .and_then(|json| json.get("openHotkey").and_then(|v| v.as_str()).map(str::to_string)) + .unwrap_or_else(|| DEFAULT_OPEN_HOTKEY.to_string()) +} + +fn apply_open_hotkey(app: &AppHandle, accel: &str) -> Result<(), String> { + let gs = app.global_shortcut(); + + if let Some(state) = app.try_state::() { + if let Ok(mut guard) = state.0.lock() { + if let Some(prev) = guard.take() { + let _ = gs.unregister(prev.as_str()); + } + } + } + + let accel = accel.trim(); + if accel.is_empty() { + return Ok(()); + } + + gs.on_shortcut(accel, |app, _shortcut, event| { + if event.state == ShortcutState::Pressed { + toggle_main_window(app); + } + }) + .map_err(|e| format!("Failed to register shortcut '{accel}': {e}"))?; + + if let Some(state) = app.try_state::() { + if let Ok(mut guard) = state.0.lock() { + *guard = Some(accel.to_string()); + } + } + + Ok(()) +} + +#[tauri::command] +fn set_open_hotkey(app: AppHandle, accelerator: String) -> Result<(), String> { + apply_open_hotkey(&app, &accelerator) +} + +fn read_popup_hotkey(app: &AppHandle) -> String { + let path = match app.path().app_config_dir() { + Ok(dir) => dir.join("settings.json"), + Err(_) => return DEFAULT_POPUP_HOTKEY.to_string(), + }; + + let content = match std::fs::read_to_string(path) { + Ok(c) => c, + Err(_) => return DEFAULT_POPUP_HOTKEY.to_string(), + }; + + serde_json::from_str::(&content) + .ok() + .and_then(|json| json.get("popupHotkey").and_then(|v| v.as_str()).map(str::to_string)) + .unwrap_or_else(|| DEFAULT_POPUP_HOTKEY.to_string()) +} + +fn apply_popup_hotkey(app: &AppHandle, accel: &str) -> Result<(), String> { + let combo = if accel.trim().is_empty() { + None + } else { + Some(parse_popup_combo(accel).ok_or_else(|| format!("Invalid popup hotkey: '{accel}'"))?) + }; + + if let Some(state) = app.try_state::() { + if let Ok(mut guard) = state.0.lock() { + *guard = combo; + } + } + + Ok(()) +} + +#[tauri::command] +fn set_popup_hotkey(app: AppHandle, accelerator: String) -> Result<(), String> { + apply_popup_hotkey(&app, &accelerator) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() @@ -54,6 +282,8 @@ pub fn run() { .plugin(tauri_plugin_http::init()) .plugin(tauri_plugin_process::init()) .manage(Mutex::new(CtrlCState { last_press: None })) + .manage(OpenHotkey(Mutex::new(None))) + .manage(PopupHotkey(Mutex::new(Some(default_popup_combo())))) .invoke_handler(tauri::generate_handler![ commands::translate::translate_text, commands::translate::validate_api_key, @@ -68,6 +298,8 @@ pub fn run() { commands::store::save_settings, commands::store::load_settings, commands::updater::download_and_install_update, + set_open_hotkey, + set_popup_hotkey, ]) .setup(|app| { #[cfg(target_os = "linux")] @@ -99,6 +331,7 @@ pub fn run() { .on_menu_event(|app, event| match event.id().as_ref() { "open" => { show_and_focus_window(app, "main"); + focus_main_input(app); } "settings" => { show_and_focus_window(app, "main"); @@ -129,12 +362,23 @@ pub fn run() { } = event { show_and_focus_window(tray.app_handle(), "main"); + focus_main_input(tray.app_handle()); } }) .build(app)?; setup_global_shortcut(app.handle())?; + let open_hotkey = read_open_hotkey(app.handle()); + if let Err(e) = apply_open_hotkey(app.handle(), &open_hotkey) { + log::warn!("Failed to register open hotkey: {e}"); + } + + let popup_hotkey = read_popup_hotkey(app.handle()); + if let Err(e) = apply_popup_hotkey(app.handle(), &popup_hotkey) { + log::warn!("Failed to register popup hotkey: {e}"); + } + Ok(()) }) .on_window_event(|window, event| { @@ -163,23 +407,53 @@ fn setup_global_shortcut(app: &AppHandle) -> Result<(), Box { - ctrl_down = true; + EventType::KeyPress(Key::ControlLeft) | EventType::KeyPress(Key::ControlRight) => { + ctrl = true; + } + EventType::KeyRelease(Key::ControlLeft) | EventType::KeyRelease(Key::ControlRight) => { + ctrl = false; + } + EventType::KeyPress(Key::ShiftLeft) | EventType::KeyPress(Key::ShiftRight) => { + shift = true; + } + EventType::KeyRelease(Key::ShiftLeft) | EventType::KeyRelease(Key::ShiftRight) => { + shift = false; + } + EventType::KeyPress(Key::Alt) | EventType::KeyPress(Key::AltGr) => { + alt = true; + } + EventType::KeyRelease(Key::Alt) | EventType::KeyRelease(Key::AltGr) => { + alt = false; + } + EventType::KeyPress(Key::MetaLeft) | EventType::KeyPress(Key::MetaRight) => { + meta = true; } - EventType::KeyRelease(Key::ControlLeft) - | EventType::KeyRelease(Key::ControlRight) - | EventType::KeyRelease(Key::MetaLeft) - | EventType::KeyRelease(Key::MetaRight) => { - ctrl_down = false; + EventType::KeyRelease(Key::MetaLeft) | EventType::KeyRelease(Key::MetaRight) => { + meta = false; } - EventType::KeyPress(Key::KeyC) => { - if ctrl_down { + EventType::KeyPress(key) => { + let matches = app_handle + .try_state::() + .and_then(|state| { + state.0.lock().ok().and_then(|guard| { + guard.as_ref().map(|combo| { + key == combo.key + && ctrl == combo.ctrl + && shift == combo.shift + && alt == combo.alt + && meta == combo.meta + }) + }) + }) + .unwrap_or(false); + + if matches { let now = std::time::Instant::now(); let mut trigger = false; diff --git a/src/components/SettingsPanel.tsx b/src/components/SettingsPanel.tsx index 1dde905..685debf 100644 --- a/src/components/SettingsPanel.tsx +++ b/src/components/SettingsPanel.tsx @@ -12,6 +12,8 @@ import { Moon, Sun, Key, + Keyboard, + Copy, Monitor, FileText, Trash2, @@ -38,12 +40,18 @@ export default function SettingsPanel() { setActiveApi, providerModes, setProviderMode, + openHotkey, + setOpenHotkey, + popupHotkey, + setPopupHotkey, uiScale, setUiScale, saveToStore, } = useSettingsStore(); const [showChangelogModal, setShowChangelogModal] = useState(false); + const [recordingHotkey, setRecordingHotkey] = useState(false); + const [recordingPopupHotkey, setRecordingPopupHotkey] = useState(false); const { setActiveApi: setTranslatorApi } = useTranslatorStore(); @@ -134,6 +142,94 @@ export default function SettingsPanel() { [setUiScale, saveToStore] ); + const applyOpenHotkey = useCallback( + async (accel: string) => { + setOpenHotkey(accel); + try { + const { invoke } = await import("@tauri-apps/api/core"); + await invoke("set_open_hotkey", { accelerator: accel }); + } catch (e) { + console.log("Failed to set open hotkey:", e); + } + await saveToStore(); + }, + [setOpenHotkey, saveToStore] + ); + + const handleHotkeyKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (!recordingHotkey) return; + e.preventDefault(); + e.stopPropagation(); + + if (e.key === "Escape") { + setRecordingHotkey(false); + return; + } + + const mods: string[] = []; + if (e.ctrlKey) mods.push("Ctrl"); + if (e.shiftKey) mods.push("Shift"); + if (e.altKey) mods.push("Alt"); + if (e.metaKey) mods.push("Super"); + + let key: string | null = null; + if (/^Key[A-Z]$/.test(e.code)) key = e.code.slice(3); + else if (/^Digit[0-9]$/.test(e.code)) key = e.code.slice(5); + else if (/^F([1-9]|1[0-2])$/.test(e.code)) key = e.code; + + if (!key || mods.length === 0) return; + + setRecordingHotkey(false); + applyOpenHotkey([...mods, key].join("+")); + }, + [recordingHotkey, applyOpenHotkey] + ); + + const applyPopupHotkey = useCallback( + async (accel: string) => { + setPopupHotkey(accel); + try { + const { invoke } = await import("@tauri-apps/api/core"); + await invoke("set_popup_hotkey", { accelerator: accel }); + } catch (e) { + console.log("Failed to set popup hotkey:", e); + } + await saveToStore(); + }, + [setPopupHotkey, saveToStore] + ); + + const handlePopupHotkeyKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (!recordingPopupHotkey) return; + e.preventDefault(); + e.stopPropagation(); + + if (e.key === "Escape") { + setRecordingPopupHotkey(false); + return; + } + + const mods: string[] = []; + if (e.ctrlKey) mods.push("Ctrl"); + if (e.shiftKey) mods.push("Shift"); + if (e.altKey) mods.push("Alt"); + if (e.metaKey) mods.push("Super"); + + let key: string | null = null; + if (/^Key[A-Z]$/.test(e.code)) key = e.code.slice(3); + else if (/^Digit[0-9]$/.test(e.code)) key = e.code.slice(5); + else if (/^F([1-9]|1[0-2])$/.test(e.code)) key = e.code; + + if (!key || mods.length === 0) return; + + setRecordingPopupHotkey(false); + applyPopupHotkey([...mods, key].join("+")); + }, + [recordingPopupHotkey, applyPopupHotkey] + ); + const handleClearData = useCallback(async () => { try { const { load } = await import("@tauri-apps/plugin-store"); @@ -390,6 +486,71 @@ export default function SettingsPanel() {
+
+

+ Shortcut +

+ +
+
+ + Open MoonTranslator +
+
+ + +
+
+ +
+
+ + Popup (double-press) +
+
+ + +
+
+ +

+ Popup translates the clipboard. Make sure your shortcut copies the + selection first (for example Ctrl+C). +

+
+ +
+

Actions diff --git a/src/components/TranslatorInput.tsx b/src/components/TranslatorInput.tsx index 07cc5c3..1e3d0af 100644 --- a/src/components/TranslatorInput.tsx +++ b/src/components/TranslatorInput.tsx @@ -17,6 +17,23 @@ export default function TranslatorInput() { } }, [sourceText]); + useEffect(() => { + let mounted = true; + let unlisten: (() => void) | undefined; + (async () => { + try { + const { listen } = await import("@tauri-apps/api/event"); + const fn = await listen("focus-input", () => textareaRef.current?.focus()); + if (mounted) unlisten = fn; + else fn(); + } catch {} + })(); + return () => { + mounted = false; + unlisten?.(); + }; + }, []); + return (