Skip to main content

cranpose_ui/
localization.rs

1//! Reactive localization that composes with existing text and string-taking APIs.
2//!
3//! ```
4//! cranpose_ui::run_test_composition(|| {
5//!     let text = cranpose_ui::tr!("Hello, {name}!", name = "Ana");
6//!     assert!(text.as_str().contains("Ana"));
7//! });
8//! ```
9//!
10//! Missing arguments are compile errors:
11//!
12//! ```compile_fail
13//! let _ = cranpose_ui::tr!("Hello, {name}!");
14//! ```
15//!
16//! Extra arguments are compile errors:
17//!
18//! ```compile_fail
19//! let _ = cranpose_ui::tr!("Save", unused = 42);
20//! ```
21
22use std::cell::RefCell;
23
24use cranpose_core::{
25    CompositionLocal, CompositionLocalProvider, compositionLocalOf, remember, rememberKeyed,
26};
27pub use cranpose_localization::{
28    Argument, Catalog, DeferredMessage, FluentValue, Language, LanguagePreference,
29    LanguagePreferenceStore, Locale, Localization, LocalizationError, Message, PreviewMode,
30    Resource, SourceCatalog, Translator,
31};
32
33/// A formatted translation with shared text storage and owned-string conversions.
34pub type LocalizedText = crate::text::SharedText;
35
36#[cfg(feature = "localization-formatting")]
37pub use cranpose_localization::{FormatError, LocaleFormatters};
38
39/// Translations for Cranpose controls, also usable by native services and test tools.
40/// UI providers add this fallback automatically; applications can override its namespaces.
41pub use crate::ui_strings::catalog as framework_catalog;
42
43/// Locale-aware data formatters retained for the current language provider.
44/// A language change replaces these formatters without changing stored application data.
45#[cfg(feature = "localization-formatting")]
46#[track_caller]
47pub fn local_formatters() -> Result<std::rc::Rc<LocaleFormatters>, FormatError> {
48    if let Some(translator) = local_translator().current() {
49        return translator.formatters();
50    }
51    rememberKeyed((), |()| {
52        LocaleFormatters::new(&Locale::parse("en").expect("source locale")).map(std::rc::Rc::new)
53    })
54}
55
56/// Displays a message captured by `message!` using the current provider.
57/// A language change updates the text even when the request remains in application state.
58#[track_caller]
59pub fn localized_message(message: &DeferredMessage) -> LocalizedText {
60    let translator = local_translator().current();
61    rememberKeyed((message.clone(), translator), |(message, translator)| {
62        LocalizedText::from(message.format(translator.as_ref()).unwrap_or_else(|error| {
63            log::error!("{error}");
64            message.fallback_text()
65        }))
66    })
67}
68
69/// The translator installed by the nearest [`ProvideLocalization`].
70/// `None` uses each message's inline source-language catalog.
71pub fn local_translator() -> CompositionLocal<Option<Translator>> {
72    crate::environment_locals::ENVIRONMENT_LOCALS.with(|locals| {
73        locals
74            .translator
75            .get_or_init(|| compositionLocalOf(|| None))
76            .clone()
77    })
78}
79
80/// Installs a catalog and language for this subtree, including its reading direction.
81/// Change the supplied locale state to update text and accessibility descriptions.
82/// Nested layout-direction providers can override the direction for individual controls.
83#[expect(non_snake_case)]
84#[track_caller]
85pub fn ProvideLocalization(catalog: &Catalog, locale: Locale, content: impl FnOnce()) {
86    let translator = rememberKeyed((catalog.clone(), locale), |(catalog, locale)| {
87        catalog
88            .clone()
89            .with_fallback(&crate::ui_strings::catalog())
90            .translator(locale.clone())
91    });
92    provide(translator, content);
93}
94
95/// Installs a prepared translator, including one negotiated from several preferred languages.
96#[expect(non_snake_case)]
97#[track_caller]
98pub fn ProvideTranslator(translator: Translator, content: impl FnOnce()) {
99    let translator = rememberKeyed(translator, |translator| {
100        translator.with_fallback(&crate::ui_strings::catalog())
101    });
102    provide(translator, content);
103}
104
105#[track_caller]
106fn provide(translator: Translator, content: impl FnOnce()) {
107    let direction = if translator.locale().is_rtl() {
108        crate::LayoutDirection::Rtl
109    } else {
110        crate::LayoutDirection::Ltr
111    };
112    let locales = rememberKeyed(translator.locale().clone(), |locale| {
113        crate::text::LocaleList::new(vec![locale.to_string()])
114    });
115    CompositionLocalProvider(
116        [
117            local_translator().provides(Some(translator)),
118            crate::local_layout_direction().provides(direction),
119            text_locales().provides(Some(locales)),
120        ],
121        content,
122    );
123}
124
125fn text_locales() -> CompositionLocal<Option<crate::text::LocaleList>> {
126    crate::environment_locals::ENVIRONMENT_LOCALS.with(|locals| {
127        locals
128            .text_locales
129            .get_or_init(|| compositionLocalOf(|| None))
130            .clone()
131    })
132}
133
134pub(crate) fn apply_text_locale(mut style: crate::text::TextStyle) -> crate::text::TextStyle {
135    if style.span_style.locale_list.is_none() {
136        style.span_style.locale_list = text_locales().current();
137    }
138    style
139}
140
141struct CachedTranslation {
142    translator: Option<Translator>,
143    message: &'static Message,
144    arguments: Vec<Argument<'static>>,
145    text: LocalizedText,
146}
147
148/// Resolves a static message at this composition position. Prefer [`crate::tr!`].
149/// Unchanged arguments, catalog, and language reuse the previous formatted text.
150#[track_caller]
151pub fn localized<const N: usize>(
152    message: &'static Message,
153    mut arguments: [Argument<'_>; N],
154) -> LocalizedText {
155    localized_impl(message, &mut arguments)
156}
157
158#[track_caller]
159fn localized_impl(message: &'static Message, arguments: &mut [Argument<'_>]) -> LocalizedText {
160    let translator = local_translator().current();
161    let slot = remember(|| RefCell::new(None::<CachedTranslation>));
162    slot.with(|slot| {
163        let mut cached = slot.borrow_mut();
164        if let Some(value) = &*cached
165            && value.translator == translator
166            && std::ptr::eq(value.message, message)
167            && value.arguments.as_slice() == &*arguments
168        {
169            return value.text.clone();
170        }
171        let formatted = match &translator {
172            Some(translator) => translator.format(message, arguments),
173            None => message.format_source(arguments),
174        };
175        let text = LocalizedText::from(formatted.unwrap_or_else(|error| {
176            log::error!("{error}");
177            message.fallback_text(arguments)
178        }));
179        if let Some(value) = &mut *cached {
180            value.translator = translator;
181            value.message = message;
182            value.text = text.clone();
183            for (index, argument) in arguments.iter_mut().enumerate() {
184                match value.arguments.get_mut(index) {
185                    Some(previous) => previous.update_from(argument),
186                    None => value.arguments.push(argument.take_owned()),
187                }
188            }
189            value.arguments.truncate(arguments.len());
190        } else {
191            *cached = Some(CachedTranslation {
192                translator,
193                message,
194                arguments: arguments.iter_mut().map(Argument::take_owned).collect(),
195                text: text.clone(),
196            });
197        }
198        text
199    })
200}