Files
OneCAD-Draft/src/i18n.rs
T

400 lines
14 KiB
Rust

//! 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<i18n_embed::unic_langid::LanguageIdentifier> {
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<i18n_embed::unic_langid::LanguageIdentifier> {
#[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<FluentLanguageLoader> = 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<str>) -> 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<str>,
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<Vec<String>> {
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)
}};
}