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
141/// [`apply_text_locale`] for a shared style: the same one when it names its
142/// locales, else the shared style with the composition's locales.
143pub(crate) fn apply_shared_text_locale(
144    style: std::sync::Arc<crate::text::TextStyle>,
145) -> std::sync::Arc<crate::text::TextStyle> {
146    if style.span_style.locale_list.is_some() {
147        return style;
148    }
149    crate::text_modifier_node::shared_text_style(apply_text_locale(crate::text::TextStyle::clone(
150        &style,
151    )))
152}
153
154struct CachedTranslation {
155    translator: Option<Translator>,
156    message: &'static Message,
157    arguments: Vec<Argument<'static>>,
158    text: LocalizedText,
159}
160
161/// Resolves a static message at this composition position. Prefer [`crate::tr!`].
162/// Unchanged arguments, catalog, and language reuse the previous formatted text.
163#[track_caller]
164pub fn localized<const N: usize>(
165    message: &'static Message,
166    mut arguments: [Argument<'_>; N],
167) -> LocalizedText {
168    localized_impl(message, &mut arguments)
169}
170
171#[track_caller]
172fn localized_impl(message: &'static Message, arguments: &mut [Argument<'_>]) -> LocalizedText {
173    let translator = local_translator().current();
174    let slot = remember(|| RefCell::new(None::<CachedTranslation>));
175    slot.with(|slot| {
176        let mut cached = slot.borrow_mut();
177        if let Some(value) = &*cached
178            && value.translator == translator
179            && std::ptr::eq(value.message, message)
180            && value.arguments.as_slice() == &*arguments
181        {
182            return value.text.clone();
183        }
184        let formatted = match &translator {
185            Some(translator) => translator.format(message, arguments),
186            None => message.format_source(arguments),
187        };
188        let text = LocalizedText::from(formatted.unwrap_or_else(|error| {
189            log::error!("{error}");
190            message.fallback_text(arguments)
191        }));
192        if let Some(value) = &mut *cached {
193            value.translator = translator;
194            value.message = message;
195            value.text = text.clone();
196            for (index, argument) in arguments.iter_mut().enumerate() {
197                match value.arguments.get_mut(index) {
198                    Some(previous) => previous.update_from(argument),
199                    None => value.arguments.push(argument.take_owned()),
200                }
201            }
202            value.arguments.truncate(arguments.len());
203        } else {
204            *cached = Some(CachedTranslation {
205                translator,
206                message,
207                arguments: arguments.iter_mut().map(Argument::take_owned).collect(),
208                text: text.clone(),
209            });
210        }
211        text
212    })
213}