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 i18n/<locale>.toml catalogs that text! compiles into your binary
  • Handle CLDR plural forms, including languages with four of them and languages with only one
  • Understand how a locale resolves: the fallback chain, zh-TW through zh-Hant, never zh-Hans
  • Format dates, numbers, units, and lists with the locale runtime
  • Override the locale for a subtree, switch it at runtime, and let layout direction follow it

Translating strings is the easy half. Plural rules differ (English has two forms, Russian has four, Japanese has one), dates reorder their fields, units change symbol, and lists change their conjunction. WaterUI keeps the translation tables in plain TOML, resolves them against a reactive locale, and routes plural selection and number formatting through ICU4X. A locale change therefore updates text in place; no view is rebuilt and no watch is involved.

Three kinds of text meet the catalog differently:

You writeTranslated byInterpolation and plurals
text!("Hello, {name}")i18n/*.toml, embedded into your crate at compile timeyes
text("Hello") (a &'static str)the TranslationCatalog in the environment, at runtimeno
text(String) or text(Str)never: the string is shown verbatimno

The rest of the chapter starts with the first row, because it is the one you will use most.

Translation files

Put one TOML file per locale in an i18n/ directory next to your crate’s Cargo.toml. The text! macro reads every *.toml file in it at compile time, so a library crate translates with its own i18n/, not the application’s. (The backend crates the water CLI generates export WATERUI_I18N_DIR from their build scripts, pointing at your application’s i18n/, so the same files serve both.)

my-app/
├── Cargo.toml
├── i18n/
│   ├── en.toml
│   ├── de.toml
│   ├── ru.toml
│   ├── zh.toml
│   ├── zh-Hant.toml
│   ├── zh-TW.toml
│   ├── ja.toml
│   └── ko.toml
└── src/lib.rs

The file stem is a BCP 47 tag. The key is the source string exactly as it appears in code; the value is the translation:

# i18n/ja.toml
"Welcome to the World Fair!" = "世界フェアへようこそ!"
"Language Booth" = "言語ブース"
# i18n/ko.toml
"Welcome to the World Fair!" = "세계 박람회에 오신 것을 환영합니다!"
"Language Booth" = "언어 부스"
# i18n/zh.toml
"Welcome to the World Fair!" = "欢迎来到世界博览会!"
"Language Booth" = "语言展台"

A long paragraph reads badly as a key, so by convention a $-prefixed key stands in for it. The macro gives $ no special meaning: the code says text!("$udhr_article_1") and every catalog, en.toml included, defines that key:

# i18n/en.toml
"$udhr_article_1" = "All human beings are born free and equal in dignity and rights."

A key that no catalog defines is not an error. Resolution tries the locale’s fallback chain, then en.toml, and finally shows the format string itself, so an untranslated app renders its source text rather than a placeholder. A malformed catalog is an error: a value that is neither a string nor a plural table, an unknown plural field, or a plural table without other all fail the build.

The text! macro

text! expands to a Text view that resolves its content against the effective locale and resolves again whenever that locale changes. Placeholders name identifiers from the surrounding scope:

#![allow(unused)]
fn main() {
use waterui::prelude::*;

fn greeting(name: Str) -> impl View {
    text!("Hello, {name}!").headline()
}
}

A placeholder is a bare identifier, so the macro can use its name as a slot key in the catalog. {}, {0} and arbitrary expressions are compile errors. When the value lives in a differently named variable, or in no variable at all, bind it with name = expr:

#![allow(unused)]
fn main() {
use waterui::prelude::*;

fn stamps(stamp_count: Binding<i32>) -> impl View {
    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),
        text!("I have {#count} passport stamp", count = stamp_count),
    ))
}
}

Placeholder values are signals. A constant works, and so does a Binding, in which case the string updates on every change without rebuilding the surrounding view. Interpolated numbers are formatted for the locale (digits and separators), and every interpolated value is wrapped in Unicode directional isolates, so an Arabic name inside an English sentence cannot reorder the text around it. A standard format spec applies to the number before it is localized:

#![allow(unused)]
fn main() {
use waterui::prelude::*;

fn zoom_label() -> impl View {
    let level = Binding::container(1.25_f64);
    text!("Zoom {level:.1}")
}
}

When one English string needs two translations, disambiguate it with an @ context. The context joins the lookup key with a #, and an untranslated string still shows the bare text:

#![allow(unused)]
fn main() {
use waterui::prelude::*;

fn open_labels() -> impl View {
    hstack((text!("Open" @ verb), text!("Open" @ adjective)))
}
}
# i18n/de.toml
"Open#verb" = "Öffnen"
"Open#adjective" = "Offen"

text! returns a Text, so the whole styling surface applies to it: .size(16.0), .bold(), .headline(), .body(), and the rest.

Plural forms

Mark the pluralizing variable with {#name} in the key, and write it as {name} (without the #) in the values. A plural entry is a table keyed by CLDR category:

# 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} паспортных штампов" }

Chinese, Japanese, and Korean have no plural distinction, so each uses other alone:

# i18n/zh.toml
"I have {#count} passport stamp" = { other = "我有{count}个护照章" }

# i18n/ja.toml
"I have {#count} passport stamp" = { other = "私はパスポートスタンプを{count}個持っています" }

# i18n/ko.toml
"I have {#count} passport stamp" = { other = "여권 도장 {count}개가 있습니다" }

The valid fields are zero, one, two, few, many and other. Only other is required, and a category your table omits falls back to other at runtime. Which categories a language needs comes from CLDR, not from the macro: it will not stop you writing few for English, it just never selects it.

When one sentence pluralizes two quantities independently, use the four dual forms, naming both variables in the key:

"I have {#apples} apple and {#oranges} orange" = { one_one = "I have {apples} apple and {oranges} orange", one_other = "I have {apples} apple and {oranges} oranges", other_one = "I have {apples} apples and {oranges} orange", other_other = "I have {apples} apples and {oranges} oranges" }

other_other is the required field. Dual forms distinguish only one from everything else, so a language whose second quantity needs few cannot express it here; split the sentence instead.

Plural rules directly

select_plural returns the CLDR category for a number in a locale, and valid_categories lists the categories a locale actually uses, which is handy for validating a translator’s file:

#![allow(unused)]
fn main() {
use waterui::locale::{PluralCategory, locales, select_plural, valid_categories};

assert_eq!(select_plural(&locales::EN, &1), PluralCategory::One);
assert_eq!(select_plural(&locales::EN, &0), PluralCategory::Other);
assert_eq!(select_plural(&locales::EN, &1.2), PluralCategory::Other);

// French treats 0 as singular.
assert_eq!(select_plural(&locales::FR, &0), PluralCategory::One);

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);

// Chinese, Japanese and Korean never leave `Other`.
assert_eq!(select_plural(&locales::ZH_CN, &1), PluralCategory::Other);
assert_eq!(select_plural(&locales::JA, &1), PluralCategory::Other);
assert_eq!(select_plural(&locales::KO, &1), PluralCategory::Other);

assert_eq!(valid_categories(&locales::ZH_CN), vec![PluralCategory::Other]);
assert!(valid_categories(&locales::EN).contains(&PluralCategory::One));
}

Rules use the absolute value, so -1 selects the same category as 1. Fractions keep their fractional digits, which is why 1.2 is Other in English.

Locales and fallback

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 and not just a language tag. It implements FromStr, and it dereferences to the language identifier, so language and region read straight off it:

#![allow(unused)]
fn main() {
use waterui::locale::Locale;

fn describe(tag: &str) -> Option<String> {
    let locale: Locale = tag.parse().ok()?;
    let region = locale.region.as_ref().map(|region| region.as_str());
    Some(format!(
        "{} / {:?} / {}",
        locale.language.as_str(),
        region,
        locale.canonical_tag()
    ))
}
}

The waterui::locale::locales module holds 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 and SR_CYRL.

A lookup walks ICU4X’s locale fallback chain, which understands scripts. A zh-TW request is answered from zh-TW, then from zh-Hant, and from neither zh nor zh-Hans, because zh means Simplified Chinese; sr-Latn never falls into Cyrillic. That is why the example project keeps only Taiwan-specific vocabulary in zh-TW.toml and lets everything else resolve from zh-Hant.toml. get_fallback_chain shows the chain; for zh-TW it is ["zh-TW", "zh-Hant"]:

#![allow(unused)]
fn main() {
use waterui::locale::locale::get_fallback_chain;
use waterui::locale::locales;

fn taiwan_chain() -> Vec<String> {
    get_fallback_chain(&locales::ZH_TW)
        .iter()
        .map(|locale| locale.canonical_tag())
        .collect()
}
}

A catalog file matches a locale by its exact language, script, region and variants: zh.toml serves zh and, through the chain, zh-CN, but never zh-TW. When the locale being resolved equals the shared runtime locale, the user’s other preferred languages are tried after its own chain. If nothing matches, the lookup tries en.toml (exactly en, not en-US), then the format string.

The runtime catalog

text! needs no runtime data, because it embeds its translations. A plain text("literal") and the framework’s own chrome look their strings up in a TranslationCatalog that sits in the environment. catalog!() builds one from the same i18n/ directory at compile time, and configure_environment!(env) installs it (together with the application’s bundled resources); the scaffolded entry points already call it, so a project made by water create needs nothing here.

#![allow(unused)]
fn main() {
use waterui::locale::TranslationCatalog;
use waterui::prelude::*;

fn embedded_catalog() -> TranslationCatalog {
    catalog!()
}
}

You can also build a catalog by hand, which is useful in tests or for translations downloaded at runtime:

#![allow(unused)]
fn main() {
use waterui::locale::{TranslationCatalog, locales};
use waterui::prelude::*;

fn install_booth_catalog(env: &mut Environment) {
    let catalog = TranslationCatalog::new()
        .add_toml("ja", r#""Language Booth" = "言語ブース""#)
        .expect("the Japanese catalog is valid TOML")
        .add_toml("ko", r#""Language Booth" = "언어 부스""#)
        .expect("the Korean catalog is valid TOML");

    let _booth = catalog.lookup_text(&locales::JA, "Language Booth");
    env.install(catalog);
}
}

lookup_text resolves simple entries only. A plural entry reached through it panics with a message telling you to use text!, so keep pluralized strings in text! calls.

Choosing the locale

The effective locale is the first of these that the environment provides:

  1. a Binding<Locale>,
  2. a RegionalContext,
  3. a plain Locale,
  4. the shared runtime locale, seeded from the platform’s settings.

The Apple backend installs a Binding<Locale>, a Locale and a RegionalContext holding the device language, and updates the binding when the system language changes. Hydrolysis installs none of them, so the shared runtime locale applies. Either way the system language is the locale for most apps, and you write no code at all.

Overriding a subtree

Scope a locale to one branch of the tree with ViewExt::with. Everything beneath it, text! and formatters included, resolves in that locale:

#![allow(unused)]
fn main() {
use waterui::locale::locales;
use waterui::prelude::*;

fn japanese_booth() -> impl View {
    vstack((
        text!("Welcome to the World Fair!"),
        text!("Language Booth"),
    ))
    .with(Binding::container(locales::JA))
}
}

Scope a Binding<Locale>, as here, rather than a plain Locale. The order above holds wherever the values sit in the tree, so a plain Locale on a subtree still ranks below a Binding<Locale> or a RegionalContext installed at the root. The Apple backend installs both, and the language picker below installs a binding, so a plain Locale scope would be ignored there.

On Hydrolysis the effective locale is also the language text is shaped in, which picks the right glyph variants for Han characters. A locale scope is therefore how you show Chinese, Japanese and Korean side by side there:

#![allow(unused)]
fn main() {
use waterui::env::with;
use waterui::locale::locales;
use waterui::prelude::*;

fn han_specimens() -> impl View {
    vstack((
        with(text("简体中文:骨、直、门、关").body(), Binding::container(locales::ZH_CN)),
        with(text("繁體中文:骨、直、門、關").body(), Binding::container(locales::ZH_TW)),
        with(text("日本語:骨、直、門、関").body(), Binding::container(locales::JA)),
        with(text("한국어: 한글과 漢字").body(), Binding::container(locales::KO)),
    ))
}
}

Switching at runtime

For an in-app language picker, install a Binding<Locale> in the environment once, and write to it. It sits first in the order above, so every text! and every formatter that reads the effective locale follows it, and so does the application’s layout direction (see below):

#![allow(unused)]
fn main() {
use waterui::app::App;
use waterui::form::picker::{Picker, PickerItem};
use waterui::locale::{Locale, locales};
use waterui::prelude::*;

fn languages() -> [PickerItem<&'static str>; 6] {
    [
        text("English").tag("en"),
        text("Deutsch").tag("de"),
        text("中文(简体)").tag("zh"),
        text("中文(繁體)").tag("zh-TW"),
        text("日本語").tag("ja"),
        text("한국어").tag("ko"),
    ]
}

fn language_picker(locale: Binding<Locale>) -> impl View {
    let selection = Binding::container("en");

    vstack((
        text!("Language Booth").headline(),
        Picker::new(text!("Language"), languages(), &selection),
        text!("Welcome to the World Fair!"),
    ))
    .on_change(&selection, move |tag: &'static str| {
        locale.set(tag.parse().expect("the picker tags are valid BCP 47"));
    })
}

pub fn app(mut env: Environment) -> App {
    let locale = Binding::container(locales::EN);
    env.insert(locale.clone());
    App::new(move || language_picker(locale.clone()), env)
}
}

Nothing here is wrapped in watch. text! subscribes to the locale itself, so switching languages re-renders the strings without recreating the picker or losing its selection. The picker’s own items re-resolve as well. Your binding replaces the one the Apple backend installed in the environment app receives, so from then on the picker, not the system language setting, decides the locale.

An app that installs no locale of its own can drive the shared runtime locale instead: waterui::regional::set_locale_tag("ja") takes a BCP 47 tag and returns Err(InvalidLocaleTag) for an invalid one. It reaches only views whose environment holds none of the three higher-priority values. That is the case on Hydrolysis, but not on the Apple backend, whose Binding<Locale> outranks it; the binding above works on both. The shared runtime also carries the time zone and preferred languages that current_settings() returns (below).

Reading the locale in a view

Locale implements Extractor, so use_env hands it to you:

#![allow(unused)]
fn main() {
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()))
}
}

That is a snapshot: the extractor reads the locale once when the body runs, and the view will not update when the locale changes. It also checks a plain Locale before a RegionalContext, the reverse of the order above. For anything that must follow the locale, take the reactive binding instead. waterui::locale::locale_binding resolves the effective locale for an environment, in the order listed above, and returns a Binding<Locale> you can map:

#![allow(unused)]
fn main() {
use waterui::env::use_env;
use waterui::locale::format::date::{DateStyle, SimpleDate, format_date};
use waterui::locale::format::unit::{Length, Meter};
use waterui::locale::{Locale, LocalizedDisplay, locale_binding};
use waterui::prelude::*;

fn festival(locale: Binding<Locale>) -> impl View {
    let date = locale
        .map(|locale| format_date(&locale, &SimpleDate::new(2006, 3, 20), DateStyle::Long))
        .computed();
    let distance = locale
        .map(|locale| Length::<Meter>::new(1500.0).to_localized_string(&locale))
        .computed();

    vstack((text!("{date}"), text!("{distance}")))
}

fn festival_in_current_locale() -> impl View {
    use_env(|env: Environment| festival(locale_binding(&env)))
}
}

The formatters take a &Locale rather than reading the environment, so you map a locale signal into the string you want and let one text leaf update.

Locale-aware formatting

LocalizedDisplay::to_localized_string(&locale) is the common entry point. A blanket implementation covers every Display type, which gives you a working call for any type, but only types with a real implementation (below) produce locale-specific output.

The formatters sit behind facade features. Number, percent, currency and unit formatting is always compiled in, because text! needs it for interpolation. Dates and times (waterui::locale::format::date) need locale-datetime, and LocalizedList needs locale-list. Both are in waterui’s default features; a lean build that sets default-features = false names the ones it wants.

Numbers and currency

#![allow(unused)]
fn main() {
use waterui::locale::{Currency, format_currency, format_number, format_percent, locales};

assert_eq!(format_number(&locales::EN, 1234.56), "1,234.56");
assert_eq!(format_number(&locales::DE, 1234.56), "1.234,56");
assert_eq!(format_percent(&locales::EN, 0.75), "75%");
assert_eq!(format_currency(&locales::EN_US, 1234.56, Currency::USD), "$1,234.56");
assert_eq!(format_currency(&locales::DE, 1234.56, Currency::EUR), "1.234,56\u{a0}€");
}

Digits and separators come from ICU4X. The percent sign and the currency symbol are placed by a small per-language table in waterui-locale, and Currency covers USD, EUR, GBP, JPY and CNY. No exchange rates are involved.

Units

waterui::locale::format::unit has dimensionally typed quantities that convert with .to::<Unit>() and format themselves: Length (Meter, Kilometer, Mile, Feet), Mass (Kilogram, Gram, Pound, Ounce) and Temperature (Celsius, Fahrenheit, Kelvin). Length and Mass also add across units. The length types are re-exported at waterui::locale as well.

#![allow(unused)]
fn main() {
use waterui::locale::{Kilometer, Length, LocalizedDisplay, Meter, Mile, locales};

let distance = Length::<Meter>::new(18.0);
assert_eq!(distance.to_localized_string(&locales::EN), "18m");
assert_eq!(distance.to_localized_string(&locales::ZH_CN), "18米");
assert_eq!(distance.to_localized_string(&locales::JA), "18メートル");
assert!(distance.to_localized_string(&locales::KO).contains("미터"));

let km = Length::<Kilometer>::new(1.0);
assert!((km.to::<Mile>().value() - 0.6214).abs() < 0.001);

// The sum keeps the left-hand unit.
let total = km + Length::<Meter>::new(500.0);
assert!((total.value() - 1.5).abs() < 0.001);
}

The number is formatted for the locale and the unit symbol follows it with no separator. Length and mass symbols are chosen by language from a small table (Chinese, Japanese and Korean words, otherwise m, kg and so on); temperature symbols are always °C, °F and K. A quantity is displayed in the unit you gave it and is not converted to the locale’s measurement system for you. Do that conversion yourself with .to::<Mile>() when the locale calls for it.

Dates and times

#![allow(unused)]
fn main() {
use waterui::locale::format::date::{DateStyle, SimpleDate, SimpleTime, TimeStyle};
use waterui::locale::format::date::{format_date, format_datetime, format_time};
use waterui::locale::locales;

let date = SimpleDate::new(2006, 3, 20);

// The same date reads in each language's own field order and calendar words.
assert!(format_date(&locales::ZH_CN, &date, DateStyle::Long).contains('年'));
assert!(format_date(&locales::JA, &date, DateStyle::Long).contains('年'));
assert!(format_date(&locales::KO, &date, DateStyle::Long).contains('년'));

let time = SimpleTime::new(9, 30, 0);
let _clock = format_time(&locales::DE, &time, TimeStyle::Short);
let _both = format_datetime(&locales::EN, &date, &time, DateStyle::Medium, TimeStyle::Short);
}

DateStyle and TimeStyle run Short, Medium, Long and Full. SimpleDate::new(year, month, day) and SimpleTime::new(hour, minute, second) are plain value types. An invalid component, such as month 13, is reported through tracing (once) and the formatter falls back to a plain 2006-03-20 style string rather than panicking.

When the output should include the device’s time zone, pass a RegionalContext to format_datetime_with_regional_context:

#![allow(unused)]
fn main() {
use waterui::locale::Locale;
use waterui::locale::format::date::{
    DateStyle, SimpleDate, SimpleTime, TimeStyle, format_datetime_with_regional_context,
};
use waterui::regional::current_settings;

fn kickoff(locale: &Locale) -> String {
    format_datetime_with_regional_context(
        &current_settings().with_locale(locale),
        &SimpleDate::new(2006, 3, 20),
        &SimpleTime::new(9, 30, 0),
        DateStyle::Long,
        TimeStyle::Long,
    )
}
}

current_settings() returns a plain RegionalContext value (the shared runtime’s locale, preferred languages, region and IANA time zone), not a signal. Its locale is the shared runtime locale, which is not necessarily the effective locale of your view, so with_locale swaps in the locale you resolved and keeps the time zone. Call kickoff inside a .map over a locale signal, as in the festival example, to follow locale changes.

Lists

LocalizedList joins items with the locale’s conjunction and separators. It borrows a &[&str], so turn other element types into strings first:

#![allow(unused)]
fn main() {
use waterui::locale::{LocalizedDisplay, LocalizedList, locales};

let items = LocalizedList(&["Apple", "Banana", "Orange"]);
assert_eq!(items.to_localized_string(&locales::EN), "Apple, Banana, and Orange");
assert_eq!(items.to_localized_string(&locales::ZH_CN), "Apple、Banana和Orange");
}

Right-to-left layout

LayoutDirection (in the prelude, with the variants LeftToRight and RightToLeft) is an environment value. App::new derives it from the effective locale unless your environment already holds a LayoutDirection, a Binding<LayoutDirection> or a Computed<LayoutDirection>, so an Arabic or Hebrew locale mirrors the app with no code, and a runtime switch through a locale binding flips it live. The function behind it is public:

#![allow(unused)]
fn main() {
use waterui::locale::{Locale, layout_direction};

fn mirrors(locale: &Locale) -> bool {
    layout_direction(locale).is_right_to_left()
}
}

Under right-to-left, hstack child order and leading/trailing alignment are logical and mirror automatically, so never swap children or hard-code left and right by hand (see Layout).

A locale scope on its own does not change direction, because the automatic direction is resolved once for the application. A subtree that should read in another direction sets both values, nested. A plain LayoutDirection ranks first, so it overrides the application’s direction; the locale is scoped as a binding for the reason given in Overriding a subtree:

#![allow(unused)]
fn main() {
use waterui::locale::{Locale, locales};
use waterui::prelude::*;

fn hebrew_panel(panel: impl View) -> impl View {
    let hebrew: Locale = "he".parse().expect("`he` is a valid BCP 47 tag");
    panel
        .with(Binding::container(hebrew))
        .with(LayoutDirection::RightToLeft)
}

fn arabic_panel(panel: impl View) -> impl View {
    panel
        .with(Binding::container(locales::AR))
        .with(LayoutDirection::RightToLeft)
}
}

There is no .direction(..) modifier; the environment is the mechanism. See Environment for how a value scopes to a subtree.

What’s next

Localization is one cross-cutting concern threaded through the environment. TranslationCatalog reaches it as a plugin, and the next chapter shows how to package your own cross-cutting concerns the same way. The examples/locale and examples/typography-rtl projects in the framework repository are complete, runnable apps built on the APIs above.