use std::collections::HashMap;
use bevy::asset::{io::Reader, AssetLoader, LoadContext};
use bevy::ecs::system::Command;
use bevy::prelude::*;
#[derive(Debug, Clone, Resource)]
pub struct MaterialLanguage {
pub tag: String,
}
impl MaterialLanguage {
pub fn new(tag: impl Into<String>) -> Self {
Self { tag: tag.into() }
}
}
impl Default for MaterialLanguage {
fn default() -> Self {
let tag = std::env::var("LANG")
.or_else(|_| std::env::var("LC_ALL"))
.or_else(|_| std::env::var("LC_MESSAGES"))
.unwrap_or_else(|_| "en-US".to_string());
let tag = tag.replace('_', "-");
Self { tag }
}
}
#[derive(Component, Debug, Clone, PartialEq, Eq)]
pub struct MaterialLanguageOverride {
pub tag: String,
}
impl MaterialLanguageOverride {
pub fn new(tag: impl Into<String>) -> Self {
Self { tag: tag.into() }
}
}
#[derive(Asset, Debug, Clone, TypePath)]
pub struct MaterialTranslations {
pub language: String,
pub strings: HashMap<String, String>,
}
#[derive(Default, TypePath)]
pub struct MaterialTranslationsLoader;
#[derive(Debug, thiserror::Error)]
pub enum MaterialTranslationsLoaderError {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("JSON parse error: {0}")]
Json(#[from] serde_json::Error),
#[error("Missing required field: {0}")]
MissingField(&'static str),
}
impl AssetLoader for MaterialTranslationsLoader {
type Asset = MaterialTranslations;
type Settings = ();
type Error = MaterialTranslationsLoaderError;
async fn load(
&self,
reader: &mut dyn Reader,
_settings: &Self::Settings,
_load_context: &mut LoadContext<'_>,
) -> Result<Self::Asset, Self::Error> {
let mut bytes = Vec::new();
reader.read_to_end(&mut bytes).await?;
let json: serde_json::Value = serde_json::from_slice(&bytes)?;
let language = json
.get("language")
.and_then(|v| v.as_str())
.ok_or(MaterialTranslationsLoaderError::MissingField("language"))?
.to_string();
let strings_value = json
.get("strings")
.ok_or(MaterialTranslationsLoaderError::MissingField("strings"))?;
let strings_obj = strings_value
.as_object()
.ok_or(MaterialTranslationsLoaderError::MissingField("strings"))?;
let mut strings = HashMap::with_capacity(strings_obj.len());
for (k, v) in strings_obj.iter() {
if let Some(s) = v.as_str() {
strings.insert(k.clone(), s.to_string());
}
}
Ok(MaterialTranslations { language, strings })
}
fn extensions(&self) -> &[&str] {
&["mui_lang"]
}
}
#[derive(Resource, Debug, Clone)]
pub struct MaterialI18n {
bundles: HashMap<String, HashMap<String, String>>,
pub fallback_language: String,
revision: u64,
}
impl MaterialI18n {
pub fn revision(&self) -> u64 {
self.revision
}
pub fn bundle_languages(&self) -> impl Iterator<Item = &String> {
self.bundles.keys()
}
pub fn insert_bundle(&mut self, language: String, strings: HashMap<String, String>) {
self.bundles.insert(language, strings);
self.revision = self.revision.wrapping_add(1);
}
pub fn translate<'a>(&'a self, language: &str, key: &str) -> Option<&'a str> {
self.bundles
.get(language)
.and_then(|b| b.get(key))
.map(|s| s.as_str())
.or_else(|| {
if self.fallback_language.is_empty() {
None
} else {
self.bundles
.get(self.fallback_language.as_str())
.and_then(|b| b.get(key))
.map(|s| s.as_str())
}
})
}
}
impl Default for MaterialI18n {
fn default() -> Self {
Self {
bundles: HashMap::default(),
fallback_language: "en-US".to_string(),
revision: 0,
}
}
}
#[derive(Component, Debug, Clone, PartialEq, Eq)]
pub struct LocalizedText {
pub key: String,
pub default: Option<String>,
}
impl LocalizedText {
pub fn new(key: impl Into<String>) -> Self {
Self {
key: key.into(),
default: None,
}
}
pub fn with_default(mut self, default: impl Into<String>) -> Self {
self.default = Some(default.into());
self
}
}
#[derive(Component, Debug, Default, Clone, PartialEq, Eq)]
struct LocalizedTextState {
last_revision: u64,
last_language: String,
}
fn resolve_language_tag(
mut entity: Entity,
child_of: &Query<&ChildOf>,
overrides: &Query<&MaterialLanguageOverride>,
global: &MaterialLanguage,
) -> String {
if let Ok(ov) = overrides.get(entity) {
return ov.tag.clone();
}
while let Ok(parent) = child_of.get(entity) {
entity = parent.parent();
if let Ok(ov) = overrides.get(entity) {
return ov.tag.clone();
}
}
global.tag.clone()
}
fn i18n_ingest_assets_system(
mut i18n: ResMut<MaterialI18n>,
translations: Res<Assets<MaterialTranslations>>,
mut events: MessageReader<AssetEvent<MaterialTranslations>>,
) {
for ev in events.read() {
match ev {
AssetEvent::Added { id }
| AssetEvent::Modified { id }
| AssetEvent::LoadedWithDependencies { id } => {
if let Some(asset) = translations.get(*id) {
i18n.insert_bundle(asset.language.clone(), asset.strings.clone());
}
}
AssetEvent::Removed { .. } => {
}
_ => {}
}
}
}
fn localized_text_apply_system(
i18n: Res<MaterialI18n>,
language: Res<MaterialLanguage>,
child_of: Query<&ChildOf>,
overrides: Query<&MaterialLanguageOverride>,
mut texts: Query<(
Entity,
&LocalizedText,
&mut Text,
Option<&mut LocalizedTextState>,
)>,
mut commands: Commands,
) {
let global_revision = i18n.revision();
for (entity, binding, mut text, state) in texts.iter_mut() {
let resolved_language = resolve_language_tag(entity, &child_of, &overrides, &language);
let needs_update = match &state {
Some(s) => s.last_revision != global_revision || s.last_language != resolved_language,
None => true,
};
if needs_update {
let resolved = i18n
.translate(&resolved_language, &binding.key)
.map(|s| s.to_string())
.or_else(|| binding.default.clone())
.unwrap_or_else(|| binding.key.clone());
if text.as_str() != resolved {
*text = Text::new(resolved);
}
if let Some(mut state) = state {
state.last_revision = global_revision;
state.last_language = resolved_language;
} else {
commands.queue(TryInsertLocalizedTextState {
entity,
state: LocalizedTextState {
last_revision: global_revision,
last_language: resolved_language,
},
});
}
}
}
}
struct TryInsertLocalizedTextState {
entity: Entity,
state: LocalizedTextState,
}
impl Command for TryInsertLocalizedTextState {
fn apply(self, world: &mut World) {
if let Ok(mut entity) = world.get_entity_mut(self.entity) {
entity.insert(self.state);
}
}
}
pub struct MaterialI18nPlugin;
impl Plugin for MaterialI18nPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<MaterialLanguage>()
.init_resource::<MaterialI18n>()
.init_asset::<MaterialTranslations>()
.register_asset_loader(MaterialTranslationsLoader)
.add_systems(
Update,
(i18n_ingest_assets_system, localized_text_apply_system),
);
}
}