From 86610b2d52f0bcbf31b913988dea4b9deae71050 Mon Sep 17 00:00:00 2001 From: gmcky <80690640+gmcky@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:03:40 +0300 Subject: [PATCH 1/7] feat(shortcut): global hotkey to open main window Register a configurable global shortcut that shows and focuses the main window, independent of the existing Ctrl+C double-press popup. The accelerator is loaded from settings on startup and can be re-registered at runtime through a set_open_hotkey command. Defaults to Ctrl+Shift+T. --- src-tauri/src/lib.rs | 65 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 441d2bb..27eb687 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,9 @@ struct CtrlCState { last_press: Option, } +struct OpenHotkey(Mutex>); + +const DEFAULT_OPEN_HOTKEY: &str = "Ctrl+Shift+T"; const DOUBLE_PRESS_TIMEOUT_MS: u128 = 500; const POPUP_WIDTH: f64 = 380.0; const POPUP_HEIGHT: f64 = 280.0; @@ -35,6 +39,60 @@ fn show_and_focus_window(app: &AppHandle, label: &str) { } } +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 { + show_and_focus_window(app, "main"); + } + }) + .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) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() @@ -54,6 +112,7 @@ 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))) .invoke_handler(tauri::generate_handler![ commands::translate::translate_text, commands::translate::validate_api_key, @@ -68,6 +127,7 @@ pub fn run() { commands::store::save_settings, commands::store::load_settings, commands::updater::download_and_install_update, + set_open_hotkey, ]) .setup(|app| { #[cfg(target_os = "linux")] @@ -135,6 +195,11 @@ pub fn run() { 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}"); + } + Ok(()) }) .on_window_event(|window, event| { From 35f85542c03eea9fc20598eea14e8801a2eacb1e Mon Sep 17 00:00:00 2001 From: gmcky <80690640+gmcky@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:03:48 +0300 Subject: [PATCH 2/7] feat(settings): persist openHotkey preference Add openHotkey to the store defaults, load merge, and save payload so the chosen accelerator survives restarts. --- src/store/settingsStore.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/store/settingsStore.ts b/src/store/settingsStore.ts index 21ffcfa..8c16673 100644 --- a/src/store/settingsStore.ts +++ b/src/store/settingsStore.ts @@ -15,6 +15,7 @@ const STORE_DEFAULTS = { apiKeys: { deepl: "", google: "", bing: "", lara: "", custom: "" }, activeApi: "google", providerModes: {} as Record, + openHotkey: "Ctrl+Shift+T", uiScale: 1, lastUpdateCheck: 0, popupSourceLang: "auto", @@ -28,6 +29,7 @@ interface SettingsState { apiKeys: Record; activeApi: ApiProvider; providerModes: Record; + openHotkey: string; uiScale: number; setDarkMode: (dark: boolean) => void; @@ -36,6 +38,7 @@ interface SettingsState { setApiKey: (provider: ApiProvider, key: string) => void; setActiveApi: (api: ApiProvider) => void; setProviderMode: (provider: string, mode: ProviderMode) => void; + setOpenHotkey: (accel: string) => void; setUiScale: (scale: number) => void; loadFromStore: () => Promise; saveToStore: () => Promise; @@ -55,6 +58,7 @@ export const useSettingsStore = create((set, get) => ({ }, activeApi: "google", providerModes: {}, + openHotkey: "Ctrl+Shift+T", uiScale: 1, setDarkMode: (dark: boolean) => { @@ -79,6 +83,7 @@ export const useSettingsStore = create((set, get) => ({ providerModes: { ...state.providerModes, [provider]: mode }, })), + setOpenHotkey: (accel: string) => set({ openHotkey: accel }), setUiScale: (scale: number) => set({ uiScale: Math.round(clampScale(scale) * 100) / 100 }), @@ -94,6 +99,7 @@ export const useSettingsStore = create((set, get) => ({ apiKeys: { ...STORE_DEFAULTS.apiKeys, ...(parsed.apiKeys || {}) }, activeApi: parsed.activeApi ?? "google", providerModes: { ...STORE_DEFAULTS.providerModes, ...(parsed.providerModes || {}) }, + openHotkey: parsed.openHotkey ?? STORE_DEFAULTS.openHotkey, uiScale: clampScale(parsed.uiScale ?? STORE_DEFAULTS.uiScale), }); if (finalDarkMode) { @@ -122,6 +128,7 @@ export const useSettingsStore = create((set, get) => ({ apiKeys: state.apiKeys, activeApi: state.activeApi, providerModes: state.providerModes, + openHotkey: state.openHotkey, uiScale: state.uiScale, }); await invoke("save_settings", { payload }); From 28bed918f2e29d4b06b51e1a46dc6d356c8621ae Mon Sep 17 00:00:00 2001 From: gmcky <80690640+gmcky@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:03:48 +0300 Subject: [PATCH 3/7] feat(settings): shortcut recorder for open hotkey Add a Shortcut section that records a modifier + key combo, applies it via set_open_hotkey, and persists it. A clear button unsets it. --- src/components/SettingsPanel.tsx | 82 ++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/src/components/SettingsPanel.tsx b/src/components/SettingsPanel.tsx index 1dde905..2c0d601 100644 --- a/src/components/SettingsPanel.tsx +++ b/src/components/SettingsPanel.tsx @@ -12,6 +12,7 @@ import { Moon, Sun, Key, + Keyboard, Monitor, FileText, Trash2, @@ -38,12 +39,15 @@ export default function SettingsPanel() { setActiveApi, providerModes, setProviderMode, + openHotkey, + setOpenHotkey, uiScale, setUiScale, saveToStore, } = useSettingsStore(); const [showChangelogModal, setShowChangelogModal] = useState(false); + const [recordingHotkey, setRecordingHotkey] = useState(false); const { setActiveApi: setTranslatorApi } = useTranslatorStore(); @@ -134,6 +138,50 @@ 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 handleClearData = useCallback(async () => { try { const { load } = await import("@tauri-apps/plugin-store"); @@ -390,6 +438,40 @@ export default function SettingsPanel() {
+
+

+ Shortcut +

+ +
+
+ + Open MoonTranslator +
+
+ + +
+
+
+ +
+

Actions From d162aa386e4070b2a1cb73794a41578cb1fcd7f7 Mon Sep 17 00:00:00 2001 From: gmcky <80690640+gmcky@users.noreply.github.com> Date: Tue, 4 Aug 2026 19:58:06 +0300 Subject: [PATCH 4/7] feat(shortcut): toggle main window on open hotkey The open hotkey only ever showed and focused the window. Toggle it instead: hide when it is already visible and focused, show otherwise, so the same key opens and dismisses the window. --- src-tauri/src/lib.rs | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 27eb687..95395b4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -39,6 +39,24 @@ fn show_and_focus_window(app: &AppHandle, label: &str) { } } +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"); + } + } +} + fn read_open_hotkey(app: &AppHandle) -> String { let path = match app.path().app_config_dir() { Ok(dir) => dir.join("settings.json"), @@ -74,7 +92,7 @@ fn apply_open_hotkey(app: &AppHandle, accel: &str) -> Result<(), String> { gs.on_shortcut(accel, |app, _shortcut, event| { if event.state == ShortcutState::Pressed { - show_and_focus_window(app, "main"); + toggle_main_window(app); } }) .map_err(|e| format!("Failed to register shortcut '{accel}': {e}"))?; From 5b1cd3c7f87122ae674419f9d2687cf493ee3d18 Mon Sep 17 00:00:00 2001 From: gmcky <80690640+gmcky@users.noreply.github.com> Date: Tue, 4 Aug 2026 19:58:18 +0300 Subject: [PATCH 5/7] feat(shortcut): configurable popup hotkey The popup translate trigger was hardcoded to a double Ctrl+C press. Add a popupHotkey setting so it can be rebound from the settings panel. The rdev double-press mechanism and clipboard coupling are kept unchanged, and an empty value disables the popup. Stored in settings.json and applied on startup like the open hotkey. --- src-tauri/src/lib.rs | 206 +++++++++++++++++++++++++++++-- src/components/SettingsPanel.tsx | 79 ++++++++++++ src/store/settingsStore.ts | 10 ++ 3 files changed, 282 insertions(+), 13 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 95395b4..4453b33 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -16,7 +16,112 @@ struct CtrlCState { 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; @@ -111,6 +216,44 @@ 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() @@ -131,6 +274,7 @@ pub fn run() { .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, @@ -146,6 +290,7 @@ pub fn run() { commands::store::load_settings, commands::updater::download_and_install_update, set_open_hotkey, + set_popup_hotkey, ]) .setup(|app| { #[cfg(target_os = "linux")] @@ -218,6 +363,11 @@ pub fn run() { 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| { @@ -246,23 +396,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 2c0d601..685debf 100644 --- a/src/components/SettingsPanel.tsx +++ b/src/components/SettingsPanel.tsx @@ -13,6 +13,7 @@ import { Sun, Key, Keyboard, + Copy, Monitor, FileText, Trash2, @@ -41,6 +42,8 @@ export default function SettingsPanel() { setProviderMode, openHotkey, setOpenHotkey, + popupHotkey, + setPopupHotkey, uiScale, setUiScale, saveToStore, @@ -48,6 +51,7 @@ export default function SettingsPanel() { const [showChangelogModal, setShowChangelogModal] = useState(false); const [recordingHotkey, setRecordingHotkey] = useState(false); + const [recordingPopupHotkey, setRecordingPopupHotkey] = useState(false); const { setActiveApi: setTranslatorApi } = useTranslatorStore(); @@ -182,6 +186,50 @@ export default function SettingsPanel() { [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"); @@ -468,6 +516,37 @@ export default function SettingsPanel() {

+ +
+
+ + Popup (double-press) +
+
+ + +
+
+ +

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

diff --git a/src/store/settingsStore.ts b/src/store/settingsStore.ts index 8c16673..196e1f1 100644 --- a/src/store/settingsStore.ts +++ b/src/store/settingsStore.ts @@ -6,6 +6,9 @@ export type ProviderMode = "api" | "web"; export const MIN_UI_SCALE = 0.7; export const MAX_UI_SCALE = 2; +const IS_MAC = + typeof navigator !== "undefined" && /Mac/i.test(navigator.userAgent); +const DEFAULT_POPUP_HOTKEY = IS_MAC ? "Super+C" : "Ctrl+C"; const clampScale = (scale: number) => Math.min(MAX_UI_SCALE, Math.max(MIN_UI_SCALE, scale)); @@ -16,6 +19,7 @@ const STORE_DEFAULTS = { activeApi: "google", providerModes: {} as Record, openHotkey: "Ctrl+Shift+T", + popupHotkey: DEFAULT_POPUP_HOTKEY, uiScale: 1, lastUpdateCheck: 0, popupSourceLang: "auto", @@ -30,6 +34,7 @@ interface SettingsState { activeApi: ApiProvider; providerModes: Record; openHotkey: string; + popupHotkey: string; uiScale: number; setDarkMode: (dark: boolean) => void; @@ -39,6 +44,7 @@ interface SettingsState { setActiveApi: (api: ApiProvider) => void; setProviderMode: (provider: string, mode: ProviderMode) => void; setOpenHotkey: (accel: string) => void; + setPopupHotkey: (accel: string) => void; setUiScale: (scale: number) => void; loadFromStore: () => Promise; saveToStore: () => Promise; @@ -59,6 +65,7 @@ export const useSettingsStore = create((set, get) => ({ activeApi: "google", providerModes: {}, openHotkey: "Ctrl+Shift+T", + popupHotkey: DEFAULT_POPUP_HOTKEY, uiScale: 1, setDarkMode: (dark: boolean) => { @@ -84,6 +91,7 @@ export const useSettingsStore = create((set, get) => ({ })), setOpenHotkey: (accel: string) => set({ openHotkey: accel }), + setPopupHotkey: (accel: string) => set({ popupHotkey: accel }), setUiScale: (scale: number) => set({ uiScale: Math.round(clampScale(scale) * 100) / 100 }), @@ -100,6 +108,7 @@ export const useSettingsStore = create((set, get) => ({ activeApi: parsed.activeApi ?? "google", providerModes: { ...STORE_DEFAULTS.providerModes, ...(parsed.providerModes || {}) }, openHotkey: parsed.openHotkey ?? STORE_DEFAULTS.openHotkey, + popupHotkey: parsed.popupHotkey ?? STORE_DEFAULTS.popupHotkey, uiScale: clampScale(parsed.uiScale ?? STORE_DEFAULTS.uiScale), }); if (finalDarkMode) { @@ -129,6 +138,7 @@ export const useSettingsStore = create((set, get) => ({ activeApi: state.activeApi, providerModes: state.providerModes, openHotkey: state.openHotkey, + popupHotkey: state.popupHotkey, uiScale: state.uiScale, }); await invoke("save_settings", { payload }); From 2e0e38cbee27703fdebf219e8702937fee5b35e6 Mon Sep 17 00:00:00 2001 From: gmcky <80690640+gmcky@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:38:24 +0300 Subject: [PATCH 6/7] feat(shortcut): focus input when window opens Opening the main window through the hotkey, tray, or the Open menu item now focuses the text input so typing can start right away. It is tied to the open action rather than to window focus, so returning to the window by other means does not steal focus or clear a selection. --- src-tauri/src/lib.rs | 11 +++++++++++ src/components/TranslatorInput.tsx | 11 +++++++++++ 2 files changed, 22 insertions(+) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4453b33..23e99c0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -144,6 +144,14 @@ 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); @@ -158,6 +166,7 @@ fn toggle_main_window(app: &AppHandle) { let _ = window.set_skip_taskbar(true); } else { show_and_focus_window(app, "main"); + focus_main_input(app); } } } @@ -322,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"); @@ -352,6 +362,7 @@ pub fn run() { } = event { show_and_focus_window(tray.app_handle(), "main"); + focus_main_input(tray.app_handle()); } }) .build(app)?; diff --git a/src/components/TranslatorInput.tsx b/src/components/TranslatorInput.tsx index 07cc5c3..9ec2808 100644 --- a/src/components/TranslatorInput.tsx +++ b/src/components/TranslatorInput.tsx @@ -17,6 +17,17 @@ export default function TranslatorInput() { } }, [sourceText]); + useEffect(() => { + let unlisten: (() => void) | undefined; + (async () => { + try { + const { listen } = await import("@tauri-apps/api/event"); + unlisten = await listen("focus-input", () => textareaRef.current?.focus()); + } catch {} + })(); + return () => unlisten?.(); + }, []); + return (