Skip to main content

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}