Back to FAQ Features

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-aware
item-count = { $count ->
[one] 1 item
*[other] { $count } items
}
# Variable interpolation
welcome = 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 lookup
crate::i18n::localize("home_title", None)
// With variables
use 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

  1. Copy the English file as a starting point:
Terminal window
mkdir -p i18n/ja
cp i18n/en/gpui-starter.ftl i18n/ja/gpui-starter.ftl
  1. Translate all 68 message keys in the new .ftl file. Do not change the key names, only the values.

  2. Add a language selector button in src/views/settings.rs for the new locale.

  3. Rebuild. The es_fluent_language macro 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