use bevy::prelude::*;
use serde::Serialize;
use serde::de::DeserializeOwned;
use serde_json::Value as JsonValue;
use std::collections::{HashMap, HashSet};
use std::env;
#[cfg(any(feature = "fluent", feature = "properties-lang"))]
use std::fs;
#[cfg(any(feature = "fluent", feature = "properties-lang"))]
use std::path::{Path, PathBuf};
#[cfg(feature = "fluent")]
use i18n_fluent::{FluentBundle, FluentResource};
#[cfg(feature = "fluent")]
use unic_langid::LanguageIdentifier;
#[cfg(feature = "properties-lang")]
use i18n_properties::try_split;
#[cfg(any(feature = "fluent", feature = "properties-lang"))]
use once_cell::sync::Lazy;
#[cfg(any(feature = "fluent", feature = "properties-lang"))]
use regex::Regex;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HtmlLangSetting {
Auto,
Forced(String),
}
#[derive(Resource, Debug, Clone)]
pub struct UILang {
pub forced: Option<String>,
pub selected: Option<String>,
pub system: Option<String>,
}
impl Default for UILang {
fn default() -> Self {
Self {
forced: None,
selected: None,
system: detect_system_language(),
}
}
}
impl Default for UiLangState {
fn default() -> Self {
Self {
last_resolved: None,
last_language_path: None,
last_vars_fingerprint: None,
last_shared_fingerprint: None,
}
}
}
impl UILang {
pub fn resolved(&self) -> Option<&str> {
self.forced
.as_deref()
.or(self.selected.as_deref())
.or(self.system.as_deref())
}
pub fn set_selected(&mut self, lang: Option<&str>) -> bool {
let normalized = normalize_lang_tag(lang);
if self.selected == normalized {
return false;
}
self.selected = normalized;
true
}
pub fn apply_html_lang(&mut self, lang: Option<&str>) -> bool {
let new_forced = match parse_html_lang(lang) {
HtmlLangSetting::Auto => None,
HtmlLangSetting::Forced(tag) => Some(tag),
};
if self.forced == new_forced {
return false;
}
self.forced = new_forced;
true
}
}
#[derive(Resource, Debug, Clone)]
pub struct UiLangState {
pub last_resolved: Option<String>,
pub last_language_path: Option<String>,
pub last_vars_fingerprint: Option<u64>,
pub last_shared_fingerprint: Option<u64>,
}
#[derive(Resource, Debug, Default, Clone)]
pub struct UiLangVariables {
pub vars: HashMap<String, String>,
}
#[derive(Resource, Debug, Default, Clone)]
pub struct UiSharedValues {
pub values: HashMap<String, JsonValue>,
pub auto_use_aliases: HashMap<String, String>,
pub known_types: HashSet<String>,
}
pub enum HtmlSharedRegistration {
Shared {
name: &'static str,
path: &'static str,
alias: &'static str,
auto_use: bool,
capture: fn(&World) -> Option<JsonValue>,
},
}
inventory::collect!(HtmlSharedRegistration);
pub use inventory;
pub use serde_json;
impl UiLangVariables {
pub fn set(&mut self, key: impl Into<String>, value: impl Into<String>) {
self.vars.insert(key.into(), value.into());
}
pub fn get(&self, key: &str) -> Option<&str> {
self.vars.get(key).map(String::as_str)
}
pub fn contains_key(&self, key: &str) -> bool {
self.vars.contains_key(key)
}
pub fn set_bool(&mut self, key: impl Into<String>, value: bool) {
self.set(key, if value { "true" } else { "false" });
}
pub fn get_bool(&self, key: &str) -> Option<bool> {
let raw = self.get(key)?;
parse_bool_var(raw)
}
pub fn toggle_bool(&mut self, key: impl Into<String>, default: bool) -> bool {
let key = key.into();
let current = self.get_bool(&key).unwrap_or(default);
let next = !current;
self.set_bool(key, next);
next
}
pub fn set_json<T: Serialize>(
&mut self,
key: impl Into<String>,
value: &T,
) -> Result<(), serde_json::Error> {
let encoded = serde_json::to_string(value)?;
self.vars.insert(key.into(), encoded);
Ok(())
}
pub fn get_json<T: DeserializeOwned>(&self, key: &str) -> Option<T> {
let raw = self.get(key)?;
serde_json::from_str(raw).ok()
}
}
fn parse_bool_var(raw: &str) -> Option<bool> {
match raw.trim().to_ascii_lowercase().as_str() {
"true" | "1" | "yes" | "on" => Some(true),
"false" | "0" | "no" | "off" => Some(false),
_ => None,
}
}
fn parse_html_lang(lang: Option<&str>) -> HtmlLangSetting {
let Some(tag) = normalize_lang_tag(lang) else {
return HtmlLangSetting::Auto;
};
if tag == "auto" || tag == "default" {
HtmlLangSetting::Auto
} else {
HtmlLangSetting::Forced(tag)
}
}
fn detect_system_language() -> Option<String> {
const KEYS: [&str; 4] = ["LC_ALL", "LC_MESSAGES", "LANG", "LANGUAGE"];
for key in KEYS {
if let Ok(value) = env::var(key) {
if let Some(tag) = normalize_env_lang(&value) {
return Some(tag);
}
}
}
None
}
fn normalize_env_lang(value: &str) -> Option<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
return None;
}
let first = trimmed
.split(|c| c == ':' || c == ';' || c == ',')
.next()
.unwrap_or("");
let first = first.split('.').next().unwrap_or("");
normalize_lang_tag(Some(first))
}
fn normalize_lang_tag(raw: Option<&str>) -> Option<String> {
let raw = raw?.trim();
if raw.is_empty() {
return None;
}
let normalized = raw.replace('_', "-").to_lowercase();
if normalized == "c" || normalized == "posix" {
return None;
}
Some(normalized)
}
#[cfg(any(feature = "fluent", feature = "properties-lang"))]
static PLACEHOLDER_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?s)\{\{\s*(.+?)\s*\}\}").unwrap());
#[cfg(not(any(feature = "fluent", feature = "properties-lang")))]
pub fn localize_html(
html: &str,
_lang: Option<&str>,
_language_path: &str,
_vars: &UiLangVariables,
) -> String {
html.to_string()
}
#[cfg(any(feature = "fluent", feature = "properties-lang"))]
pub fn localize_html(
html: &str,
lang: Option<&str>,
language_path: &str,
vars: &UiLangVariables,
) -> String {
if !PLACEHOLDER_RE.is_match(html) {
return html.to_string();
}
let Some(normalized) = normalize_lang_tag(lang) else {
return html.to_string();
};
let mut localized = html.to_string();
#[cfg(feature = "fluent")]
if let Some(out) = localize_html_fluent(&localized, &normalized, language_path, vars) {
localized = out;
}
#[cfg(feature = "properties-lang")]
if let Some(out) = localize_html_properties(&localized, &normalized, language_path, vars) {
localized = out;
}
localized
}
#[cfg(feature = "fluent")]
fn localize_html_fluent(
html: &str,
lang: &str,
language_path: &str,
vars: &UiLangVariables,
) -> Option<String> {
let base = Path::new(language_path);
let path = find_lang_file(base, lang, "ftl")?;
let content = fs::read_to_string(path).ok()?;
let resource = FluentResource::try_new(content).ok()?;
let langid: LanguageIdentifier = lang.parse().unwrap_or_else(|_| {
"en-US"
.parse()
.expect("Failed to parse fallback language identifier")
});
let mut bundle = FluentBundle::new(vec![langid]);
if bundle.add_resource(resource).is_err() {
return None;
}
let mut errors = Vec::new();
let localized = PLACEHOLDER_RE
.replace_all(html, |caps: ®ex::Captures| {
let inner = caps.get(1).map(|m| m.as_str()).unwrap_or_default();
let resolved = resolve_placeholder(
inner,
|key| {
let message = bundle.get_message(key)?;
let pattern = message.value()?;
errors.clear();
let value = bundle.format_pattern(pattern, None, &mut errors);
if errors.is_empty() {
Some(value.to_string())
} else {
None
}
},
vars,
);
resolved.unwrap_or_else(|| {
caps.get(0)
.map(|m| m.as_str())
.unwrap_or_default()
.to_string()
})
})
.into_owned();
Some(localized)
}
#[cfg(feature = "properties-lang")]
fn localize_html_properties(
html: &str,
lang: &str,
language_path: &str,
vars: &UiLangVariables,
) -> Option<String> {
let base = Path::new(language_path);
let path = find_lang_file(base, lang, "properties")?;
let content = fs::read_to_string(path).ok()?;
let map = parse_properties(&content);
let localized = PLACEHOLDER_RE
.replace_all(html, |caps: ®ex::Captures| {
let inner = caps.get(1).map(|m| m.as_str()).unwrap_or_default();
let resolved = resolve_placeholder(inner, |key| map.get(key).cloned(), vars);
resolved.unwrap_or_else(|| {
caps.get(0)
.map(|m| m.as_str())
.unwrap_or_default()
.to_string()
})
})
.into_owned();
Some(localized)
}
#[cfg(feature = "properties-lang")]
fn parse_properties(content: &str) -> HashMap<String, String> {
let mut map = HashMap::new();
for line in content.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with('!') {
continue;
}
let separator = if trimmed.contains('=') {
Some('=')
} else if trimmed.contains(':') {
Some(':')
} else {
None
};
let prop = match separator {
Some(sep) => try_split(trimmed, Some(sep), None),
None => try_split(trimmed, None, None),
};
if let Some(prop) = prop {
map.insert(prop.key(), prop.value());
}
}
map
}
#[cfg(any(feature = "fluent", feature = "properties-lang"))]
fn find_lang_file(base: &Path, lang: &str, extension: &str) -> Option<PathBuf> {
let Some(primary) = find_lang_file_for(base, lang, extension) else {
let fallback = "en";
if normalize_lang_tag(Some(lang)) == normalize_lang_tag(Some(fallback)) {
return None;
}
return find_lang_file_for(base, fallback, extension);
};
Some(primary)
}
#[cfg(any(feature = "fluent", feature = "properties-lang"))]
fn find_lang_file_for(base: &Path, lang: &str, extension: &str) -> Option<PathBuf> {
if !base.exists() {
return None;
}
let target = normalize_lang_tag(Some(lang))?;
for candidate in build_lang_candidates(lang) {
let path = base.join(format!("{candidate}.{extension}"));
if path.is_file() {
return Some(path);
}
}
let entries = fs::read_dir(base).ok()?;
for entry in entries.flatten() {
let path = entry.path();
let Some(ext) = path.extension().and_then(|s| s.to_str()) else {
continue;
};
if ext.to_lowercase() != extension {
continue;
}
let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
continue;
};
let Some(normalized_stem) = normalize_lang_tag(Some(stem)) else {
continue;
};
if normalized_stem == target
|| normalized_stem.starts_with(&format!("{target}-"))
|| normalized_stem.starts_with(&format!("{target}_"))
{
return Some(path);
}
}
None
}
#[cfg(any(feature = "fluent", feature = "properties-lang"))]
fn build_lang_candidates(lang: &str) -> Vec<String> {
let Some(normalized) = normalize_lang_tag(Some(lang)) else {
return Vec::new();
};
let parts: Vec<&str> = normalized.split('-').filter(|p| !p.is_empty()).collect();
if parts.is_empty() {
return Vec::new();
}
let mut candidates = Vec::new();
candidates.push(normalized.clone());
candidates.push(normalized.replace('-', "_"));
if parts.len() >= 2 {
let region_upper = parts[1].to_uppercase();
candidates.push(format!("{}_{}", parts[0], region_upper));
candidates.push(format!("{}_{}", parts[0], parts[1]));
candidates.push(format!("{}-{}", parts[0], parts[1]));
}
candidates.push(parts[0].to_string());
let mut seen = HashSet::new();
candidates.retain(|item| seen.insert(item.clone()));
candidates
}
pub fn vars_fingerprint(vars: &UiLangVariables) -> u64 {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut items: Vec<_> = vars.vars.iter().collect();
items.sort_by(|(a, _), (b, _)| a.cmp(b));
let mut hasher = DefaultHasher::new();
for (key, value) in items {
key.hash(&mut hasher);
value.hash(&mut hasher);
}
hasher.finish()
}
pub fn shared_values_fingerprint(shared: &UiSharedValues) -> u64 {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut shared_entries: Vec<_> = shared.values.iter().collect();
shared_entries.sort_by(|(a, _), (b, _)| a.cmp(b));
let mut alias_entries: Vec<_> = shared.auto_use_aliases.iter().collect();
alias_entries.sort_by(|(a, _), (b, _)| a.cmp(b));
let mut known_types: Vec<_> = shared.known_types.iter().collect();
known_types.sort();
let mut hasher = DefaultHasher::new();
for (key, value) in shared_entries {
key.hash(&mut hasher);
value.to_string().hash(&mut hasher);
}
for (alias, target) in alias_entries {
alias.hash(&mut hasher);
target.hash(&mut hasher);
}
for type_name in known_types {
type_name.hash(&mut hasher);
}
hasher.finish()
}
pub fn refresh_shared_values(world: &mut World) {
let mut values: HashMap<String, JsonValue> = HashMap::new();
let mut aliases: HashMap<String, String> = HashMap::new();
let mut known_types: HashSet<String> = HashSet::new();
for registration in inventory::iter::<HtmlSharedRegistration> {
let HtmlSharedRegistration::Shared {
name,
path,
alias,
auto_use,
capture,
} = registration;
known_types.insert((*name).to_string());
known_types.insert((*path).to_string());
if *auto_use {
if let Some(previous) = aliases.get(*alias) {
if previous != name {
warn!(
"Duplicate #[html_use] alias '{}' for '{}' ignored (already used by '{}')",
alias, name, previous
);
continue;
}
}
aliases.insert((*alias).to_string(), (*name).to_string());
}
if let Some(value) = (*capture)(&*world) {
values.insert((*path).to_string(), value.clone());
values.insert((*name).to_string(), value);
}
}
let mut shared = world.resource_mut::<UiSharedValues>();
if shared.values != values {
shared.values = values;
}
if shared.auto_use_aliases != aliases {
shared.auto_use_aliases = aliases;
}
if shared.known_types != known_types {
shared.known_types = known_types;
}
}
#[cfg(any(feature = "fluent", feature = "properties-lang"))]
pub fn resolve_placeholder<F>(
inner: &str,
mut translate: F,
vars: &UiLangVariables,
) -> Option<String>
where
F: FnMut(&str) -> Option<String>,
{
let resolved = resolve_inner_tokens(inner, &mut translate, vars);
if resolved == inner {
None
} else {
Some(resolved)
}
}
#[cfg(any(feature = "fluent", feature = "properties-lang"))]
fn resolve_inner_tokens<F>(inner: &str, translate: &mut F, vars: &UiLangVariables) -> String
where
F: FnMut(&str) -> Option<String>,
{
let mut out = String::new();
let mut chars = inner.chars().peekable();
while let Some(ch) = chars.next() {
if ch.is_whitespace() {
out.push(ch);
continue;
}
if ch == '%' {
let mut name = String::new();
let mut closed = false;
let mut valid = true;
while let Some(next) = chars.next() {
if next == '%' {
closed = true;
break;
}
if !is_key_char(next) {
valid = false;
}
name.push(next);
}
if closed && valid && !name.is_empty() {
if let Some(value) = vars.vars.get(&name) {
out.push_str(value);
} else {
out.push('%');
out.push_str(&name);
out.push('%');
}
} else {
out.push('%');
out.push_str(&name);
if closed {
out.push('%');
}
}
continue;
}
if is_key_char(ch) {
let mut key = String::new();
key.push(ch);
while let Some(next) = chars.peek().copied() {
if is_key_char(next) {
chars.next();
key.push(next);
} else {
break;
}
}
if let Some(value) = translate(&key) {
out.push_str(&value);
} else {
out.push_str(&key);
}
continue;
}
out.push(ch);
}
out
}
#[cfg(any(feature = "fluent", feature = "properties-lang"))]
fn is_key_char(ch: char) -> bool {
ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | ':' | '-')
}
#[cfg(any(feature = "fluent", feature = "properties-lang"))]
pub use i18n;
#[cfg(feature = "fluent")]
pub use i18n_fluent;
#[cfg(feature = "properties-lang")]
pub use i18n_properties;