Internationalization (i18n)
The cgd1-rs-controller GTK 4 application supports UI translation via the Fluent localization system. Translation files (.ftl) are embedded into the binary at compile time using rust-embed, so no external files are needed at runtime.
Supported Languages
| Language | Code | Status |
|---|---|---|
| English | en | Fallback (all keys present) |
| German | de | Complete |
The application auto-detects the system language at startup via i18n_embed::DesktopLanguageRequester. If the requested language is not available, it falls back to English.
Selecting a Language
The language is determined by the system locale. You can override it via environment variables:
# English
LANG=en ./target/debug/cgd1-controller
# German
LANG=de_DE.UTF-8 ./target/debug/cgd1-controller
# Unsupported locale -> falls back to English
LANG=fr_FR.UTF-8 ./target/debug/cgd1-controller
Architecture
File Layout
cgd1-rs-controller/
├── i18n.toml # i18n-embed configuration
├── i18n/
│ ├── en/
│ │ └── cgd1-rs-controller.ftl # English (fallback)
│ └── de/
│ └── cgd1-rs-controller.ftl # German
└── src/
└── i18n.rs # Loader, macro, helpers
The .ftl filename must match the crate name (cgd1-rs-controller) — this is how the fl! macro resolves the domain at compile time.
Core Module (src/i18n.rs)
The i18n module provides:
fl!macro — Compile-time-validated translation lookup. Missing keys cause build failures.get()— Runtime lookup for dynamic keys (e.g. weekday names selected by index).get_str()/get_int()— Runtime lookup with a single string/integer argument.init()— Forces lazy initialization of the language loader. Called early inmain().
Initialization
In src/main.rs:
mod i18n;
fn main() {
// ... tracing setup ...
i18n::init();
let app = app::ClockControllerApp::new(cli.backend);
app.run();
}
Adding a New Language
- Create a new directory:
cgd1-rs-controller/i18n/{lang}/(e.g.fr/). - Copy
cgd1-rs-controller/i18n/en/cgd1-rs-controller.ftlto the new directory. - Translate all values to the target language.
- Rebuild — the new language is automatically embedded via
rust-embedand selected when the system locale matches.
No changes to i18n.toml or source code are needed. The rust-embed macro scans the i18n/ directory at compile time and includes all subdirectories.
Translation Key Conventions
- Use
kebab-casefor message IDs (e.g.status-no-device-connected). - Arguments use
{ $name }syntax in.ftlfiles. - Group keys by feature area with comment headers (e.g.
# Alarm editor,# Audio editor). - Shared keys (e.g.
status-no-device-connected,button-read) are defined once in the “Common status messages” section and reused across dialogs.
Using the fl! Macro (Contributors)
Static strings
For strings with known keys at compile time:
#![allow(unused)]
fn main() {
use crate::fl;
let label = Label::builder().label(&fl!("status-disconnected")).build();
}
Strings with arguments
#![allow(unused)]
fn main() {
status.set_label(&fl!("status-connected", addr = addr.to_string()));
}
Cast expressions must be parenthesized:
#![allow(unused)]
fn main() {
// Correct
status.set_label(&fl!("status-devices-found", count = (found.len() as i64)));
// Wrong — won't compile
status.set_label(&fl!("status-devices-found", count = found.len() as i64));
}
Dynamic lookups
For runtime-determined keys (e.g. weekday names looked up by index), use get():
#![allow(unused)]
fn main() {
use crate::i18n::get;
let key = match weekday {
Mon => "weekday-mon",
Tue => "weekday-tue",
// ...
};
let day_name = get(key);
}
What not to translate
- Log messages (
tracing::debug!,warn!,info!,error!) — developer-facing. - Error strings in
ClockError::Parse("device not found")— internal error handling. - Ringtone names from
RingtoneSignature::name()— device firmware identifiers. - Timezone labels in
common.rs— technical identifiers (UTC offsets). - Sensor units (
°C,%) — universal. - CLI help text — could be translated in a future phase using
clap’s i18n support.