Skip to main content

floem/views/
localization.rs

1#![deny(missing_docs)]
2//! Localization privitives.
3use std::borrow::Cow;
4use std::rc::Rc;
5
6use crate::style::{CustomStylable, CustomStyle, Style, StylePropValue};
7use crate::views::Decorators;
8use crate::{AnyView, IntoView, View, ViewId, prop, prop_extractor, style_class};
9use floem_reactive::UpdaterEffect;
10use fluent_bundle::{FluentBundle, FluentResource};
11use ouroboros::self_referencing;
12use parley::Alignment;
13
14pub use fluent_bundle::FluentArgs;
15pub use fluent_bundle::types::FluentValue;
16pub use unic_langid::LanguageIdentifier;
17
18use super::Label;
19
20/// A map that stores localizations.
21#[derive(Clone)]
22pub struct LocaleMap(pub imbl::HashMap<LanguageIdentifier, Rc<FluentBundle<FluentResource>>>);
23
24impl std::ops::Deref for LocaleMap {
25    type Target = imbl::HashMap<LanguageIdentifier, Rc<FluentBundle<FluentResource>>>;
26
27    fn deref(&self) -> &Self::Target {
28        &self.0
29    }
30}
31
32impl std::ops::DerefMut for LocaleMap {
33    fn deref_mut(&mut self) -> &mut Self::Target {
34        &mut self.0
35    }
36}
37
38impl std::fmt::Debug for LocaleMap {
39    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40        f.debug_map()
41            .entries(self.0.keys().map(|lang_id| (lang_id, "<FluentBundle>")))
42            .finish()
43    }
44}
45
46impl PartialEq for LocaleMap {
47    fn eq(&self, other: &Self) -> bool {
48        if self.0.len() != other.0.len() {
49            return false;
50        }
51
52        self.0.keys().all(|key| {
53            other.0.contains_key(key)
54                && Rc::ptr_eq(self.0.get(key).unwrap(), other.0.get(key).unwrap())
55        })
56    }
57}
58
59impl StylePropValue for LocaleMap {
60    fn debug_view(&self) -> Option<AnyView> {
61        use crate::prelude::*;
62
63        let languages: Vec<String> = self.0.keys().map(|lang_id| lang_id.to_string()).collect();
64
65        let count = languages.len();
66
67        let view = Stack::new((
68            format!("Languages ({count})").style(|s| {
69                s.font_size(12.0)
70                    .font_weight(floem_renderer::text::FontWeight::SEMI_BOLD)
71            }),
72            Stack::vertical_from_iter(languages.into_iter().map(|lang| {
73                lang.style(|s| {
74                    s.font_size(11.0)
75                        .color(Color::WHITE.with_alpha(0.7))
76                        .width_full()
77                        .items_center()
78                        .justify_center()
79                        .text_align(Alignment::Center)
80                })
81            }))
82            .style(|s| s.gap(2.0).width_full()),
83        ))
84        .style(|s| {
85            s.flex_row()
86                .gap(8.0)
87                .items_center()
88                .padding(8.0)
89                .border(1.)
90                .border_color(palette::css::WHITE.with_alpha(0.3))
91                .border_radius(6.0)
92                .min_width(120.0)
93        });
94
95        Some(view.into_any())
96    }
97}
98
99impl StylePropValue for LanguageIdentifier {
100    fn debug_view(&self) -> Option<Box<dyn View>> {
101        Some(crate::views::Label::new(format!("{self:?}")).into_any())
102    }
103
104    fn interpolate(&self, _other: &Self, _value: f64) -> Option<Self> {
105        None
106    }
107}
108
109impl LocaleMap {
110    /// Accepts list of the language resources in a form of a list implementing [IntoIterator]
111    /// containing tuple of (<language_name>, <localization_file>) string slices.
112    /// ### Example
113    /// ```rust
114    /// # use floem::views::localization::LocaleMap;
115    /// let locales = LocaleMap::from_resources([
116    ///     ("en-US", include_str!("../../examples/localization/locales/en-US/app.ftl")),
117    ///     ("pl-PL", include_str!("../../examples/localization/locales/pl-PL/app.ftl"))
118    /// ]);
119    /// ```
120    pub fn from_resources<'a, I>(resources: I) -> Result<Self, Box<dyn std::error::Error>>
121    where
122        I: IntoIterator<Item = (&'a str, &'a str)>,
123    {
124        let mut map = imbl::HashMap::new();
125        for (lang_id, resource_str) in resources {
126            let lang_id = lang_id.parse::<LanguageIdentifier>()?;
127            let resource = FluentResource::try_new(resource_str.to_string())
128                .map_err(|(_, errs)| format!("Failed to parse Fluent resource: {:?}", errs))?;
129
130            let mut bundle = FluentBundle::new(vec![lang_id.clone()]);
131            bundle
132                .add_resource(resource)
133                .map_err(|errs| format!("Failed to add resource to bundle: {:?}", errs))?;
134
135            map.insert(lang_id, Rc::new(bundle));
136        }
137
138        Ok(Self(map))
139    }
140
141    /// Find a language bundle that matches the given locale.
142    /// First, it tries exact match, then falls back to matching only the language component.
143    fn find_bundle(
144        &self,
145        locale: &LanguageIdentifier,
146    ) -> Option<&Rc<FluentBundle<FluentResource>>> {
147        self.0.get(locale).or_else(|| {
148            self.0.iter().find_map(|(key, resource)| {
149                if key.language == locale.language {
150                    Some(resource)
151                } else {
152                    None
153                }
154            })
155        })
156    }
157}
158
159prop!(pub L10nLocale: Option<LanguageIdentifier> { inherited } = sys_locale::get_locale().and_then(|l| l.parse().ok()));
160prop!(pub L10nFallback: Option<String> {} = None);
161prop!(pub L10nBundle: LocaleMap { inherited } = LocaleMap(imbl::HashMap::new()));
162
163prop_extractor! {
164    LanguageExtractor {
165        locale: L10nLocale,
166        bundle: L10nBundle,
167    }
168}
169
170prop_extractor! {
171    FallBackExtractor {
172        fallback: L10nFallback,
173    }
174}
175
176style_class!(
177    /// A localization class.
178    pub L10nClass
179);
180
181/// An enum for the locatization state updates.
182pub enum L10nState {
183    /// Add an argument(a variable) for the locatization label:
184    /// ### Example:
185    /// `msg-key = Hello, { $user }. You have { $emailCount } messages.`
186    /// `$user` and `$emailCount` are the arguments, which can be filled with values.
187    Arg(usize, FluentValue<'static>),
188    /// Add a fallback label that will be displayed for the
189    /// localized label when translation will be unavailable.
190    Fallback(String),
191}
192
193#[self_referencing]
194struct L10nArgs {
195    arg_keys: Vec<String>,
196    arg_values: Vec<FluentValue<'static>>,
197    #[borrows(arg_keys)]
198    #[covariant]
199    args: FluentArgs<'this>,
200}
201
202impl L10nArgs {
203    fn empty() -> Self {
204        L10nArgsBuilder {
205            arg_keys: Vec::new(),
206            arg_values: Vec::new(),
207            args_builder: |_| FluentArgs::new(),
208        }
209        .build()
210    }
211
212    fn from_parts(arg_keys: Vec<String>, arg_values: Vec<FluentValue<'static>>) -> Self {
213        let values_for_args = arg_values.clone();
214        L10nArgsBuilder {
215            arg_keys,
216            arg_values,
217            args_builder: move |arg_keys| {
218                let mut args = FluentArgs::with_capacity(values_for_args.len());
219                for (key, value) in arg_keys.iter().zip(values_for_args.iter().cloned()) {
220                    args.set(Cow::Borrowed(key.as_str()), value);
221                }
222                args
223            },
224        }
225        .build()
226    }
227
228    fn len(&self) -> usize {
229        self.borrow_arg_keys().len()
230    }
231
232    fn insert(self, arg_key: String, arg_val: FluentValue<'static>) -> Self {
233        let mut heads = self.into_heads();
234        heads.arg_keys.push(arg_key);
235        heads.arg_values.push(arg_val);
236        Self::from_parts(heads.arg_keys, heads.arg_values)
237    }
238
239    fn set(&mut self, offset: usize, arg_val: FluentValue<'static>) {
240        self.with_arg_values_mut(|values| values[offset] = arg_val.clone());
241        self.with_mut(|fields| {
242            let key = fields.arg_keys[offset].as_str();
243            fields.args.set(Cow::Borrowed(key), arg_val);
244        });
245    }
246}
247
248/// A localization primitive that stores localized message data and state.
249pub struct L10n {
250    id: ViewId,
251    label_id: ViewId,
252    key: String,
253    args: L10nArgs,
254    locale: LanguageExtractor,
255    fallback: FallBackExtractor,
256    fallback_override: Option<String>,
257    has_format_value: bool,
258}
259
260impl L10n {
261    /// Constructs new localized label with the key.
262    pub fn new(key: impl Into<String>) -> Self {
263        let key: String = key.into();
264        let label = Label::new(key.clone());
265        let label_id = label.id();
266        let id = ViewId::new();
267        id.add_child(label.into_any());
268        Self {
269            id,
270            label_id,
271            key,
272            args: L10nArgs::empty(),
273            locale: Default::default(),
274            fallback: Default::default(),
275            fallback_override: None,
276            has_format_value: false,
277        }
278        .class(L10nClass)
279    }
280
281    /// Add an argument to the label. It can contain static label or signal, that will
282    /// reactively update the value.
283    /// ### Example
284    /// ```rust
285    /// # use floem::prelude::{RwSignal, SignalGet};
286    /// # use floem::views::localization::l10n;
287    ///
288    /// let username = RwSignal::new("John");
289    /// let localized_label = l10n("Hello").arg("user", move || username.get());
290    /// ```
291    /// ### Reactivity
292    /// This function will reactively update its value on the signal change.
293    pub fn arg<FV: Into<FluentValue<'static>>>(
294        mut self,
295        arg_key: impl Into<String>,
296        arg_val: impl Fn() -> FV + 'static,
297    ) -> Self {
298        let id = self.id;
299        let arg_key = arg_key.into();
300        let offset = self.args.len();
301
302        let initial_val = UpdaterEffect::new(
303            move || arg_val().into(),
304            move |arg_val: FluentValue<'static>| {
305                id.update_state(L10nState::Arg(offset, arg_val));
306            },
307        );
308        self.args = self.args.insert(arg_key, initial_val);
309        self
310    }
311
312    /// Adds fallback label, that going to be used in case the translation will fail or will
313    /// be missing.
314    /// ### Example
315    /// ```rust
316    /// # use floem::prelude::{RwSignal, SignalGet};
317    /// # use floem::views::localization::l10n;
318    /// let localized_with_fallback = l10n("greet").fallback(|| "Hello user!");
319    /// let username = RwSignal::new("John");
320    /// let localized_with_fallback_and_args = l10n("greet")
321    ///     .arg("user", move || username.get())
322    ///     .fallback(|| "Hello user!");
323    /// ```
324    /// ### Info
325    /// This fallback takes precendence over any fallback from `Style`.
326    /// ### Reactivity
327    /// This function will reactively update its value on the signal change.
328    pub fn fallback<S: Into<String>>(mut self, fallback: impl Fn() -> S + 'static) -> Self {
329        let id = self.id;
330        let initial_fallback = UpdaterEffect::new(
331            move || fallback().into(),
332            move |fallback| {
333                id.update_state(L10nState::Fallback(fallback));
334            },
335        );
336        self.fallback_override = Some(initial_fallback);
337        self
338    }
339
340    fn try_format_message(&self) -> Option<String> {
341        let bundle = self.locale.bundle();
342        let locale = self.locale.locale()?;
343        let resource = bundle.find_bundle(&locale)?;
344        let message = resource.get_message(&self.key)?;
345        let pattern = message.value()?;
346        let errors = &mut vec![];
347        self.args.with_args(|args| {
348            let value = resource.format_pattern(pattern, Some(args), errors);
349            if errors.is_empty() {
350                Some(value.to_string())
351            } else {
352                None
353            }
354        })
355    }
356
357    fn apply_fallback(&self) -> bool {
358        if let Some(fallback) = &self.fallback_override {
359            self.label_id.update_state(fallback.to_string());
360            true
361        } else if let Some(fallback) = self.fallback.fallback() {
362            self.label_id.update_state(fallback.to_string());
363            true
364        } else {
365            false
366        }
367    }
368}
369
370/// Construct localized label with the message key.
371///
372/// ### Example
373/// ```rust
374/// # use floem::prelude::*;
375/// # use floem::views::localization::*;
376/// // Simple label:
377/// let simple = l10n("greet");
378/// // With arg (variables):
379/// let user = RwSignal::new("John");
380/// let with_args = l10n("greet").arg("user", move || user.get());
381/// // With fallback label:
382/// let with_fallback = l10n("greet").fallback(|| "Hello user!");
383///
384/// // Full localization example:
385/// let locales = LocaleMap::from_resources([
386///     ("en-US", include_str!("../../examples/localization/locales/en-US/app.ftl")),
387///     ("pl-PL", include_str!("../../examples/localization/locales/pl-PL/app.ftl"))
388/// ]).unwrap();
389/// let active_language = RwSignal::new(None);
390/// let mut counter = RwSignal::new(0);
391///
392/// let language_selectors = h_stack((
393///  // Static string slice implement [IntoView].
394///  "System Default"
395///     .class(TabSelectorClass)
396///     .style(move |s| s.apply_if(active_language.get().is_none(), |s| s.set_selected(true)))
397///     .action(move || active_language.set(None)),
398///     "pl-PL"
399///         .class(TabSelectorClass)
400///         .style(move |s| {
401///             s.apply_opt(active_language.get(), |s, l| {
402///                 s.apply_if(l == "pl-PL", |s| s.set_selected(true))
403///             })
404///         })
405///         .action(move || active_language.set(Some("pl-PL"))),
406///     "en-US"
407///         .class(TabSelectorClass)
408///         .style(move |s| {
409///             s.apply_opt(active_language.get(), |s, l| {
410///                 s.apply_if(l == "en-US", |s| s.set_selected(true))
411///             })
412///         })
413///         .action(move || active_language.set(Some("en-US"))),
414/// ));
415///
416/// let value_controls = h_stack((
417///     // Construct localized label
418///     l10n("inc")
419///         // Add fllback string
420///         .fallback(|| "increment")
421///         // Make it a button
422///         .button()
423///         // Add action on click
424///         .action(move || counter += 1),
425///     l10n("val")
426///         // Fallback label will be dependent on a variable value
427///         .fallback(move || format!("{counter}"))
428///         // Add variable `counter` as an argument to `val` label
429///         .arg("counter", move || counter.get()),
430///     l10n("dec")
431///         .fallback(|| "decrement")
432///         .button()
433///         .action(move || counter -= 1),
434/// ));
435/// let view = (language_selectors, value_controls)
436///         .v_stack()
437///         .style(move |s| s
438///             .size_full()
439///             .items_center()
440///             .justify_center()
441///             // Set up locales for the app providing it in the root view styles
442///             .custom(|ls: L10nCustomStyle| {
443///                 // Add language bundle(s)
444///                 ls.bundle(locales.clone())
445///                     // Apply language provided from the reactive signal and fallback to
446///                     // default on `None`
447///                     .apply_opt(active_language.get(), |ls, locale| {
448///                         ls.locale(locale.parse::<LanguageIdentifier>().unwrap())
449///                     })
450///             })
451///         );
452/// ```
453/// ### Info
454/// Label is not reactive, to make it behave on signal change, use arguments
455/// as described in fluent [specification](https://projectfluent.org/fluent/guide/variables.html).
456pub fn l10n(label_key: impl Into<String>) -> L10n {
457    L10n::new(label_key)
458}
459
460impl View for L10n {
461    fn id(&self) -> ViewId {
462        self.id
463    }
464
465    fn style_pass(&mut self, cx: &mut crate::context::StyleCx<'_>) {
466        if self.locale.read(cx) {
467            self.has_format_value = false;
468        }
469        if !self.has_format_value
470            && let Some(formatted) = self.try_format_message()
471        {
472            self.label_id.update_state(formatted);
473            self.has_format_value = true;
474        }
475        self.fallback.read(cx);
476        if !self.has_format_value && !self.apply_fallback() {
477            self.label_id.update_state(self.key.clone());
478        }
479    }
480
481    fn update(&mut self, _cx: &mut crate::context::UpdateCx, state: Box<dyn std::any::Any>) {
482        if let Ok(inner) = state.downcast::<L10nState>() {
483            match *inner {
484                L10nState::Arg(stack_offset, fluent_value) => {
485                    self.has_format_value = false;
486                    self.args.set(stack_offset, fluent_value);
487
488                    if let Some(formatted) = self.try_format_message() {
489                        self.label_id.update_state(formatted);
490                        self.has_format_value = true;
491                    }
492
493                    if !self.has_format_value && !self.apply_fallback() {
494                        self.label_id.update_state(self.key.clone());
495                    }
496                }
497                L10nState::Fallback(fallback_override) => {
498                    self.fallback_override = Some(fallback_override);
499                    if !self.has_format_value {
500                        self.label_id
501                            .update_state(self.fallback_override.clone().unwrap());
502                    }
503                }
504            }
505        }
506    }
507}
508
509/// Represents a custom style for the [L10n].
510#[derive(Debug, Clone)]
511pub struct L10nCustomStyle(Style);
512
513impl From<L10nCustomStyle> for Style {
514    fn from(value: L10nCustomStyle) -> Self {
515        value.0
516    }
517}
518
519impl From<Style> for L10nCustomStyle {
520    fn from(value: Style) -> Self {
521        Self(value)
522    }
523}
524
525impl CustomStyle for L10nCustomStyle {
526    type StyleClass = L10nClass;
527}
528
529impl CustomStylable<L10nCustomStyle> for L10n {
530    type DV = Self;
531}
532
533impl L10nCustomStyle {
534    /// Construct new custom [Style] for the [L10n].
535    pub fn new() -> Self {
536        Self(Style::new())
537    }
538
539    /// Override default locale with provided one.
540    pub fn locale(mut self, locale_key: impl Into<LanguageIdentifier>) -> Self {
541        let locale = locale_key.into();
542        self = Self(self.0.set(L10nLocale, Some(locale)));
543        self
544    }
545
546    /// Provide fallback label, that going to be used in case
547    /// the translation will fail.
548    pub fn fallback(mut self, fallback: impl Into<String>) -> Self {
549        let string = fallback.into();
550        self = Self(self.0.set(L10nFallback, Some(string)));
551        self
552    }
553
554    /// Apply styles optionally.
555    pub fn apply_opt<T>(self, opt: Option<T>, f: impl FnOnce(Self, T) -> Self) -> Self {
556        if let Some(t) = opt { f(self, t) } else { self }
557    }
558
559    /// Provide localization bundle(s).
560    pub fn bundle(mut self, bundle: impl Into<LocaleMap>) -> Self {
561        self = Self(self.0.set(L10nBundle, bundle));
562        self
563    }
564}
565
566impl Default for L10nCustomStyle {
567    fn default() -> Self {
568        Self::new()
569    }
570}