//! Application language selection and embedded Fluent resources. use i18n_embed::fluent::{fluent_language_loader, FluentLanguageLoader}; use i18n_embed::LanguageLoader; #[cfg(not(target_arch = "wasm32"))] use i18n_embed::DesktopLanguageRequester; #[cfg(target_arch = "wasm32")] use i18n_embed::WebLanguageRequester; use rust_embed::RustEmbed; use serde::{Deserialize, Serialize}; use std::borrow::Cow; use std::sync::OnceLock; #[path = "locale_catalog.rs"] mod locale_catalog; #[derive(RustEmbed)] #[folder = "locales/"] struct Localizations; /// User-selectable UI language. `System` keeps following the platform's /// preferred locale while explicit choices remain stable across restarts. #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] pub enum Language { #[default] #[serde(rename = "system")] System, #[serde(rename = "en-US")] EnUs, #[serde(rename = "bg-BG")] BgBg, #[serde(rename = "tr-TR")] TrTr, #[serde(rename = "nl-NL")] NlNl, #[serde(rename = "fr-FR")] FrFr, #[serde(rename = "de-DE")] DeDe, #[serde(rename = "hi-IN")] HiIn, #[serde(rename = "ru-RU")] RuRu, #[serde(rename = "zh-CN")] ZhCn, #[serde(rename = "es-ES")] EsEs, #[serde(rename = "pt-BR")] PtBr, #[serde(rename = "ar-SA")] ArSa, #[serde(rename = "ja-JP")] JaJp, #[serde(rename = "ko-KR")] KoKr, #[serde(rename = "cs-CZ")] CsCz, #[serde(rename = "it-IT")] ItIt, #[serde(rename = "fi-FI")] FiFi, #[serde(rename = "hu-HU")] HuHu, #[serde(rename = "pl-PL")] PlPl, #[serde(rename = "zh-TW")] ZhTw, } impl Language { pub const ALL: [Language; 21] = [ Language::System, Language::EnUs, Language::BgBg, Language::PtBr, Language::CsCz, Language::NlNl, Language::FrFr, Language::FiFi, Language::DeDe, Language::HuHu, Language::ItIt, Language::JaJp, Language::KoKr, Language::PlPl, Language::RuRu, Language::ZhCn, Language::EsEs, Language::ZhTw, Language::TrTr, Language::HiIn, Language::ArSa, ]; fn requested(self) -> Vec { match self { Language::System => system_languages(), Language::EnUs => vec!["en-US".parse().expect("valid locale")], Language::BgBg => vec!["bg-BG".parse().expect("valid locale")], Language::TrTr => vec!["tr-TR".parse().expect("valid locale")], Language::NlNl => vec!["nl-NL".parse().expect("valid locale")], Language::FrFr => vec!["fr-FR".parse().expect("valid locale")], Language::DeDe => vec!["de-DE".parse().expect("valid locale")], Language::HiIn => vec!["hi-IN".parse().expect("valid locale")], Language::RuRu => vec!["ru-RU".parse().expect("valid locale")], Language::ZhCn => vec!["zh-CN".parse().expect("valid locale")], Language::EsEs => vec!["es-ES".parse().expect("valid locale")], Language::PtBr => vec!["pt-BR".parse().expect("valid locale")], Language::ArSa => vec!["ar-SA".parse().expect("valid locale")], Language::JaJp => vec!["ja-JP".parse().expect("valid locale")], Language::KoKr => vec!["ko-KR".parse().expect("valid locale")], Language::CsCz => vec!["cs-CZ".parse().expect("valid locale")], Language::ItIt => vec!["it-IT".parse().expect("valid locale")], Language::FiFi => vec!["fi-FI".parse().expect("valid locale")], Language::HuHu => vec!["hu-HU".parse().expect("valid locale")], Language::PlPl => vec!["pl-PL".parse().expect("valid locale")], Language::ZhTw => vec!["zh-TW".parse().expect("valid locale")], } } pub fn label(self) -> String { match self { Language::System => crate::tr!("language", "system"), Language::EnUs => crate::tr!("language", "english"), Language::BgBg => crate::tr!("language", "bulgarian"), Language::TrTr => crate::tr!("language", "turkish"), Language::NlNl => crate::tr!("language", "dutch"), Language::FrFr => crate::tr!("language", "french"), Language::DeDe => crate::tr!("language", "german"), Language::HiIn => crate::tr!("language", "hindi"), Language::RuRu => crate::tr!("language", "russian"), Language::ZhCn => crate::tr!("language", "chinese-simplified"), Language::EsEs => crate::tr!("language", "spanish"), Language::PtBr => crate::tr!("language", "portuguese"), Language::ArSa => crate::tr!("language", "arabic"), Language::JaJp => crate::tr!("language", "japanese"), Language::KoKr => crate::tr!("language", "korean"), Language::CsCz => crate::tr!("language", "czech"), Language::ItIt => crate::tr!("language", "italian"), Language::FiFi => crate::tr!("language", "finnish"), Language::HuHu => crate::tr!("language", "hungarian"), Language::PlPl => crate::tr!("language", "polish"), Language::ZhTw => crate::tr!("language", "chinese-traditional"), } } } impl std::fmt::Display for Language { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let label = self.label(); f.write_str(&label) } } fn system_languages() -> Vec { #[cfg(target_arch = "wasm32")] let mut requested = WebLanguageRequester::requested_languages(); #[cfg(not(target_arch = "wasm32"))] let requested = DesktopLanguageRequester::requested_languages(); // `navigator.languages` may be empty in privacy-restricted browser // contexts. The singular preference is still exposed by mainstream // browsers, so keep it as the first fallback before English. #[cfg(target_arch = "wasm32")] if requested.is_empty() { if let Some(language) = web_sys::window() .and_then(|window| window.navigator().language()) .and_then(|language| language.parse().ok()) { requested.push(language); } } requested } fn load_language( loader: &FluentLanguageLoader, language: Language, ) -> Result<(), i18n_embed::I18nEmbedError> { let mut requested = language.requested(); if requested.is_empty() { requested.push(loader.fallback_language().clone()); } #[allow(unused_variables)] let selected = i18n_embed::select(loader, &Localizations, &requested)?; #[cfg(target_arch = "wasm32")] if let Some(language) = selected.first() { if let Some(root) = web_sys::window() .and_then(|window| window.document()) .and_then(|document| document.document_element()) { let _ = root.set_attribute("lang", &language.to_string()); let direction = if language.to_string().starts_with("ar") { "rtl" } else { "ltr" }; let _ = root.set_attribute("dir", direction); } } Ok(()) } pub fn loader() -> &'static FluentLanguageLoader { static LOADER: OnceLock = OnceLock::new(); LOADER.get_or_init(|| { let loader = fluent_language_loader!(); if let Err(error) = load_language(&loader, Language::System) { eprintln!("Unable to load system UI language: {error}"); loader .load_languages(&Localizations, &[loader.fallback_language().clone()]) .expect("fallback UI language must be embedded"); } loader }) } /// Apply a user preference process-wide. The loader swaps resources atomically, /// so the next Iced view pass immediately receives the new language. pub fn set_language(language: Language) -> Result<(), i18n_embed::I18nEmbedError> { load_language(loader(), language) } #[cfg(target_arch = "wasm32")] pub fn active_language_tag() -> String { loader() .current_languages() .into_iter() .next() .unwrap_or_else(|| loader().fallback_language().clone()) .to_string() } /// Translate an application-facing source label from the complete UI catalog. /// /// Stable semantic ids remain preferable for new code. This compatibility /// layer lets existing UI surfaces move to Fluent without turning command /// tokens, property ids, file-format values, or plug-in supplied text into /// translatable data. pub fn translate(source: impl AsRef) -> Cow<'static, str> { let source = source.as_ref(); locale_catalog::message_attribute(source) .map(|(message_id, attribute_id)| Cow::Owned(loader().get_attr(message_id, attribute_id))) .unwrap_or_else(|| Cow::Owned(source.to_string())) } /// Translate a catalog message and replace the named values used by legacy /// command prompts. Catalog generation protects these markers from machine /// translation and Fluent parsing. pub fn translate_args( source: impl AsRef, args: &[(&str, String)], ) -> Cow<'static, str> { let source = source.as_ref(); let mut translated = locale_catalog::message_attribute(source) .map(|(message_id, attribute_id)| loader().get_attr(message_id, attribute_id)) .unwrap_or_else(|| source.to_string()); for (name, value) in args { translated = translated.replace(&format!("__ocs_arg_{name}__"), value); translated = translated.replace(&format!("%{{{name}}}"), value); } Cow::Owned(translated) } /// Translate a Rust formatting template after its values have been rendered. /// The catalog stores positional markers, while this function recovers each /// rendered value from the English template and places it in the localized /// sentence. File names, handles, counts, and command values therefore remain /// data instead of being sent through translation. pub fn translate_format(template: &str, rendered: String) -> Cow<'static, str> { let Some((message_id, attribute_id)) = locale_catalog::message_attribute(template) else { return Cow::Owned(rendered); }; let Some(values) = format_values(template, &rendered) else { return Cow::Owned(rendered); }; let mut translated = loader().get_attr(message_id, attribute_id); for (index, value) in values.iter().enumerate() { translated = translated.replace(&format!("__ocs_fmt_{index}__"), value); } Cow::Owned(translated) } fn format_values(template: &str, rendered: &str) -> Option> { let mut literals = vec![String::new()]; let mut chars = template.chars().peekable(); while let Some(ch) = chars.next() { match (ch, chars.peek().copied()) { ('{', Some('{')) => { chars.next(); literals.last_mut()?.push('{'); } ('}', Some('}')) => { chars.next(); literals.last_mut()?.push('}'); } ('{', _) => { let mut closed = false; for inner in chars.by_ref() { if inner == '}' { closed = true; break; } } if !closed { return None; } literals.push(String::new()); } ('}', _) => return None, _ => literals.last_mut()?.push(ch), } } let mut cursor = 0; if !rendered.starts_with(&literals[0]) { return None; } cursor += literals[0].len(); let mut values = Vec::with_capacity(literals.len().saturating_sub(1)); for index in 1..literals.len() { let separator = &literals[index]; if index + 1 == literals.len() && separator.is_empty() { values.push(rendered[cursor..].to_string()); cursor = rendered.len(); } else { let offset = rendered[cursor..].find(separator)?; values.push(rendered[cursor..cursor + offset].to_string()); cursor += offset + separator.len(); } } (cursor == rendered.len()).then_some(values) } /// Built-in ribbon modules have stable ids; plug-ins keep their supplied title /// until they provide their own localization bundle. pub fn ribbon_module_title(id: &str, fallback: &str) -> String { match id { "draw" => crate::tr!("ribbon-tab", "draw"), "annotate" => crate::tr!("ribbon-tab", "annotate"), "insert" => crate::tr!("ribbon-tab", "insert"), "model" => crate::tr!("ribbon-tab", "model"), "layout" => crate::tr!("ribbon-tab", "layout"), "manage" => crate::tr!("ribbon-tab", "manage"), "view" => crate::tr!("ribbon-tab", "view"), _ => fallback.to_string(), } } #[macro_export] macro_rules! tr { ($message_id:literal, $attribute_id:literal $(,)?) => { i18n_embed_fl::fl!($crate::i18n::loader(), $message_id, $attribute_id) }; ($message_id:literal, $attribute_id:literal, $($name:ident = $value:expr),+ $(,)?) => { i18n_embed_fl::fl!( $crate::i18n::loader(), $message_id, $attribute_id, $($name = $value),+ ) }; ($message_id:literal $(,)?) => { i18n_embed_fl::fl!($crate::i18n::loader(), $message_id) }; ($message_id:literal, $($name:ident = $value:expr),+ $(,)?) => { i18n_embed_fl::fl!( $crate::i18n::loader(), $message_id, $($name = $value),+ ) }; } /// Source-catalog compatibility macro used while the existing interface is /// migrated to semantic Fluent ids. Named arguments mirror the command prompt /// placeholders already present in the source catalog. #[macro_export] macro_rules! t { ($source:expr $(,)?) => { $crate::i18n::translate($source) }; ($source:expr, $($name:ident = $value:expr),+ $(,)?) => { $crate::i18n::translate_args( $source, &[$((stringify!($name), ($value).to_string())),+], ) }; } /// Localized counterpart of `format!` for application-facing messages. #[macro_export] macro_rules! tf { ($template:literal $($args:tt)*) => {{ let rendered = format!($template $($args)*); $crate::i18n::translate_format($template, rendered) }}; }