diff --git a/Cargo.lock b/Cargo.lock index 9c845d7..67e749b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -88,6 +88,15 @@ version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" +[[package]] +name = "arc-swap" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f3647c145568cec02c42054e07bdf9a5a698e15b466fb2341bfc393cd24aa5" +dependencies = [ + "rustversion", +] + [[package]] name = "atomic" version = "0.6.1" @@ -109,6 +118,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "base62" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1adf9755786e27479693dedd3271691a92b5e242ab139cacb9fb8e7fb5381111" + [[package]] name = "base64" version = "0.22.1" @@ -151,6 +166,16 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "bumpalo" version = "3.19.1" @@ -797,6 +822,36 @@ dependencies = [ "wasip2", ] +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "globwalk" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93e3af942408868f6934a7b85134a3230832b9977cf66125df2f9edcfce4ddcc" +dependencies = [ + "bitflags 1.3.2", + "ignore", + "walkdir", +] + [[package]] name = "h2" version = "0.4.13" @@ -1106,6 +1161,22 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "ignore" +version = "0.4.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + [[package]] name = "indexmap" version = "2.13.0" @@ -1180,6 +1251,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.14.0" @@ -1229,10 +1309,13 @@ dependencies = [ "icu_normalizer", "rand", "ratatui", + "regex", "reqwest", "rust-embed", + "rust-i18n", "serde", "serde_json", + "serde_yaml", "tempfile", "thiserror 2.0.18", "toml", @@ -1418,6 +1501,15 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "normpath" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf23ab2b905654b4cb177e30b629937b3868311d4e1cba859f899c041046e69b" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "num-conv" version = "0.2.0" @@ -2029,6 +2121,60 @@ dependencies = [ "walkdir", ] +[[package]] +name = "rust-i18n" +version = "3.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fda2551fdfaf6cc5ee283adc15e157047b92ae6535cf80f6d4962d05717dc332" +dependencies = [ + "globwalk", + "once_cell", + "regex", + "rust-i18n-macro", + "rust-i18n-support", + "smallvec", +] + +[[package]] +name = "rust-i18n-macro" +version = "3.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22baf7d7f56656d23ebe24f6bb57a5d40d2bce2a5f1c503e692b5b2fa450f965" +dependencies = [ + "glob", + "once_cell", + "proc-macro2", + "quote", + "rust-i18n-support", + "serde", + "serde_json", + "serde_yaml", + "syn 2.0.114", +] + +[[package]] +name = "rust-i18n-support" +version = "3.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940ed4f52bba4c0152056d771e563b7133ad9607d4384af016a134b58d758f19" +dependencies = [ + "arc-swap", + "base62", + "globwalk", + "itertools 0.11.0", + "lazy_static", + "normpath", + "once_cell", + "proc-macro2", + "regex", + "serde", + "serde_json", + "serde_yaml", + "siphasher", + "toml", + "triomphe", +] + [[package]] name = "rustc_version" version = "0.4.1" @@ -2226,6 +2372,19 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "sha2" version = "0.10.9" @@ -2719,6 +2878,17 @@ dependencies = [ "once_cell", ] +[[package]] +name = "triomphe" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd69c5aa8f924c7519d6372789a74eac5b94fb0f8fcf0d4a97eb0bfc3e785f39" +dependencies = [ + "arc-swap", + "serde", + "stable_deref_trait", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -2766,6 +2936,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.9.0" diff --git a/Cargo.toml b/Cargo.toml index 92ff034..57a43ea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,9 +20,12 @@ anyhow = "1.0" thiserror = "2.0" reqwest = { version = "0.12", features = ["blocking"], optional = true } icu_normalizer = { version = "2.1", default-features = false, features = ["compiled_data"] } +rust-i18n = "3" [dev-dependencies] tempfile = "3" +serde_yaml = "0.9" +regex = "1" criterion = { version = "0.5", features = ["html_reports"] } [[bench]] diff --git a/docs/plans/2026-03-08-internationalize-ui-text.md b/docs/plans/2026-03-08-internationalize-ui-text.md new file mode 100644 index 0000000..764fb67 --- /dev/null +++ b/docs/plans/2026-03-08-internationalize-ui-text.md @@ -0,0 +1,217 @@ +# 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` 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` +- `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 diff --git a/docs/plans/2026-03-17-fix-remaining-untranslated-strings.md b/docs/plans/2026-03-17-fix-remaining-untranslated-strings.md new file mode 100644 index 0000000..b71d75e --- /dev/null +++ b/docs/plans/2026-03-17-fix-remaining-untranslated-strings.md @@ -0,0 +1,117 @@ +# Plan: Fix Remaining Untranslated UI Strings + +## Context + +The i18n system is implemented but several categories of strings were missed: +1. Menu item labels/descriptions are cached as `String` at construction and never refreshed when locale changes +2. Skill tree branch names and level names are hardcoded `&'static str` in `BranchDefinition`/`LevelDefinition` +3. Passage selector labels ("All (Built-in + all books)", "Built-in passages only", "Book: ...") are hardcoded +4. Branch progress list (`branch_progress_list.rs`) renders branch names and "Overall Key Progress" / "unlocked" / "mastered" in English + +## Fix 1: Menu Items — Translate at Render Time + +**Problem:** `Menu::new()` calls `t!()` once during `App::new()`. Even though `set_ui_locale()` runs after construction, the items are cached as `String` and never refreshed when the user changes UI language mid-session. + +**Fix:** Define a shared static item list (keys + translation keys) and build rendered strings from it in both `Widget::render()` and navigation code. + +**Files:** `src/ui/components/menu.rs` + +- Define a `const MENU_ITEMS` array of `(&str, &str, &str)` tuples: `(shortcut_key, label_i18n_key, desc_i18n_key)`. This is the single authoritative definition. +- Remove `MenuItem` struct and the `items: Vec` field. +- Keep `selected: usize` and `theme` fields. `next()`/`prev()` use `MENU_ITEMS.len()`. +- Add a `Menu::item_count() -> usize` helper returning `MENU_ITEMS.len()`. +- In `Widget::render()`, iterate `MENU_ITEMS` and call `t!()` for label/description each frame. +- Replace `app.menu.items.len()` in `src/main.rs` mouse handler (~line 660) with `Menu::item_count()`. + +## Fix 2: Skill Tree Branch and Level Names — Replace `name` with `name_key` + +**Problem:** `BranchDefinition.name` and `LevelDefinition.name` are `&'static str` with English text. They are used purely for UI display (confirmed: no serialization, logging, or export uses). + +**Fix:** Replace `name` with `name_key` on both structs. The `name_key` holds a translation key (e.g. `"skill_tree.branch_primary_letters"`). All display sites use `t!(def.name_key)`. + +Add `BranchDefinition::display_name()` and `LevelDefinition::display_name()` convenience methods that return `t!(self.name_key)` so call sites stay simple. + +Change `find_key_branch()` to return `(&'static BranchDefinition, &'static LevelDefinition, usize)` instead of `(&'static BranchDefinition, &'static str, usize)`. This gives callers access to the `LevelDefinition` and its `name_key` so they can localize the level name themselves. + +**Complete consumer inventory:** + +| File | Lines | Usage | +|------|-------|-------| +| `src/ui/components/skill_tree.rs` | ~366 | Branch name in branch list header | +| `src/ui/components/skill_tree.rs` | ~445 | Branch name in detail header | +| `src/ui/components/skill_tree.rs` | ~483 | Level name in detail level list | +| `src/ui/components/branch_progress_list.rs` | ~95 | Branch name in single-branch drill sidebar | +| `src/ui/components/branch_progress_list.rs` | ~188 | Branch name in multi-branch progress cells | +| `src/main.rs` | ~3931 | Branch name in "branches available" milestone | +| `src/main.rs` | ~3961 | Branch names in "branch complete" milestone text | +| `src/main.rs` | ~6993 | Branch name in unlock confirmation dialog | +| `src/main.rs` | ~7327 | Branch name + level name in keyboard detail panel (via `find_key_branch()`) | + +**Files:** +- `src/engine/skill_tree.rs` — Replace `name` with `name_key` on both structs; add `display_name()` methods; change `find_key_branch()` return type; populate `name_key` for all entries +- `src/ui/components/skill_tree.rs` — Use `def.display_name()` / `level.display_name()` at 3 sites +- `src/ui/components/branch_progress_list.rs` — Use `def.display_name()` at 2 sites; also translate "Overall Key Progress", "unlocked", "mastered" +- `src/main.rs` — Use `def.display_name()` at 4 sites; update `find_key_branch()` call site to use `level.display_name()` +- `locales/en.yml` — Add branch/level name keys under `skill_tree:` +- `locales/de.yml` — Add German translations + +Note on truncation: `branch_progress_list.rs` uses fixed-width formatting (`{:<14}`, truncation widths 10/12/14). German branch names that exceed these widths will be truncated. This is acceptable for now — the widget already handles this via `truncate_and_pad()`. Proper dynamic-width layout is a separate concern. + +Translation keys to add: +```yaml +skill_tree: + branch_primary_letters: 'Primary Letters' + branch_capital_letters: 'Capital Letters' + branch_numbers: 'Numbers 0-9' + branch_prose_punctuation: 'Prose Punctuation' + branch_whitespace: 'Whitespace' + branch_code_symbols: 'Code Symbols' + level_frequency_order: 'Frequency Order' + level_common_sentence_capitals: 'Common Sentence Capitals' + level_name_capitals: 'Name Capitals' + level_remaining_capitals: 'Remaining Capitals' + level_common_digits: 'Common Digits' + level_all_digits: 'All Digits' + level_essential: 'Essential' + level_common: 'Common' + level_expressive: 'Expressive' + level_enter_return: 'Enter/Return' + level_tab_indent: 'Tab/Indent' + level_arithmetic_assignment: 'Arithmetic & Assignment' + level_grouping: 'Grouping' + level_logic_reference: 'Logic & Reference' + level_special: 'Special' +``` + +Also add to `progress` section (translation values contain only text, no alignment whitespace — padding is applied in rendering code): +```yaml +progress: + overall_key_progress: 'Overall Key Progress' + unlocked_mastered: '%{unlocked}/%{total} unlocked (%{mastered} mastered)' +``` + +## Fix 3: Passage Book Selector Labels + +**Problem:** `passage_options()` returns hardcoded `"All (Built-in + all books)"`, `"Built-in passages only"`, and `"Book: {title}"`. + +**Fix:** Add `t!()` calls in `passage_options()`. Book titles (proper nouns like "Pride and Prejudice") stay untranslated per plan. + +**Files:** +- `src/generator/passage.rs` — Add `use crate::i18n::t;`, convert the two label strings and the "Book:" prefix +- `locales/en.yml` — Add keys under `select:`: + ```yaml + select: + passage_all: 'All (Built-in + all books)' + passage_builtin: 'Built-in passages only' + passage_book_prefix: 'Book: %{title}' + ``` +- `locales/de.yml` — German translations + +## Verification + +1. `cargo check` — must compile +2. `cargo test --lib i18n::tests` — catalog parity and placeholder parity tests catch missing keys +3. `cargo test --lib` — no new test failures +4. Add tests for the new translated surfaces. To avoid parallel-test races on global locale state, new tests use `t!("key", locale = "de")` directly on the translation keys rather than calling ambient-locale helpers like `display_name()` or `passage_options()`. This keeps tests deterministic without needing serial execution or locale-parameterized API variants. + - Test that `t!("skill_tree.branch_primary_letters", locale = "de")` returns the expected German text + - Test that `t!("select.passage_all", locale = "de")` returns the expected German text diff --git a/locales/de.yml b/locales/de.yml new file mode 100644 index 0000000..5540ae6 --- /dev/null +++ b/locales/de.yml @@ -0,0 +1,457 @@ +# Main menu +menu: + subtitle: 'Terminal-Tipptrainer' + adaptive_drill: 'Adaptive Lektion' + adaptive_drill_desc: 'Phonetische Woerter mit adaptiver Buchstabenfreischaltung' + code_drill: 'Code-Lektion' + code_drill_desc: 'Code-Syntax tippen ueben' + passage_drill: 'Textpassagen-Lektion' + passage_drill_desc: 'Passagen aus Buechern abtippen' + skill_tree: 'Faehigkeitenbaum' + skill_tree_desc: 'Fortschrittszweige ansehen und Lektionen starten' + keyboard: 'Tastatur' + keyboard_desc: 'Tastaturlayout und Tastenstatistiken erkunden' + statistics: 'Statistik' + statistics_desc: 'Tippstatistiken ansehen' + settings: 'Einstellungen' + settings_desc: 'keydr konfigurieren' + day_streak: ' | %{days} Tage Serie' + key_progress: ' Tastenfortschritt %{unlocked}/%{total} (%{mastered} gemeistert) | Ziel %{target} WPM%{streak}' + hint_start: '[1-3] Start' + hint_skill_tree: '[t] Faehigkeitenbaum' + hint_keyboard: '[b] Tastatur' + hint_stats: '[s] Statistik' + hint_settings: '[c] Einstellungen' + hint_quit: '[q] Beenden' + +# Drill screen +drill: + title: ' Lektion ' + mode_adaptive: 'Adaptiv' + mode_code: 'Code (ohne Wertung)' + mode_passage: 'Textpassage (ohne Wertung)' + focus_char: 'Fokus: ''%{ch}''' + focus_bigram: 'Fokus: "%{bigram}"' + focus_both: 'Fokus: ''%{ch}'' + "%{bigram}"' + header_wpm: 'WPM' + header_acc: 'Gen' + header_err: 'Feh' + code_source: ' Code-Quelle ' + passage_source: ' Textquelle ' + footer: '[ESC] Lektion beenden [Backspace] Loeschen' + keys_reenabled: 'Tasten nach %{ms}ms wieder aktiv' + hint_end: '[ESC] Lektion beenden' + hint_backspace: '[Backspace] Loeschen' + +# Dashboard / drill result +dashboard: + title: ' Lektion abgeschlossen ' + results: 'Ergebnisse' + unranked_note_prefix: ' (Ohne Wertung' + unranked_note_suffix: ' zaehlt nicht fuer den Faehigkeitenbaum)' + speed: ' Tempo: ' + accuracy_label: ' Genauigkeit: ' + time_label: ' Zeit: ' + errors_label: ' Fehler: ' + correct_detail: ' (%{correct}/%{total} korrekt)' + input_blocked: ' Eingabe voruebergehend blockiert ' + input_blocked_ms: '(%{ms}ms verbleibend)' + hint_continue: '[c/Enter/Space] Weiter' + hint_retry: '[r] Wiederholen' + hint_menu: '[q] Menue' + hint_stats: '[s] Statistik' + hint_delete: '[x] Loeschen' + +# Stats sidebar (during drill) +sidebar: + title: ' Statistik ' + wpm: 'WPM: ' + target: 'Ziel: ' + target_wpm: '%{wpm} WPM' + accuracy: 'Genauigkeit: ' + progress: 'Fortschritt: ' + correct: 'Korrekt: ' + errors: 'Fehler: ' + time: 'Zeit: ' + last_drill: ' Letzte Lektion ' + vs_avg: ' vs Schnitt: ' + +# Statistics dashboard +stats: + title: ' Statistik ' + empty: 'Noch keine Lektionen abgeschlossen. Fang an zu tippen!' + tab_dashboard: '[1] Dashboard' + tab_history: '[2] Verlauf' + tab_activity: '[3] Aktivitaet' + tab_accuracy: '[4] Genauigkeit' + tab_timing: '[5] Timing' + tab_ngrams: '[6] N-Gramme' + hint_back: '[ESC] Zurueck' + hint_next_tab: '[Tab] Naechster Tab' + hint_switch_tab: '[1-6] Tab wechseln' + hint_navigate: '[j/k] Navigieren' + hint_page: '[PgUp/PgDn] Seite' + hint_delete: '[x] Loeschen' + summary_title: ' Zusammenfassung ' + drills: ' Lektionen: ' + avg_wpm: ' Schnitt WPM: ' + best_wpm: ' Bestes WPM: ' + accuracy_label: ' Genauigkeit: ' + total_time: ' Gesamtzeit: ' + wpm_chart_title: ' WPM pro Lektion (Letzte 20, Ziel: %{target}) ' + accuracy_chart_title: ' Genauigkeit %% (Letzte 50 Lektionen) ' + chart_drill: 'Lektion #' + chart_accuracy_pct: 'Genauigkeit %%' + sessions_title: ' Letzte Sitzungen ' + session_header: ' # WPM Roh Gen%% Zeit Datum/Uhrzeit Modus Gewertet Teilw.' + session_separator: ' ─────────────────────────────────────────────────────────────────────' + delete_confirm: 'Sitzung #%{idx} loeschen? (y/n)' + confirm_title: ' Bestaetigen ' + yes: 'ja' + no: 'nein' + keyboard_accuracy_title: ' Tastatur-Genauigkeit %% ' + keyboard_timing_title: ' Tastatur-Timing (ms) ' + slowest_keys_title: ' Langsamste Tasten (ms) ' + fastest_keys_title: ' Schnellste Tasten (ms) ' + worst_accuracy_title: ' Schlechteste Genauigkeit (%%) ' + best_accuracy_title: ' Beste Genauigkeit (%%) ' + not_enough_data: ' Nicht genug Daten' + streaks_title: ' Serien ' + current_streak: ' Aktuell: ' + best_streak: ' Beste: ' + active_days: ' Aktive Tage: ' + top_days_none: ' Top-Tage: keine' + top_days: ' Top-Tage: %{days}' + wpm_label: ' WPM: %{avg}/%{target} (%{pct}%%)' + acc_label: ' Gen: %{pct}%%' + keys_label: ' Tasten: %{unlocked}/%{total} (%{mastered} gemeistert)' + ngram_empty: 'Schliesse einige adaptive Lektionen ab, um N-Gramm-Daten zu sehen' + ngram_header_speed_narrow: ' Bgrm Tempo Erw. Anom%' + ngram_header_error_narrow: ' Bgrm Feh Stp Rate Erw Anom%' + ngram_header_speed: ' Bigramm Tempo Erwartet Stichpr. Anom%' + ngram_header_error: ' Bigramm Fehler Stichpr. Rate Erwartet Anom%' + focus_title: ' Aktiver Fokus ' + focus_char_label: ' Fokus: ' + focus_bigram_value: 'Bigramm %{label}' + focus_plus: ' + ' + anomaly_error: 'Fehler' + anomaly_speed: 'Tempo' + focus_detail_both: ' Zeichen ''%{ch}'': schwaechste Taste | Bigramm %{label}: %{type}-Anomalie %{pct}%%' + focus_detail_char_only: ' Zeichen ''%{ch}'': schwaechste Taste, keine bestaetigten Bigramm-Anomalien' + focus_detail_bigram_only: ' (%{type}-Anomalie: %{pct}%%)' + focus_empty: ' Schliesse einige adaptive Lektionen ab, um Fokusdaten zu sehen' + error_anomalies_title: ' Fehler-Anomalien (%{count}) ' + no_error_anomalies: ' Keine Fehler-Anomalien erkannt' + speed_anomalies_title: ' Tempo-Anomalien (%{count}) ' + no_speed_anomalies: ' Keine Tempo-Anomalien erkannt' + scope_label_prefix: ' ' + bi_label: ' | Bi: %{count}' + tri_label: ' | Tri: %{count}' + hes_label: ' | Hes: >%{ms}ms' + gain_label: ' | Gewinn: %{value}' + gain_interval: ' (alle 50)' + focus_char_value: 'Zeichen ''%{ch}''' + +# Activity heatmap +heatmap: + title: ' Taegliche Aktivitaet (Sitzungen pro Tag) ' + jan: 'Jan' + feb: 'Feb' + mar: 'Mär' + apr: 'Apr' + may: 'Mai' + jun: 'Jun' + jul: 'Jul' + aug: 'Aug' + sep: 'Sep' + oct: 'Okt' + nov: 'Nov' + dec: 'Dez' + +# Chart +chart: + wpm_over_time: ' WPM im Zeitverlauf ' + drill_number: 'Lektion #' + +# Settings +settings: + title: ' Einstellungen ' + subtitle: 'Pfeiltasten zum Navigieren, Enter/Rechts zum Aendern, ESC zum Speichern' + target_wpm: 'Ziel-WPM' + theme: 'Farbschema' + word_count: 'Wortanzahl' + ui_language: 'UI-Sprache' + dictionary_language: 'Woerterbuchsprache' + keyboard_layout: 'Tastaturlayout' + code_language: 'Codesprache' + code_downloads: 'Code-Downloads' + on: 'An' + off: 'Aus' + code_download_dir: 'Code-Downloadverz.' + snippets_per_repo: 'Schnipsel pro Repo' + unlimited: 'Unbegrenzt' + download_code_now: 'Code jetzt laden' + run_downloader: 'Download starten' + passage_downloads: 'Text-Downloads' + passage_download_dir: 'Text-Downloadverz.' + paragraphs_per_book: 'Absaetze pro Buch' + whole_book: 'Ganzes Buch' + download_passages_now: 'Texte jetzt laden' + export_path: 'Exportpfad' + export_data: 'Daten exportieren' + export_now: 'Jetzt exportieren' + import_path: 'Importpfad' + import_data: 'Daten importieren' + import_now: 'Jetzt importieren' + hint_save_back: '[ESC] Speichern & zurueck' + hint_change_value: '[Enter/Pfeile] Wert aendern' + hint_edit_path: '[Enter auf Pfad] Bearbeiten' + hint_move: '[←→] Bewegen' + hint_tab_complete: '[Tab] Vervollstaendigen (am Ende)' + hint_confirm: '[Enter] Bestaetigen' + hint_cancel: '[Esc] Abbrechen' + success_title: ' Erfolg ' + error_title: ' Fehler ' + press_any_key: 'Beliebige Taste druecken' + file_exists_title: ' Datei existiert ' + file_exists: 'An diesem Pfad existiert bereits eine Datei.' + overwrite_rename: '[d] Ueberschreiben [r] Umbenennen [Esc] Abbrechen' + erase_warning: 'Dies wird Ihre aktuellen Daten loeschen.' + export_first: 'Exportieren Sie zuerst, wenn Sie sie behalten moechten.' + proceed_yn: 'Fortfahren? (y/n)' + confirm_import_title: ' Import bestaetigen ' + +# Selection screens +select: + dictionary_language_title: ' Woerterbuchsprache waehlen ' + keyboard_layout_title: ' Tastaturlayout waehlen ' + code_language_title: ' Codesprache waehlen ' + passage_source_title: ' Textquelle waehlen ' + ui_language_title: ' UI-Sprache waehlen ' + more_above: '... %{count} weitere oben ...' + more_below: '... %{count} weitere unten ...' + current: ' (aktuell)' + disabled: ' (deaktiviert)' + enabled_default: ' (aktiviert, Standard: %{layout})' + enabled: ' (aktiviert)' + disabled_blocked: ' (deaktiviert: gesperrt)' + built_in: ' (eingebaut)' + cached: ' (gespeichert)' + disabled_download: ' (deaktiviert: Download erforderlich)' + download_required: ' (Download erforderlich)' + hint_navigate: '[Auf/Ab/BildAuf/BildAb] Navigieren' + hint_confirm: '[Enter] Bestaetigen' + hint_back: '[ESC] Zurueck' + language_resets_layout: 'Die Sprachauswahl setzt das Tastaturlayout auf den Standard der Sprache zurueck.' + layout_no_language_change: 'Layoutaenderungen aendern nicht die Woerterbuchsprache.' + disabled_network_notice: 'Einige Sprachen sind deaktiviert: Netzwerk-Downloads in Intro/Einstellungen aktivieren.' + disabled_sources_notice: 'Einige Quellen sind deaktiviert: Netzwerk-Downloads in Intro/Einstellungen aktivieren.' + passage_all: 'Alle (Eingebaut + alle Buecher)' + passage_builtin: 'Nur eingebaute Passagen' + passage_book_prefix: 'Buch: %{title}' + +# Progress +progress: + overall_key_progress: 'Gesamter Tastenfortschritt' + unlocked_mastered: '%{unlocked}/%{total} freigeschaltet (%{mastered} gemeistert)' + +# Skill tree +skill_tree: + title: ' Faehigkeitenbaum ' + locked: 'Gesperrt' + unlocked: 'freigeschaltet' + mastered: 'gemeistert' + in_progress: 'in Bearbeitung' + complete: 'abgeschlossen' + locked_status: 'gesperrt' + locked_notice: '%{count} Grundbuchstaben abschliessen, um Zweige freizuschalten' + branches_separator: 'Zweige (verfuegbar nach %{count} Grundbuchstaben)' + unlocked_letters: '%{unlocked}/%{total} Buchstaben freigeschaltet' + level: 'Stufe %{current}/%{total}' + level_zero: 'Stufe 0/%{total}' + in_focus: ' im Fokus' + hint_navigate: '[↑↓/jk] Navigieren' + hint_scroll: '[BildAuf/BildAb oder Strg+U/Strg+D] Scrollen' + hint_back: '[q] Zurueck' + hint_unlock: '[Enter] Freischalten' + hint_start_drill: '[Enter] Lektion starten' + unlock_msg_1: 'Nach dem Freischalten werden freigeschaltete Tasten dieses Zweigs in die adaptive Lektion eingemischt.' + unlock_msg_2: 'Um nur diesen Zweig zu ueben, starte eine Lektion direkt aus diesem Zweig im Faehigkeitenbaum.' + confirm_unlock: '%{branch} freischalten?' + confirm_yn: '[y] Freischalten [n/ESC] Abbrechen' + lvl_prefix: 'Lvl' + branch_primary_letters: 'Grundbuchstaben' + branch_capital_letters: 'Grossbuchstaben' + branch_numbers: 'Zahlen 0-9' + branch_prose_punctuation: 'Interpunktion' + branch_whitespace: 'Leerzeichen' + branch_code_symbols: 'Code-Symbole' + level_frequency_order: 'Haeufigkeitsfolge' + level_common_sentence_capitals: 'Haeufige Satzanfaenge' + level_name_capitals: 'Namensgrossbuchst.' + level_remaining_capitals: 'Restl. Grossbuchst.' + level_common_digits: 'Haeufige Ziffern' + level_all_digits: 'Alle Ziffern' + level_essential: 'Grundlegend' + level_common: 'Haeufig' + level_expressive: 'Ausdruck' + level_enter_return: 'Enter/Return' + level_tab_indent: 'Tab/Einrueckung' + level_arithmetic_assignment: 'Arithmetik & Zuweisung' + level_grouping: 'Gruppierung' + level_logic_reference: 'Logik & Referenz' + level_special: 'Spezial' + +# Milestones +milestones: + unlock_title: ' Taste freigeschaltet! ' + mastery_title: ' Taste gemeistert! ' + branches_title: ' Neue Faehigkeitenzweige verfuegbar! ' + branch_complete_title: ' Zweig abgeschlossen! ' + all_unlocked_title: ' Alle Tasten freigeschaltet! ' + all_mastered_title: ' Volle Tastaturbeherrschung! ' + unlocked: 'freigeschaltet' + mastered: 'gemeistert' + use_finger: 'Benutze deinen %{finger}' + hold_right_shift: 'Rechte Umschalttaste halten (rechter kleiner Finger)' + hold_left_shift: 'Linke Umschalttaste halten (linker kleiner Finger)' + congratulations_all_letters: 'Glueckwunsch! Du hast alle %{count} Grundbuchstaben gemeistert' + new_branches_available: 'Neue Faehigkeitenzweige sind jetzt verfuegbar:' + visit_skill_tree: 'Besuche den Faehigkeitenbaum, um einen neuen Zweig' + and_start_training: 'freizuschalten und zu trainieren!' + open_skill_tree: 'Druecke [t], um den Faehigkeitenbaum zu oeffnen' + branch_complete_msg: 'Du hast den Zweig %{branch} abgeschlossen!' + all_levels_mastered: 'Alle %{count} Stufen gemeistert.' + all_keys_confident: 'Jede Taste in diesem Zweig hat volle Sicherheit.' + all_unlocked_msg: 'Du hast jede Taste auf der Tastatur freigeschaltet!' + all_unlocked_desc: 'Jedes Zeichen, Symbol und jeder Modifikator ist jetzt in deinen Lektionen verfuegbar.' + keep_practicing_mastery: 'Uebe weiter, um Meisterschaft aufzubauen — wenn jede Taste volle' + confidence_complete: 'Sicherheit erreicht hat, hast du die volle Tastaturbeherrschung!' + all_mastered_msg: 'Glueckwunsch — du hast volle Tastaturbeherrschung erreicht!' + all_mastered_desc: 'Jede Taste auf der Tastatur hat maximale Sicherheit.' + mastery_takes_practice: 'Meisterschaft ist kein Ziel — sie erfordert staendiges Ueben.' + keep_drilling: 'Uebe weiter, um dein Koennen zu erhalten.' + hint_skill_tree_continue: '[t] Faehigkeitenbaum [Andere Taste] Weiter' + hint_any_key: 'Beliebige Taste zum Fortfahren' + input_blocked: 'Eingabe voruebergehend blockiert (%{ms}ms verbleibend)' + unlock_msg_1: 'Gut gemacht! Baue deine Tippfaehigkeiten weiter aus.' + unlock_msg_2: 'Eine weitere Taste in deinem Arsenal!' + unlock_msg_3: 'Deine Tastatur waechst! Weiter so.' + unlock_msg_4: 'Einen Schritt naeher an voller Tastaturbeherrschung!' + mastery_msg_1: 'Diese Taste hat jetzt volle Sicherheit!' + mastery_msg_2: 'Diese Taste sitzt perfekt!' + mastery_msg_3: 'Muskelgedaechtnis verankert!' + mastery_msg_4: 'Eine weitere Taste bezwungen!' + +# Keyboard explorer +keyboard: + title: ' Tastatur ' + subtitle: 'Druecke eine Taste oder klicke darauf' + hint_navigate: '[←→↑↓/hjkl/Tab] Navigieren' + hint_back: '[q/ESC] Zurueck' + key_label: 'Taste: ' + finger_label: 'Finger: ' + hand_left: 'Links' + hand_right: 'Rechts' + finger_index: 'Zeigefinger' + finger_middle: 'Mittelfinger' + finger_ring: 'Ringfinger' + finger_pinky: 'Kleiner Finger' + finger_thumb: 'Daumen' + overall_accuracy: ' Gesamtgenauigkeit: %{correct}/%{total} (%{pct}%%)' + ranked_accuracy: ' Gewertete Genauigkeit: %{correct}/%{total} (%{pct}%%)' + confidence: 'Sicherheit: ' + no_data: 'Noch keine Daten' + no_data_short: 'Keine Daten' + key_details: ' Tastendetails ' + key_details_char: ' Tastendetails: ''%{ch}'' ' + key_details_name: ' Tastendetails: %{name} ' + press_key_hint: 'Druecke eine Taste fuer Details' + shift_label: 'Umschalt: ' + shift_no: 'Nein' + overall_avg_time: 'Gesamt Schnittzeit: ' + overall_best_time: 'Gesamt Bestzeit: ' + overall_samples: 'Gesamt Stichproben: ' + overall_accuracy_label: 'Gesamt Genauigkeit: ' + branch_label: 'Zweig: ' + level_label: 'Stufe: ' + built_in_key: 'Eingebaute Taste' + unlocked_label: 'Freigeschaltet: ' + yes: 'Ja' + no: 'Nein' + in_focus_label: 'Im Fokus?: ' + mastery_label: 'Meisterschaft: ' + mastery_locked: 'Gesperrt' + ranked_avg_time: 'Gewertete Schnittzeit: ' + ranked_best_time: 'Gewertete Bestzeit: ' + ranked_samples: 'Gewertete Stichproben: ' + ranked_accuracy_label: 'Gewertete Genauigkeit: ' + +# Intro dialogs +intro: + passage_title: ' Textpassagen-Download Einrichtung ' + code_title: ' Code-Download Einrichtung ' + enable_downloads: 'Netzwerk-Downloads aktivieren' + download_dir: 'Download-Verzeichnis' + paragraphs_per_book: 'Absaetze pro Buch (0 = ganz)' + whole_book: 'ganzes Buch' + snippets_per_repo: 'Schnipsel pro Repo (0 = unbegrenzt)' + unlimited: 'unbegrenzt' + start_passage_drill: 'Textpassagen-Lektion starten' + start_code_drill: 'Code-Lektion starten' + confirm: 'Bestaetigen' + hint_navigate: '[Auf/Ab] Navigieren' + hint_adjust: '[Links/Rechts] Anpassen' + hint_edit: '[Tippen/Backspace] Bearbeiten' + hint_confirm: '[Enter] Bestaetigen' + hint_cancel: '[ESC] Abbrechen' + preparing_download: 'Download wird vorbereitet...' + download_passage_title: ' Textquelle wird heruntergeladen ' + download_code_title: ' Code-Quelle wird heruntergeladen ' + book_label: ' Buch: %{name}' + repo_label: ' Repo: %{name}' + progress_bytes: '[%{name}] %{downloaded}/%{total} Bytes' + downloaded_bytes: 'Heruntergeladen: %{bytes} Bytes' + downloading_book_progress: 'Aktuelles Buch wird geladen: [%{bar}] %{downloaded}/%{total} Bytes' + downloading_book_bytes: 'Aktuelles Buch wird geladen: %{bytes} Bytes' + downloading_code_progress: 'Wird heruntergeladen: [%{bar}] %{downloaded}/%{total} Bytes' + downloading_code_bytes: 'Wird heruntergeladen: %{bytes} Bytes' + current_book: 'Aktuell: %{name} (Buch %{done}/%{total})' + current_repo: 'Aktuell: %{name} (Repo %{done}/%{total})' + passage_instructions_1: 'keydr kann Textpassagen von Project Gutenberg zum Tippueben herunterladen.' + passage_instructions_2: 'Buecher werden einmal heruntergeladen und lokal gespeichert.' + passage_instructions_3: 'Konfiguriere die Download-Einstellungen unten und starte eine Textpassagen-Lektion.' + code_instructions_1: 'keydr kann Open-Source-Code von GitHub zum Tippueben herunterladen.' + code_instructions_2: 'Code wird einmal heruntergeladen und lokal gespeichert.' + code_instructions_3: 'Konfiguriere die Download-Einstellungen unten und starte eine Code-Lektion.' + +# Status messages (from app.rs) +status: + recovery_files: 'Wiederherstellungsdateien von unterbrochenem Import gefunden. Daten koennten inkonsistent sein — erneuter Import empfohlen.' + dir_not_exist: 'Verzeichnis existiert nicht: %{path}' + no_data_store: 'Kein Datenspeicher verfuegbar' + serialization_error: 'Serialisierungsfehler: %{error}' + exported_to: 'Exportiert nach %{path}' + export_failed: 'Export fehlgeschlagen: %{error}' + could_not_read: 'Datei konnte nicht gelesen werden: %{error}' + invalid_export: 'Ungueltige Exportdatei: %{error}' + unsupported_version: 'Nicht unterstuetzte Exportversion: %{got} (erwartet %{expected})' + import_failed: 'Import fehlgeschlagen: %{error}' + imported_theme_fallback: 'Erfolgreich importiert (Farbschema ''%{theme}'' nicht gefunden, Standard wird verwendet)' + imported_success: 'Erfolgreich importiert' + adaptive_unavailable: 'Adaptiver gewerteter Modus nicht verfuegbar: %{error}' + switched_to: 'Gewechselt zu %{name}' + layout_changed: 'Layout geaendert zu %{name}' + +# Errors (for UI boundary translation) +errors: + unknown_language: 'Unbekannte Sprache: %{key}' + unknown_layout: 'Unbekanntes Tastaturlayout: %{key}' + unsupported_pair: 'Nicht unterstuetztes Sprach-/Layout-Paar: %{language} + %{layout}' + language_blocked: 'Sprache durch Unterstuetzungsstufe gesperrt: %{key}' + +# Common +common: + wpm: 'WPM' + cpm: 'ZPM' + back: 'Zurueck' diff --git a/locales/en.yml b/locales/en.yml new file mode 100644 index 0000000..447913a --- /dev/null +++ b/locales/en.yml @@ -0,0 +1,457 @@ +# Main menu +menu: + subtitle: 'Terminal Typing Tutor' + adaptive_drill: 'Adaptive Drill' + adaptive_drill_desc: 'Phonetic words with adaptive letter unlocking' + code_drill: 'Code Drill' + code_drill_desc: 'Practice typing code syntax' + passage_drill: 'Passage Drill' + passage_drill_desc: 'Type passages from books' + skill_tree: 'Skill Tree' + skill_tree_desc: 'View progression branches and launch drills' + keyboard: 'Keyboard' + keyboard_desc: 'Explore keyboard layout and key statistics' + statistics: 'Statistics' + statistics_desc: 'View your typing statistics' + settings: 'Settings' + settings_desc: 'Configure keydr' + day_streak: ' | %{days} day streak' + key_progress: ' Key Progress %{unlocked}/%{total} (%{mastered} mastered) | Target %{target} WPM%{streak}' + hint_start: '[1-3] Start' + hint_skill_tree: '[t] Skill Tree' + hint_keyboard: '[b] Keyboard' + hint_stats: '[s] Stats' + hint_settings: '[c] Settings' + hint_quit: '[q] Quit' + +# Drill screen +drill: + title: ' Drill ' + mode_adaptive: 'Adaptive' + mode_code: 'Code (Unranked)' + mode_passage: 'Passage (Unranked)' + focus_char: 'Focus: ''%{ch}''' + focus_bigram: 'Focus: "%{bigram}"' + focus_both: 'Focus: ''%{ch}'' + "%{bigram}"' + header_wpm: 'WPM' + header_acc: 'Acc' + header_err: 'Err' + code_source: ' Code source ' + passage_source: ' Passage source ' + footer: '[ESC] End drill [Backspace] Delete' + keys_reenabled: 'Keys re-enabled in %{ms}ms' + hint_end: '[ESC] End drill' + hint_backspace: '[Backspace] Delete' + +# Dashboard / drill result +dashboard: + title: ' Drill Complete ' + results: 'Results' + unranked_note_prefix: ' (Unranked' + unranked_note_suffix: ' does not count toward skill tree)' + speed: ' Speed: ' + accuracy_label: ' Accuracy: ' + time_label: ' Time: ' + errors_label: ' Errors: ' + correct_detail: ' (%{correct}/%{total} correct)' + input_blocked: ' Input temporarily blocked ' + input_blocked_ms: '(%{ms}ms remaining)' + hint_continue: '[c/Enter/Space] Continue' + hint_retry: '[r] Retry' + hint_menu: '[q] Menu' + hint_stats: '[s] Stats' + hint_delete: '[x] Delete' + +# Stats sidebar (during drill) +sidebar: + title: ' Stats ' + wpm: 'WPM: ' + target: 'Target: ' + target_wpm: '%{wpm} WPM' + accuracy: 'Accuracy: ' + progress: 'Progress: ' + correct: 'Correct: ' + errors: 'Errors: ' + time: 'Time: ' + last_drill: ' Last Drill ' + vs_avg: ' vs avg: ' + +# Statistics dashboard +stats: + title: ' Statistics ' + empty: 'No drills completed yet. Start typing!' + tab_dashboard: '[1] Dashboard' + tab_history: '[2] History' + tab_activity: '[3] Activity' + tab_accuracy: '[4] Accuracy' + tab_timing: '[5] Timing' + tab_ngrams: '[6] N-grams' + hint_back: '[ESC] Back' + hint_next_tab: '[Tab] Next tab' + hint_switch_tab: '[1-6] Switch tab' + hint_navigate: '[j/k] Navigate' + hint_page: '[PgUp/PgDn] Page' + hint_delete: '[x] Delete' + summary_title: ' Summary ' + drills: ' Drills: ' + avg_wpm: ' Avg WPM: ' + best_wpm: ' Best WPM: ' + accuracy_label: ' Accuracy: ' + total_time: ' Total time: ' + wpm_chart_title: ' WPM per Drill (Last 20, Target: %{target}) ' + accuracy_chart_title: ' Accuracy %% (Last 50 Drills) ' + chart_drill: 'Drill #' + chart_accuracy_pct: 'Accuracy %%' + sessions_title: ' Recent Sessions ' + session_header: ' # WPM Raw Acc%% Time Date/Time Mode Ranked Partial' + session_separator: ' ─────────────────────────────────────────────────────────────────────' + delete_confirm: 'Delete session #%{idx}? (y/n)' + confirm_title: ' Confirm ' + yes: 'yes' + no: 'no' + keyboard_accuracy_title: ' Keyboard Accuracy %% ' + keyboard_timing_title: ' Keyboard Timing (ms) ' + slowest_keys_title: ' Slowest Keys (ms) ' + fastest_keys_title: ' Fastest Keys (ms) ' + worst_accuracy_title: ' Worst Accuracy (%%) ' + best_accuracy_title: ' Best Accuracy (%%) ' + not_enough_data: ' Not enough data' + streaks_title: ' Streaks ' + current_streak: ' Current: ' + best_streak: ' Best: ' + active_days: ' Active Days: ' + top_days_none: ' Top Days: none' + top_days: ' Top Days: %{days}' + wpm_label: ' WPM: %{avg}/%{target} (%{pct}%%)' + acc_label: ' Acc: %{pct}%%' + keys_label: ' Keys: %{unlocked}/%{total} (%{mastered} mastered)' + ngram_empty: 'Complete some adaptive drills to see n-gram data' + ngram_header_speed_narrow: ' Bgrm Speed Expct Anom%' + ngram_header_error_narrow: ' Bgrm Err Smp Rate Exp Anom%' + ngram_header_speed: ' Bigram Speed Expect Samples Anom%' + ngram_header_error: ' Bigram Errors Samples Rate Expect Anom%' + focus_title: ' Active Focus ' + focus_char_label: ' Focus: ' + focus_bigram_value: 'Bigram %{label}' + focus_plus: ' + ' + anomaly_error: 'error' + anomaly_speed: 'speed' + focus_detail_both: ' Char ''%{ch}'': weakest key | Bigram %{label}: %{type} anomaly %{pct}%%' + focus_detail_char_only: ' Char ''%{ch}'': weakest key, no confirmed bigram anomalies' + focus_detail_bigram_only: ' (%{type} anomaly: %{pct}%%)' + focus_empty: ' Complete some adaptive drills to see focus data' + error_anomalies_title: ' Error Anomalies (%{count}) ' + no_error_anomalies: ' No error anomalies detected' + speed_anomalies_title: ' Speed Anomalies (%{count}) ' + no_speed_anomalies: ' No speed anomalies detected' + scope_label_prefix: ' ' + bi_label: ' | Bi: %{count}' + tri_label: ' | Tri: %{count}' + hes_label: ' | Hes: >%{ms}ms' + gain_label: ' | Gain: %{value}' + gain_interval: ' (every 50)' + focus_char_value: 'Char ''%{ch}''' + +# Activity heatmap +heatmap: + title: ' Daily Activity (Sessions per Day) ' + jan: 'Jan' + feb: 'Feb' + mar: 'Mar' + apr: 'Apr' + may: 'May' + jun: 'Jun' + jul: 'Jul' + aug: 'Aug' + sep: 'Sep' + oct: 'Oct' + nov: 'Nov' + dec: 'Dec' + +# Chart +chart: + wpm_over_time: ' WPM Over Time ' + drill_number: 'Drill #' + +# Settings +settings: + title: ' Settings ' + subtitle: 'Use arrows to navigate, Enter/Right to change, ESC to save & exit' + target_wpm: 'Target WPM' + theme: 'Theme' + word_count: 'Word Count' + ui_language: 'UI Language' + dictionary_language: 'Dictionary Language' + keyboard_layout: 'Keyboard Layout' + code_language: 'Code Language' + code_downloads: 'Code Downloads' + on: 'On' + off: 'Off' + code_download_dir: 'Code Download Dir' + snippets_per_repo: 'Snippets per Repo' + unlimited: 'Unlimited' + download_code_now: 'Download Code Now' + run_downloader: 'Run downloader' + passage_downloads: 'Passage Downloads' + passage_download_dir: 'Passage Download Dir' + paragraphs_per_book: 'Paragraphs per Book' + whole_book: 'Whole book' + download_passages_now: 'Download Passages Now' + export_path: 'Export Path' + export_data: 'Export Data' + export_now: 'Export now' + import_path: 'Import Path' + import_data: 'Import Data' + import_now: 'Import now' + hint_save_back: '[ESC] Save & back' + hint_change_value: '[Enter/arrows] Change value' + hint_edit_path: '[Enter on path] Edit' + hint_move: '[←→] Move' + hint_tab_complete: '[Tab] Complete (at end)' + hint_confirm: '[Enter] Confirm' + hint_cancel: '[Esc] Cancel' + success_title: ' Success ' + error_title: ' Error ' + press_any_key: 'Press any key' + file_exists_title: ' File Exists ' + file_exists: 'A file already exists at this path.' + overwrite_rename: '[d] Overwrite [r] Rename [Esc] Cancel' + erase_warning: 'This will erase your current data.' + export_first: 'Export first if you want to keep it.' + proceed_yn: 'Proceed? (y/n)' + confirm_import_title: ' Confirm Import ' + +# Selection screens +select: + dictionary_language_title: ' Select Dictionary Language ' + keyboard_layout_title: ' Select Keyboard Layout ' + code_language_title: ' Select Code Language ' + passage_source_title: ' Select Passage Source ' + ui_language_title: ' Select UI Language ' + more_above: '... %{count} more above ...' + more_below: '... %{count} more below ...' + current: ' (current)' + disabled: ' (disabled)' + enabled_default: ' (enabled, default: %{layout})' + enabled: ' (enabled)' + disabled_blocked: ' (disabled: blocked)' + built_in: ' (built-in)' + cached: ' (cached)' + disabled_download: ' (disabled: download required)' + download_required: ' (download required)' + hint_navigate: '[Up/Down/PgUp/PgDn] Navigate' + hint_confirm: '[Enter] Confirm' + hint_back: '[ESC] Back' + language_resets_layout: 'Selecting a language resets keyboard layout to that language''s default.' + layout_no_language_change: 'Layout changes do not change dictionary language.' + disabled_network_notice: 'Some languages are disabled: enable network downloads in intro/settings.' + disabled_sources_notice: 'Some sources are disabled: enable network downloads in intro/settings.' + passage_all: 'All (Built-in + all books)' + passage_builtin: 'Built-in passages only' + passage_book_prefix: 'Book: %{title}' + +# Progress +progress: + overall_key_progress: 'Overall Key Progress' + unlocked_mastered: '%{unlocked}/%{total} unlocked (%{mastered} mastered)' + +# Skill tree +skill_tree: + title: ' Skill Tree ' + locked: 'Locked' + unlocked: 'unlocked' + mastered: 'mastered' + in_progress: 'in progress' + complete: 'complete' + locked_status: 'locked' + locked_notice: 'Complete %{count} primary letters to unlock branches' + branches_separator: 'Branches (available after %{count} primary letters)' + unlocked_letters: 'Unlocked %{unlocked}/%{total} letters' + level: 'Level %{current}/%{total}' + level_zero: 'Level 0/%{total}' + in_focus: ' in focus' + hint_navigate: '[↑↓/jk] Navigate' + hint_scroll: '[PgUp/PgDn or Ctrl+U/Ctrl+D] Scroll' + hint_back: '[q] Back' + hint_unlock: '[Enter] Unlock' + hint_start_drill: '[Enter] Start Drill' + unlock_msg_1: 'Once unlocked, the default adaptive drill will mix in keys in this branch that are unlocked.' + unlock_msg_2: 'If you want to focus only on this branch, launch a drill directly from this branch in the Skill Tree.' + confirm_unlock: 'Unlock %{branch}?' + confirm_yn: '[y] Unlock [n/ESC] Cancel' + lvl_prefix: 'Lvl' + branch_primary_letters: 'Primary Letters' + branch_capital_letters: 'Capital Letters' + branch_numbers: 'Numbers 0-9' + branch_prose_punctuation: 'Prose Punctuation' + branch_whitespace: 'Whitespace' + branch_code_symbols: 'Code Symbols' + level_frequency_order: 'Frequency Order' + level_common_sentence_capitals: 'Common Sentence Capitals' + level_name_capitals: 'Name Capitals' + level_remaining_capitals: 'Remaining Capitals' + level_common_digits: 'Common Digits' + level_all_digits: 'All Digits' + level_essential: 'Essential' + level_common: 'Common' + level_expressive: 'Expressive' + level_enter_return: 'Enter/Return' + level_tab_indent: 'Tab/Indent' + level_arithmetic_assignment: 'Arithmetic & Assignment' + level_grouping: 'Grouping' + level_logic_reference: 'Logic & Reference' + level_special: 'Special' + +# Milestones +milestones: + unlock_title: ' Key Unlocked! ' + mastery_title: ' Key Mastered! ' + branches_title: ' New Skill Branches Available! ' + branch_complete_title: ' Branch Complete! ' + all_unlocked_title: ' Every Key Unlocked! ' + all_mastered_title: ' Full Keyboard Mastery! ' + unlocked: 'unlocked' + mastered: 'mastered' + use_finger: 'Use your %{finger}' + hold_right_shift: 'Hold Right Shift (right pinky)' + hold_left_shift: 'Hold Left Shift (left pinky)' + congratulations_all_letters: 'Congratulations! You''ve mastered all %{count} primary letters' + new_branches_available: 'New skill branches are now available:' + visit_skill_tree: 'Visit the Skill Tree to unlock a new branch' + and_start_training: 'and start training!' + open_skill_tree: 'Press [t] to open the Skill Tree now' + branch_complete_msg: 'You''ve completed the %{branch} branch!' + all_levels_mastered: 'All %{count} levels mastered.' + all_keys_confident: 'Every key in this branch is at full confidence.' + all_unlocked_msg: 'You''ve unlocked every key on the keyboard!' + all_unlocked_desc: 'Every character, symbol, and modifier is now available in your drills.' + keep_practicing_mastery: 'Keep practicing to build mastery — once every key reaches full' + confidence_complete: 'confidence, you''ll have achieved complete keyboard mastery!' + all_mastered_msg: 'Congratulations — you''ve reached full keyboard mastery!' + all_mastered_desc: 'Every key on the keyboard is at maximum confidence.' + mastery_takes_practice: 'Mastery is not a destination — it takes ongoing practice.' + keep_drilling: 'Keep drilling to maintain your edge.' + hint_skill_tree_continue: '[t] Open Skill Tree [Any other key] Continue' + hint_any_key: 'Press any key to continue' + input_blocked: 'Input temporarily blocked (%{ms}ms remaining)' + unlock_msg_1: 'Nice work! Keep building your typing skills.' + unlock_msg_2: 'Another key added to your arsenal!' + unlock_msg_3: 'Your keyboard is growing! Keep it up.' + unlock_msg_4: 'One step closer to full keyboard mastery!' + mastery_msg_1: 'This key is now at full confidence!' + mastery_msg_2: 'You''ve got this key down pat!' + mastery_msg_3: 'Muscle memory locked in!' + mastery_msg_4: 'One more key conquered!' + +# Keyboard explorer +keyboard: + title: ' Keyboard ' + subtitle: 'Press any key or click a key' + hint_navigate: '[←→↑↓/hjkl/Tab] Navigate' + hint_back: '[q/ESC] Back' + key_label: 'Key: ' + finger_label: 'Finger: ' + hand_left: 'Left' + hand_right: 'Right' + finger_index: 'Index' + finger_middle: 'Middle' + finger_ring: 'Ring' + finger_pinky: 'Pinky' + finger_thumb: 'Thumb' + overall_accuracy: ' Overall accuracy: %{correct}/%{total} (%{pct}%%)' + ranked_accuracy: ' Ranked accuracy: %{correct}/%{total} (%{pct}%%)' + confidence: 'Confidence: ' + no_data: 'No data yet' + no_data_short: 'No data' + key_details: ' Key Details ' + key_details_char: ' Key Details: ''%{ch}'' ' + key_details_name: ' Key Details: %{name} ' + press_key_hint: 'Press a key to see its details' + shift_label: 'Shift: ' + shift_no: 'No' + overall_avg_time: 'Overall Avg Time: ' + overall_best_time: 'Overall Best Time: ' + overall_samples: 'Overall Samples: ' + overall_accuracy_label: 'Overall Accuracy: ' + branch_label: 'Branch: ' + level_label: 'Level: ' + built_in_key: 'Built-in Key' + unlocked_label: 'Unlocked: ' + yes: 'Yes' + no: 'No' + in_focus_label: 'In Focus?: ' + mastery_label: 'Mastery: ' + mastery_locked: 'Locked' + ranked_avg_time: 'Ranked Avg Time: ' + ranked_best_time: 'Ranked Best Time: ' + ranked_samples: 'Ranked Samples: ' + ranked_accuracy_label: 'Ranked Accuracy: ' + +# Intro dialogs +intro: + passage_title: ' Passage Downloads Setup ' + code_title: ' Code Downloads Setup ' + enable_downloads: 'Enable network downloads' + download_dir: 'Download directory' + paragraphs_per_book: 'Paragraphs per book (0 = whole)' + whole_book: 'whole book' + snippets_per_repo: 'Snippets per repo (0 = unlimited)' + unlimited: 'unlimited' + start_passage_drill: 'Start passage drill' + start_code_drill: 'Start code drill' + confirm: 'Confirm' + hint_navigate: '[Up/Down] Navigate' + hint_adjust: '[Left/Right] Adjust' + hint_edit: '[Type/Backspace] Edit' + hint_confirm: '[Enter] Confirm' + hint_cancel: '[ESC] Cancel' + preparing_download: 'Preparing download...' + download_passage_title: ' Downloading Passage Source ' + download_code_title: ' Downloading Code Source ' + book_label: ' Book: %{name}' + repo_label: ' Repo: %{name}' + progress_bytes: '[%{name}] %{downloaded}/%{total} bytes' + downloaded_bytes: 'Downloaded: %{bytes} bytes' + downloading_book_progress: 'Downloading current book: [%{bar}] %{downloaded}/%{total} bytes' + downloading_book_bytes: 'Downloading current book: %{bytes} bytes' + downloading_code_progress: 'Downloading: [%{bar}] %{downloaded}/%{total} bytes' + downloading_code_bytes: 'Downloading: %{bytes} bytes' + current_book: 'Current: %{name} (book %{done}/%{total})' + current_repo: 'Current: %{name} (repo %{done}/%{total})' + passage_instructions_1: 'keydr can download passages from Project Gutenberg for typing practice.' + passage_instructions_2: 'Books are downloaded once and cached locally.' + passage_instructions_3: 'Configure download settings below, then start a passage drill.' + code_instructions_1: 'keydr can download open-source code from GitHub for typing practice.' + code_instructions_2: 'Code is downloaded once and cached locally.' + code_instructions_3: 'Configure download settings below, then start a code drill.' + +# Status messages (from app.rs) +status: + recovery_files: 'Recovery files found from interrupted import. Data may be inconsistent — consider re-importing.' + dir_not_exist: 'Directory does not exist: %{path}' + no_data_store: 'No data store available' + serialization_error: 'Serialization error: %{error}' + exported_to: 'Exported to %{path}' + export_failed: 'Export failed: %{error}' + could_not_read: 'Could not read file: %{error}' + invalid_export: 'Invalid export file: %{error}' + unsupported_version: 'Unsupported export version: %{got} (expected %{expected})' + import_failed: 'Import failed: %{error}' + imported_theme_fallback: 'Imported successfully (theme ''%{theme}'' not found, using default)' + imported_success: 'Imported successfully' + adaptive_unavailable: 'Adaptive ranked mode unavailable: %{error}' + switched_to: 'Switched to %{name}' + layout_changed: 'Layout changed to %{name}' + +# Errors (for UI boundary translation) +errors: + unknown_language: 'Unknown language: %{key}' + unknown_layout: 'Unknown keyboard layout: %{key}' + unsupported_pair: 'Unsupported language/layout pair: %{language} + %{layout}' + language_blocked: 'Language is blocked by support level: %{key}' + +# Common +common: + wpm: 'WPM' + cpm: 'CPM' + back: 'Back' diff --git a/src/app.rs b/src/app.rs index de6ae1c..97277ae 100644 --- a/src/app.rs +++ b/src/app.rs @@ -8,6 +8,8 @@ use rand::Rng; use rand::SeedableRng; use rand::rngs::SmallRng; +use crate::i18n::t; + use crate::config::Config; use crate::engine::FocusSelection; use crate::engine::filter::CharFilter; @@ -70,6 +72,7 @@ pub enum AppScreen { CodeIntro, CodeDownloadProgress, Keyboard, + UiLanguageSelect, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -84,6 +87,7 @@ pub enum SettingItem { TargetWpm, Theme, WordCount, + UiLanguage, DictionaryLanguage, KeyboardLayout, CodeLanguage, @@ -102,10 +106,11 @@ pub enum SettingItem { } impl SettingItem { - pub const ALL: [Self; 18] = [ + pub const ALL: [Self; 19] = [ Self::TargetWpm, Self::Theme, Self::WordCount, + Self::UiLanguage, Self::DictionaryLanguage, Self::KeyboardLayout, Self::CodeLanguage, @@ -183,23 +188,29 @@ pub struct KeyMilestonePopup { pub kind: MilestoneKind, pub keys: Vec, pub finger_info: Vec<(char, String)>, - pub message: &'static str, + pub message: String, pub branch_ids: Vec, } -const UNLOCK_MESSAGES: &[&str] = &[ - "Nice work! Keep building your typing skills.", - "Another key added to your arsenal!", - "Your keyboard is growing! Keep it up.", - "One step closer to full keyboard mastery!", -]; +fn unlock_messages() -> Vec { + use crate::i18n::t; + vec![ + t!("milestones.unlock_msg_1").to_string(), + t!("milestones.unlock_msg_2").to_string(), + t!("milestones.unlock_msg_3").to_string(), + t!("milestones.unlock_msg_4").to_string(), + ] +} -const MASTERY_MESSAGES: &[&str] = &[ - "This key is now at full confidence!", - "You've got this key down pat!", - "Muscle memory locked in!", - "One more key conquered!", -]; +fn mastery_messages() -> Vec { + use crate::i18n::t; + vec![ + t!("milestones.mastery_msg_1").to_string(), + t!("milestones.mastery_msg_2").to_string(), + t!("milestones.mastery_msg_3").to_string(), + t!("milestones.mastery_msg_4").to_string(), + ] +} const POST_DRILL_INPUT_LOCK_MS: u64 = 800; @@ -319,6 +330,8 @@ pub struct App { pub skill_tree_detail_scroll: usize, pub skill_tree_confirm_unlock: Option, pub drill_source_info: Option, + pub ui_language_selected: usize, + pub ui_language_scroll: usize, pub dictionary_language_selected: usize, pub dictionary_language_scroll: usize, pub keyboard_layout_selected: usize, @@ -525,6 +538,8 @@ impl App { skill_tree_detail_scroll: 0, skill_tree_confirm_unlock: None, drill_source_info: None, + ui_language_selected: 0, + ui_language_scroll: 0, dictionary_language_selected: 0, dictionary_language_scroll: 0, keyboard_layout_selected: 0, @@ -596,7 +611,7 @@ impl App { { app.settings_status_message = Some(StatusMessage { kind: StatusKind::Error, - text: "Recovery files found from interrupted import. Data may be inconsistent — consider re-importing.".to_string(), + text: t!("status.recovery_files").to_string(), }); } @@ -677,7 +692,7 @@ impl App { { self.settings_status_message = Some(StatusMessage { kind: StatusKind::Error, - text: format!("Directory does not exist: {}", parent.display()), + text: t!("status.dir_not_exist", path = parent.display().to_string()).to_string(), }); return; } @@ -685,7 +700,7 @@ impl App { let Some(ref store) = self.store else { self.settings_status_message = Some(StatusMessage { kind: StatusKind::Error, - text: "No data store available".to_string(), + text: t!("status.no_data_store").to_string(), }); return; }; @@ -696,7 +711,7 @@ impl App { Err(e) => { self.settings_status_message = Some(StatusMessage { kind: StatusKind::Error, - text: format!("Serialization error: {e}"), + text: t!("status.serialization_error", error = e.to_string()).to_string(), }); return; } @@ -717,14 +732,14 @@ impl App { Ok(()) => { self.settings_status_message = Some(StatusMessage { kind: StatusKind::Success, - text: format!("Exported to {}", self.settings_export_path), + text: t!("status.exported_to", path = &self.settings_export_path).to_string(), }); } Err(e) => { let _ = std::fs::remove_file(&tmp_path); self.settings_status_message = Some(StatusMessage { kind: StatusKind::Error, - text: format!("Export failed: {e}"), + text: t!("status.export_failed", error = e.to_string()).to_string(), }); } } @@ -739,7 +754,7 @@ impl App { Err(e) => { self.settings_status_message = Some(StatusMessage { kind: StatusKind::Error, - text: format!("Could not read file: {e}"), + text: t!("status.could_not_read", error = e.to_string()).to_string(), }); return; } @@ -750,7 +765,7 @@ impl App { Err(e) => { self.settings_status_message = Some(StatusMessage { kind: StatusKind::Error, - text: format!("Invalid export file: {e}"), + text: t!("status.invalid_export", error = e.to_string()).to_string(), }); return; } @@ -760,10 +775,7 @@ impl App { if export.keydr_export_version != EXPORT_VERSION { self.settings_status_message = Some(StatusMessage { kind: StatusKind::Error, - text: format!( - "Unsupported export version: {} (expected {})", - export.keydr_export_version, EXPORT_VERSION - ), + text: t!("status.unsupported_version", got = export.keydr_export_version, expected = EXPORT_VERSION).to_string(), }); return; } @@ -778,14 +790,14 @@ impl App { let Some(ref store) = self.store else { self.settings_status_message = Some(StatusMessage { kind: StatusKind::Error, - text: "No data store available".to_string(), + text: t!("status.no_data_store").to_string(), }); return; }; if let Err(e) = store.import_all(&export) { self.settings_status_message = Some(StatusMessage { kind: StatusKind::Error, - text: format!("Import failed: {e}"), + text: t!("status.import_failed", error = e.to_string()).to_string(), }); return; } @@ -841,15 +853,12 @@ impl App { let _ = self.config.save(); self.settings_status_message = Some(StatusMessage { kind: StatusKind::Success, - text: format!( - "Imported successfully (theme '{}' not found, using default)", - theme_name - ), + text: t!("status.imported_theme_fallback", theme = &theme_name).to_string(), }); } else { self.settings_status_message = Some(StatusMessage { kind: StatusKind::Success, - text: "Imported successfully".to_string(), + text: t!("status.imported_success").to_string(), }); } } @@ -1227,11 +1236,12 @@ impl App { .newly_unlocked .iter() .map(|&ch| { - let desc = self.keyboard_model.finger_for_char(ch).description(); - (ch, desc.to_string()) + let desc = self.keyboard_model.finger_for_char(ch).localized_description(); + (ch, desc) }) .collect(); - let msg = UNLOCK_MESSAGES[self.rng.gen_range(0..UNLOCK_MESSAGES.len())]; + let msgs = unlock_messages(); + let msg = msgs[self.rng.gen_range(0..msgs.len())].clone(); self.milestone_queue.push_back(KeyMilestonePopup { kind: MilestoneKind::Unlock, keys: update.newly_unlocked, @@ -1247,11 +1257,12 @@ impl App { .newly_mastered .iter() .map(|&ch| { - let desc = self.keyboard_model.finger_for_char(ch).description(); - (ch, desc.to_string()) + let desc = self.keyboard_model.finger_for_char(ch).localized_description(); + (ch, desc) }) .collect(); - let msg = MASTERY_MESSAGES[self.rng.gen_range(0..MASTERY_MESSAGES.len())]; + let msgs = mastery_messages(); + let msg = msgs[self.rng.gen_range(0..msgs.len())].clone(); self.milestone_queue.push_back(KeyMilestonePopup { kind: MilestoneKind::Mastery, keys: update.newly_mastered, @@ -1267,7 +1278,7 @@ impl App { kind: MilestoneKind::BranchesAvailable, keys: vec![], finger_info: vec![], - message: "", + message: String::new(), branch_ids: update.branches_newly_available, }); } @@ -1284,7 +1295,7 @@ impl App { kind: MilestoneKind::BranchComplete, keys: vec![], finger_info: vec![], - message: "", + message: String::new(), branch_ids: completed_non_lowercase, }); } @@ -1294,7 +1305,7 @@ impl App { kind: MilestoneKind::AllKeysUnlocked, keys: vec![], finger_info: vec![], - message: "", + message: String::new(), branch_ids: vec![], }); } @@ -1304,7 +1315,7 @@ impl App { kind: MilestoneKind::AllKeysMastered, keys: vec![], finger_info: vec![], - message: "", + message: String::new(), branch_ids: vec![], }); } @@ -1912,7 +1923,7 @@ impl App { Err(err) => { self.settings_status_message = Some(StatusMessage { kind: StatusKind::Error, - text: format!("Adaptive ranked mode unavailable: {err}"), + text: t!("status.adaptive_unavailable", error = err.to_string()).to_string(), }); self.go_to_settings(); self.settings_selected = SettingItem::DictionaryLanguage.index(); @@ -1942,6 +1953,15 @@ impl App { self.screen = AppScreen::Settings; } + pub fn go_to_ui_language_select(&mut self) { + self.ui_language_selected = crate::i18n::SUPPORTED_UI_LOCALES + .iter() + .position(|&k| k == self.config.ui_language) + .unwrap_or(0); + self.ui_language_scroll = 0; + self.screen = AppScreen::UiLanguageSelect; + } + pub fn go_to_dictionary_language_select(&mut self) { let options = crate::l10n::language_pack::language_packs(); self.dictionary_language_selected = options @@ -2335,7 +2355,7 @@ impl App { if keys.is_empty() { self.settings_status_message = Some(StatusMessage { kind: StatusKind::Error, - text: "No supported dictionary languages are registered".to_string(), + text: t!("errors.unknown_language", key = "none").to_string(), }); return; } @@ -2356,7 +2376,7 @@ impl App { Err(err) => { self.settings_status_message = Some(StatusMessage { kind: StatusKind::Error, - text: err.to_string(), + text: crate::i18n::localized_language_layout_error(&err), }); } } @@ -2367,7 +2387,7 @@ impl App { if keys.is_empty() { self.settings_status_message = Some(StatusMessage { kind: StatusKind::Error, - text: "No keyboard layouts are registered".to_string(), + text: t!("errors.unknown_layout", key = "none").to_string(), }); return; } @@ -2387,7 +2407,7 @@ impl App { Err(err) => { self.settings_status_message = Some(StatusMessage { kind: StatusKind::Error, - text: err.to_string(), + text: crate::i18n::localized_language_layout_error(&err), }); } } @@ -2519,6 +2539,13 @@ impl App { SettingItem::WordCount => { self.config.word_count = (self.config.word_count + 5).min(100); } + SettingItem::UiLanguage => { + let locales = crate::i18n::SUPPORTED_UI_LOCALES; + let idx = locales.iter().position(|&l| l == self.config.ui_language).unwrap_or(0); + let next = (idx + 1) % locales.len(); + self.config.ui_language = locales[next].to_string(); + crate::i18n::set_ui_locale(&self.config.ui_language); + } SettingItem::DictionaryLanguage => { self.apply_dictionary_language_by_offset(1); } @@ -2595,6 +2622,13 @@ impl App { SettingItem::WordCount => { self.config.word_count = self.config.word_count.saturating_sub(5).max(5); } + SettingItem::UiLanguage => { + let locales = crate::i18n::SUPPORTED_UI_LOCALES; + let idx = locales.iter().position(|&l| l == self.config.ui_language).unwrap_or(0); + let next = if idx == 0 { locales.len() - 1 } else { idx - 1 }; + self.config.ui_language = locales[next].to_string(); + crate::i18n::set_ui_locale(&self.config.ui_language); + } SettingItem::DictionaryLanguage => { self.apply_dictionary_language_by_offset(-1); } @@ -3039,6 +3073,8 @@ impl App { skill_tree_detail_scroll: 0, skill_tree_confirm_unlock: None, drill_source_info: None, + ui_language_selected: 0, + ui_language_scroll: 0, dictionary_language_selected: 0, dictionary_language_scroll: 0, keyboard_layout_selected: 0, @@ -3519,7 +3555,7 @@ mod tests { kind: MilestoneKind::Unlock, keys: vec!['a'], finger_info: vec![('a', "left pinky".to_string())], - message: "Test milestone", + message: "Test milestone".to_string(), branch_ids: vec![], }); diff --git a/src/config.rs b/src/config.rs index 1a0a2d2..ce9b9ce 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,6 +1,7 @@ use std::fs; use std::path::PathBuf; +use crate::i18n; use crate::keyboard::model::KeyboardModel; use crate::l10n::language_pack::{ LanguageLayoutValidationError, dictionary_languages_for_layout, supported_dictionary_languages, @@ -41,6 +42,8 @@ pub struct Config { pub code_snippets_per_repo: usize, #[serde(default = "default_code_onboarding_done")] pub code_onboarding_done: bool, + #[serde(default = "default_ui_language")] + pub ui_language: String, } fn default_target_wpm() -> u32 { @@ -98,6 +101,9 @@ fn default_code_snippets_per_repo() -> usize { fn default_code_onboarding_done() -> bool { false } +fn default_ui_language() -> String { + "en".to_string() +} impl Default for Config { fn default() -> Self { @@ -117,6 +123,7 @@ impl Default for Config { code_download_dir: default_code_download_dir(), code_snippets_per_repo: default_code_snippets_per_repo(), code_onboarding_done: default_code_onboarding_done(), + ui_language: default_ui_language(), } } } @@ -163,6 +170,7 @@ impl Config { self.normalize_keyboard_layout(); self.normalize_dictionary_language(); self.normalize_language_layout_pair(); + self.normalize_ui_language(); } /// Validate `code_language` against known options, resetting to default if invalid. @@ -215,6 +223,13 @@ impl Config { } } + /// Validate `ui_language` against supported UI locales. + fn normalize_ui_language(&mut self) { + if !i18n::SUPPORTED_UI_LOCALES.contains(&self.ui_language.as_str()) { + self.ui_language = default_ui_language(); + } + } + pub fn validate_language_layout_pair(&self) -> Result<(), LanguageLayoutValidationError> { validate_language_layout_pair(&self.dictionary_language, &self.keyboard_layout).map(|_| ()) } @@ -329,25 +344,22 @@ code_language = "go" } #[test] - fn test_normalize_language_layout_pair_resets_invalid_pair() { + fn test_normalize_language_layout_pair_keeps_valid_cross_language_pair() { let mut config = Config::default(); config.dictionary_language = "de".to_string(); config.keyboard_layout = "dvorak".to_string(); config.normalize_language_layout_pair(); - assert_eq!(config.dictionary_language, "en"); + // Cross-language/layout pairs are now valid + assert_eq!(config.dictionary_language, "de"); assert_eq!(config.keyboard_layout, "dvorak"); } #[test] - fn test_validate_language_layout_pair_returns_typed_error() { + fn test_validate_language_layout_pair_accepts_cross_language_pair() { let mut config = Config::default(); config.dictionary_language = "de".to_string(); config.keyboard_layout = "dvorak".to_string(); - let err = config.validate_language_layout_pair().unwrap_err(); - assert!(matches!( - err, - LanguageLayoutValidationError::UnsupportedLanguageLayoutPair { .. } - )); + assert!(config.validate_language_layout_pair().is_ok()); } #[test] diff --git a/src/engine/skill_tree.rs b/src/engine/skill_tree.rs index 7529566..7250529 100644 --- a/src/engine/skill_tree.rs +++ b/src/engine/skill_tree.rs @@ -80,20 +80,32 @@ pub enum BranchStatus { // --- Static Definitions --- pub struct LevelDefinition { - pub name: &'static str, + pub name_key: &'static str, pub keys: &'static [char], } +impl LevelDefinition { + pub fn display_name(&self) -> String { + crate::i18n::t!(self.name_key).to_string() + } +} + pub struct BranchDefinition { pub id: BranchId, - pub name: &'static str, + pub name_key: &'static str, pub levels: &'static [LevelDefinition], } +impl BranchDefinition { + pub fn display_name(&self) -> String { + crate::i18n::t!(self.name_key).to_string() + } +} + // Lowercase metadata remains for static branch lookup/UI labels. Runtime // progression and unlock counts are driven by `SkillTree::primary_letters`. const LOWERCASE_LEVELS: &[LevelDefinition] = &[LevelDefinition { - name: "Frequency Order", + name_key: "skill_tree.level_frequency_order", keys: &[ 'e', 't', 'a', 'o', 'i', 'n', 's', 'h', 'r', 'd', 'l', 'c', 'u', 'm', 'w', 'f', 'g', 'y', 'p', 'b', 'v', 'k', 'j', 'x', 'q', 'z', @@ -102,71 +114,71 @@ const LOWERCASE_LEVELS: &[LevelDefinition] = &[LevelDefinition { const CAPITALS_LEVELS: &[LevelDefinition] = &[ LevelDefinition { - name: "Common Sentence Capitals", + name_key: "skill_tree.level_common_sentence_capitals", keys: &['T', 'I', 'A', 'S', 'W', 'H', 'B', 'M'], }, LevelDefinition { - name: "Name Capitals", + name_key: "skill_tree.level_name_capitals", keys: &['J', 'D', 'R', 'C', 'E', 'N', 'P', 'L', 'F', 'G'], }, LevelDefinition { - name: "Remaining Capitals", + name_key: "skill_tree.level_remaining_capitals", keys: &['O', 'U', 'K', 'V', 'Y', 'X', 'Q', 'Z'], }, ]; const NUMBERS_LEVELS: &[LevelDefinition] = &[ LevelDefinition { - name: "Common Digits", + name_key: "skill_tree.level_common_digits", keys: &['1', '2', '3', '4', '5'], }, LevelDefinition { - name: "All Digits", + name_key: "skill_tree.level_all_digits", keys: &['0', '6', '7', '8', '9'], }, ]; const PROSE_PUNCTUATION_LEVELS: &[LevelDefinition] = &[ LevelDefinition { - name: "Essential", + name_key: "skill_tree.level_essential", keys: &['.', ',', '\''], }, LevelDefinition { - name: "Common", + name_key: "skill_tree.level_common", keys: &[';', ':', '"', '-'], }, LevelDefinition { - name: "Expressive", + name_key: "skill_tree.level_expressive", keys: &['?', '!', '(', ')'], }, ]; const WHITESPACE_LEVELS: &[LevelDefinition] = &[ LevelDefinition { - name: "Enter/Return", + name_key: "skill_tree.level_enter_return", keys: &['\n'], }, LevelDefinition { - name: "Tab/Indent", + name_key: "skill_tree.level_tab_indent", keys: &['\t'], }, ]; const CODE_SYMBOLS_LEVELS: &[LevelDefinition] = &[ LevelDefinition { - name: "Arithmetic & Assignment", + name_key: "skill_tree.level_arithmetic_assignment", keys: &['=', '+', '*', '/', '-'], }, LevelDefinition { - name: "Grouping", + name_key: "skill_tree.level_grouping", keys: &['{', '}', '[', ']', '<', '>'], }, LevelDefinition { - name: "Logic & Reference", + name_key: "skill_tree.level_logic_reference", keys: &['&', '|', '^', '~', '!'], }, LevelDefinition { - name: "Special", + name_key: "skill_tree.level_special", keys: &['@', '#', '$', '%', '_', '\\', '`'], }, ]; @@ -174,43 +186,43 @@ const CODE_SYMBOLS_LEVELS: &[LevelDefinition] = &[ pub const ALL_BRANCHES: &[BranchDefinition] = &[ BranchDefinition { id: BranchId::Lowercase, - name: "Primary Letters", + name_key: "skill_tree.branch_primary_letters", levels: LOWERCASE_LEVELS, }, BranchDefinition { id: BranchId::Capitals, - name: "Capital Letters", + name_key: "skill_tree.branch_capital_letters", levels: CAPITALS_LEVELS, }, BranchDefinition { id: BranchId::Numbers, - name: "Numbers 0-9", + name_key: "skill_tree.branch_numbers", levels: NUMBERS_LEVELS, }, BranchDefinition { id: BranchId::ProsePunctuation, - name: "Prose Punctuation", + name_key: "skill_tree.branch_prose_punctuation", levels: PROSE_PUNCTUATION_LEVELS, }, BranchDefinition { id: BranchId::Whitespace, - name: "Whitespace", + name_key: "skill_tree.branch_whitespace", levels: WHITESPACE_LEVELS, }, BranchDefinition { id: BranchId::CodeSymbols, - name: "Code Symbols", + name_key: "skill_tree.branch_code_symbols", levels: CODE_SYMBOLS_LEVELS, }, ]; /// Find which branch and level a key belongs to. -/// Returns (branch_def, level_name, 1-based position in level). -pub fn find_key_branch(ch: char) -> Option<(&'static BranchDefinition, &'static str, usize)> { +/// Returns (branch_def, level_def, 1-based position in level). +pub fn find_key_branch(ch: char) -> Option<(&'static BranchDefinition, &'static LevelDefinition, usize)> { for branch in ALL_BRANCHES { for level in branch.levels { if let Some(pos) = level.keys.iter().position(|&k| k == ch) { - return Some((branch, level.name, pos + 1)); + return Some((branch, level, pos + 1)); } } } @@ -1292,9 +1304,9 @@ mod tests { fn test_find_key_branch_lowercase() { let result = find_key_branch('e'); assert!(result.is_some()); - let (branch, level_name, pos) = result.unwrap(); + let (branch, level, pos) = result.unwrap(); assert_eq!(branch.id, BranchId::Lowercase); - assert_eq!(level_name, "Frequency Order"); + assert_eq!(level.name_key, "skill_tree.level_frequency_order"); assert_eq!(pos, 1); // 'e' is first in the frequency order } @@ -1302,9 +1314,9 @@ mod tests { fn test_find_key_branch_capitals() { let result = find_key_branch('T'); assert!(result.is_some()); - let (branch, level_name, pos) = result.unwrap(); + let (branch, level, pos) = result.unwrap(); assert_eq!(branch.id, BranchId::Capitals); - assert_eq!(level_name, "Common Sentence Capitals"); + assert_eq!(level.name_key, "skill_tree.level_common_sentence_capitals"); assert_eq!(pos, 1); // 'T' is first } diff --git a/src/generator/passage.rs b/src/generator/passage.rs index 0be0543..eb37383 100644 --- a/src/generator/passage.rs +++ b/src/generator/passage.rs @@ -7,6 +7,7 @@ use rand::rngs::SmallRng; use crate::engine::filter::CharFilter; use crate::generator::TextGenerator; use crate::generator::cache::fetch_url_bytes_with_progress; +use crate::i18n::t; const PASSAGES: &[&str] = &[ "the quick brown fox jumps over the lazy dog and then runs across the field while the sun sets behind the distant hills", @@ -67,11 +68,11 @@ pub const GUTENBERG_BOOKS: &[GutenbergBook] = &[ pub fn passage_options() -> Vec<(&'static str, String)> { let mut out = vec![ - ("all", "All (Built-in + all books)".to_string()), - ("builtin", "Built-in passages only".to_string()), + ("all", t!("select.passage_all").to_string()), + ("builtin", t!("select.passage_builtin").to_string()), ]; for book in GUTENBERG_BOOKS { - out.push((book.key, format!("Book: {}", book.title))); + out.push((book.key, t!("select.passage_book_prefix", title = book.title).to_string())); } out } diff --git a/src/i18n.rs b/src/i18n.rs new file mode 100644 index 0000000..090cc5a --- /dev/null +++ b/src/i18n.rs @@ -0,0 +1,214 @@ +pub use rust_i18n::t; + +/// 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); +} + +/// Retrieve the set of all translation keys for a given locale. +/// Used by the catalog parity test to verify every key exists in every locale. +#[cfg(test)] +fn collect_yaml_keys(value: &serde_yaml::Value, prefix: &str, keys: &mut std::collections::BTreeSet) { + match value { + serde_yaml::Value::Mapping(map) => { + for (k, v) in map { + let key_str = k.as_str().unwrap_or(""); + let full = if prefix.is_empty() { + key_str.to_string() + } else { + format!("{prefix}.{key_str}") + }; + collect_yaml_keys(v, &full, keys); + } + } + _ => { + keys.insert(prefix.to_string()); + } + } +} + +/// 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).to_string(), + UnknownLayout(key) => t!("errors.unknown_layout", key = key).to_string(), + UnsupportedLanguageLayoutPair { + language_key, + layout_key, + } => t!( + "errors.unsupported_pair", + language = language_key, + layout = layout_key + ) + .to_string(), + LanguageBlockedBySupportLevel(key) => { + t!("errors.language_blocked", key = key).to_string() + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeSet; + + fn locale_keys(locale: &str) -> BTreeSet { + let path = format!("locales/{locale}.yml"); + let content = std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("Failed to read {path}: {e}")); + let root: serde_yaml::Value = serde_yaml::from_str(&content) + .unwrap_or_else(|e| panic!("Failed to parse {path}: {e}")); + let mut keys = BTreeSet::new(); + collect_yaml_keys(&root, "", &mut keys); + keys + } + + #[test] + fn catalog_parity_en_de() { + let en = locale_keys("en"); + let de = locale_keys("de"); + + let missing_in_de: Vec<_> = en.difference(&de).collect(); + let extra_in_de: Vec<_> = de.difference(&en).collect(); + + assert!( + missing_in_de.is_empty(), + "Keys in en.yml missing from de.yml:\n {}", + missing_in_de + .iter() + .map(|k| k.as_str()) + .collect::>() + .join("\n ") + ); + assert!( + extra_in_de.is_empty(), + "Keys in de.yml not present in en.yml:\n {}", + extra_in_de + .iter() + .map(|k| k.as_str()) + .collect::>() + .join("\n ") + ); + } + + #[test] + fn placeholder_parity_en_de() { + let en_content = std::fs::read_to_string("locales/en.yml").unwrap(); + let de_content = std::fs::read_to_string("locales/de.yml").unwrap(); + let en_root: serde_yaml::Value = serde_yaml::from_str(&en_content).unwrap(); + let de_root: serde_yaml::Value = serde_yaml::from_str(&de_content).unwrap(); + + let mut en_map = std::collections::BTreeMap::new(); + let mut de_map = std::collections::BTreeMap::new(); + collect_leaf_values(&en_root, "", &mut en_map); + collect_leaf_values(&de_root, "", &mut de_map); + + let placeholder_re = regex::Regex::new(r"%\{(\w+)\}").unwrap(); + let mut mismatches = Vec::new(); + + for (key, en_val) in &en_map { + if let Some(de_val) = de_map.get(key) { + let en_placeholders: BTreeSet<_> = placeholder_re + .captures_iter(en_val) + .map(|c| c[1].to_string()) + .collect(); + let de_placeholders: BTreeSet<_> = placeholder_re + .captures_iter(de_val) + .map(|c| c[1].to_string()) + .collect(); + if en_placeholders != de_placeholders { + mismatches.push(format!( + " {key}: en={en_placeholders:?} de={de_placeholders:?}" + )); + } + } + } + + assert!( + mismatches.is_empty(), + "Placeholder mismatches between en.yml and de.yml:\n{}", + mismatches.join("\n") + ); + } + + fn collect_leaf_values( + value: &serde_yaml::Value, + prefix: &str, + map: &mut std::collections::BTreeMap, + ) { + match value { + serde_yaml::Value::Mapping(m) => { + for (k, v) in m { + let key_str = k.as_str().unwrap_or(""); + let full = if prefix.is_empty() { + key_str.to_string() + } else { + format!("{prefix}.{key_str}") + }; + collect_leaf_values(v, &full, map); + } + } + serde_yaml::Value::String(s) => { + map.insert(prefix.to_string(), s.clone()); + } + _ => {} + } + } + + #[test] + fn set_locale_english_produces_english() { + set_ui_locale("en"); + let text = t!("menu.subtitle").to_string(); + assert_eq!(text, "Terminal Typing Tutor"); + } + + #[test] + fn set_locale_german_produces_german() { + // Use the explicit locale parameter to avoid race conditions with + // parallel tests that share the global locale state. + let text = t!("menu.subtitle", locale = "de").to_string(); + assert_eq!(text, "Terminal-Tipptrainer"); + } + + #[test] + fn unsupported_locale_falls_back_to_english() { + set_ui_locale("zz"); + // After setting unsupported locale, the effective locale is "en" + let text = t!("menu.subtitle", locale = "en").to_string(); + assert_eq!(text, "Terminal Typing Tutor"); + } + + #[test] + fn branch_name_translated_de() { + let text = t!("skill_tree.branch_primary_letters", locale = "de").to_string(); + assert_eq!(text, "Grundbuchstaben"); + } + + #[test] + fn level_name_translated_de() { + let text = t!("skill_tree.level_frequency_order", locale = "de").to_string(); + assert_eq!(text, "Haeufigkeitsfolge"); + } + + #[test] + fn passage_all_translated_de() { + let text = t!("select.passage_all", locale = "de").to_string(); + assert_eq!(text, "Alle (Eingebaut + alle Buecher)"); + } + + #[test] + fn progress_overall_translated_de() { + let text = t!("progress.overall_key_progress", locale = "de").to_string(); + assert_eq!(text, "Gesamter Tastenfortschritt"); + } +} diff --git a/src/keyboard/finger.rs b/src/keyboard/finger.rs index 4f9dec5..e7d663f 100644 --- a/src/keyboard/finger.rs +++ b/src/keyboard/finger.rs @@ -27,6 +27,7 @@ impl FingerAssignment { Self { hand, finger } } + #[allow(dead_code)] pub fn description(&self) -> &'static str { match (self.hand, self.finger) { (Hand::Left, Finger::Pinky) => "left pinky", @@ -41,6 +42,22 @@ impl FingerAssignment { (Hand::Right, Finger::Thumb) => "right thumb", } } + + pub fn localized_description(&self) -> String { + use crate::i18n::t; + let hand = match self.hand { + Hand::Left => t!("keyboard.hand_left"), + Hand::Right => t!("keyboard.hand_right"), + }; + let finger = match self.finger { + Finger::Pinky => t!("keyboard.finger_pinky"), + Finger::Ring => t!("keyboard.finger_ring"), + Finger::Middle => t!("keyboard.finger_middle"), + Finger::Index => t!("keyboard.finger_index"), + Finger::Thumb => t!("keyboard.finger_thumb"), + }; + format!("{hand} {finger}") + } } #[allow(dead_code)] diff --git a/src/keyboard/model.rs b/src/keyboard/model.rs index 6a6b327..fd3124c 100644 --- a/src/keyboard/model.rs +++ b/src/keyboard/model.rs @@ -513,10 +513,17 @@ impl KeyboardModel { let Some(&(row, col)) = slots.next() else { break; }; - let shifted = ch.to_uppercase().next().unwrap_or(ch); + let candidate = ch.to_uppercase().next().unwrap_or(ch); + let shifted = if candidate != ch && used.contains(&candidate) { + ch + } else { + candidate + }; model.rows[row][col] = PhysicalKey { base: ch, shifted }; used.insert(ch); - used.insert(shifted); + if shifted != ch { + used.insert(shifted); + } } model } @@ -737,12 +744,14 @@ mod tests { pk.base, key ); - assert!( - seen.insert(pk.shifted), - "duplicate shifted char {:?} in profile {}", - pk.shifted, - key - ); + if pk.shifted != pk.base { + assert!( + seen.insert(pk.shifted), + "duplicate shifted char {:?} in profile {}", + pk.shifted, + key + ); + } } } } diff --git a/src/l10n/language_pack.rs b/src/l10n/language_pack.rs index 0f7b484..88c021f 100644 --- a/src/l10n/language_pack.rs +++ b/src/l10n/language_pack.rs @@ -78,6 +78,7 @@ impl fmt::Display for RankedReadinessError { pub struct LanguagePack { pub language_key: &'static str, pub display_name: &'static str, + pub autonym: &'static str, pub script: Script, pub dictionary_asset_id: &'static str, pub supported_keyboard_layout_keys: &'static [&'static str], @@ -135,6 +136,7 @@ static LANGUAGE_PACKS: &[LanguagePack] = &[ LanguagePack { language_key: "en", display_name: "English", + autonym: "English", script: Script::Latin, dictionary_asset_id: "words-en", supported_keyboard_layout_keys: EN_LAYOUTS, @@ -144,6 +146,7 @@ static LANGUAGE_PACKS: &[LanguagePack] = &[ LanguagePack { language_key: "de", display_name: "German", + autonym: "Deutsch", script: Script::Latin, dictionary_asset_id: "words-de", supported_keyboard_layout_keys: DE_LAYOUTS, @@ -153,6 +156,7 @@ static LANGUAGE_PACKS: &[LanguagePack] = &[ LanguagePack { language_key: "es", display_name: "Spanish", + autonym: "Español", script: Script::Latin, dictionary_asset_id: "words-es", supported_keyboard_layout_keys: ES_LAYOUTS, @@ -162,6 +166,7 @@ static LANGUAGE_PACKS: &[LanguagePack] = &[ LanguagePack { language_key: "fr", display_name: "French", + autonym: "Français", script: Script::Latin, dictionary_asset_id: "words-fr", supported_keyboard_layout_keys: FR_LAYOUTS, @@ -171,6 +176,7 @@ static LANGUAGE_PACKS: &[LanguagePack] = &[ LanguagePack { language_key: "it", display_name: "Italian", + autonym: "Italiano", script: Script::Latin, dictionary_asset_id: "words-it", supported_keyboard_layout_keys: IT_LAYOUTS, @@ -180,6 +186,7 @@ static LANGUAGE_PACKS: &[LanguagePack] = &[ LanguagePack { language_key: "pt", display_name: "Portuguese", + autonym: "Português", script: Script::Latin, dictionary_asset_id: "words-pt", supported_keyboard_layout_keys: PT_LAYOUTS, @@ -189,6 +196,7 @@ static LANGUAGE_PACKS: &[LanguagePack] = &[ LanguagePack { language_key: "nl", display_name: "Dutch", + autonym: "Nederlands", script: Script::Latin, dictionary_asset_id: "words-nl", supported_keyboard_layout_keys: NL_LAYOUTS, @@ -198,6 +206,7 @@ static LANGUAGE_PACKS: &[LanguagePack] = &[ LanguagePack { language_key: "sv", display_name: "Swedish", + autonym: "Svenska", script: Script::Latin, dictionary_asset_id: "words-sv", supported_keyboard_layout_keys: SV_LAYOUTS, @@ -207,6 +216,7 @@ static LANGUAGE_PACKS: &[LanguagePack] = &[ LanguagePack { language_key: "da", display_name: "Danish", + autonym: "Dansk", script: Script::Latin, dictionary_asset_id: "words-da", supported_keyboard_layout_keys: DA_LAYOUTS, @@ -216,6 +226,7 @@ static LANGUAGE_PACKS: &[LanguagePack] = &[ LanguagePack { language_key: "nb", display_name: "Norwegian Bokmal", + autonym: "Norsk bokmål", script: Script::Latin, dictionary_asset_id: "words-nb", supported_keyboard_layout_keys: NB_LAYOUTS, @@ -225,6 +236,7 @@ static LANGUAGE_PACKS: &[LanguagePack] = &[ LanguagePack { language_key: "fi", display_name: "Finnish", + autonym: "Suomi", script: Script::Latin, dictionary_asset_id: "words-fi", supported_keyboard_layout_keys: FI_LAYOUTS, @@ -234,6 +246,7 @@ static LANGUAGE_PACKS: &[LanguagePack] = &[ LanguagePack { language_key: "pl", display_name: "Polish", + autonym: "Polski", script: Script::Latin, dictionary_asset_id: "words-pl", supported_keyboard_layout_keys: PL_LAYOUTS, @@ -243,6 +256,7 @@ static LANGUAGE_PACKS: &[LanguagePack] = &[ LanguagePack { language_key: "cs", display_name: "Czech", + autonym: "Čeština", script: Script::Latin, dictionary_asset_id: "words-cs", supported_keyboard_layout_keys: CS_LAYOUTS, @@ -252,6 +266,7 @@ static LANGUAGE_PACKS: &[LanguagePack] = &[ LanguagePack { language_key: "ro", display_name: "Romanian", + autonym: "Română", script: Script::Latin, dictionary_asset_id: "words-ro", supported_keyboard_layout_keys: RO_LAYOUTS, @@ -261,6 +276,7 @@ static LANGUAGE_PACKS: &[LanguagePack] = &[ LanguagePack { language_key: "hr", display_name: "Croatian", + autonym: "Hrvatski", script: Script::Latin, dictionary_asset_id: "words-hr", supported_keyboard_layout_keys: HR_LAYOUTS, @@ -270,6 +286,7 @@ static LANGUAGE_PACKS: &[LanguagePack] = &[ LanguagePack { language_key: "hu", display_name: "Hungarian", + autonym: "Magyar", script: Script::Latin, dictionary_asset_id: "words-hu", supported_keyboard_layout_keys: HU_LAYOUTS, @@ -279,6 +296,7 @@ static LANGUAGE_PACKS: &[LanguagePack] = &[ LanguagePack { language_key: "lt", display_name: "Lithuanian", + autonym: "Lietuvių", script: Script::Latin, dictionary_asset_id: "words-lt", supported_keyboard_layout_keys: LT_LAYOUTS, @@ -288,6 +306,7 @@ static LANGUAGE_PACKS: &[LanguagePack] = &[ LanguagePack { language_key: "lv", display_name: "Latvian", + autonym: "Latviešu", script: Script::Latin, dictionary_asset_id: "words-lv", supported_keyboard_layout_keys: LV_LAYOUTS, @@ -297,6 +316,7 @@ static LANGUAGE_PACKS: &[LanguagePack] = &[ LanguagePack { language_key: "sl", display_name: "Slovene", + autonym: "Slovenščina", script: Script::Latin, dictionary_asset_id: "words-sl", supported_keyboard_layout_keys: SL_LAYOUTS, @@ -306,6 +326,7 @@ static LANGUAGE_PACKS: &[LanguagePack] = &[ LanguagePack { language_key: "et", display_name: "Estonian", + autonym: "Eesti", script: Script::Latin, dictionary_asset_id: "words-et", supported_keyboard_layout_keys: ET_LAYOUTS, @@ -315,6 +336,7 @@ static LANGUAGE_PACKS: &[LanguagePack] = &[ LanguagePack { language_key: "tr", display_name: "Turkish", + autonym: "Türkçe", script: Script::Latin, dictionary_asset_id: "words-tr", supported_keyboard_layout_keys: TR_LAYOUTS, diff --git a/src/lib.rs b/src/lib.rs index ee54f23..3168790 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,6 +4,8 @@ // Most code is only exercised through the binary, so suppress dead_code warnings. #![allow(dead_code)] +rust_i18n::i18n!("locales", fallback = "en"); + // Public: used by benchmarks and the generate_test_profiles binary pub mod config; pub mod engine; @@ -16,4 +18,5 @@ pub mod store; mod app; mod event; mod generator; +mod i18n; mod ui; diff --git a/src/main.rs b/src/main.rs index 13feb84..f747796 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,8 +1,11 @@ +rust_i18n::i18n!("locales", fallback = "en"); + mod app; mod config; mod engine; mod event; mod generator; +mod i18n; mod keyboard; mod l10n; mod session; @@ -31,6 +34,7 @@ use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Paragraph, Widget, Wrap}; use app::{App, AppScreen, DrillMode, MilestoneKind, SettingItem, StatusKind}; +use i18n::t; use engine::skill_tree::{BranchStatus, DrillScope, find_key_branch, get_branch_definition}; use event::{AppEvent, EventHandler}; use generator::code_syntax::{code_language_options, is_language_cached, language_by_key}; @@ -43,6 +47,7 @@ use l10n::language_pack::{ }; use ui::components::dashboard::Dashboard; use ui::components::keyboard_diagram::KeyboardDiagram; +use ui::components::menu::Menu; use ui::components::skill_tree::{ SkillTreeWidget, branch_list_spacing_flags, detail_line_count_with_level_spacing_for_tree, selectable_branches, use_expanded_level_spacing_for_tree, use_side_by_side_layout, @@ -81,6 +86,7 @@ fn main() -> Result<()> { let cli = Cli::parse(); let mut app = App::new(); + i18n::set_ui_locale(&app.config.ui_language); if let Some(words) = cli.words { app.config.word_count = words; @@ -344,6 +350,7 @@ fn handle_key(app: &mut App, key: KeyEvent) { AppScreen::CodeIntro => handle_code_intro_key(app, key), AppScreen::CodeDownloadProgress => handle_code_download_progress_key(app, key), AppScreen::Keyboard => handle_keyboard_explorer_key(app, key), + AppScreen::UiLanguageSelect => handle_ui_language_key(app, key), } } @@ -488,9 +495,12 @@ fn milestone_footer_hint_token_at( let inner = Block::bordered().inner(overlay_area); let footer_y = inner.y + inner.height.saturating_sub(1); let footer_area = Rect::new(inner.x, footer_y, inner.width, 1); + let hint_skill_tree = t!("milestones.hint_skill_tree_continue"); + let hint_any_key = t!("milestones.hint_any_key"); + let hints: Vec<&str> = vec![hint_skill_tree.as_ref(), hint_any_key.as_ref()]; hint_token_at( footer_area, - &["[t] Open Skill Tree", "[Any other key] Continue"], + &hints, x, y, ) @@ -539,6 +549,7 @@ fn handle_mouse(app: &mut App, mouse: MouseEvent) { AppScreen::CodeIntro => handle_code_intro_mouse(app, mouse), AppScreen::CodeDownloadProgress => handle_code_download_progress_mouse(app, mouse), AppScreen::Keyboard => handle_keyboard_explorer_mouse(app, mouse), + AppScreen::UiLanguageSelect => handle_ui_language_mouse(app, mouse), } } @@ -576,13 +587,19 @@ fn handle_menu_mouse(app: &mut App, mouse: MouseEvent) { MouseEventKind::Down(MouseButton::Left) | MouseEventKind::Down(MouseButton::Right) => { let is_secondary = matches!(mouse.kind, MouseEventKind::Down(MouseButton::Right)); let area = terminal_area(); - let menu_hints = [ - "[1-3] Start", - "[t] Skill Tree", - "[b] Keyboard", - "[s] Stats", - "[c] Settings", - "[q] Quit", + let mh_start = t!("menu.hint_start"); + let mh_tree = t!("menu.hint_skill_tree"); + let mh_kbd = t!("menu.hint_keyboard"); + let mh_stats = t!("menu.hint_stats"); + let mh_settings = t!("menu.hint_settings"); + let mh_quit = t!("menu.hint_quit"); + let menu_hints: Vec<&str> = vec![ + mh_start.as_ref(), + mh_tree.as_ref(), + mh_kbd.as_ref(), + mh_stats.as_ref(), + mh_settings.as_ref(), + mh_quit.as_ref(), ]; let footer_line_count = pack_hint_lines(&menu_hints, area.width as usize) .len() @@ -641,7 +658,7 @@ fn handle_menu_mouse(app: &mut App, mouse: MouseEvent) { let list_area = sections[2]; if point_in_rect(mouse.column, mouse.row, list_area) { let row = ((mouse.row - list_area.y) / 3) as usize; - if row < app.menu.items.len() { + if row < Menu::item_count() { app.menu.selected = row; activate_menu_selected(app); } @@ -660,7 +677,9 @@ fn handle_drill_mouse(app: &mut App, mouse: MouseEvent) { } let layout = AppLayout::new(terminal_area()); if point_in_rect(mouse.column, mouse.row, layout.footer) { - let hints = ["[ESC] End drill", "[Backspace] Delete"]; + let hint_end = t!("drill.hint_end"); + let hint_bs = t!("drill.hint_backspace"); + let hints: Vec<&str> = vec![hint_end.as_ref(), hint_bs.as_ref()]; if let Some(token) = hint_token_at(layout.footer, &hints, mouse.column, mouse.row) { match token.as_str() { "ESC" => { @@ -720,12 +739,17 @@ fn handle_result_mouse(app: &mut App, mouse: MouseEvent) { let area = terminal_area(); let centered = ui::layout::centered_rect(60, 70, area); let inner = Block::bordered().inner(centered); - let hints = [ - "[c/Enter/Space] Continue", - "[r] Retry", - "[q] Menu", - "[s] Stats", - "[x] Delete", + let h_cont = t!("dashboard.hint_continue"); + let h_retry = t!("dashboard.hint_retry"); + let h_menu = t!("dashboard.hint_menu"); + let h_stats = t!("dashboard.hint_stats"); + let h_del = t!("dashboard.hint_delete"); + let hints: Vec<&str> = vec![ + h_cont.as_ref(), + h_retry.as_ref(), + h_menu.as_ref(), + h_stats.as_ref(), + h_del.as_ref(), ]; let footer_line_count = pack_hint_lines(&hints, inner.width as usize).len().max(1) as u16; let footer_y = inner @@ -754,19 +778,22 @@ fn handle_result_mouse(app: &mut App, mouse: MouseEvent) { } } -const STATS_TAB_LABELS: [&str; 6] = [ - "[1] Dashboard", - "[2] History", - "[3] Activity", - "[4] Accuracy", - "[5] Timing", - "[6] N-grams", -]; +fn stats_tab_labels() -> [String; 6] { + [ + t!("stats.tab_dashboard").to_string(), + t!("stats.tab_history").to_string(), + t!("stats.tab_activity").to_string(), + t!("stats.tab_accuracy").to_string(), + t!("stats.tab_timing").to_string(), + t!("stats.tab_ngrams").to_string(), + ] +} fn wrapped_stats_tab_line_count(width: usize) -> usize { + let labels = stats_tab_labels(); let mut lines = 1usize; let mut current_width = 0usize; - for label in STATS_TAB_LABELS { + for label in &labels { let item_width = format!(" {label} ").chars().count() + 2; if current_width > 0 && current_width + item_width > width { lines += 1; @@ -778,11 +805,12 @@ fn wrapped_stats_tab_line_count(width: usize) -> usize { } fn stats_tab_at_point(tab_area: Rect, width: usize, x: u16, y: u16) -> Option { + let labels = stats_tab_labels(); let mut row = tab_area.y; let mut col = tab_area.x; let max_col = tab_area.x + width as u16; - for (idx, label) in STATS_TAB_LABELS.iter().enumerate() { + for (idx, label) in labels.iter().enumerate() { let text = format!(" {label} "); let text_width = text.chars().count() as u16; let item_width = text_width + 2; // separator @@ -824,17 +852,23 @@ fn handle_stats_mouse(app: &mut App, mouse: MouseEvent) { let inner = Block::bordered().inner(area); let width = inner.width as usize; let tab_line_count = wrapped_stats_tab_line_count(width) as u16; + let sh_back = t!("stats.hint_back"); + let sh_next = t!("stats.hint_next_tab"); + let sh_switch = t!("stats.hint_switch_tab"); + let sh_nav = t!("stats.hint_navigate"); + let sh_page = t!("stats.hint_page"); + let sh_del = t!("stats.hint_delete"); let footer_hints: Vec<&str> = if app.stats_tab == 1 { vec![ - "[ESC] Back", - "[Tab] Next tab", - "[1-6] Switch tab", - "[j/k] Navigate", - "[PgUp/PgDn] Page", - "[x] Delete", + sh_back.as_ref(), + sh_next.as_ref(), + sh_switch.as_ref(), + sh_nav.as_ref(), + sh_page.as_ref(), + sh_del.as_ref(), ] } else { - vec!["[ESC] Back", "[Tab] Next tab", "[1-6] Switch tab"] + vec![sh_back.as_ref(), sh_next.as_ref(), sh_switch.as_ref()] }; let footer_line_count = pack_hint_lines(&footer_hints, width).len().max(1) as u16; let layout = Layout::default() @@ -954,116 +988,123 @@ fn handle_stats_mouse(app: &mut App, mouse: MouseEvent) { fn settings_fields(app: &App) -> Vec<(SettingItem, String, String)> { let dictionary_language_label = find_language_pack(&app.config.dictionary_language) - .map(|pack| pack.display_name.to_string()) + .map(|pack| pack.autonym.to_string()) .unwrap_or_else(|| app.config.dictionary_language.clone()); let keyboard_layout_label = app.config.keyboard_layout.clone(); vec![ ( SettingItem::TargetWpm, - "Target WPM".to_string(), + t!("settings.target_wpm").to_string(), format!("{}", app.config.target_wpm), ), ( SettingItem::Theme, - "Theme".to_string(), + t!("settings.theme").to_string(), app.config.theme.clone(), ), ( SettingItem::WordCount, - "Word Count".to_string(), + t!("settings.word_count").to_string(), format!("{}", app.config.word_count), ), + ( + SettingItem::UiLanguage, + t!("settings.ui_language").to_string(), + find_language_pack(&app.config.ui_language) + .map(|pack| pack.autonym.to_string()) + .unwrap_or_else(|| app.config.ui_language.clone()), + ), ( SettingItem::DictionaryLanguage, - "Dictionary Language".to_string(), + t!("settings.dictionary_language").to_string(), dictionary_language_label, ), ( SettingItem::KeyboardLayout, - "Keyboard Layout".to_string(), + t!("settings.keyboard_layout").to_string(), keyboard_layout_label, ), ( SettingItem::CodeLanguage, - "Code Language".to_string(), + t!("settings.code_language").to_string(), app.config.code_language.clone(), ), ( SettingItem::CodeDownloads, - "Code Downloads".to_string(), + t!("settings.code_downloads").to_string(), if app.config.code_downloads_enabled { - "On".to_string() + t!("settings.on").to_string() } else { - "Off".to_string() + t!("settings.off").to_string() }, ), ( SettingItem::CodeDownloadDir, - "Code Download Dir".to_string(), + t!("settings.code_download_dir").to_string(), app.config.code_download_dir.clone(), ), ( SettingItem::SnippetsPerRepo, - "Snippets per Repo".to_string(), + t!("settings.snippets_per_repo").to_string(), if app.config.code_snippets_per_repo == 0 { - "Unlimited".to_string() + t!("settings.unlimited").to_string() } else { format!("{}", app.config.code_snippets_per_repo) }, ), ( SettingItem::DownloadCodeNow, - "Download Code Now".to_string(), - "Run downloader".to_string(), + t!("settings.download_code_now").to_string(), + t!("settings.run_downloader").to_string(), ), ( SettingItem::PassageDownloads, - "Passage Downloads".to_string(), + t!("settings.passage_downloads").to_string(), if app.config.passage_downloads_enabled { - "On".to_string() + t!("settings.on").to_string() } else { - "Off".to_string() + t!("settings.off").to_string() }, ), ( SettingItem::PassageDownloadDir, - "Passage Download Dir".to_string(), + t!("settings.passage_download_dir").to_string(), app.config.passage_download_dir.clone(), ), ( SettingItem::ParagraphsPerBook, - "Paragraphs per Book".to_string(), + t!("settings.paragraphs_per_book").to_string(), if app.config.passage_paragraphs_per_book == 0 { - "Whole book".to_string() + t!("settings.whole_book").to_string() } else { format!("{}", app.config.passage_paragraphs_per_book) }, ), ( SettingItem::DownloadPassagesNow, - "Download Passages Now".to_string(), - "Run downloader".to_string(), + t!("settings.download_passages_now").to_string(), + t!("settings.run_downloader").to_string(), ), ( SettingItem::ExportPath, - "Export Path".to_string(), + t!("settings.export_path").to_string(), app.settings_export_path.clone(), ), ( SettingItem::ExportData, - "Export Data".to_string(), - "Export now".to_string(), + t!("settings.export_data").to_string(), + t!("settings.export_now").to_string(), ), ( SettingItem::ImportPath, - "Import Path".to_string(), + t!("settings.import_path").to_string(), app.settings_import_path.clone(), ), ( SettingItem::ImportData, - "Import Data".to_string(), - "Import now".to_string(), + t!("settings.import_data").to_string(), + t!("settings.import_now").to_string(), ), ] } @@ -1127,6 +1168,7 @@ fn handle_settings_mouse(app: &mut App, mouse: MouseEvent) { if mouse.column < dialog.x + dialog.width / 2 { app.settings_confirm_import = false; app.import_data(); + i18n::set_ui_locale(&app.config.ui_language); } else { app.settings_confirm_import = false; } @@ -1139,10 +1181,13 @@ fn handle_settings_mouse(app: &mut App, mouse: MouseEvent) { let inner = Block::bordered().inner(centered); let fields = settings_fields(app); let header_height = if inner.height > 0 { 1 } else { 0 }; - let footer_hints = vec![ - "[ESC] Save & back", - "[Enter/arrows] Change value", - "[Enter on path] Edit", + let sfh_save = t!("settings.hint_save_back"); + let sfh_change = t!("settings.hint_change_value"); + let sfh_edit = t!("settings.hint_edit_path"); + let footer_hints: Vec<&str> = vec![ + sfh_save.as_ref(), + sfh_change.as_ref(), + sfh_edit.as_ref(), ]; let footer_height = if inner.height > header_height { pack_hint_lines(&footer_hints, inner.width as usize) @@ -1162,18 +1207,22 @@ fn handle_settings_mouse(app: &mut App, mouse: MouseEvent) { .split(inner); if is_click && layout[2].height > 0 { + let efh_move = t!("settings.hint_move"); + let efh_tab = t!("settings.hint_tab_complete"); + let efh_confirm = t!("settings.hint_confirm"); + let efh_cancel = t!("settings.hint_cancel"); let footer_hints: Vec<&str> = if app.settings_editing_path.is_some() { vec![ - "[←→] Move", - "[Tab] Complete (at end)", - "[Enter] Confirm", - "[Esc] Cancel", + efh_move.as_ref(), + efh_tab.as_ref(), + efh_confirm.as_ref(), + efh_cancel.as_ref(), ] } else { vec![ - "[ESC] Save & back", - "[Enter/arrows] Change value", - "[Enter on path] Edit", + sfh_save.as_ref(), + sfh_change.as_ref(), + sfh_edit.as_ref(), ] }; if let Some(token) = hint_token_at(layout[2], &footer_hints, mouse.column, mouse.row) { @@ -1462,6 +1511,7 @@ fn handle_stats_key(app: &mut App, key: KeyEvent) { fn activate_settings_selected(app: &mut App) { match SettingItem::from_index(app.settings_selected) { + SettingItem::UiLanguage => app.go_to_ui_language_select(), SettingItem::DictionaryLanguage => app.go_to_dictionary_language_select(), SettingItem::KeyboardLayout => app.go_to_keyboard_layout_select(), SettingItem::CodeDownloadDir => { @@ -1539,6 +1589,7 @@ fn handle_settings_key(app: &mut App, key: KeyEvent) { KeyCode::Char('y') => { app.settings_confirm_import = false; app.import_data(); + i18n::set_ui_locale(&app.config.ui_language); } KeyCode::Char('n') | KeyCode::Esc => { app.settings_confirm_import = false; @@ -1606,12 +1657,11 @@ fn is_dictionary_language_disabled(_app: &App, language_key: &str) -> bool { fn dictionary_language_list_area(area: Rect) -> Rect { let centered = ui::layout::centered_rect(60, 70, area); let inner = Block::bordered().inner(centered); + let h_nav = t!("select.hint_navigate"); + let h_confirm = t!("select.hint_confirm"); + let h_back = t!("select.hint_back"); let hint_lines = pack_hint_lines( - &[ - "[Up/Down/PgUp/PgDn] Navigate", - "[Enter] Confirm", - "[ESC] Back", - ], + &[h_nav.as_ref(), h_confirm.as_ref(), h_back.as_ref()], inner.width as usize, ); let footer_height = (hint_lines.len() as u16).max(1); @@ -1724,11 +1774,10 @@ fn handle_dictionary_language_mouse(app: &mut App, mouse: MouseEvent) { let area = terminal_area(); let centered = ui::layout::centered_rect(60, 70, area); let inner = Block::bordered().inner(centered); - let hints = [ - "[Up/Down/PgUp/PgDn] Navigate", - "[Enter] Confirm", - "[ESC] Back", - ]; + let h_nav = t!("select.hint_navigate"); + let h_confirm = t!("select.hint_confirm"); + let h_back = t!("select.hint_back"); + let hints: Vec<&str> = vec![h_nav.as_ref(), h_confirm.as_ref(), h_back.as_ref()]; let footer_h = pack_hint_lines(&hints, inner.width as usize).len().max(1) as u16; let chunks = if inner.height > footer_h { Some( @@ -1784,6 +1833,65 @@ fn handle_dictionary_language_mouse(app: &mut App, mouse: MouseEvent) { } } +// --- UI Language Select --- + +fn confirm_ui_language_selection(app: &mut App) { + let locales = i18n::SUPPORTED_UI_LOCALES; + if app.ui_language_selected >= locales.len() { + return; + } + let selected = locales[app.ui_language_selected]; + app.config.ui_language = selected.to_string(); + i18n::set_ui_locale(selected); + let _ = app.config.save(); + app.go_to_settings(); + app.settings_selected = SettingItem::UiLanguage.index(); +} + +fn handle_ui_language_key(app: &mut App, key: KeyEvent) { + let locales = i18n::SUPPORTED_UI_LOCALES; + let len = locales.len(); + if len == 0 { + return; + } + match key.code { + KeyCode::Esc | KeyCode::Char('q') => { + app.go_to_settings(); + app.settings_selected = SettingItem::UiLanguage.index(); + } + KeyCode::Up | KeyCode::Char('k') => { + app.ui_language_selected = app.ui_language_selected.saturating_sub(1); + } + KeyCode::Down | KeyCode::Char('j') => { + if app.ui_language_selected + 1 < len { + app.ui_language_selected += 1; + } + } + KeyCode::Enter => confirm_ui_language_selection(app), + _ => {} + } +} + +fn handle_ui_language_mouse(app: &mut App, mouse: MouseEvent) { + let locales = i18n::SUPPORTED_UI_LOCALES; + if locales.is_empty() { + return; + } + match mouse.kind { + MouseEventKind::ScrollUp => { + app.ui_language_selected = app.ui_language_selected.saturating_sub(1); + } + MouseEventKind::ScrollDown => { + if app.ui_language_selected + 1 < locales.len() { + app.ui_language_selected += 1; + } + } + _ => {} + } +} + +// --- Keyboard Layout --- + fn is_keyboard_layout_disabled(layout_key: &str) -> bool { dictionary_languages_for_layout(layout_key).is_empty() } @@ -1791,12 +1899,11 @@ fn is_keyboard_layout_disabled(layout_key: &str) -> bool { fn keyboard_layout_list_area(area: Rect) -> Rect { let centered = ui::layout::centered_rect(60, 70, area); let inner = Block::bordered().inner(centered); + let h_nav = t!("select.hint_navigate"); + let h_confirm = t!("select.hint_confirm"); + let h_back = t!("select.hint_back"); let hint_lines = pack_hint_lines( - &[ - "[Up/Down/PgUp/PgDn] Navigate", - "[Enter] Confirm", - "[ESC] Back", - ], + &[h_nav.as_ref(), h_confirm.as_ref(), h_back.as_ref()], inner.width as usize, ); let footer_height = (hint_lines.len() as u16).max(1); @@ -1896,11 +2003,10 @@ fn handle_keyboard_layout_mouse(app: &mut App, mouse: MouseEvent) { let area = terminal_area(); let centered = ui::layout::centered_rect(60, 70, area); let inner = Block::bordered().inner(centered); - let hints = [ - "[Up/Down/PgUp/PgDn] Navigate", - "[Enter] Confirm", - "[ESC] Back", - ]; + let h_nav = t!("select.hint_navigate"); + let h_confirm = t!("select.hint_confirm"); + let h_back = t!("select.hint_back"); + let hints: Vec<&str> = vec![h_nav.as_ref(), h_confirm.as_ref(), h_back.as_ref()]; let footer_h = pack_hint_lines(&hints, inner.width as usize).len().max(1) as u16; let chunks = if inner.height > footer_h { Some( @@ -2012,16 +2118,15 @@ fn code_language_list_area(app: &App, area: Rect) -> Rect { let inner = Block::bordered().inner(centered); let options = code_language_options(); let width = inner.width as usize; + let h_nav = t!("select.hint_navigate"); + let h_confirm = t!("select.hint_confirm"); + let h_back = t!("select.hint_back"); let hint_lines = pack_hint_lines( - &[ - "[Up/Down/PgUp/PgDn] Navigate", - "[Enter] Confirm", - "[ESC] Back", - ], + &[h_nav.as_ref(), h_confirm.as_ref(), h_back.as_ref()], width, ); - let disabled_notice = - " Some languages are disabled: enable network downloads in intro/settings."; + let disabled_notice_t = t!("select.disabled_network_notice"); + let disabled_notice = disabled_notice_t.as_ref(); let has_disabled = !app.config.code_downloads_enabled && options .iter() @@ -2060,14 +2165,13 @@ fn handle_code_language_mouse(app: &mut App, mouse: MouseEvent) { MouseEventKind::Down(MouseButton::Left) | MouseEventKind::Down(MouseButton::Right) => { let centered = ui::layout::centered_rect(50, 70, terminal_area()); let inner = Block::bordered().inner(centered); - let hints = [ - "[Up/Down/PgUp/PgDn] Navigate", - "[Enter] Confirm", - "[ESC] Back", - ]; + let h_nav = t!("select.hint_navigate"); + let h_confirm = t!("select.hint_confirm"); + let h_back = t!("select.hint_back"); + let hints: Vec<&str> = vec![h_nav.as_ref(), h_confirm.as_ref(), h_back.as_ref()]; let hint_lines = pack_hint_lines(&hints, inner.width as usize); - let disabled_notice = - " Some languages are disabled: enable network downloads in intro/settings."; + let disabled_notice_t = t!("select.disabled_network_notice"); + let disabled_notice = disabled_notice_t.as_ref(); let has_disabled = !app.config.code_downloads_enabled && options .iter() @@ -2206,12 +2310,15 @@ fn passage_book_list_area(app: &App, area: Rect) -> Rect { let inner = Block::bordered().inner(centered); let options = passage_options(); let width = inner.width as usize; + let h_nav_t = t!("select.hint_navigate"); + let h_confirm_t = t!("select.hint_confirm"); + let h_back_t = t!("select.hint_back"); let hint_lines = pack_hint_lines( - &["[Up/Down] Navigate", "[Enter] Confirm", "[ESC] Back"], + &[h_nav_t.as_ref(), h_confirm_t.as_ref(), h_back_t.as_ref()], width, ); - let disabled_notice = - " Some sources are disabled: enable network downloads in intro/settings."; + let disabled_notice_t = t!("select.disabled_sources_notice"); + let disabled_notice = disabled_notice_t.as_ref(); let has_disabled = !app.config.passage_downloads_enabled && options .iter() @@ -2249,10 +2356,13 @@ fn handle_passage_book_mouse(app: &mut App, mouse: MouseEvent) { MouseEventKind::Down(MouseButton::Left) | MouseEventKind::Down(MouseButton::Right) => { let centered = ui::layout::centered_rect(60, 70, terminal_area()); let inner = Block::bordered().inner(centered); - let hints = ["[Up/Down] Navigate", "[Enter] Confirm", "[ESC] Back"]; + let h_nav = t!("select.hint_navigate"); + let h_confirm = t!("select.hint_confirm"); + let h_back = t!("select.hint_back"); + let hints: Vec<&str> = vec![h_nav.as_ref(), h_confirm.as_ref(), h_back.as_ref()]; let hint_lines = pack_hint_lines(&hints, inner.width as usize); - let disabled_notice = - " Some sources are disabled: enable network downloads in intro/settings."; + let disabled_notice_t = t!("select.disabled_sources_notice"); + let disabled_notice = disabled_notice_t.as_ref(); let has_disabled = !app.config.passage_downloads_enabled && options .iter() @@ -2433,14 +2543,13 @@ fn intro_field_at_row(base_y: u16, y: u16) -> Option<(usize, bool)> { fn passage_intro_content_area(area: Rect) -> Rect { let centered = ui::layout::centered_rect(75, 80, area); let inner = Block::bordered().inner(centered); + let ih_nav = t!("intro.hint_navigate"); + let ih_adj = t!("intro.hint_adjust"); + let ih_edit = t!("intro.hint_edit"); + let ih_confirm = t!("intro.hint_confirm"); + let ih_cancel = t!("intro.hint_cancel"); let hint_lines = pack_hint_lines( - &[ - "[Up/Down] Navigate", - "[Left/Right] Adjust", - "[Type/Backspace] Edit", - "[Enter] Confirm", - "[ESC] Cancel", - ], + &[ih_nav.as_ref(), ih_adj.as_ref(), ih_edit.as_ref(), ih_confirm.as_ref(), ih_cancel.as_ref()], inner.width as usize, ); let footer_height = (hint_lines.len() + 1) as u16; @@ -2469,13 +2578,12 @@ fn handle_passage_intro_mouse(app: &mut App, mouse: MouseEvent) { MouseEventKind::Down(MouseButton::Left) | MouseEventKind::Down(MouseButton::Right) => { let centered = ui::layout::centered_rect(75, 80, terminal_area()); let inner = Block::bordered().inner(centered); - let hints = [ - "[Up/Down] Navigate", - "[Left/Right] Adjust", - "[Type/Backspace] Edit", - "[Enter] Confirm", - "[ESC] Cancel", - ]; + let ih_nav = t!("intro.hint_navigate"); + let ih_adj = t!("intro.hint_adjust"); + let ih_edit = t!("intro.hint_edit"); + let ih_confirm = t!("intro.hint_confirm"); + let ih_cancel = t!("intro.hint_cancel"); + let hints: Vec<&str> = vec![ih_nav.as_ref(), ih_adj.as_ref(), ih_edit.as_ref(), ih_confirm.as_ref(), ih_cancel.as_ref()]; let hint_lines = pack_hint_lines(&hints, inner.width as usize); let footer_height = (hint_lines.len() + 1) as u16; if footer_height > 0 && footer_height < inner.height { @@ -2650,14 +2758,13 @@ fn handle_code_intro_key(app: &mut App, key: KeyEvent) { fn code_intro_content_area(area: Rect) -> Rect { let centered = ui::layout::centered_rect(75, 80, area); let inner = Block::bordered().inner(centered); + let ih_nav = t!("intro.hint_navigate"); + let ih_adj = t!("intro.hint_adjust"); + let ih_edit = t!("intro.hint_edit"); + let ih_confirm = t!("intro.hint_confirm"); + let ih_cancel = t!("intro.hint_cancel"); let hint_lines = pack_hint_lines( - &[ - "[Up/Down] Navigate", - "[Left/Right] Adjust", - "[Type/Backspace] Edit", - "[Enter] Confirm", - "[ESC] Cancel", - ], + &[ih_nav.as_ref(), ih_adj.as_ref(), ih_edit.as_ref(), ih_confirm.as_ref(), ih_cancel.as_ref()], inner.width as usize, ); let footer_height = (hint_lines.len() + 1) as u16; @@ -2686,13 +2793,12 @@ fn handle_code_intro_mouse(app: &mut App, mouse: MouseEvent) { MouseEventKind::Down(MouseButton::Left) | MouseEventKind::Down(MouseButton::Right) => { let centered = ui::layout::centered_rect(75, 80, terminal_area()); let inner = Block::bordered().inner(centered); - let hints = [ - "[Up/Down] Navigate", - "[Left/Right] Adjust", - "[Type/Backspace] Edit", - "[Enter] Confirm", - "[ESC] Cancel", - ]; + let ih_nav = t!("intro.hint_navigate"); + let ih_adj = t!("intro.hint_adjust"); + let ih_edit = t!("intro.hint_edit"); + let ih_confirm = t!("intro.hint_confirm"); + let ih_cancel = t!("intro.hint_cancel"); + let hints: Vec<&str> = vec![ih_nav.as_ref(), ih_adj.as_ref(), ih_edit.as_ref(), ih_confirm.as_ref(), ih_cancel.as_ref()]; let hint_lines = pack_hint_lines(&hints, inner.width as usize); let footer_height = (hint_lines.len() + 1) as u16; if footer_height > 0 && footer_height < inner.height { @@ -2859,10 +2965,7 @@ struct SkillTreeMouseLayout { } fn locked_branch_notice(app: &App) -> String { - format!( - "Complete {} primary letters to unlock branches", - app.skill_tree.primary_letters().len() - ) + t!("skill_tree.locked_notice", count = app.skill_tree.primary_letters().len()).to_string() } fn skill_tree_interactive_areas(app: &App, area: Rect) -> SkillTreeMouseLayout { @@ -2875,39 +2978,44 @@ fn skill_tree_interactive_areas(app: &App, area: Rect) -> SkillTreeMouseLayout { let bp = branches .get(selected) .map(|id| app.skill_tree.branch_progress(*id)); + let st_nav = t!("skill_tree.hint_navigate"); + let st_scroll = t!("skill_tree.hint_scroll"); + let st_back = t!("skill_tree.hint_back"); + let st_unlock = t!("skill_tree.hint_unlock"); + let st_drill = t!("skill_tree.hint_start_drill"); let (footer_hints, footer_notice): (Vec<&str>, Option) = match bp.map(|b| b.status.clone()) { Some(BranchStatus::Locked) => ( vec![ - "[↑↓/jk] Navigate", - "[PgUp/PgDn or Ctrl+U/Ctrl+D] Scroll", - "[q] Back", + st_nav.as_ref(), + st_scroll.as_ref(), + st_back.as_ref(), ], Some(locked_branch_notice(app)), ), Some(BranchStatus::Available) => ( vec![ - "[Enter] Unlock", - "[↑↓/jk] Navigate", - "[PgUp/PgDn or Ctrl+U/Ctrl+D] Scroll", - "[q] Back", + st_unlock.as_ref(), + st_nav.as_ref(), + st_scroll.as_ref(), + st_back.as_ref(), ], None, ), Some(BranchStatus::InProgress) => ( vec![ - "[Enter] Start Drill", - "[↑↓/jk] Navigate", - "[PgUp/PgDn or Ctrl+U/Ctrl+D] Scroll", - "[q] Back", + st_drill.as_ref(), + st_nav.as_ref(), + st_scroll.as_ref(), + st_back.as_ref(), ], None, ), _ => ( vec![ - "[↑↓/jk] Navigate", - "[PgUp/PgDn or Ctrl+U/Ctrl+D] Scroll", - "[q] Back", + st_nav.as_ref(), + st_scroll.as_ref(), + st_back.as_ref(), ], None, ), @@ -3061,44 +3169,49 @@ fn handle_skill_tree_mouse(app: &mut App, mouse: MouseEvent) { .skill_tree_selected .min(branches.len().saturating_sub(1)); let bp = app.skill_tree.branch_progress(branches[selected]); + let st_nav = t!("skill_tree.hint_navigate"); + let st_scroll = t!("skill_tree.hint_scroll"); + let st_back = t!("skill_tree.hint_back"); + let st_unlock = t!("skill_tree.hint_unlock"); + let st_drill = t!("skill_tree.hint_start_drill"); let (footer_hints, footer_notice): (Vec<&str>, Option) = if *app.skill_tree.branch_status(branches[selected]) == engine::skill_tree::BranchStatus::Locked { ( vec![ - "[↑↓/jk] Navigate", - "[PgUp/PgDn or Ctrl+U/Ctrl+D] Scroll", - "[q] Back", + st_nav.as_ref(), + st_scroll.as_ref(), + st_back.as_ref(), ], Some(locked_branch_notice(app)), ) } else if bp.status == engine::skill_tree::BranchStatus::Available { ( vec![ - "[Enter] Unlock", - "[↑↓/jk] Navigate", - "[PgUp/PgDn or Ctrl+U/Ctrl+D] Scroll", - "[q] Back", + st_unlock.as_ref(), + st_nav.as_ref(), + st_scroll.as_ref(), + st_back.as_ref(), ], None, ) } else if bp.status == engine::skill_tree::BranchStatus::InProgress { ( vec![ - "[Enter] Start Drill", - "[↑↓/jk] Navigate", - "[PgUp/PgDn or Ctrl+U/Ctrl+D] Scroll", - "[q] Back", + st_drill.as_ref(), + st_nav.as_ref(), + st_scroll.as_ref(), + st_back.as_ref(), ], None, ) } else { ( vec![ - "[↑↓/jk] Navigate", - "[PgUp/PgDn or Ctrl+U/Ctrl+D] Scroll", - "[q] Back", + st_nav.as_ref(), + st_scroll.as_ref(), + st_back.as_ref(), ], None, ) @@ -3216,44 +3329,49 @@ fn skill_tree_detail_max_scroll(app: &App) -> usize { .skill_tree_selected .min(branches.len().saturating_sub(1)); let bp = app.skill_tree.branch_progress(branches[selected]); + let st_nav = t!("skill_tree.hint_navigate"); + let st_scroll = t!("skill_tree.hint_scroll"); + let st_back = t!("skill_tree.hint_back"); + let st_unlock = t!("skill_tree.hint_unlock"); + let st_drill = t!("skill_tree.hint_start_drill"); let (footer_hints, footer_notice): (Vec<&str>, Option) = if *app.skill_tree.branch_status(branches[selected]) == engine::skill_tree::BranchStatus::Locked { ( vec![ - "[↑↓/jk] Navigate", - "[PgUp/PgDn or Ctrl+U/Ctrl+D] Scroll", - "[q] Back", + st_nav.as_ref(), + st_scroll.as_ref(), + st_back.as_ref(), ], Some(locked_branch_notice(app)), ) } else if bp.status == engine::skill_tree::BranchStatus::Available { ( vec![ - "[Enter] Unlock", - "[↑↓/jk] Navigate", - "[PgUp/PgDn or Ctrl+U/Ctrl+D] Scroll", - "[q] Back", + st_unlock.as_ref(), + st_nav.as_ref(), + st_scroll.as_ref(), + st_back.as_ref(), ], None, ) } else if bp.status == engine::skill_tree::BranchStatus::InProgress { ( vec![ - "[Enter] Start Drill", - "[↑↓/jk] Navigate", - "[PgUp/PgDn or Ctrl+U/Ctrl+D] Scroll", - "[q] Back", + st_drill.as_ref(), + st_nav.as_ref(), + st_scroll.as_ref(), + st_back.as_ref(), ], None, ) } else { ( vec![ - "[↑↓/jk] Navigate", - "[PgUp/PgDn or Ctrl+U/Ctrl+D] Scroll", - "[q] Back", + st_nav.as_ref(), + st_scroll.as_ref(), + st_back.as_ref(), ], None, ) @@ -3337,6 +3455,7 @@ fn render(frame: &mut ratatui::Frame, app: &App) { AppScreen::CodeIntro => render_code_intro(frame, app), AppScreen::CodeDownloadProgress => render_code_download_progress(frame, app), AppScreen::Keyboard => render_keyboard_explorer(frame, app), + AppScreen::UiLanguageSelect => render_ui_language_select(frame, app), } } @@ -3344,13 +3463,19 @@ fn render_menu(frame: &mut ratatui::Frame, app: &App) { let area = frame.area(); let colors = &app.theme.colors; - let menu_hints = [ - "[1-3] Start", - "[t] Skill Tree", - "[b] Keyboard", - "[s] Stats", - "[c] Settings", - "[q] Quit", + let mh_start = t!("menu.hint_start"); + let mh_tree = t!("menu.hint_skill_tree"); + let mh_kbd = t!("menu.hint_keyboard"); + let mh_stats = t!("menu.hint_stats"); + let mh_settings = t!("menu.hint_settings"); + let mh_quit = t!("menu.hint_quit"); + let menu_hints: Vec<&str> = vec![ + mh_start.as_ref(), + mh_tree.as_ref(), + mh_kbd.as_ref(), + mh_stats.as_ref(), + mh_settings.as_ref(), + mh_quit.as_ref(), ]; let footer_lines_vec = pack_hint_lines(&menu_hints, area.width as usize); let footer_line_count = footer_lines_vec.len().max(1) as u16; @@ -3365,16 +3490,20 @@ fn render_menu(frame: &mut ratatui::Frame, app: &App) { .split(area); let streak_text = if app.profile.streak_days > 0 { - format!(" | {} day streak", app.profile.streak_days) + t!("menu.day_streak", days = app.profile.streak_days).to_string() } else { String::new() }; let total_keys = app.skill_tree.total_unique_keys; let unlocked = app.skill_tree.total_unlocked_count(); let mastered = app.skill_tree.total_confident_keys(&app.ranked_key_stats); - let header_info = format!( - " Key Progress {unlocked}/{total_keys} ({mastered} mastered) | Target {} WPM{}", - app.config.target_wpm, streak_text, + let header_info = t!( + "menu.key_progress", + unlocked = unlocked, + total = total_keys, + mastered = mastered, + target = app.config.target_wpm, + streak = streak_text, ); let header = Paragraph::new(Line::from(vec![ Span::styled( @@ -3385,7 +3514,7 @@ fn render_menu(frame: &mut ratatui::Frame, app: &App) { .add_modifier(Modifier::BOLD), ), Span::styled( - &*header_info, + header_info.as_ref(), Style::default() .fg(colors.text_pending()) .bg(colors.header_bg()), @@ -3418,21 +3547,24 @@ fn render_drill(frame: &mut ratatui::Frame, app: &App) { let app_layout = AppLayout::new(area); let tier = app_layout.tier; - let mode_name = match app.drill_mode { - DrillMode::Adaptive => "Adaptive", - DrillMode::Code => "Code (Unranked)", - DrillMode::Passage => "Passage (Unranked)", + let mode_name_t = match app.drill_mode { + DrillMode::Adaptive => t!("drill.mode_adaptive"), + DrillMode::Code => t!("drill.mode_code"), + DrillMode::Passage => t!("drill.mode_passage"), }; + let mode_name = mode_name_t.as_ref(); // Compute focus text from stored selection (what generated this drill's text) let focus_text = if let Some(ref focus) = app.current_focus { match (&focus.char_focus, &focus.bigram_focus) { (Some(ch), Some((key, _, _))) => { - format!(" | Focus: '{ch}' + \"{}{}\"", key.0[0], key.0[1]) + let bigram = format!("{}{}", key.0[0], key.0[1]); + format!(" | {}", t!("drill.focus_both", ch = ch, bigram = bigram)) } - (Some(ch), None) => format!(" | Focus: '{ch}'"), + (Some(ch), None) => format!(" | {}", t!("drill.focus_char", ch = ch)), (None, Some((key, _, _))) => { - format!(" | Focus: \"{}{}\"", key.0[0], key.0[1]) + let bigram = format!("{}{}", key.0[0], key.0[1]); + format!(" | {}", t!("drill.focus_bigram", bigram = bigram)) } (None, None) => String::new(), } @@ -3445,8 +3577,11 @@ fn render_drill(frame: &mut ratatui::Frame, app: &App) { let wpm = drill.wpm(); let accuracy = drill.accuracy(); let errors = drill.typo_count(); + let wpm_label = t!("drill.header_wpm"); + let acc_label = t!("drill.header_acc"); + let err_label = t!("drill.header_err"); let header_text = format!( - " {mode_name} | WPM: {wpm:.0} | Acc: {accuracy:.1}% | Err: {errors}{focus_text}" + " {mode_name} | {wpm_label}: {wpm:.0} | {acc_label}: {accuracy:.1}% | {err_label}: {errors}{focus_text}" ); let header = Paragraph::new(Line::from(Span::styled( &*header_text, @@ -3458,7 +3593,7 @@ fn render_drill(frame: &mut ratatui::Frame, app: &App) { .style(Style::default().bg(colors.header_bg())); frame.render_widget(header, app_layout.header); } else { - let header_title = format!(" {mode_name} Drill "); + let header_title = format!(" {mode_name}{}", t!("drill.title")); let header = Paragraph::new(Line::from(vec![ Span::styled( &*header_title, @@ -3565,11 +3700,12 @@ fn render_drill(frame: &mut ratatui::Frame, app: &App) { frame.render_widget(progress_widget, main_layout[idx]); } else { let source = app.drill_source_info.as_deref().unwrap_or("unknown source"); - let label = if app.drill_mode == DrillMode::Code { - " Code source " + let label_t = if app.drill_mode == DrillMode::Code { + t!("drill.code_source") } else { - " Passage source " + t!("drill.passage_source") }; + let label = label_t.as_ref(); let source_info = Paragraph::new(Line::from(vec![ Span::styled( label, @@ -3611,15 +3747,16 @@ fn render_drill(frame: &mut ratatui::Frame, app: &App) { frame.render_widget(sidebar, sidebar_area); } + let drill_footer_text = t!("drill.footer"); let footer = Paragraph::new(Line::from(Span::styled( - " [ESC] End drill [Backspace] Delete ", + format!(" {} ", drill_footer_text), Style::default().fg(colors.text_pending()), ))); frame.render_widget(footer, app_layout.footer); // Show a brief countdown overlay while the post-drill input lock is active. if let Some(ms) = app.post_drill_input_lock_remaining_ms() { - let msg = format!("Keys re-enabled in {}ms", ms); + let msg = t!("drill.keys_reenabled", ms = ms).to_string(); let width = msg.len() as u16 + 4; // border + padding let height = 3; let x = area.x + area.width.saturating_sub(width) / 2; @@ -3680,17 +3817,17 @@ fn render_milestone_overlay( // Clear the area behind the overlay frame.render_widget(ratatui::widgets::Clear, overlay_area); - let title = match milestone.kind { - MilestoneKind::Unlock => " Key Unlocked! ", - MilestoneKind::Mastery => " Key Mastered! ", - MilestoneKind::BranchesAvailable => " New Skill Branches Available! ", - MilestoneKind::BranchComplete => " Branch Complete! ", - MilestoneKind::AllKeysUnlocked => " Every Key Unlocked! ", - MilestoneKind::AllKeysMastered => " Full Keyboard Mastery! ", + let title_t = match milestone.kind { + MilestoneKind::Unlock => t!("milestones.unlock_title"), + MilestoneKind::Mastery => t!("milestones.mastery_title"), + MilestoneKind::BranchesAvailable => t!("milestones.branches_title"), + MilestoneKind::BranchComplete => t!("milestones.branch_complete_title"), + MilestoneKind::AllKeysUnlocked => t!("milestones.all_unlocked_title"), + MilestoneKind::AllKeysMastered => t!("milestones.all_mastered_title"), }; let block = Block::bordered() - .title(title) + .title(title_t.as_ref()) .border_style(Style::default().fg(colors.accent())) .style(Style::default().bg(colors.bg())); let inner = block.inner(overlay_area); @@ -3700,10 +3837,11 @@ fn render_milestone_overlay( match milestone.kind { MilestoneKind::Unlock | MilestoneKind::Mastery => { - let key_action = match milestone.kind { - MilestoneKind::Unlock => "unlocked", - _ => "mastered", + let key_action_t = match milestone.kind { + MilestoneKind::Unlock => t!("milestones.unlocked"), + _ => t!("milestones.mastered"), }; + let key_action = key_action_t.as_ref(); let key_names: Vec = milestone .keys @@ -3738,8 +3876,9 @@ fn render_milestone_overlay( name.to_string() } }; + let use_finger_msg = t!("milestones.use_finger", finger = finger_desc.as_str()); lines.push(Line::from(Span::styled( - format!(" {key_label}: Use your {finger_desc}"), + format!(" {key_label}: {use_finger_msg}"), Style::default().fg(colors.fg()), ))); @@ -3753,9 +3892,9 @@ fn render_milestone_overlay( && *ch != ' ') { let shift_hint = if fa.hand == keyboard::finger::Hand::Left { - "Hold Right Shift (right pinky)" + t!("milestones.hold_right_shift") } else { - "Hold Left Shift (left pinky)" + t!("milestones.hold_left_shift") }; lines.push(Line::from(Span::styled( format!(" {shift_hint}"), @@ -3776,124 +3915,137 @@ fn render_milestone_overlay( MilestoneKind::BranchesAvailable => { lines.push(Line::from("")); let primary_count = app.skill_tree.primary_letters().len(); + let congrats_msg = t!("milestones.congratulations_all_letters", count = primary_count); lines.push(Line::from(Span::styled( - format!(" Congratulations! You've mastered all {primary_count} primary letters"), + format!(" {congrats_msg}"), Style::default() .fg(colors.accent()) .add_modifier(Modifier::BOLD), ))); lines.push(Line::from("")); + let new_branches = t!("milestones.new_branches_available"); lines.push(Line::from(Span::styled( - " New skill branches are now available:", + format!(" {new_branches}"), Style::default().fg(colors.fg()), ))); for &branch_id in &milestone.branch_ids { - let name = get_branch_definition(branch_id).name; + let name = get_branch_definition(branch_id).display_name(); lines.push(Line::from(Span::styled( format!(" \u{2022} {name}"), Style::default().fg(colors.focused_key()), ))); } lines.push(Line::from("")); + let visit_msg = t!("milestones.visit_skill_tree"); lines.push(Line::from(Span::styled( - " Visit the Skill Tree to unlock a new branch", + format!(" {visit_msg}"), Style::default().fg(colors.fg()), ))); + let and_start = t!("milestones.and_start_training"); lines.push(Line::from(Span::styled( - " and start training!", + format!(" {and_start}"), Style::default().fg(colors.fg()), ))); lines.push(Line::from("")); + let open_tree = t!("milestones.open_skill_tree"); lines.push(Line::from(Span::styled( - " Press [t] to open the Skill Tree now", + format!(" {open_tree}"), Style::default().fg(colors.text_pending()), ))); } MilestoneKind::BranchComplete => { lines.push(Line::from("")); - let branch_names: Vec<&str> = milestone + let branch_names: Vec = milestone .branch_ids .iter() - .map(|&id| get_branch_definition(id).name) + .map(|&id| get_branch_definition(id).display_name()) .collect(); - let branches_text = if branch_names.len() == 1 { - format!(" You've fully mastered the {} branch!", branch_names[0]) + let branch_text = if branch_names.len() == 1 { + branch_names[0].to_string() } else { let all_but_last = &branch_names[..branch_names.len() - 1]; - let last = branch_names[branch_names.len() - 1]; - format!( - " You've fully mastered the {} and {} branches!", - all_but_last.join(", "), - last - ) + let last = &branch_names[branch_names.len() - 1]; + format!("{} and {}", all_but_last.join(", "), last) }; + let complete_msg = t!("milestones.branch_complete_msg", branch = branch_text); lines.push(Line::from(Span::styled( - branches_text, + format!(" {complete_msg}"), Style::default() .fg(colors.accent()) .add_modifier(Modifier::BOLD), ))); lines.push(Line::from("")); + let visit_msg = t!("milestones.visit_skill_tree"); lines.push(Line::from(Span::styled( - " Other branches are waiting to be unlocked in the", + format!(" {visit_msg}"), Style::default().fg(colors.fg()), ))); + let and_start = t!("milestones.and_start_training"); lines.push(Line::from(Span::styled( - " Skill Tree. Keep going!", + format!(" {and_start}"), Style::default().fg(colors.fg()), ))); lines.push(Line::from("")); + let open_tree = t!("milestones.open_skill_tree"); lines.push(Line::from(Span::styled( - " Press [t] to open the Skill Tree now", + format!(" {open_tree}"), Style::default().fg(colors.text_pending()), ))); } MilestoneKind::AllKeysUnlocked => { lines.push(Line::from("")); + let unlocked_msg = t!("milestones.all_unlocked_msg"); lines.push(Line::from(Span::styled( - " You've unlocked every key on the keyboard!", + format!(" {unlocked_msg}"), Style::default() .fg(colors.accent()) .add_modifier(Modifier::BOLD), ))); lines.push(Line::from("")); + let desc = t!("milestones.all_unlocked_desc"); lines.push(Line::from(Span::styled( - " All keys are now part of your practice drills.", + format!(" {desc}"), Style::default().fg(colors.fg()), ))); + let keep_practicing = t!("milestones.keep_practicing_mastery"); lines.push(Line::from(Span::styled( - " Keep training to build full confidence with each", + format!(" {keep_practicing}"), Style::default().fg(colors.fg()), ))); + let confidence = t!("milestones.confidence_complete"); lines.push(Line::from(Span::styled( - " key!", + format!(" {confidence}"), Style::default().fg(colors.fg()), ))); } MilestoneKind::AllKeysMastered => { lines.push(Line::from("")); + let mastered_msg = t!("milestones.all_mastered_msg"); lines.push(Line::from(Span::styled( - " Incredible! You've reached full confidence with", + format!(" {mastered_msg}"), Style::default() .fg(colors.accent()) .add_modifier(Modifier::BOLD), ))); + let mastered_desc = t!("milestones.all_mastered_desc"); lines.push(Line::from(Span::styled( - " every single key on the keyboard!", + format!(" {mastered_desc}"), Style::default() .fg(colors.accent()) .add_modifier(Modifier::BOLD), ))); lines.push(Line::from("")); + let takes_practice = t!("milestones.mastery_takes_practice"); lines.push(Line::from(Span::styled( - " You've completed everything keydr has to teach.", + format!(" {takes_practice}"), Style::default().fg(colors.fg()), ))); + let keep_drilling = t!("milestones.keep_drilling"); lines.push(Line::from(Span::styled( - " Keep practicing to maintain your skills!", + format!(" {keep_drilling}"), Style::default().fg(colors.fg()), ))); } @@ -3941,11 +4093,11 @@ fn render_milestone_overlay( if footer_y < inner.y + inner.height { let footer_area = Rect::new(inner.x, footer_y, inner.width, 1); let footer_text = if let Some(ms) = app.post_drill_input_lock_remaining_ms() { - format!(" Input temporarily blocked ({ms}ms remaining)") + format!(" {}", t!("milestones.input_blocked", ms = ms)) } else if milestone_supports_skill_tree_shortcut(milestone) { - " [t] Open Skill Tree [Any other key] Continue".to_string() + format!(" {}", t!("milestones.hint_skill_tree_continue")) } else { - " Press any key to continue".to_string() + format!(" {}", t!("milestones.hint_any_key")) }; let footer = Paragraph::new(Line::from(Span::styled( footer_text, @@ -4019,7 +4171,7 @@ mod review_tests { kind: crate::app::MilestoneKind::Unlock, keys: vec!['a'], finger_info: vec![('a', "left pinky".to_string())], - message: "msg", + message: "msg".to_string(), branch_ids: vec![], }); @@ -4043,7 +4195,7 @@ mod review_tests { kind: crate::app::MilestoneKind::Unlock, keys: vec!['a'], finger_info: vec![('a', "left pinky".to_string())], - message: "msg1", + message: "msg1".to_string(), branch_ids: vec![], }); app.milestone_queue @@ -4051,7 +4203,7 @@ mod review_tests { kind: crate::app::MilestoneKind::Mastery, keys: vec!['a'], finger_info: vec![('a', "left pinky".to_string())], - message: "msg2", + message: "msg2".to_string(), branch_ids: vec![], }); @@ -4102,7 +4254,7 @@ mod review_tests { kind: crate::app::MilestoneKind::Unlock, keys: vec!['a'], finger_info: vec![('a', "left pinky".to_string())], - message: "msg", + message: "msg".to_string(), branch_ids: vec![], }); app.post_drill_input_lock_until = @@ -4125,7 +4277,7 @@ mod review_tests { kind: crate::app::MilestoneKind::BranchesAvailable, keys: vec![], finger_info: vec![], - message: "msg", + message: "msg".to_string(), branch_ids: vec![engine::skill_tree::BranchId::Capitals], }); @@ -5268,7 +5420,7 @@ mod review_tests { kind: crate::app::MilestoneKind::BranchesAvailable, keys: vec![], finger_info: vec![], - message: "msg", + message: "msg".to_string(), branch_ids: vec![engine::skill_tree::BranchId::Capitals], }); @@ -5351,20 +5503,21 @@ fn render_result(frame: &mut ratatui::Frame, app: &App) { let dialog_area = Rect::new(dialog_x, dialog_y, dialog_width, dialog_height); let idx = app.drill_history.len().saturating_sub(app.history_selected); - let dialog_text = format!("Delete session #{idx}? (y/n)"); + let dialog_text = t!("stats.delete_confirm", idx = idx); frame.render_widget(ratatui::widgets::Clear, dialog_area); + let confirm_title = t!("stats.confirm_title"); let dialog = Paragraph::new(vec![ Line::from(""), Line::from(Span::styled( - format!(" {dialog_text} "), + format!(" {} ", dialog_text), Style::default().fg(colors.fg()), )), ]) .style(Style::default().bg(colors.bg())) .block( Block::bordered() - .title(" Confirm ") + .title(confirm_title.as_ref()) .border_style(Style::default().fg(colors.error())) .style(Style::default().bg(colors.bg())), ); @@ -5488,8 +5641,9 @@ fn render_settings(frame: &mut ratatui::Frame, app: &App) { let centered = ui::layout::centered_rect(60, 80, area); + let settings_title = t!("settings.title"); let block = Block::bordered() - .title(" Settings ") + .title(settings_title.as_ref()) .border_style(Style::default().fg(colors.accent())) .style(Style::default().bg(colors.bg())); let inner = block.inner(centered); @@ -5505,12 +5659,19 @@ fn render_settings(frame: &mut ratatui::Frame, app: &App) { .as_ref() .map(|(_, input)| input.completion_error) .unwrap_or(false); + let fh_move = t!("settings.hint_move"); + let fh_tab = t!("settings.hint_tab_complete"); + let fh_confirm = t!("settings.hint_confirm"); + let fh_cancel = t!("settings.hint_cancel"); + let fh_save = t!("settings.hint_save_back"); + let fh_change = t!("settings.hint_change_value"); + let fh_edit = t!("settings.hint_edit_path"); let footer_hints: Vec<&str> = if app.is_editing_path() { let mut hints = vec![ - "[←→] Move", - "[Tab] Complete (at end)", - "[Enter] Confirm", - "[Esc] Cancel", + fh_move.as_ref(), + fh_tab.as_ref(), + fh_confirm.as_ref(), + fh_cancel.as_ref(), ]; if completion_error { hints.push("(cannot read directory)"); @@ -5518,9 +5679,9 @@ fn render_settings(frame: &mut ratatui::Frame, app: &App) { hints } else { vec![ - "[ESC] Save & back", - "[Enter/arrows] Change value", - "[Enter on path] Edit", + fh_save.as_ref(), + fh_change.as_ref(), + fh_edit.as_ref(), ] }; let footer_packed = pack_hint_lines(&footer_hints, inner.width as usize); @@ -5541,8 +5702,9 @@ fn render_settings(frame: &mut ratatui::Frame, app: &App) { ]) .split(inner); + let subtitle = t!("settings.subtitle"); let header = Paragraph::new(Line::from(Span::styled( - " Use arrows to navigate, Enter/Right to change, ESC to save & exit", + format!(" {subtitle}"), Style::default().fg(colors.text_pending()), ))); header.render(layout[0], frame.buffer_mut()); @@ -5659,10 +5821,11 @@ fn render_settings(frame: &mut ratatui::Frame, app: &App) { StatusKind::Success => colors.accent(), StatusKind::Error => colors.error(), }; - let title = match msg.kind { - StatusKind::Success => " Success ", - StatusKind::Error => " Error ", + let title_t = match msg.kind { + StatusKind::Success => t!("settings.success_title"), + StatusKind::Error => t!("settings.error_title"), }; + let title = title_t.as_ref(); let dialog_width = 56u16.min(area.width.saturating_sub(4)); let dialog_height = 6u16; let dialog_x = area.x + area.width.saturating_sub(dialog_width) / 2; @@ -5678,7 +5841,7 @@ fn render_settings(frame: &mut ratatui::Frame, app: &App) { )), Line::from(""), Line::from(Span::styled( - " Press any key", + format!(" {}", t!("settings.press_any_key")), Style::default().fg(colors.text_pending()), )), ]) @@ -5699,25 +5862,28 @@ fn render_settings(frame: &mut ratatui::Frame, app: &App) { let dialog_area = Rect::new(dialog_x, dialog_y, dialog_width, dialog_height); frame.render_widget(ratatui::widgets::Clear, dialog_area); + let file_exists_msg = t!("settings.file_exists"); + let overwrite_rename = t!("settings.overwrite_rename"); let dialog = Paragraph::new(vec![ Line::from(""), Line::from(Span::styled( - " A file already exists at this path.", + format!(" {file_exists_msg}"), Style::default().fg(colors.fg()), )), Line::from(""), Line::from(Span::styled( - " [d] Overwrite [r] Rename [Esc] Cancel", + format!(" {overwrite_rename}"), Style::default().fg(colors.text_pending()), )), ]) .style(Style::default().bg(colors.bg())) - .block( + .block({ + let fe_title = t!("settings.file_exists_title"); Block::bordered() - .title(" File Exists ") + .title(fe_title.to_string()) .border_style(Style::default().fg(colors.error())) - .style(Style::default().bg(colors.bg())), - ); + .style(Style::default().bg(colors.bg())) + }); frame.render_widget(dialog, dialog_area); } else if app.settings_confirm_import { let dialog_width = 52u16.min(area.width.saturating_sub(4)); @@ -5727,29 +5893,33 @@ fn render_settings(frame: &mut ratatui::Frame, app: &App) { let dialog_area = Rect::new(dialog_x, dialog_y, dialog_width, dialog_height); frame.render_widget(ratatui::widgets::Clear, dialog_area); + let erase_warning = t!("settings.erase_warning"); + let export_first = t!("settings.export_first"); + let proceed_yn = t!("settings.proceed_yn"); let dialog = Paragraph::new(vec![ Line::from(""), Line::from(Span::styled( - " This will erase your current data.", + format!(" {erase_warning}"), Style::default().fg(colors.fg()), )), Line::from(Span::styled( - " Export first if you want to keep it.", + format!(" {export_first}"), Style::default().fg(colors.text_pending()), )), Line::from(""), Line::from(Span::styled( - " Proceed? (y/n)", + format!(" {proceed_yn}"), Style::default().fg(colors.fg()), )), ]) .style(Style::default().bg(colors.bg())) - .block( + .block({ + let ci_title = t!("settings.confirm_import_title"); Block::bordered() - .title(" Confirm Import ") + .title(ci_title.to_string()) .border_style(Style::default().fg(colors.error())) - .style(Style::default().bg(colors.bg())), - ); + .style(Style::default().bg(colors.bg())) + }); frame.render_widget(dialog, dialog_area); } } @@ -5759,21 +5929,21 @@ fn render_dictionary_language_select(frame: &mut ratatui::Frame, app: &App) { let colors = &app.theme.colors; let centered = ui::layout::centered_rect(60, 70, area); + let sel_title = t!("select.dictionary_language_title"); let block = Block::bordered() - .title(" Select Dictionary Language ") + .title(sel_title.as_ref()) .border_style(Style::default().fg(colors.accent())) .style(Style::default().bg(colors.bg())); let inner = block.inner(centered); block.render(centered, frame.buffer_mut()); let options = language_packs(); - let footer_hints = [ - "[Up/Down/PgUp/PgDn] Navigate", - "[Enter] Confirm", - "[ESC] Back", - ]; - let support_notice = - " Selecting a language resets keyboard layout to that language's default."; + let h_nav = t!("select.hint_navigate"); + let h_confirm = t!("select.hint_confirm"); + let h_back = t!("select.hint_back"); + let footer_hints: Vec<&str> = vec![h_nav.as_ref(), h_confirm.as_ref(), h_back.as_ref()]; + let support_notice_t = t!("select.language_resets_layout"); + let support_notice = support_notice_t.as_ref(); let width = inner.width as usize; let hint_lines_vec = pack_hint_lines(&footer_hints, width); let hint_lines = hint_lines_vec.len(); @@ -5798,8 +5968,9 @@ fn render_dictionary_language_select(frame: &mut ratatui::Frame, app: &App) { let mut lines: Vec = Vec::new(); if scroll > 0 { + let more_above = t!("select.more_above", count = scroll); lines.push(Line::from(Span::styled( - format!(" ... {} more above ...", scroll), + format!(" {more_above}"), Style::default().fg(colors.text_pending()), ))); } else { @@ -5811,14 +5982,15 @@ fn render_dictionary_language_select(frame: &mut ratatui::Frame, app: &App) { let is_selected = i == app.dictionary_language_selected; let is_current = pack.language_key == app.config.dictionary_language; let indicator = if is_selected { " > " } else { " " }; - let current_marker = if is_current { " (current)" } else { "" }; + let current_marker_t = if is_current { t!("select.current") } else { std::borrow::Cow::Borrowed("") }; + let current_marker = current_marker_t.as_ref(); let is_disabled = is_dictionary_language_disabled(app, pack.language_key); let default_layout = default_keyboard_layout_for_language(pack.language_key).unwrap_or("unknown"); let availability = if is_disabled { - " (disabled)".to_string() + t!("select.disabled").to_string() } else { - format!(" (enabled, default: {default_layout})") + t!("select.enabled_default", layout = default_layout).to_string() }; let name_style = if is_disabled { @@ -5846,8 +6018,9 @@ fn render_dictionary_language_select(frame: &mut ratatui::Frame, app: &App) { } if visible_end < options.len() { + let more_below = t!("select.more_below", count = options.len() - visible_end); lines.push(Line::from(Span::styled( - format!(" ... {} more below ...", options.len() - visible_end), + format!(" {more_below}"), Style::default().fg(colors.text_pending()), ))); } else { @@ -5878,25 +6051,86 @@ fn render_dictionary_language_select(frame: &mut ratatui::Frame, app: &App) { } } +fn render_ui_language_select(frame: &mut ratatui::Frame, app: &App) { + use crate::i18n::t; + let area = frame.area(); + let colors = &app.theme.colors; + let centered = ui::layout::centered_rect(50, 50, area); + + let title = t!("select.ui_language_title"); + let block = Block::bordered() + .title(title.as_ref()) + .border_style(Style::default().fg(colors.accent())) + .style(Style::default().bg(colors.bg())); + let inner = block.inner(centered); + block.render(centered, frame.buffer_mut()); + + let locales = i18n::SUPPORTED_UI_LOCALES; + let hint_back = t!("select.hint_back"); + let hint_confirm = t!("select.hint_confirm"); + let footer_hints = [hint_back.as_ref(), hint_confirm.as_ref()]; + let footer_lines = pack_hint_lines(&footer_hints, inner.width as usize); + let footer_h = footer_lines.len().max(1) as u16; + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(1), Constraint::Length(footer_h)]) + .split(inner); + + let mut lines: Vec = Vec::new(); + for (i, &locale) in locales.iter().enumerate() { + let is_selected = i == app.ui_language_selected; + let is_current = locale == app.config.ui_language; + let autonym = find_language_pack(locale) + .map(|p| p.autonym) + .unwrap_or(locale); + let suffix = if is_current { + t!("select.current").to_string() + } else { + String::new() + }; + let indicator = if is_selected { "> " } else { " " }; + let label = format!("{indicator}{autonym}{suffix}"); + let style = if is_selected { + Style::default() + .fg(colors.accent()) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(colors.fg()) + }; + lines.push(Line::from(Span::styled(label, style))); + } + + let list = Paragraph::new(lines); + list.render(chunks[0], frame.buffer_mut()); + + let footer: Vec = footer_lines + .into_iter() + .map(|l| Line::from(Span::styled(l, Style::default().fg(colors.text_pending())))) + .collect(); + Paragraph::new(footer).render(chunks[1], frame.buffer_mut()); +} + fn render_keyboard_layout_select(frame: &mut ratatui::Frame, app: &App) { let area = frame.area(); let colors = &app.theme.colors; let centered = ui::layout::centered_rect(60, 70, area); + let sel_title = t!("select.keyboard_layout_title"); let block = Block::bordered() - .title(" Select Keyboard Layout ") + .title(sel_title.as_ref()) .border_style(Style::default().fg(colors.accent())) .style(Style::default().bg(colors.bg())); let inner = block.inner(centered); block.render(centered, frame.buffer_mut()); let options = keyboard::model::KeyboardModel::supported_layout_keys(); - let footer_hints = [ - "[Up/Down/PgUp/PgDn] Navigate", - "[Enter] Confirm", - "[ESC] Back", - ]; - let support_notice = " Layout changes do not change dictionary language."; + let h_nav = t!("select.hint_navigate"); + let h_confirm = t!("select.hint_confirm"); + let h_back = t!("select.hint_back"); + let footer_hints: Vec<&str> = vec![h_nav.as_ref(), h_confirm.as_ref(), h_back.as_ref()]; + let support_notice_t = t!("select.layout_no_language_change"); + let support_notice = support_notice_t.as_ref(); let width = inner.width as usize; let hint_lines_vec = pack_hint_lines(&footer_hints, width); let hint_lines = hint_lines_vec.len(); @@ -5921,8 +6155,9 @@ fn render_keyboard_layout_select(frame: &mut ratatui::Frame, app: &App) { let mut lines: Vec = Vec::new(); if scroll > 0 { + let more_above = t!("select.more_above", count = scroll); lines.push(Line::from(Span::styled( - format!(" ... {} more above ...", scroll), + format!(" {more_above}"), Style::default().fg(colors.text_pending()), ))); } else { @@ -5934,21 +6169,22 @@ fn render_keyboard_layout_select(frame: &mut ratatui::Frame, app: &App) { let is_selected = i == app.keyboard_layout_selected; let is_current = key == app.config.keyboard_layout; let indicator = if is_selected { " > " } else { " " }; - let current_marker = if is_current { " (current)" } else { "" }; + let current_marker_t = if is_current { t!("select.current") } else { std::borrow::Cow::Borrowed("") }; + let current_marker = current_marker_t.as_ref(); let validation = validate_language_layout_pair(&app.config.dictionary_language, key); let (availability, is_disabled) = match validation { - Ok(CapabilityState::Enabled) => (" (enabled)".to_string(), false), - Ok(CapabilityState::Disabled) => (" (disabled)".to_string(), true), + Ok(CapabilityState::Enabled) => (t!("select.enabled").to_string(), false), + Ok(CapabilityState::Disabled) => (t!("select.disabled").to_string(), true), Err(crate::l10n::language_pack::LanguageLayoutValidationError::UnsupportedLanguageLayoutPair { .. }) => { - (" (disabled)".to_string(), true) + (t!("select.disabled").to_string(), true) } Err(crate::l10n::language_pack::LanguageLayoutValidationError::LanguageBlockedBySupportLevel(_)) => { - (" (disabled: blocked)".to_string(), true) + (t!("select.disabled_blocked").to_string(), true) } Err(crate::l10n::language_pack::LanguageLayoutValidationError::UnknownLanguage(_)) | Err(crate::l10n::language_pack::LanguageLayoutValidationError::UnknownLayout(_)) => { - (" (disabled)".to_string(), true) + (t!("select.disabled").to_string(), true) } }; @@ -5971,8 +6207,9 @@ fn render_keyboard_layout_select(frame: &mut ratatui::Frame, app: &App) { } if visible_end < options.len() { + let more_below = t!("select.more_below", count = options.len() - visible_end); lines.push(Line::from(Span::styled( - format!(" ... {} more below ...", options.len() - visible_end), + format!(" {more_below}"), Style::default().fg(colors.text_pending()), ))); } else { @@ -6008,8 +6245,9 @@ fn render_code_language_select(frame: &mut ratatui::Frame, app: &App) { let colors = &app.theme.colors; let centered = ui::layout::centered_rect(50, 70, area); + let sel_title = t!("select.code_language_title"); let block = Block::bordered() - .title(" Select Code Language ") + .title(sel_title.as_ref()) .border_style(Style::default().fg(colors.accent())) .style(Style::default().bg(colors.bg())); let inner = block.inner(centered); @@ -6017,13 +6255,12 @@ fn render_code_language_select(frame: &mut ratatui::Frame, app: &App) { let options = code_language_options(); let cache_dir = &app.config.code_download_dir; - let footer_hints = [ - "[Up/Down/PgUp/PgDn] Navigate", - "[Enter] Confirm", - "[ESC] Back", - ]; - let disabled_notice = - " Some languages are disabled: enable network downloads in intro/settings."; + let h_nav = t!("select.hint_navigate"); + let h_confirm = t!("select.hint_confirm"); + let h_back = t!("select.hint_back"); + let footer_hints: Vec<&str> = vec![h_nav.as_ref(), h_confirm.as_ref(), h_back.as_ref()]; + let disabled_notice_t = t!("select.disabled_sources_notice"); + let disabled_notice = disabled_notice_t.as_ref(); let has_disabled = !app.config.code_downloads_enabled && options .iter() @@ -6053,8 +6290,9 @@ fn render_code_language_select(frame: &mut ratatui::Frame, app: &App) { // Show scroll indicator at top if scrolled down if scroll > 0 { + let more_above = t!("select.more_above", count = scroll); lines.push(Line::from(Span::styled( - format!(" ... {} more above ...", scroll), + format!(" {more_above}"), Style::default().fg(colors.text_pending()), ))); } else { @@ -6070,20 +6308,21 @@ fn render_code_language_select(frame: &mut ratatui::Frame, app: &App) { let is_disabled = is_code_language_disabled(app, key); let indicator = if is_selected { " > " } else { " " }; - let current_marker = if is_current { " (current)" } else { "" }; + let current_marker_t = if is_current { t!("select.current") } else { std::borrow::Cow::Borrowed("") }; + let current_marker = current_marker_t.as_ref(); // Determine availability label let availability = if *key == "all" { String::new() } else if let Some(lang) = language_by_key(key) { if lang.has_builtin { - " (built-in)".to_string() + t!("select.built_in").to_string() } else if is_language_cached(cache_dir, key) { - " (cached)".to_string() + t!("select.cached").to_string() } else if is_disabled { - " (disabled: download required)".to_string() + t!("select.disabled_download").to_string() } else { - " (download required)".to_string() + t!("select.download_required").to_string() } } else { String::new() @@ -6114,8 +6353,9 @@ fn render_code_language_select(frame: &mut ratatui::Frame, app: &App) { // Show scroll indicator at bottom if more items below if visible_end < options.len() { + let more_below = t!("select.more_below", count = options.len() - visible_end); lines.push(Line::from(Span::styled( - format!(" ... {} more below ...", options.len() - visible_end), + format!(" {more_below}"), Style::default().fg(colors.text_pending()), ))); } else { @@ -6151,17 +6391,21 @@ fn render_passage_book_select(frame: &mut ratatui::Frame, app: &App) { let colors = &app.theme.colors; let centered = ui::layout::centered_rect(60, 70, area); + let sel_title = t!("select.passage_source_title"); let block = Block::bordered() - .title(" Select Passage Source ") + .title(sel_title.as_ref()) .border_style(Style::default().fg(colors.accent())) .style(Style::default().bg(colors.bg())); let inner = block.inner(centered); block.render(centered, frame.buffer_mut()); let options = passage_options(); - let footer_hints = ["[Up/Down] Navigate", "[Enter] Confirm", "[ESC] Back"]; - let disabled_notice = - " Some sources are disabled: enable network downloads in intro/settings."; + let h_nav = t!("intro.hint_navigate"); + let h_confirm = t!("select.hint_confirm"); + let h_back = t!("select.hint_back"); + let footer_hints: Vec<&str> = vec![h_nav.as_ref(), h_confirm.as_ref(), h_back.as_ref()]; + let disabled_notice_t = t!("select.disabled_sources_notice"); + let disabled_notice = disabled_notice_t.as_ref(); let has_disabled = !app.config.passage_downloads_enabled && options .iter() @@ -6197,13 +6441,13 @@ fn render_passage_book_select(frame: &mut ratatui::Frame, app: &App) { let availability = if *key == "all" { String::new() } else if *key == "builtin" { - " (built-in)".to_string() + t!("select.built_in").to_string() } else if is_book_cached(&app.config.passage_download_dir, key) { - " (cached)".to_string() + t!("select.cached").to_string() } else if is_disabled { - " (disabled: download required)".to_string() + t!("select.disabled_download").to_string() } else { - " (download required)".to_string() + t!("select.download_required").to_string() }; let name_style = if is_disabled { Style::default().fg(colors.text_pending()) @@ -6256,46 +6500,55 @@ fn render_passage_intro(frame: &mut ratatui::Frame, app: &App) { let colors = &app.theme.colors; let centered = ui::layout::centered_rect(75, 80, area); + let intro_title = t!("intro.passage_title"); let block = Block::bordered() - .title(" Passage Downloads Setup ") + .title(intro_title.as_ref()) .border_style(Style::default().fg(colors.accent())) .style(Style::default().bg(colors.bg())); let inner = block.inner(centered); block.render(centered, frame.buffer_mut()); let paragraphs_value = if app.passage_intro_paragraph_limit == 0 { - "whole book".to_string() + t!("intro.whole_book").to_string() } else { app.passage_intro_paragraph_limit.to_string() }; + let enable_label = t!("intro.enable_downloads"); + let dir_label = t!("intro.download_dir"); + let para_label = t!("intro.paragraphs_per_book"); + let start_label = t!("intro.start_passage_drill"); + let confirm_label = t!("intro.confirm"); let fields = vec![ ( - "Enable network downloads", + enable_label.as_ref(), if app.passage_intro_downloads_enabled { - "On".to_string() + t!("settings.on").to_string() } else { - "Off".to_string() + t!("settings.off").to_string() }, ), - ("Download directory", app.passage_intro_download_dir.clone()), - ("Paragraphs per book (0 = whole)", paragraphs_value), - ("Start passage drill", "Confirm".to_string()), + (dir_label.as_ref(), app.passage_intro_download_dir.clone()), + (para_label.as_ref(), paragraphs_value), + (start_label.as_ref(), confirm_label.to_string()), ]; + let instr1 = t!("intro.passage_instructions_1"); + let instr2 = t!("intro.passage_instructions_2"); + let instr3 = t!("intro.passage_instructions_3"); let mut lines = vec![ Line::from(Span::styled( - "Configure passage source settings before your first passage drill.", + instr1.as_ref(), Style::default() .fg(colors.fg()) .add_modifier(Modifier::BOLD), )), Line::from(Span::styled( - "Downloads are lazy: books are fetched only when first needed.", + instr2.as_ref(), Style::default().fg(colors.text_pending()), )), Line::from(Span::styled( - "If you exit without confirming, this dialog will appear again next time.", + instr3.as_ref(), Style::default().fg(colors.text_pending()), )), Line::from(""), @@ -6356,9 +6609,9 @@ fn render_passage_intro(frame: &mut ratatui::Frame, app: &App) { " ".repeat(width.saturating_sub(fill)) ); let progress_text = if total_bytes > 0 { - format!(" Downloading current book: [{bar}] {done_bytes}/{total_bytes} bytes") + format!(" {}", t!("intro.downloading_book_progress", bar = bar, downloaded = done_bytes, total = total_bytes)) } else { - format!(" Downloading current book: {done_bytes} bytes") + format!(" {}", t!("intro.downloading_book_bytes", bytes = done_bytes)) }; lines.push(Line::from(Span::styled( progress_text, @@ -6367,13 +6620,13 @@ fn render_passage_intro(frame: &mut ratatui::Frame, app: &App) { .add_modifier(Modifier::BOLD), ))); if !app.passage_intro_current_book.is_empty() { + let current_text = t!("intro.current_book", + name = app.passage_intro_current_book.as_str(), + done = done_books.saturating_add(1).min(total_books), + total = total_books + ); lines.push(Line::from(Span::styled( - format!( - " Current: {} (book {}/{})", - app.passage_intro_current_book, - done_books.saturating_add(1).min(total_books), - total_books - ), + format!(" {current_text}"), Style::default().fg(colors.text_pending()), ))); } @@ -6381,14 +6634,14 @@ fn render_passage_intro(frame: &mut ratatui::Frame, app: &App) { let hint_lines = if app.passage_intro_downloading { Vec::new() } else { + let ih_nav = t!("intro.hint_navigate"); + let ih_adj = t!("intro.hint_adjust"); + let ih_edit = t!("intro.hint_edit"); + let ih_confirm = t!("intro.hint_confirm"); + let ih_cancel = t!("intro.hint_cancel"); + let hints: Vec<&str> = vec![ih_nav.as_ref(), ih_adj.as_ref(), ih_edit.as_ref(), ih_confirm.as_ref(), ih_cancel.as_ref()]; pack_hint_lines( - &[ - "[Up/Down] Navigate", - "[Left/Right] Adjust", - "[Type/Backspace] Edit", - "[Enter] Confirm", - "[ESC] Cancel", - ], + &hints, inner.width as usize, ) }; @@ -6426,8 +6679,9 @@ fn render_passage_download_progress(frame: &mut ratatui::Frame, app: &App) { let colors = &app.theme.colors; let centered = ui::layout::centered_rect(60, 35, area); + let dl_title = t!("intro.download_passage_title"); let block = Block::bordered() - .title(" Downloading Passage Source ") + .title(dl_title.as_ref()) .border_style(Style::default().fg(colors.accent())) .style(Style::default().bg(colors.bg())); let inner = block.inner(centered); @@ -6450,14 +6704,15 @@ fn render_passage_download_progress(frame: &mut ratatui::Frame, app: &App) { ); let book_name = if app.passage_intro_current_book.is_empty() { - "Preparing download...".to_string() + t!("intro.preparing_download").to_string() } else { app.passage_intro_current_book.clone() }; + let book_label = t!("intro.book_label", name = book_name); let lines = vec![ Line::from(Span::styled( - format!(" Book: {book_name}"), + book_label.as_ref(), Style::default() .fg(colors.fg()) .add_modifier(Modifier::BOLD), @@ -6465,9 +6720,9 @@ fn render_passage_download_progress(frame: &mut ratatui::Frame, app: &App) { Line::from(""), Line::from(Span::styled( if total_bytes > 0 { - format!(" [{bar}] {done_bytes}/{total_bytes} bytes") + t!("intro.progress_bytes", name = bar, downloaded = done_bytes, total = total_bytes).to_string() } else { - format!(" Downloaded: {done_bytes} bytes") + t!("intro.downloaded_bytes", bytes = done_bytes).to_string() }, Style::default().fg(colors.accent()), )), @@ -6481,46 +6736,55 @@ fn render_code_intro(frame: &mut ratatui::Frame, app: &App) { let colors = &app.theme.colors; let centered = ui::layout::centered_rect(75, 80, area); + let intro_title = t!("intro.code_title"); let block = Block::bordered() - .title(" Code Downloads Setup ") + .title(intro_title.as_ref()) .border_style(Style::default().fg(colors.accent())) .style(Style::default().bg(colors.bg())); let inner = block.inner(centered); block.render(centered, frame.buffer_mut()); let snippets_value = if app.code_intro_snippets_per_repo == 0 { - "unlimited".to_string() + t!("intro.unlimited").to_string() } else { app.code_intro_snippets_per_repo.to_string() }; + let enable_label = t!("intro.enable_downloads"); + let dir_label = t!("intro.download_dir"); + let snippets_label = t!("intro.snippets_per_repo"); + let start_label = t!("intro.start_code_drill"); + let confirm_label = t!("intro.confirm"); let fields = vec![ ( - "Enable network downloads", + enable_label.as_ref(), if app.code_intro_downloads_enabled { - "On".to_string() + t!("settings.on").to_string() } else { - "Off".to_string() + t!("settings.off").to_string() }, ), - ("Download directory", app.code_intro_download_dir.clone()), - ("Snippets per repo (0 = unlimited)", snippets_value), - ("Start code drill", "Confirm".to_string()), + (dir_label.as_ref(), app.code_intro_download_dir.clone()), + (snippets_label.as_ref(), snippets_value), + (start_label.as_ref(), confirm_label.to_string()), ]; + let instr1 = t!("intro.code_instructions_1"); + let instr2 = t!("intro.code_instructions_2"); + let instr3 = t!("intro.code_instructions_3"); let mut lines = vec![ Line::from(Span::styled( - "Configure code source settings before your first code drill.", + instr1.as_ref(), Style::default() .fg(colors.fg()) .add_modifier(Modifier::BOLD), )), Line::from(Span::styled( - "Downloads are lazy: code is fetched only when first needed.", + instr2.as_ref(), Style::default().fg(colors.text_pending()), )), Line::from(Span::styled( - "If you exit without confirming, this dialog will appear again next time.", + instr3.as_ref(), Style::default().fg(colors.text_pending()), )), Line::from(""), @@ -6581,9 +6845,9 @@ fn render_code_intro(frame: &mut ratatui::Frame, app: &App) { " ".repeat(width.saturating_sub(fill)) ); let progress_text = if total_bytes > 0 { - format!(" Downloading: [{bar}] {done_bytes}/{total_bytes} bytes") + format!(" {}", t!("intro.downloading_code_progress", bar = bar, downloaded = done_bytes, total = total_bytes)) } else { - format!(" Downloading: {done_bytes} bytes") + format!(" {}", t!("intro.downloading_code_bytes", bytes = done_bytes)) }; lines.push(Line::from(Span::styled( progress_text, @@ -6592,13 +6856,13 @@ fn render_code_intro(frame: &mut ratatui::Frame, app: &App) { .add_modifier(Modifier::BOLD), ))); if !app.code_intro_current_repo.is_empty() { + let current_text = t!("intro.current_repo", + name = app.code_intro_current_repo.as_str(), + done = done_repos.saturating_add(1).min(total_repos), + total = total_repos + ); lines.push(Line::from(Span::styled( - format!( - " Current: {} (repo {}/{})", - app.code_intro_current_repo, - done_repos.saturating_add(1).min(total_repos), - total_repos - ), + format!(" {current_text}"), Style::default().fg(colors.text_pending()), ))); } @@ -6606,14 +6870,14 @@ fn render_code_intro(frame: &mut ratatui::Frame, app: &App) { let hint_lines = if app.code_intro_downloading { Vec::new() } else { + let ih_nav = t!("intro.hint_navigate"); + let ih_adj = t!("intro.hint_adjust"); + let ih_edit = t!("intro.hint_edit"); + let ih_confirm = t!("intro.hint_confirm"); + let ih_cancel = t!("intro.hint_cancel"); + let hints: Vec<&str> = vec![ih_nav.as_ref(), ih_adj.as_ref(), ih_edit.as_ref(), ih_confirm.as_ref(), ih_cancel.as_ref()]; pack_hint_lines( - &[ - "[Up/Down] Navigate", - "[Left/Right] Adjust", - "[Type/Backspace] Edit", - "[Enter] Confirm", - "[ESC] Cancel", - ], + &hints, inner.width as usize, ) }; @@ -6651,8 +6915,9 @@ fn render_code_download_progress(frame: &mut ratatui::Frame, app: &App) { let colors = &app.theme.colors; let centered = ui::layout::centered_rect(60, 35, area); + let dl_title = t!("intro.download_code_title"); let block = Block::bordered() - .title(" Downloading Code Source ") + .title(dl_title.as_ref()) .border_style(Style::default().fg(colors.accent())) .style(Style::default().bg(colors.bg())); let inner = block.inner(centered); @@ -6675,14 +6940,16 @@ fn render_code_download_progress(frame: &mut ratatui::Frame, app: &App) { ); let repo_name = if app.code_intro_current_repo.is_empty() { - "Preparing download...".to_string() + t!("intro.preparing_download").to_string() } else { app.code_intro_current_repo.clone() }; + let repo_label = t!("intro.repo_label", name = repo_name); + let cancel_hint = t!("intro.hint_cancel"); let lines = vec![ Line::from(Span::styled( - format!(" Repo: {repo_name}"), + repo_label.as_ref(), Style::default() .fg(colors.fg()) .add_modifier(Modifier::BOLD), @@ -6690,15 +6957,15 @@ fn render_code_download_progress(frame: &mut ratatui::Frame, app: &App) { Line::from(""), Line::from(Span::styled( if total_bytes > 0 { - format!(" [{bar}] {done_bytes}/{total_bytes} bytes") + t!("intro.progress_bytes", name = bar, downloaded = done_bytes, total = total_bytes).to_string() } else { - format!(" Downloaded: {done_bytes} bytes") + t!("intro.downloaded_bytes", bytes = done_bytes).to_string() }, Style::default().fg(colors.accent()), )), Line::from(""), Line::from(Span::styled( - " [ESC] Cancel", + format!(" {}", cancel_hint.as_ref()), Style::default().fg(colors.text_pending()), )), ]; @@ -6720,9 +6987,11 @@ fn render_skill_tree(frame: &mut ratatui::Frame, app: &App) { frame.render_widget(widget, centered); if let Some(branch_id) = app.skill_tree_confirm_unlock { - let sentence_one = "Once unlocked, the default adaptive drill will mix in keys in this branch that are unlocked."; - let sentence_two = "If you want to focus only on this branch, launch a drill directly from this branch in the Skill Tree."; - let branch_name = engine::skill_tree::get_branch_definition(branch_id).name; + let sentence_one_t = t!("skill_tree.unlock_msg_1"); + let sentence_one = sentence_one_t.as_ref(); + let sentence_two_t = t!("skill_tree.unlock_msg_2"); + let sentence_two = sentence_two_t.as_ref(); + let branch_name = engine::skill_tree::get_branch_definition(branch_id).display_name(); let dialog_width = 72u16.min(area.width.saturating_sub(4)); let content_width = dialog_width.saturating_sub(6).max(1) as usize; // border + side margins let body_required = 4 // blank + title + blank + blank-between-sentences @@ -6741,8 +7010,9 @@ fn render_skill_tree(frame: &mut ratatui::Frame, app: &App) { let dialog_area = Rect::new(dialog_x, dialog_y, dialog_width, dialog_height); frame.render_widget(ratatui::widgets::Clear, dialog_area); + let confirm_title = t!("stats.confirm_title"); let block = Block::bordered() - .title(" Confirm Unlock ") + .title(confirm_title.as_ref()) .border_style(Style::default().fg(colors.error())) .style(Style::default().bg(colors.bg())); let inner = block.inner(dialog_area); @@ -6764,10 +7034,11 @@ fn render_skill_tree(frame: &mut ratatui::Frame, app: &App) { .direction(Direction::Vertical) .constraints([Constraint::Min(1), Constraint::Length(prompt_block_height)]) .split(content); + let unlock_prompt = t!("skill_tree.confirm_unlock", branch = branch_name); let body = Paragraph::new(vec![ Line::from(""), Line::from(Span::styled( - format!("Unlock {branch_name}?"), + unlock_prompt.to_string(), Style::default().fg(colors.fg()), )), Line::from(""), @@ -6784,17 +7055,18 @@ fn render_skill_tree(frame: &mut ratatui::Frame, app: &App) { .wrap(Wrap { trim: false }) .style(Style::default().bg(colors.bg())); frame.render_widget(body, content_layout[0]); + let confirm_yn = t!("skill_tree.confirm_yn"); let confirm_lines = if prompt_block_height > 1 { vec![ Line::from(""), Line::from(Span::styled( - "Proceed? (y/n)", + confirm_yn.as_ref(), Style::default().fg(colors.fg()), )), ] } else { vec![Line::from(Span::styled( - "Proceed? (y/n)", + confirm_yn.as_ref(), Style::default().fg(colors.fg()), ))] }; @@ -6849,7 +7121,8 @@ fn handle_keyboard_explorer_mouse(app: &mut App, mouse: MouseEvent) { Constraint::Length(1), ]) .split(area); - let footer_hints = ["[ESC] Back"]; + let h_back = t!("keyboard.hint_back"); + let footer_hints: Vec<&str> = vec![h_back.as_ref()]; if hint_token_at(layout[3], &footer_hints, mouse.column, mouse.row).is_some() || point_in_rect(mouse.column, mouse.row, layout[3]) { @@ -6901,16 +7174,18 @@ fn render_keyboard_explorer(frame: &mut ratatui::Frame, app: &App) { .split(area); // Header + let kbd_title = t!("keyboard.title"); + let kbd_hint_nav = t!("keyboard.subtitle"); let header_lines = vec![ Line::from(""), Line::from(Span::styled( - " Keyboard Explorer ", + kbd_title.as_ref(), Style::default() .fg(colors.accent()) .add_modifier(Modifier::BOLD), )), Line::from(Span::styled( - "Press any key or click a key", + kbd_hint_nav.as_ref(), Style::default().fg(colors.text_pending()), )), ]; @@ -6935,8 +7210,9 @@ fn render_keyboard_explorer(frame: &mut ratatui::Frame, app: &App) { render_keyboard_detail_panel(frame, app, layout[2]); // Footer + let kbd_back = t!("keyboard.hint_back"); let footer = Paragraph::new(Line::from(vec![Span::styled( - " [ESC] Back ", + format!(" {} ", kbd_back.as_ref()), Style::default().fg(colors.text_pending()), )])); frame.render_widget(footer, layout[3]); @@ -6948,15 +7224,17 @@ fn render_keyboard_detail_panel(frame: &mut ratatui::Frame, app: &App, area: Rec let selected = match app.keyboard_explorer_selected { Some(ch) => ch, None => { + let press_hint = t!("keyboard.press_key_hint"); + let key_details_title = t!("keyboard.key_details"); let hint = Paragraph::new(Line::from(Span::styled( - "Press a key to see its details", + press_hint.as_ref(), Style::default().fg(colors.text_pending()), ))) .alignment(ratatui::layout::Alignment::Center) .block( Block::bordered() .border_style(Style::default().fg(colors.border())) - .title(" Key Details "), + .title(key_details_title.to_string()), ); frame.render_widget(hint, area); return; @@ -6966,9 +7244,9 @@ fn render_keyboard_detail_panel(frame: &mut ratatui::Frame, app: &App, area: Rec // Build display name for title let display_name = key_display_name(selected); let title = if display_name.is_empty() { - format!(" Key Details: '{}' ", selected) + t!("keyboard.key_details_char", ch = selected).to_string() } else { - format!(" Key Details: {} ", display_name) + t!("keyboard.key_details_name", name = display_name).to_string() }; let block = Block::bordered() @@ -6987,12 +7265,12 @@ fn render_keyboard_detail_panel(frame: &mut ratatui::Frame, app: &App, area: Rec selected.is_uppercase() || app.keyboard_model.shifted_to_base(selected).is_some(); let shift_guidance = if is_shifted { if finger.hand == Hand::Left { - "Hold Right Shift (right pinky)".to_string() + t!("milestones.hold_right_shift").to_string() } else { - "Hold Left Shift (left pinky)".to_string() + t!("milestones.hold_left_shift").to_string() } } else { - "No".to_string() + t!("keyboard.shift_no").to_string() }; let unlocked_keys = app.skill_tree.unlocked_keys(DrillScope::Global); @@ -7017,7 +7295,7 @@ fn render_keyboard_detail_panel(frame: &mut ratatui::Frame, app: &App, area: Rec return format!("{:.0}ms", stat.filtered_time_ms); } } - "No data".to_string() + t!("keyboard.no_data_short").to_string() }; let fmt_best_time = |stat: Option<&crate::engine::key_stats::KeyStat>| -> String { if let Some(stat) = stat { @@ -7030,7 +7308,7 @@ fn render_keyboard_detail_panel(frame: &mut ratatui::Frame, app: &App, area: Rec return format!("{best:.0}ms"); } } - "No data".to_string() + t!("keyboard.no_data_short").to_string() }; let fmt_samples = |stat: Option<&crate::engine::key_stats::KeyStat>| -> String { stat.map(|s| s.sample_count.to_string()) @@ -7043,11 +7321,11 @@ fn render_keyboard_detail_panel(frame: &mut ratatui::Frame, app: &App, area: Rec return format!("{:.1}% ({}/{})", pct, correct, total); } } - "No data".to_string() + t!("keyboard.no_data_short").to_string() }; let branch_info = find_key_branch(selected) - .map(|(branch, level, pos)| (branch.name.to_string(), format!("{level} (key #{pos})"))); + .map(|(branch, level, pos)| (branch.display_name(), format!("{} (key #{pos})", level.display_name()))); // Ranked-only mastery display (same semantics as skill tree per-key progress) let ranked_conf = app.ranked_key_stats.get_confidence(selected).min(1.0); @@ -7061,44 +7339,50 @@ fn render_keyboard_detail_panel(frame: &mut ratatui::Frame, app: &App, area: Rec let mastery_text = format!("{mastery_bar} {:>3.0}%", ranked_conf * 100.0); let mut left_col: Vec = vec![ - format!("Finger: {}", finger.description()), - format!("Shift: {shift_guidance}"), - format!("Overall Avg Time: {}", fmt_avg_time(overall_stat)), - format!("Overall Best Time: {}", fmt_best_time(overall_stat)), - format!("Overall Samples: {}", fmt_samples(overall_stat)), - format!("Overall Accuracy: {}", fmt_acc(overall_acc)), + format!("{}{}", t!("keyboard.finger_label"), finger.localized_description()), + format!("{}{shift_guidance}", t!("keyboard.shift_label")), + format!("{}{}", t!("keyboard.overall_avg_time"), fmt_avg_time(overall_stat)), + format!("{}{}", t!("keyboard.overall_best_time"), fmt_best_time(overall_stat)), + format!("{}{}", t!("keyboard.overall_samples"), fmt_samples(overall_stat)), + format!("{}{}", t!("keyboard.overall_accuracy_label"), fmt_acc(overall_acc)), ]; let mut right_col: Vec = Vec::new(); if let Some((branch_name, level_name)) = branch_info { - right_col.push(format!("Branch: {branch_name}")); - right_col.push(format!("Level: {level_name}")); + right_col.push(format!("{}{branch_name}", t!("keyboard.branch_label"))); + right_col.push(format!("{}{level_name}", t!("keyboard.level_label"))); } else { - right_col.push("Built-in Key".to_string()); + right_col.push(t!("keyboard.built_in_key").to_string()); } + let yes_t = t!("keyboard.yes"); + let no_t = t!("keyboard.no"); right_col.push(format!( - "Unlocked: {}", - if is_unlocked { "Yes" } else { "No" } + "{}{}", + t!("keyboard.unlocked_label"), + if is_unlocked { yes_t.as_ref() } else { no_t.as_ref() } )); + let yes_t2 = t!("keyboard.yes"); + let no_t2 = t!("keyboard.no"); right_col.push(format!( - "In Focus?: {}", - if in_focus { "Yes" } else { "No" } + "{}{}", + t!("keyboard.in_focus_label"), + if in_focus { yes_t2.as_ref() } else { no_t2.as_ref() } )); if is_unlocked { - right_col.push(format!("Mastery: {mastery_text}")); + right_col.push(format!("{}{mastery_text}", t!("keyboard.mastery_label"))); } else { - right_col.push("Mastery: Locked".to_string()); + right_col.push(format!("{}{}", t!("keyboard.mastery_label"), t!("keyboard.mastery_locked"))); } - right_col.push(format!("Ranked Avg Time: {}", fmt_avg_time(ranked_stat))); - right_col.push(format!("Ranked Best Time: {}", fmt_best_time(ranked_stat))); - right_col.push(format!("Ranked Samples: {}", fmt_samples(ranked_stat))); - right_col.push(format!("Ranked Accuracy: {}", fmt_acc(ranked_acc))); + right_col.push(format!("{}{}", t!("keyboard.ranked_avg_time"), fmt_avg_time(ranked_stat))); + right_col.push(format!("{}{}", t!("keyboard.ranked_best_time"), fmt_best_time(ranked_stat))); + right_col.push(format!("{}{}", t!("keyboard.ranked_samples"), fmt_samples(ranked_stat))); + right_col.push(format!("{}{}", t!("keyboard.ranked_accuracy_label"), fmt_acc(ranked_acc))); if left_col.is_empty() { - left_col.push("No data yet".to_string()); + left_col.push(t!("keyboard.no_data").to_string()); } if right_col.is_empty() { - right_col.push("No data yet".to_string()); + right_col.push(t!("keyboard.no_data").to_string()); } let mut lines: Vec = Vec::new(); diff --git a/src/ui/components/activity_heatmap.rs b/src/ui/components/activity_heatmap.rs index d3573c7..9e00647 100644 --- a/src/ui/components/activity_heatmap.rs +++ b/src/ui/components/activity_heatmap.rs @@ -7,6 +7,7 @@ use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Widget}; +use crate::i18n::t; use crate::session::result::DrillResult; use crate::ui::theme::Theme; @@ -27,7 +28,7 @@ impl Widget for ActivityHeatmap<'_> { let block = Block::bordered() .title(Line::from(Span::styled( - " Daily Activity (Sessions per Day) ", + t!("heatmap.title"), Style::default() .fg(colors.accent()) .add_modifier(Modifier::BOLD), @@ -87,27 +88,27 @@ impl Widget for ActivityHeatmap<'_> { // Month label on first row let month = current_date.month(); if month != last_month { - let month_name = match month { - 1 => "Jan", - 2 => "Feb", - 3 => "Mar", - 4 => "Apr", - 5 => "May", - 6 => "Jun", - 7 => "Jul", - 8 => "Aug", - 9 => "Sep", - 10 => "Oct", - 11 => "Nov", - 12 => "Dec", - _ => "", + let month_name: std::borrow::Cow<'_, str> = match month { + 1 => t!("heatmap.jan"), + 2 => t!("heatmap.feb"), + 3 => t!("heatmap.mar"), + 4 => t!("heatmap.apr"), + 5 => t!("heatmap.may"), + 6 => t!("heatmap.jun"), + 7 => t!("heatmap.jul"), + 8 => t!("heatmap.aug"), + 9 => t!("heatmap.sep"), + 10 => t!("heatmap.oct"), + 11 => t!("heatmap.nov"), + 12 => t!("heatmap.dec"), + _ => std::borrow::Cow::Borrowed(""), }; // Only show if we have space (3 chars) if x + 3 <= inner.x + inner.width { buf.set_string( x, inner.y, - month_name, + month_name.as_ref(), Style::default().fg(colors.text_pending()), ); } diff --git a/src/ui/components/branch_progress_list.rs b/src/ui/components/branch_progress_list.rs index 9bf1b6a..1c68436 100644 --- a/src/ui/components/branch_progress_list.rs +++ b/src/ui/components/branch_progress_list.rs @@ -5,6 +5,7 @@ use ratatui::text::{Line, Span}; use ratatui::widgets::{Paragraph, Widget}; use crate::engine::skill_tree::{BranchId, DrillScope, SkillTree, get_branch_definition}; +use crate::i18n::t; use crate::ui::theme::Theme; pub struct BranchProgressList<'a> { @@ -92,7 +93,7 @@ impl Widget for BranchProgressList<'_> { let (m_bar, u_bar, e_bar) = compact_dual_bar_parts(mastered, unlocked, total, 12); lines.push(Line::from(vec![ Span::styled( - format!(" \u{25b6} {:<14}", def.name), + format!(" \u{25b6} {:<14}", def.display_name()), Style::default().fg(colors.accent()), ), Span::styled(m_bar, Style::default().fg(colors.text_correct())), @@ -123,9 +124,12 @@ impl Widget for BranchProgressList<'_> { 0 }; let right_pad = if area.width >= 75 { 2 } else { 0 }; - let label = format!("{}Overall Key Progress ", " ".repeat(left_pad)); + let overall_label = t!("progress.overall_key_progress"); + let label = format!("{}{} ", " ".repeat(left_pad), overall_label); + let unlocked_mastered = t!("progress.unlocked_mastered", unlocked = unlocked, total = total, mastered = mastered); let suffix = format!( - " {unlocked}/{total} unlocked ({mastered} mastered){}", + " {}{}", + unlocked_mastered, " ".repeat(right_pad) ); let reserved = label.len() + suffix.len(); @@ -185,7 +189,8 @@ fn render_branch_cell<'a>( let fixed = prefix.len() + name_width + 1 + count.len(); let bar_width = cell_width.saturating_sub(fixed).max(6); let (m_bar, u_bar, e_bar) = compact_dual_bar_parts(mastered, unlocked, total, bar_width); - let name = truncate_and_pad(def.name, name_width); + let display = def.display_name(); + let name = truncate_and_pad(&display, name_width); let mut spans: Vec = vec![ Span::styled(prefix.to_string(), Style::default().fg(label_color)), diff --git a/src/ui/components/chart.rs b/src/ui/components/chart.rs index 95281ce..c618bb5 100644 --- a/src/ui/components/chart.rs +++ b/src/ui/components/chart.rs @@ -4,6 +4,7 @@ use ratatui::style::Style; use ratatui::symbols; use ratatui::widgets::{Axis, Block, Chart, Dataset, GraphType, Widget}; +use crate::i18n::t; use crate::ui::theme::Theme; #[allow(dead_code)] @@ -24,8 +25,9 @@ impl Widget for WpmChart<'_> { let colors = &self.theme.colors; if self.data.is_empty() { + let wpm_title = t!("chart.wpm_over_time"); let block = Block::bordered() - .title(" WPM Over Time ") + .title(wpm_title.to_string()) .border_style(Style::default().fg(colors.border())); block.render(area, buf); return; @@ -45,21 +47,24 @@ impl Widget for WpmChart<'_> { .style(Style::default().fg(colors.accent())) .data(self.data); + let wpm_title = t!("chart.wpm_over_time"); + let drill_number_label = t!("chart.drill_number"); + let wpm_label = t!("common.wpm"); let chart = Chart::new(vec![dataset]) .block( Block::bordered() - .title(" WPM Over Time ") + .title(wpm_title.to_string()) .border_style(Style::default().fg(colors.border())), ) .x_axis( Axis::default() - .title("Drill #") + .title(drill_number_label.to_string()) .style(Style::default().fg(colors.text_pending())) .bounds([0.0, max_x]), ) .y_axis( Axis::default() - .title("WPM") + .title(wpm_label.to_string()) .style(Style::default().fg(colors.text_pending())) .bounds([0.0, max_y * 1.1]), ); diff --git a/src/ui/components/dashboard.rs b/src/ui/components/dashboard.rs index 84aa81f..61f0fdc 100644 --- a/src/ui/components/dashboard.rs +++ b/src/ui/components/dashboard.rs @@ -4,6 +4,7 @@ use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Paragraph, Widget}; +use crate::i18n::t; use crate::session::result::DrillResult; use crate::ui::layout::pack_hint_lines; use crate::ui::theme::Theme; @@ -32,8 +33,9 @@ impl Widget for Dashboard<'_> { fn render(self, area: Rect, buf: &mut Buffer) { let colors = &self.theme.colors; + let title_text = t!("dashboard.title"); let block = Block::bordered() - .title(" Drill Complete ") + .title(title_text.to_string()) .border_style(Style::default().fg(colors.accent())) .style(Style::default().bg(colors.bg())); let inner = block.inner(area); @@ -42,12 +44,17 @@ impl Widget for Dashboard<'_> { let footer_line_count = if self.input_lock_remaining_ms.is_some() { 1u16 } else { + let hint_continue = t!("dashboard.hint_continue"); + let hint_retry = t!("dashboard.hint_retry"); + let hint_menu = t!("dashboard.hint_menu"); + let hint_stats = t!("dashboard.hint_stats"); + let hint_delete = t!("dashboard.hint_delete"); let hints = [ - "[c/Enter/Space] Continue", - "[r] Retry", - "[q] Menu", - "[s] Stats", - "[x] Delete", + hint_continue.as_ref(), + hint_retry.as_ref(), + hint_menu.as_ref(), + hint_stats.as_ref(), + hint_delete.as_ref(), ]; pack_hint_lines(&hints, inner.width as usize).len().max(1) as u16 }; @@ -65,25 +72,32 @@ impl Widget for Dashboard<'_> { ]) .split(inner); + let results_label = t!("dashboard.results"); let mut title_spans = vec![Span::styled( - "Results", + results_label.to_string(), Style::default() .fg(colors.accent()) .add_modifier(Modifier::BOLD), )]; if !self.result.ranked { + let unranked_note = format!( + "{}\u{2014}{}", + t!("dashboard.unranked_note_prefix"), + t!("dashboard.unranked_note_suffix") + ); title_spans.push(Span::styled( - " (Unranked \u{2014} does not count toward skill tree)", + unranked_note, Style::default().fg(colors.text_pending()), )); } let title = Paragraph::new(Line::from(title_spans)).alignment(Alignment::Center); title.render(layout[0], buf); + let speed_label = t!("dashboard.speed"); let wpm_text = format!("{:.0} WPM", self.result.wpm); let cpm_text = format!(" ({:.0} CPM)", self.result.cpm); let wpm_line = Line::from(vec![ - Span::styled(" Speed: ", Style::default().fg(colors.fg())), + Span::styled(speed_label.to_string(), Style::default().fg(colors.fg())), Span::styled( &*wpm_text, Style::default() @@ -101,31 +115,31 @@ impl Widget for Dashboard<'_> { } else { colors.error() }; + let accuracy_label = t!("dashboard.accuracy_label"); let acc_text = format!("{:.1}%", self.result.accuracy); - let acc_detail = format!( - " ({}/{} correct)", - self.result.correct, self.result.total_chars - ); + let acc_detail = t!("dashboard.correct_detail", correct = self.result.correct, total = self.result.total_chars); let acc_line = Line::from(vec![ - Span::styled(" Accuracy: ", Style::default().fg(colors.fg())), + Span::styled(accuracy_label.to_string(), Style::default().fg(colors.fg())), Span::styled( &*acc_text, Style::default().fg(acc_color).add_modifier(Modifier::BOLD), ), - Span::styled(&*acc_detail, Style::default().fg(colors.text_pending())), + Span::styled(acc_detail.to_string(), Style::default().fg(colors.text_pending())), ]); Paragraph::new(acc_line).render(layout[2], buf); + let time_label = t!("dashboard.time_label"); let time_text = format!("{:.1}s", self.result.elapsed_secs); let time_line = Line::from(vec![ - Span::styled(" Time: ", Style::default().fg(colors.fg())), + Span::styled(time_label.to_string(), Style::default().fg(colors.fg())), Span::styled(&*time_text, Style::default().fg(colors.fg())), ]); Paragraph::new(time_line).render(layout[3], buf); + let errors_label = t!("dashboard.errors_label"); let error_text = format!("{}", self.result.incorrect); let chars_line = Line::from(vec![ - Span::styled(" Errors: ", Style::default().fg(colors.fg())), + Span::styled(errors_label.to_string(), Style::default().fg(colors.fg())), Span::styled( &*error_text, Style::default().fg(if self.result.incorrect == 0 { @@ -138,25 +152,32 @@ impl Widget for Dashboard<'_> { Paragraph::new(chars_line).render(layout[4], buf); let help = if let Some(ms) = self.input_lock_remaining_ms { + let input_blocked_label = t!("dashboard.input_blocked"); + let input_blocked_ms = t!("dashboard.input_blocked_ms", ms = ms); Paragraph::new(Line::from(vec![ Span::styled( - " Input temporarily blocked ", + input_blocked_label.to_string(), Style::default().fg(colors.warning()), ), Span::styled( - format!("({ms}ms remaining)"), + input_blocked_ms.to_string(), Style::default() .fg(colors.warning()) .add_modifier(Modifier::BOLD), ), ])) } else { + let hint_continue = t!("dashboard.hint_continue"); + let hint_retry = t!("dashboard.hint_retry"); + let hint_menu = t!("dashboard.hint_menu"); + let hint_stats = t!("dashboard.hint_stats"); + let hint_delete = t!("dashboard.hint_delete"); let hints = [ - "[c/Enter/Space] Continue", - "[r] Retry", - "[q] Menu", - "[s] Stats", - "[x] Delete", + hint_continue.as_ref(), + hint_retry.as_ref(), + hint_menu.as_ref(), + hint_stats.as_ref(), + hint_delete.as_ref(), ]; let lines: Vec = pack_hint_lines(&hints, inner.width as usize) .into_iter() diff --git a/src/ui/components/menu.rs b/src/ui/components/menu.rs index 3d0981e..dd40909 100644 --- a/src/ui/components/menu.rs +++ b/src/ui/components/menu.rs @@ -4,16 +4,20 @@ use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Paragraph, Widget}; +use crate::i18n::t; use crate::ui::theme::Theme; -pub struct MenuItem { - pub key: String, - pub label: String, - pub description: String, -} +const MENU_ITEMS: &[(&str, &str, &str)] = &[ + ("1", "menu.adaptive_drill", "menu.adaptive_drill_desc"), + ("2", "menu.code_drill", "menu.code_drill_desc"), + ("3", "menu.passage_drill", "menu.passage_drill_desc"), + ("t", "menu.skill_tree", "menu.skill_tree_desc"), + ("b", "menu.keyboard", "menu.keyboard_desc"), + ("s", "menu.statistics", "menu.statistics_desc"), + ("c", "menu.settings", "menu.settings_desc"), +]; pub struct Menu<'a> { - pub items: Vec, pub selected: usize, pub theme: &'a Theme, } @@ -21,57 +25,24 @@ pub struct Menu<'a> { impl<'a> Menu<'a> { pub fn new(theme: &'a Theme) -> Self { Self { - items: vec![ - MenuItem { - key: "1".to_string(), - label: "Adaptive Drill".to_string(), - description: "Phonetic words with adaptive letter unlocking".to_string(), - }, - MenuItem { - key: "2".to_string(), - label: "Code Drill".to_string(), - description: "Practice typing code syntax".to_string(), - }, - MenuItem { - key: "3".to_string(), - label: "Passage Drill".to_string(), - description: "Type passages from books".to_string(), - }, - MenuItem { - key: "t".to_string(), - label: "Skill Tree".to_string(), - description: "View progression branches and launch drills".to_string(), - }, - MenuItem { - key: "b".to_string(), - label: "Keyboard".to_string(), - description: "Explore keyboard layout and key statistics".to_string(), - }, - MenuItem { - key: "s".to_string(), - label: "Statistics".to_string(), - description: "View your typing statistics".to_string(), - }, - MenuItem { - key: "c".to_string(), - label: "Settings".to_string(), - description: "Configure keydr".to_string(), - }, - ], selected: 0, theme, } } + pub fn item_count() -> usize { + MENU_ITEMS.len() + } + pub fn next(&mut self) { - self.selected = (self.selected + 1) % self.items.len(); + self.selected = (self.selected + 1) % MENU_ITEMS.len(); } pub fn prev(&mut self) { if self.selected > 0 { self.selected -= 1; } else { - self.selected = self.items.len() - 1; + self.selected = MENU_ITEMS.len() - 1; } } } @@ -95,6 +66,7 @@ impl Widget for &Menu<'_> { ]) .split(inner); + let subtitle = t!("menu.subtitle"); let title_lines = vec![ Line::from(""), Line::from(Span::styled( @@ -104,7 +76,7 @@ impl Widget for &Menu<'_> { .add_modifier(Modifier::BOLD), )), Line::from(Span::styled( - "Terminal Typing Tutor", + subtitle.as_ref(), Style::default().fg(colors.fg()), )), Line::from(""), @@ -116,33 +88,31 @@ impl Widget for &Menu<'_> { let menu_layout = Layout::default() .direction(Direction::Vertical) .constraints( - self.items + MENU_ITEMS .iter() .map(|_| Constraint::Length(3)) .collect::>(), ) .split(layout[2]); - let key_width = self - .items + let key_width = MENU_ITEMS .iter() - .map(|item| item.key.len()) + .map(|(key, _, _)| key.len()) .max() .unwrap_or(1); - for (i, item) in self.items.iter().enumerate() { + for (i, &(key, label_key, desc_key)) in MENU_ITEMS.iter().enumerate() { let is_selected = i == self.selected; let indicator = if is_selected { ">" } else { " " }; + let label = t!(label_key); + let description = t!(desc_key); let label_text = format!( " {indicator} [{key: SkillTreeWidget<'a> { } fn locked_branch_notice(skill_tree: &SkillTreeEngine) -> String { - format!( - "Complete {} primary letters to unlock branches", - skill_tree.primary_letters().len() - ) + t!("skill_tree.locked_notice", count = skill_tree.primary_letters().len()).to_string() } /// Get the list of selectable branch IDs (Lowercase first, then other branches). @@ -131,7 +129,7 @@ impl Widget for SkillTreeWidget<'_> { let colors = &self.theme.colors; let block = Block::bordered() - .title(" Skill Tree ") + .title(t!("skill_tree.title").to_string()) .border_style(Style::default().fg(colors.accent())) .style(Style::default().bg(colors.bg())); let inner = block.inner(area); @@ -139,44 +137,49 @@ impl Widget for SkillTreeWidget<'_> { // Layout: main split (branch list + detail) and footer (adaptive height) let branches = selectable_branches(); + let h_navigate = t!("skill_tree.hint_navigate").to_string(); + let h_scroll = t!("skill_tree.hint_scroll").to_string(); + let h_back = t!("skill_tree.hint_back").to_string(); + let h_unlock = t!("skill_tree.hint_unlock").to_string(); + let h_start_drill = t!("skill_tree.hint_start_drill").to_string(); let (footer_hints, footer_notice): (Vec<&str>, Option) = if self.selected < branches.len() { let bp = self.skill_tree.branch_progress(branches[self.selected]); if *self.skill_tree.branch_status(branches[self.selected]) == BranchStatus::Locked { ( vec![ - "[↑↓/jk] Navigate", - "[PgUp/PgDn or Ctrl+U/Ctrl+D] Scroll", - "[q] Back", + h_navigate.as_str(), + h_scroll.as_str(), + h_back.as_str(), ], Some(locked_branch_notice(self.skill_tree)), ) } else if bp.status == BranchStatus::Available { ( vec![ - "[Enter] Unlock", - "[↑↓/jk] Navigate", - "[PgUp/PgDn or Ctrl+U/Ctrl+D] Scroll", - "[q] Back", + h_unlock.as_str(), + h_navigate.as_str(), + h_scroll.as_str(), + h_back.as_str(), ], None, ) } else if bp.status == BranchStatus::InProgress { ( vec![ - "[Enter] Start Drill", - "[↑↓/jk] Navigate", - "[PgUp/PgDn or Ctrl+U/Ctrl+D] Scroll", - "[q] Back", + h_start_drill.as_str(), + h_navigate.as_str(), + h_scroll.as_str(), + h_back.as_str(), ], None, ) } else { ( vec![ - "[↑↓/jk] Navigate", - "[PgUp/PgDn or Ctrl+U/Ctrl+D] Scroll", - "[q] Back", + h_navigate.as_str(), + h_scroll.as_str(), + h_back.as_str(), ], None, ) @@ -184,9 +187,9 @@ impl Widget for SkillTreeWidget<'_> { } else { ( vec![ - "[↑↓/jk] Navigate", - "[PgUp/PgDn or Ctrl+U/Ctrl+D] Scroll", - "[q] Back", + h_navigate.as_str(), + h_scroll.as_str(), + h_back.as_str(), ], None, ) @@ -332,33 +335,35 @@ impl SkillTreeWidget<'_> { let unlocked = self.skill_tree.branch_unlocked_count(branch_id); let mastered_text = if confident_keys > 0 { - format!(" ({confident_keys} mastered)") + format!(" ({confident_keys} {})", t!("skill_tree.mastered")) } else { String::new() }; let status_text = match bp.status { BranchStatus::Complete => { - format!("{unlocked}/{total_keys} unlocked{mastered_text}") + format!("{unlocked}/{total_keys} {}{mastered_text}", t!("skill_tree.unlocked")) } BranchStatus::InProgress => { if branch_id == BranchId::Lowercase { - format!("{unlocked}/{total_keys} unlocked{mastered_text}") + format!("{unlocked}/{total_keys} {}{mastered_text}", t!("skill_tree.unlocked")) } else { format!( - "Lvl {}/{} {unlocked}/{total_keys} unlocked{mastered_text}", + "{} {}/{} {unlocked}/{total_keys} {}{mastered_text}", + t!("skill_tree.lvl_prefix"), bp.current_level + 1, - def.levels.len() + def.levels.len(), + t!("skill_tree.unlocked") ) } } - BranchStatus::Available => format!("0/{total_keys} unlocked"), - BranchStatus::Locked => format!("Locked 0/{total_keys}"), + BranchStatus::Available => format!("0/{total_keys} {}", t!("skill_tree.unlocked")), + BranchStatus::Locked => format!("{} 0/{total_keys}", t!("skill_tree.locked")), }; let sel_indicator = if is_selected { "> " } else { " " }; lines.push(Line::from(vec![ - Span::styled(format!("{sel_indicator}{prefix}{}", def.name), style), + Span::styled(format!("{sel_indicator}{prefix}{}", def.display_name()), style), Span::styled( format!(" {status_text}"), Style::default().fg(colors.text_pending()), @@ -381,8 +386,8 @@ impl SkillTreeWidget<'_> { } lines.push(Line::from(Span::styled( format!( - " \u{2500}\u{2500} Branches (available after {} primary letters) \u{2500}\u{2500}", - self.skill_tree.primary_letters().len() + " \u{2500}\u{2500} {} \u{2500}\u{2500}", + t!("skill_tree.branches_separator", count = self.skill_tree.primary_letters().len()) ), Style::default().fg(colors.text_pending()), ))); @@ -423,21 +428,21 @@ impl SkillTreeWidget<'_> { let level_text = if branch_id == BranchId::Lowercase { let unlocked = self.skill_tree.branch_unlocked_count(BranchId::Lowercase); let total = self.skill_tree.branch_total_keys_for(BranchId::Lowercase); - format!("Unlocked {unlocked}/{total} letters") + t!("skill_tree.unlocked_letters", unlocked = unlocked, total = total).to_string() } else { match bp.status { BranchStatus::InProgress => { - format!("Level {}/{}", bp.current_level + 1, def.levels.len()) + t!("skill_tree.level", current = bp.current_level + 1, total = def.levels.len()).to_string() } BranchStatus::Complete => { - format!("Level {}/{}", def.levels.len(), def.levels.len()) + t!("skill_tree.level", current = def.levels.len(), total = def.levels.len()).to_string() } - _ => format!("Level 0/{}", def.levels.len()), + _ => t!("skill_tree.level_zero", total = def.levels.len()).to_string(), } }; lines.push(Line::from(vec![ Span::styled( - format!(" {}", def.name), + format!(" {}", def.display_name()), Style::default() .fg(colors.accent()) .add_modifier(Modifier::BOLD), @@ -462,18 +467,20 @@ impl SkillTreeWidget<'_> { }; for (level_idx, level) in def.levels.iter().enumerate() { - let level_status = - if bp.status == BranchStatus::Complete || level_idx < bp.current_level { - "complete" - } else if bp.status == BranchStatus::InProgress && level_idx == bp.current_level { - "in progress" - } else { - "locked" - }; + let level_is_locked = !(bp.status == BranchStatus::Complete || level_idx < bp.current_level + || (bp.status == BranchStatus::InProgress && level_idx == bp.current_level)); + let level_status_owned = if bp.status == BranchStatus::Complete || level_idx < bp.current_level { + t!("skill_tree.complete").to_string() + } else if bp.status == BranchStatus::InProgress && level_idx == bp.current_level { + t!("skill_tree.in_progress").to_string() + } else { + t!("skill_tree.locked_status").to_string() + }; + let level_status = level_status_owned.as_str(); // Level header lines.push(Line::from(Span::styled( - format!(" L{}: {} ({level_status})", level_idx + 1, level.name), + format!(" L{}: {} ({level_status})", level_idx + 1, level.display_name()), Style::default().fg(colors.fg()), ))); @@ -492,7 +499,7 @@ impl SkillTreeWidget<'_> { let is_locked = if branch_id == BranchId::Lowercase { !lowercase_unlocked_keys.contains(&key) } else { - level_status == "locked" + level_is_locked }; let display = if key == '\n' { @@ -509,7 +516,7 @@ impl SkillTreeWidget<'_> { format!(" {display} "), Style::default().fg(colors.text_pending()), ), - Span::styled("locked", Style::default().fg(colors.text_pending())), + Span::styled(t!("skill_tree.locked_status").to_string(), Style::default().fg(colors.text_pending())), ])); } else { let bar_width = 10; @@ -517,7 +524,7 @@ impl SkillTreeWidget<'_> { let empty = bar_width - filled; let bar = format!("{}{}", "\u{2588}".repeat(filled), "\u{2591}".repeat(empty)); let pct_str = format!("{:>3.0}%", confidence * 100.0); - let focus_label = if is_focused { " in focus" } else { "" }; + let focus_label = if is_focused { t!("skill_tree.in_focus").to_string() } else { String::new() }; let key_style = if is_focused { Style::default() diff --git a/src/ui/components/stats_dashboard.rs b/src/ui/components/stats_dashboard.rs index 1371e09..aff1d1c 100644 --- a/src/ui/components/stats_dashboard.rs +++ b/src/ui/components/stats_dashboard.rs @@ -12,6 +12,7 @@ use crate::keyboard::display::{self, BACKSPACE, ENTER, MODIFIER_SENTINELS, SPACE use crate::keyboard::model::KeyboardModel; use crate::session::result::DrillResult; use crate::ui::components::activity_heatmap::ActivityHeatmap; +use crate::i18n::t; use crate::ui::layout::pack_hint_lines; use crate::ui::theme::Theme; @@ -95,8 +96,9 @@ impl Widget for StatsDashboard<'_> { fn render(self, area: Rect, buf: &mut Buffer) { let colors = &self.theme.colors; + let title = t!("stats.title"); let block = Block::bordered() - .title(" Statistics ") + .title(title.as_ref()) .border_style(Style::default().fg(colors.accent())) .style(Style::default().bg(colors.bg())); let inner = block.inner(area); @@ -104,7 +106,7 @@ impl Widget for StatsDashboard<'_> { if self.history.is_empty() { let msg = Paragraph::new(Line::from(Span::styled( - "No drills completed yet. Start typing!", + t!("stats.empty").to_string(), Style::default().fg(colors.text_pending()), ))); msg.render(inner, buf); @@ -113,10 +115,11 @@ impl Widget for StatsDashboard<'_> { // Tab header — width-aware wrapping let width = inner.width as usize; + let labels = tab_labels(); let mut tab_lines: Vec = Vec::new(); let mut current_spans: Vec = Vec::new(); let mut current_width: usize = 0; - for (i, &label) in TAB_LABELS.iter().enumerate() { + for (i, label) in labels.iter().enumerate() { let styled_label = format!(" {label} "); let item_width = styled_label.chars().count() + TAB_SEPARATOR.len(); if current_width > 0 && current_width + item_width > width { @@ -141,12 +144,13 @@ impl Widget for StatsDashboard<'_> { let tab_line_count = tab_lines.len().max(1) as u16; // Footer — width-aware wrapping - let footer_hints: Vec<&str> = if self.active_tab == 1 { - FOOTER_HINTS_HISTORY.to_vec() + let footer_hints = if self.active_tab == 1 { + footer_hints_history() } else { - FOOTER_HINTS_DEFAULT.to_vec() + footer_hints_default() }; - let footer_lines_vec = pack_hint_lines(&footer_hints, width); + let hint_refs: Vec<&str> = footer_hints.iter().map(|s| s.as_str()).collect(); + let footer_lines_vec = pack_hint_lines(&hint_refs, width); let footer_line_count = footer_lines_vec.len().max(1) as u16; let layout = Layout::default() @@ -179,7 +183,8 @@ impl Widget for StatsDashboard<'_> { let dialog_area = Rect::new(dialog_x, dialog_y, dialog_width, dialog_height); let idx = self.history.len().saturating_sub(self.history_selected); - let dialog_text = format!("Delete session #{idx}? (y/n)"); + let dialog_text = t!("stats.delete_confirm", idx = idx); + let confirm_title = t!("stats.confirm_title"); Clear.render(dialog_area, buf); let dialog = Paragraph::new(vec![ @@ -192,7 +197,7 @@ impl Widget for StatsDashboard<'_> { .style(Style::default().bg(colors.bg())) .block( Block::bordered() - .title(" Confirm ") + .title(confirm_title.as_ref()) .border_style(Style::default().fg(colors.error())) .style(Style::default().bg(colors.bg())), ); @@ -281,9 +286,10 @@ impl StatsDashboard<'_> { let avg_acc_str = format!("{avg_accuracy:.1}%"); let time_str = format_duration(total_time); + let summary_title = t!("stats.summary_title"); let summary_block = Block::bordered() .title(Line::from(Span::styled( - " Summary ", + summary_title.to_string(), Style::default() .fg(colors.accent()) .add_modifier(Modifier::BOLD), @@ -292,18 +298,23 @@ impl StatsDashboard<'_> { let summary_inner = summary_block.inner(layout[0]); summary_block.render(layout[0], buf); + let drills_label = t!("stats.drills"); + let avg_wpm_label = t!("stats.avg_wpm"); + let best_wpm_label = t!("stats.best_wpm"); + let accuracy_label = t!("stats.accuracy_label"); + let total_time_label = t!("stats.total_time"); let summary = vec![ Line::from(vec![ - Span::styled(" Drills: ", Style::default().fg(colors.fg())), + Span::styled(drills_label.to_string(), Style::default().fg(colors.fg())), Span::styled( &*total_str, Style::default() .fg(colors.accent()) .add_modifier(Modifier::BOLD), ), - Span::styled(" Avg WPM: ", Style::default().fg(colors.fg())), + Span::styled(avg_wpm_label.to_string(), Style::default().fg(colors.fg())), Span::styled(&*avg_wpm_str, Style::default().fg(colors.accent())), - Span::styled(" Best WPM: ", Style::default().fg(colors.fg())), + Span::styled(best_wpm_label.to_string(), Style::default().fg(colors.fg())), Span::styled( &*best_wpm_str, Style::default() @@ -312,7 +323,7 @@ impl StatsDashboard<'_> { ), ]), Line::from(vec![ - Span::styled(" Accuracy: ", Style::default().fg(colors.fg())), + Span::styled(accuracy_label.to_string(), Style::default().fg(colors.fg())), Span::styled( &*avg_acc_str, Style::default().fg(if avg_accuracy >= 95.0 { @@ -323,7 +334,7 @@ impl StatsDashboard<'_> { colors.error() }), ), - Span::styled(" Total time: ", Style::default().fg(colors.fg())), + Span::styled(total_time_label.to_string(), Style::default().fg(colors.fg())), Span::styled(&*time_str, Style::default().fg(colors.text_pending())), ]), ]; @@ -345,10 +356,10 @@ impl StatsDashboard<'_> { fn render_wpm_bar_graph(&self, area: Rect, buf: &mut Buffer) { let colors = &self.theme.colors; - let target_label = format!(" WPM per Drill (Last 20, Target: {}) ", self.target_wpm); + let target_label = t!("stats.wpm_chart_title", target = self.target_wpm); let block = Block::bordered() .title(Line::from(Span::styled( - target_label, + target_label.to_string(), Style::default() .fg(colors.accent()) .add_modifier(Modifier::BOLD), @@ -490,7 +501,7 @@ impl StatsDashboard<'_> { if data.is_empty() { let block = Block::bordered() .title(Line::from(Span::styled( - " Accuracy % (Last 50 Drills) ", + t!("stats.accuracy_chart_title").to_string(), Style::default() .fg(colors.accent()) .add_modifier(Modifier::BOLD), @@ -514,7 +525,7 @@ impl StatsDashboard<'_> { .block( Block::bordered() .title(Line::from(Span::styled( - " Accuracy % (Last 50 Drills) ", + t!("stats.accuracy_chart_title").to_string(), Style::default() .fg(colors.accent()) .add_modifier(Modifier::BOLD), @@ -524,13 +535,13 @@ impl StatsDashboard<'_> { ) .x_axis( Axis::default() - .title("Drill #") + .title(t!("stats.chart_drill").to_string()) .style(Style::default().fg(colors.text_pending()).bg(colors.bg())) .bounds([0.0, max_x]), ) .y_axis( Axis::default() - .title("Accuracy %") + .title(t!("stats.chart_accuracy_pct").to_string()) .style(Style::default().fg(colors.text_pending()).bg(colors.bg())) .labels(vec![ Span::styled( @@ -575,7 +586,7 @@ impl StatsDashboard<'_> { } else { colors.accent() }; - let wpm_label = format!(" WPM: {avg_wpm:.0}/{} ({wpm_pct:.0}%)", self.target_wpm); + let wpm_label = t!("stats.wpm_label", avg = format!("{avg_wpm:.0}"), target = self.target_wpm, pct = format!("{wpm_pct:.0}")).to_string(); render_text_bar( &wpm_label, wpm_pct / 100.0, @@ -587,7 +598,7 @@ impl StatsDashboard<'_> { // Accuracy progress let acc_pct = avg_accuracy.min(100.0); - let acc_label = format!(" Acc: {acc_pct:.1}%"); + let acc_label = t!("stats.acc_label", pct = format!("{acc_pct:.1}")).to_string(); let acc_color = if acc_pct >= 95.0 { colors.success() } else if acc_pct >= 85.0 { @@ -610,10 +621,7 @@ impl StatsDashboard<'_> { } else { 0.0 }; - let level_label = format!( - " Keys: {}/{} ({} mastered)", - self.overall_unlocked, self.overall_total, self.overall_mastered - ); + let level_label = t!("stats.keys_label", unlocked = self.overall_unlocked, total = self.overall_total, mastered = self.overall_mastered).to_string(); render_text_bar( &level_label, key_pct, @@ -628,9 +636,10 @@ impl StatsDashboard<'_> { let colors = &self.theme.colors; // Recent tests bordered table + let sessions_title = t!("stats.sessions_title"); let table_block = Block::bordered() .title(Line::from(Span::styled( - " Recent Sessions ", + sessions_title.to_string(), Style::default() .fg(colors.accent()) .add_modifier(Modifier::BOLD), @@ -640,7 +649,7 @@ impl StatsDashboard<'_> { table_block.render(area, buf); let header = Line::from(vec![Span::styled( - " # WPM Raw Acc% Time Date/Time Mode Ranked Partial", + t!("stats.session_header").to_string(), Style::default() .fg(colors.accent()) .add_modifier(Modifier::BOLD), @@ -649,7 +658,7 @@ impl StatsDashboard<'_> { let mut lines = vec![ header, Line::from(Span::styled( - " ─────────────────────────────────────────────────────────────────────", + t!("stats.session_separator").to_string(), Style::default().fg(colors.border()), )), ]; @@ -680,7 +689,8 @@ impl StatsDashboard<'_> { " " }; - let rank_str = if result.ranked { "yes" } else { "no" }; + let rank_label = if result.ranked { t!("stats.yes") } else { t!("stats.no") }; + let rank_str = rank_label.as_ref(); let partial_pct = if result.partial { result.completion_percent } else { @@ -721,9 +731,10 @@ impl StatsDashboard<'_> { fn render_keyboard_heatmap(&self, area: Rect, buf: &mut Buffer) { let colors = &self.theme.colors; + let kbd_acc_title = t!("stats.keyboard_accuracy_title"); let block = Block::bordered() .title(Line::from(Span::styled( - " Keyboard Accuracy % ", + kbd_acc_title.to_string(), Style::default() .fg(colors.accent()) .add_modifier(Modifier::BOLD), @@ -876,9 +887,10 @@ impl StatsDashboard<'_> { fn render_keyboard_timing(&self, area: Rect, buf: &mut Buffer) { let colors = &self.theme.colors; + let kbd_timing_title = t!("stats.keyboard_timing_title"); let block = Block::bordered() .title(Line::from(Span::styled( - " Keyboard Timing (ms) ", + kbd_timing_title.to_string(), Style::default() .fg(colors.accent()) .add_modifier(Modifier::BOLD), @@ -997,9 +1009,10 @@ impl StatsDashboard<'_> { fn render_slowest_keys(&self, area: Rect, buf: &mut Buffer) { let colors = &self.theme.colors; + let slowest_title = t!("stats.slowest_keys_title"); let block = Block::bordered() .title(Line::from(Span::styled( - " Slowest Keys (ms) ", + slowest_title.to_string(), Style::default() .fg(colors.accent()) .add_modifier(Modifier::BOLD), @@ -1045,9 +1058,10 @@ impl StatsDashboard<'_> { fn render_fastest_keys(&self, area: Rect, buf: &mut Buffer) { let colors = &self.theme.colors; + let fastest_title = t!("stats.fastest_keys_title"); let block = Block::bordered() .title(Line::from(Span::styled( - " Fastest Keys (ms) ", + fastest_title.to_string(), Style::default() .fg(colors.accent()) .add_modifier(Modifier::BOLD), @@ -1093,9 +1107,10 @@ impl StatsDashboard<'_> { fn render_worst_accuracy_keys(&self, area: Rect, buf: &mut Buffer) { let colors = &self.theme.colors; + let worst_title = t!("stats.worst_accuracy_title"); let block = Block::bordered() .title(Line::from(Span::styled( - " Worst Accuracy (%) ", + worst_title.to_string(), Style::default() .fg(colors.accent()) .add_modifier(Modifier::BOLD), @@ -1134,10 +1149,11 @@ impl StatsDashboard<'_> { key_accuracies.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap().then_with(|| a.0.cmp(&b.0))); if key_accuracies.is_empty() { + let no_data = t!("stats.not_enough_data"); buf.set_string( inner.x, inner.y, - " Not enough data", + no_data.as_ref(), Style::default().fg(colors.text_pending()), ); return; @@ -1173,9 +1189,10 @@ impl StatsDashboard<'_> { fn render_best_accuracy_keys(&self, area: Rect, buf: &mut Buffer) { let colors = &self.theme.colors; + let best_title = t!("stats.best_accuracy_title"); let block = Block::bordered() .title(Line::from(Span::styled( - " Best Accuracy (%) ", + best_title.to_string(), Style::default() .fg(colors.accent()) .add_modifier(Modifier::BOLD), @@ -1211,10 +1228,11 @@ impl StatsDashboard<'_> { key_accuracies.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap().then_with(|| a.0.cmp(&b.0))); if key_accuracies.is_empty() { + let no_data = t!("stats.not_enough_data"); buf.set_string( inner.x, inner.y, - " Not enough data", + no_data.as_ref(), Style::default().fg(colors.text_pending()), ); return; @@ -1249,9 +1267,10 @@ impl StatsDashboard<'_> { fn render_activity_stats(&self, area: Rect, buf: &mut Buffer) { let colors = &self.theme.colors; + let streaks_title = t!("stats.streaks_title"); let block = Block::bordered() .title(Line::from(Span::styled( - " Streaks ", + streaks_title.to_string(), Style::default() .fg(colors.accent()) .add_modifier(Modifier::BOLD), @@ -1272,22 +1291,25 @@ impl StatsDashboard<'_> { let mut top_days: Vec<(chrono::NaiveDate, usize)> = day_counts.into_iter().collect(); top_days.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| b.0.cmp(&a.0))); + let current_label = t!("stats.current_streak"); + let best_label = t!("stats.best_streak"); + let active_days_label = t!("stats.active_days"); let mut lines = vec![Line::from(vec![ - Span::styled(" Current: ", Style::default().fg(colors.fg())), + Span::styled(current_label.to_string(), Style::default().fg(colors.fg())), Span::styled( format!("{current_streak}d"), Style::default() .fg(colors.success()) .add_modifier(Modifier::BOLD), ), - Span::styled(" Best: ", Style::default().fg(colors.fg())), + Span::styled(best_label.to_string(), Style::default().fg(colors.fg())), Span::styled( format!("{best_streak}d"), Style::default() .fg(colors.accent()) .add_modifier(Modifier::BOLD), ), - Span::styled(" Active Days: ", Style::default().fg(colors.fg())), + Span::styled(active_days_label.to_string(), Style::default().fg(colors.fg())), Span::styled( format!("{active_days_count}"), Style::default().fg(colors.text_pending()), @@ -1295,14 +1317,14 @@ impl StatsDashboard<'_> { ])]; let top_days_text = if top_days.is_empty() { - " Top Days: none".to_string() + t!("stats.top_days_none").to_string() } else { let parts: Vec = top_days .iter() .take(3) .map(|(d, c)| format!("{} ({})", d.format("%Y-%m-%d"), c)) .collect(); - format!(" Top Days: {}", parts.join(" | ")) + t!("stats.top_days", days = parts.join(" | ")).to_string() }; lines.push(Line::from(Span::styled( top_days_text, @@ -1321,7 +1343,7 @@ impl StatsDashboard<'_> { Some(d) => d, None => { let msg = Paragraph::new(Line::from(Span::styled( - "Complete some adaptive drills to see n-gram data", + t!("stats.ngram_empty").to_string(), Style::default().fg(colors.text_pending()), ))); msg.render(area, buf); @@ -1370,9 +1392,10 @@ impl StatsDashboard<'_> { fn render_ngram_focus(&self, data: &NgramTabData, area: Rect, buf: &mut Buffer) { let colors = &self.theme.colors; + let focus_title = t!("stats.focus_title"); let block = Block::bordered() .title(Line::from(Span::styled( - " Active Focus ", + focus_title.to_string(), Style::default() .fg(colors.accent()) .add_modifier(Modifier::BOLD), @@ -1392,16 +1415,16 @@ impl StatsDashboard<'_> { let bigram_label = format!("\"{}{}\"", key.0[0], key.0[1]); // Line 1: both focuses lines.push(Line::from(vec![ - Span::styled(" Focus: ", Style::default().fg(colors.fg())), + Span::styled(t!("stats.focus_char_label").to_string(), Style::default().fg(colors.fg())), Span::styled( - format!("Char '{ch}'"), + t!("stats.focus_char_value", ch = ch).to_string(), Style::default() .fg(colors.focused_key()) .add_modifier(Modifier::BOLD), ), - Span::styled(" + ", Style::default().fg(colors.fg())), + Span::styled(t!("stats.focus_plus").to_string(), Style::default().fg(colors.fg())), Span::styled( - format!("Bigram {bigram_label}"), + t!("stats.focus_bigram_value", label = &bigram_label).to_string(), Style::default() .fg(colors.focused_key()) .add_modifier(Modifier::BOLD), @@ -1410,23 +1433,21 @@ impl StatsDashboard<'_> { // Line 2: details if inner.height >= 2 { let type_label = match anomaly_type { - AnomalyType::Error => "error", - AnomalyType::Speed => "speed", + AnomalyType::Error => t!("stats.anomaly_error").to_string(), + AnomalyType::Speed => t!("stats.anomaly_speed").to_string(), }; - let detail = format!( - " Char '{ch}': weakest key | Bigram {bigram_label}: {type_label} anomaly {anomaly_pct:.0}%" - ); + let detail = t!("stats.focus_detail_both", ch = ch, label = &bigram_label, r#type = &type_label, pct = format!("{anomaly_pct:.0}")); lines.push(Line::from(Span::styled( - detail, + detail.to_string(), Style::default().fg(colors.text_pending()), ))); } } (Some(ch), None) => { lines.push(Line::from(vec![ - Span::styled(" Focus: ", Style::default().fg(colors.fg())), + Span::styled(t!("stats.focus_char_label").to_string(), Style::default().fg(colors.fg())), Span::styled( - format!("Char '{ch}'"), + t!("stats.focus_char_value", ch = ch).to_string(), Style::default() .fg(colors.focused_key()) .add_modifier(Modifier::BOLD), @@ -1434,7 +1455,7 @@ impl StatsDashboard<'_> { ])); if inner.height >= 2 { lines.push(Line::from(Span::styled( - format!(" Char '{ch}': weakest key, no confirmed bigram anomalies"), + t!("stats.focus_detail_char_only", ch = ch).to_string(), Style::default().fg(colors.text_pending()), ))); } @@ -1442,26 +1463,26 @@ impl StatsDashboard<'_> { (None, Some((key, anomaly_pct, anomaly_type))) => { let bigram_label = format!("\"{}{}\"", key.0[0], key.0[1]); let type_label = match anomaly_type { - AnomalyType::Error => "error", - AnomalyType::Speed => "speed", + AnomalyType::Error => t!("stats.anomaly_error").to_string(), + AnomalyType::Speed => t!("stats.anomaly_speed").to_string(), }; lines.push(Line::from(vec![ - Span::styled(" Focus: ", Style::default().fg(colors.fg())), + Span::styled(t!("stats.focus_char_label").to_string(), Style::default().fg(colors.fg())), Span::styled( - format!("Bigram {bigram_label}"), + t!("stats.focus_bigram_value", label = &bigram_label).to_string(), Style::default() .fg(colors.focused_key()) .add_modifier(Modifier::BOLD), ), Span::styled( - format!(" ({type_label} anomaly: {anomaly_pct:.0}%)"), + t!("stats.focus_detail_bigram_only", r#type = &type_label, pct = format!("{anomaly_pct:.0}")).to_string(), Style::default().fg(colors.text_pending()), ), ])); } (None, None) => { lines.push(Line::from(Span::styled( - " Complete some adaptive drills to see focus data", + t!("stats.focus_empty").to_string(), Style::default().fg(colors.text_pending()), ))); } @@ -1512,14 +1533,14 @@ impl StatsDashboard<'_> { // Speed table: Bigram Anom% Speed Smp Strk let header = if narrow { if is_speed { - " Bgrm Speed Expct Anom%" + t!("stats.ngram_header_speed_narrow").to_string() } else { - " Bgrm Err Smp Rate Exp Anom%" + t!("stats.ngram_header_error_narrow").to_string() } } else if is_speed { - " Bigram Speed Expect Samples Anom%" + t!("stats.ngram_header_speed").to_string() } else { - " Bigram Errors Samples Rate Expect Anom%" + t!("stats.ngram_header_error").to_string() }; buf.set_string( inner.x, @@ -1586,10 +1607,11 @@ impl StatsDashboard<'_> { } fn render_error_anomalies(&self, data: &NgramTabData, area: Rect, buf: &mut Buffer) { - let title = format!(" Error Anomalies ({}) ", data.error_anomalies.len()); + let title = t!("stats.error_anomalies_title", count = data.error_anomalies.len()); + let empty_msg = t!("stats.no_error_anomalies"); self.render_anomaly_panel( - &title, - " No error anomalies detected", + title.as_ref(), + empty_msg.as_ref(), &data.error_anomalies, false, area, @@ -1598,10 +1620,11 @@ impl StatsDashboard<'_> { } fn render_speed_anomalies(&self, data: &NgramTabData, area: Rect, buf: &mut Buffer) { - let title = format!(" Speed Anomalies ({}) ", data.speed_anomalies.len()); + let title = t!("stats.speed_anomalies_title", count = data.speed_anomalies.len()); + let empty_msg = t!("stats.no_speed_anomalies"); self.render_anomaly_panel( - &title, - " No speed anomalies detected", + title.as_ref(), + empty_msg.as_ref(), &data.speed_anomalies, true, area, @@ -1619,18 +1642,18 @@ impl StatsDashboard<'_> { }; // Build segments from most to least important, progressively drop from the right - let scope = format!(" {}", data.scope_label); - let bigrams = format!(" | Bi: {}", data.total_bigrams); - let trigrams = format!(" | Tri: {}", data.total_trigrams); - let hesitation = format!(" | Hes: >{:.0}ms", data.hesitation_threshold_ms); - let gain = format!(" | Gain: {}", gain_str); - let gain_note = if data.latest_trigram_gain.is_none() { - " (every 50)" + let scope = t!("stats.scope_label_prefix", ).to_string() + &data.scope_label; + let bigrams = t!("stats.bi_label", count = data.total_bigrams).to_string(); + let trigrams = t!("stats.tri_label", count = data.total_trigrams).to_string(); + let hesitation = t!("stats.hes_label", ms = format!("{:.0}", data.hesitation_threshold_ms)).to_string(); + let gain = t!("stats.gain_label", value = &gain_str).to_string(); + let gain_note_str = if data.latest_trigram_gain.is_none() { + t!("stats.gain_interval").to_string() } else { - "" + String::new() }; - let segments: &[&str] = &[&scope, &bigrams, &trigrams, &hesitation, &gain, gain_note]; + let segments: &[&str] = &[&scope, &bigrams, &trigrams, &hesitation, &gain, &gain_note_str]; let mut line = String::new(); for seg in segments { if line.len() + seg.len() <= w { @@ -1649,33 +1672,47 @@ impl StatsDashboard<'_> { } } -const TAB_LABELS: [&str; 6] = [ - "[1] Dashboard", - "[2] History", - "[3] Activity", - "[4] Accuracy", - "[5] Timing", - "[6] N-grams", -]; +fn tab_labels() -> Vec { + vec![ + t!("stats.tab_dashboard").to_string(), + t!("stats.tab_history").to_string(), + t!("stats.tab_activity").to_string(), + t!("stats.tab_accuracy").to_string(), + t!("stats.tab_timing").to_string(), + t!("stats.tab_ngrams").to_string(), + ] +} + const TAB_SEPARATOR: &str = " "; -const FOOTER_HINTS_DEFAULT: [&str; 3] = ["[ESC] Back", "[Tab] Next tab", "[1-6] Switch tab"]; -const FOOTER_HINTS_HISTORY: [&str; 6] = [ - "[ESC] Back", - "[Tab] Next tab", - "[1-6] Switch tab", - "[j/k] Navigate", - "[PgUp/PgDn] Page", - "[x] Delete", -]; + +fn footer_hints_default() -> Vec { + vec![ + t!("stats.hint_back").to_string(), + t!("stats.hint_next_tab").to_string(), + t!("stats.hint_switch_tab").to_string(), + ] +} + +fn footer_hints_history() -> Vec { + vec![ + t!("stats.hint_back").to_string(), + t!("stats.hint_next_tab").to_string(), + t!("stats.hint_switch_tab").to_string(), + t!("stats.hint_navigate").to_string(), + t!("stats.hint_page").to_string(), + t!("stats.hint_delete").to_string(), + ] +} fn history_visible_rows(table_inner: Rect) -> usize { table_inner.height.saturating_sub(2) as usize } fn wrapped_tab_line_count(width: usize) -> usize { + let labels = tab_labels(); let mut lines = 1usize; let mut current_width = 0usize; - for label in TAB_LABELS { + for label in &labels { let item_width = format!(" {label} ").chars().count() + TAB_SEPARATOR.len(); if current_width > 0 && current_width + item_width > width { lines += 1; @@ -1687,7 +1724,9 @@ fn wrapped_tab_line_count(width: usize) -> usize { } fn footer_line_count_for_history(width: usize) -> usize { - pack_hint_lines(&FOOTER_HINTS_HISTORY, width).len().max(1) + let hints = footer_hints_history(); + let hint_refs: Vec<&str> = hints.iter().map(|s| s.as_str()).collect(); + pack_hint_lines(&hint_refs, width).len().max(1) } pub fn history_page_size_for_terminal(width: u16, height: u16) -> usize { diff --git a/src/ui/components/stats_sidebar.rs b/src/ui/components/stats_sidebar.rs index af2d4c5..b4fdcd4 100644 --- a/src/ui/components/stats_sidebar.rs +++ b/src/ui/components/stats_sidebar.rs @@ -6,6 +6,7 @@ use ratatui::widgets::{Block, Paragraph, Widget}; use crate::session::drill::DrillState; use crate::session::result::DrillResult; +use crate::i18n::t; use crate::ui::theme::Theme; pub struct StatsSidebar<'a> { @@ -80,21 +81,30 @@ impl Widget for StatsSidebar<'_> { let incorrect_str = format!("{incorrect}"); let elapsed_str = format!("{elapsed:.1}s"); + let wpm_label = t!("sidebar.wpm"); + let target_label = t!("sidebar.target"); + let target_wpm_val = t!("sidebar.target_wpm", wpm = self.target_wpm); + let accuracy_label = t!("sidebar.accuracy"); + let progress_label = t!("sidebar.progress"); + let correct_label = t!("sidebar.correct"); + let errors_label = t!("sidebar.errors"); + let time_label = t!("sidebar.time"); + let lines = vec![ Line::from(vec![ - Span::styled("WPM: ", Style::default().fg(colors.fg())), + Span::styled(wpm_label.as_ref(), Style::default().fg(colors.fg())), Span::styled(wpm_str, Style::default().fg(colors.accent())), ]), Line::from(vec![ - Span::styled("Target: ", Style::default().fg(colors.fg())), + Span::styled(target_label.as_ref(), Style::default().fg(colors.fg())), Span::styled( - format!("{} WPM", self.target_wpm), + target_wpm_val.to_string(), Style::default().fg(colors.text_pending()), ), ]), Line::from(""), Line::from(vec![ - Span::styled("Accuracy: ", Style::default().fg(colors.fg())), + Span::styled(accuracy_label.as_ref(), Style::default().fg(colors.fg())), Span::styled( acc_str, Style::default().fg(if accuracy >= 95.0 { @@ -108,27 +118,28 @@ impl Widget for StatsSidebar<'_> { ]), Line::from(""), Line::from(vec![ - Span::styled("Progress: ", Style::default().fg(colors.fg())), + Span::styled(progress_label.as_ref(), Style::default().fg(colors.fg())), Span::styled(prog_str, Style::default().fg(colors.accent())), ]), Line::from(""), Line::from(vec![ - Span::styled("Correct: ", Style::default().fg(colors.fg())), + Span::styled(correct_label.as_ref(), Style::default().fg(colors.fg())), Span::styled(correct_str, Style::default().fg(colors.success())), ]), Line::from(vec![ - Span::styled("Errors: ", Style::default().fg(colors.fg())), + Span::styled(errors_label.as_ref(), Style::default().fg(colors.fg())), Span::styled(incorrect_str, Style::default().fg(colors.error())), ]), Line::from(""), Line::from(vec![ - Span::styled("Time: ", Style::default().fg(colors.fg())), + Span::styled(time_label.as_ref(), Style::default().fg(colors.fg())), Span::styled(elapsed_str, Style::default().fg(colors.fg())), ]), ]; + let stats_title = t!("sidebar.title"); let block = Block::bordered() - .title(" Stats ") + .title(stats_title.to_string()) .border_style(Style::default().fg(colors.border())) .style(Style::default().bg(colors.bg())); @@ -174,14 +185,20 @@ impl Widget for StatsSidebar<'_> { colors.text_pending() }; + let wpm_label = t!("sidebar.wpm"); + let vs_avg_label = t!("sidebar.vs_avg"); + let accuracy_label = t!("sidebar.accuracy"); + let errors_label = t!("sidebar.errors"); + let time_label = t!("sidebar.time"); + let mut lines = vec![Line::from(vec![ - Span::styled("WPM: ", Style::default().fg(colors.fg())), + Span::styled(wpm_label.as_ref(), Style::default().fg(colors.fg())), Span::styled(wpm_str, Style::default().fg(colors.accent())), ])]; if prior_count > 0 { lines.push(Line::from(vec![ - Span::styled(" vs avg: ", Style::default().fg(colors.text_pending())), + Span::styled(vs_avg_label.as_ref(), Style::default().fg(colors.text_pending())), Span::styled(wpm_delta_str, Style::default().fg(wpm_delta_color)), ])); } @@ -189,7 +206,7 @@ impl Widget for StatsSidebar<'_> { lines.push(Line::from("")); lines.push(Line::from(vec![ - Span::styled("Accuracy: ", Style::default().fg(colors.fg())), + Span::styled(accuracy_label.as_ref(), Style::default().fg(colors.fg())), Span::styled( acc_str, Style::default().fg(if last.accuracy >= 95.0 { @@ -203,25 +220,27 @@ impl Widget for StatsSidebar<'_> { ])); if prior_count > 0 { + let vs_avg_label2 = t!("sidebar.vs_avg").to_string(); lines.push(Line::from(vec![ - Span::styled(" vs avg: ", Style::default().fg(colors.text_pending())), + Span::styled(vs_avg_label2, Style::default().fg(colors.text_pending())), Span::styled(acc_delta_str, Style::default().fg(acc_delta_color)), ])); } lines.push(Line::from("")); lines.push(Line::from(vec![ - Span::styled("Errors: ", Style::default().fg(colors.fg())), + Span::styled(errors_label.as_ref(), Style::default().fg(colors.fg())), Span::styled(errors_str, Style::default().fg(colors.error())), ])); lines.push(Line::from("")); lines.push(Line::from(vec![ - Span::styled("Time: ", Style::default().fg(colors.fg())), + Span::styled(time_label.as_ref(), Style::default().fg(colors.fg())), Span::styled(time_str, Style::default().fg(colors.fg())), ])); + let last_drill_title = t!("sidebar.last_drill"); let block = Block::bordered() - .title(" Last Drill ") + .title(last_drill_title.to_string()) .border_style(Style::default().fg(colors.border())) .style(Style::default().bg(colors.bg()));