Files
keydr/docs/plans/2026-03-08-internationalize-ui-text.md
Tyler Hallada 6d5de33f55 Internationalize UI text w/ german as first second lang
Adds rust-i18n and refactors all of the text copy in the app to use the
translation function so that the UI language can be dynamically updated
in the settings.
2026-03-17 04:29:25 +00:00

218 lines
12 KiB
Markdown

# Plan: Internationalize UI Text
## Context
keydr supports 21 languages for dictionaries and keyboard layouts, but all UI text is hardcoded English (~200 strings across inline literals, `format!()` templates, `const` arrays, `Display` impls, and prebuilt state like milestone messages). This plan translates all app-owned UI copy via a separate "UI Language" config setting. Source texts from code/passage drills remain untranslated. Nested error details from system/library errors (e.g. IO errors, serde errors) embedded in status messages remain in their original form — only the app-owned wrapper text around them is translated.
This initial change ships **English + German only**. Remaining languages will follow in a separate commit.
## Design Decisions
**Library: `rust-i18n` v3**
- `t!("key")` / `t!("key", var = val)` macro API
- Translations in YAML, compiled into binary — no runtime file loading
**Separate UI language setting:** `ui_language` config field independent of `dictionary_language`. Defaults to `"en"`.
**Separate supported locale list:** UI locale validation uses `SUPPORTED_UI_LOCALES` (initially `["en", "de"]`), decoupled from the dictionary language pack system.
**Language names as autonyms everywhere:** All places that display a language name (selectors, settings summaries, status messages) use the language's autonym ("Deutsch", "Français") via a new `autonym` field on `LanguagePack`. No exonyms or locale-translated language names. Tradeoff: users may not recognize unfamiliar languages by autonym alone (e.g. "Suomi" for Finnish), but this is consistent and avoids translating language names per-locale. The existing English `display_name` field remains available as context.
**Stale text on locale switch:** Already-rendered `StatusMessage.text` and open `KeyMilestonePopup` messages stay in the old language until dismissed. Only newly produced text uses the new locale.
**Domain errors stay UI-agnostic:** `LanguageLayoutValidationError::Display` keeps its current English implementation. Translation happens at the UI boundary via a helper function in the i18n module.
**Canonical import:** All files use `use crate::i18n::t;` as the single import style for the translation macro.
## Text Source Categories
| Category | Example Location | Strategy |
|----------|-----------------|----------|
| **Inline literals** | Render functions in `main.rs`, UI components | Replace with `t!()` |
| **`const` arrays** | `UNLOCK_MESSAGES`, `MASTERY_MESSAGES`, `TAB_LABELS`, `FOOTER_HINTS_*` | Convert to functions returning `Vec<String>` or build inline |
| **`format!()` templates** | `StatusMessage` construction in `app.rs`/`main.rs` | Replace template with `t!("key", var = val)` |
| **`Display` impls** | `LanguageLayoutValidationError` | Keep `Display` stable; translate at UI boundary in i18n module |
| **Domain display names** | `LanguagePack.display_name` | Add `autonym` field; code language names stay English ("Rust", "Python") |
| **Cached `'static` fields** | `KeyMilestonePopup.message: &'static str` | Change to `String` |
## Implementation Steps
### Step 1: Centralized i18n module and dependency setup
Add `rust-i18n = "3"` to `[dependencies]` and `serde_yaml = "0.9"` to `[dev-dependencies]` in `Cargo.toml`.
Create `src/i18n.rs`:
```rust
pub use rust_i18n::t;
rust_i18n::i18n!("locales", fallback = "en");
/// Available UI locale codes. Separate from dictionary language support.
pub const SUPPORTED_UI_LOCALES: &[&str] = &["en", "de"];
pub fn set_ui_locale(locale: &str) {
let effective = if SUPPORTED_UI_LOCALES.contains(&locale) { locale } else { "en" };
rust_i18n::set_locale(effective);
}
/// Translate a LanguageLayoutValidationError for display in the UI.
pub fn localized_language_layout_error(err: &crate::l10n::language_pack::LanguageLayoutValidationError) -> String {
use crate::l10n::language_pack::LanguageLayoutValidationError::*;
match err {
UnknownLanguage(key) => t!("errors.unknown_language", key = key),
UnknownLayout(key) => t!("errors.unknown_layout", key = key),
UnsupportedLanguageLayoutPair { language_key, layout_key } =>
t!("errors.unsupported_pair", language = language_key, layout = layout_key),
LanguageBlockedBySupportLevel(key) =>
t!("errors.language_blocked", key = key),
}
}
```
**Crate root ownership — which targets compile translated modules:**
| Target | Declares `mod i18n` | Compiles modules that call `t!()` | Must call `set_ui_locale()` |
|--------|---------------------|-----------------------------------|----------------------------|
| `src/main.rs` (binary) | Yes | Yes (`app.rs`, UI components, `main.rs` itself) | Yes, at startup |
| `src/lib.rs` (library) | Yes | Yes (`app.rs` is in the lib module tree) | No — lib is for benchmarks/test profiles; locale defaults to English via `fallback = "en"` |
| `src/bin/generate_test_profiles.rs` | No | No — imports from `keydr::` lib but only uses data types, not UI/translated code | No |
**Invariant:** Any module that calls `t!()` must be in a crate whose root declares `mod i18n;` with the `i18n!()` macro. If a future change adds `t!()` calls to a module reachable from `generate_test_profiles`, that binary must also add `mod i18n;`. The `fallback = "en"` default ensures English output when `set_ui_locale()` is never called.
Create `locales/en.yml` with initial structure, verify `cargo build`.
### Step 2: Add `ui_language` config field
**`src/config.rs`:**
- Add `ui_language: String` with `#[serde(default = "default_ui_language")]`, default `"en"`
- Add `normalize_ui_language()` — validates against `i18n::SUPPORTED_UI_LOCALES`, resets to `"en"` if unsupported
- Add to `Default` impl and `validate()`
**`src/app.rs`:**
- Add `UiLanguageSelect` variant to `AppScreen`
- Change `KeyMilestonePopup.message` from `&'static str` to `String`
**`src/main.rs`:**
- Call `i18n::set_ui_locale(&app.config.ui_language)` after `App::new()`
- Add "UI Language" setting item in settings menu (before "Dictionary Language")
- Add `UiLanguageSelect` screen reusing language selection list pattern (filtered to `SUPPORTED_UI_LOCALES`)
- On selection: update `config.ui_language`, call `i18n::set_ui_locale()`
- After data import: call `i18n::set_ui_locale()` again
### Step 3: Add autonym field to LanguagePack
**`src/l10n/language_pack.rs`:**
- Add `autonym: &'static str` field to `LanguagePack`
- Populate for all 21 languages: "English", "Deutsch", "Español", "Français", "Italiano", "Português", "Nederlands", "Svenska", "Dansk", "Norsk bokmål", "Suomi", "Polski", "Čeština", "Română", "Hrvatski", "Magyar", "Lietuvių", "Latviešu", "Slovenščina", "Eesti", "Türkçe"
**Update all language name display sites in `main.rs`:**
- Dictionary language selector: show `pack.autonym` instead of `pack.display_name`
- UI language selector: show `pack.autonym`
- Settings value display for dictionary language: show `pack.autonym`
- Status messages mentioning languages (e.g. "Switched to {}"): use `pack.autonym`
### Step 4: Create English base translation file (`locales/en.yml`)
Populate all ~200 keys organized by component:
```yaml
en:
menu: # menu items, descriptions, subtitle
drill: # mode headers, footers, focus labels
dashboard: # results screen labels, hints
sidebar: # stats sidebar labels
settings: # setting names, toggle values, buttons
status: # import/export/error messages (format templates)
skill_tree: # status labels, hints, notices
milestones: # unlock/mastery messages, congratulations
stats: # tab names, chart titles, hints, empty states
heatmap: # month/day abbreviations, title
keyboard: # explorer labels, detail fields
intro: # passage/code download setup dialogs
dialogs: # confirmation dialogs
errors: # validation error messages (for UI boundary translation)
common: # WPM, CPM, Back, etc.
```
### Step 5: Convert source files to use `t!()` — vertical slice first
**Phase A — Vertical slice (one file per text category to establish patterns):**
1. `src/ui/components/menu.rs` — inline literals (9 strings)
2. `src/ui/components/stats_dashboard.rs` — inline literals + `const` arrays → functions
3. `src/app.rs``StatusMessage` format templates (~20 strings), `UNLOCK_MESSAGES`/`MASTERY_MESSAGES` → functions
4. Update `StatusMessage` creation sites in `main.rs` that reference `LanguageLayoutValidationError` to use `i18n::localized_language_layout_error()` instead of `err.to_string()`
**Phase B — Remaining components:**
5. `src/ui/components/chart.rs` (3 strings)
6. `src/ui/components/activity_heatmap.rs` (14 strings)
7. `src/ui/components/stats_sidebar.rs` (10 strings)
8. `src/ui/components/dashboard.rs` (12 strings)
9. `src/ui/components/skill_tree.rs` (15 strings)
**Phase C — main.rs (largest):**
10. `src/main.rs` (~120+ strings) — settings menu, drill rendering, milestone overlay rendering, keyboard explorer, intro dialogs, footer hints, status messages
**Key patterns:**
- `use crate::i18n::t;` in every file that needs translation
- `t!()` returns `String`; for `&str` contexts: `let label = t!("key"); &label`
- Footer hints like `"[ESC] Back"` — full string in YAML, translators preserve bracket keys: `"[ESC] Zurück"`
- `const` arrays → functions: e.g. `fn unlock_messages() -> Vec<String>`
- `StatusMessage.text` built via `t!()` at creation time
### Step 6: Create German translation file (`locales/de.yml`)
AI-generated translation of all keys from `en.yml`:
- Keep `%{var}` placeholders unchanged
- Keep key names inside `[brackets]` unchanged (`[ESC]`, `[Enter]`, `[Tab]`, etc.)
- Keep technical terms WPM/CPM untranslated
- Be concise — German text tends to run ~20-30% longer; keep terminal width in mind
### Step 7: Tests and validation
- Add `rust_i18n::set_locale("en")` in test setup where tests assert against English output
- Add a test that sets locale to `"de"` and verifies a rendered component uses German text
- Add a test that switches locale mid-run and verifies new `StatusMessage` text uses the new locale
- **Add a catalog parity test** (using `serde_yaml` dev-dependency): parse both `locales/en.yml` and `locales/de.yml` as `serde_yaml::Value`, recursively walk the key trees, verify every key in `en.yml` exists in `de.yml` and vice versa, and that `%{var}` placeholders in each value string match between corresponding entries
- Run `cargo test` and `cargo build`
## Files Modified
| File | Scope |
|------|-------|
| `Cargo.toml` | Add `rust-i18n = "3"`, `serde_yaml = "0.9"` (dev) |
| `src/config.rs` | Add `ui_language` field, default, validation |
| `src/lib.rs` | Add `mod i18n;` |
| `src/main.rs` | Add `mod i18n;`, `set_ui_locale()` calls, UI Language setting/select screen, ~120 string replacements, use `localized_language_layout_error()` |
| `src/app.rs` | Add `UiLanguageSelect` to `AppScreen`, `KeyMilestonePopup.message``String`, ~20 StatusMessage string replacements, convert milestone constants to functions |
| `src/l10n/language_pack.rs` | Add `autonym` field to `LanguagePack` |
| `src/ui/components/menu.rs` | 9 string replacements |
| `src/ui/components/dashboard.rs` | 12 string replacements |
| `src/ui/components/stats_dashboard.rs` | 25 string replacements, refactor `const` arrays to functions |
| `src/ui/components/skill_tree.rs` | 15 string replacements |
| `src/ui/components/stats_sidebar.rs` | 10 string replacements |
| `src/ui/components/activity_heatmap.rs` | 14 string replacements |
| `src/ui/components/chart.rs` | 3 string replacements |
## Files Created
| File | Content |
|------|---------|
| `src/i18n.rs` | Centralized i18n bootstrap, `SUPPORTED_UI_LOCALES`, `set_ui_locale()`, `localized_language_layout_error()` |
| `locales/en.yml` | English base translations (~200 keys) |
| `locales/de.yml` | German translations |
## Verification
1. `cargo build` — rust-i18n checks referenced keys at compile time (not a complete catalog correctness guarantee; parity test and manual checks cover the rest)
2. `cargo test` — including catalog parity test + locale-specific tests
3. Manual testing with UI set to English: navigate all screens, verify identical behavior to pre-i18n
4. Manual testing with UI set to German: navigate all screens, verify German text
5. Verify drill source text (passage/code content) is NOT translated
6. Verify language selectors show autonyms ("Deutsch", not "German")
7. Test locale switch: change UI language in settings, verify new text appears in new language, existing status banner stays in old language
8. Check for layout/truncation issues with German text