How do I add multi-language support and i18n to a Rust desktop app?
gpui-starter uses Mozilla's Fluent system via the es-fluent crate for type-safe, plural-aware translations with runtime language switching.
gpui-starter ships with a dual i18n setup: es-fluent handles Fluent-based localization with compile-time message validation, and rust-i18n manages runtime locale switching. English (en) and Simplified Chinese (zh-CN) are included out of the box.
Why Fluent, not a simple HashMap
Most i18n libraries treat translations as key-value pairs: "greeting" = "Hello". That works for English. It breaks fast for languages with grammatical gender, plural rules, or case agreement. Mozilla’s Fluent format handles all of those natively:
# Plural-awareitem-count = { $count -> [one] 1 item *[other] { $count } items}
# Variable interpolationwelcome = Welcome, { $name }!
# Gender agreement (e.g., for Slavic languages)greeting = { $gender -> [masculine] Witaj, { $name }! [feminine] Witaj, { $name }! *[other] Witaj, { $name }!}The * marks the default selector branch. Fluent resolves the correct variant at runtime based on locale plural rules defined by CLDR.
Translation file structure
Strings live in .ftl files organized by locale:
i18n/├── en/│ └── gpui-starter.ftl # English (68 messages)└── zh-CN/ └── gpui-starter.ftl # Simplified Chinese (68 messages)Both files must define the same message keys. If a key is missing in a locale file, the build fails with a compile-time error from es-fluent rather than showing a blank string at runtime.
Using translations in views
The localize() helper from src/i18n.rs resolves messages to the current locale:
// Simple lookupcrate::i18n::localize("home_title", None)
// With variablesuse std::collections::HashMap;let mut args = HashMap::new();args.insert("name", FluentValue::from("GPUI"));crate::i18n::localize("greeting", Some(&args));For form validation errors specifically, use localize_message() which consumes typed message variants generated by the EsFluentVariants macro. See the forms documentation for the full setup.
Runtime language switching
Languages switch without restarting the app. Select a new locale through the settings view or the command launcher (Cmd+K). The change propagates immediately because es-fluent resolves messages on every call rather than caching them at startup.
The supported locales are defined by the Languages enum in src/app.rs, which the es_fluent_language macro auto-populates from the i18n/ directory:
#[es_fluent_language]#[derive(Clone, Copy, Debug, EnumIter, EsFluent, PartialEq)]pub enum Languages {}Adding a new language
- Copy the English file as a starting point:
mkdir -p i18n/jacp i18n/en/gpui-starter.ftl i18n/ja/gpui-starter.ftl-
Translate all 68 message keys in the new
.ftlfile. Do not change the key names, only the values. -
Add a language selector button in
src/views/settings.rsfor the new locale. -
Rebuild. The
es_fluent_languagemacro discovers the new directory automatically, so no enum changes are needed.
The full step-by-step walkthrough is in the i18n documentation.
How this fits into the app architecture
The i18n layer sits between the view code and the domain logic. Views call localize() at render time with the current locale context. Domain types never touch translations directly. Mixing display concerns into domain logic makes testing harder and prevents reuse across different UI configurations, so they stay separate. The architecture guide covers this layer separation in detail.
Further reading
- The state management patterns post explains how locale state integrates with GPUI’s entity system.
- The scaling GPUI to production post covers i18n in a larger app architecture.
- If you are coming from Electron, the Electron to Rust migration guide covers replacing
i18nextwith Fluent.