Internationalization
In this chapter, you will:
- Write
i18n/<locale>.tomlcatalogs thattext!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-TWthroughzh-Hant, neverzh-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 write | Translated by | Interpolation and plurals |
|---|---|---|
text!("Hello, {name}") | i18n/*.toml, embedded into your crate at compile time | yes |
text("Hello") (a &'static str) | the TranslationCatalog in the environment, at runtime | no |
text(String) or text(Str) | never: the string is shown verbatim | no |
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:
- a
Binding<Locale>, - a
RegionalContext, - a plain
Locale, - 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(
¤t_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.