use std::collections::HashMap;
use crate::{App, Global};
pub type Locale = String;
#[derive(Debug, Clone)]
pub struct Translation {
pub key: String,
pub value: String,
}
pub struct I18nManager {
current_locale: Locale,
translations: HashMap<Locale, HashMap<String, String>>,
fallback_locale: Option<Locale>,
}
impl Global for I18nManager {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct I18nSnapshot {
pub current_locale: Locale,
pub fallback_locale: Option<Locale>,
}
impl I18nManager {
pub fn new(current_locale: &str) -> Self {
Self {
current_locale: current_locale.to_string(),
translations: HashMap::new(),
fallback_locale: None,
}
}
pub fn with_fallback(mut self, fallback: &str) -> Self {
self.fallback_locale = Some(fallback.to_string());
self
}
pub fn load_translations(&mut self, locale: &str, json: &str) -> anyhow::Result<()> {
let data: HashMap<String, String> = serde_json::from_str(json)?;
self.translations.insert(locale.to_string(), data);
Ok(())
}
pub fn load_translations_map(&mut self, locale: &str, map: HashMap<String, String>) {
self.translations.insert(locale.to_string(), map);
}
pub fn t(&self, key: &str, args: &[(&str, &str)]) -> String {
let value = self.get_translated_text(key);
self.format_text(&value, args)
}
fn get_translated_text(&self, key: &str) -> String {
if let Some(lang_translations) = self.translations.get(&self.current_locale) {
if let Some(value) = lang_translations.get(key) {
return value.clone();
}
}
if let Some(ref fallback) = self.fallback_locale {
if let Some(lang_translations) = self.translations.get(fallback) {
if let Some(value) = lang_translations.get(key) {
return value.clone();
}
}
}
key.to_string()
}
fn format_text(&self, text: &str, args: &[(&str, &str)]) -> String {
let mut result = text.to_string();
for (key, value) in args {
result = result.replace(&format!("{{{}}}", key), value);
}
result
}
pub fn current_locale(&self) -> &str {
&self.current_locale
}
pub fn set_locale(&mut self, locale: &str) {
self.current_locale = locale.to_string();
}
pub fn available_locales(&self) -> Vec<&str> {
self.translations.keys().map(|s| s.as_str()).collect()
}
#[cfg(not(target_family = "wasm"))]
pub fn load_locale_dir(
&mut self,
dir: impl AsRef<std::path::Path>,
) -> anyhow::Result<Vec<Locale>> {
let mut loaded = Vec::new();
for entry in std::fs::read_dir(dir.as_ref())? {
let entry = entry?;
let path = entry.path();
if !path.is_file() || path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
let Some(locale) = path.file_stem().and_then(|s| s.to_str()) else {
continue;
};
let json = std::fs::read_to_string(&path)?;
self.load_translations(locale, &json)?;
loaded.push(locale.to_string());
}
loaded.sort();
Ok(loaded)
}
pub fn snapshot(&self) -> I18nSnapshot {
I18nSnapshot {
current_locale: self.current_locale.clone(),
fallback_locale: self.fallback_locale.clone(),
}
}
pub fn restore(&mut self, snapshot: I18nSnapshot) {
self.current_locale = snapshot.current_locale;
self.fallback_locale = snapshot.fallback_locale;
}
}
impl Default for I18nManager {
fn default() -> Self {
Self::new("en")
}
}
pub struct I18nText {
key: String,
args: Vec<(String, String)>,
}
impl I18nText {
pub fn new(key: &str) -> Self {
Self {
key: key.to_string(),
args: Vec::new(),
}
}
pub fn with_arg(mut self, key: &str, value: &str) -> Self {
self.args.push((key.to_string(), value.to_string()));
self
}
pub fn translate(&self, i18n: &I18nManager) -> String {
let args: Vec<(&str, &str)> = self
.args
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
i18n.t(&self.key, &args)
}
pub fn translate_global(&self, cx: &App) -> String {
match cx.try_global::<I18nManager>() {
Some(i18n) => self.translate(i18n),
None => self.key.clone(),
}
}
}
pub struct PluralRule {
pub count: i64,
pub singular: String,
pub plural: String,
}
impl PluralRule {
pub fn new(count: i64, singular: &str, plural: &str) -> Self {
Self {
count,
singular: singular.to_string(),
plural: plural.to_string(),
}
}
pub fn key(&self) -> &str {
if self.count == 1 {
&self.singular
} else {
&self.plural
}
}
pub fn translate(&self, i18n: &I18nManager) -> String {
let key = self.key();
i18n.t(key, &[("count", &self.count.to_string())])
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
#[test]
fn test_i18n_manager_creation() {
let manager = I18nManager::new("en");
assert_eq!(manager.current_locale(), "en");
}
#[test]
fn test_load_translations() {
let mut manager = I18nManager::new("en");
let mut translations = HashMap::new();
translations.insert("hello".to_string(), "Hello".to_string());
translations.insert("world".to_string(), "World".to_string());
manager.load_translations_map("en", translations);
assert_eq!(manager.t("hello", &[]), "Hello");
assert_eq!(manager.t("world", &[]), "World");
}
#[test]
fn test_fallback_locale() {
let mut manager = I18nManager::new("zh-CN").with_fallback("en");
let mut en_translations = HashMap::new();
en_translations.insert("hello".to_string(), "Hello".to_string());
manager.load_translations_map("en", en_translations);
assert_eq!(manager.t("hello", &[]), "Hello");
}
#[test]
fn test_format_text() {
let mut manager = I18nManager::new("en");
let mut translations = HashMap::new();
translations.insert("greeting".to_string(), "Hello, {name}!".to_string());
manager.load_translations_map("en", translations);
assert_eq!(manager.t("greeting", &[("name", "World")]), "Hello, World!");
}
#[test]
fn test_set_locale() {
let mut manager = I18nManager::new("en");
manager.set_locale("zh-CN");
assert_eq!(manager.current_locale(), "zh-CN");
}
#[test]
fn test_plural_rule() {
let rule = PluralRule::new(1, "item", "items");
assert_eq!(rule.key(), "item");
let rule = PluralRule::new(5, "item", "items");
assert_eq!(rule.key(), "items");
}
#[test]
fn test_snapshot_restore() {
let mut manager = I18nManager::new("en").with_fallback("en");
manager.set_locale("zh-CN");
let snapshot = manager.snapshot();
assert_eq!(snapshot.current_locale, "zh-CN");
manager.set_locale("ja");
manager.restore(snapshot);
assert_eq!(manager.current_locale(), "zh-CN");
}
#[test]
fn test_load_locale_dir() {
let dir = std::env::temp_dir().join(format!(
"rgpui-i18n-test-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("en.json"), r#"{"hello": "Hello"}"#).unwrap();
std::fs::write(dir.join("zh-CN.json"), r#"{"hello": "你好"}"#).unwrap();
std::fs::write(dir.join("README.md"), "not a locale").unwrap();
let mut manager = I18nManager::new("en");
let loaded = manager.load_locale_dir(&dir).unwrap();
assert_eq!(loaded, vec!["en".to_string(), "zh-CN".to_string()]);
assert_eq!(manager.t("hello", &[]), "Hello");
manager.set_locale("zh-CN");
assert_eq!(manager.t("hello", &[]), "你好");
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn test_manager_is_global() {
fn assert_global<T: crate::Global>() {}
assert_global::<I18nManager>();
}
#[rgpui::test]
fn test_translate_global_reads_manager(cx: &mut crate::TestAppContext) {
let text = I18nText::new("hello");
let fallback = cx.update(|cx| text.translate_global(cx));
assert_eq!(fallback, "hello");
cx.update(|cx| {
let mut manager = I18nManager::new("en");
manager.load_translations_map(
"en",
HashMap::from([("hello".to_string(), "Hello".to_string())]),
);
cx.set_global(manager);
});
let translated = cx.update(|cx| text.translate_global(cx));
assert_eq!(translated, "Hello");
}
}