Skip to content

Commit

Permalink
Updated Localization Changelog + Docs + Example
Browse files Browse the repository at this point in the history
  • Loading branch information
ecton committed Dec 22, 2024
1 parent d0c2704 commit 3787d5c
Show file tree
Hide file tree
Showing 8 changed files with 82 additions and 85 deletions.
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -390,8 +390,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
supported by a `GraphicsContext`. `FontSettings::apply()` can be used to apply
settings in one step. `FontSettings` also implements `PartialEq` allowing it
to be used as a cache invalidation key.
- New feature `localization`, included in Cushy's default features, enables
multi-lingual/multi-locale support using [Fluent][fluent]. See the
`localization` module for documentation of this feature, or see the
`localization.rs` example in the repository to see it in action.


[fluent]: https://projectfluent.org/
[139]: https://github.com/khonsulabs/cushy/issues/139

## v0.4.0 (2024-08-20)
Expand Down
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ name = "tokio"
required-features = ["tokio"]

[[example]]
name = "localized"
name = "localization"
required-features = ["localization"]

[profile.release]
Expand Down
4 changes: 2 additions & 2 deletions examples/assets/localizations/en-GB/hello.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ language-en-gb = English (Great Britain)
language-en-us = English (United States)
language-es-es = Spanish (Spain)
banana-counter-message =
{ $bananas_counter ->
{ $bananas ->
[one] There is one banana.
*[other] You have { $bananas_counter } bananas.
*[other] You have { $bananas } bananas.
}
4 changes: 2 additions & 2 deletions examples/assets/localizations/en-US/hello.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ language-en-gb = English (Great Britain)
language-en-us = English (United States)
language-es-es = Spanish (Spain)
banana-counter-message =
{ $bananas_counter ->
{ $bananas ->
[one] There is one banana.
*[other] You have { $bananas_counter } bananas.
*[other] You have { $bananas } bananas.
}
4 changes: 2 additions & 2 deletions examples/assets/localizations/es-ES/hello.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ language-en-gb = Inglés (Gran Bretaña)
language-en-us = Inglés (Estados Unidos)
language-es-es = Español (España)
banana-counter-message =
{ $bananas_counter ->
{ $bananas ->
[one] Hay un plátano.
*[other] Tienes { $bananas_counter } plátanos.
*[other] Tienes { $bananas } plátanos.
}
70 changes: 40 additions & 30 deletions examples/localized.rs → examples/localization.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,19 @@ use cushy::widget::MakeWidget;
use cushy::{localize, Open, PendingApp};
use unic_langid::LanguageIdentifier;

fn localized() -> impl MakeWidget {
fn localization() -> impl MakeWidget {
// Create a widget showing `message-hello-world`, which we will place on the
// window such that it detects the system locale.
let element_in_default_locale = localize!("message-hello-world").contain();

// Create a widget showing `message-hello-world` in Spanish, always.
let specific_locale: LanguageIdentifier = "es-ES".parse().unwrap();
let elements_in_specific_locale = localize!("message-hello-world")
.localized_in(specific_locale)
.contain();

// Create a widget that shows `message-hello-world` in the locale selected
// by this example's available locales.
let dynamic_locale: Dynamic<LanguageChoices> = Dynamic::default();
let dynamic_message_label = localize!("message-hello-world");

Expand All @@ -31,33 +36,29 @@ fn localized() -> impl MakeWidget {
.into_rows()
.contain();

let bananas_counter = Dynamic::new(0u32);
// Fluent also supports parameterization, allowing localizers incredible
// flexibility in how messages and values are localized. This example shows
// how a dynamic counter can be used in localization in Cushy.
let bananas = Dynamic::new(0u32);

let counter_elements =
localize!("banana-counter-message", "bananas_counter" => &bananas_counter)
.and(
"+".into_button()
.on_click(bananas_counter.with_clone(|counter| {
move |_| {
let mut counter = counter.lock();
counter.checked_add(1).inspect(|new_counter| {
*counter = *new_counter;
});
}
})),
)
.and(
"-".into_button()
.on_click(bananas_counter.with_clone(|counter| {
move |_| {
let mut counter = counter.lock();
counter.checked_sub(1).inspect(|new_counter| {
*counter = *new_counter;
});
}
})),
)
.into_columns();
let counter_elements = localize!("banana-counter-message", "bananas" => &bananas)
.and("+".into_button().on_click(bananas.with_clone(|counter| {
move |_| {
let mut counter = counter.lock();
counter.checked_add(1).inspect(|new_counter| {
*counter = *new_counter;
});
}
})))
.and("-".into_button().on_click(bananas.with_clone(|counter| {
move |_| {
let mut counter = counter.lock();
counter.checked_sub(1).inspect(|new_counter| {
*counter = *new_counter;
});
}
})))
.into_columns();

let dynamic_container = dynamic_message_label
.and(counter_elements)
Expand All @@ -66,6 +67,7 @@ fn localized() -> impl MakeWidget {
.contain()
.localized_in(dynamic_locale.map_each(LanguageChoices::to_locale));

// Assemble the parts of the interface.
element_in_default_locale
.and(elements_in_specific_locale)
.and(dynamic_container)
Expand Down Expand Up @@ -94,22 +96,30 @@ impl LanguageChoices {

#[cushy::main]
fn main(app: &mut PendingApp) -> cushy::Result {
// If you comment this block out, you can see the effect of having missing translation files.
// If you comment this block out, you can see the effect of having missing localization files.
{
// Adds a localization for en-US, setting it as the default
// localization. If the system running this application is not
// compatible with the available locales, the `en-US` localization will
// be used.
app.cushy().localizations().add_default(
Localization::for_language(
"en-US",
include_str!("assets/localizations/en-US/hello.ftl"),
)
.expect("valid language id"),
);
// Adds a localization for en-GB. Fluent supports region-specific
// localizations, and Cushy will attempt to find localizations in the
// best-matching locale.
app.cushy().localizations().add(
Localization::for_language(
"en-GB",
include_str!("assets/localizations/en-GB/hello.ftl"),
)
.expect("valid language id"),
);
// Adds a localization for es-ES.
app.cushy().localizations().add(
Localization::for_language(
"es-ES",
Expand All @@ -119,12 +129,12 @@ fn main(app: &mut PendingApp) -> cushy::Result {
);
}

localized().into_window().open(app)?;
localization().into_window().open(app)?;

Ok(())
}

#[test]
fn runs() {
cushy::example!(localized).untested_still_frame();
cushy::example!(localization).untested_still_frame();
}
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,7 @@ fn initialize_tracing() {
)
.with(
Targets::new()
.with_default(MAX_LEVEL)
.with_target("winit", Level::ERROR)
.with_target("wgpu", Level::ERROR)
.with_target("naga", Level::ERROR),
Expand Down
76 changes: 29 additions & 47 deletions src/localization.rs
Original file line number Diff line number Diff line change
Expand Up @@ -294,34 +294,29 @@ impl Localize {
f: &mut W,
) -> fmt::Result {
let locale = context.locale();

let localizations = context.localizations();
let mut state = localizations.state.lock();
let mut state = context.localizations().state.lock();
// When localizing, we need mut access to update the FallbackLocales
// cache. We don't want fallback locale renegotation to cause extra
// invalidations.
state.prevent_notifications();

let Some((bundle, message)) = state.localize(self, &locale) else {
return f.write_str(&format!("No message. locale: {locale}, key: {}", self.key));
};

let Some(value) = message.value() else {
return f.write_str(&format!("No value. locale: {locale}, key: {}", self.key));
let Some((bundle, value)) = state
.localize(self, &locale)
.and_then(|(bundle, message)| message.value().map(|value| (bundle, value)))
else {
tracing::warn!("missing localization of `{}` for {locale}", self.key);
return f.write_str(&format!("$missing {} for {locale}$", self.key));
};

let mut err = vec![];
let args = self.get_args(context);
let res = bundle.format_pattern(value, Some(&args), &mut err);

if err.is_empty() {
f.write_str(&res)
} else {
f.write_str(&format!(
"{} {{Error. locale: {}, key: {}, cause: {:?}}}",
locale, self.key, res, err
))
bundle.write_pattern(f, value, Some(&args), &mut err)?;

for err in err {
tracing::error!("error localizing {} in {locale}: {err}", self.key);
}

Ok(())
}
}

Expand Down Expand Up @@ -464,27 +459,27 @@ pub struct Localizations {
}

impl Localizations {
/// Add a `Fluent` translation file for a given locale.
/// Add a localization to this collection.
///
/// Note the `.ftl` file is not immediately parsed.
pub fn add(&self, translation: Localization) {
/// Any errors will be output using `tracing`.
pub fn add(&self, localization: Localization) {
let mut state = self.state.lock();

state.add(translation);
state.add(localization);
}

/// Add a `Fluent` translation file for a given locale, setting this
/// translation's locale as the default locale for this application.
/// Add a localization to this collection, setting this localizations's
/// locale as the default locale.
///
/// Note the `.ftl` file is not immediately parsed.
/// Any errors will be output using `tracing`.
///
/// See [`Localizations::set_default_locale`] for more information about
/// what the default locale is for.
pub fn add_default(&self, translation: Localization) {
pub fn add_default(&self, localization: Localization) {
let mut state = self.state.lock();
state.default_locale = translation.locale.clone();
state.default_locale = localization.locale.clone();

state.add(translation);
state.add(localization);
}

/// Sets the locale to use as a fallback when the currently set or detected
Expand Down Expand Up @@ -532,25 +527,12 @@ impl Localizations {
}
}

#[derive(Default)]
struct TranslationState {
fallback_locales: FallbackLocales,
default_locale: LanguageIdentifier,
all_locales: Vec<LanguageIdentifier>,
loaded_translations: HashMap<LanguageIdentifier, FluentBundle<FluentResource>>,
}

impl Default for TranslationState {
fn default() -> Self {
Self {
fallback_locales: FallbackLocales::default(),
default_locale: LanguageIdentifier::default(),
all_locales: Vec::new(),
loaded_translations: HashMap::from([(
LanguageIdentifier::default(),
FluentBundle::new_concurrent(vec![LanguageIdentifier::default()]),
)]),
}
}
loaded_bundles: HashMap<LanguageIdentifier, FluentBundle<FluentResource>>,
}

impl TranslationState {
Expand All @@ -565,7 +547,7 @@ impl TranslationState {
}
};
let bundle = self
.loaded_translations
.loaded_bundles
.entry(translation.locale.clone())
.or_insert_with(|| FluentBundle::new_concurrent(vec![translation.locale.clone()]));
if let Err(errors) = bundle.add_resource(res) {
Expand All @@ -582,7 +564,7 @@ impl TranslationState {
message: &Localize,
locale: &LanguageIdentifier,
) -> Option<(&'a FluentBundle<FluentResource>, FluentMessage<'a>)> {
self.loaded_translations
self.loaded_bundles
.get(locale)
.and_then(|bundle| {
bundle
Expand All @@ -592,15 +574,15 @@ impl TranslationState {
.or_else(|| {
self.fallback_locales
.fallback_for(locale, &self.all_locales, &self.default_locale)
.and_then(|fallback| self.loaded_translations.get(fallback))
.and_then(|fallback| self.loaded_bundles.get(fallback))
.and_then(|bundle| {
bundle
.get_message(&message.key)
.map(|message| (bundle, message))
})
})
.or_else(|| {
self.loaded_translations
self.loaded_bundles
.get(&self.default_locale)
.and_then(|bundle| {
bundle
Expand Down

0 comments on commit 3787d5c

Please sign in to comment.