Internationalization (i18n)
Overview
Section titled “Overview”gpui-starter ships with bilingual support out of the box: English (en) as the default and Simplified Chinese (zh-CN). The i18n layer is built on two crates:
es-fluent provides compile-time message validation and the localize() / localize_message() API you call from views. es-fluent-manager-embedded bundles .ftl files into the binary at compile time so you do not need to ship locale files alongside the app.
Every user-visible string in the app, from page titles to form validation errors, is routed through this system. If you are new to the project, start with the getting started guide to get the app running first.
Supported languages
Section titled “Supported languages”| Code | Language |
|---|---|
en | English (default) |
zh-CN | Simplified Chinese (简体中文) |
The app detects the system locale on launch via the sys-locale crate and falls back to en when detection fails. You can see the detection logic in src/services/i18n.rs:
pub fn detect_system_locale() -> String { sys_locale::get_locale().unwrap_or_else(|| "en".to_string())}Users can also switch languages at runtime from the Settings page.
Fluent message files
Section titled “Fluent message files”All translatable strings live in .ftl (Fluent) files under i18n/<locale>/gpui-starter.ftl. The English and Chinese files each contain 80 lines covering page titles, settings labels, form fields, and notification text.
i18n/├── en/│ └── gpui-starter.ftl # English (80 lines)└── zh-CN/ └── gpui-starter.ftl # Simplified Chinese (80 lines)A sample from i18n/en/gpui-starter.ftl:
## HomePage
home_title = Welcome to GPUI Starterhome_subtitle = A boilerplate for building desktop apps with GPUIhome_get_started = Get Started
## SettingsPage
settings_title = Settingssettings_dark_mode = Dark Modesettings_language = Languagesettings_language_english = Englishsettings_language_simplified_chinese = 简体中文The matching entries in i18n/zh-CN/gpui-starter.ftl:
## HomePage
home_title = 欢迎使用 GPUI Starterhome_subtitle = 使用 GPUI 构建桌面应用的模板home_get_started = 开始使用
## SettingsPage
settings_title = 设置settings_dark_mode = 暗色模式settings_language = 语言settings_language_english = Englishsettings_language_simplified_chinese = 简体中文Messages are grouped by feature section (## HomePage, ## SettingsPage, ## FormPage, etc.) to keep the file scannable.
Using translations in views
Section titled “Using translations in views”Call the localize() function from src/services/i18n.rs to look up a string by its message ID:
// Simple messagecrate::services::i18n::localize("home_title", None)// → "Welcome to GPUI Starter"
// Message with variablesuse std::collections::HashMap;use es_fluent::FluentValue;
let mut args = HashMap::new();args.insert("name", FluentValue::from("GPUI"));crate::services::i18n::localize("welcome", Some(&args));// → "Welcome, GPUI!"When a message ID is not found, localize() returns the ID string itself as a fallback. This means a missing translation will not crash the app; you will see the raw key in the UI instead.
For typed message variants (used by form validation labels and descriptions), use localize_message():
use crate::features::pages::form_page::RegistrationFormLabelVariants;
crate::services::i18n::localize_message( &RegistrationFormLabelVariants::Email)// → "Email" (or "邮箱" in zh-CN)This is how form validation errors resolve to the current locale automatically.
Fluent message format
Section titled “Fluent message format”Fluent supports variables, plurals, and selectors. Here are the patterns used in gpui-starter:
# Simple stringhome_title = Welcome to GPUI Starter
# Variable interpolationwelcome = Welcome, { $name }!
# Plural formsitem-count = { $count -> [one] 1 item *[other] { $count } items}Place variables inside { $var }. The * marks the default plural category. Refer to the Fluent syntax guide for the full spec.
Adding a new language
Section titled “Adding a new language”Adding Japanese (ja) as a third language takes four steps.
1. Copy and translate the .ftl file
Section titled “1. Copy and translate the .ftl file”mkdir -p i18n/jacp i18n/en/gpui-starter.ftl i18n/ja/gpui-starter.ftlTranslate every message value on the right side of the = sign. Keep the message IDs (left side) exactly the same.
2. Register the language variant
Section titled “2. Register the language variant”The Languages enum in src/app/mod.rs uses the #[es_fluent_language] attribute macro, which auto-discovers locale directories under i18n/ at compile time. No enum variants to add manually:
#[es_fluent_language]#[derive(Clone, Copy, Debug, EnumIter, EsFluent, PartialEq)]pub enum Languages {}After creating the i18n/ja/ directory, rebuild and the macro picks it up.
3. Add a language toggle button
Section titled “3. Add a language toggle button”Open src/features/pages/settings.rs and add a button for the new language alongside the existing English and Chinese buttons:
.label(crate::services::i18n::localize("settings_language_japanese", None))Add the corresponding .ftl entries:
settings_language_japanese = Japanese
# ja/gpui-starter.ftlsettings_language_japanese = 日本語4. Rebuild and verify
Section titled “4. Rebuild and verify”cargo runSwitch to the new language in Settings and check that every view renders translated text. If a message is missing, the raw ID appears in the UI as a visual cue.
Form validation messages
Section titled “Form validation messages”Form validation errors go through the same Fluent pipeline. The EsFluentVariants and KorumaAllFluent derive macros generate typed message variants that resolve to the current locale at runtime:
#[derive(EsFluentVariants, GpuiForm, Koruma, KorumaAllFluent)]pub struct RegistrationForm { #[koruma(NonEmptyValidation::<_>::builder())] pub name: String, #[koruma(EmailValidation::<_>::builder())] pub email: String, // ...}The generated label variants (RegistrationFormLabelVariants::Name, RegistrationFormLabelVariants::Email, etc.) map directly to .ftl entries like registration_form_label_variants-name and registration_form_label_variants-email. Validation error messages (“Enter your full name.”, “Please enter a valid email address.”) are also looked up from the current locale’s .ftl file. See the forms documentation for the full form setup.
Testing translations
Section titled “Testing translations”When writing tests that exercise views with localized text, call init_i18n() with a specific locale before assertions to get deterministic output:
use crate::services::i18n;
#[gpui::test]fn test_home_title_in_chinese(cx: &mut App) { let lang = "zh-CN".parse().unwrap(); i18n::init_i18n(lang).unwrap();
let title = i18n::localize("home_title", None); assert_eq!(title, "欢迎使用 GPUI Starter");}This avoids depending on the system locale in CI environments.
How the i18n service works
Section titled “How the i18n service works”The src/services/i18n.rs module initializes once at app startup and stores the EmbeddedI18n instance in a OnceLock:
static I18N: OnceLock<EmbeddedI18n> = OnceLock::new();
pub fn init_i18n(lang: LanguageIdentifier) -> Result<(), I18nError> { EmbeddedI18n::try_new_with_language(lang) .map_err(|e| I18nError::InitFailed(Box::new(e))) .map(|i18n| { let _ = I18N.set(i18n); })}The define_i18n_module!() macro embeds all .ftl files from the i18n/ directory into the binary at compile time. No runtime file I/O needed for translations. If init_i18n() is never called, i18n() falls back to a default EmbeddedI18n instance with a logged warning.