Internationalize UI text w/ german as first second lang

Adds rust-i18n and refactors all of the text copy in the app to use the
translation function so that the UI language can be dynamically updated
in the settings.
This commit is contained in:
2026-03-17 04:29:25 +00:00
parent 895e04d6ce
commit 6d5de33f55
24 changed files with 2924 additions and 820 deletions

176
Cargo.lock generated
View File

@@ -88,6 +88,15 @@ version = "1.0.101"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" 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]] [[package]]
name = "atomic" name = "atomic"
version = "0.6.1" version = "0.6.1"
@@ -109,6 +118,12 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "base62"
version = "2.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1adf9755786e27479693dedd3271691a92b5e242ab139cacb9fb8e7fb5381111"
[[package]] [[package]]
name = "base64" name = "base64"
version = "0.22.1" version = "0.22.1"
@@ -151,6 +166,16 @@ dependencies = [
"generic-array", "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]] [[package]]
name = "bumpalo" name = "bumpalo"
version = "3.19.1" version = "3.19.1"
@@ -797,6 +822,36 @@ dependencies = [
"wasip2", "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]] [[package]]
name = "h2" name = "h2"
version = "0.4.13" version = "0.4.13"
@@ -1106,6 +1161,22 @@ dependencies = [
"icu_properties", "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]] [[package]]
name = "indexmap" name = "indexmap"
version = "2.13.0" version = "2.13.0"
@@ -1180,6 +1251,15 @@ dependencies = [
"either", "either",
] ]
[[package]]
name = "itertools"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57"
dependencies = [
"either",
]
[[package]] [[package]]
name = "itertools" name = "itertools"
version = "0.14.0" version = "0.14.0"
@@ -1229,10 +1309,13 @@ dependencies = [
"icu_normalizer", "icu_normalizer",
"rand", "rand",
"ratatui", "ratatui",
"regex",
"reqwest", "reqwest",
"rust-embed", "rust-embed",
"rust-i18n",
"serde", "serde",
"serde_json", "serde_json",
"serde_yaml",
"tempfile", "tempfile",
"thiserror 2.0.18", "thiserror 2.0.18",
"toml", "toml",
@@ -1418,6 +1501,15 @@ dependencies = [
"minimal-lexical", "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]] [[package]]
name = "num-conv" name = "num-conv"
version = "0.2.0" version = "0.2.0"
@@ -2029,6 +2121,60 @@ dependencies = [
"walkdir", "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]] [[package]]
name = "rustc_version" name = "rustc_version"
version = "0.4.1" version = "0.4.1"
@@ -2226,6 +2372,19 @@ dependencies = [
"serde", "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]] [[package]]
name = "sha2" name = "sha2"
version = "0.10.9" version = "0.10.9"
@@ -2719,6 +2878,17 @@ dependencies = [
"once_cell", "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]] [[package]]
name = "try-lock" name = "try-lock"
version = "0.2.5" version = "0.2.5"
@@ -2766,6 +2936,12 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]]
name = "unsafe-libyaml"
version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
[[package]] [[package]]
name = "untrusted" name = "untrusted"
version = "0.9.0" version = "0.9.0"

View File

@@ -20,9 +20,12 @@ anyhow = "1.0"
thiserror = "2.0" thiserror = "2.0"
reqwest = { version = "0.12", features = ["blocking"], optional = true } reqwest = { version = "0.12", features = ["blocking"], optional = true }
icu_normalizer = { version = "2.1", default-features = false, features = ["compiled_data"] } icu_normalizer = { version = "2.1", default-features = false, features = ["compiled_data"] }
rust-i18n = "3"
[dev-dependencies] [dev-dependencies]
tempfile = "3" tempfile = "3"
serde_yaml = "0.9"
regex = "1"
criterion = { version = "0.5", features = ["html_reports"] } criterion = { version = "0.5", features = ["html_reports"] }
[[bench]] [[bench]]

View File

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

View File

@@ -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<MenuItem>` 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

457
locales/de.yml Normal file
View File

@@ -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'

457
locales/en.yml Normal file
View File

@@ -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'

View File

@@ -8,6 +8,8 @@ use rand::Rng;
use rand::SeedableRng; use rand::SeedableRng;
use rand::rngs::SmallRng; use rand::rngs::SmallRng;
use crate::i18n::t;
use crate::config::Config; use crate::config::Config;
use crate::engine::FocusSelection; use crate::engine::FocusSelection;
use crate::engine::filter::CharFilter; use crate::engine::filter::CharFilter;
@@ -70,6 +72,7 @@ pub enum AppScreen {
CodeIntro, CodeIntro,
CodeDownloadProgress, CodeDownloadProgress,
Keyboard, Keyboard,
UiLanguageSelect,
} }
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -84,6 +87,7 @@ pub enum SettingItem {
TargetWpm, TargetWpm,
Theme, Theme,
WordCount, WordCount,
UiLanguage,
DictionaryLanguage, DictionaryLanguage,
KeyboardLayout, KeyboardLayout,
CodeLanguage, CodeLanguage,
@@ -102,10 +106,11 @@ pub enum SettingItem {
} }
impl SettingItem { impl SettingItem {
pub const ALL: [Self; 18] = [ pub const ALL: [Self; 19] = [
Self::TargetWpm, Self::TargetWpm,
Self::Theme, Self::Theme,
Self::WordCount, Self::WordCount,
Self::UiLanguage,
Self::DictionaryLanguage, Self::DictionaryLanguage,
Self::KeyboardLayout, Self::KeyboardLayout,
Self::CodeLanguage, Self::CodeLanguage,
@@ -183,23 +188,29 @@ pub struct KeyMilestonePopup {
pub kind: MilestoneKind, pub kind: MilestoneKind,
pub keys: Vec<char>, pub keys: Vec<char>,
pub finger_info: Vec<(char, String)>, pub finger_info: Vec<(char, String)>,
pub message: &'static str, pub message: String,
pub branch_ids: Vec<BranchId>, pub branch_ids: Vec<BranchId>,
} }
const UNLOCK_MESSAGES: &[&str] = &[ fn unlock_messages() -> Vec<String> {
"Nice work! Keep building your typing skills.", use crate::i18n::t;
"Another key added to your arsenal!", vec![
"Your keyboard is growing! Keep it up.", t!("milestones.unlock_msg_1").to_string(),
"One step closer to full keyboard mastery!", 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] = &[ fn mastery_messages() -> Vec<String> {
"This key is now at full confidence!", use crate::i18n::t;
"You've got this key down pat!", vec![
"Muscle memory locked in!", t!("milestones.mastery_msg_1").to_string(),
"One more key conquered!", 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; const POST_DRILL_INPUT_LOCK_MS: u64 = 800;
@@ -319,6 +330,8 @@ pub struct App {
pub skill_tree_detail_scroll: usize, pub skill_tree_detail_scroll: usize,
pub skill_tree_confirm_unlock: Option<BranchId>, pub skill_tree_confirm_unlock: Option<BranchId>,
pub drill_source_info: Option<String>, pub drill_source_info: Option<String>,
pub ui_language_selected: usize,
pub ui_language_scroll: usize,
pub dictionary_language_selected: usize, pub dictionary_language_selected: usize,
pub dictionary_language_scroll: usize, pub dictionary_language_scroll: usize,
pub keyboard_layout_selected: usize, pub keyboard_layout_selected: usize,
@@ -525,6 +538,8 @@ impl App {
skill_tree_detail_scroll: 0, skill_tree_detail_scroll: 0,
skill_tree_confirm_unlock: None, skill_tree_confirm_unlock: None,
drill_source_info: None, drill_source_info: None,
ui_language_selected: 0,
ui_language_scroll: 0,
dictionary_language_selected: 0, dictionary_language_selected: 0,
dictionary_language_scroll: 0, dictionary_language_scroll: 0,
keyboard_layout_selected: 0, keyboard_layout_selected: 0,
@@ -596,7 +611,7 @@ impl App {
{ {
app.settings_status_message = Some(StatusMessage { app.settings_status_message = Some(StatusMessage {
kind: StatusKind::Error, 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 { self.settings_status_message = Some(StatusMessage {
kind: StatusKind::Error, 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; return;
} }
@@ -685,7 +700,7 @@ impl App {
let Some(ref store) = self.store else { let Some(ref store) = self.store else {
self.settings_status_message = Some(StatusMessage { self.settings_status_message = Some(StatusMessage {
kind: StatusKind::Error, kind: StatusKind::Error,
text: "No data store available".to_string(), text: t!("status.no_data_store").to_string(),
}); });
return; return;
}; };
@@ -696,7 +711,7 @@ impl App {
Err(e) => { Err(e) => {
self.settings_status_message = Some(StatusMessage { self.settings_status_message = Some(StatusMessage {
kind: StatusKind::Error, kind: StatusKind::Error,
text: format!("Serialization error: {e}"), text: t!("status.serialization_error", error = e.to_string()).to_string(),
}); });
return; return;
} }
@@ -717,14 +732,14 @@ impl App {
Ok(()) => { Ok(()) => {
self.settings_status_message = Some(StatusMessage { self.settings_status_message = Some(StatusMessage {
kind: StatusKind::Success, 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) => { Err(e) => {
let _ = std::fs::remove_file(&tmp_path); let _ = std::fs::remove_file(&tmp_path);
self.settings_status_message = Some(StatusMessage { self.settings_status_message = Some(StatusMessage {
kind: StatusKind::Error, 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) => { Err(e) => {
self.settings_status_message = Some(StatusMessage { self.settings_status_message = Some(StatusMessage {
kind: StatusKind::Error, kind: StatusKind::Error,
text: format!("Could not read file: {e}"), text: t!("status.could_not_read", error = e.to_string()).to_string(),
}); });
return; return;
} }
@@ -750,7 +765,7 @@ impl App {
Err(e) => { Err(e) => {
self.settings_status_message = Some(StatusMessage { self.settings_status_message = Some(StatusMessage {
kind: StatusKind::Error, kind: StatusKind::Error,
text: format!("Invalid export file: {e}"), text: t!("status.invalid_export", error = e.to_string()).to_string(),
}); });
return; return;
} }
@@ -760,10 +775,7 @@ impl App {
if export.keydr_export_version != EXPORT_VERSION { if export.keydr_export_version != EXPORT_VERSION {
self.settings_status_message = Some(StatusMessage { self.settings_status_message = Some(StatusMessage {
kind: StatusKind::Error, kind: StatusKind::Error,
text: format!( text: t!("status.unsupported_version", got = export.keydr_export_version, expected = EXPORT_VERSION).to_string(),
"Unsupported export version: {} (expected {})",
export.keydr_export_version, EXPORT_VERSION
),
}); });
return; return;
} }
@@ -778,14 +790,14 @@ impl App {
let Some(ref store) = self.store else { let Some(ref store) = self.store else {
self.settings_status_message = Some(StatusMessage { self.settings_status_message = Some(StatusMessage {
kind: StatusKind::Error, kind: StatusKind::Error,
text: "No data store available".to_string(), text: t!("status.no_data_store").to_string(),
}); });
return; return;
}; };
if let Err(e) = store.import_all(&export) { if let Err(e) = store.import_all(&export) {
self.settings_status_message = Some(StatusMessage { self.settings_status_message = Some(StatusMessage {
kind: StatusKind::Error, kind: StatusKind::Error,
text: format!("Import failed: {e}"), text: t!("status.import_failed", error = e.to_string()).to_string(),
}); });
return; return;
} }
@@ -841,15 +853,12 @@ impl App {
let _ = self.config.save(); let _ = self.config.save();
self.settings_status_message = Some(StatusMessage { self.settings_status_message = Some(StatusMessage {
kind: StatusKind::Success, kind: StatusKind::Success,
text: format!( text: t!("status.imported_theme_fallback", theme = &theme_name).to_string(),
"Imported successfully (theme '{}' not found, using default)",
theme_name
),
}); });
} else { } else {
self.settings_status_message = Some(StatusMessage { self.settings_status_message = Some(StatusMessage {
kind: StatusKind::Success, kind: StatusKind::Success,
text: "Imported successfully".to_string(), text: t!("status.imported_success").to_string(),
}); });
} }
} }
@@ -1227,11 +1236,12 @@ impl App {
.newly_unlocked .newly_unlocked
.iter() .iter()
.map(|&ch| { .map(|&ch| {
let desc = self.keyboard_model.finger_for_char(ch).description(); let desc = self.keyboard_model.finger_for_char(ch).localized_description();
(ch, desc.to_string()) (ch, desc)
}) })
.collect(); .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 { self.milestone_queue.push_back(KeyMilestonePopup {
kind: MilestoneKind::Unlock, kind: MilestoneKind::Unlock,
keys: update.newly_unlocked, keys: update.newly_unlocked,
@@ -1247,11 +1257,12 @@ impl App {
.newly_mastered .newly_mastered
.iter() .iter()
.map(|&ch| { .map(|&ch| {
let desc = self.keyboard_model.finger_for_char(ch).description(); let desc = self.keyboard_model.finger_for_char(ch).localized_description();
(ch, desc.to_string()) (ch, desc)
}) })
.collect(); .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 { self.milestone_queue.push_back(KeyMilestonePopup {
kind: MilestoneKind::Mastery, kind: MilestoneKind::Mastery,
keys: update.newly_mastered, keys: update.newly_mastered,
@@ -1267,7 +1278,7 @@ impl App {
kind: MilestoneKind::BranchesAvailable, kind: MilestoneKind::BranchesAvailable,
keys: vec![], keys: vec![],
finger_info: vec![], finger_info: vec![],
message: "", message: String::new(),
branch_ids: update.branches_newly_available, branch_ids: update.branches_newly_available,
}); });
} }
@@ -1284,7 +1295,7 @@ impl App {
kind: MilestoneKind::BranchComplete, kind: MilestoneKind::BranchComplete,
keys: vec![], keys: vec![],
finger_info: vec![], finger_info: vec![],
message: "", message: String::new(),
branch_ids: completed_non_lowercase, branch_ids: completed_non_lowercase,
}); });
} }
@@ -1294,7 +1305,7 @@ impl App {
kind: MilestoneKind::AllKeysUnlocked, kind: MilestoneKind::AllKeysUnlocked,
keys: vec![], keys: vec![],
finger_info: vec![], finger_info: vec![],
message: "", message: String::new(),
branch_ids: vec![], branch_ids: vec![],
}); });
} }
@@ -1304,7 +1315,7 @@ impl App {
kind: MilestoneKind::AllKeysMastered, kind: MilestoneKind::AllKeysMastered,
keys: vec![], keys: vec![],
finger_info: vec![], finger_info: vec![],
message: "", message: String::new(),
branch_ids: vec![], branch_ids: vec![],
}); });
} }
@@ -1912,7 +1923,7 @@ impl App {
Err(err) => { Err(err) => {
self.settings_status_message = Some(StatusMessage { self.settings_status_message = Some(StatusMessage {
kind: StatusKind::Error, 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.go_to_settings();
self.settings_selected = SettingItem::DictionaryLanguage.index(); self.settings_selected = SettingItem::DictionaryLanguage.index();
@@ -1942,6 +1953,15 @@ impl App {
self.screen = AppScreen::Settings; 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) { pub fn go_to_dictionary_language_select(&mut self) {
let options = crate::l10n::language_pack::language_packs(); let options = crate::l10n::language_pack::language_packs();
self.dictionary_language_selected = options self.dictionary_language_selected = options
@@ -2335,7 +2355,7 @@ impl App {
if keys.is_empty() { if keys.is_empty() {
self.settings_status_message = Some(StatusMessage { self.settings_status_message = Some(StatusMessage {
kind: StatusKind::Error, kind: StatusKind::Error,
text: "No supported dictionary languages are registered".to_string(), text: t!("errors.unknown_language", key = "none").to_string(),
}); });
return; return;
} }
@@ -2356,7 +2376,7 @@ impl App {
Err(err) => { Err(err) => {
self.settings_status_message = Some(StatusMessage { self.settings_status_message = Some(StatusMessage {
kind: StatusKind::Error, 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() { if keys.is_empty() {
self.settings_status_message = Some(StatusMessage { self.settings_status_message = Some(StatusMessage {
kind: StatusKind::Error, kind: StatusKind::Error,
text: "No keyboard layouts are registered".to_string(), text: t!("errors.unknown_layout", key = "none").to_string(),
}); });
return; return;
} }
@@ -2387,7 +2407,7 @@ impl App {
Err(err) => { Err(err) => {
self.settings_status_message = Some(StatusMessage { self.settings_status_message = Some(StatusMessage {
kind: StatusKind::Error, kind: StatusKind::Error,
text: err.to_string(), text: crate::i18n::localized_language_layout_error(&err),
}); });
} }
} }
@@ -2519,6 +2539,13 @@ impl App {
SettingItem::WordCount => { SettingItem::WordCount => {
self.config.word_count = (self.config.word_count + 5).min(100); 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 => { SettingItem::DictionaryLanguage => {
self.apply_dictionary_language_by_offset(1); self.apply_dictionary_language_by_offset(1);
} }
@@ -2595,6 +2622,13 @@ impl App {
SettingItem::WordCount => { SettingItem::WordCount => {
self.config.word_count = self.config.word_count.saturating_sub(5).max(5); 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 => { SettingItem::DictionaryLanguage => {
self.apply_dictionary_language_by_offset(-1); self.apply_dictionary_language_by_offset(-1);
} }
@@ -3039,6 +3073,8 @@ impl App {
skill_tree_detail_scroll: 0, skill_tree_detail_scroll: 0,
skill_tree_confirm_unlock: None, skill_tree_confirm_unlock: None,
drill_source_info: None, drill_source_info: None,
ui_language_selected: 0,
ui_language_scroll: 0,
dictionary_language_selected: 0, dictionary_language_selected: 0,
dictionary_language_scroll: 0, dictionary_language_scroll: 0,
keyboard_layout_selected: 0, keyboard_layout_selected: 0,
@@ -3519,7 +3555,7 @@ mod tests {
kind: MilestoneKind::Unlock, kind: MilestoneKind::Unlock,
keys: vec!['a'], keys: vec!['a'],
finger_info: vec![('a', "left pinky".to_string())], finger_info: vec![('a', "left pinky".to_string())],
message: "Test milestone", message: "Test milestone".to_string(),
branch_ids: vec![], branch_ids: vec![],
}); });

View File

@@ -1,6 +1,7 @@
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
use crate::i18n;
use crate::keyboard::model::KeyboardModel; use crate::keyboard::model::KeyboardModel;
use crate::l10n::language_pack::{ use crate::l10n::language_pack::{
LanguageLayoutValidationError, dictionary_languages_for_layout, supported_dictionary_languages, LanguageLayoutValidationError, dictionary_languages_for_layout, supported_dictionary_languages,
@@ -41,6 +42,8 @@ pub struct Config {
pub code_snippets_per_repo: usize, pub code_snippets_per_repo: usize,
#[serde(default = "default_code_onboarding_done")] #[serde(default = "default_code_onboarding_done")]
pub code_onboarding_done: bool, pub code_onboarding_done: bool,
#[serde(default = "default_ui_language")]
pub ui_language: String,
} }
fn default_target_wpm() -> u32 { fn default_target_wpm() -> u32 {
@@ -98,6 +101,9 @@ fn default_code_snippets_per_repo() -> usize {
fn default_code_onboarding_done() -> bool { fn default_code_onboarding_done() -> bool {
false false
} }
fn default_ui_language() -> String {
"en".to_string()
}
impl Default for Config { impl Default for Config {
fn default() -> Self { fn default() -> Self {
@@ -117,6 +123,7 @@ impl Default for Config {
code_download_dir: default_code_download_dir(), code_download_dir: default_code_download_dir(),
code_snippets_per_repo: default_code_snippets_per_repo(), code_snippets_per_repo: default_code_snippets_per_repo(),
code_onboarding_done: default_code_onboarding_done(), code_onboarding_done: default_code_onboarding_done(),
ui_language: default_ui_language(),
} }
} }
} }
@@ -163,6 +170,7 @@ impl Config {
self.normalize_keyboard_layout(); self.normalize_keyboard_layout();
self.normalize_dictionary_language(); self.normalize_dictionary_language();
self.normalize_language_layout_pair(); self.normalize_language_layout_pair();
self.normalize_ui_language();
} }
/// Validate `code_language` against known options, resetting to default if invalid. /// 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> { pub fn validate_language_layout_pair(&self) -> Result<(), LanguageLayoutValidationError> {
validate_language_layout_pair(&self.dictionary_language, &self.keyboard_layout).map(|_| ()) validate_language_layout_pair(&self.dictionary_language, &self.keyboard_layout).map(|_| ())
} }
@@ -329,25 +344,22 @@ code_language = "go"
} }
#[test] #[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(); let mut config = Config::default();
config.dictionary_language = "de".to_string(); config.dictionary_language = "de".to_string();
config.keyboard_layout = "dvorak".to_string(); config.keyboard_layout = "dvorak".to_string();
config.normalize_language_layout_pair(); 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"); assert_eq!(config.keyboard_layout, "dvorak");
} }
#[test] #[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(); let mut config = Config::default();
config.dictionary_language = "de".to_string(); config.dictionary_language = "de".to_string();
config.keyboard_layout = "dvorak".to_string(); config.keyboard_layout = "dvorak".to_string();
let err = config.validate_language_layout_pair().unwrap_err(); assert!(config.validate_language_layout_pair().is_ok());
assert!(matches!(
err,
LanguageLayoutValidationError::UnsupportedLanguageLayoutPair { .. }
));
} }
#[test] #[test]

View File

@@ -80,20 +80,32 @@ pub enum BranchStatus {
// --- Static Definitions --- // --- Static Definitions ---
pub struct LevelDefinition { pub struct LevelDefinition {
pub name: &'static str, pub name_key: &'static str,
pub keys: &'static [char], pub keys: &'static [char],
} }
impl LevelDefinition {
pub fn display_name(&self) -> String {
crate::i18n::t!(self.name_key).to_string()
}
}
pub struct BranchDefinition { pub struct BranchDefinition {
pub id: BranchId, pub id: BranchId,
pub name: &'static str, pub name_key: &'static str,
pub levels: &'static [LevelDefinition], 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 // Lowercase metadata remains for static branch lookup/UI labels. Runtime
// progression and unlock counts are driven by `SkillTree::primary_letters`. // progression and unlock counts are driven by `SkillTree::primary_letters`.
const LOWERCASE_LEVELS: &[LevelDefinition] = &[LevelDefinition { const LOWERCASE_LEVELS: &[LevelDefinition] = &[LevelDefinition {
name: "Frequency Order", name_key: "skill_tree.level_frequency_order",
keys: &[ keys: &[
'e', 't', 'a', 'o', 'i', 'n', 's', 'h', 'r', 'd', 'l', 'c', 'u', 'm', 'w', 'f', 'g', 'y', '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', 'p', 'b', 'v', 'k', 'j', 'x', 'q', 'z',
@@ -102,71 +114,71 @@ const LOWERCASE_LEVELS: &[LevelDefinition] = &[LevelDefinition {
const CAPITALS_LEVELS: &[LevelDefinition] = &[ const CAPITALS_LEVELS: &[LevelDefinition] = &[
LevelDefinition { LevelDefinition {
name: "Common Sentence Capitals", name_key: "skill_tree.level_common_sentence_capitals",
keys: &['T', 'I', 'A', 'S', 'W', 'H', 'B', 'M'], keys: &['T', 'I', 'A', 'S', 'W', 'H', 'B', 'M'],
}, },
LevelDefinition { LevelDefinition {
name: "Name Capitals", name_key: "skill_tree.level_name_capitals",
keys: &['J', 'D', 'R', 'C', 'E', 'N', 'P', 'L', 'F', 'G'], keys: &['J', 'D', 'R', 'C', 'E', 'N', 'P', 'L', 'F', 'G'],
}, },
LevelDefinition { LevelDefinition {
name: "Remaining Capitals", name_key: "skill_tree.level_remaining_capitals",
keys: &['O', 'U', 'K', 'V', 'Y', 'X', 'Q', 'Z'], keys: &['O', 'U', 'K', 'V', 'Y', 'X', 'Q', 'Z'],
}, },
]; ];
const NUMBERS_LEVELS: &[LevelDefinition] = &[ const NUMBERS_LEVELS: &[LevelDefinition] = &[
LevelDefinition { LevelDefinition {
name: "Common Digits", name_key: "skill_tree.level_common_digits",
keys: &['1', '2', '3', '4', '5'], keys: &['1', '2', '3', '4', '5'],
}, },
LevelDefinition { LevelDefinition {
name: "All Digits", name_key: "skill_tree.level_all_digits",
keys: &['0', '6', '7', '8', '9'], keys: &['0', '6', '7', '8', '9'],
}, },
]; ];
const PROSE_PUNCTUATION_LEVELS: &[LevelDefinition] = &[ const PROSE_PUNCTUATION_LEVELS: &[LevelDefinition] = &[
LevelDefinition { LevelDefinition {
name: "Essential", name_key: "skill_tree.level_essential",
keys: &['.', ',', '\''], keys: &['.', ',', '\''],
}, },
LevelDefinition { LevelDefinition {
name: "Common", name_key: "skill_tree.level_common",
keys: &[';', ':', '"', '-'], keys: &[';', ':', '"', '-'],
}, },
LevelDefinition { LevelDefinition {
name: "Expressive", name_key: "skill_tree.level_expressive",
keys: &['?', '!', '(', ')'], keys: &['?', '!', '(', ')'],
}, },
]; ];
const WHITESPACE_LEVELS: &[LevelDefinition] = &[ const WHITESPACE_LEVELS: &[LevelDefinition] = &[
LevelDefinition { LevelDefinition {
name: "Enter/Return", name_key: "skill_tree.level_enter_return",
keys: &['\n'], keys: &['\n'],
}, },
LevelDefinition { LevelDefinition {
name: "Tab/Indent", name_key: "skill_tree.level_tab_indent",
keys: &['\t'], keys: &['\t'],
}, },
]; ];
const CODE_SYMBOLS_LEVELS: &[LevelDefinition] = &[ const CODE_SYMBOLS_LEVELS: &[LevelDefinition] = &[
LevelDefinition { LevelDefinition {
name: "Arithmetic & Assignment", name_key: "skill_tree.level_arithmetic_assignment",
keys: &['=', '+', '*', '/', '-'], keys: &['=', '+', '*', '/', '-'],
}, },
LevelDefinition { LevelDefinition {
name: "Grouping", name_key: "skill_tree.level_grouping",
keys: &['{', '}', '[', ']', '<', '>'], keys: &['{', '}', '[', ']', '<', '>'],
}, },
LevelDefinition { LevelDefinition {
name: "Logic & Reference", name_key: "skill_tree.level_logic_reference",
keys: &['&', '|', '^', '~', '!'], keys: &['&', '|', '^', '~', '!'],
}, },
LevelDefinition { LevelDefinition {
name: "Special", name_key: "skill_tree.level_special",
keys: &['@', '#', '$', '%', '_', '\\', '`'], keys: &['@', '#', '$', '%', '_', '\\', '`'],
}, },
]; ];
@@ -174,43 +186,43 @@ const CODE_SYMBOLS_LEVELS: &[LevelDefinition] = &[
pub const ALL_BRANCHES: &[BranchDefinition] = &[ pub const ALL_BRANCHES: &[BranchDefinition] = &[
BranchDefinition { BranchDefinition {
id: BranchId::Lowercase, id: BranchId::Lowercase,
name: "Primary Letters", name_key: "skill_tree.branch_primary_letters",
levels: LOWERCASE_LEVELS, levels: LOWERCASE_LEVELS,
}, },
BranchDefinition { BranchDefinition {
id: BranchId::Capitals, id: BranchId::Capitals,
name: "Capital Letters", name_key: "skill_tree.branch_capital_letters",
levels: CAPITALS_LEVELS, levels: CAPITALS_LEVELS,
}, },
BranchDefinition { BranchDefinition {
id: BranchId::Numbers, id: BranchId::Numbers,
name: "Numbers 0-9", name_key: "skill_tree.branch_numbers",
levels: NUMBERS_LEVELS, levels: NUMBERS_LEVELS,
}, },
BranchDefinition { BranchDefinition {
id: BranchId::ProsePunctuation, id: BranchId::ProsePunctuation,
name: "Prose Punctuation", name_key: "skill_tree.branch_prose_punctuation",
levels: PROSE_PUNCTUATION_LEVELS, levels: PROSE_PUNCTUATION_LEVELS,
}, },
BranchDefinition { BranchDefinition {
id: BranchId::Whitespace, id: BranchId::Whitespace,
name: "Whitespace", name_key: "skill_tree.branch_whitespace",
levels: WHITESPACE_LEVELS, levels: WHITESPACE_LEVELS,
}, },
BranchDefinition { BranchDefinition {
id: BranchId::CodeSymbols, id: BranchId::CodeSymbols,
name: "Code Symbols", name_key: "skill_tree.branch_code_symbols",
levels: CODE_SYMBOLS_LEVELS, levels: CODE_SYMBOLS_LEVELS,
}, },
]; ];
/// Find which branch and level a key belongs to. /// Find which branch and level a key belongs to.
/// Returns (branch_def, level_name, 1-based position in level). /// Returns (branch_def, level_def, 1-based position in level).
pub fn find_key_branch(ch: char) -> Option<(&'static BranchDefinition, &'static str, usize)> { pub fn find_key_branch(ch: char) -> Option<(&'static BranchDefinition, &'static LevelDefinition, usize)> {
for branch in ALL_BRANCHES { for branch in ALL_BRANCHES {
for level in branch.levels { for level in branch.levels {
if let Some(pos) = level.keys.iter().position(|&k| k == ch) { 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() { fn test_find_key_branch_lowercase() {
let result = find_key_branch('e'); let result = find_key_branch('e');
assert!(result.is_some()); 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!(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 assert_eq!(pos, 1); // 'e' is first in the frequency order
} }
@@ -1302,9 +1314,9 @@ mod tests {
fn test_find_key_branch_capitals() { fn test_find_key_branch_capitals() {
let result = find_key_branch('T'); let result = find_key_branch('T');
assert!(result.is_some()); 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!(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 assert_eq!(pos, 1); // 'T' is first
} }

View File

@@ -7,6 +7,7 @@ use rand::rngs::SmallRng;
use crate::engine::filter::CharFilter; use crate::engine::filter::CharFilter;
use crate::generator::TextGenerator; use crate::generator::TextGenerator;
use crate::generator::cache::fetch_url_bytes_with_progress; use crate::generator::cache::fetch_url_bytes_with_progress;
use crate::i18n::t;
const PASSAGES: &[&str] = &[ 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", "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)> { pub fn passage_options() -> Vec<(&'static str, String)> {
let mut out = vec![ let mut out = vec![
("all", "All (Built-in + all books)".to_string()), ("all", t!("select.passage_all").to_string()),
("builtin", "Built-in passages only".to_string()), ("builtin", t!("select.passage_builtin").to_string()),
]; ];
for book in GUTENBERG_BOOKS { 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 out
} }

214
src/i18n.rs Normal file
View File

@@ -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<String>) {
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<String> {
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::<Vec<_>>()
.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::<Vec<_>>()
.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<String, String>,
) {
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");
}
}

View File

@@ -27,6 +27,7 @@ impl FingerAssignment {
Self { hand, finger } Self { hand, finger }
} }
#[allow(dead_code)]
pub fn description(&self) -> &'static str { pub fn description(&self) -> &'static str {
match (self.hand, self.finger) { match (self.hand, self.finger) {
(Hand::Left, Finger::Pinky) => "left pinky", (Hand::Left, Finger::Pinky) => "left pinky",
@@ -41,6 +42,22 @@ impl FingerAssignment {
(Hand::Right, Finger::Thumb) => "right thumb", (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)] #[allow(dead_code)]

View File

@@ -513,10 +513,17 @@ impl KeyboardModel {
let Some(&(row, col)) = slots.next() else { let Some(&(row, col)) = slots.next() else {
break; 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 }; model.rows[row][col] = PhysicalKey { base: ch, shifted };
used.insert(ch); used.insert(ch);
used.insert(shifted); if shifted != ch {
used.insert(shifted);
}
} }
model model
} }
@@ -737,12 +744,14 @@ mod tests {
pk.base, pk.base,
key key
); );
assert!( if pk.shifted != pk.base {
seen.insert(pk.shifted), assert!(
"duplicate shifted char {:?} in profile {}", seen.insert(pk.shifted),
pk.shifted, "duplicate shifted char {:?} in profile {}",
key pk.shifted,
); key
);
}
} }
} }
} }

View File

@@ -78,6 +78,7 @@ impl fmt::Display for RankedReadinessError {
pub struct LanguagePack { pub struct LanguagePack {
pub language_key: &'static str, pub language_key: &'static str,
pub display_name: &'static str, pub display_name: &'static str,
pub autonym: &'static str,
pub script: Script, pub script: Script,
pub dictionary_asset_id: &'static str, pub dictionary_asset_id: &'static str,
pub supported_keyboard_layout_keys: &'static [&'static str], pub supported_keyboard_layout_keys: &'static [&'static str],
@@ -135,6 +136,7 @@ static LANGUAGE_PACKS: &[LanguagePack] = &[
LanguagePack { LanguagePack {
language_key: "en", language_key: "en",
display_name: "English", display_name: "English",
autonym: "English",
script: Script::Latin, script: Script::Latin,
dictionary_asset_id: "words-en", dictionary_asset_id: "words-en",
supported_keyboard_layout_keys: EN_LAYOUTS, supported_keyboard_layout_keys: EN_LAYOUTS,
@@ -144,6 +146,7 @@ static LANGUAGE_PACKS: &[LanguagePack] = &[
LanguagePack { LanguagePack {
language_key: "de", language_key: "de",
display_name: "German", display_name: "German",
autonym: "Deutsch",
script: Script::Latin, script: Script::Latin,
dictionary_asset_id: "words-de", dictionary_asset_id: "words-de",
supported_keyboard_layout_keys: DE_LAYOUTS, supported_keyboard_layout_keys: DE_LAYOUTS,
@@ -153,6 +156,7 @@ static LANGUAGE_PACKS: &[LanguagePack] = &[
LanguagePack { LanguagePack {
language_key: "es", language_key: "es",
display_name: "Spanish", display_name: "Spanish",
autonym: "Español",
script: Script::Latin, script: Script::Latin,
dictionary_asset_id: "words-es", dictionary_asset_id: "words-es",
supported_keyboard_layout_keys: ES_LAYOUTS, supported_keyboard_layout_keys: ES_LAYOUTS,
@@ -162,6 +166,7 @@ static LANGUAGE_PACKS: &[LanguagePack] = &[
LanguagePack { LanguagePack {
language_key: "fr", language_key: "fr",
display_name: "French", display_name: "French",
autonym: "Français",
script: Script::Latin, script: Script::Latin,
dictionary_asset_id: "words-fr", dictionary_asset_id: "words-fr",
supported_keyboard_layout_keys: FR_LAYOUTS, supported_keyboard_layout_keys: FR_LAYOUTS,
@@ -171,6 +176,7 @@ static LANGUAGE_PACKS: &[LanguagePack] = &[
LanguagePack { LanguagePack {
language_key: "it", language_key: "it",
display_name: "Italian", display_name: "Italian",
autonym: "Italiano",
script: Script::Latin, script: Script::Latin,
dictionary_asset_id: "words-it", dictionary_asset_id: "words-it",
supported_keyboard_layout_keys: IT_LAYOUTS, supported_keyboard_layout_keys: IT_LAYOUTS,
@@ -180,6 +186,7 @@ static LANGUAGE_PACKS: &[LanguagePack] = &[
LanguagePack { LanguagePack {
language_key: "pt", language_key: "pt",
display_name: "Portuguese", display_name: "Portuguese",
autonym: "Português",
script: Script::Latin, script: Script::Latin,
dictionary_asset_id: "words-pt", dictionary_asset_id: "words-pt",
supported_keyboard_layout_keys: PT_LAYOUTS, supported_keyboard_layout_keys: PT_LAYOUTS,
@@ -189,6 +196,7 @@ static LANGUAGE_PACKS: &[LanguagePack] = &[
LanguagePack { LanguagePack {
language_key: "nl", language_key: "nl",
display_name: "Dutch", display_name: "Dutch",
autonym: "Nederlands",
script: Script::Latin, script: Script::Latin,
dictionary_asset_id: "words-nl", dictionary_asset_id: "words-nl",
supported_keyboard_layout_keys: NL_LAYOUTS, supported_keyboard_layout_keys: NL_LAYOUTS,
@@ -198,6 +206,7 @@ static LANGUAGE_PACKS: &[LanguagePack] = &[
LanguagePack { LanguagePack {
language_key: "sv", language_key: "sv",
display_name: "Swedish", display_name: "Swedish",
autonym: "Svenska",
script: Script::Latin, script: Script::Latin,
dictionary_asset_id: "words-sv", dictionary_asset_id: "words-sv",
supported_keyboard_layout_keys: SV_LAYOUTS, supported_keyboard_layout_keys: SV_LAYOUTS,
@@ -207,6 +216,7 @@ static LANGUAGE_PACKS: &[LanguagePack] = &[
LanguagePack { LanguagePack {
language_key: "da", language_key: "da",
display_name: "Danish", display_name: "Danish",
autonym: "Dansk",
script: Script::Latin, script: Script::Latin,
dictionary_asset_id: "words-da", dictionary_asset_id: "words-da",
supported_keyboard_layout_keys: DA_LAYOUTS, supported_keyboard_layout_keys: DA_LAYOUTS,
@@ -216,6 +226,7 @@ static LANGUAGE_PACKS: &[LanguagePack] = &[
LanguagePack { LanguagePack {
language_key: "nb", language_key: "nb",
display_name: "Norwegian Bokmal", display_name: "Norwegian Bokmal",
autonym: "Norsk bokmål",
script: Script::Latin, script: Script::Latin,
dictionary_asset_id: "words-nb", dictionary_asset_id: "words-nb",
supported_keyboard_layout_keys: NB_LAYOUTS, supported_keyboard_layout_keys: NB_LAYOUTS,
@@ -225,6 +236,7 @@ static LANGUAGE_PACKS: &[LanguagePack] = &[
LanguagePack { LanguagePack {
language_key: "fi", language_key: "fi",
display_name: "Finnish", display_name: "Finnish",
autonym: "Suomi",
script: Script::Latin, script: Script::Latin,
dictionary_asset_id: "words-fi", dictionary_asset_id: "words-fi",
supported_keyboard_layout_keys: FI_LAYOUTS, supported_keyboard_layout_keys: FI_LAYOUTS,
@@ -234,6 +246,7 @@ static LANGUAGE_PACKS: &[LanguagePack] = &[
LanguagePack { LanguagePack {
language_key: "pl", language_key: "pl",
display_name: "Polish", display_name: "Polish",
autonym: "Polski",
script: Script::Latin, script: Script::Latin,
dictionary_asset_id: "words-pl", dictionary_asset_id: "words-pl",
supported_keyboard_layout_keys: PL_LAYOUTS, supported_keyboard_layout_keys: PL_LAYOUTS,
@@ -243,6 +256,7 @@ static LANGUAGE_PACKS: &[LanguagePack] = &[
LanguagePack { LanguagePack {
language_key: "cs", language_key: "cs",
display_name: "Czech", display_name: "Czech",
autonym: "Čeština",
script: Script::Latin, script: Script::Latin,
dictionary_asset_id: "words-cs", dictionary_asset_id: "words-cs",
supported_keyboard_layout_keys: CS_LAYOUTS, supported_keyboard_layout_keys: CS_LAYOUTS,
@@ -252,6 +266,7 @@ static LANGUAGE_PACKS: &[LanguagePack] = &[
LanguagePack { LanguagePack {
language_key: "ro", language_key: "ro",
display_name: "Romanian", display_name: "Romanian",
autonym: "Română",
script: Script::Latin, script: Script::Latin,
dictionary_asset_id: "words-ro", dictionary_asset_id: "words-ro",
supported_keyboard_layout_keys: RO_LAYOUTS, supported_keyboard_layout_keys: RO_LAYOUTS,
@@ -261,6 +276,7 @@ static LANGUAGE_PACKS: &[LanguagePack] = &[
LanguagePack { LanguagePack {
language_key: "hr", language_key: "hr",
display_name: "Croatian", display_name: "Croatian",
autonym: "Hrvatski",
script: Script::Latin, script: Script::Latin,
dictionary_asset_id: "words-hr", dictionary_asset_id: "words-hr",
supported_keyboard_layout_keys: HR_LAYOUTS, supported_keyboard_layout_keys: HR_LAYOUTS,
@@ -270,6 +286,7 @@ static LANGUAGE_PACKS: &[LanguagePack] = &[
LanguagePack { LanguagePack {
language_key: "hu", language_key: "hu",
display_name: "Hungarian", display_name: "Hungarian",
autonym: "Magyar",
script: Script::Latin, script: Script::Latin,
dictionary_asset_id: "words-hu", dictionary_asset_id: "words-hu",
supported_keyboard_layout_keys: HU_LAYOUTS, supported_keyboard_layout_keys: HU_LAYOUTS,
@@ -279,6 +296,7 @@ static LANGUAGE_PACKS: &[LanguagePack] = &[
LanguagePack { LanguagePack {
language_key: "lt", language_key: "lt",
display_name: "Lithuanian", display_name: "Lithuanian",
autonym: "Lietuvių",
script: Script::Latin, script: Script::Latin,
dictionary_asset_id: "words-lt", dictionary_asset_id: "words-lt",
supported_keyboard_layout_keys: LT_LAYOUTS, supported_keyboard_layout_keys: LT_LAYOUTS,
@@ -288,6 +306,7 @@ static LANGUAGE_PACKS: &[LanguagePack] = &[
LanguagePack { LanguagePack {
language_key: "lv", language_key: "lv",
display_name: "Latvian", display_name: "Latvian",
autonym: "Latviešu",
script: Script::Latin, script: Script::Latin,
dictionary_asset_id: "words-lv", dictionary_asset_id: "words-lv",
supported_keyboard_layout_keys: LV_LAYOUTS, supported_keyboard_layout_keys: LV_LAYOUTS,
@@ -297,6 +316,7 @@ static LANGUAGE_PACKS: &[LanguagePack] = &[
LanguagePack { LanguagePack {
language_key: "sl", language_key: "sl",
display_name: "Slovene", display_name: "Slovene",
autonym: "Slovenščina",
script: Script::Latin, script: Script::Latin,
dictionary_asset_id: "words-sl", dictionary_asset_id: "words-sl",
supported_keyboard_layout_keys: SL_LAYOUTS, supported_keyboard_layout_keys: SL_LAYOUTS,
@@ -306,6 +326,7 @@ static LANGUAGE_PACKS: &[LanguagePack] = &[
LanguagePack { LanguagePack {
language_key: "et", language_key: "et",
display_name: "Estonian", display_name: "Estonian",
autonym: "Eesti",
script: Script::Latin, script: Script::Latin,
dictionary_asset_id: "words-et", dictionary_asset_id: "words-et",
supported_keyboard_layout_keys: ET_LAYOUTS, supported_keyboard_layout_keys: ET_LAYOUTS,
@@ -315,6 +336,7 @@ static LANGUAGE_PACKS: &[LanguagePack] = &[
LanguagePack { LanguagePack {
language_key: "tr", language_key: "tr",
display_name: "Turkish", display_name: "Turkish",
autonym: "Türkçe",
script: Script::Latin, script: Script::Latin,
dictionary_asset_id: "words-tr", dictionary_asset_id: "words-tr",
supported_keyboard_layout_keys: TR_LAYOUTS, supported_keyboard_layout_keys: TR_LAYOUTS,

View File

@@ -4,6 +4,8 @@
// Most code is only exercised through the binary, so suppress dead_code warnings. // Most code is only exercised through the binary, so suppress dead_code warnings.
#![allow(dead_code)] #![allow(dead_code)]
rust_i18n::i18n!("locales", fallback = "en");
// Public: used by benchmarks and the generate_test_profiles binary // Public: used by benchmarks and the generate_test_profiles binary
pub mod config; pub mod config;
pub mod engine; pub mod engine;
@@ -16,4 +18,5 @@ pub mod store;
mod app; mod app;
mod event; mod event;
mod generator; mod generator;
mod i18n;
mod ui; mod ui;

File diff suppressed because it is too large Load Diff

View File

@@ -7,6 +7,7 @@ use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span}; use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Widget}; use ratatui::widgets::{Block, Widget};
use crate::i18n::t;
use crate::session::result::DrillResult; use crate::session::result::DrillResult;
use crate::ui::theme::Theme; use crate::ui::theme::Theme;
@@ -27,7 +28,7 @@ impl Widget for ActivityHeatmap<'_> {
let block = Block::bordered() let block = Block::bordered()
.title(Line::from(Span::styled( .title(Line::from(Span::styled(
" Daily Activity (Sessions per Day) ", t!("heatmap.title"),
Style::default() Style::default()
.fg(colors.accent()) .fg(colors.accent())
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
@@ -87,27 +88,27 @@ impl Widget for ActivityHeatmap<'_> {
// Month label on first row // Month label on first row
let month = current_date.month(); let month = current_date.month();
if month != last_month { if month != last_month {
let month_name = match month { let month_name: std::borrow::Cow<'_, str> = match month {
1 => "Jan", 1 => t!("heatmap.jan"),
2 => "Feb", 2 => t!("heatmap.feb"),
3 => "Mar", 3 => t!("heatmap.mar"),
4 => "Apr", 4 => t!("heatmap.apr"),
5 => "May", 5 => t!("heatmap.may"),
6 => "Jun", 6 => t!("heatmap.jun"),
7 => "Jul", 7 => t!("heatmap.jul"),
8 => "Aug", 8 => t!("heatmap.aug"),
9 => "Sep", 9 => t!("heatmap.sep"),
10 => "Oct", 10 => t!("heatmap.oct"),
11 => "Nov", 11 => t!("heatmap.nov"),
12 => "Dec", 12 => t!("heatmap.dec"),
_ => "", _ => std::borrow::Cow::Borrowed(""),
}; };
// Only show if we have space (3 chars) // Only show if we have space (3 chars)
if x + 3 <= inner.x + inner.width { if x + 3 <= inner.x + inner.width {
buf.set_string( buf.set_string(
x, x,
inner.y, inner.y,
month_name, month_name.as_ref(),
Style::default().fg(colors.text_pending()), Style::default().fg(colors.text_pending()),
); );
} }

View File

@@ -5,6 +5,7 @@ use ratatui::text::{Line, Span};
use ratatui::widgets::{Paragraph, Widget}; use ratatui::widgets::{Paragraph, Widget};
use crate::engine::skill_tree::{BranchId, DrillScope, SkillTree, get_branch_definition}; use crate::engine::skill_tree::{BranchId, DrillScope, SkillTree, get_branch_definition};
use crate::i18n::t;
use crate::ui::theme::Theme; use crate::ui::theme::Theme;
pub struct BranchProgressList<'a> { 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); let (m_bar, u_bar, e_bar) = compact_dual_bar_parts(mastered, unlocked, total, 12);
lines.push(Line::from(vec![ lines.push(Line::from(vec![
Span::styled( Span::styled(
format!(" \u{25b6} {:<14}", def.name), format!(" \u{25b6} {:<14}", def.display_name()),
Style::default().fg(colors.accent()), Style::default().fg(colors.accent()),
), ),
Span::styled(m_bar, Style::default().fg(colors.text_correct())), Span::styled(m_bar, Style::default().fg(colors.text_correct())),
@@ -123,9 +124,12 @@ impl Widget for BranchProgressList<'_> {
0 0
}; };
let right_pad = if area.width >= 75 { 2 } else { 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!( let suffix = format!(
" {unlocked}/{total} unlocked ({mastered} mastered){}", " {}{}",
unlocked_mastered,
" ".repeat(right_pad) " ".repeat(right_pad)
); );
let reserved = label.len() + suffix.len(); 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 fixed = prefix.len() + name_width + 1 + count.len();
let bar_width = cell_width.saturating_sub(fixed).max(6); 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 (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<Span> = vec![ let mut spans: Vec<Span> = vec![
Span::styled(prefix.to_string(), Style::default().fg(label_color)), Span::styled(prefix.to_string(), Style::default().fg(label_color)),

View File

@@ -4,6 +4,7 @@ use ratatui::style::Style;
use ratatui::symbols; use ratatui::symbols;
use ratatui::widgets::{Axis, Block, Chart, Dataset, GraphType, Widget}; use ratatui::widgets::{Axis, Block, Chart, Dataset, GraphType, Widget};
use crate::i18n::t;
use crate::ui::theme::Theme; use crate::ui::theme::Theme;
#[allow(dead_code)] #[allow(dead_code)]
@@ -24,8 +25,9 @@ impl Widget for WpmChart<'_> {
let colors = &self.theme.colors; let colors = &self.theme.colors;
if self.data.is_empty() { if self.data.is_empty() {
let wpm_title = t!("chart.wpm_over_time");
let block = Block::bordered() let block = Block::bordered()
.title(" WPM Over Time ") .title(wpm_title.to_string())
.border_style(Style::default().fg(colors.border())); .border_style(Style::default().fg(colors.border()));
block.render(area, buf); block.render(area, buf);
return; return;
@@ -45,21 +47,24 @@ impl Widget for WpmChart<'_> {
.style(Style::default().fg(colors.accent())) .style(Style::default().fg(colors.accent()))
.data(self.data); .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]) let chart = Chart::new(vec![dataset])
.block( .block(
Block::bordered() Block::bordered()
.title(" WPM Over Time ") .title(wpm_title.to_string())
.border_style(Style::default().fg(colors.border())), .border_style(Style::default().fg(colors.border())),
) )
.x_axis( .x_axis(
Axis::default() Axis::default()
.title("Drill #") .title(drill_number_label.to_string())
.style(Style::default().fg(colors.text_pending())) .style(Style::default().fg(colors.text_pending()))
.bounds([0.0, max_x]), .bounds([0.0, max_x]),
) )
.y_axis( .y_axis(
Axis::default() Axis::default()
.title("WPM") .title(wpm_label.to_string())
.style(Style::default().fg(colors.text_pending())) .style(Style::default().fg(colors.text_pending()))
.bounds([0.0, max_y * 1.1]), .bounds([0.0, max_y * 1.1]),
); );

View File

@@ -4,6 +4,7 @@ use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span}; use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph, Widget}; use ratatui::widgets::{Block, Paragraph, Widget};
use crate::i18n::t;
use crate::session::result::DrillResult; use crate::session::result::DrillResult;
use crate::ui::layout::pack_hint_lines; use crate::ui::layout::pack_hint_lines;
use crate::ui::theme::Theme; use crate::ui::theme::Theme;
@@ -32,8 +33,9 @@ impl Widget for Dashboard<'_> {
fn render(self, area: Rect, buf: &mut Buffer) { fn render(self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors; let colors = &self.theme.colors;
let title_text = t!("dashboard.title");
let block = Block::bordered() let block = Block::bordered()
.title(" Drill Complete ") .title(title_text.to_string())
.border_style(Style::default().fg(colors.accent())) .border_style(Style::default().fg(colors.accent()))
.style(Style::default().bg(colors.bg())); .style(Style::default().bg(colors.bg()));
let inner = block.inner(area); 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() { let footer_line_count = if self.input_lock_remaining_ms.is_some() {
1u16 1u16
} else { } 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 = [ let hints = [
"[c/Enter/Space] Continue", hint_continue.as_ref(),
"[r] Retry", hint_retry.as_ref(),
"[q] Menu", hint_menu.as_ref(),
"[s] Stats", hint_stats.as_ref(),
"[x] Delete", hint_delete.as_ref(),
]; ];
pack_hint_lines(&hints, inner.width as usize).len().max(1) as u16 pack_hint_lines(&hints, inner.width as usize).len().max(1) as u16
}; };
@@ -65,25 +72,32 @@ impl Widget for Dashboard<'_> {
]) ])
.split(inner); .split(inner);
let results_label = t!("dashboard.results");
let mut title_spans = vec![Span::styled( let mut title_spans = vec![Span::styled(
"Results", results_label.to_string(),
Style::default() Style::default()
.fg(colors.accent()) .fg(colors.accent())
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
)]; )];
if !self.result.ranked { 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( title_spans.push(Span::styled(
" (Unranked \u{2014} does not count toward skill tree)", unranked_note,
Style::default().fg(colors.text_pending()), Style::default().fg(colors.text_pending()),
)); ));
} }
let title = Paragraph::new(Line::from(title_spans)).alignment(Alignment::Center); let title = Paragraph::new(Line::from(title_spans)).alignment(Alignment::Center);
title.render(layout[0], buf); title.render(layout[0], buf);
let speed_label = t!("dashboard.speed");
let wpm_text = format!("{:.0} WPM", self.result.wpm); let wpm_text = format!("{:.0} WPM", self.result.wpm);
let cpm_text = format!(" ({:.0} CPM)", self.result.cpm); let cpm_text = format!(" ({:.0} CPM)", self.result.cpm);
let wpm_line = Line::from(vec![ 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( Span::styled(
&*wpm_text, &*wpm_text,
Style::default() Style::default()
@@ -101,31 +115,31 @@ impl Widget for Dashboard<'_> {
} else { } else {
colors.error() colors.error()
}; };
let accuracy_label = t!("dashboard.accuracy_label");
let acc_text = format!("{:.1}%", self.result.accuracy); let acc_text = format!("{:.1}%", self.result.accuracy);
let acc_detail = format!( let acc_detail = t!("dashboard.correct_detail", correct = self.result.correct, total = self.result.total_chars);
" ({}/{} correct)",
self.result.correct, self.result.total_chars
);
let acc_line = Line::from(vec![ 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( Span::styled(
&*acc_text, &*acc_text,
Style::default().fg(acc_color).add_modifier(Modifier::BOLD), 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); 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_text = format!("{:.1}s", self.result.elapsed_secs);
let time_line = Line::from(vec![ 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())), Span::styled(&*time_text, Style::default().fg(colors.fg())),
]); ]);
Paragraph::new(time_line).render(layout[3], buf); Paragraph::new(time_line).render(layout[3], buf);
let errors_label = t!("dashboard.errors_label");
let error_text = format!("{}", self.result.incorrect); let error_text = format!("{}", self.result.incorrect);
let chars_line = Line::from(vec![ 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( Span::styled(
&*error_text, &*error_text,
Style::default().fg(if self.result.incorrect == 0 { Style::default().fg(if self.result.incorrect == 0 {
@@ -138,25 +152,32 @@ impl Widget for Dashboard<'_> {
Paragraph::new(chars_line).render(layout[4], buf); Paragraph::new(chars_line).render(layout[4], buf);
let help = if let Some(ms) = self.input_lock_remaining_ms { 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![ Paragraph::new(Line::from(vec![
Span::styled( Span::styled(
" Input temporarily blocked ", input_blocked_label.to_string(),
Style::default().fg(colors.warning()), Style::default().fg(colors.warning()),
), ),
Span::styled( Span::styled(
format!("({ms}ms remaining)"), input_blocked_ms.to_string(),
Style::default() Style::default()
.fg(colors.warning()) .fg(colors.warning())
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
), ),
])) ]))
} else { } 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 = [ let hints = [
"[c/Enter/Space] Continue", hint_continue.as_ref(),
"[r] Retry", hint_retry.as_ref(),
"[q] Menu", hint_menu.as_ref(),
"[s] Stats", hint_stats.as_ref(),
"[x] Delete", hint_delete.as_ref(),
]; ];
let lines: Vec<Line> = pack_hint_lines(&hints, inner.width as usize) let lines: Vec<Line> = pack_hint_lines(&hints, inner.width as usize)
.into_iter() .into_iter()

View File

@@ -4,16 +4,20 @@ use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span}; use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph, Widget}; use ratatui::widgets::{Block, Paragraph, Widget};
use crate::i18n::t;
use crate::ui::theme::Theme; use crate::ui::theme::Theme;
pub struct MenuItem { const MENU_ITEMS: &[(&str, &str, &str)] = &[
pub key: String, ("1", "menu.adaptive_drill", "menu.adaptive_drill_desc"),
pub label: String, ("2", "menu.code_drill", "menu.code_drill_desc"),
pub description: String, ("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 struct Menu<'a> {
pub items: Vec<MenuItem>,
pub selected: usize, pub selected: usize,
pub theme: &'a Theme, pub theme: &'a Theme,
} }
@@ -21,57 +25,24 @@ pub struct Menu<'a> {
impl<'a> Menu<'a> { impl<'a> Menu<'a> {
pub fn new(theme: &'a Theme) -> Self { pub fn new(theme: &'a Theme) -> Self {
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, selected: 0,
theme, theme,
} }
} }
pub fn item_count() -> usize {
MENU_ITEMS.len()
}
pub fn next(&mut self) { 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) { pub fn prev(&mut self) {
if self.selected > 0 { if self.selected > 0 {
self.selected -= 1; self.selected -= 1;
} else { } else {
self.selected = self.items.len() - 1; self.selected = MENU_ITEMS.len() - 1;
} }
} }
} }
@@ -95,6 +66,7 @@ impl Widget for &Menu<'_> {
]) ])
.split(inner); .split(inner);
let subtitle = t!("menu.subtitle");
let title_lines = vec![ let title_lines = vec![
Line::from(""), Line::from(""),
Line::from(Span::styled( Line::from(Span::styled(
@@ -104,7 +76,7 @@ impl Widget for &Menu<'_> {
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
)), )),
Line::from(Span::styled( Line::from(Span::styled(
"Terminal Typing Tutor", subtitle.as_ref(),
Style::default().fg(colors.fg()), Style::default().fg(colors.fg()),
)), )),
Line::from(""), Line::from(""),
@@ -116,33 +88,31 @@ impl Widget for &Menu<'_> {
let menu_layout = Layout::default() let menu_layout = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.constraints( .constraints(
self.items MENU_ITEMS
.iter() .iter()
.map(|_| Constraint::Length(3)) .map(|_| Constraint::Length(3))
.collect::<Vec<_>>(), .collect::<Vec<_>>(),
) )
.split(layout[2]); .split(layout[2]);
let key_width = self let key_width = MENU_ITEMS
.items
.iter() .iter()
.map(|item| item.key.len()) .map(|(key, _, _)| key.len())
.max() .max()
.unwrap_or(1); .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 is_selected = i == self.selected;
let indicator = if is_selected { ">" } else { " " }; let indicator = if is_selected { ">" } else { " " };
let label = t!(label_key);
let description = t!(desc_key);
let label_text = format!( let label_text = format!(
" {indicator} [{key:<key_width$}] {label}", " {indicator} [{key:<key_width$}] {label}",
key = item.key,
key_width = key_width, key_width = key_width,
label = item.label
); );
let desc_text = format!( let desc_text = format!(
" {:indent$}{}", " {:indent$}{description}",
"", "",
item.description,
indent = key_width + 4 indent = key_width + 4
); );

View File

@@ -4,6 +4,7 @@ use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span}; use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph, Widget, Wrap}; use ratatui::widgets::{Block, Paragraph, Widget, Wrap};
use crate::i18n::t;
use crate::engine::key_stats::KeyStatsStore; use crate::engine::key_stats::KeyStatsStore;
use crate::engine::skill_tree::{ use crate::engine::skill_tree::{
BranchId, BranchStatus, DrillScope, SkillTree as SkillTreeEngine, get_branch_definition, BranchId, BranchStatus, DrillScope, SkillTree as SkillTreeEngine, get_branch_definition,
@@ -38,10 +39,7 @@ impl<'a> SkillTreeWidget<'a> {
} }
fn locked_branch_notice(skill_tree: &SkillTreeEngine) -> String { fn locked_branch_notice(skill_tree: &SkillTreeEngine) -> String {
format!( t!("skill_tree.locked_notice", count = skill_tree.primary_letters().len()).to_string()
"Complete {} primary letters to unlock branches",
skill_tree.primary_letters().len()
)
} }
/// Get the list of selectable branch IDs (Lowercase first, then other branches). /// 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 colors = &self.theme.colors;
let block = Block::bordered() let block = Block::bordered()
.title(" Skill Tree ") .title(t!("skill_tree.title").to_string())
.border_style(Style::default().fg(colors.accent())) .border_style(Style::default().fg(colors.accent()))
.style(Style::default().bg(colors.bg())); .style(Style::default().bg(colors.bg()));
let inner = block.inner(area); let inner = block.inner(area);
@@ -139,44 +137,49 @@ impl Widget for SkillTreeWidget<'_> {
// Layout: main split (branch list + detail) and footer (adaptive height) // Layout: main split (branch list + detail) and footer (adaptive height)
let branches = selectable_branches(); 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<String>) = let (footer_hints, footer_notice): (Vec<&str>, Option<String>) =
if self.selected < branches.len() { if self.selected < branches.len() {
let bp = self.skill_tree.branch_progress(branches[self.selected]); let bp = self.skill_tree.branch_progress(branches[self.selected]);
if *self.skill_tree.branch_status(branches[self.selected]) == BranchStatus::Locked { if *self.skill_tree.branch_status(branches[self.selected]) == BranchStatus::Locked {
( (
vec![ vec![
"[↑↓/jk] Navigate", h_navigate.as_str(),
"[PgUp/PgDn or Ctrl+U/Ctrl+D] Scroll", h_scroll.as_str(),
"[q] Back", h_back.as_str(),
], ],
Some(locked_branch_notice(self.skill_tree)), Some(locked_branch_notice(self.skill_tree)),
) )
} else if bp.status == BranchStatus::Available { } else if bp.status == BranchStatus::Available {
( (
vec![ vec![
"[Enter] Unlock", h_unlock.as_str(),
"[↑↓/jk] Navigate", h_navigate.as_str(),
"[PgUp/PgDn or Ctrl+U/Ctrl+D] Scroll", h_scroll.as_str(),
"[q] Back", h_back.as_str(),
], ],
None, None,
) )
} else if bp.status == BranchStatus::InProgress { } else if bp.status == BranchStatus::InProgress {
( (
vec![ vec![
"[Enter] Start Drill", h_start_drill.as_str(),
"[↑↓/jk] Navigate", h_navigate.as_str(),
"[PgUp/PgDn or Ctrl+U/Ctrl+D] Scroll", h_scroll.as_str(),
"[q] Back", h_back.as_str(),
], ],
None, None,
) )
} else { } else {
( (
vec![ vec![
"[↑↓/jk] Navigate", h_navigate.as_str(),
"[PgUp/PgDn or Ctrl+U/Ctrl+D] Scroll", h_scroll.as_str(),
"[q] Back", h_back.as_str(),
], ],
None, None,
) )
@@ -184,9 +187,9 @@ impl Widget for SkillTreeWidget<'_> {
} else { } else {
( (
vec![ vec![
"[↑↓/jk] Navigate", h_navigate.as_str(),
"[PgUp/PgDn or Ctrl+U/Ctrl+D] Scroll", h_scroll.as_str(),
"[q] Back", h_back.as_str(),
], ],
None, None,
) )
@@ -332,33 +335,35 @@ impl SkillTreeWidget<'_> {
let unlocked = self.skill_tree.branch_unlocked_count(branch_id); let unlocked = self.skill_tree.branch_unlocked_count(branch_id);
let mastered_text = if confident_keys > 0 { let mastered_text = if confident_keys > 0 {
format!(" ({confident_keys} mastered)") format!(" ({confident_keys} {})", t!("skill_tree.mastered"))
} else { } else {
String::new() String::new()
}; };
let status_text = match bp.status { let status_text = match bp.status {
BranchStatus::Complete => { BranchStatus::Complete => {
format!("{unlocked}/{total_keys} unlocked{mastered_text}") format!("{unlocked}/{total_keys} {}{mastered_text}", t!("skill_tree.unlocked"))
} }
BranchStatus::InProgress => { BranchStatus::InProgress => {
if branch_id == BranchId::Lowercase { if branch_id == BranchId::Lowercase {
format!("{unlocked}/{total_keys} unlocked{mastered_text}") format!("{unlocked}/{total_keys} {}{mastered_text}", t!("skill_tree.unlocked"))
} else { } else {
format!( format!(
"Lvl {}/{} {unlocked}/{total_keys} unlocked{mastered_text}", "{} {}/{} {unlocked}/{total_keys} {}{mastered_text}",
t!("skill_tree.lvl_prefix"),
bp.current_level + 1, bp.current_level + 1,
def.levels.len() def.levels.len(),
t!("skill_tree.unlocked")
) )
} }
} }
BranchStatus::Available => format!("0/{total_keys} unlocked"), BranchStatus::Available => format!("0/{total_keys} {}", t!("skill_tree.unlocked")),
BranchStatus::Locked => format!("Locked 0/{total_keys}"), BranchStatus::Locked => format!("{} 0/{total_keys}", t!("skill_tree.locked")),
}; };
let sel_indicator = if is_selected { "> " } else { " " }; let sel_indicator = if is_selected { "> " } else { " " };
lines.push(Line::from(vec![ 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( Span::styled(
format!(" {status_text}"), format!(" {status_text}"),
Style::default().fg(colors.text_pending()), Style::default().fg(colors.text_pending()),
@@ -381,8 +386,8 @@ impl SkillTreeWidget<'_> {
} }
lines.push(Line::from(Span::styled( lines.push(Line::from(Span::styled(
format!( format!(
" \u{2500}\u{2500} Branches (available after {} primary letters) \u{2500}\u{2500}", " \u{2500}\u{2500} {} \u{2500}\u{2500}",
self.skill_tree.primary_letters().len() t!("skill_tree.branches_separator", count = self.skill_tree.primary_letters().len())
), ),
Style::default().fg(colors.text_pending()), Style::default().fg(colors.text_pending()),
))); )));
@@ -423,21 +428,21 @@ impl SkillTreeWidget<'_> {
let level_text = if branch_id == BranchId::Lowercase { let level_text = if branch_id == BranchId::Lowercase {
let unlocked = self.skill_tree.branch_unlocked_count(BranchId::Lowercase); let unlocked = self.skill_tree.branch_unlocked_count(BranchId::Lowercase);
let total = self.skill_tree.branch_total_keys_for(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 { } else {
match bp.status { match bp.status {
BranchStatus::InProgress => { 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 => { 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![ lines.push(Line::from(vec![
Span::styled( Span::styled(
format!(" {}", def.name), format!(" {}", def.display_name()),
Style::default() Style::default()
.fg(colors.accent()) .fg(colors.accent())
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
@@ -462,18 +467,20 @@ impl SkillTreeWidget<'_> {
}; };
for (level_idx, level) in def.levels.iter().enumerate() { for (level_idx, level) in def.levels.iter().enumerate() {
let level_status = let level_is_locked = !(bp.status == BranchStatus::Complete || level_idx < bp.current_level
if bp.status == BranchStatus::Complete || level_idx < bp.current_level { || (bp.status == BranchStatus::InProgress && level_idx == bp.current_level));
"complete" let level_status_owned = if bp.status == BranchStatus::Complete || level_idx < bp.current_level {
} else if bp.status == BranchStatus::InProgress && level_idx == bp.current_level { t!("skill_tree.complete").to_string()
"in progress" } else if bp.status == BranchStatus::InProgress && level_idx == bp.current_level {
} else { t!("skill_tree.in_progress").to_string()
"locked" } else {
}; t!("skill_tree.locked_status").to_string()
};
let level_status = level_status_owned.as_str();
// Level header // Level header
lines.push(Line::from(Span::styled( 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()), Style::default().fg(colors.fg()),
))); )));
@@ -492,7 +499,7 @@ impl SkillTreeWidget<'_> {
let is_locked = if branch_id == BranchId::Lowercase { let is_locked = if branch_id == BranchId::Lowercase {
!lowercase_unlocked_keys.contains(&key) !lowercase_unlocked_keys.contains(&key)
} else { } else {
level_status == "locked" level_is_locked
}; };
let display = if key == '\n' { let display = if key == '\n' {
@@ -509,7 +516,7 @@ impl SkillTreeWidget<'_> {
format!(" {display} "), format!(" {display} "),
Style::default().fg(colors.text_pending()), 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 { } else {
let bar_width = 10; let bar_width = 10;
@@ -517,7 +524,7 @@ impl SkillTreeWidget<'_> {
let empty = bar_width - filled; let empty = bar_width - filled;
let bar = format!("{}{}", "\u{2588}".repeat(filled), "\u{2591}".repeat(empty)); let bar = format!("{}{}", "\u{2588}".repeat(filled), "\u{2591}".repeat(empty));
let pct_str = format!("{:>3.0}%", confidence * 100.0); 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 { let key_style = if is_focused {
Style::default() Style::default()

View File

@@ -12,6 +12,7 @@ use crate::keyboard::display::{self, BACKSPACE, ENTER, MODIFIER_SENTINELS, SPACE
use crate::keyboard::model::KeyboardModel; use crate::keyboard::model::KeyboardModel;
use crate::session::result::DrillResult; use crate::session::result::DrillResult;
use crate::ui::components::activity_heatmap::ActivityHeatmap; use crate::ui::components::activity_heatmap::ActivityHeatmap;
use crate::i18n::t;
use crate::ui::layout::pack_hint_lines; use crate::ui::layout::pack_hint_lines;
use crate::ui::theme::Theme; use crate::ui::theme::Theme;
@@ -95,8 +96,9 @@ impl Widget for StatsDashboard<'_> {
fn render(self, area: Rect, buf: &mut Buffer) { fn render(self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors; let colors = &self.theme.colors;
let title = t!("stats.title");
let block = Block::bordered() let block = Block::bordered()
.title(" Statistics ") .title(title.as_ref())
.border_style(Style::default().fg(colors.accent())) .border_style(Style::default().fg(colors.accent()))
.style(Style::default().bg(colors.bg())); .style(Style::default().bg(colors.bg()));
let inner = block.inner(area); let inner = block.inner(area);
@@ -104,7 +106,7 @@ impl Widget for StatsDashboard<'_> {
if self.history.is_empty() { if self.history.is_empty() {
let msg = Paragraph::new(Line::from(Span::styled( 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()), Style::default().fg(colors.text_pending()),
))); )));
msg.render(inner, buf); msg.render(inner, buf);
@@ -113,10 +115,11 @@ impl Widget for StatsDashboard<'_> {
// Tab header — width-aware wrapping // Tab header — width-aware wrapping
let width = inner.width as usize; let width = inner.width as usize;
let labels = tab_labels();
let mut tab_lines: Vec<Line> = Vec::new(); let mut tab_lines: Vec<Line> = Vec::new();
let mut current_spans: Vec<Span> = Vec::new(); let mut current_spans: Vec<Span> = Vec::new();
let mut current_width: usize = 0; 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 styled_label = format!(" {label} ");
let item_width = styled_label.chars().count() + TAB_SEPARATOR.len(); let item_width = styled_label.chars().count() + TAB_SEPARATOR.len();
if current_width > 0 && current_width + item_width > width { 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; let tab_line_count = tab_lines.len().max(1) as u16;
// Footer — width-aware wrapping // Footer — width-aware wrapping
let footer_hints: Vec<&str> = if self.active_tab == 1 { let footer_hints = if self.active_tab == 1 {
FOOTER_HINTS_HISTORY.to_vec() footer_hints_history()
} else { } 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 footer_line_count = footer_lines_vec.len().max(1) as u16;
let layout = Layout::default() 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 dialog_area = Rect::new(dialog_x, dialog_y, dialog_width, dialog_height);
let idx = self.history.len().saturating_sub(self.history_selected); 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); Clear.render(dialog_area, buf);
let dialog = Paragraph::new(vec![ let dialog = Paragraph::new(vec![
@@ -192,7 +197,7 @@ impl Widget for StatsDashboard<'_> {
.style(Style::default().bg(colors.bg())) .style(Style::default().bg(colors.bg()))
.block( .block(
Block::bordered() Block::bordered()
.title(" Confirm ") .title(confirm_title.as_ref())
.border_style(Style::default().fg(colors.error())) .border_style(Style::default().fg(colors.error()))
.style(Style::default().bg(colors.bg())), .style(Style::default().bg(colors.bg())),
); );
@@ -281,9 +286,10 @@ impl StatsDashboard<'_> {
let avg_acc_str = format!("{avg_accuracy:.1}%"); let avg_acc_str = format!("{avg_accuracy:.1}%");
let time_str = format_duration(total_time); let time_str = format_duration(total_time);
let summary_title = t!("stats.summary_title");
let summary_block = Block::bordered() let summary_block = Block::bordered()
.title(Line::from(Span::styled( .title(Line::from(Span::styled(
" Summary ", summary_title.to_string(),
Style::default() Style::default()
.fg(colors.accent()) .fg(colors.accent())
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
@@ -292,18 +298,23 @@ impl StatsDashboard<'_> {
let summary_inner = summary_block.inner(layout[0]); let summary_inner = summary_block.inner(layout[0]);
summary_block.render(layout[0], buf); 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![ let summary = vec![
Line::from(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( Span::styled(
&*total_str, &*total_str,
Style::default() Style::default()
.fg(colors.accent()) .fg(colors.accent())
.add_modifier(Modifier::BOLD), .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(&*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( Span::styled(
&*best_wpm_str, &*best_wpm_str,
Style::default() Style::default()
@@ -312,7 +323,7 @@ impl StatsDashboard<'_> {
), ),
]), ]),
Line::from(vec![ Line::from(vec![
Span::styled(" Accuracy: ", Style::default().fg(colors.fg())), Span::styled(accuracy_label.to_string(), Style::default().fg(colors.fg())),
Span::styled( Span::styled(
&*avg_acc_str, &*avg_acc_str,
Style::default().fg(if avg_accuracy >= 95.0 { Style::default().fg(if avg_accuracy >= 95.0 {
@@ -323,7 +334,7 @@ impl StatsDashboard<'_> {
colors.error() 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())), 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) { fn render_wpm_bar_graph(&self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors; 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() let block = Block::bordered()
.title(Line::from(Span::styled( .title(Line::from(Span::styled(
target_label, target_label.to_string(),
Style::default() Style::default()
.fg(colors.accent()) .fg(colors.accent())
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
@@ -490,7 +501,7 @@ impl StatsDashboard<'_> {
if data.is_empty() { if data.is_empty() {
let block = Block::bordered() let block = Block::bordered()
.title(Line::from(Span::styled( .title(Line::from(Span::styled(
" Accuracy % (Last 50 Drills) ", t!("stats.accuracy_chart_title").to_string(),
Style::default() Style::default()
.fg(colors.accent()) .fg(colors.accent())
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
@@ -514,7 +525,7 @@ impl StatsDashboard<'_> {
.block( .block(
Block::bordered() Block::bordered()
.title(Line::from(Span::styled( .title(Line::from(Span::styled(
" Accuracy % (Last 50 Drills) ", t!("stats.accuracy_chart_title").to_string(),
Style::default() Style::default()
.fg(colors.accent()) .fg(colors.accent())
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
@@ -524,13 +535,13 @@ impl StatsDashboard<'_> {
) )
.x_axis( .x_axis(
Axis::default() Axis::default()
.title("Drill #") .title(t!("stats.chart_drill").to_string())
.style(Style::default().fg(colors.text_pending()).bg(colors.bg())) .style(Style::default().fg(colors.text_pending()).bg(colors.bg()))
.bounds([0.0, max_x]), .bounds([0.0, max_x]),
) )
.y_axis( .y_axis(
Axis::default() Axis::default()
.title("Accuracy %") .title(t!("stats.chart_accuracy_pct").to_string())
.style(Style::default().fg(colors.text_pending()).bg(colors.bg())) .style(Style::default().fg(colors.text_pending()).bg(colors.bg()))
.labels(vec![ .labels(vec![
Span::styled( Span::styled(
@@ -575,7 +586,7 @@ impl StatsDashboard<'_> {
} else { } else {
colors.accent() 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( render_text_bar(
&wpm_label, &wpm_label,
wpm_pct / 100.0, wpm_pct / 100.0,
@@ -587,7 +598,7 @@ impl StatsDashboard<'_> {
// Accuracy progress // Accuracy progress
let acc_pct = avg_accuracy.min(100.0); 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 { let acc_color = if acc_pct >= 95.0 {
colors.success() colors.success()
} else if acc_pct >= 85.0 { } else if acc_pct >= 85.0 {
@@ -610,10 +621,7 @@ impl StatsDashboard<'_> {
} else { } else {
0.0 0.0
}; };
let level_label = format!( let level_label = t!("stats.keys_label", unlocked = self.overall_unlocked, total = self.overall_total, mastered = self.overall_mastered).to_string();
" Keys: {}/{} ({} mastered)",
self.overall_unlocked, self.overall_total, self.overall_mastered
);
render_text_bar( render_text_bar(
&level_label, &level_label,
key_pct, key_pct,
@@ -628,9 +636,10 @@ impl StatsDashboard<'_> {
let colors = &self.theme.colors; let colors = &self.theme.colors;
// Recent tests bordered table // Recent tests bordered table
let sessions_title = t!("stats.sessions_title");
let table_block = Block::bordered() let table_block = Block::bordered()
.title(Line::from(Span::styled( .title(Line::from(Span::styled(
" Recent Sessions ", sessions_title.to_string(),
Style::default() Style::default()
.fg(colors.accent()) .fg(colors.accent())
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
@@ -640,7 +649,7 @@ impl StatsDashboard<'_> {
table_block.render(area, buf); table_block.render(area, buf);
let header = Line::from(vec![Span::styled( let header = Line::from(vec![Span::styled(
" # WPM Raw Acc% Time Date/Time Mode Ranked Partial", t!("stats.session_header").to_string(),
Style::default() Style::default()
.fg(colors.accent()) .fg(colors.accent())
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
@@ -649,7 +658,7 @@ impl StatsDashboard<'_> {
let mut lines = vec![ let mut lines = vec![
header, header,
Line::from(Span::styled( Line::from(Span::styled(
" ─────────────────────────────────────────────────────────────────────", t!("stats.session_separator").to_string(),
Style::default().fg(colors.border()), 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 { let partial_pct = if result.partial {
result.completion_percent result.completion_percent
} else { } else {
@@ -721,9 +731,10 @@ impl StatsDashboard<'_> {
fn render_keyboard_heatmap(&self, area: Rect, buf: &mut Buffer) { fn render_keyboard_heatmap(&self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors; let colors = &self.theme.colors;
let kbd_acc_title = t!("stats.keyboard_accuracy_title");
let block = Block::bordered() let block = Block::bordered()
.title(Line::from(Span::styled( .title(Line::from(Span::styled(
" Keyboard Accuracy % ", kbd_acc_title.to_string(),
Style::default() Style::default()
.fg(colors.accent()) .fg(colors.accent())
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
@@ -876,9 +887,10 @@ impl StatsDashboard<'_> {
fn render_keyboard_timing(&self, area: Rect, buf: &mut Buffer) { fn render_keyboard_timing(&self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors; let colors = &self.theme.colors;
let kbd_timing_title = t!("stats.keyboard_timing_title");
let block = Block::bordered() let block = Block::bordered()
.title(Line::from(Span::styled( .title(Line::from(Span::styled(
" Keyboard Timing (ms) ", kbd_timing_title.to_string(),
Style::default() Style::default()
.fg(colors.accent()) .fg(colors.accent())
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
@@ -997,9 +1009,10 @@ impl StatsDashboard<'_> {
fn render_slowest_keys(&self, area: Rect, buf: &mut Buffer) { fn render_slowest_keys(&self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors; let colors = &self.theme.colors;
let slowest_title = t!("stats.slowest_keys_title");
let block = Block::bordered() let block = Block::bordered()
.title(Line::from(Span::styled( .title(Line::from(Span::styled(
" Slowest Keys (ms) ", slowest_title.to_string(),
Style::default() Style::default()
.fg(colors.accent()) .fg(colors.accent())
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
@@ -1045,9 +1058,10 @@ impl StatsDashboard<'_> {
fn render_fastest_keys(&self, area: Rect, buf: &mut Buffer) { fn render_fastest_keys(&self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors; let colors = &self.theme.colors;
let fastest_title = t!("stats.fastest_keys_title");
let block = Block::bordered() let block = Block::bordered()
.title(Line::from(Span::styled( .title(Line::from(Span::styled(
" Fastest Keys (ms) ", fastest_title.to_string(),
Style::default() Style::default()
.fg(colors.accent()) .fg(colors.accent())
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
@@ -1093,9 +1107,10 @@ impl StatsDashboard<'_> {
fn render_worst_accuracy_keys(&self, area: Rect, buf: &mut Buffer) { fn render_worst_accuracy_keys(&self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors; let colors = &self.theme.colors;
let worst_title = t!("stats.worst_accuracy_title");
let block = Block::bordered() let block = Block::bordered()
.title(Line::from(Span::styled( .title(Line::from(Span::styled(
" Worst Accuracy (%) ", worst_title.to_string(),
Style::default() Style::default()
.fg(colors.accent()) .fg(colors.accent())
.add_modifier(Modifier::BOLD), .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))); 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() { if key_accuracies.is_empty() {
let no_data = t!("stats.not_enough_data");
buf.set_string( buf.set_string(
inner.x, inner.x,
inner.y, inner.y,
" Not enough data", no_data.as_ref(),
Style::default().fg(colors.text_pending()), Style::default().fg(colors.text_pending()),
); );
return; return;
@@ -1173,9 +1189,10 @@ impl StatsDashboard<'_> {
fn render_best_accuracy_keys(&self, area: Rect, buf: &mut Buffer) { fn render_best_accuracy_keys(&self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors; let colors = &self.theme.colors;
let best_title = t!("stats.best_accuracy_title");
let block = Block::bordered() let block = Block::bordered()
.title(Line::from(Span::styled( .title(Line::from(Span::styled(
" Best Accuracy (%) ", best_title.to_string(),
Style::default() Style::default()
.fg(colors.accent()) .fg(colors.accent())
.add_modifier(Modifier::BOLD), .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))); 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() { if key_accuracies.is_empty() {
let no_data = t!("stats.not_enough_data");
buf.set_string( buf.set_string(
inner.x, inner.x,
inner.y, inner.y,
" Not enough data", no_data.as_ref(),
Style::default().fg(colors.text_pending()), Style::default().fg(colors.text_pending()),
); );
return; return;
@@ -1249,9 +1267,10 @@ impl StatsDashboard<'_> {
fn render_activity_stats(&self, area: Rect, buf: &mut Buffer) { fn render_activity_stats(&self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors; let colors = &self.theme.colors;
let streaks_title = t!("stats.streaks_title");
let block = Block::bordered() let block = Block::bordered()
.title(Line::from(Span::styled( .title(Line::from(Span::styled(
" Streaks ", streaks_title.to_string(),
Style::default() Style::default()
.fg(colors.accent()) .fg(colors.accent())
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
@@ -1272,22 +1291,25 @@ impl StatsDashboard<'_> {
let mut top_days: Vec<(chrono::NaiveDate, usize)> = day_counts.into_iter().collect(); 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))); 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![ 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( Span::styled(
format!("{current_streak}d"), format!("{current_streak}d"),
Style::default() Style::default()
.fg(colors.success()) .fg(colors.success())
.add_modifier(Modifier::BOLD), .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( Span::styled(
format!("{best_streak}d"), format!("{best_streak}d"),
Style::default() Style::default()
.fg(colors.accent()) .fg(colors.accent())
.add_modifier(Modifier::BOLD), .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( Span::styled(
format!("{active_days_count}"), format!("{active_days_count}"),
Style::default().fg(colors.text_pending()), Style::default().fg(colors.text_pending()),
@@ -1295,14 +1317,14 @@ impl StatsDashboard<'_> {
])]; ])];
let top_days_text = if top_days.is_empty() { let top_days_text = if top_days.is_empty() {
" Top Days: none".to_string() t!("stats.top_days_none").to_string()
} else { } else {
let parts: Vec<String> = top_days let parts: Vec<String> = top_days
.iter() .iter()
.take(3) .take(3)
.map(|(d, c)| format!("{} ({})", d.format("%Y-%m-%d"), c)) .map(|(d, c)| format!("{} ({})", d.format("%Y-%m-%d"), c))
.collect(); .collect();
format!(" Top Days: {}", parts.join(" | ")) t!("stats.top_days", days = parts.join(" | ")).to_string()
}; };
lines.push(Line::from(Span::styled( lines.push(Line::from(Span::styled(
top_days_text, top_days_text,
@@ -1321,7 +1343,7 @@ impl StatsDashboard<'_> {
Some(d) => d, Some(d) => d,
None => { None => {
let msg = Paragraph::new(Line::from(Span::styled( 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()), Style::default().fg(colors.text_pending()),
))); )));
msg.render(area, buf); msg.render(area, buf);
@@ -1370,9 +1392,10 @@ impl StatsDashboard<'_> {
fn render_ngram_focus(&self, data: &NgramTabData, area: Rect, buf: &mut Buffer) { fn render_ngram_focus(&self, data: &NgramTabData, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors; let colors = &self.theme.colors;
let focus_title = t!("stats.focus_title");
let block = Block::bordered() let block = Block::bordered()
.title(Line::from(Span::styled( .title(Line::from(Span::styled(
" Active Focus ", focus_title.to_string(),
Style::default() Style::default()
.fg(colors.accent()) .fg(colors.accent())
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
@@ -1392,16 +1415,16 @@ impl StatsDashboard<'_> {
let bigram_label = format!("\"{}{}\"", key.0[0], key.0[1]); let bigram_label = format!("\"{}{}\"", key.0[0], key.0[1]);
// Line 1: both focuses // Line 1: both focuses
lines.push(Line::from(vec![ 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( Span::styled(
format!("Char '{ch}'"), t!("stats.focus_char_value", ch = ch).to_string(),
Style::default() Style::default()
.fg(colors.focused_key()) .fg(colors.focused_key())
.add_modifier(Modifier::BOLD), .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( Span::styled(
format!("Bigram {bigram_label}"), t!("stats.focus_bigram_value", label = &bigram_label).to_string(),
Style::default() Style::default()
.fg(colors.focused_key()) .fg(colors.focused_key())
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
@@ -1410,23 +1433,21 @@ impl StatsDashboard<'_> {
// Line 2: details // Line 2: details
if inner.height >= 2 { if inner.height >= 2 {
let type_label = match anomaly_type { let type_label = match anomaly_type {
AnomalyType::Error => "error", AnomalyType::Error => t!("stats.anomaly_error").to_string(),
AnomalyType::Speed => "speed", AnomalyType::Speed => t!("stats.anomaly_speed").to_string(),
}; };
let detail = format!( let detail = t!("stats.focus_detail_both", ch = ch, label = &bigram_label, r#type = &type_label, pct = format!("{anomaly_pct:.0}"));
" Char '{ch}': weakest key | Bigram {bigram_label}: {type_label} anomaly {anomaly_pct:.0}%"
);
lines.push(Line::from(Span::styled( lines.push(Line::from(Span::styled(
detail, detail.to_string(),
Style::default().fg(colors.text_pending()), Style::default().fg(colors.text_pending()),
))); )));
} }
} }
(Some(ch), None) => { (Some(ch), None) => {
lines.push(Line::from(vec![ 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( Span::styled(
format!("Char '{ch}'"), t!("stats.focus_char_value", ch = ch).to_string(),
Style::default() Style::default()
.fg(colors.focused_key()) .fg(colors.focused_key())
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
@@ -1434,7 +1455,7 @@ impl StatsDashboard<'_> {
])); ]));
if inner.height >= 2 { if inner.height >= 2 {
lines.push(Line::from(Span::styled( 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()), Style::default().fg(colors.text_pending()),
))); )));
} }
@@ -1442,26 +1463,26 @@ impl StatsDashboard<'_> {
(None, Some((key, anomaly_pct, anomaly_type))) => { (None, Some((key, anomaly_pct, anomaly_type))) => {
let bigram_label = format!("\"{}{}\"", key.0[0], key.0[1]); let bigram_label = format!("\"{}{}\"", key.0[0], key.0[1]);
let type_label = match anomaly_type { let type_label = match anomaly_type {
AnomalyType::Error => "error", AnomalyType::Error => t!("stats.anomaly_error").to_string(),
AnomalyType::Speed => "speed", AnomalyType::Speed => t!("stats.anomaly_speed").to_string(),
}; };
lines.push(Line::from(vec![ 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( Span::styled(
format!("Bigram {bigram_label}"), t!("stats.focus_bigram_value", label = &bigram_label).to_string(),
Style::default() Style::default()
.fg(colors.focused_key()) .fg(colors.focused_key())
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
), ),
Span::styled( 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()), Style::default().fg(colors.text_pending()),
), ),
])); ]));
} }
(None, None) => { (None, None) => {
lines.push(Line::from(Span::styled( 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()), Style::default().fg(colors.text_pending()),
))); )));
} }
@@ -1512,14 +1533,14 @@ impl StatsDashboard<'_> {
// Speed table: Bigram Anom% Speed Smp Strk // Speed table: Bigram Anom% Speed Smp Strk
let header = if narrow { let header = if narrow {
if is_speed { if is_speed {
" Bgrm Speed Expct Anom%" t!("stats.ngram_header_speed_narrow").to_string()
} else { } else {
" Bgrm Err Smp Rate Exp Anom%" t!("stats.ngram_header_error_narrow").to_string()
} }
} else if is_speed { } else if is_speed {
" Bigram Speed Expect Samples Anom%" t!("stats.ngram_header_speed").to_string()
} else { } else {
" Bigram Errors Samples Rate Expect Anom%" t!("stats.ngram_header_error").to_string()
}; };
buf.set_string( buf.set_string(
inner.x, inner.x,
@@ -1586,10 +1607,11 @@ impl StatsDashboard<'_> {
} }
fn render_error_anomalies(&self, data: &NgramTabData, area: Rect, buf: &mut Buffer) { 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( self.render_anomaly_panel(
&title, title.as_ref(),
" No error anomalies detected", empty_msg.as_ref(),
&data.error_anomalies, &data.error_anomalies,
false, false,
area, area,
@@ -1598,10 +1620,11 @@ impl StatsDashboard<'_> {
} }
fn render_speed_anomalies(&self, data: &NgramTabData, area: Rect, buf: &mut Buffer) { 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( self.render_anomaly_panel(
&title, title.as_ref(),
" No speed anomalies detected", empty_msg.as_ref(),
&data.speed_anomalies, &data.speed_anomalies,
true, true,
area, area,
@@ -1619,18 +1642,18 @@ impl StatsDashboard<'_> {
}; };
// Build segments from most to least important, progressively drop from the right // Build segments from most to least important, progressively drop from the right
let scope = format!(" {}", data.scope_label); let scope = t!("stats.scope_label_prefix", ).to_string() + &data.scope_label;
let bigrams = format!(" | Bi: {}", data.total_bigrams); let bigrams = t!("stats.bi_label", count = data.total_bigrams).to_string();
let trigrams = format!(" | Tri: {}", data.total_trigrams); let trigrams = t!("stats.tri_label", count = data.total_trigrams).to_string();
let hesitation = format!(" | Hes: >{:.0}ms", data.hesitation_threshold_ms); let hesitation = t!("stats.hes_label", ms = format!("{:.0}", data.hesitation_threshold_ms)).to_string();
let gain = format!(" | Gain: {}", gain_str); let gain = t!("stats.gain_label", value = &gain_str).to_string();
let gain_note = if data.latest_trigram_gain.is_none() { let gain_note_str = if data.latest_trigram_gain.is_none() {
" (every 50)" t!("stats.gain_interval").to_string()
} else { } 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(); let mut line = String::new();
for seg in segments { for seg in segments {
if line.len() + seg.len() <= w { if line.len() + seg.len() <= w {
@@ -1649,33 +1672,47 @@ impl StatsDashboard<'_> {
} }
} }
const TAB_LABELS: [&str; 6] = [ fn tab_labels() -> Vec<String> {
"[1] Dashboard", vec![
"[2] History", t!("stats.tab_dashboard").to_string(),
"[3] Activity", t!("stats.tab_history").to_string(),
"[4] Accuracy", t!("stats.tab_activity").to_string(),
"[5] Timing", t!("stats.tab_accuracy").to_string(),
"[6] N-grams", t!("stats.tab_timing").to_string(),
]; t!("stats.tab_ngrams").to_string(),
]
}
const TAB_SEPARATOR: &str = " "; 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] = [ fn footer_hints_default() -> Vec<String> {
"[ESC] Back", vec![
"[Tab] Next tab", t!("stats.hint_back").to_string(),
"[1-6] Switch tab", t!("stats.hint_next_tab").to_string(),
"[j/k] Navigate", t!("stats.hint_switch_tab").to_string(),
"[PgUp/PgDn] Page", ]
"[x] Delete", }
];
fn footer_hints_history() -> Vec<String> {
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 { fn history_visible_rows(table_inner: Rect) -> usize {
table_inner.height.saturating_sub(2) as usize table_inner.height.saturating_sub(2) as usize
} }
fn wrapped_tab_line_count(width: usize) -> usize { fn wrapped_tab_line_count(width: usize) -> usize {
let labels = tab_labels();
let mut lines = 1usize; let mut lines = 1usize;
let mut current_width = 0usize; let mut current_width = 0usize;
for label in TAB_LABELS { for label in &labels {
let item_width = format!(" {label} ").chars().count() + TAB_SEPARATOR.len(); let item_width = format!(" {label} ").chars().count() + TAB_SEPARATOR.len();
if current_width > 0 && current_width + item_width > width { if current_width > 0 && current_width + item_width > width {
lines += 1; lines += 1;
@@ -1687,7 +1724,9 @@ fn wrapped_tab_line_count(width: usize) -> usize {
} }
fn footer_line_count_for_history(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 { pub fn history_page_size_for_terminal(width: u16, height: u16) -> usize {

View File

@@ -6,6 +6,7 @@ use ratatui::widgets::{Block, Paragraph, Widget};
use crate::session::drill::DrillState; use crate::session::drill::DrillState;
use crate::session::result::DrillResult; use crate::session::result::DrillResult;
use crate::i18n::t;
use crate::ui::theme::Theme; use crate::ui::theme::Theme;
pub struct StatsSidebar<'a> { pub struct StatsSidebar<'a> {
@@ -80,21 +81,30 @@ impl Widget for StatsSidebar<'_> {
let incorrect_str = format!("{incorrect}"); let incorrect_str = format!("{incorrect}");
let elapsed_str = format!("{elapsed:.1}s"); 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![ let lines = vec![
Line::from(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())), Span::styled(wpm_str, Style::default().fg(colors.accent())),
]), ]),
Line::from(vec![ Line::from(vec![
Span::styled("Target: ", Style::default().fg(colors.fg())), Span::styled(target_label.as_ref(), Style::default().fg(colors.fg())),
Span::styled( Span::styled(
format!("{} WPM", self.target_wpm), target_wpm_val.to_string(),
Style::default().fg(colors.text_pending()), Style::default().fg(colors.text_pending()),
), ),
]), ]),
Line::from(""), Line::from(""),
Line::from(vec![ Line::from(vec![
Span::styled("Accuracy: ", Style::default().fg(colors.fg())), Span::styled(accuracy_label.as_ref(), Style::default().fg(colors.fg())),
Span::styled( Span::styled(
acc_str, acc_str,
Style::default().fg(if accuracy >= 95.0 { Style::default().fg(if accuracy >= 95.0 {
@@ -108,27 +118,28 @@ impl Widget for StatsSidebar<'_> {
]), ]),
Line::from(""), Line::from(""),
Line::from(vec![ 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())), Span::styled(prog_str, Style::default().fg(colors.accent())),
]), ]),
Line::from(""), Line::from(""),
Line::from(vec![ 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())), Span::styled(correct_str, Style::default().fg(colors.success())),
]), ]),
Line::from(vec![ 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())), Span::styled(incorrect_str, Style::default().fg(colors.error())),
]), ]),
Line::from(""), Line::from(""),
Line::from(vec![ 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())), Span::styled(elapsed_str, Style::default().fg(colors.fg())),
]), ]),
]; ];
let stats_title = t!("sidebar.title");
let block = Block::bordered() let block = Block::bordered()
.title(" Stats ") .title(stats_title.to_string())
.border_style(Style::default().fg(colors.border())) .border_style(Style::default().fg(colors.border()))
.style(Style::default().bg(colors.bg())); .style(Style::default().bg(colors.bg()));
@@ -174,14 +185,20 @@ impl Widget for StatsSidebar<'_> {
colors.text_pending() 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![ 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())), Span::styled(wpm_str, Style::default().fg(colors.accent())),
])]; ])];
if prior_count > 0 { if prior_count > 0 {
lines.push(Line::from(vec![ 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)), 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(""));
lines.push(Line::from(vec![ 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( Span::styled(
acc_str, acc_str,
Style::default().fg(if last.accuracy >= 95.0 { Style::default().fg(if last.accuracy >= 95.0 {
@@ -203,25 +220,27 @@ impl Widget for StatsSidebar<'_> {
])); ]));
if prior_count > 0 { if prior_count > 0 {
let vs_avg_label2 = t!("sidebar.vs_avg").to_string();
lines.push(Line::from(vec![ 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)), Span::styled(acc_delta_str, Style::default().fg(acc_delta_color)),
])); ]));
} }
lines.push(Line::from("")); lines.push(Line::from(""));
lines.push(Line::from(vec![ 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())), Span::styled(errors_str, Style::default().fg(colors.error())),
])); ]));
lines.push(Line::from("")); lines.push(Line::from(""));
lines.push(Line::from(vec![ 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())), Span::styled(time_str, Style::default().fg(colors.fg())),
])); ]));
let last_drill_title = t!("sidebar.last_drill");
let block = Block::bordered() let block = Block::bordered()
.title(" Last Drill ") .title(last_drill_title.to_string())
.border_style(Style::default().fg(colors.border())) .border_style(Style::default().fg(colors.border()))
.style(Style::default().bg(colors.bg())); .style(Style::default().bg(colors.bg()));