feat: add tray menu localization using system locale (#446)
This commit is contained in:
parent
b3dd5a8c8e
commit
bfca46fcd6
17 changed files with 226 additions and 11 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
36
src-tauri/src/tray_i18n.rs
Normal file
36
src-tauri/src/tray_i18n.rs
Normal 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")
|
||||
}
|
||||
|
|
@ -1,4 +1,10 @@
|
|||
{
|
||||
"tray": {
|
||||
"settings": "Einstellungen...",
|
||||
"checkUpdates": "Nach Updates suchen...",
|
||||
"quit": "Beenden",
|
||||
"cancel": "Abbrechen"
|
||||
},
|
||||
"sidebar": {
|
||||
"general": "Allgemein",
|
||||
"advanced": "Erweitert",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,10 @@
|
|||
{
|
||||
"tray": {
|
||||
"settings": "Settings...",
|
||||
"checkUpdates": "Check for Updates...",
|
||||
"quit": "Quit",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"sidebar": {
|
||||
"general": "General",
|
||||
"advanced": "Advanced",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,10 @@
|
|||
{
|
||||
"tray": {
|
||||
"settings": "Configuración...",
|
||||
"checkUpdates": "Buscar actualizaciones...",
|
||||
"quit": "Salir",
|
||||
"cancel": "Cancelar"
|
||||
},
|
||||
"sidebar": {
|
||||
"general": "General",
|
||||
"advanced": "Avanzado",
|
||||
|
|
|
|||
|
|
@ -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é",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,10 @@
|
|||
{
|
||||
"tray": {
|
||||
"settings": "Impostazioni...",
|
||||
"checkUpdates": "Verifica aggiornamenti...",
|
||||
"quit": "Esci",
|
||||
"cancel": "Annulla"
|
||||
},
|
||||
"sidebar": {
|
||||
"general": "Generale",
|
||||
"advanced": "Avanzate",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,10 @@
|
|||
{
|
||||
"tray": {
|
||||
"settings": "設定...",
|
||||
"checkUpdates": "アップデートを確認...",
|
||||
"quit": "終了",
|
||||
"cancel": "キャンセル"
|
||||
},
|
||||
"sidebar": {
|
||||
"general": "一般",
|
||||
"advanced": "詳細設定",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,10 @@
|
|||
{
|
||||
"tray": {
|
||||
"settings": "Ustawienia...",
|
||||
"checkUpdates": "Sprawdź aktualizacje...",
|
||||
"quit": "Zamknij",
|
||||
"cancel": "Anuluj"
|
||||
},
|
||||
"sidebar": {
|
||||
"general": "Ogólne",
|
||||
"advanced": "Zaawansowane",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,10 @@
|
|||
{
|
||||
"tray": {
|
||||
"settings": "设置...",
|
||||
"checkUpdates": "检查更新...",
|
||||
"quit": "退出",
|
||||
"cancel": "取消"
|
||||
},
|
||||
"sidebar": {
|
||||
"general": "通用",
|
||||
"advanced": "高级",
|
||||
|
|
|
|||
Loading…
Reference in a new issue