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
- 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

View file

@ -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"

View file

@ -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<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"))]
fn build_apple_intelligence_bridge() {
use std::env;

View file

@ -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();

View file

@ -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 {

View file

@ -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(())
}

View file

@ -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,

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": {
"general": "Allgemein",
"advanced": "Erweitert",

View file

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

View file

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

View file

@ -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é",

View file

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

View file

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

View file

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

View file

@ -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",

View file

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