Skip to main content

es_fluent_manager_bevy/
lib.rs

1#![doc = include_str!("../README.md")]
2
3pub use bevy;
4pub use inventory;
5
6use bevy::asset::{Asset, AssetLoader, AsyncReadExt as _, LoadContext};
7use bevy::prelude::*;
8use fluent_bundle::{FluentResource, FluentValue, bundle::FluentBundle};
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11use std::sync::Arc;
12use unic_langid::LanguageIdentifier;
13
14#[cfg(feature = "macros")]
15pub use es_fluent_manager_macros::BevyFluentText;
16#[cfg(feature = "macros")]
17pub use es_fluent_manager_macros::define_bevy_i18n_module as define_i18n_module;
18
19pub use unic_langid;
20
21pub mod components;
22pub mod plugin;
23pub mod systems;
24
25pub use components::*;
26pub use es_fluent::{FluentDisplay, ToFluentString};
27pub use plugin::*;
28pub use systems::*;
29
30/// A Bevy resource that holds the currently active `LanguageIdentifier`.
31#[derive(Clone, Resource)]
32pub struct CurrentLanguageId(pub LanguageIdentifier);
33
34/// Returns the primary language subtag from a `LanguageIdentifier`.
35///
36/// For example, for `en-US`, this would return `en`.
37pub fn primary_language(lang: &LanguageIdentifier) -> &str {
38    lang.language.as_str()
39}
40
41/// A trait for types that can be constructed from a `LanguageIdentifier`.
42///
43/// This is useful for components that need to be initialized with locale-specific
44/// data.
45pub trait FromLocale {
46    /// Creates an instance of `Self` from the given language identifier.
47    fn from_locale(lang: &LanguageIdentifier) -> Self;
48}
49
50/// A trait for types that can be updated in place when the locale changes.
51///
52/// This allows preserving the state of a component while updating only the
53/// locale-dependent fields.
54pub trait RefreshForLocale {
55    /// Refreshes the internal state of `self` based on the new language identifier.
56    fn refresh_for_locale(&mut self, lang: &LanguageIdentifier);
57}
58
59/// Blanket implementation of `RefreshForLocale` for types that implement `FromLocale`.
60///
61/// This falls back to rebuilding the entire object if no specialized implementation
62/// is provided.
63impl<T> RefreshForLocale for T
64where
65    T: FromLocale,
66{
67    #[inline]
68    fn refresh_for_locale(&mut self, lang: &LanguageIdentifier) {
69        *self = T::from_locale(lang);
70    }
71}
72
73/// A Bevy asset representing a Fluent Translation List (`.ftl`) file.
74#[derive(Asset, Clone, Debug, Deserialize, Serialize, TypePath)]
75pub struct FtlAsset {
76    /// The raw string content of the `.ftl` file.
77    pub content: String,
78}
79
80/// An `AssetLoader` for loading `.ftl` files as `FtlAsset`s.
81#[derive(Default, TypePath)]
82pub struct FtlAssetLoader;
83
84impl AssetLoader for FtlAssetLoader {
85    type Asset = FtlAsset;
86    type Settings = ();
87    type Error = std::io::Error;
88
89    async fn load(
90        &self,
91        reader: &mut dyn bevy::asset::io::Reader,
92        _settings: &Self::Settings,
93        _load_context: &mut LoadContext<'_>,
94    ) -> Result<Self::Asset, Self::Error> {
95        let mut content = String::new();
96        reader.read_to_string(&mut content).await?;
97        Ok(FtlAsset { content })
98    }
99
100    fn extensions(&self) -> &[&str] {
101        &["ftl"]
102    }
103}
104
105/// A Bevy `Message` sent to request a change of the current locale.
106#[derive(Clone, Message)]
107pub struct LocaleChangeEvent(pub LanguageIdentifier);
108
109/// A Bevy `Message` sent after the current locale has been successfully changed.
110#[derive(Clone, Message)]
111pub struct LocaleChangedEvent(pub LanguageIdentifier);
112
113/// A Bevy resource that manages the loading of `FtlAsset`s.
114#[derive(Clone, Default, Resource)]
115pub struct I18nAssets {
116    /// A map from `(LanguageIdentifier, domain)` to the corresponding `Handle<FtlAsset>`.
117    pub assets: HashMap<(LanguageIdentifier, String), Handle<FtlAsset>>,
118    /// A map from `(LanguageIdentifier, domain)` to the parsed `FluentResource`.
119    pub loaded_resources: HashMap<(LanguageIdentifier, String), Arc<FluentResource>>,
120}
121
122type SyncFluentBundle =
123    FluentBundle<Arc<FluentResource>, intl_memoizer::concurrent::IntlLangMemoizer>;
124
125/// A Bevy resource containing the `FluentBundle` for each loaded language.
126#[derive(Clone, Default, Resource)]
127pub struct I18nBundle(pub HashMap<LanguageIdentifier, Arc<SyncFluentBundle>>);
128
129impl I18nAssets {
130    /// Creates a new, empty `I18nAssets` resource.
131    pub fn new() -> Self {
132        Self::default()
133    }
134
135    /// Adds an FTL asset to be managed.
136    pub fn add_asset(
137        &mut self,
138        lang: LanguageIdentifier,
139        domain: String,
140        handle: Handle<FtlAsset>,
141    ) {
142        self.assets.insert((lang, domain), handle);
143    }
144
145    /// Checks if all registered FTL assets for a given language have been loaded.
146    pub fn is_language_loaded(&self, lang: &LanguageIdentifier) -> bool {
147        self.assets
148            .keys()
149            .filter(|(l, _)| l == lang)
150            .all(|key| self.loaded_resources.contains_key(key))
151    }
152
153    /// Retrieves all loaded `FluentResource`s for a given language.
154    pub fn get_language_resources(&self, lang: &LanguageIdentifier) -> Vec<&Arc<FluentResource>> {
155        self.loaded_resources
156            .iter()
157            .filter_map(
158                |((l, _), resource)| {
159                    if l == lang { Some(resource) } else { None }
160                },
161            )
162            .collect()
163    }
164
165    /// Returns the set of languages that have assets registered.
166    pub fn available_languages(&self) -> Vec<LanguageIdentifier> {
167        let mut seen = std::collections::HashSet::new();
168        let mut languages = Vec::new();
169
170        for (lang, _) in self.assets.keys() {
171            if seen.insert(lang.clone()) {
172                languages.push(lang.clone());
173            }
174        }
175
176        languages.sort_by_key(|lang| lang.to_string());
177        languages
178    }
179}
180
181/// The main resource for handling localization.
182#[derive(Resource)]
183pub struct I18nResource {
184    current_language: LanguageIdentifier,
185}
186
187impl I18nResource {
188    /// Creates a new `I18nResource` with the given initial language.
189    pub fn new(initial_language: LanguageIdentifier) -> Self {
190        Self {
191            current_language: initial_language,
192        }
193    }
194
195    /// Returns the current `LanguageIdentifier`.
196    pub fn current_language(&self) -> &LanguageIdentifier {
197        &self.current_language
198    }
199
200    /// Sets the current language.
201    pub fn set_language(&mut self, lang: LanguageIdentifier) {
202        self.current_language = lang;
203    }
204
205    /// Localizes a message by its ID and arguments.
206    ///
207    /// Returns `None` if the message ID is not found in the bundle for the current language.
208    pub fn localize<'a>(
209        &self,
210        id: &str,
211        args: Option<&HashMap<&str, FluentValue<'a>>>,
212        i18n_bundle: &I18nBundle,
213    ) -> Option<String> {
214        let bundle = i18n_bundle.0.get(&self.current_language)?;
215
216        let message = bundle.get_message(id)?;
217        let pattern = message.value()?;
218
219        let mut errors = Vec::new();
220        let fluent_args = args.map(|args| {
221            let mut fa = fluent_bundle::FluentArgs::new();
222            for (key, value) in args {
223                fa.set(*key, value.clone());
224            }
225            fa
226        });
227
228        let value = bundle.format_pattern(pattern, fluent_args.as_ref(), &mut errors);
229
230        if !errors.is_empty() {
231            error!("Fluent formatting errors for '{}': {:?}", id, errors);
232        }
233
234        Some(value.into_owned())
235    }
236}
237
238/// A convenience function for localizing a message by its ID.
239///
240/// This function uses the `I18nResource` and `I18nBundle` to look up the
241/// translation. If the translation is not found, a warning is logged and the
242/// ID is returned as a fallback.
243pub fn localize<'a>(
244    i18n_resource: &I18nResource,
245    i18n_bundle: &I18nBundle,
246    id: &str,
247    args: Option<&HashMap<&str, FluentValue<'a>>>,
248) -> String {
249    i18n_resource
250        .localize(id, args, i18n_bundle)
251        .unwrap_or_else(|| {
252            warn!("Translation for '{}' not found", id);
253            id.to_string()
254        })
255}
256
257/// A Bevy system that listens for `LocaleChangedEvent`s and updates components
258/// that implement `RefreshForLocale`.
259pub fn update_values_on_locale_change<T>(
260    mut locale_changed_events: MessageReader<LocaleChangedEvent>,
261    mut query: Query<&mut FluentText<T>>,
262) where
263    T: RefreshForLocale + ToFluentString + Clone + Component,
264{
265    for event in locale_changed_events.read() {
266        for mut fluent_text in query.iter_mut() {
267            fluent_text.value.refresh_for_locale(&event.0);
268        }
269    }
270}
271
272/// A plugin that initializes the `es-fluent` Bevy integration.
273pub struct EsFluentBevyPlugin;
274
275impl Plugin for EsFluentBevyPlugin {
276    fn build(&self, _app: &mut App) {
277        debug!("EsFluentBevyPlugin initialized");
278    }
279}
280
281/// Trait for auto-registering FluentText systems with Bevy.
282///
283/// This trait is implemented by the `#[derive(EsFluent)]` macro when using
284/// `#[fluent(bevy)]` or `#[fluent(bevy_locale)]` attributes.
285pub trait BevyFluentTextRegistration: Send + Sync {
286    /// Registers the FluentText systems for this type with the Bevy app.
287    fn register(&self, app: &mut App);
288}
289
290inventory::collect!(&'static dyn BevyFluentTextRegistration);
291
292/// An extension trait for `App` to simplify the registration of `FluentText` components.
293pub trait FluentTextRegistration {
294    /// Registers the necessary systems for a `FluentText<T>` component.
295    fn register_fluent_text<
296        T: es_fluent::ToFluentString + Clone + Component + Send + Sync + 'static,
297    >(
298        &mut self,
299    ) -> &mut Self;
300
301    /// Registers the necessary systems for a `FluentText<T>` component that
302    /// also implements `RefreshForLocale`.
303    ///
304    /// This ensures that the component's value is updated when the locale changes.
305    fn register_fluent_text_from_locale<
306        T: es_fluent::ToFluentString + Clone + Component + RefreshForLocale + Send + Sync + 'static,
307    >(
308        &mut self,
309    ) -> &mut Self;
310}
311
312impl FluentTextRegistration for App {
313    fn register_fluent_text<
314        T: es_fluent::ToFluentString + Clone + Component + Send + Sync + 'static,
315    >(
316        &mut self,
317    ) -> &mut Self {
318        self.add_systems(
319            PostUpdate,
320            (
321                crate::systems::update_all_fluent_text_on_locale_change::<T>,
322                crate::systems::update_fluent_text_system::<T>,
323            )
324                .chain(),
325        );
326        self
327    }
328
329    fn register_fluent_text_from_locale<
330        T: es_fluent::ToFluentString + Clone + Component + RefreshForLocale + Send + Sync + 'static,
331    >(
332        &mut self,
333    ) -> &mut Self {
334        self.add_systems(
335            PostUpdate,
336            (
337                crate::update_values_on_locale_change::<T>,
338                crate::systems::update_fluent_text_system::<T>,
339            )
340                .chain(),
341        );
342        self
343    }
344}
345
346pub use unic_langid::langid;