Skip to main content

waterui_locale/
lib.rs

1//! # `WaterUI` i18n
2//!
3//! Internationalization (i18n) support for `WaterUI` applications.
4//!
5//! ## What it does
6//!
7//! Always available:
8//!
9//! - **Type-safe locales**: Using ICU4X for locale identifiers
10//! - **CLDR plural rules**: Proper pluralization for all languages
11//! - **Smart fallback**: zh-TW → zh-Hant → zh (not zh-Hans!)
12//! - **`LocalizedDisplay`**: Trait for locale-aware formatting
13//!
14//! Behind a cargo feature, because each carries its own slice of CLDR data:
15//!
16//! - `number`: `format_number`, `format_percent`, `format_currency`, the unit
17//!   wrappers (`Length<Meter>`, `Temperature<Celsius>`, …) that render through
18//!   them, and the numeric interpolation `text!` performs
19//! - `datetime`: `format::date`, the locale-aware date and time formatters
20//! - `list`: `LocalizedList`, locale-aware list conjunction
21//!
22//! ## Usage
23//!
24//! Application text goes through `waterui`'s `text!` macro, which resolves
25//! translations and CLDR plural categories against the effective locale:
26//!
27//! ```rust
28//! use waterui::prelude::*;
29//!
30//! # fn apples(count: i32) -> impl View {
31//! // With en: "I have 5 apples"
32//! // With zh: "我有5个苹果"
33//! text!("I have {#count} apple")
34//! # }
35//! ```
36//!
37//! This crate is the runtime underneath it: locale identification and
38//! fallback, plural-category selection, and locale-aware formatting.
39//!
40//! ```rust
41//! use waterui_locale::{PluralCategory, locales, select_plural};
42//!
43//! assert_eq!(select_plural(&locales::EN, &1), PluralCategory::One);
44//! assert_eq!(select_plural(&locales::EN, &5), PluralCategory::Other);
45//! ```
46
47#![forbid(unsafe_code)]
48
49pub mod catalog;
50pub mod format;
51pub mod locale;
52pub mod parser;
53pub mod plural;
54pub mod regional;
55mod system;
56
57// Re-exports
58pub use catalog::TranslationCatalog;
59#[cfg(feature = "number")]
60pub use format::LocalizedArgument;
61pub use format::LocalizedDisplay;
62#[cfg(feature = "list")]
63pub use format::LocalizedList;
64#[cfg(feature = "number")]
65pub use format::number::{Currency, format_currency, format_number, format_percent};
66#[cfg(feature = "number")]
67pub use format::unit::{Feet, Kilometer, Length, LengthUnit, Meter, Mile};
68pub use locale::{Locale, locales};
69pub use plural::{PluralCategory, select_plural, valid_categories};
70
71/// Returns the Unicode layout direction for a locale.
72#[must_use]
73pub fn layout_direction(locale: &Locale) -> waterui_core::layout::LayoutDirection {
74    if icu_locale::LocaleDirectionality::new_extended().is_right_to_left(locale.id()) {
75        waterui_core::layout::LayoutDirection::RightToLeft
76    } else {
77        waterui_core::layout::LayoutDirection::LeftToRight
78    }
79}
80
81/// Returns a reactive layout direction derived from the effective locale.
82#[must_use]
83pub fn layout_direction_computed(
84    env: &waterui_core::Environment,
85) -> nami::Computed<waterui_core::layout::LayoutDirection> {
86    use nami::SignalExt;
87
88    locale_binding(env)
89        .map(|locale| layout_direction(&locale))
90        .computed()
91}
92
93/// Starts delivering system locale changes to locale-aware views.
94///
95/// A backend calls this once, after installing the `LocalExecutor` of the loop
96/// it owns. Reading the locale works without it; only *changing* with the
97/// system needs a running loop to deliver the change.
98pub fn start_system_locale_listener() {
99    system::start_system_listener();
100}
101
102#[doc(hidden)]
103/// Clears the current thread's cached runtime locale binding before an executor shuts down.
104pub fn shutdown_current_thread_runtime_locale_state() {
105    system::shutdown_current_thread_runtime_locale_state();
106}
107
108/// Returns a reactive binding for the effective locale in the given environment.
109#[must_use]
110pub fn locale_binding(env: &waterui_core::Environment) -> nami::Binding<Locale> {
111    if let Some(locale) = env.get::<nami::Binding<Locale>>() {
112        return locale.clone();
113    }
114
115    if let Some(context) = env.get::<regional::RegionalContext>().cloned() {
116        return nami::Binding::custom(nami::Container::new(context.locale().clone()));
117    }
118
119    if let Some(locale) = env.get::<Locale>().cloned() {
120        return nami::Binding::custom(nami::Container::new(locale));
121    }
122
123    system::runtime_locale_binding()
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129    use format::date::{DateStyle, SimpleDate, format_date};
130    use format::unit::{Gram, Kilogram, Mass};
131    use waterui_core::Environment;
132
133    // =========================================================
134    // Locale Binding Tests
135    // =========================================================
136
137    #[test]
138    fn locale_binding_uses_static_locale_from_environment() {
139        let mut env = Environment::new();
140        env.insert(locales::EN_GB);
141
142        let locale = locale_binding(&env).get();
143        assert_eq!(locale.language.as_str(), "en");
144        assert_eq!(
145            locale
146                .region
147                .as_ref()
148                .map(icu_locale::subtags::Region::as_str),
149            Some("GB")
150        );
151    }
152
153    #[test]
154    fn locale_binding_prefers_an_explicit_binding_over_a_static_locale() {
155        let binding = nami::Binding::container(locales::ZH_CN);
156        let mut env = Environment::new();
157        env.insert(locales::EN_GB);
158        env.insert(binding.clone());
159
160        let resolved = locale_binding(&env);
161        assert_eq!(resolved.get().language.as_str(), "zh");
162
163        // The returned binding is the very same one, so later writes are visible.
164        binding.set(locales::EN_US);
165        assert_eq!(resolved.get().language.as_str(), "en");
166    }
167
168    #[test]
169    fn locale_direction_uses_unicode_script_data() {
170        assert_eq!(
171            layout_direction(&locales::EN),
172            waterui_core::layout::LayoutDirection::LeftToRight
173        );
174        assert_eq!(
175            layout_direction(&locales::AR),
176            waterui_core::layout::LayoutDirection::RightToLeft
177        );
178        let hebrew = "he".parse().expect("Hebrew locale must parse");
179        assert_eq!(
180            layout_direction(&hebrew),
181            waterui_core::layout::LayoutDirection::RightToLeft
182        );
183    }
184
185    // =========================================================
186    // Plural Rules Tests
187    // =========================================================
188
189    #[test]
190    fn test_plural_selection_english() {
191        crate::system::reset_runtime_locale_state_for_tests();
192        // English: 1 → One, 0/2+ → Other
193        assert_eq!(select_plural(&locales::EN, &1), PluralCategory::One);
194        assert_eq!(select_plural(&locales::EN, &0), PluralCategory::Other);
195        assert_eq!(select_plural(&locales::EN, &2), PluralCategory::Other);
196        assert_eq!(select_plural(&locales::EN, &100), PluralCategory::Other);
197    }
198
199    #[test]
200    fn test_plural_selection_english_fractional() {
201        // English fractional values should be "other"
202        assert_eq!(select_plural(&locales::EN, &1.2), PluralCategory::Other);
203        assert_eq!(select_plural(&locales::EN, &0.5), PluralCategory::Other);
204    }
205
206    #[test]
207    fn test_plural_selection_chinese() {
208        // Chinese has no plural distinction - all → Other
209        assert_eq!(select_plural(&locales::ZH_CN, &0), PluralCategory::Other);
210        assert_eq!(select_plural(&locales::ZH_CN, &1), PluralCategory::Other);
211        assert_eq!(select_plural(&locales::ZH_CN, &100), PluralCategory::Other);
212    }
213
214    #[test]
215    fn test_plural_selection_japanese() {
216        // Japanese has no plural distinction - all → Other
217        assert_eq!(select_plural(&locales::JA, &0), PluralCategory::Other);
218        assert_eq!(select_plural(&locales::JA, &1), PluralCategory::Other);
219        assert_eq!(select_plural(&locales::JA, &100), PluralCategory::Other);
220    }
221
222    #[test]
223    fn test_plural_selection_korean() {
224        // Korean has no plural distinction - all → Other
225        assert_eq!(select_plural(&locales::KO, &0), PluralCategory::Other);
226        assert_eq!(select_plural(&locales::KO, &1), PluralCategory::Other);
227        assert_eq!(select_plural(&locales::KO, &100), PluralCategory::Other);
228    }
229
230    #[test]
231    fn test_plural_selection_german() {
232        // German: 1 → One, others → Other
233        assert_eq!(select_plural(&locales::DE, &1), PluralCategory::One);
234        assert_eq!(select_plural(&locales::DE, &0), PluralCategory::Other);
235        assert_eq!(select_plural(&locales::DE, &2), PluralCategory::Other);
236    }
237
238    #[test]
239    fn test_plural_selection_french() {
240        // French: 0,1 → One, 2+ → Other (differs from English!)
241        assert_eq!(select_plural(&locales::FR, &0), PluralCategory::One);
242        assert_eq!(select_plural(&locales::FR, &1), PluralCategory::One);
243        assert_eq!(select_plural(&locales::FR, &2), PluralCategory::Other);
244    }
245
246    #[test]
247    fn test_plural_selection_spanish() {
248        // Spanish: 1 → One, others → Other
249        assert_eq!(select_plural(&locales::ES, &1), PluralCategory::One);
250        assert_eq!(select_plural(&locales::ES, &0), PluralCategory::Other);
251        assert_eq!(select_plural(&locales::ES, &2), PluralCategory::Other);
252    }
253
254    #[test]
255    fn test_plural_selection_russian() {
256        // Russian: complex rules with One, Few, Many
257        assert_eq!(select_plural(&locales::RU, &1), PluralCategory::One);
258        assert_eq!(select_plural(&locales::RU, &2), PluralCategory::Few);
259        assert_eq!(select_plural(&locales::RU, &5), PluralCategory::Many);
260        assert_eq!(select_plural(&locales::RU, &21), PluralCategory::One);
261        assert_eq!(select_plural(&locales::RU, &22), PluralCategory::Few);
262    }
263
264    #[test]
265    fn test_plural_selection_negative_uses_absolute_value() {
266        // CLDR plural rules use absolute value.
267        assert_eq!(select_plural(&locales::EN, &-1), PluralCategory::One);
268        assert_eq!(select_plural(&locales::EN, &-2), PluralCategory::Other);
269    }
270
271    // =========================================================
272    // Length Unit Tests
273    // =========================================================
274
275    #[test]
276    fn test_length_conversion() {
277        let km = Length::<Kilometer>::new(1.0);
278        let m = km.to::<Meter>();
279        assert!((m.value() - 1000.0).abs() < 0.001);
280
281        let mile = Length::<Mile>::new(1.0);
282        let m2 = mile.to::<Meter>();
283        assert!((m2.value() - 1609.344).abs() < 0.001);
284    }
285
286    #[test]
287    fn test_length_addition() {
288        let km = Length::<Kilometer>::new(1.0);
289        let m = Length::<Meter>::new(500.0);
290        let total = km + m;
291        assert!((total.value() - 1.5).abs() < 0.001);
292    }
293
294    #[test]
295    fn test_localized_display_length() {
296        let distance = Length::<Meter>::new(100.0);
297
298        // English
299        let en_str = distance.to_localized_string(&locales::EN);
300        assert!(en_str.contains("100"));
301        assert!(en_str.contains('m'));
302
303        // Chinese
304        let zh_str = distance.to_localized_string(&locales::ZH_CN);
305        assert!(zh_str.contains("100"));
306        assert!(zh_str.contains("米"));
307
308        // Japanese
309        let ja_str = distance.to_localized_string(&locales::JA);
310        assert!(ja_str.contains("100"));
311        assert!(ja_str.contains("メートル"));
312
313        // Korean
314        let ko_str = distance.to_localized_string(&locales::KO);
315        assert!(ko_str.contains("100"));
316        assert!(ko_str.contains("미터"));
317    }
318
319    // =========================================================
320    // Mass Unit Tests
321    // =========================================================
322
323    #[test]
324    fn test_mass_localized_display() {
325        let mass = Mass::<Kilogram>::new(5.0);
326
327        // Chinese
328        let zh_str = mass.to_localized_string(&locales::ZH_CN);
329        assert!(zh_str.contains('5'));
330        assert!(zh_str.contains("公斤"));
331
332        // Japanese
333        let ja_str = mass.to_localized_string(&locales::JA);
334        assert!(ja_str.contains('5'));
335        assert!(ja_str.contains("キログラム"));
336
337        // Korean
338        let ko_str = mass.to_localized_string(&locales::KO);
339        assert!(ko_str.contains('5'));
340        assert!(ko_str.contains("킬로그램"));
341
342        // European languages use "kg"
343        let de_str = mass.to_localized_string(&locales::DE);
344        assert!(de_str.contains('5'));
345        assert!(de_str.contains("kg"));
346    }
347
348    #[test]
349    fn test_mass_conversion() {
350        let kg = Mass::<Kilogram>::new(1.0);
351        let g = kg.to::<Gram>();
352        assert!((g.value() - 1000.0).abs() < 0.001);
353    }
354
355    // =========================================================
356    // Date Formatting Tests (using Go's canonical date: 2006.01.02)
357    // =========================================================
358
359    #[test]
360    fn test_date_formatting_english() {
361        let date = SimpleDate::new(2006, 3, 20);
362        let short = format_date(&locales::EN, &date, DateStyle::Short);
363        let long = format_date(&locales::EN, &date, DateStyle::Long);
364        assert!(short.contains('/'));
365        assert!(short.contains("20"));
366        assert!(long.contains("2006"));
367        assert!(long.to_lowercase().contains("mar"));
368    }
369
370    #[test]
371    fn test_date_formatting_german() {
372        let date = SimpleDate::new(2006, 3, 20);
373        let short = format_date(&locales::DE, &date, DateStyle::Short);
374        let long = format_date(&locales::DE, &date, DateStyle::Long);
375        assert!(short.contains('.'));
376        assert!(short.contains("20"));
377        assert!(long.contains("2006"));
378        assert!(long.contains("Mär") || long.to_lowercase().contains("mar"));
379    }
380
381    #[test]
382    fn test_date_formatting_french() {
383        let date = SimpleDate::new(2006, 3, 20);
384        let short = format_date(&locales::FR, &date, DateStyle::Short);
385        let long = format_date(&locales::FR, &date, DateStyle::Long);
386        assert!(short.contains("2006"));
387        assert!(short.contains('/'));
388        assert!(long.contains("2006"));
389        assert!(long.to_lowercase().contains("mar"));
390    }
391
392    #[test]
393    fn test_date_formatting_spanish() {
394        let date = SimpleDate::new(2006, 3, 20);
395        let short = format_date(&locales::ES, &date, DateStyle::Short);
396        let long = format_date(&locales::ES, &date, DateStyle::Long);
397        assert!(short.contains('/'));
398        assert!(short.contains("20"));
399        assert!(long.contains("2006"));
400        assert!(long.to_lowercase().contains("mar"));
401    }
402
403    #[test]
404    fn test_date_formatting_japanese() {
405        let date = SimpleDate::new(2006, 3, 20);
406        let short = format_date(&locales::JA, &date, DateStyle::Short);
407        let long = format_date(&locales::JA, &date, DateStyle::Long);
408        assert!(short.contains("2006"));
409        assert!(long.contains('年'));
410        assert!(long.contains('月'));
411        assert!(long.contains('日'));
412    }
413
414    #[test]
415    fn test_date_formatting_korean() {
416        let date = SimpleDate::new(2006, 3, 20);
417        let short = format_date(&locales::KO, &date, DateStyle::Short);
418        let long = format_date(&locales::KO, &date, DateStyle::Long);
419        assert!(short.contains("20"));
420        assert!(long.contains('년'));
421        assert!(long.contains('월'));
422        assert!(long.contains('일'));
423    }
424
425    #[test]
426    fn test_date_formatting_chinese() {
427        let date = SimpleDate::new(2006, 3, 20);
428        let short = format_date(&locales::ZH_CN, &date, DateStyle::Short);
429        let long = format_date(&locales::ZH_CN, &date, DateStyle::Long);
430        assert!(short.contains("2006"));
431        assert!(long.contains('年'));
432        assert!(long.contains('月'));
433        assert!(long.contains('日'));
434    }
435
436    // =========================================================
437    // Translation File Parsing Tests
438    // =========================================================
439
440    #[test]
441    fn test_translation_file_parsing() {
442        use parser::{TranslationFile, TranslationValue};
443
444        let toml_content = r#"
445"Hello, World!" = "Hello, World!"
446"Goodbye" = "Farewell"
447"I have {#count} apple" = { one = "I have {count} apple", other = "I have {count} apples" }
448"#;
449
450        let file = TranslationFile::parse(toml_content).unwrap();
451        assert_eq!(file.len(), 3);
452
453        // Check simple translation
454        match file.get("Goodbye") {
455            Some(TranslationValue::Simple(s)) => assert_eq!(s, "Farewell"),
456            _ => panic!("Expected simple translation"),
457        }
458
459        // Check plural translation
460        match file.get("I have {#count} apple") {
461            Some(TranslationValue::Plural(forms)) => {
462                assert_eq!(forms.get(PluralCategory::One), "I have {count} apple");
463                assert_eq!(forms.get(PluralCategory::Other), "I have {count} apples");
464            }
465            _ => panic!("Expected plural translation"),
466        }
467    }
468
469    #[test]
470    fn test_valid_categories() {
471        let en_cats = plural::valid_categories(&locales::EN);
472        assert!(en_cats.contains(&PluralCategory::One));
473        assert!(en_cats.contains(&PluralCategory::Other));
474
475        let zh_cats = plural::valid_categories(&locales::ZH_CN);
476        // Chinese only has "other"
477        assert!(zh_cats.contains(&PluralCategory::Other));
478        assert!(!zh_cats.contains(&PluralCategory::One));
479    }
480}