Skip to content

Internationalization (i18n)

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.

CodeLanguage
enEnglish (default)
zh-CNSimplified 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.

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 Starter
home_subtitle = A boilerplate for building desktop apps with GPUI
home_get_started = Get Started
## SettingsPage
settings_title = Settings
settings_dark_mode = Dark Mode
settings_language = Language
settings_language_english = English
settings_language_simplified_chinese = 简体中文

The matching entries in i18n/zh-CN/gpui-starter.ftl:

## HomePage
home_title = 欢迎使用 GPUI Starter
home_subtitle = 使用 GPUI 构建桌面应用的模板
home_get_started = 开始使用
## SettingsPage
settings_title = 设置
settings_dark_mode = 暗色模式
settings_language = 语言
settings_language_english = English
settings_language_simplified_chinese = 简体中文

Messages are grouped by feature section (## HomePage, ## SettingsPage, ## FormPage, etc.) to keep the file scannable.

Call the localize() function from src/services/i18n.rs to look up a string by its message ID:

// Simple message
crate::services::i18n::localize("home_title", None)
// → "Welcome to GPUI Starter"
// Message with variables
use 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 supports variables, plurals, and selectors. Here are the patterns used in gpui-starter:

# Simple string
home_title = Welcome to GPUI Starter
# Variable interpolation
welcome = Welcome, { $name }!
# Plural forms
item-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 Japanese (ja) as a third language takes four steps.

Terminal window
mkdir -p i18n/ja
cp i18n/en/gpui-starter.ftl i18n/ja/gpui-starter.ftl

Translate every message value on the right side of the = sign. Keep the message IDs (left side) exactly the same.

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.

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:

en/gpui-starter.ftl
settings_language_japanese = Japanese
# ja/gpui-starter.ftl
settings_language_japanese = 日本語
Terminal window
cargo run

Switch 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 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.

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.

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.