i18n and locales
ftui-i18n is the localization foundation FrankenTUI uses to render the
same UI in multiple languages without sacrificing determinism or
testability. It provides a keyed string catalog with explicit fallback
chains, CLDR plural rules for the major language families, single-pass
variable interpolation, and a coverage report so teams can see at a glance
which translations are lagging.
The crate is pure data and pure functions. It does not depend on the renderer, the runtime, or any I/O — you can unit-test a catalog lookup in microseconds. Widgets call into it during their update or render phases to resolve keys into localized text before composing a frame.
The model
key ──> locale primary ──> locale fallback 1 ──> ... ──> base locale
│ │ │
▼ ▼ ▼
entry entry entry
│
▼
PluralForms (optional) + {name} interpolation
│
▼
resolved StringThree separable concerns:
- Catalog lookup — find the best entry for
(locale, key)using a fallback chain. - Plural selection — if the entry is a
PluralForms, pick the right form for the count. - Interpolation — substitute
{name}tokens with arguments.
Keeping them separate is what makes the crate testable: each stage is a total function with no hidden state.
StringCatalog
let mut catalog = StringCatalog::new();
let mut en = LocaleStrings::new();
en.insert("greeting", "Hello");
en.insert("welcome", "Welcome, {name}!");
catalog.add_locale("en", en);
// Try primary locale, then fallbacks in order.
catalog.set_fallback_chain(vec!["es-MX".into(), "es".into(), "en".into()]);
// Direct hit.
assert_eq!(catalog.get("es-MX", "greeting"), Some("Hola"));
// Falls through es-MX → es → en.
assert_eq!(catalog.get("es-MX", "color"), Some("Color"));
// Interpolation.
let out = catalog.format("en", "welcome", &[("name", "Alice")]);
assert_eq!(out, Some("Welcome, Alice!".into()));add_locale(tag, LocaleStrings)— register strings for one locale. Order of insertion does not matter.set_fallback_chain(Vec<String>)— the list of locales to try, in order, when a requested locale/key pair is not found.get(locale, key) -> Option<&str>— raw lookup, walks the fallback chain.format(locale, key, args) -> Option<String>— lookup plus interpolation.
The fallback chain is the idiomatic way to express regional specialization:
es-MX → es → en means “show the Mexican-Spanish string if we have one,
otherwise generic Spanish, otherwise English.”
Interpolation: single-pass, {name} only
catalog.format("en", "welcome", &[("name", "Alice")])- Tokens look like
{identifier}. - Substitution is single-pass. If an argument value itself contains
{other}, that brace is not re-expanded. This is intentional — it makes the function trivially safe against recursive injection from translator-supplied strings. - Missing arguments leave the token in place:
{missing}stays literal. The compiler cannot check this for you, so the coverage report will flag catalogs that reference undefined arguments in practice.
Plural forms
pub struct PluralForms {
pub zero: Option<String>,
pub one: String,
pub two: Option<String>,
pub few: Option<String>,
pub many: Option<String>,
pub other: String,
}one and other are mandatory; every other category is optional and
falls back to other when absent. This is the CLDR convention.
CLDR plural rules
PluralRule implements the CLDR classification for the major language
families:
| Rule | Languages (subset) | Categories | Example behavior |
|---|---|---|---|
| English | en, de, nl, sv, da, no/nb/nn, it, es, pt, el, hu, fi, et, he, tr, bg | one (n=1), other | ”1 item”, “5 items” |
| French | fr, hi, bn | one (n ∈ 1), other | ”0 élément”, “2 éléments” |
| Russian | ru, uk, hr, sr, bs | one, few, many, other by mod-10 / mod-100 | 1 файл · 3 файла · 5 файлов |
| Polish | pl | Similar to Russian with different many boundary | 1 plik · 2 pliki · 5 plików |
| Arabic | ar | zero, one, two, few, many, other | Full six-category set |
| CJK | zh, ja, ko, th, vi, id, ms | Always other | No count-based variation |
| Custom | — | User-supplied fn(i64) -> PluralCategory | Escape hatch |
let rule = PluralRule::for_locale("ru");
assert_eq!(rule.categorize(1), PluralCategory::One);
assert_eq!(rule.categorize(3), PluralCategory::Few);
assert_eq!(rule.categorize(5), PluralCategory::Many);
assert_eq!(rule.categorize(21), PluralCategory::One); // last-digit rulePluralRule::for_locale(tag) extracts the primary language subtag — en-US,
en_GB, and en all resolve to PluralRule::English. Unknown languages
fall back to English, which is the least-opinionated choice (every
Unicode-aware string has a one and an other).
Rules are pure functions: same count always yields the same category. Negative counts use the absolute value for the built-in rules.
Coverage report
pub struct CoverageReport {
pub total_keys: usize,
pub locales: Vec<LocaleCoverage>,
}
pub struct LocaleCoverage {
pub locale: String,
pub present: usize, // keys present after fallback
pub missing: Vec<String>, // keys still absent
pub coverage_percent: f32,
}
let report = catalog.coverage_report();missing_keys(locale, reference_keys) gives the same data scoped to a
provided set of keys, which is the right thing for CI — lock in a
reference list, assert the list has not regressed.
This is the only function in the crate that reports on what is not there rather than what is, and it is how the demo screen’s “this locale is 87 % covered” badge is computed.
The i18n demo screen
The i18n_demo screen in ftui-demo-showcase is the live playground.
Four panels, toggled with Tab / Shift+Tab:
const PANEL_NAMES: [&str; 4] = ["Overview", "Plurals", "RTL Layout", "Stress Lab"];Supported locales in the demo (from the actual screen source):
| Tag | Name | Native | Direction |
|---|---|---|---|
en | English | English | LTR |
es | Spanish | Español | LTR |
fr | French | Français | LTR |
ru | Russian | Русский | LTR |
ar | Arabic | العربية | RTL |
ja | Japanese | 日本語 | LTR |
The locale picker in the demo cycles through these six tags. Switching locale rebuilds the catalog lookup context, so you watch the greeting, the plural demo, and the RTL panel update live in a single frame. This is the easiest way to see the fallback chain and plural rules do real work.
Panel breakdown:
- Overview — locale picker, greeting lookup, plural demo.
- Plurals — cycles the count (1 / 2 / 5 / 21) and shows the chosen form. The Russian case is the instructive one: 1 / 2 / 5 / 21 map to four distinct categories.
- RTL Layout — Arabic test cases flowing right-to-left, including mixed LTR/RTL paragraphs. See Bidi / RTL support for the underlying algorithm.
- Stress Lab — complex graphemes, combining marks, CJK widths, ZWJ
emoji sequences. Uses the
ftui-textgrapheme utilities (grapheme_count,display_width,truncate_to_width_with_info,wrap_text).
Example: plurals end-to-end
Declare the forms in English
let mut en = LocaleStrings::new();
en.insert_plural("file_count", PluralForms {
one: "1 file".into(),
other: "{count} files".into(),
..Default::default()
});
catalog.add_locale("en", en);Declare them in Russian
let mut ru = LocaleStrings::new();
ru.insert_plural("file_count", PluralForms {
one: "{count} файл".into(),
few: Some("{count} файла".into()),
many: Some("{count} файлов".into()),
other: "{count} файлов".into(),
..Default::default()
});
catalog.add_locale("ru", ru);Resolve for a given count
fn file_count(catalog: &StringCatalog, locale: &str, count: i64) -> String {
let rule = PluralRule::for_locale(locale);
let forms = catalog.get_plural(locale, "file_count").unwrap();
let form = forms.select(rule.categorize(count));
form.replace("{count}", &count.to_string())
}
assert_eq!(file_count(&catalog, "en", 1), "1 file");
assert_eq!(file_count(&catalog, "en", 5), "5 files");
assert_eq!(file_count(&catalog, "ru", 1), "1 файл");
assert_eq!(file_count(&catalog, "ru", 3), "3 файла");
assert_eq!(file_count(&catalog, "ru", 5), "5 файлов");Runtime wiring
ProgramConfig::with_locale(tag) threads a Locale through the runtime;
widgets read it via the locale context when they resolve strings. A
with_locale_context variant gives full control of the LocaleContext
(primary locale, overrides, fallback chain) for apps that need more than
one locale active simultaneously (rare, but supported).
Pitfalls
- Do not concatenate localized strings in widget code. Use one key per sentence and let the translation encode the joining. “Hello, ” + name + ”!” is an English-only assumption.
- Do not hand-roll plural selection.
1vs"2-4"vs"5+"is not how Russian, Polish, or Arabic work. UsePluralRuleeven when you think your app only targets English — the discipline pays off the first time a translator files a bug. - Do not forget the
otherform.select(category)falls back toother; an emptyotherproduces an empty string, which renders as a baffling blank line. - Do not treat the fallback chain as an ordering hint. It is a specification. The order is load-bearing for correctness, not a preference.
See also
- Bidi / RTL support — how RTL locales render
- Accessibility tree — localized
name/descriptionpropagate through the same tree - Focus graph — focus indicators read localized labels
- Text — bidi · Widgets — focus · Demo showcase overview