vizia_core/localization/mod.rs
1//! Provides types for adapting an application to a particular language or regional peculiarities.
2//!
3//! # Language Translation
4//!
5//! Vizia provides the ability to dynamically translate text using [fluent](https://projectfluent.org/). Fluent provides a syntax for describing how text should be translated into different languages.
6//! The [fluent syntax guide](https://projectfluent.org/fluent/guide/) contains more information on the fluent syntax.
7//!
8//! ## Adding Fluent Files
9//! Before text can be translated, one or more fluent files must be added to an application with the corresponding locale:
10//! ```ignore
11//! # use vizia_core::prelude::*;
12//! # let mut cx = &mut Context::default();
13//! // Adds a fluent file to the application resource manager.
14//! // This file is then used for translations to the corresponding locale.
15//! cx.add_translation(
16//! langid!("en-US"),
17//! include_str!("resources/en-US/translation.ftl").to_owned(),
18//! );
19//!
20//! ```
21//!
22//! ## Setting the Locale
23//! The application will use the system locale by default, however an environment event can be used to set a custom locale.
24//! If no exact translation exists for the specified locale, vizia will negotiate the best available match and then fall back
25//! per message to the default translation bundle when needed.
26//! ```ignore
27//! # use vizia_core::prelude::*;
28//! # let mut cx = &mut Context::default();
29//! // Sets the current locale to en-US, regardless of the system locale
30//! cx.emit(EnvironmentEvent::SetLocale(langid!("en-US")));
31//! ```
32//!
33//! ## Diagnostics
34//! Missing keys, missing attributes, and Fluent formatting issues are reported through the standard
35//! [`log`](https://docs.rs/log) backend at `warn` level.
36//! Configure your logger (for example with `env_logger`, `tracing-log`, or `fern`) to surface these messages.
37//!
38//! ## Basic Translation
39//! Use the [`Localized`] type to specify a translation key to be used with fluent files. The key is then used to look up the corresponding translation.
40//! ```ignore
41//! # use vizia_core::prelude::*;
42//! # let mut cx = &mut Context::default();
43//! Label::new(cx, Localized::new("hello-world"));
44//! ```
45//! The markup in the loaded fluent (.ftl) files defines the translations for a particular key. The translation used depends on the application locale, which can be queried from [`Environment`].
46//! ```ftl
47//! // en-US/hello.ftl
48//! hello-world = Hello, world!
49//! ```
50//! ```ftl
51//! // fr/hello.ftl
52//! hello-world = Bonjour, monde!
53//! ```
54//!
55//! ## Variables
56//! Data from the application can be inserted into translated text using a [placeable](https://projectfluent.org/fluent/guide/variables.html).
57//! The variable is enclosed in curly braces and prefixed with a `$` symbol.
58//! ```ftl
59//! welcome = Welcome, { $user }!
60//! ```
61//! The [`Localized`] type provides the `arg(...)` method for referencing a variable. It accepts either a plain value
62//! or a signal, binding the fluent variable to application data and updating when that data changes.
63//! ```ignore
64//! # use vizia_core::prelude::*;
65//! # let mut cx = &mut Context::default();
66//! Label::new(cx, Localized::new("welcome").arg("user", "Jane"));
67//! ```
68//!
69//! ```ignore
70//! # use vizia_core::prelude::*;
71//! # let mut cx = &mut Context::default();
72//! #
73//! let user = Signal::new("Jane".to_string());
74//!
75//! Label::new(cx, Localized::new("welcome").arg("user", user));
76//! ```
77//!
78//! ## Attributes
79//! Messages can have named attributes that provide alternative translations. These are useful for UI elements that need multiple text values.
80//! Attributes must be preceded by a main message value:
81//! ```ftl
82//! file-dialog = File Dialog
83//! .title = Save File
84//! .save-button = Save
85//! ```
86//! To reference an attribute, use the `.attribute()` method:
87//! ```ignore
88//! # use vizia_core::prelude::*;
89//! # let mut cx = &mut Context::default();
90//! Label::new(cx, Localized::new("file-dialog").attribute("title"));
91//! ```
92//!
93//! ## Terms
94//! Terms are special variables prefixed with a hyphen that can be referenced in other messages. They're automatically
95//! available in all translations and are commonly used for product names or branding.
96//! ```ftl
97//! -brand = Vizia
98//! welcome = Welcome to { -brand }!
99//! ```
100//! Terms are automatically resolved when formatting, so no special configuration is needed when using them in messages.
101//!
102//! ## Message References
103//! Messages can reference other messages to maintain consistency and reduce duplication:
104//! ```ftl
105//! menu-save = Save
106//! help-menu-save = Click { menu-save } to save the file.
107//! ```
108//! Message references are automatically resolved by the localization system and work seamlessly with the [`Localized`] type.
109//! This is useful for keeping certain translations consistent across the interface and making maintenance easier.
110//!
111//! ## Selectors and Plurals
112//! Fluent selectors let you choose translations based on a variable value:
113//! ```ftl
114//! role-label = { $role ->
115//! [admin] You are signed in as an administrator.
116//! *[user] You are signed in as a user.
117//! }
118//! cart-summary = { $count ->
119//! [one] You have one item in your cart.
120//! *[other] You have { $count } items in your cart.
121//! }
122//! ```
123//! In Rust, pass the selector values with `arg(...)`:
124//! ```ignore
125//! # use vizia_core::prelude::*;
126//! # let mut cx = &mut Context::default();
127//! Label::new(cx, Localized::new("role-label").arg("role", "admin"));
128//! Label::new(cx, Localized::new("cart-summary").arg("count", 3));
129//! ```
130//!
131//! ## Number Formatting
132//! Numbers can be formatted in FTL with the built-in `NUMBER` function:
133//! ```ftl
134//! price = Your total is { NUMBER($amount) }
135//! ```
136//! In Rust, pass numbers directly as arguments:
137//! ```ignore
138//! # use vizia_core::prelude::*;
139//! # let mut cx = &mut Context::default();
140//! Label::new(cx, Localized::new("price").arg("amount", 99.99));
141//! ```
142//!
143//! For more control, use the helper functions to specify decimal places:
144//! ```ignore
145//! # use vizia_core::prelude::*;
146//! # let mut cx = &mut Context::default();
147//! Label::new(cx, Localized::new("price").arg("amount", number_with_fraction(99.99, 2)));
148//! ```
149//! Or for percentages:
150//! ```ignore
151//! # use vizia_core::prelude::*;
152//! # let mut cx = &mut Context::default();
153//! Label::new(cx, Localized::new("completion").arg("percent", percentage(0.75, 1)));
154//! ```
155//!
156//! Currency symbols and symbol placement are best handled in translations today.
157//! Pre-format the numeric portion in Rust or upstream and let each locale decide where the symbol belongs:
158//! ```ftl
159//! # en-US
160//! price-currency = Price: ${ $amount }
161//!
162//! # fr
163//! price-currency = Prix : { $amount } €
164//! ```
165//! ```ignore
166//! # use vizia_core::prelude::*;
167//! # let mut cx = &mut Context::default();
168//! Label::new(cx, Localized::new("price-currency").arg("amount", "99.99"));
169//! ```
170//!
171//! ## Date Formatting
172//! Dates can be formatted with locale-specific rules using the built-in `DATETIME` function in FTL.
173//! Pass chrono datetime values directly to `arg()` - the conversion to milliseconds is handled automatically:
174//! ```ignore
175//! # use vizia_core::prelude::*;
176//! # use chrono::Utc;
177//! # let mut cx = &mut Context::default();
178//! let now = Utc::now();
179//! Label::new(cx, Localized::new("event-date").arg("date", now));
180//! ```
181//!
182//! Both timezone-aware and naive datetimes are supported:
183//! - Timezone-aware datetimes like `DateTime<Utc>` or `DateTime<Local>` work directly
184//! - Naive datetimes are automatically assumed to be in UTC
185//!
186//! For custom formatting, you can use pre-formatted date strings:
187//! ```ignore
188//! # use vizia_core::prelude::*;
189//! # let mut cx = &mut Context::default();
190//! let formatted_date = "April 13, 2026".to_string();
191//! Label::new(cx, Localized::new("event-date").arg("date", formatted_date));
192//! ```
193use crate::context::LocalizationContext;
194use crate::prelude::*;
195use crate::resource::LocalizationIssue;
196use chrono::{DateTime, NaiveDateTime, Utc};
197use fluent_bundle::FluentArgs;
198use fluent_bundle::FluentValue;
199use fluent_bundle::types::{FluentNumber, FluentNumberOptions};
200use hashbrown::HashMap;
201use std::marker::PhantomData;
202use std::rc::Rc;
203use std::sync::Arc;
204
205/// Helper function for formatting a number with decimal places for localized display.
206///
207/// # Example
208/// ```ignore
209/// # use vizia_core::prelude::*;
210/// # let mut cx = &mut Context::default();
211/// Label::new(cx, Localized::new("price").arg("amount", number_with_fraction(99.99, 2)));
212/// ```
213pub fn number_with_fraction(value: f64, fraction_digits: usize) -> FluentNumber {
214 FluentNumber::new(
215 value,
216 FluentNumberOptions {
217 minimum_fraction_digits: Some(fraction_digits),
218 maximum_fraction_digits: Some(fraction_digits),
219 ..Default::default()
220 },
221 )
222}
223
224impl Res<FluentNumber> for FluentNumber {
225 fn get_value(&self, _: &impl DataContext) -> Self {
226 self.clone()
227 }
228}
229
230/// Helper function for formatting a number as a percentage for localized display.
231///
232/// # Example
233/// ```ignore
234/// # use vizia_core::prelude::*;
235/// # let mut cx = &mut Context::default();
236/// Label::new(cx, Localized::new("completion").arg("percent", percentage(0.75, 1)));
237/// ```
238pub fn percentage(value: f64, fraction_digits: usize) -> FluentNumber {
239 FluentNumber::new(
240 value * 100.0,
241 FluentNumberOptions {
242 minimum_fraction_digits: Some(fraction_digits),
243 maximum_fraction_digits: Some(fraction_digits),
244 ..Default::default()
245 },
246 )
247}
248
249/// Wrapper for chrono DateTime that automatically converts to Fluent's expected millisecond format.
250///
251/// While this wrapper can be used, chrono DateTime types implement `Res` directly,
252/// so you can pass them to `arg()` without wrapping:
253///
254/// # Example
255/// ```ignore
256/// # use vizia_core::prelude::*;
257/// # use chrono::Utc;
258/// # let mut cx = &mut Context::default();
259/// let now = Utc::now();
260/// // Chrono datetimes work directly with arg()
261/// Label::new(cx, Localized::new("event-date").arg("date", now));
262/// ```
263#[derive(Clone)]
264pub struct FluentDateTime<Tz: chrono::TimeZone + Clone>(pub DateTime<Tz>);
265
266impl<Tz: chrono::TimeZone + Clone> From<FluentDateTime<Tz>> for FluentValue<'static> {
267 fn from(val: FluentDateTime<Tz>) -> Self {
268 let FluentDateTime(datetime) = val;
269 datetime.with_timezone(&Utc).timestamp_millis().into()
270 }
271}
272
273impl<Tz: chrono::TimeZone + Clone + 'static> Res<FluentDateTime<Tz>> for FluentDateTime<Tz> {
274 fn get_value(&self, _: &impl DataContext) -> Self {
275 self.clone()
276 }
277}
278
279impl<Tz: chrono::TimeZone + Clone + 'static> Res<FluentDateTime<Tz>> for DateTime<Tz> {
280 fn get_value(&self, _: &impl DataContext) -> FluentDateTime<Tz> {
281 FluentDateTime(self.clone())
282 }
283}
284
285/// Wrapper for chrono NaiveDateTime that automatically converts to Fluent's expected millisecond format.
286///
287/// While this wrapper can be used, chrono NaiveDateTime types implement `Res` directly,
288/// so you can pass them to `arg()` without wrapping. Note: assumes UTC timezone for the conversion.
289///
290/// # Example
291/// ```ignore
292/// # use vizia_core::prelude::*;
293/// # use chrono::Utc;
294/// # let mut cx = &mut Context::default();
295/// let now = Utc::now().naive_utc();
296/// // Naive datetimes work directly with arg() (assumes UTC)
297/// Label::new(cx, Localized::new("event-date").arg("date", now));
298/// ```
299#[derive(Clone)]
300pub struct FluentNaiveDateTime(pub NaiveDateTime);
301
302impl From<FluentNaiveDateTime> for FluentValue<'static> {
303 fn from(val: FluentNaiveDateTime) -> Self {
304 val.0.and_utc().timestamp_millis().into()
305 }
306}
307
308impl Res<FluentNaiveDateTime> for FluentNaiveDateTime {
309 fn get_value(&self, _: &impl DataContext) -> Self {
310 self.clone()
311 }
312}
313
314impl Res<FluentNaiveDateTime> for NaiveDateTime {
315 fn get_value(&self, _: &impl DataContext) -> FluentNaiveDateTime {
316 FluentNaiveDateTime(*self)
317 }
318}
319
320pub(crate) trait FluentStore {
321 fn get_val(&self, cx: &LocalizationContext) -> FluentValue<'static>;
322 fn make_clone(&self) -> Box<dyn FluentStore>;
323 fn bind(&self, cx: &mut Context, closure: Box<dyn Fn(&mut Context)>);
324}
325
326#[derive(Clone)]
327pub(crate) struct ResState<R, T> {
328 res: R,
329 _marker: PhantomData<T>,
330}
331
332impl<R, T> FluentStore for ResState<R, T>
333where
334 R: 'static + Clone + Res<T>,
335 T: 'static + Clone + Into<FluentValue<'static>>,
336{
337 fn get_val(&self, cx: &LocalizationContext) -> FluentValue<'static> {
338 self.res.get_value(cx).into()
339 }
340
341 fn make_clone(&self) -> Box<dyn FluentStore> {
342 Box::new(self.clone())
343 }
344
345 fn bind(&self, cx: &mut Context, closure: Box<dyn Fn(&mut Context)>) {
346 self.res.clone().set_or_bind(cx, move |cx, _| closure(cx));
347 }
348}
349
350/// A type which formats a localized message with any number of named arguments.
351pub struct Localized {
352 key: String,
353 attribute: Option<String>,
354 args: HashMap<String, Box<dyn FluentStore>>,
355 map: Rc<dyn Fn(&str) -> String + 'static>,
356}
357
358impl PartialEq for Localized {
359 fn eq(&self, other: &Self) -> bool {
360 self.key == other.key && self.attribute == other.attribute
361 }
362}
363
364impl Clone for Localized {
365 fn clone(&self) -> Self {
366 Self {
367 key: self.key.clone(),
368 attribute: self.attribute.clone(),
369 args: self.args.iter().map(|(k, v)| (k.clone(), v.make_clone())).collect(),
370 map: self.map.clone(),
371 }
372 }
373}
374
375impl Localized {
376 fn resolve_text(&self, cx: &LocalizationContext) -> String {
377 let requested_locale = cx.environment().locale.get();
378 let args = self.get_args(cx);
379 let mut saw_message = false;
380
381 for locale in cx.resource_manager.translation_locales(&requested_locale) {
382 let bundle = cx.resource_manager.current_translation(&locale);
383 let Some(message) = bundle.get_message(&self.key) else {
384 continue;
385 };
386 saw_message = true;
387
388 let value = if let Some(attr_name) = &self.attribute {
389 if let Some(attr) = message.get_attribute(attr_name) {
390 attr.value()
391 } else {
392 continue;
393 }
394 } else if let Some(value) = message.value() {
395 value
396 } else {
397 continue;
398 };
399
400 let mut err = vec![];
401 let res = bundle.format_pattern(value, Some(&args), &mut err);
402
403 if !err.is_empty() {
404 cx.resource_manager.report_localization_issue(LocalizationIssue::FormatError {
405 key: self.key.clone(),
406 locale: locale.to_string(),
407 details: format!("{:?}", err),
408 });
409 }
410
411 return (self.map)(&res);
412 }
413
414 if let Some(attr_name) = &self.attribute {
415 if saw_message {
416 cx.resource_manager.report_localization_issue(
417 LocalizationIssue::MissingAttribute {
418 key: self.key.clone(),
419 attribute: attr_name.clone(),
420 requested_locale: requested_locale.to_string(),
421 },
422 );
423 } else {
424 cx.resource_manager.report_localization_issue(LocalizationIssue::MissingMessage {
425 key: self.key.clone(),
426 requested_locale: requested_locale.to_string(),
427 });
428 }
429 (self.map)(&format!("{}.{}", &self.key, attr_name))
430 } else {
431 cx.resource_manager.report_localization_issue(LocalizationIssue::MissingMessage {
432 key: self.key.clone(),
433 requested_locale: requested_locale.to_string(),
434 });
435 (self.map)(&self.key)
436 }
437 }
438
439 fn get_args(&self, cx: &LocalizationContext) -> FluentArgs {
440 let mut res = FluentArgs::new();
441 for (name, arg) in &self.args {
442 res.set(name.to_owned(), arg.get_val(cx));
443 }
444 res
445 }
446
447 /// Creates a new Localized type with a given key.
448 ///
449 /// The given key is used to retrieve a translation from a fluent bundle resource.
450 ///
451 /// # Example
452 /// ```no_run
453 /// # use vizia_core::prelude::*;
454 ///
455 /// # use vizia_winit::application::Application;
456 /// Application::new(|cx|{
457 /// Label::new(cx, Localized::new("key"));
458 /// })
459 /// .run();
460 pub fn new(key: &str) -> Self {
461 Self {
462 key: key.to_owned(),
463 attribute: None,
464 args: HashMap::new(),
465 map: Rc::new(|s| s.to_string()),
466 }
467 }
468
469 /// Sets a mapping function to apply to the translated text.
470 pub fn map(mut self, mapping: impl Fn(&str) -> String + 'static) -> Self {
471 self.map = Rc::new(mapping);
472
473 self
474 }
475
476 /// Selects a message attribute to translate instead of the message value.
477 ///
478 /// Messages can contain multiple attributes that define alternative translations.
479 /// This method allows you to select a specific attribute by name.
480 ///
481 /// # Example
482 /// ```no_run
483 /// # use vizia_core::prelude::*;
484 /// # use vizia_winit::application::Application;
485 /// Application::new(|cx|{
486 /// // Resolves the "title" attribute of the "dialog" message
487 /// Label::new(cx, Localized::new("dialog").attribute("title"));
488 /// })
489 /// .run();
490 pub fn attribute(mut self, attr_name: &str) -> Self {
491 self.attribute = Some(attr_name.to_owned());
492 self
493 }
494 ///
495 /// Takes a key name and a resource for the argument value.
496 ///
497 /// This accepts both plain values and reactive resources.
498 ///
499 /// # Example
500 /// ```no_run
501 /// # use vizia_core::prelude::*;
502 ///
503 /// # use vizia_winit::application::Application;
504 /// #
505 /// # struct AppData {
506 /// # value: i32,
507 /// # }
508 /// # impl Model for AppData {}
509 /// Application::new(|cx|{
510 ///
511 /// AppData {
512 /// value: 5,
513 /// }.build(cx);
514 ///
515 /// Label::new(cx, Localized::new("key").arg("value", AppData::value));
516 /// })
517 /// .run();
518 pub fn arg<R, T>(mut self, key: &str, res: R) -> Self
519 where
520 R: 'static + Clone + Res<T>,
521 T: 'static + Clone + Into<FluentValue<'static>>,
522 {
523 self.args.insert(key.to_owned(), Box::new(ResState { res, _marker: PhantomData }));
524 self
525 }
526}
527
528impl Res<String> for Localized {
529 fn get_value(&self, cx: &impl DataContext) -> String {
530 let cx = cx.localization_context().expect("Failed to get context");
531 self.resolve_text(&cx)
532 }
533
534 fn set_or_bind<F>(self, cx: &mut Context, closure: F)
535 where
536 F: 'static + Fn(&mut Context, Localized),
537 {
538 let current = cx.current();
539 let self2 = self.clone();
540 let closure = Arc::new(closure);
541 cx.with_current(current, |cx| {
542 let stores = self2.args.values().map(|x| x.make_clone()).collect::<Vec<_>>();
543 bind_recursive(cx, &stores, move |cx| {
544 let locale = cx.environment().locale;
545 let self3 = self2.clone();
546 let closure = closure.clone();
547 locale.set_or_bind(cx, move |cx, _| {
548 closure(cx, self3.clone());
549 });
550 });
551 });
552 }
553}
554
555fn bind_recursive<F>(cx: &mut Context, stores: &[Box<dyn FluentStore>], closure: F)
556where
557 F: 'static + Clone + Fn(&mut Context),
558{
559 if let Some((store, rest)) = stores.split_last() {
560 let rest = rest.iter().map(|x| x.make_clone()).collect::<Vec<_>>();
561 store.bind(
562 cx,
563 Box::new(move |cx| {
564 bind_recursive(cx, &rest, closure.clone());
565 }),
566 );
567 } else {
568 closure(cx);
569 }
570}
571
572impl<T: ToString> ToStringLocalized for T {
573 fn to_string_local(&self, _cx: &impl DataContext) -> String {
574 self.to_string()
575 }
576}
577
578/// A trait for converting from [Localized] to a `String` via a translation using fluent.
579pub trait ToStringLocalized {
580 /// Method for converting the current type to a `String` via a translation using fluent.
581 fn to_string_local(&self, cx: &impl DataContext) -> String;
582}
583
584impl ToStringLocalized for Localized {
585 fn to_string_local(&self, cx: &impl DataContext) -> String {
586 let cx = cx.localization_context().expect("Failed to get context");
587 self.resolve_text(&cx)
588 }
589}
590
591#[cfg(test)]
592mod tests {
593 use super::*;
594
595 #[test]
596 fn missing_message_falls_back_to_key() {
597 let cx = Context::default();
598 cx.data::<Environment>().locale.set("en-US".parse().unwrap());
599
600 let text = Localized::new("missing-key").to_string_local(&cx);
601
602 assert_eq!(text, "missing-key");
603 }
604
605 #[test]
606 fn missing_attribute_falls_back_to_key_attribute() {
607 let mut cx = Context::default();
608 cx.data::<Environment>().locale.set("en-US".parse().unwrap());
609 cx.add_translation("en-US".parse().unwrap(), "dialog = File Dialog".to_string()).unwrap();
610
611 let text = Localized::new("dialog").attribute("title").to_string_local(&cx);
612
613 assert_eq!(text, "dialog.title");
614 }
615
616 #[test]
617 fn format_error_returns_partial_resolved_text() {
618 let mut cx = Context::default();
619 cx.data::<Environment>().locale.set("en-US".parse().unwrap());
620 cx.add_translation("en-US".parse().unwrap(), "welcome = Welcome, { $name }!".to_string())
621 .unwrap();
622
623 let text = Localized::new("welcome").to_string_local(&cx);
624 assert!(text.contains("Welcome"));
625 assert!(text.contains("$name"));
626 }
627
628 #[test]
629 fn falls_back_to_default_bundle_per_key() {
630 let mut cx = Context::default();
631 cx.data::<Environment>().locale.set("fr".parse().unwrap());
632
633 // Ensure the requested locale exists but does not contain the requested key.
634 cx.add_translation("fr".parse().unwrap(), "bonjour = Bonjour".to_string()).unwrap();
635
636 // Provide the requested key only in the default bundle.
637 cx.add_translation(
638 LanguageIdentifier::default(),
639 "greeting = Hello from default".to_string(),
640 )
641 .unwrap();
642
643 let text = Localized::new("greeting").to_string_local(&cx);
644
645 assert_eq!(text, "Hello from default");
646 }
647
648 #[test]
649 fn falls_back_to_default_bundle_for_attribute_when_message_exists_in_requested_locale() {
650 let mut cx = Context::default();
651 cx.data::<Environment>().locale.set("fr".parse().unwrap());
652
653 // Requested locale has the message but not the attribute.
654 cx.add_translation("fr".parse().unwrap(), "dialog = Dialogue".to_string()).unwrap();
655
656 // Default locale provides the missing attribute.
657 cx.add_translation(
658 LanguageIdentifier::default(),
659 "dialog = Dialog\n .title = Default Title".to_string(),
660 )
661 .unwrap();
662
663 let text = Localized::new("dialog").attribute("title").to_string_local(&cx);
664
665 assert_eq!(text, "Default Title");
666 }
667}