Internationalization
In this chapter, you will:
- Write TOML translation files that
text!compiles into your binary- Handle CLDR plural forms, including languages with four of them
- Understand script-aware fallback (zh-TW resolves to zh-Hant, never zh-Hans)
- Format dates, numbers, units, and lists per locale
- Switch locale at runtime and have every string follow
Translating strings is the easy half. Plural rules differ (English has two forms, Russian has four), dates reorder their fields, units change symbol, and lists change their conjunction. WaterUI puts all of that behind ICU4X and wires the result into the reactive system, so a locale change updates text in place without rebuilding views.
Translation files
Put one TOML file per locale in an i18n/ directory next to your crate’s
Cargo.toml. The text! macro reads them at compile time from
CARGO_MANIFEST_DIR, so a library crate uses its own i18n/, not the
application’s.
my-app/
├── Cargo.toml
├── i18n/
│ ├── en.toml
│ ├── de.toml
│ ├── ru.toml
│ └── zh-Hant.toml
└── src/lib.rs
The key is the source string as it appears in code; the value is the translation:
# i18n/de.toml
"Welcome to the World Fair!" = "Willkommen auf der Weltausstellung!"
"Language Booth" = "Sprachkabine"
For a long paragraph, a $-prefixed key keeps the code readable:
# i18n/en.toml
"$udhr_article_1" = "All human beings are born free and equal in dignity and rights."
If a key is missing everywhere, text! falls back through the locale chain,
then to English, then to the key itself — so an untranslated string shows the
source text rather than a placeholder.
Plural forms
Mark the pluralizing variable with {#name} in the key, and use {name}
without the # in the values:
# i18n/en.toml
"I have {#count} passport stamp" = { one = "I have {count} passport stamp", other = "I have {count} passport stamps" }
# i18n/ru.toml
"I have {#count} passport stamp" = { one = "У меня {count} паспортный штамп", few = "У меня {count} паспортных штампа", many = "У меня {count} паспортных штампов", other = "У меня {count} паспортных штампов" }
# i18n/zh.toml
"I have {#count} passport stamp" = { other = "我有{count}个护照章" }
The form keys are the CLDR categories: zero, one, two, few, many, and
other. Only other is required, and a plural entry without it fails the
build. Chinese, Japanese, and Korean use other alone; English uses one and
other; Russian uses one, few, many, and other.
When one sentence pluralizes two quantities independently, use the four dual forms:
"I have {#apples} apple and {#oranges} orange" = { one_one = "…", one_other = "…", other_one = "…", other_other = "…" }
The text! macro
text! expands to a Text view that resolves its content against the
environment’s effective locale and re-resolves when that locale changes.
Placeholders capture identifiers from the surrounding scope:
use waterui::prelude::*;
fn greeting(name: &str) -> impl View {
text!("Hello, {name}!").headline()
}
Pass a value explicitly with name = expr when the local variable has a
different name — or when there is no local variable at all:
use waterui::prelude::*;
vstack((
text!("I have {#count} passport stamp", count = 0),
text!("I have {#count} passport stamp", count = 1),
text!("I have {#count} passport stamp", count = 5),
))
Placeholder values are signals. Constants like 0 work, and so does a
Binding<i32> — in which case the string re-renders on every change without
rebuilding the surrounding view:
use waterui::prelude::*;
let count = Binding::i32(0);
text!("I have {#count} passport stamp", count = count.clone())
When the same English string needs two different translations, disambiguate
with @context. The context becomes part of the lookup key:
text!("Open" @ verb) // key: "Open#verb"
text!("Open" @ adjective) // key: "Open#adjective"
text! returns a Text, so the whole styling surface applies: .size(n),
.bold(), .font(f), .body(), .title(), .headline(), .sub_headline(),
.caption(), .footnote(). (.italic(signal) takes a boolean signal rather
than being a bare toggle.)
Locales
waterui::locale::Locale wraps an ICU4X locale and keeps its Unicode
extensions (u-ca calendar, u-hc hour cycle, u-nu numbering system), so it
represents a full preference rather than a bare language tag.
use core::str::FromStr;
use waterui::locale::{Locale, locales};
let locale = Locale::from_str("en-US").expect("valid BCP 47 tag");
let tag = locale.canonical_tag(); // "en-US"
let lang = locale.language.as_str(); // "en" — via Deref to the language identifier
Parsing failures surface as icu_locale::ParseError. The locales module has
constants so common tags never need parsing: EN, EN_US, EN_GB, ZH_CN,
ZH_TW, ZH_HK, ZH_HANS, ZH_HANT, JA, KO, FR, DE, ES, RU,
AR, HI, PT, PT_BR, PT_PT, SR_LATN, SR_CYRL.
Fallback is script-aware
Lookups walk ICU4X’s fallback chain, which understands scripts:
use core::str::FromStr;
use waterui::locale::Locale;
use waterui::locale::locale::get_fallback_chain;
let chain = get_fallback_chain(&Locale::from_str("zh-TW").unwrap());
// zh-TW → zh-Hant → zh (never zh-Hans)
This is why a Taiwan build can keep only its Taiwan-specific vocabulary in
i18n/zh-TW.toml and let everything else resolve from i18n/zh-Hant.toml.
Serbian behaves the same way: sr-Latn never falls back into Cyrillic.
Plural rules directly
select_plural gives the CLDR category for a number in a locale, and
valid_categories lists the categories a locale actually uses — handy for
validating a translator’s file:
use waterui::locale::plural::valid_categories;
use waterui::locale::{PluralCategory, locales, select_plural};
assert_eq!(select_plural(&locales::EN, &1), PluralCategory::One);
assert_eq!(select_plural(&locales::EN, &0), PluralCategory::Other);
assert_eq!(select_plural(&locales::FR, &0), PluralCategory::One); // 0 is "one" in French
assert_eq!(select_plural(&locales::RU, &2), PluralCategory::Few);
assert_eq!(select_plural(&locales::RU, &5), PluralCategory::Many);
assert_eq!(select_plural(&locales::RU, &21), PluralCategory::One);
let en = valid_categories(&locales::EN);
assert!(en.contains(&PluralCategory::One) && en.contains(&PluralCategory::Other));
Rules use absolute values, so a negative number selects the same category as
its positive counterpart, and fractional values are handled per CLDR (1.2 is
Other in English).
Locale-aware formatting
LocalizedDisplay::to_localized_string(&locale) is the entry point for
everything below. A blanket implementation covers all Display types, which
gives you a working call for any type — but only types with a real
implementation produce locale-specific output.
Units
waterui::locale::format::unit gives dimensionally typed quantities that
convert, add, and format themselves:
use waterui::locale::format::unit::{Kilometer, Length, Meter, Mile};
use waterui::locale::{LocalizedDisplay, locales};
let distance = Length::<Meter>::new(1500.0);
distance.to_localized_string(&locales::EN); // "1,500m"
distance.to_localized_string(&locales::ZH_CN); // "1,500米"
distance.to_localized_string(&locales::JA); // "1,500メートル"
let km = Length::<Kilometer>::new(1.0);
let in_miles = km.to::<Mile>(); // ≈0.621 mi
let total = km + Length::<Meter>::new(500.0); // 1.5 km — result keeps the left unit
The number is formatted for the locale and the symbol is appended without a
separator. Three families ship today: Length (Meter, Kilometer, Mile,
Feet), Mass (Kilogram, Gram, Pound, Ounce), and Temperature
(Celsius, Fahrenheit, Kelvin).
Dates and times
use waterui::locale::format::date::{DateStyle, SimpleDate, TimeStyle, SimpleTime, format_date, format_time};
use waterui::locale::locales;
let date = SimpleDate::new(2006, 3, 20);
format_date(&locales::EN, &date, DateStyle::Short); // slash-separated, month first
format_date(&locales::DE, &date, DateStyle::Short); // dot-separated, day first
format_date(&locales::JA, &date, DateStyle::Long); // 2006年3月20日
format_time(&locales::EN, &SimpleTime::new(9, 30, 0), TimeStyle::Short);
Both styles run Short, Medium, Long, and Full. Use
format_datetime_with_regional_context when the time zone from the device’s
regional settings should be part of the output.
Numbers and lists
use waterui::locale::format::LocalizedList;
use waterui::locale::format::number::{Currency, format_currency, format_number, format_percent};
use waterui::locale::{LocalizedDisplay, locales};
format_number(&locales::DE, 1234.5);
format_percent(&locales::EN, 0.42);
format_currency(&locales::EN, 19.99, Currency::USD);
let items = LocalizedList(&["Apple", "Banana", "Orange"]);
items.to_localized_string(&locales::EN); // "Apple, Banana, and Orange"
items.to_localized_string(&locales::ZH_CN); // "Apple、Banana和Orange"
LocalizedList borrows a &[&str], so format the elements yourself first if
they are not already strings.
Choosing the locale
WaterUI resolves the effective locale in this order, taking the first match:
- a
Binding<Locale>installed in the environment, - a
RegionalContextin the environment, - a plain
Localein the environment, - the shared runtime locale, seeded from the platform.
Overriding a subtree
Insert a Locale for one branch of the tree with ViewExt::with:
use waterui::locale::locales;
use waterui::prelude::*;
vstack((text!("Welcome to the World Fair!"), text!("Language Booth")))
.with(locales::JA)
Switching at runtime
For an app-wide language picker, drive the shared runtime locale.
waterui::regional::set_locale_tag takes a BCP 47 tag and returns an error for
an invalid one; every text! in the app re-resolves:
use waterui::form::picker::Picker;
use waterui::prelude::*;
fn language_picker() -> impl View {
let selection = Binding::container("en-US");
Picker::new(
[
text("English (US)").tag("en-US"),
text("Deutsch").tag("de"),
text("日本語").tag("ja"),
],
&selection,
)
.on_change(&selection, |tag| {
waterui::regional::set_locale_tag(tag).expect("picker tags must be valid");
})
}
Nothing is wrapped in watch here. text! subscribes to the locale itself, so
switching languages re-renders the strings without recreating the picker or
losing its selection.
Reading the locale in a view
Locale implements Extractor, so use_env hands it to you:
use waterui::env::use_env;
use waterui::locale::Locale;
use waterui::prelude::*;
fn current_language() -> impl View {
use_env(|locale: Locale| text(locale.canonical_tag()))
}
This is a snapshot, not a subscription: the extractor reads the locale once
when the body runs, and the view will not update when the locale changes. Use
it for one-time setup, and keep reactive strings in text!.
Formatting helpers take &Locale rather than reading the environment, so map a
locale signal into the string you want and let a single text leaf update:
use waterui::locale::format::unit::{Length, Meter};
use waterui::locale::{Locale, LocalizedDisplay};
use waterui::prelude::*;
fn distance_label(locale: Computed<Locale>) -> impl View {
let distance = locale
.map(|locale| Length::<Meter>::new(1500.0).to_localized_string(&locale))
.computed();
text!("{distance}")
}
Runtime catalogs
text! needs no runtime data — its translations are baked in at expansion
time. Components that look strings up at runtime (a Table re-resolving its
column labels on a locale change, for example) read a TranslationCatalog from
the environment. catalog!() builds one from the same i18n/ directory, and
configure_environment!(env) installs it at a backend entry point.
What’s next
Localization is one cross-cutting concern threaded through the environment; theming, analytics, and error views are others. In the next chapter you will package such concerns as plugins that extend the framework without touching its core.