//! Ferese interface language; independent of keyboard layout and system locale. use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use std::sync::{ OnceLock, atomic::{AtomicU8, Ordering}, }; #[derive( Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema, )] pub enum Language { #[default] #[serde(rename = "auto")] Auto, #[serde(rename = "en", alias = "en-US", alias = "en_US")] English, #[serde(rename = "zh-CN", alias = "zh_CN", alias = "zh-Hans", alias = "zh")] Chinese, } impl Language { #[must_use] pub const fn key(self) -> &'static str { match self { Self::Auto => "auto", Self::English => "en", Self::Chinese => "zh-CN", } } } #[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, schemars::JsonSchema)] #[serde(default, deny_unknown_fields)] pub struct Settings { pub language: Language, } static LANGUAGE: AtomicU8 = AtomicU8::new(0); pub fn set(language: Language) { LANGUAGE.store( match language { Language::Auto => 0, Language::English => 1, Language::Chinese => 2, }, Ordering::Relaxed, ); } #[must_use] pub fn configured() -> Language { match LANGUAGE.load(Ordering::Relaxed) { 1 => Language::English, 2 => Language::Chinese, _ => Language::Auto, } } #[must_use] pub fn language_for_locale(locale: &str) -> Language { if locale.to_ascii_lowercase().starts_with("zh") { Language::Chinese } else { Language::English } } #[must_use] pub fn resolved() -> Language { match configured() { Language::Auto => { static SYSTEM: OnceLock = OnceLock::new(); *SYSTEM.get_or_init(|| { let locale = ["LC_ALL", "LC_MESSAGES", "LANGUAGE", "LANG"] .into_iter() .find_map(|key| std::env::var(key).ok().filter(|value| !value.is_empty())) .unwrap_or_else(|| "en".into()); language_for_locale(&locale) }) } language => language, } } pub fn from_document(document: &crate::Document) -> Language { document .get("ui.language") .cloned() .and_then(|value| serde_json::from_value(value).ok()) .unwrap_or_default() } pub fn load(path: Option<&std::path::Path>) { let language = path .and_then(|path| std::fs::read_to_string(path).ok()) .and_then(|source| crate::Document::parse(&source).ok()) .map(|doc| from_document(&doc)) .unwrap_or_default(); set(language); } fn chinese() -> &'static BTreeMap { static CATALOG: OnceLock> = OnceLock::new(); CATALOG.get_or_init(|| { serde_json::from_str(include_str!("../locales/zh-CN.json")) .expect("valid built-in Chinese catalog") }) } #[must_use] pub fn in_language<'a>(language: Language, key: &'a str) -> &'a str { if language == Language::Chinese { chinese().get(key).map_or(key, String::as_str) } else { key } } #[must_use] pub fn translate(key: &str) -> String { in_language(resolved(), key).to_owned() } #[must_use] pub fn text(key: &'static str) -> &'static str { in_language(resolved(), key) } #[must_use] pub fn format(key: &str, arguments: &[(&str, String)]) -> String { let mut result = translate(key); for (name, value) in arguments { result = result.replace(&format!("{{{name}}}"), value); } result } #[macro_export] macro_rules! tr { ($key:literal) => { $crate::locale::text($key) }; } #[cfg(test)] mod tests { use super::*; #[test] fn locale_choice_does_not_rewrite_identifiers_or_unknown_values() { assert_eq!(language_for_locale("zh_CN.UTF-8"), Language::Chinese); assert_eq!(language_for_locale("en_US.UTF-8"), Language::English); assert_eq!(language_for_locale("C.UTF-8"), Language::English); assert_eq!(in_language(Language::Chinese, "Appearance"), "外观与设置"); assert_eq!(in_language(Language::English, "Appearance"), "Appearance"); assert_eq!( in_language(Language::Chinese, "org.example.CustomApp"), "org.example.CustomApp" ); assert!(serde_json::from_str::("\"unsupported\"").is_err()); } }