Skip to Content

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 String

Three separable concerns:

  1. Catalog lookup — find the best entry for (locale, key) using a fallback chain.
  2. Plural selection — if the entry is a PluralForms, pick the right form for the count.
  3. 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:

RuleLanguages (subset)CategoriesExample behavior
Englishen, de, nl, sv, da, no/nb/nn, it, es, pt, el, hu, fi, et, he, tr, bgone (n=1), other”1 item”, “5 items”
Frenchfr, hi, bnone (n ∈ 1), other”0 élément”, “2 éléments”
Russianru, uk, hr, sr, bsone, few, many, other by mod-10 / mod-1001 файл · 3 файла · 5 файлов
PolishplSimilar to Russian with different many boundary1 plik · 2 pliki · 5 plików
Arabicarzero, one, two, few, many, otherFull six-category set
CJKzh, ja, ko, th, vi, id, msAlways otherNo count-based variation
CustomUser-supplied fn(i64) -> PluralCategoryEscape 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 rule

PluralRule::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):

TagNameNativeDirection
enEnglishEnglishLTR
esSpanishEspañolLTR
frFrenchFrançaisLTR
ruRussianРусскийLTR
arArabicالعربيةRTL
jaJapanese日本語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:

  1. Overview — locale picker, greeting lookup, plural demo.
  2. 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.
  3. RTL Layout — Arabic test cases flowing right-to-left, including mixed LTR/RTL paragraphs. See Bidi / RTL support for the underlying algorithm.
  4. Stress Lab — complex graphemes, combining marks, CJK widths, ZWJ emoji sequences. Uses the ftui-text grapheme 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. 1 vs "2-4" vs "5+" is not how Russian, Polish, or Arabic work. Use PluralRule even when you think your app only targets English — the discipline pays off the first time a translator files a bug.
  • Do not forget the other form. select(category) falls back to other; an empty other produces 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