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
|
- 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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
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": {
|
"sidebar": {
|
||||||
"general": "Allgemein",
|
"general": "Allgemein",
|
||||||
"advanced": "Erweitert",
|
"advanced": "Erweitert",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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é",
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,10 @@
|
||||||
{
|
{
|
||||||
|
"tray": {
|
||||||
|
"settings": "Impostazioni...",
|
||||||
|
"checkUpdates": "Verifica aggiornamenti...",
|
||||||
|
"quit": "Esci",
|
||||||
|
"cancel": "Annulla"
|
||||||
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"general": "Generale",
|
"general": "Generale",
|
||||||
"advanced": "Avanzate",
|
"advanced": "Avanzate",
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,10 @@
|
||||||
{
|
{
|
||||||
|
"tray": {
|
||||||
|
"settings": "設定...",
|
||||||
|
"checkUpdates": "アップデートを確認...",
|
||||||
|
"quit": "終了",
|
||||||
|
"cancel": "キャンセル"
|
||||||
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"general": "一般",
|
"general": "一般",
|
||||||
"advanced": "詳細設定",
|
"advanced": "詳細設定",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,10 @@
|
||||||
{
|
{
|
||||||
|
"tray": {
|
||||||
|
"settings": "设置...",
|
||||||
|
"checkUpdates": "检查更新...",
|
||||||
|
"quit": "退出",
|
||||||
|
"cancel": "取消"
|
||||||
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"general": "通用",
|
"general": "通用",
|
||||||
"advanced": "高级",
|
"advanced": "高级",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue