diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ab8c089..6c0baa1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -219,7 +219,7 @@ Community feedback is essential to keeping Handy the best it can be for everyone - How you tested the changes - Screenshots/videos if applicable - Breaking changes (if any) - + **Remember:** PRs with community support are prioritized. If you haven't already, start a [discussion](https://github.com/cjpais/Handy/discussions) to gather feedback before or alongside your PR. It is not explicitly required to gather feedback, but it certainly helps your PR get merged faster. ### Code Style Guidelines diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 5ef5df8..cc4f132 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -25,6 +25,8 @@ crate-type = ["staticlib", "cdylib", "rlib"] [build-dependencies] tauri-build = { version = "2", features = [] } +serde_json = "1" +serde = { version = "1", features = ["derive"] } [dependencies] once_cell = "1" diff --git a/src-tauri/build.rs b/src-tauri/build.rs index ae35a85..db4eabe 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -2,9 +2,115 @@ fn main() { #[cfg(all(target_os = "macos", target_arch = "aarch64"))] build_apple_intelligence_bridge(); + generate_tray_translations(); + tauri_build::build() } +/// Generate tray menu translations from frontend locale files. +/// +/// Source of truth: src/i18n/locales/*/translation.json +/// The English "tray" section defines the struct fields. +fn generate_tray_translations() { + use std::collections::BTreeMap; + use std::fs; + use std::path::Path; + + let out_dir = std::env::var("OUT_DIR").unwrap(); + let locales_dir = Path::new("../src/i18n/locales"); + + println!("cargo:rerun-if-changed=../src/i18n/locales"); + + // Collect all locale translations + let mut translations: BTreeMap = BTreeMap::new(); + + for entry in fs::read_dir(locales_dir).unwrap().flatten() { + let path = entry.path(); + if !path.is_dir() { + continue; + } + + let lang = path.file_name().unwrap().to_str().unwrap().to_string(); + let json_path = path.join("translation.json"); + + println!("cargo:rerun-if-changed={}", json_path.display()); + + let content = fs::read_to_string(&json_path).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&content).unwrap(); + + if let Some(tray) = parsed.get("tray").cloned() { + translations.insert(lang, tray); + } + } + + // English defines the schema + let english = translations.get("en").unwrap().as_object().unwrap(); + let fields: Vec<_> = english + .keys() + .map(|k| (camel_to_snake(k), k.clone())) + .collect(); + + // Generate code + let mut out = String::from( + "// Auto-generated from src/i18n/locales/*/translation.json - do not edit\n\n", + ); + + // Struct + out.push_str("#[derive(Debug, Clone)]\npub struct TrayStrings {\n"); + for (rust_field, _) in &fields { + out.push_str(&format!(" pub {rust_field}: String,\n")); + } + out.push_str("}\n\n"); + + // Static map + out.push_str( + "pub static TRANSLATIONS: Lazy> = Lazy::new(|| {\n", + ); + out.push_str(" let mut m = HashMap::new();\n"); + + for (lang, tray) in &translations { + out.push_str(&format!(" m.insert(\"{lang}\", TrayStrings {{\n")); + for (rust_field, json_key) in &fields { + let val = tray.get(json_key).and_then(|v| v.as_str()).unwrap_or(""); + out.push_str(&format!( + " {rust_field}: \"{}\".to_string(),\n", + escape_string(val) + )); + } + out.push_str(" });\n"); + } + + out.push_str(" m\n});\n"); + + fs::write(Path::new(&out_dir).join("tray_translations.rs"), out).unwrap(); + + println!( + "cargo:warning=Generated tray translations: {} languages, {} fields", + translations.len(), + fields.len() + ); +} + +fn camel_to_snake(s: &str) -> String { + s.chars() + .enumerate() + .fold(String::new(), |mut acc, (i, c)| { + if c.is_uppercase() && i > 0 { + acc.push('_'); + } + acc.push(c.to_lowercase().next().unwrap()); + acc + }) +} + +fn escape_string(s: &str) -> String { + s.replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('\n', "\\n") + .replace('\r', "\\r") + .replace('\t', "\\t") +} + #[cfg(all(target_os = "macos", target_arch = "aarch64"))] fn build_apple_intelligence_bridge() { use std::env; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e39c792..be98784 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -14,6 +14,7 @@ mod settings; mod shortcut; mod signal_handle; mod tray; +mod tray_i18n; mod utils; use specta_typescript::{BigIntExportBehavior, Typescript}; use tauri_specta::{collect_commands, Builder}; @@ -194,7 +195,7 @@ fn initialize_core_logic(app_handle: &AppHandle) { app_handle.manage(tray); // Initialize tray menu with idle state - utils::update_tray_menu(app_handle, &utils::TrayIconState::Idle); + utils::update_tray_menu(app_handle, &utils::TrayIconState::Idle, None); // Get the autostart manager and configure based on user setting let autostart_manager = app_handle.autolaunch(); diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index cc98d98..4b2380e 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -363,7 +363,9 @@ fn default_post_process_enabled() -> bool { } fn default_app_language() -> String { - "en".to_string() + tauri_plugin_os::locale() + .and_then(|l| l.split(['-', '_']).next().map(String::from)) + .unwrap_or_else(|| "en".to_string()) } fn default_post_process_provider_id() -> String { diff --git a/src-tauri/src/shortcut.rs b/src-tauri/src/shortcut.rs index fa855ba..0558f87 100644 --- a/src-tauri/src/shortcut.rs +++ b/src-tauri/src/shortcut.rs @@ -13,6 +13,7 @@ use crate::settings::{ self, get_settings, ClipboardHandling, LLMPrompt, OverlayPosition, PasteMethod, SoundTheme, APPLE_INTELLIGENCE_DEFAULT_MODEL_ID, APPLE_INTELLIGENCE_PROVIDER_ID, }; +use crate::tray; use crate::ManagedToggleState; pub fn init_shortcuts(app: &AppHandle) { @@ -721,9 +722,12 @@ pub fn change_append_trailing_space_setting(app: AppHandle, enabled: bool) -> Re #[specta::specta] pub fn change_app_language_setting(app: AppHandle, language: String) -> Result<(), String> { let mut settings = settings::get_settings(&app); - settings.app_language = language; + settings.app_language = language.clone(); settings::write_settings(&app, settings); + // Refresh the tray menu with the new language + tray::update_tray_menu(&app, &tray::TrayIconState::Idle, Some(&language)); + Ok(()) } diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index ee2505e..ce02123 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -1,4 +1,5 @@ use crate::settings; +use crate::tray_i18n::get_tray_translations; use tauri::image::Image; use tauri::menu::{Menu, MenuItem, PredefinedMenuItem}; use tauri::tray::TrayIcon; @@ -71,12 +72,15 @@ pub fn change_tray_icon(app: &AppHandle, icon: TrayIconState) { )); // Update menu based on state - update_tray_menu(app, &icon); + update_tray_menu(app, &icon, None); } -pub fn update_tray_menu(app: &AppHandle, state: &TrayIconState) { +pub fn update_tray_menu(app: &AppHandle, state: &TrayIconState, locale: Option<&str>) { let settings = settings::get_settings(app); + let locale = locale.unwrap_or(&settings.app_language); + let strings = get_tray_translations(Some(locale.to_string())); + // Platform-specific accelerators #[cfg(target_os = "macos")] let (settings_accelerator, quit_accelerator) = (Some("Cmd+,"), Some("Cmd+Q")); @@ -87,23 +91,29 @@ pub fn update_tray_menu(app: &AppHandle, state: &TrayIconState) { let version_label = format!("Handy v{}", env!("CARGO_PKG_VERSION")); let version_i = MenuItem::with_id(app, "version", &version_label, false, None::<&str>) .expect("failed to create version item"); - let settings_i = MenuItem::with_id(app, "settings", "Settings...", true, settings_accelerator) - .expect("failed to create settings item"); + let settings_i = MenuItem::with_id( + app, + "settings", + &strings.settings, + true, + settings_accelerator, + ) + .expect("failed to create settings item"); let check_updates_i = MenuItem::with_id( app, "check_updates", - "Check for Updates...", + &strings.check_updates, settings.update_checks_enabled, None::<&str>, ) .expect("failed to create check updates item"); - let quit_i = MenuItem::with_id(app, "quit", "Quit", true, quit_accelerator) + let quit_i = MenuItem::with_id(app, "quit", &strings.quit, true, quit_accelerator) .expect("failed to create quit item"); let separator = || PredefinedMenuItem::separator(app).expect("failed to create separator"); let menu = match state { TrayIconState::Recording | TrayIconState::Transcribing => { - let cancel_i = MenuItem::with_id(app, "cancel", "Cancel", true, None::<&str>) + let cancel_i = MenuItem::with_id(app, "cancel", &strings.cancel, true, None::<&str>) .expect("failed to create cancel item"); Menu::with_items( app, diff --git a/src-tauri/src/tray_i18n.rs b/src-tauri/src/tray_i18n.rs new file mode 100644 index 0000000..846faba --- /dev/null +++ b/src-tauri/src/tray_i18n.rs @@ -0,0 +1,36 @@ +//! Tray menu internationalization +//! +//! Everything is auto-generated at compile time by build.rs from the +//! frontend locale files (src/i18n/locales/*/translation.json). +//! +//! The English translation.json is the single source of truth: +//! - TrayStrings struct fields are derived from the English "tray" keys +//! - All languages are auto-discovered from the locales directory +//! +//! To add a new tray menu item: +//! 1. Add the key to en/translation.json under "tray" +//! 2. Add translations to other locale files +//! 3. Update tray.rs to use the new field (e.g., strings.new_field) + +use once_cell::sync::Lazy; +use std::collections::HashMap; + +// Include the auto-generated TrayStrings struct and TRANSLATIONS static +include!(concat!(env!("OUT_DIR"), "/tray_translations.rs")); + +/// Get the language code from a locale string (e.g., "en-US" -> "en") +fn get_language_code(locale: &str) -> &str { + locale.split(['-', '_']).next().unwrap_or("en") +} + +/// Get localized tray menu strings based on the system locale +pub fn get_tray_translations(locale: Option) -> TrayStrings { + let lang = locale.as_deref().map(get_language_code).unwrap_or("en"); + + // Try requested language, fall back to English + TRANSLATIONS + .get(lang) + .or_else(|| TRANSLATIONS.get("en")) + .cloned() + .expect("English translations must exist") +} diff --git a/src/i18n/locales/de/translation.json b/src/i18n/locales/de/translation.json index 8fefed6..bd31c46 100644 --- a/src/i18n/locales/de/translation.json +++ b/src/i18n/locales/de/translation.json @@ -1,4 +1,10 @@ { + "tray": { + "settings": "Einstellungen...", + "checkUpdates": "Nach Updates suchen...", + "quit": "Beenden", + "cancel": "Abbrechen" + }, "sidebar": { "general": "Allgemein", "advanced": "Erweitert", diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json index a97bd27..c02d2df 100644 --- a/src/i18n/locales/en/translation.json +++ b/src/i18n/locales/en/translation.json @@ -1,4 +1,10 @@ { + "tray": { + "settings": "Settings...", + "checkUpdates": "Check for Updates...", + "quit": "Quit", + "cancel": "Cancel" + }, "sidebar": { "general": "General", "advanced": "Advanced", diff --git a/src/i18n/locales/es/translation.json b/src/i18n/locales/es/translation.json index eb8896e..9743302 100644 --- a/src/i18n/locales/es/translation.json +++ b/src/i18n/locales/es/translation.json @@ -1,4 +1,10 @@ { + "tray": { + "settings": "Configuración...", + "checkUpdates": "Buscar actualizaciones...", + "quit": "Salir", + "cancel": "Cancelar" + }, "sidebar": { "general": "General", "advanced": "Avanzado", diff --git a/src/i18n/locales/fr/translation.json b/src/i18n/locales/fr/translation.json index f36cd25..bb2f722 100644 --- a/src/i18n/locales/fr/translation.json +++ b/src/i18n/locales/fr/translation.json @@ -1,5 +1,11 @@ { "_comment": "French translation for Handy. Contribute at: https://github.com/cjpais/Handy", + "tray": { + "settings": "Paramètres...", + "checkUpdates": "Rechercher des mises à jour...", + "quit": "Quitter", + "cancel": "Annuler" + }, "sidebar": { "general": "Général", "advanced": "Avancé", diff --git a/src/i18n/locales/it/translation.json b/src/i18n/locales/it/translation.json index a22c44f..591c685 100644 --- a/src/i18n/locales/it/translation.json +++ b/src/i18n/locales/it/translation.json @@ -1,4 +1,10 @@ { + "tray": { + "settings": "Impostazioni...", + "checkUpdates": "Verifica aggiornamenti...", + "quit": "Esci", + "cancel": "Annulla" + }, "sidebar": { "general": "Generale", "advanced": "Avanzate", diff --git a/src/i18n/locales/ja/translation.json b/src/i18n/locales/ja/translation.json index 2f2ea89..5c996dc 100644 --- a/src/i18n/locales/ja/translation.json +++ b/src/i18n/locales/ja/translation.json @@ -1,4 +1,10 @@ { + "tray": { + "settings": "設定...", + "checkUpdates": "アップデートを確認...", + "quit": "終了", + "cancel": "キャンセル" + }, "sidebar": { "general": "一般", "advanced": "詳細設定", diff --git a/src/i18n/locales/pl/translation.json b/src/i18n/locales/pl/translation.json index 6943a48..2419543 100644 --- a/src/i18n/locales/pl/translation.json +++ b/src/i18n/locales/pl/translation.json @@ -1,4 +1,10 @@ { + "tray": { + "settings": "Ustawienia...", + "checkUpdates": "Sprawdź aktualizacje...", + "quit": "Zamknij", + "cancel": "Anuluj" + }, "sidebar": { "general": "Ogólne", "advanced": "Zaawansowane", diff --git a/src/i18n/locales/vi/translation.json b/src/i18n/locales/vi/translation.json index 7d54851..22a5829 100644 --- a/src/i18n/locales/vi/translation.json +++ b/src/i18n/locales/vi/translation.json @@ -1,5 +1,11 @@ { "_comment": "Vietnamese translation for Handy. Contribute at: https://github.com/cjpais/Handy", + "tray": { + "settings": "Cài đặt...", + "checkUpdates": "Kiểm tra cập nhật...", + "quit": "Thoát", + "cancel": "Hủy" + }, "sidebar": { "general": "Chung", "advanced": "Nâng cao", diff --git a/src/i18n/locales/zh/translation.json b/src/i18n/locales/zh/translation.json index 9463e82..1779b4e 100644 --- a/src/i18n/locales/zh/translation.json +++ b/src/i18n/locales/zh/translation.json @@ -1,4 +1,10 @@ { + "tray": { + "settings": "设置...", + "checkUpdates": "检查更新...", + "quit": "退出", + "cancel": "取消" + }, "sidebar": { "general": "通用", "advanced": "高级",