Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

  1. a Binding<Locale> installed in the environment,
  2. a RegionalContext in the environment,
  3. a plain Locale in the environment,
  4. 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.