feat: add tray menu localization using system locale (#446)

This commit is contained in:
Viren Mohindra 2025-12-18 17:44:32 +05:30 committed by GitHub
parent b3dd5a8c8e
commit bfca46fcd6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 226 additions and 11 deletions

View file

@ -219,7 +219,7 @@ Community feedback is essential to keeping Handy the best it can be for everyone
- How you tested the changes - How you tested the changes
- Screenshots/videos if applicable - Screenshots/videos if applicable
- Breaking changes (if any) - 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. **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 ### Code Style Guidelines

View file

@ -25,6 +25,8 @@ crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies] [build-dependencies]
tauri-build = { version = "2", features = [] } tauri-build = { version = "2", features = [] }
serde_json = "1"
serde = { version = "1", features = ["derive"] }
[dependencies] [dependencies]
once_cell = "1" once_cell = "1"

View file

@ -2,9 +2,115 @@ fn main() {
#[cfg(all(target_os = "macos", target_arch = "aarch64"))] #[cfg(all(target_os = "macos", target_arch = "aarch64"))]
build_apple_intelligence_bridge(); build_apple_intelligence_bridge();
generate_tray_translations();
tauri_build::build() 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<String, serde_json::Value> = 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<HashMap<&'static str, TrayStrings>> = 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"))] #[cfg(all(target_os = "macos", target_arch = "aarch64"))]
fn build_apple_intelligence_bridge() { fn build_apple_intelligence_bridge() {
use std::env; use std::env;

View file

@ -14,6 +14,7 @@ mod settings;
mod shortcut; mod shortcut;
mod signal_handle; mod signal_handle;
mod tray; mod tray;
mod tray_i18n;
mod utils; mod utils;
use specta_typescript::{BigIntExportBehavior, Typescript}; use specta_typescript::{BigIntExportBehavior, Typescript};
use tauri_specta::{collect_commands, Builder}; use tauri_specta::{collect_commands, Builder};
@ -194,7 +195,7 @@ fn initialize_core_logic(app_handle: &AppHandle) {
app_handle.manage(tray); app_handle.manage(tray);
// Initialize tray menu with idle state // 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 // Get the autostart manager and configure based on user setting
let autostart_manager = app_handle.autolaunch(); let autostart_manager = app_handle.autolaunch();

View file

@ -363,7 +363,9 @@ fn default_post_process_enabled() -> bool {
} }
fn default_app_language() -> String { 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 { fn default_post_process_provider_id() -> String {

View file

@ -13,6 +13,7 @@ use crate::settings::{
self, get_settings, ClipboardHandling, LLMPrompt, OverlayPosition, PasteMethod, SoundTheme, self, get_settings, ClipboardHandling, LLMPrompt, OverlayPosition, PasteMethod, SoundTheme,
APPLE_INTELLIGENCE_DEFAULT_MODEL_ID, APPLE_INTELLIGENCE_PROVIDER_ID, APPLE_INTELLIGENCE_DEFAULT_MODEL_ID, APPLE_INTELLIGENCE_PROVIDER_ID,
}; };
use crate::tray;
use crate::ManagedToggleState; use crate::ManagedToggleState;
pub fn init_shortcuts(app: &AppHandle) { pub fn init_shortcuts(app: &AppHandle) {
@ -721,9 +722,12 @@ pub fn change_append_trailing_space_setting(app: AppHandle, enabled: bool) -> Re
#[specta::specta] #[specta::specta]
pub fn change_app_language_setting(app: AppHandle, language: String) -> Result<(), String> { pub fn change_app_language_setting(app: AppHandle, language: String) -> Result<(), String> {
let mut settings = settings::get_settings(&app); let mut settings = settings::get_settings(&app);
settings.app_language = language; settings.app_language = language.clone();
settings::write_settings(&app, settings); settings::write_settings(&app, settings);
// Refresh the tray menu with the new language
tray::update_tray_menu(&app, &tray::TrayIconState::Idle, Some(&language));
Ok(()) Ok(())
} }

View file

@ -1,4 +1,5 @@
use crate::settings; use crate::settings;
use crate::tray_i18n::get_tray_translations;
use tauri::image::Image; use tauri::image::Image;
use tauri::menu::{Menu, MenuItem, PredefinedMenuItem}; use tauri::menu::{Menu, MenuItem, PredefinedMenuItem};
use tauri::tray::TrayIcon; use tauri::tray::TrayIcon;
@ -71,12 +72,15 @@ pub fn change_tray_icon(app: &AppHandle, icon: TrayIconState) {
)); ));
// Update menu based on state // 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 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 // Platform-specific accelerators
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
let (settings_accelerator, quit_accelerator) = (Some("Cmd+,"), Some("Cmd+Q")); 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_label = format!("Handy v{}", env!("CARGO_PKG_VERSION"));
let version_i = MenuItem::with_id(app, "version", &version_label, false, None::<&str>) let version_i = MenuItem::with_id(app, "version", &version_label, false, None::<&str>)
.expect("failed to create version item"); .expect("failed to create version item");
let settings_i = MenuItem::with_id(app, "settings", "Settings...", true, settings_accelerator) let settings_i = MenuItem::with_id(
.expect("failed to create settings item"); app,
"settings",
&strings.settings,
true,
settings_accelerator,
)
.expect("failed to create settings item");
let check_updates_i = MenuItem::with_id( let check_updates_i = MenuItem::with_id(
app, app,
"check_updates", "check_updates",
"Check for Updates...", &strings.check_updates,
settings.update_checks_enabled, settings.update_checks_enabled,
None::<&str>, None::<&str>,
) )
.expect("failed to create check updates item"); .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"); .expect("failed to create quit item");
let separator = || PredefinedMenuItem::separator(app).expect("failed to create separator"); let separator = || PredefinedMenuItem::separator(app).expect("failed to create separator");
let menu = match state { let menu = match state {
TrayIconState::Recording | TrayIconState::Transcribing => { 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"); .expect("failed to create cancel item");
Menu::with_items( Menu::with_items(
app, app,

View file

@ -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<String>) -> 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")
}

View file

@ -1,4 +1,10 @@
{ {
"tray": {
"settings": "Einstellungen...",
"checkUpdates": "Nach Updates suchen...",
"quit": "Beenden",
"cancel": "Abbrechen"
},
"sidebar": { "sidebar": {
"general": "Allgemein", "general": "Allgemein",
"advanced": "Erweitert", "advanced": "Erweitert",

View file

@ -1,4 +1,10 @@
{ {
"tray": {
"settings": "Settings...",
"checkUpdates": "Check for Updates...",
"quit": "Quit",
"cancel": "Cancel"
},
"sidebar": { "sidebar": {
"general": "General", "general": "General",
"advanced": "Advanced", "advanced": "Advanced",

View file

@ -1,4 +1,10 @@
{ {
"tray": {
"settings": "Configuración...",
"checkUpdates": "Buscar actualizaciones...",
"quit": "Salir",
"cancel": "Cancelar"
},
"sidebar": { "sidebar": {
"general": "General", "general": "General",
"advanced": "Avanzado", "advanced": "Avanzado",

View file

@ -1,5 +1,11 @@
{ {
"_comment": "French translation for Handy. Contribute at: https://github.com/cjpais/Handy", "_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": { "sidebar": {
"general": "Général", "general": "Général",
"advanced": "Avancé", "advanced": "Avancé",

View file

@ -1,4 +1,10 @@
{ {
"tray": {
"settings": "Impostazioni...",
"checkUpdates": "Verifica aggiornamenti...",
"quit": "Esci",
"cancel": "Annulla"
},
"sidebar": { "sidebar": {
"general": "Generale", "general": "Generale",
"advanced": "Avanzate", "advanced": "Avanzate",

View file

@ -1,4 +1,10 @@
{ {
"tray": {
"settings": "設定...",
"checkUpdates": "アップデートを確認...",
"quit": "終了",
"cancel": "キャンセル"
},
"sidebar": { "sidebar": {
"general": "一般", "general": "一般",
"advanced": "詳細設定", "advanced": "詳細設定",

View file

@ -1,4 +1,10 @@
{ {
"tray": {
"settings": "Ustawienia...",
"checkUpdates": "Sprawdź aktualizacje...",
"quit": "Zamknij",
"cancel": "Anuluj"
},
"sidebar": { "sidebar": {
"general": "Ogólne", "general": "Ogólne",
"advanced": "Zaawansowane", "advanced": "Zaawansowane",

View file

@ -1,5 +1,11 @@
{ {
"_comment": "Vietnamese translation for Handy. Contribute at: https://github.com/cjpais/Handy", "_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": { "sidebar": {
"general": "Chung", "general": "Chung",
"advanced": "Nâng cao", "advanced": "Nâng cao",

View file

@ -1,4 +1,10 @@
{ {
"tray": {
"settings": "设置...",
"checkUpdates": "检查更新...",
"quit": "退出",
"cancel": "取消"
},
"sidebar": { "sidebar": {
"general": "通用", "general": "通用",
"advanced": "高级", "advanced": "高级",