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:
134
src/app.rs
134
src/app.rs
@@ -8,6 +8,8 @@ use rand::Rng;
|
||||
use rand::SeedableRng;
|
||||
use rand::rngs::SmallRng;
|
||||
|
||||
use crate::i18n::t;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::engine::FocusSelection;
|
||||
use crate::engine::filter::CharFilter;
|
||||
@@ -70,6 +72,7 @@ pub enum AppScreen {
|
||||
CodeIntro,
|
||||
CodeDownloadProgress,
|
||||
Keyboard,
|
||||
UiLanguageSelect,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -84,6 +87,7 @@ pub enum SettingItem {
|
||||
TargetWpm,
|
||||
Theme,
|
||||
WordCount,
|
||||
UiLanguage,
|
||||
DictionaryLanguage,
|
||||
KeyboardLayout,
|
||||
CodeLanguage,
|
||||
@@ -102,10 +106,11 @@ pub enum SettingItem {
|
||||
}
|
||||
|
||||
impl SettingItem {
|
||||
pub const ALL: [Self; 18] = [
|
||||
pub const ALL: [Self; 19] = [
|
||||
Self::TargetWpm,
|
||||
Self::Theme,
|
||||
Self::WordCount,
|
||||
Self::UiLanguage,
|
||||
Self::DictionaryLanguage,
|
||||
Self::KeyboardLayout,
|
||||
Self::CodeLanguage,
|
||||
@@ -183,23 +188,29 @@ pub struct KeyMilestonePopup {
|
||||
pub kind: MilestoneKind,
|
||||
pub keys: Vec<char>,
|
||||
pub finger_info: Vec<(char, String)>,
|
||||
pub message: &'static str,
|
||||
pub message: String,
|
||||
pub branch_ids: Vec<BranchId>,
|
||||
}
|
||||
|
||||
const UNLOCK_MESSAGES: &[&str] = &[
|
||||
"Nice work! Keep building your typing skills.",
|
||||
"Another key added to your arsenal!",
|
||||
"Your keyboard is growing! Keep it up.",
|
||||
"One step closer to full keyboard mastery!",
|
||||
];
|
||||
fn unlock_messages() -> Vec<String> {
|
||||
use crate::i18n::t;
|
||||
vec![
|
||||
t!("milestones.unlock_msg_1").to_string(),
|
||||
t!("milestones.unlock_msg_2").to_string(),
|
||||
t!("milestones.unlock_msg_3").to_string(),
|
||||
t!("milestones.unlock_msg_4").to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
const MASTERY_MESSAGES: &[&str] = &[
|
||||
"This key is now at full confidence!",
|
||||
"You've got this key down pat!",
|
||||
"Muscle memory locked in!",
|
||||
"One more key conquered!",
|
||||
];
|
||||
fn mastery_messages() -> Vec<String> {
|
||||
use crate::i18n::t;
|
||||
vec![
|
||||
t!("milestones.mastery_msg_1").to_string(),
|
||||
t!("milestones.mastery_msg_2").to_string(),
|
||||
t!("milestones.mastery_msg_3").to_string(),
|
||||
t!("milestones.mastery_msg_4").to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
const POST_DRILL_INPUT_LOCK_MS: u64 = 800;
|
||||
|
||||
@@ -319,6 +330,8 @@ pub struct App {
|
||||
pub skill_tree_detail_scroll: usize,
|
||||
pub skill_tree_confirm_unlock: Option<BranchId>,
|
||||
pub drill_source_info: Option<String>,
|
||||
pub ui_language_selected: usize,
|
||||
pub ui_language_scroll: usize,
|
||||
pub dictionary_language_selected: usize,
|
||||
pub dictionary_language_scroll: usize,
|
||||
pub keyboard_layout_selected: usize,
|
||||
@@ -525,6 +538,8 @@ impl App {
|
||||
skill_tree_detail_scroll: 0,
|
||||
skill_tree_confirm_unlock: None,
|
||||
drill_source_info: None,
|
||||
ui_language_selected: 0,
|
||||
ui_language_scroll: 0,
|
||||
dictionary_language_selected: 0,
|
||||
dictionary_language_scroll: 0,
|
||||
keyboard_layout_selected: 0,
|
||||
@@ -596,7 +611,7 @@ impl App {
|
||||
{
|
||||
app.settings_status_message = Some(StatusMessage {
|
||||
kind: StatusKind::Error,
|
||||
text: "Recovery files found from interrupted import. Data may be inconsistent — consider re-importing.".to_string(),
|
||||
text: t!("status.recovery_files").to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -677,7 +692,7 @@ impl App {
|
||||
{
|
||||
self.settings_status_message = Some(StatusMessage {
|
||||
kind: StatusKind::Error,
|
||||
text: format!("Directory does not exist: {}", parent.display()),
|
||||
text: t!("status.dir_not_exist", path = parent.display().to_string()).to_string(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -685,7 +700,7 @@ impl App {
|
||||
let Some(ref store) = self.store else {
|
||||
self.settings_status_message = Some(StatusMessage {
|
||||
kind: StatusKind::Error,
|
||||
text: "No data store available".to_string(),
|
||||
text: t!("status.no_data_store").to_string(),
|
||||
});
|
||||
return;
|
||||
};
|
||||
@@ -696,7 +711,7 @@ impl App {
|
||||
Err(e) => {
|
||||
self.settings_status_message = Some(StatusMessage {
|
||||
kind: StatusKind::Error,
|
||||
text: format!("Serialization error: {e}"),
|
||||
text: t!("status.serialization_error", error = e.to_string()).to_string(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -717,14 +732,14 @@ impl App {
|
||||
Ok(()) => {
|
||||
self.settings_status_message = Some(StatusMessage {
|
||||
kind: StatusKind::Success,
|
||||
text: format!("Exported to {}", self.settings_export_path),
|
||||
text: t!("status.exported_to", path = &self.settings_export_path).to_string(),
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = std::fs::remove_file(&tmp_path);
|
||||
self.settings_status_message = Some(StatusMessage {
|
||||
kind: StatusKind::Error,
|
||||
text: format!("Export failed: {e}"),
|
||||
text: t!("status.export_failed", error = e.to_string()).to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -739,7 +754,7 @@ impl App {
|
||||
Err(e) => {
|
||||
self.settings_status_message = Some(StatusMessage {
|
||||
kind: StatusKind::Error,
|
||||
text: format!("Could not read file: {e}"),
|
||||
text: t!("status.could_not_read", error = e.to_string()).to_string(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -750,7 +765,7 @@ impl App {
|
||||
Err(e) => {
|
||||
self.settings_status_message = Some(StatusMessage {
|
||||
kind: StatusKind::Error,
|
||||
text: format!("Invalid export file: {e}"),
|
||||
text: t!("status.invalid_export", error = e.to_string()).to_string(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -760,10 +775,7 @@ impl App {
|
||||
if export.keydr_export_version != EXPORT_VERSION {
|
||||
self.settings_status_message = Some(StatusMessage {
|
||||
kind: StatusKind::Error,
|
||||
text: format!(
|
||||
"Unsupported export version: {} (expected {})",
|
||||
export.keydr_export_version, EXPORT_VERSION
|
||||
),
|
||||
text: t!("status.unsupported_version", got = export.keydr_export_version, expected = EXPORT_VERSION).to_string(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -778,14 +790,14 @@ impl App {
|
||||
let Some(ref store) = self.store else {
|
||||
self.settings_status_message = Some(StatusMessage {
|
||||
kind: StatusKind::Error,
|
||||
text: "No data store available".to_string(),
|
||||
text: t!("status.no_data_store").to_string(),
|
||||
});
|
||||
return;
|
||||
};
|
||||
if let Err(e) = store.import_all(&export) {
|
||||
self.settings_status_message = Some(StatusMessage {
|
||||
kind: StatusKind::Error,
|
||||
text: format!("Import failed: {e}"),
|
||||
text: t!("status.import_failed", error = e.to_string()).to_string(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -841,15 +853,12 @@ impl App {
|
||||
let _ = self.config.save();
|
||||
self.settings_status_message = Some(StatusMessage {
|
||||
kind: StatusKind::Success,
|
||||
text: format!(
|
||||
"Imported successfully (theme '{}' not found, using default)",
|
||||
theme_name
|
||||
),
|
||||
text: t!("status.imported_theme_fallback", theme = &theme_name).to_string(),
|
||||
});
|
||||
} else {
|
||||
self.settings_status_message = Some(StatusMessage {
|
||||
kind: StatusKind::Success,
|
||||
text: "Imported successfully".to_string(),
|
||||
text: t!("status.imported_success").to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1227,11 +1236,12 @@ impl App {
|
||||
.newly_unlocked
|
||||
.iter()
|
||||
.map(|&ch| {
|
||||
let desc = self.keyboard_model.finger_for_char(ch).description();
|
||||
(ch, desc.to_string())
|
||||
let desc = self.keyboard_model.finger_for_char(ch).localized_description();
|
||||
(ch, desc)
|
||||
})
|
||||
.collect();
|
||||
let msg = UNLOCK_MESSAGES[self.rng.gen_range(0..UNLOCK_MESSAGES.len())];
|
||||
let msgs = unlock_messages();
|
||||
let msg = msgs[self.rng.gen_range(0..msgs.len())].clone();
|
||||
self.milestone_queue.push_back(KeyMilestonePopup {
|
||||
kind: MilestoneKind::Unlock,
|
||||
keys: update.newly_unlocked,
|
||||
@@ -1247,11 +1257,12 @@ impl App {
|
||||
.newly_mastered
|
||||
.iter()
|
||||
.map(|&ch| {
|
||||
let desc = self.keyboard_model.finger_for_char(ch).description();
|
||||
(ch, desc.to_string())
|
||||
let desc = self.keyboard_model.finger_for_char(ch).localized_description();
|
||||
(ch, desc)
|
||||
})
|
||||
.collect();
|
||||
let msg = MASTERY_MESSAGES[self.rng.gen_range(0..MASTERY_MESSAGES.len())];
|
||||
let msgs = mastery_messages();
|
||||
let msg = msgs[self.rng.gen_range(0..msgs.len())].clone();
|
||||
self.milestone_queue.push_back(KeyMilestonePopup {
|
||||
kind: MilestoneKind::Mastery,
|
||||
keys: update.newly_mastered,
|
||||
@@ -1267,7 +1278,7 @@ impl App {
|
||||
kind: MilestoneKind::BranchesAvailable,
|
||||
keys: vec![],
|
||||
finger_info: vec![],
|
||||
message: "",
|
||||
message: String::new(),
|
||||
branch_ids: update.branches_newly_available,
|
||||
});
|
||||
}
|
||||
@@ -1284,7 +1295,7 @@ impl App {
|
||||
kind: MilestoneKind::BranchComplete,
|
||||
keys: vec![],
|
||||
finger_info: vec![],
|
||||
message: "",
|
||||
message: String::new(),
|
||||
branch_ids: completed_non_lowercase,
|
||||
});
|
||||
}
|
||||
@@ -1294,7 +1305,7 @@ impl App {
|
||||
kind: MilestoneKind::AllKeysUnlocked,
|
||||
keys: vec![],
|
||||
finger_info: vec![],
|
||||
message: "",
|
||||
message: String::new(),
|
||||
branch_ids: vec![],
|
||||
});
|
||||
}
|
||||
@@ -1304,7 +1315,7 @@ impl App {
|
||||
kind: MilestoneKind::AllKeysMastered,
|
||||
keys: vec![],
|
||||
finger_info: vec![],
|
||||
message: "",
|
||||
message: String::new(),
|
||||
branch_ids: vec![],
|
||||
});
|
||||
}
|
||||
@@ -1912,7 +1923,7 @@ impl App {
|
||||
Err(err) => {
|
||||
self.settings_status_message = Some(StatusMessage {
|
||||
kind: StatusKind::Error,
|
||||
text: format!("Adaptive ranked mode unavailable: {err}"),
|
||||
text: t!("status.adaptive_unavailable", error = err.to_string()).to_string(),
|
||||
});
|
||||
self.go_to_settings();
|
||||
self.settings_selected = SettingItem::DictionaryLanguage.index();
|
||||
@@ -1942,6 +1953,15 @@ impl App {
|
||||
self.screen = AppScreen::Settings;
|
||||
}
|
||||
|
||||
pub fn go_to_ui_language_select(&mut self) {
|
||||
self.ui_language_selected = crate::i18n::SUPPORTED_UI_LOCALES
|
||||
.iter()
|
||||
.position(|&k| k == self.config.ui_language)
|
||||
.unwrap_or(0);
|
||||
self.ui_language_scroll = 0;
|
||||
self.screen = AppScreen::UiLanguageSelect;
|
||||
}
|
||||
|
||||
pub fn go_to_dictionary_language_select(&mut self) {
|
||||
let options = crate::l10n::language_pack::language_packs();
|
||||
self.dictionary_language_selected = options
|
||||
@@ -2335,7 +2355,7 @@ impl App {
|
||||
if keys.is_empty() {
|
||||
self.settings_status_message = Some(StatusMessage {
|
||||
kind: StatusKind::Error,
|
||||
text: "No supported dictionary languages are registered".to_string(),
|
||||
text: t!("errors.unknown_language", key = "none").to_string(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -2356,7 +2376,7 @@ impl App {
|
||||
Err(err) => {
|
||||
self.settings_status_message = Some(StatusMessage {
|
||||
kind: StatusKind::Error,
|
||||
text: err.to_string(),
|
||||
text: crate::i18n::localized_language_layout_error(&err),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2367,7 +2387,7 @@ impl App {
|
||||
if keys.is_empty() {
|
||||
self.settings_status_message = Some(StatusMessage {
|
||||
kind: StatusKind::Error,
|
||||
text: "No keyboard layouts are registered".to_string(),
|
||||
text: t!("errors.unknown_layout", key = "none").to_string(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -2387,7 +2407,7 @@ impl App {
|
||||
Err(err) => {
|
||||
self.settings_status_message = Some(StatusMessage {
|
||||
kind: StatusKind::Error,
|
||||
text: err.to_string(),
|
||||
text: crate::i18n::localized_language_layout_error(&err),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2519,6 +2539,13 @@ impl App {
|
||||
SettingItem::WordCount => {
|
||||
self.config.word_count = (self.config.word_count + 5).min(100);
|
||||
}
|
||||
SettingItem::UiLanguage => {
|
||||
let locales = crate::i18n::SUPPORTED_UI_LOCALES;
|
||||
let idx = locales.iter().position(|&l| l == self.config.ui_language).unwrap_or(0);
|
||||
let next = (idx + 1) % locales.len();
|
||||
self.config.ui_language = locales[next].to_string();
|
||||
crate::i18n::set_ui_locale(&self.config.ui_language);
|
||||
}
|
||||
SettingItem::DictionaryLanguage => {
|
||||
self.apply_dictionary_language_by_offset(1);
|
||||
}
|
||||
@@ -2595,6 +2622,13 @@ impl App {
|
||||
SettingItem::WordCount => {
|
||||
self.config.word_count = self.config.word_count.saturating_sub(5).max(5);
|
||||
}
|
||||
SettingItem::UiLanguage => {
|
||||
let locales = crate::i18n::SUPPORTED_UI_LOCALES;
|
||||
let idx = locales.iter().position(|&l| l == self.config.ui_language).unwrap_or(0);
|
||||
let next = if idx == 0 { locales.len() - 1 } else { idx - 1 };
|
||||
self.config.ui_language = locales[next].to_string();
|
||||
crate::i18n::set_ui_locale(&self.config.ui_language);
|
||||
}
|
||||
SettingItem::DictionaryLanguage => {
|
||||
self.apply_dictionary_language_by_offset(-1);
|
||||
}
|
||||
@@ -3039,6 +3073,8 @@ impl App {
|
||||
skill_tree_detail_scroll: 0,
|
||||
skill_tree_confirm_unlock: None,
|
||||
drill_source_info: None,
|
||||
ui_language_selected: 0,
|
||||
ui_language_scroll: 0,
|
||||
dictionary_language_selected: 0,
|
||||
dictionary_language_scroll: 0,
|
||||
keyboard_layout_selected: 0,
|
||||
@@ -3519,7 +3555,7 @@ mod tests {
|
||||
kind: MilestoneKind::Unlock,
|
||||
keys: vec!['a'],
|
||||
finger_info: vec![('a', "left pinky".to_string())],
|
||||
message: "Test milestone",
|
||||
message: "Test milestone".to_string(),
|
||||
branch_ids: vec![],
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::i18n;
|
||||
use crate::keyboard::model::KeyboardModel;
|
||||
use crate::l10n::language_pack::{
|
||||
LanguageLayoutValidationError, dictionary_languages_for_layout, supported_dictionary_languages,
|
||||
@@ -41,6 +42,8 @@ pub struct Config {
|
||||
pub code_snippets_per_repo: usize,
|
||||
#[serde(default = "default_code_onboarding_done")]
|
||||
pub code_onboarding_done: bool,
|
||||
#[serde(default = "default_ui_language")]
|
||||
pub ui_language: String,
|
||||
}
|
||||
|
||||
fn default_target_wpm() -> u32 {
|
||||
@@ -98,6 +101,9 @@ fn default_code_snippets_per_repo() -> usize {
|
||||
fn default_code_onboarding_done() -> bool {
|
||||
false
|
||||
}
|
||||
fn default_ui_language() -> String {
|
||||
"en".to_string()
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
@@ -117,6 +123,7 @@ impl Default for Config {
|
||||
code_download_dir: default_code_download_dir(),
|
||||
code_snippets_per_repo: default_code_snippets_per_repo(),
|
||||
code_onboarding_done: default_code_onboarding_done(),
|
||||
ui_language: default_ui_language(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -163,6 +170,7 @@ impl Config {
|
||||
self.normalize_keyboard_layout();
|
||||
self.normalize_dictionary_language();
|
||||
self.normalize_language_layout_pair();
|
||||
self.normalize_ui_language();
|
||||
}
|
||||
|
||||
/// Validate `code_language` against known options, resetting to default if invalid.
|
||||
@@ -215,6 +223,13 @@ impl Config {
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate `ui_language` against supported UI locales.
|
||||
fn normalize_ui_language(&mut self) {
|
||||
if !i18n::SUPPORTED_UI_LOCALES.contains(&self.ui_language.as_str()) {
|
||||
self.ui_language = default_ui_language();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_language_layout_pair(&self) -> Result<(), LanguageLayoutValidationError> {
|
||||
validate_language_layout_pair(&self.dictionary_language, &self.keyboard_layout).map(|_| ())
|
||||
}
|
||||
@@ -329,25 +344,22 @@ code_language = "go"
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_language_layout_pair_resets_invalid_pair() {
|
||||
fn test_normalize_language_layout_pair_keeps_valid_cross_language_pair() {
|
||||
let mut config = Config::default();
|
||||
config.dictionary_language = "de".to_string();
|
||||
config.keyboard_layout = "dvorak".to_string();
|
||||
config.normalize_language_layout_pair();
|
||||
assert_eq!(config.dictionary_language, "en");
|
||||
// Cross-language/layout pairs are now valid
|
||||
assert_eq!(config.dictionary_language, "de");
|
||||
assert_eq!(config.keyboard_layout, "dvorak");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_language_layout_pair_returns_typed_error() {
|
||||
fn test_validate_language_layout_pair_accepts_cross_language_pair() {
|
||||
let mut config = Config::default();
|
||||
config.dictionary_language = "de".to_string();
|
||||
config.keyboard_layout = "dvorak".to_string();
|
||||
let err = config.validate_language_layout_pair().unwrap_err();
|
||||
assert!(matches!(
|
||||
err,
|
||||
LanguageLayoutValidationError::UnsupportedLanguageLayoutPair { .. }
|
||||
));
|
||||
assert!(config.validate_language_layout_pair().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -80,20 +80,32 @@ pub enum BranchStatus {
|
||||
// --- Static Definitions ---
|
||||
|
||||
pub struct LevelDefinition {
|
||||
pub name: &'static str,
|
||||
pub name_key: &'static str,
|
||||
pub keys: &'static [char],
|
||||
}
|
||||
|
||||
impl LevelDefinition {
|
||||
pub fn display_name(&self) -> String {
|
||||
crate::i18n::t!(self.name_key).to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct BranchDefinition {
|
||||
pub id: BranchId,
|
||||
pub name: &'static str,
|
||||
pub name_key: &'static str,
|
||||
pub levels: &'static [LevelDefinition],
|
||||
}
|
||||
|
||||
impl BranchDefinition {
|
||||
pub fn display_name(&self) -> String {
|
||||
crate::i18n::t!(self.name_key).to_string()
|
||||
}
|
||||
}
|
||||
|
||||
// Lowercase metadata remains for static branch lookup/UI labels. Runtime
|
||||
// progression and unlock counts are driven by `SkillTree::primary_letters`.
|
||||
const LOWERCASE_LEVELS: &[LevelDefinition] = &[LevelDefinition {
|
||||
name: "Frequency Order",
|
||||
name_key: "skill_tree.level_frequency_order",
|
||||
keys: &[
|
||||
'e', 't', 'a', 'o', 'i', 'n', 's', 'h', 'r', 'd', 'l', 'c', 'u', 'm', 'w', 'f', 'g', 'y',
|
||||
'p', 'b', 'v', 'k', 'j', 'x', 'q', 'z',
|
||||
@@ -102,71 +114,71 @@ const LOWERCASE_LEVELS: &[LevelDefinition] = &[LevelDefinition {
|
||||
|
||||
const CAPITALS_LEVELS: &[LevelDefinition] = &[
|
||||
LevelDefinition {
|
||||
name: "Common Sentence Capitals",
|
||||
name_key: "skill_tree.level_common_sentence_capitals",
|
||||
keys: &['T', 'I', 'A', 'S', 'W', 'H', 'B', 'M'],
|
||||
},
|
||||
LevelDefinition {
|
||||
name: "Name Capitals",
|
||||
name_key: "skill_tree.level_name_capitals",
|
||||
keys: &['J', 'D', 'R', 'C', 'E', 'N', 'P', 'L', 'F', 'G'],
|
||||
},
|
||||
LevelDefinition {
|
||||
name: "Remaining Capitals",
|
||||
name_key: "skill_tree.level_remaining_capitals",
|
||||
keys: &['O', 'U', 'K', 'V', 'Y', 'X', 'Q', 'Z'],
|
||||
},
|
||||
];
|
||||
|
||||
const NUMBERS_LEVELS: &[LevelDefinition] = &[
|
||||
LevelDefinition {
|
||||
name: "Common Digits",
|
||||
name_key: "skill_tree.level_common_digits",
|
||||
keys: &['1', '2', '3', '4', '5'],
|
||||
},
|
||||
LevelDefinition {
|
||||
name: "All Digits",
|
||||
name_key: "skill_tree.level_all_digits",
|
||||
keys: &['0', '6', '7', '8', '9'],
|
||||
},
|
||||
];
|
||||
|
||||
const PROSE_PUNCTUATION_LEVELS: &[LevelDefinition] = &[
|
||||
LevelDefinition {
|
||||
name: "Essential",
|
||||
name_key: "skill_tree.level_essential",
|
||||
keys: &['.', ',', '\''],
|
||||
},
|
||||
LevelDefinition {
|
||||
name: "Common",
|
||||
name_key: "skill_tree.level_common",
|
||||
keys: &[';', ':', '"', '-'],
|
||||
},
|
||||
LevelDefinition {
|
||||
name: "Expressive",
|
||||
name_key: "skill_tree.level_expressive",
|
||||
keys: &['?', '!', '(', ')'],
|
||||
},
|
||||
];
|
||||
|
||||
const WHITESPACE_LEVELS: &[LevelDefinition] = &[
|
||||
LevelDefinition {
|
||||
name: "Enter/Return",
|
||||
name_key: "skill_tree.level_enter_return",
|
||||
keys: &['\n'],
|
||||
},
|
||||
LevelDefinition {
|
||||
name: "Tab/Indent",
|
||||
name_key: "skill_tree.level_tab_indent",
|
||||
keys: &['\t'],
|
||||
},
|
||||
];
|
||||
|
||||
const CODE_SYMBOLS_LEVELS: &[LevelDefinition] = &[
|
||||
LevelDefinition {
|
||||
name: "Arithmetic & Assignment",
|
||||
name_key: "skill_tree.level_arithmetic_assignment",
|
||||
keys: &['=', '+', '*', '/', '-'],
|
||||
},
|
||||
LevelDefinition {
|
||||
name: "Grouping",
|
||||
name_key: "skill_tree.level_grouping",
|
||||
keys: &['{', '}', '[', ']', '<', '>'],
|
||||
},
|
||||
LevelDefinition {
|
||||
name: "Logic & Reference",
|
||||
name_key: "skill_tree.level_logic_reference",
|
||||
keys: &['&', '|', '^', '~', '!'],
|
||||
},
|
||||
LevelDefinition {
|
||||
name: "Special",
|
||||
name_key: "skill_tree.level_special",
|
||||
keys: &['@', '#', '$', '%', '_', '\\', '`'],
|
||||
},
|
||||
];
|
||||
@@ -174,43 +186,43 @@ const CODE_SYMBOLS_LEVELS: &[LevelDefinition] = &[
|
||||
pub const ALL_BRANCHES: &[BranchDefinition] = &[
|
||||
BranchDefinition {
|
||||
id: BranchId::Lowercase,
|
||||
name: "Primary Letters",
|
||||
name_key: "skill_tree.branch_primary_letters",
|
||||
levels: LOWERCASE_LEVELS,
|
||||
},
|
||||
BranchDefinition {
|
||||
id: BranchId::Capitals,
|
||||
name: "Capital Letters",
|
||||
name_key: "skill_tree.branch_capital_letters",
|
||||
levels: CAPITALS_LEVELS,
|
||||
},
|
||||
BranchDefinition {
|
||||
id: BranchId::Numbers,
|
||||
name: "Numbers 0-9",
|
||||
name_key: "skill_tree.branch_numbers",
|
||||
levels: NUMBERS_LEVELS,
|
||||
},
|
||||
BranchDefinition {
|
||||
id: BranchId::ProsePunctuation,
|
||||
name: "Prose Punctuation",
|
||||
name_key: "skill_tree.branch_prose_punctuation",
|
||||
levels: PROSE_PUNCTUATION_LEVELS,
|
||||
},
|
||||
BranchDefinition {
|
||||
id: BranchId::Whitespace,
|
||||
name: "Whitespace",
|
||||
name_key: "skill_tree.branch_whitespace",
|
||||
levels: WHITESPACE_LEVELS,
|
||||
},
|
||||
BranchDefinition {
|
||||
id: BranchId::CodeSymbols,
|
||||
name: "Code Symbols",
|
||||
name_key: "skill_tree.branch_code_symbols",
|
||||
levels: CODE_SYMBOLS_LEVELS,
|
||||
},
|
||||
];
|
||||
|
||||
/// Find which branch and level a key belongs to.
|
||||
/// Returns (branch_def, level_name, 1-based position in level).
|
||||
pub fn find_key_branch(ch: char) -> Option<(&'static BranchDefinition, &'static str, usize)> {
|
||||
/// Returns (branch_def, level_def, 1-based position in level).
|
||||
pub fn find_key_branch(ch: char) -> Option<(&'static BranchDefinition, &'static LevelDefinition, usize)> {
|
||||
for branch in ALL_BRANCHES {
|
||||
for level in branch.levels {
|
||||
if let Some(pos) = level.keys.iter().position(|&k| k == ch) {
|
||||
return Some((branch, level.name, pos + 1));
|
||||
return Some((branch, level, pos + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1292,9 +1304,9 @@ mod tests {
|
||||
fn test_find_key_branch_lowercase() {
|
||||
let result = find_key_branch('e');
|
||||
assert!(result.is_some());
|
||||
let (branch, level_name, pos) = result.unwrap();
|
||||
let (branch, level, pos) = result.unwrap();
|
||||
assert_eq!(branch.id, BranchId::Lowercase);
|
||||
assert_eq!(level_name, "Frequency Order");
|
||||
assert_eq!(level.name_key, "skill_tree.level_frequency_order");
|
||||
assert_eq!(pos, 1); // 'e' is first in the frequency order
|
||||
}
|
||||
|
||||
@@ -1302,9 +1314,9 @@ mod tests {
|
||||
fn test_find_key_branch_capitals() {
|
||||
let result = find_key_branch('T');
|
||||
assert!(result.is_some());
|
||||
let (branch, level_name, pos) = result.unwrap();
|
||||
let (branch, level, pos) = result.unwrap();
|
||||
assert_eq!(branch.id, BranchId::Capitals);
|
||||
assert_eq!(level_name, "Common Sentence Capitals");
|
||||
assert_eq!(level.name_key, "skill_tree.level_common_sentence_capitals");
|
||||
assert_eq!(pos, 1); // 'T' is first
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ use rand::rngs::SmallRng;
|
||||
use crate::engine::filter::CharFilter;
|
||||
use crate::generator::TextGenerator;
|
||||
use crate::generator::cache::fetch_url_bytes_with_progress;
|
||||
use crate::i18n::t;
|
||||
|
||||
const PASSAGES: &[&str] = &[
|
||||
"the quick brown fox jumps over the lazy dog and then runs across the field while the sun sets behind the distant hills",
|
||||
@@ -67,11 +68,11 @@ pub const GUTENBERG_BOOKS: &[GutenbergBook] = &[
|
||||
|
||||
pub fn passage_options() -> Vec<(&'static str, String)> {
|
||||
let mut out = vec![
|
||||
("all", "All (Built-in + all books)".to_string()),
|
||||
("builtin", "Built-in passages only".to_string()),
|
||||
("all", t!("select.passage_all").to_string()),
|
||||
("builtin", t!("select.passage_builtin").to_string()),
|
||||
];
|
||||
for book in GUTENBERG_BOOKS {
|
||||
out.push((book.key, format!("Book: {}", book.title)));
|
||||
out.push((book.key, t!("select.passage_book_prefix", title = book.title).to_string()));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
214
src/i18n.rs
Normal file
214
src/i18n.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ impl FingerAssignment {
|
||||
Self { hand, finger }
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn description(&self) -> &'static str {
|
||||
match (self.hand, self.finger) {
|
||||
(Hand::Left, Finger::Pinky) => "left pinky",
|
||||
@@ -41,6 +42,22 @@ impl FingerAssignment {
|
||||
(Hand::Right, Finger::Thumb) => "right thumb",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn localized_description(&self) -> String {
|
||||
use crate::i18n::t;
|
||||
let hand = match self.hand {
|
||||
Hand::Left => t!("keyboard.hand_left"),
|
||||
Hand::Right => t!("keyboard.hand_right"),
|
||||
};
|
||||
let finger = match self.finger {
|
||||
Finger::Pinky => t!("keyboard.finger_pinky"),
|
||||
Finger::Ring => t!("keyboard.finger_ring"),
|
||||
Finger::Middle => t!("keyboard.finger_middle"),
|
||||
Finger::Index => t!("keyboard.finger_index"),
|
||||
Finger::Thumb => t!("keyboard.finger_thumb"),
|
||||
};
|
||||
format!("{hand} {finger}")
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
|
||||
@@ -513,10 +513,17 @@ impl KeyboardModel {
|
||||
let Some(&(row, col)) = slots.next() else {
|
||||
break;
|
||||
};
|
||||
let shifted = ch.to_uppercase().next().unwrap_or(ch);
|
||||
let candidate = ch.to_uppercase().next().unwrap_or(ch);
|
||||
let shifted = if candidate != ch && used.contains(&candidate) {
|
||||
ch
|
||||
} else {
|
||||
candidate
|
||||
};
|
||||
model.rows[row][col] = PhysicalKey { base: ch, shifted };
|
||||
used.insert(ch);
|
||||
used.insert(shifted);
|
||||
if shifted != ch {
|
||||
used.insert(shifted);
|
||||
}
|
||||
}
|
||||
model
|
||||
}
|
||||
@@ -737,12 +744,14 @@ mod tests {
|
||||
pk.base,
|
||||
key
|
||||
);
|
||||
assert!(
|
||||
seen.insert(pk.shifted),
|
||||
"duplicate shifted char {:?} in profile {}",
|
||||
pk.shifted,
|
||||
key
|
||||
);
|
||||
if pk.shifted != pk.base {
|
||||
assert!(
|
||||
seen.insert(pk.shifted),
|
||||
"duplicate shifted char {:?} in profile {}",
|
||||
pk.shifted,
|
||||
key
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,6 +78,7 @@ impl fmt::Display for RankedReadinessError {
|
||||
pub struct LanguagePack {
|
||||
pub language_key: &'static str,
|
||||
pub display_name: &'static str,
|
||||
pub autonym: &'static str,
|
||||
pub script: Script,
|
||||
pub dictionary_asset_id: &'static str,
|
||||
pub supported_keyboard_layout_keys: &'static [&'static str],
|
||||
@@ -135,6 +136,7 @@ static LANGUAGE_PACKS: &[LanguagePack] = &[
|
||||
LanguagePack {
|
||||
language_key: "en",
|
||||
display_name: "English",
|
||||
autonym: "English",
|
||||
script: Script::Latin,
|
||||
dictionary_asset_id: "words-en",
|
||||
supported_keyboard_layout_keys: EN_LAYOUTS,
|
||||
@@ -144,6 +146,7 @@ static LANGUAGE_PACKS: &[LanguagePack] = &[
|
||||
LanguagePack {
|
||||
language_key: "de",
|
||||
display_name: "German",
|
||||
autonym: "Deutsch",
|
||||
script: Script::Latin,
|
||||
dictionary_asset_id: "words-de",
|
||||
supported_keyboard_layout_keys: DE_LAYOUTS,
|
||||
@@ -153,6 +156,7 @@ static LANGUAGE_PACKS: &[LanguagePack] = &[
|
||||
LanguagePack {
|
||||
language_key: "es",
|
||||
display_name: "Spanish",
|
||||
autonym: "Español",
|
||||
script: Script::Latin,
|
||||
dictionary_asset_id: "words-es",
|
||||
supported_keyboard_layout_keys: ES_LAYOUTS,
|
||||
@@ -162,6 +166,7 @@ static LANGUAGE_PACKS: &[LanguagePack] = &[
|
||||
LanguagePack {
|
||||
language_key: "fr",
|
||||
display_name: "French",
|
||||
autonym: "Français",
|
||||
script: Script::Latin,
|
||||
dictionary_asset_id: "words-fr",
|
||||
supported_keyboard_layout_keys: FR_LAYOUTS,
|
||||
@@ -171,6 +176,7 @@ static LANGUAGE_PACKS: &[LanguagePack] = &[
|
||||
LanguagePack {
|
||||
language_key: "it",
|
||||
display_name: "Italian",
|
||||
autonym: "Italiano",
|
||||
script: Script::Latin,
|
||||
dictionary_asset_id: "words-it",
|
||||
supported_keyboard_layout_keys: IT_LAYOUTS,
|
||||
@@ -180,6 +186,7 @@ static LANGUAGE_PACKS: &[LanguagePack] = &[
|
||||
LanguagePack {
|
||||
language_key: "pt",
|
||||
display_name: "Portuguese",
|
||||
autonym: "Português",
|
||||
script: Script::Latin,
|
||||
dictionary_asset_id: "words-pt",
|
||||
supported_keyboard_layout_keys: PT_LAYOUTS,
|
||||
@@ -189,6 +196,7 @@ static LANGUAGE_PACKS: &[LanguagePack] = &[
|
||||
LanguagePack {
|
||||
language_key: "nl",
|
||||
display_name: "Dutch",
|
||||
autonym: "Nederlands",
|
||||
script: Script::Latin,
|
||||
dictionary_asset_id: "words-nl",
|
||||
supported_keyboard_layout_keys: NL_LAYOUTS,
|
||||
@@ -198,6 +206,7 @@ static LANGUAGE_PACKS: &[LanguagePack] = &[
|
||||
LanguagePack {
|
||||
language_key: "sv",
|
||||
display_name: "Swedish",
|
||||
autonym: "Svenska",
|
||||
script: Script::Latin,
|
||||
dictionary_asset_id: "words-sv",
|
||||
supported_keyboard_layout_keys: SV_LAYOUTS,
|
||||
@@ -207,6 +216,7 @@ static LANGUAGE_PACKS: &[LanguagePack] = &[
|
||||
LanguagePack {
|
||||
language_key: "da",
|
||||
display_name: "Danish",
|
||||
autonym: "Dansk",
|
||||
script: Script::Latin,
|
||||
dictionary_asset_id: "words-da",
|
||||
supported_keyboard_layout_keys: DA_LAYOUTS,
|
||||
@@ -216,6 +226,7 @@ static LANGUAGE_PACKS: &[LanguagePack] = &[
|
||||
LanguagePack {
|
||||
language_key: "nb",
|
||||
display_name: "Norwegian Bokmal",
|
||||
autonym: "Norsk bokmål",
|
||||
script: Script::Latin,
|
||||
dictionary_asset_id: "words-nb",
|
||||
supported_keyboard_layout_keys: NB_LAYOUTS,
|
||||
@@ -225,6 +236,7 @@ static LANGUAGE_PACKS: &[LanguagePack] = &[
|
||||
LanguagePack {
|
||||
language_key: "fi",
|
||||
display_name: "Finnish",
|
||||
autonym: "Suomi",
|
||||
script: Script::Latin,
|
||||
dictionary_asset_id: "words-fi",
|
||||
supported_keyboard_layout_keys: FI_LAYOUTS,
|
||||
@@ -234,6 +246,7 @@ static LANGUAGE_PACKS: &[LanguagePack] = &[
|
||||
LanguagePack {
|
||||
language_key: "pl",
|
||||
display_name: "Polish",
|
||||
autonym: "Polski",
|
||||
script: Script::Latin,
|
||||
dictionary_asset_id: "words-pl",
|
||||
supported_keyboard_layout_keys: PL_LAYOUTS,
|
||||
@@ -243,6 +256,7 @@ static LANGUAGE_PACKS: &[LanguagePack] = &[
|
||||
LanguagePack {
|
||||
language_key: "cs",
|
||||
display_name: "Czech",
|
||||
autonym: "Čeština",
|
||||
script: Script::Latin,
|
||||
dictionary_asset_id: "words-cs",
|
||||
supported_keyboard_layout_keys: CS_LAYOUTS,
|
||||
@@ -252,6 +266,7 @@ static LANGUAGE_PACKS: &[LanguagePack] = &[
|
||||
LanguagePack {
|
||||
language_key: "ro",
|
||||
display_name: "Romanian",
|
||||
autonym: "Română",
|
||||
script: Script::Latin,
|
||||
dictionary_asset_id: "words-ro",
|
||||
supported_keyboard_layout_keys: RO_LAYOUTS,
|
||||
@@ -261,6 +276,7 @@ static LANGUAGE_PACKS: &[LanguagePack] = &[
|
||||
LanguagePack {
|
||||
language_key: "hr",
|
||||
display_name: "Croatian",
|
||||
autonym: "Hrvatski",
|
||||
script: Script::Latin,
|
||||
dictionary_asset_id: "words-hr",
|
||||
supported_keyboard_layout_keys: HR_LAYOUTS,
|
||||
@@ -270,6 +286,7 @@ static LANGUAGE_PACKS: &[LanguagePack] = &[
|
||||
LanguagePack {
|
||||
language_key: "hu",
|
||||
display_name: "Hungarian",
|
||||
autonym: "Magyar",
|
||||
script: Script::Latin,
|
||||
dictionary_asset_id: "words-hu",
|
||||
supported_keyboard_layout_keys: HU_LAYOUTS,
|
||||
@@ -279,6 +296,7 @@ static LANGUAGE_PACKS: &[LanguagePack] = &[
|
||||
LanguagePack {
|
||||
language_key: "lt",
|
||||
display_name: "Lithuanian",
|
||||
autonym: "Lietuvių",
|
||||
script: Script::Latin,
|
||||
dictionary_asset_id: "words-lt",
|
||||
supported_keyboard_layout_keys: LT_LAYOUTS,
|
||||
@@ -288,6 +306,7 @@ static LANGUAGE_PACKS: &[LanguagePack] = &[
|
||||
LanguagePack {
|
||||
language_key: "lv",
|
||||
display_name: "Latvian",
|
||||
autonym: "Latviešu",
|
||||
script: Script::Latin,
|
||||
dictionary_asset_id: "words-lv",
|
||||
supported_keyboard_layout_keys: LV_LAYOUTS,
|
||||
@@ -297,6 +316,7 @@ static LANGUAGE_PACKS: &[LanguagePack] = &[
|
||||
LanguagePack {
|
||||
language_key: "sl",
|
||||
display_name: "Slovene",
|
||||
autonym: "Slovenščina",
|
||||
script: Script::Latin,
|
||||
dictionary_asset_id: "words-sl",
|
||||
supported_keyboard_layout_keys: SL_LAYOUTS,
|
||||
@@ -306,6 +326,7 @@ static LANGUAGE_PACKS: &[LanguagePack] = &[
|
||||
LanguagePack {
|
||||
language_key: "et",
|
||||
display_name: "Estonian",
|
||||
autonym: "Eesti",
|
||||
script: Script::Latin,
|
||||
dictionary_asset_id: "words-et",
|
||||
supported_keyboard_layout_keys: ET_LAYOUTS,
|
||||
@@ -315,6 +336,7 @@ static LANGUAGE_PACKS: &[LanguagePack] = &[
|
||||
LanguagePack {
|
||||
language_key: "tr",
|
||||
display_name: "Turkish",
|
||||
autonym: "Türkçe",
|
||||
script: Script::Latin,
|
||||
dictionary_asset_id: "words-tr",
|
||||
supported_keyboard_layout_keys: TR_LAYOUTS,
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
// Most code is only exercised through the binary, so suppress dead_code warnings.
|
||||
#![allow(dead_code)]
|
||||
|
||||
rust_i18n::i18n!("locales", fallback = "en");
|
||||
|
||||
// Public: used by benchmarks and the generate_test_profiles binary
|
||||
pub mod config;
|
||||
pub mod engine;
|
||||
@@ -16,4 +18,5 @@ pub mod store;
|
||||
mod app;
|
||||
mod event;
|
||||
mod generator;
|
||||
mod i18n;
|
||||
mod ui;
|
||||
|
||||
1184
src/main.rs
1184
src/main.rs
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@ use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Widget};
|
||||
|
||||
use crate::i18n::t;
|
||||
use crate::session::result::DrillResult;
|
||||
use crate::ui::theme::Theme;
|
||||
|
||||
@@ -27,7 +28,7 @@ impl Widget for ActivityHeatmap<'_> {
|
||||
|
||||
let block = Block::bordered()
|
||||
.title(Line::from(Span::styled(
|
||||
" Daily Activity (Sessions per Day) ",
|
||||
t!("heatmap.title"),
|
||||
Style::default()
|
||||
.fg(colors.accent())
|
||||
.add_modifier(Modifier::BOLD),
|
||||
@@ -87,27 +88,27 @@ impl Widget for ActivityHeatmap<'_> {
|
||||
// Month label on first row
|
||||
let month = current_date.month();
|
||||
if month != last_month {
|
||||
let month_name = match month {
|
||||
1 => "Jan",
|
||||
2 => "Feb",
|
||||
3 => "Mar",
|
||||
4 => "Apr",
|
||||
5 => "May",
|
||||
6 => "Jun",
|
||||
7 => "Jul",
|
||||
8 => "Aug",
|
||||
9 => "Sep",
|
||||
10 => "Oct",
|
||||
11 => "Nov",
|
||||
12 => "Dec",
|
||||
_ => "",
|
||||
let month_name: std::borrow::Cow<'_, str> = match month {
|
||||
1 => t!("heatmap.jan"),
|
||||
2 => t!("heatmap.feb"),
|
||||
3 => t!("heatmap.mar"),
|
||||
4 => t!("heatmap.apr"),
|
||||
5 => t!("heatmap.may"),
|
||||
6 => t!("heatmap.jun"),
|
||||
7 => t!("heatmap.jul"),
|
||||
8 => t!("heatmap.aug"),
|
||||
9 => t!("heatmap.sep"),
|
||||
10 => t!("heatmap.oct"),
|
||||
11 => t!("heatmap.nov"),
|
||||
12 => t!("heatmap.dec"),
|
||||
_ => std::borrow::Cow::Borrowed(""),
|
||||
};
|
||||
// Only show if we have space (3 chars)
|
||||
if x + 3 <= inner.x + inner.width {
|
||||
buf.set_string(
|
||||
x,
|
||||
inner.y,
|
||||
month_name,
|
||||
month_name.as_ref(),
|
||||
Style::default().fg(colors.text_pending()),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Paragraph, Widget};
|
||||
|
||||
use crate::engine::skill_tree::{BranchId, DrillScope, SkillTree, get_branch_definition};
|
||||
use crate::i18n::t;
|
||||
use crate::ui::theme::Theme;
|
||||
|
||||
pub struct BranchProgressList<'a> {
|
||||
@@ -92,7 +93,7 @@ impl Widget for BranchProgressList<'_> {
|
||||
let (m_bar, u_bar, e_bar) = compact_dual_bar_parts(mastered, unlocked, total, 12);
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
format!(" \u{25b6} {:<14}", def.name),
|
||||
format!(" \u{25b6} {:<14}", def.display_name()),
|
||||
Style::default().fg(colors.accent()),
|
||||
),
|
||||
Span::styled(m_bar, Style::default().fg(colors.text_correct())),
|
||||
@@ -123,9 +124,12 @@ impl Widget for BranchProgressList<'_> {
|
||||
0
|
||||
};
|
||||
let right_pad = if area.width >= 75 { 2 } else { 0 };
|
||||
let label = format!("{}Overall Key Progress ", " ".repeat(left_pad));
|
||||
let overall_label = t!("progress.overall_key_progress");
|
||||
let label = format!("{}{} ", " ".repeat(left_pad), overall_label);
|
||||
let unlocked_mastered = t!("progress.unlocked_mastered", unlocked = unlocked, total = total, mastered = mastered);
|
||||
let suffix = format!(
|
||||
" {unlocked}/{total} unlocked ({mastered} mastered){}",
|
||||
" {}{}",
|
||||
unlocked_mastered,
|
||||
" ".repeat(right_pad)
|
||||
);
|
||||
let reserved = label.len() + suffix.len();
|
||||
@@ -185,7 +189,8 @@ fn render_branch_cell<'a>(
|
||||
let fixed = prefix.len() + name_width + 1 + count.len();
|
||||
let bar_width = cell_width.saturating_sub(fixed).max(6);
|
||||
let (m_bar, u_bar, e_bar) = compact_dual_bar_parts(mastered, unlocked, total, bar_width);
|
||||
let name = truncate_and_pad(def.name, name_width);
|
||||
let display = def.display_name();
|
||||
let name = truncate_and_pad(&display, name_width);
|
||||
|
||||
let mut spans: Vec<Span> = vec![
|
||||
Span::styled(prefix.to_string(), Style::default().fg(label_color)),
|
||||
|
||||
@@ -4,6 +4,7 @@ use ratatui::style::Style;
|
||||
use ratatui::symbols;
|
||||
use ratatui::widgets::{Axis, Block, Chart, Dataset, GraphType, Widget};
|
||||
|
||||
use crate::i18n::t;
|
||||
use crate::ui::theme::Theme;
|
||||
|
||||
#[allow(dead_code)]
|
||||
@@ -24,8 +25,9 @@ impl Widget for WpmChart<'_> {
|
||||
let colors = &self.theme.colors;
|
||||
|
||||
if self.data.is_empty() {
|
||||
let wpm_title = t!("chart.wpm_over_time");
|
||||
let block = Block::bordered()
|
||||
.title(" WPM Over Time ")
|
||||
.title(wpm_title.to_string())
|
||||
.border_style(Style::default().fg(colors.border()));
|
||||
block.render(area, buf);
|
||||
return;
|
||||
@@ -45,21 +47,24 @@ impl Widget for WpmChart<'_> {
|
||||
.style(Style::default().fg(colors.accent()))
|
||||
.data(self.data);
|
||||
|
||||
let wpm_title = t!("chart.wpm_over_time");
|
||||
let drill_number_label = t!("chart.drill_number");
|
||||
let wpm_label = t!("common.wpm");
|
||||
let chart = Chart::new(vec![dataset])
|
||||
.block(
|
||||
Block::bordered()
|
||||
.title(" WPM Over Time ")
|
||||
.title(wpm_title.to_string())
|
||||
.border_style(Style::default().fg(colors.border())),
|
||||
)
|
||||
.x_axis(
|
||||
Axis::default()
|
||||
.title("Drill #")
|
||||
.title(drill_number_label.to_string())
|
||||
.style(Style::default().fg(colors.text_pending()))
|
||||
.bounds([0.0, max_x]),
|
||||
)
|
||||
.y_axis(
|
||||
Axis::default()
|
||||
.title("WPM")
|
||||
.title(wpm_label.to_string())
|
||||
.style(Style::default().fg(colors.text_pending()))
|
||||
.bounds([0.0, max_y * 1.1]),
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Paragraph, Widget};
|
||||
|
||||
use crate::i18n::t;
|
||||
use crate::session::result::DrillResult;
|
||||
use crate::ui::layout::pack_hint_lines;
|
||||
use crate::ui::theme::Theme;
|
||||
@@ -32,8 +33,9 @@ impl Widget for Dashboard<'_> {
|
||||
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||
let colors = &self.theme.colors;
|
||||
|
||||
let title_text = t!("dashboard.title");
|
||||
let block = Block::bordered()
|
||||
.title(" Drill Complete ")
|
||||
.title(title_text.to_string())
|
||||
.border_style(Style::default().fg(colors.accent()))
|
||||
.style(Style::default().bg(colors.bg()));
|
||||
let inner = block.inner(area);
|
||||
@@ -42,12 +44,17 @@ impl Widget for Dashboard<'_> {
|
||||
let footer_line_count = if self.input_lock_remaining_ms.is_some() {
|
||||
1u16
|
||||
} else {
|
||||
let hint_continue = t!("dashboard.hint_continue");
|
||||
let hint_retry = t!("dashboard.hint_retry");
|
||||
let hint_menu = t!("dashboard.hint_menu");
|
||||
let hint_stats = t!("dashboard.hint_stats");
|
||||
let hint_delete = t!("dashboard.hint_delete");
|
||||
let hints = [
|
||||
"[c/Enter/Space] Continue",
|
||||
"[r] Retry",
|
||||
"[q] Menu",
|
||||
"[s] Stats",
|
||||
"[x] Delete",
|
||||
hint_continue.as_ref(),
|
||||
hint_retry.as_ref(),
|
||||
hint_menu.as_ref(),
|
||||
hint_stats.as_ref(),
|
||||
hint_delete.as_ref(),
|
||||
];
|
||||
pack_hint_lines(&hints, inner.width as usize).len().max(1) as u16
|
||||
};
|
||||
@@ -65,25 +72,32 @@ impl Widget for Dashboard<'_> {
|
||||
])
|
||||
.split(inner);
|
||||
|
||||
let results_label = t!("dashboard.results");
|
||||
let mut title_spans = vec![Span::styled(
|
||||
"Results",
|
||||
results_label.to_string(),
|
||||
Style::default()
|
||||
.fg(colors.accent())
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)];
|
||||
if !self.result.ranked {
|
||||
let unranked_note = format!(
|
||||
"{}\u{2014}{}",
|
||||
t!("dashboard.unranked_note_prefix"),
|
||||
t!("dashboard.unranked_note_suffix")
|
||||
);
|
||||
title_spans.push(Span::styled(
|
||||
" (Unranked \u{2014} does not count toward skill tree)",
|
||||
unranked_note,
|
||||
Style::default().fg(colors.text_pending()),
|
||||
));
|
||||
}
|
||||
let title = Paragraph::new(Line::from(title_spans)).alignment(Alignment::Center);
|
||||
title.render(layout[0], buf);
|
||||
|
||||
let speed_label = t!("dashboard.speed");
|
||||
let wpm_text = format!("{:.0} WPM", self.result.wpm);
|
||||
let cpm_text = format!(" ({:.0} CPM)", self.result.cpm);
|
||||
let wpm_line = Line::from(vec![
|
||||
Span::styled(" Speed: ", Style::default().fg(colors.fg())),
|
||||
Span::styled(speed_label.to_string(), Style::default().fg(colors.fg())),
|
||||
Span::styled(
|
||||
&*wpm_text,
|
||||
Style::default()
|
||||
@@ -101,31 +115,31 @@ impl Widget for Dashboard<'_> {
|
||||
} else {
|
||||
colors.error()
|
||||
};
|
||||
let accuracy_label = t!("dashboard.accuracy_label");
|
||||
let acc_text = format!("{:.1}%", self.result.accuracy);
|
||||
let acc_detail = format!(
|
||||
" ({}/{} correct)",
|
||||
self.result.correct, self.result.total_chars
|
||||
);
|
||||
let acc_detail = t!("dashboard.correct_detail", correct = self.result.correct, total = self.result.total_chars);
|
||||
let acc_line = Line::from(vec![
|
||||
Span::styled(" Accuracy: ", Style::default().fg(colors.fg())),
|
||||
Span::styled(accuracy_label.to_string(), Style::default().fg(colors.fg())),
|
||||
Span::styled(
|
||||
&*acc_text,
|
||||
Style::default().fg(acc_color).add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(&*acc_detail, Style::default().fg(colors.text_pending())),
|
||||
Span::styled(acc_detail.to_string(), Style::default().fg(colors.text_pending())),
|
||||
]);
|
||||
Paragraph::new(acc_line).render(layout[2], buf);
|
||||
|
||||
let time_label = t!("dashboard.time_label");
|
||||
let time_text = format!("{:.1}s", self.result.elapsed_secs);
|
||||
let time_line = Line::from(vec![
|
||||
Span::styled(" Time: ", Style::default().fg(colors.fg())),
|
||||
Span::styled(time_label.to_string(), Style::default().fg(colors.fg())),
|
||||
Span::styled(&*time_text, Style::default().fg(colors.fg())),
|
||||
]);
|
||||
Paragraph::new(time_line).render(layout[3], buf);
|
||||
|
||||
let errors_label = t!("dashboard.errors_label");
|
||||
let error_text = format!("{}", self.result.incorrect);
|
||||
let chars_line = Line::from(vec![
|
||||
Span::styled(" Errors: ", Style::default().fg(colors.fg())),
|
||||
Span::styled(errors_label.to_string(), Style::default().fg(colors.fg())),
|
||||
Span::styled(
|
||||
&*error_text,
|
||||
Style::default().fg(if self.result.incorrect == 0 {
|
||||
@@ -138,25 +152,32 @@ impl Widget for Dashboard<'_> {
|
||||
Paragraph::new(chars_line).render(layout[4], buf);
|
||||
|
||||
let help = if let Some(ms) = self.input_lock_remaining_ms {
|
||||
let input_blocked_label = t!("dashboard.input_blocked");
|
||||
let input_blocked_ms = t!("dashboard.input_blocked_ms", ms = ms);
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::styled(
|
||||
" Input temporarily blocked ",
|
||||
input_blocked_label.to_string(),
|
||||
Style::default().fg(colors.warning()),
|
||||
),
|
||||
Span::styled(
|
||||
format!("({ms}ms remaining)"),
|
||||
input_blocked_ms.to_string(),
|
||||
Style::default()
|
||||
.fg(colors.warning())
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
]))
|
||||
} else {
|
||||
let hint_continue = t!("dashboard.hint_continue");
|
||||
let hint_retry = t!("dashboard.hint_retry");
|
||||
let hint_menu = t!("dashboard.hint_menu");
|
||||
let hint_stats = t!("dashboard.hint_stats");
|
||||
let hint_delete = t!("dashboard.hint_delete");
|
||||
let hints = [
|
||||
"[c/Enter/Space] Continue",
|
||||
"[r] Retry",
|
||||
"[q] Menu",
|
||||
"[s] Stats",
|
||||
"[x] Delete",
|
||||
hint_continue.as_ref(),
|
||||
hint_retry.as_ref(),
|
||||
hint_menu.as_ref(),
|
||||
hint_stats.as_ref(),
|
||||
hint_delete.as_ref(),
|
||||
];
|
||||
let lines: Vec<Line> = pack_hint_lines(&hints, inner.width as usize)
|
||||
.into_iter()
|
||||
|
||||
@@ -4,16 +4,20 @@ use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Paragraph, Widget};
|
||||
|
||||
use crate::i18n::t;
|
||||
use crate::ui::theme::Theme;
|
||||
|
||||
pub struct MenuItem {
|
||||
pub key: String,
|
||||
pub label: String,
|
||||
pub description: String,
|
||||
}
|
||||
const MENU_ITEMS: &[(&str, &str, &str)] = &[
|
||||
("1", "menu.adaptive_drill", "menu.adaptive_drill_desc"),
|
||||
("2", "menu.code_drill", "menu.code_drill_desc"),
|
||||
("3", "menu.passage_drill", "menu.passage_drill_desc"),
|
||||
("t", "menu.skill_tree", "menu.skill_tree_desc"),
|
||||
("b", "menu.keyboard", "menu.keyboard_desc"),
|
||||
("s", "menu.statistics", "menu.statistics_desc"),
|
||||
("c", "menu.settings", "menu.settings_desc"),
|
||||
];
|
||||
|
||||
pub struct Menu<'a> {
|
||||
pub items: Vec<MenuItem>,
|
||||
pub selected: usize,
|
||||
pub theme: &'a Theme,
|
||||
}
|
||||
@@ -21,57 +25,24 @@ pub struct Menu<'a> {
|
||||
impl<'a> Menu<'a> {
|
||||
pub fn new(theme: &'a Theme) -> Self {
|
||||
Self {
|
||||
items: vec![
|
||||
MenuItem {
|
||||
key: "1".to_string(),
|
||||
label: "Adaptive Drill".to_string(),
|
||||
description: "Phonetic words with adaptive letter unlocking".to_string(),
|
||||
},
|
||||
MenuItem {
|
||||
key: "2".to_string(),
|
||||
label: "Code Drill".to_string(),
|
||||
description: "Practice typing code syntax".to_string(),
|
||||
},
|
||||
MenuItem {
|
||||
key: "3".to_string(),
|
||||
label: "Passage Drill".to_string(),
|
||||
description: "Type passages from books".to_string(),
|
||||
},
|
||||
MenuItem {
|
||||
key: "t".to_string(),
|
||||
label: "Skill Tree".to_string(),
|
||||
description: "View progression branches and launch drills".to_string(),
|
||||
},
|
||||
MenuItem {
|
||||
key: "b".to_string(),
|
||||
label: "Keyboard".to_string(),
|
||||
description: "Explore keyboard layout and key statistics".to_string(),
|
||||
},
|
||||
MenuItem {
|
||||
key: "s".to_string(),
|
||||
label: "Statistics".to_string(),
|
||||
description: "View your typing statistics".to_string(),
|
||||
},
|
||||
MenuItem {
|
||||
key: "c".to_string(),
|
||||
label: "Settings".to_string(),
|
||||
description: "Configure keydr".to_string(),
|
||||
},
|
||||
],
|
||||
selected: 0,
|
||||
theme,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn item_count() -> usize {
|
||||
MENU_ITEMS.len()
|
||||
}
|
||||
|
||||
pub fn next(&mut self) {
|
||||
self.selected = (self.selected + 1) % self.items.len();
|
||||
self.selected = (self.selected + 1) % MENU_ITEMS.len();
|
||||
}
|
||||
|
||||
pub fn prev(&mut self) {
|
||||
if self.selected > 0 {
|
||||
self.selected -= 1;
|
||||
} else {
|
||||
self.selected = self.items.len() - 1;
|
||||
self.selected = MENU_ITEMS.len() - 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -95,6 +66,7 @@ impl Widget for &Menu<'_> {
|
||||
])
|
||||
.split(inner);
|
||||
|
||||
let subtitle = t!("menu.subtitle");
|
||||
let title_lines = vec![
|
||||
Line::from(""),
|
||||
Line::from(Span::styled(
|
||||
@@ -104,7 +76,7 @@ impl Widget for &Menu<'_> {
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)),
|
||||
Line::from(Span::styled(
|
||||
"Terminal Typing Tutor",
|
||||
subtitle.as_ref(),
|
||||
Style::default().fg(colors.fg()),
|
||||
)),
|
||||
Line::from(""),
|
||||
@@ -116,33 +88,31 @@ impl Widget for &Menu<'_> {
|
||||
let menu_layout = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints(
|
||||
self.items
|
||||
MENU_ITEMS
|
||||
.iter()
|
||||
.map(|_| Constraint::Length(3))
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.split(layout[2]);
|
||||
let key_width = self
|
||||
.items
|
||||
let key_width = MENU_ITEMS
|
||||
.iter()
|
||||
.map(|item| item.key.len())
|
||||
.map(|(key, _, _)| key.len())
|
||||
.max()
|
||||
.unwrap_or(1);
|
||||
|
||||
for (i, item) in self.items.iter().enumerate() {
|
||||
for (i, &(key, label_key, desc_key)) in MENU_ITEMS.iter().enumerate() {
|
||||
let is_selected = i == self.selected;
|
||||
let indicator = if is_selected { ">" } else { " " };
|
||||
let label = t!(label_key);
|
||||
let description = t!(desc_key);
|
||||
|
||||
let label_text = format!(
|
||||
" {indicator} [{key:<key_width$}] {label}",
|
||||
key = item.key,
|
||||
key_width = key_width,
|
||||
label = item.label
|
||||
);
|
||||
let desc_text = format!(
|
||||
" {:indent$}{}",
|
||||
" {:indent$}{description}",
|
||||
"",
|
||||
item.description,
|
||||
indent = key_width + 4
|
||||
);
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Paragraph, Widget, Wrap};
|
||||
|
||||
use crate::i18n::t;
|
||||
use crate::engine::key_stats::KeyStatsStore;
|
||||
use crate::engine::skill_tree::{
|
||||
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 {
|
||||
format!(
|
||||
"Complete {} primary letters to unlock branches",
|
||||
skill_tree.primary_letters().len()
|
||||
)
|
||||
t!("skill_tree.locked_notice", count = skill_tree.primary_letters().len()).to_string()
|
||||
}
|
||||
|
||||
/// Get the list of selectable branch IDs (Lowercase first, then other branches).
|
||||
@@ -131,7 +129,7 @@ impl Widget for SkillTreeWidget<'_> {
|
||||
let colors = &self.theme.colors;
|
||||
|
||||
let block = Block::bordered()
|
||||
.title(" Skill Tree ")
|
||||
.title(t!("skill_tree.title").to_string())
|
||||
.border_style(Style::default().fg(colors.accent()))
|
||||
.style(Style::default().bg(colors.bg()));
|
||||
let inner = block.inner(area);
|
||||
@@ -139,44 +137,49 @@ impl Widget for SkillTreeWidget<'_> {
|
||||
|
||||
// Layout: main split (branch list + detail) and footer (adaptive height)
|
||||
let branches = selectable_branches();
|
||||
let h_navigate = t!("skill_tree.hint_navigate").to_string();
|
||||
let h_scroll = t!("skill_tree.hint_scroll").to_string();
|
||||
let h_back = t!("skill_tree.hint_back").to_string();
|
||||
let h_unlock = t!("skill_tree.hint_unlock").to_string();
|
||||
let h_start_drill = t!("skill_tree.hint_start_drill").to_string();
|
||||
let (footer_hints, footer_notice): (Vec<&str>, Option<String>) =
|
||||
if self.selected < branches.len() {
|
||||
let bp = self.skill_tree.branch_progress(branches[self.selected]);
|
||||
if *self.skill_tree.branch_status(branches[self.selected]) == BranchStatus::Locked {
|
||||
(
|
||||
vec![
|
||||
"[↑↓/jk] Navigate",
|
||||
"[PgUp/PgDn or Ctrl+U/Ctrl+D] Scroll",
|
||||
"[q] Back",
|
||||
h_navigate.as_str(),
|
||||
h_scroll.as_str(),
|
||||
h_back.as_str(),
|
||||
],
|
||||
Some(locked_branch_notice(self.skill_tree)),
|
||||
)
|
||||
} else if bp.status == BranchStatus::Available {
|
||||
(
|
||||
vec![
|
||||
"[Enter] Unlock",
|
||||
"[↑↓/jk] Navigate",
|
||||
"[PgUp/PgDn or Ctrl+U/Ctrl+D] Scroll",
|
||||
"[q] Back",
|
||||
h_unlock.as_str(),
|
||||
h_navigate.as_str(),
|
||||
h_scroll.as_str(),
|
||||
h_back.as_str(),
|
||||
],
|
||||
None,
|
||||
)
|
||||
} else if bp.status == BranchStatus::InProgress {
|
||||
(
|
||||
vec![
|
||||
"[Enter] Start Drill",
|
||||
"[↑↓/jk] Navigate",
|
||||
"[PgUp/PgDn or Ctrl+U/Ctrl+D] Scroll",
|
||||
"[q] Back",
|
||||
h_start_drill.as_str(),
|
||||
h_navigate.as_str(),
|
||||
h_scroll.as_str(),
|
||||
h_back.as_str(),
|
||||
],
|
||||
None,
|
||||
)
|
||||
} else {
|
||||
(
|
||||
vec![
|
||||
"[↑↓/jk] Navigate",
|
||||
"[PgUp/PgDn or Ctrl+U/Ctrl+D] Scroll",
|
||||
"[q] Back",
|
||||
h_navigate.as_str(),
|
||||
h_scroll.as_str(),
|
||||
h_back.as_str(),
|
||||
],
|
||||
None,
|
||||
)
|
||||
@@ -184,9 +187,9 @@ impl Widget for SkillTreeWidget<'_> {
|
||||
} else {
|
||||
(
|
||||
vec![
|
||||
"[↑↓/jk] Navigate",
|
||||
"[PgUp/PgDn or Ctrl+U/Ctrl+D] Scroll",
|
||||
"[q] Back",
|
||||
h_navigate.as_str(),
|
||||
h_scroll.as_str(),
|
||||
h_back.as_str(),
|
||||
],
|
||||
None,
|
||||
)
|
||||
@@ -332,33 +335,35 @@ impl SkillTreeWidget<'_> {
|
||||
|
||||
let unlocked = self.skill_tree.branch_unlocked_count(branch_id);
|
||||
let mastered_text = if confident_keys > 0 {
|
||||
format!(" ({confident_keys} mastered)")
|
||||
format!(" ({confident_keys} {})", t!("skill_tree.mastered"))
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let status_text = match bp.status {
|
||||
BranchStatus::Complete => {
|
||||
format!("{unlocked}/{total_keys} unlocked{mastered_text}")
|
||||
format!("{unlocked}/{total_keys} {}{mastered_text}", t!("skill_tree.unlocked"))
|
||||
}
|
||||
BranchStatus::InProgress => {
|
||||
if branch_id == BranchId::Lowercase {
|
||||
format!("{unlocked}/{total_keys} unlocked{mastered_text}")
|
||||
format!("{unlocked}/{total_keys} {}{mastered_text}", t!("skill_tree.unlocked"))
|
||||
} else {
|
||||
format!(
|
||||
"Lvl {}/{} {unlocked}/{total_keys} unlocked{mastered_text}",
|
||||
"{} {}/{} {unlocked}/{total_keys} {}{mastered_text}",
|
||||
t!("skill_tree.lvl_prefix"),
|
||||
bp.current_level + 1,
|
||||
def.levels.len()
|
||||
def.levels.len(),
|
||||
t!("skill_tree.unlocked")
|
||||
)
|
||||
}
|
||||
}
|
||||
BranchStatus::Available => format!("0/{total_keys} unlocked"),
|
||||
BranchStatus::Locked => format!("Locked 0/{total_keys}"),
|
||||
BranchStatus::Available => format!("0/{total_keys} {}", t!("skill_tree.unlocked")),
|
||||
BranchStatus::Locked => format!("{} 0/{total_keys}", t!("skill_tree.locked")),
|
||||
};
|
||||
|
||||
let sel_indicator = if is_selected { "> " } else { " " };
|
||||
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(format!("{sel_indicator}{prefix}{}", def.name), style),
|
||||
Span::styled(format!("{sel_indicator}{prefix}{}", def.display_name()), style),
|
||||
Span::styled(
|
||||
format!(" {status_text}"),
|
||||
Style::default().fg(colors.text_pending()),
|
||||
@@ -381,8 +386,8 @@ impl SkillTreeWidget<'_> {
|
||||
}
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(
|
||||
" \u{2500}\u{2500} Branches (available after {} primary letters) \u{2500}\u{2500}",
|
||||
self.skill_tree.primary_letters().len()
|
||||
" \u{2500}\u{2500} {} \u{2500}\u{2500}",
|
||||
t!("skill_tree.branches_separator", count = self.skill_tree.primary_letters().len())
|
||||
),
|
||||
Style::default().fg(colors.text_pending()),
|
||||
)));
|
||||
@@ -423,21 +428,21 @@ impl SkillTreeWidget<'_> {
|
||||
let level_text = if branch_id == BranchId::Lowercase {
|
||||
let unlocked = self.skill_tree.branch_unlocked_count(BranchId::Lowercase);
|
||||
let total = self.skill_tree.branch_total_keys_for(BranchId::Lowercase);
|
||||
format!("Unlocked {unlocked}/{total} letters")
|
||||
t!("skill_tree.unlocked_letters", unlocked = unlocked, total = total).to_string()
|
||||
} else {
|
||||
match bp.status {
|
||||
BranchStatus::InProgress => {
|
||||
format!("Level {}/{}", bp.current_level + 1, def.levels.len())
|
||||
t!("skill_tree.level", current = bp.current_level + 1, total = def.levels.len()).to_string()
|
||||
}
|
||||
BranchStatus::Complete => {
|
||||
format!("Level {}/{}", def.levels.len(), def.levels.len())
|
||||
t!("skill_tree.level", current = def.levels.len(), total = def.levels.len()).to_string()
|
||||
}
|
||||
_ => format!("Level 0/{}", def.levels.len()),
|
||||
_ => t!("skill_tree.level_zero", total = def.levels.len()).to_string(),
|
||||
}
|
||||
};
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
format!(" {}", def.name),
|
||||
format!(" {}", def.display_name()),
|
||||
Style::default()
|
||||
.fg(colors.accent())
|
||||
.add_modifier(Modifier::BOLD),
|
||||
@@ -462,18 +467,20 @@ impl SkillTreeWidget<'_> {
|
||||
};
|
||||
|
||||
for (level_idx, level) in def.levels.iter().enumerate() {
|
||||
let level_status =
|
||||
if bp.status == BranchStatus::Complete || level_idx < bp.current_level {
|
||||
"complete"
|
||||
} else if bp.status == BranchStatus::InProgress && level_idx == bp.current_level {
|
||||
"in progress"
|
||||
} else {
|
||||
"locked"
|
||||
};
|
||||
let level_is_locked = !(bp.status == BranchStatus::Complete || level_idx < bp.current_level
|
||||
|| (bp.status == BranchStatus::InProgress && level_idx == bp.current_level));
|
||||
let level_status_owned = if bp.status == BranchStatus::Complete || level_idx < bp.current_level {
|
||||
t!("skill_tree.complete").to_string()
|
||||
} else if bp.status == BranchStatus::InProgress && level_idx == bp.current_level {
|
||||
t!("skill_tree.in_progress").to_string()
|
||||
} else {
|
||||
t!("skill_tree.locked_status").to_string()
|
||||
};
|
||||
let level_status = level_status_owned.as_str();
|
||||
|
||||
// Level header
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(" L{}: {} ({level_status})", level_idx + 1, level.name),
|
||||
format!(" L{}: {} ({level_status})", level_idx + 1, level.display_name()),
|
||||
Style::default().fg(colors.fg()),
|
||||
)));
|
||||
|
||||
@@ -492,7 +499,7 @@ impl SkillTreeWidget<'_> {
|
||||
let is_locked = if branch_id == BranchId::Lowercase {
|
||||
!lowercase_unlocked_keys.contains(&key)
|
||||
} else {
|
||||
level_status == "locked"
|
||||
level_is_locked
|
||||
};
|
||||
|
||||
let display = if key == '\n' {
|
||||
@@ -509,7 +516,7 @@ impl SkillTreeWidget<'_> {
|
||||
format!(" {display} "),
|
||||
Style::default().fg(colors.text_pending()),
|
||||
),
|
||||
Span::styled("locked", Style::default().fg(colors.text_pending())),
|
||||
Span::styled(t!("skill_tree.locked_status").to_string(), Style::default().fg(colors.text_pending())),
|
||||
]));
|
||||
} else {
|
||||
let bar_width = 10;
|
||||
@@ -517,7 +524,7 @@ impl SkillTreeWidget<'_> {
|
||||
let empty = bar_width - filled;
|
||||
let bar = format!("{}{}", "\u{2588}".repeat(filled), "\u{2591}".repeat(empty));
|
||||
let pct_str = format!("{:>3.0}%", confidence * 100.0);
|
||||
let focus_label = if is_focused { " in focus" } else { "" };
|
||||
let focus_label = if is_focused { t!("skill_tree.in_focus").to_string() } else { String::new() };
|
||||
|
||||
let key_style = if is_focused {
|
||||
Style::default()
|
||||
|
||||
@@ -12,6 +12,7 @@ use crate::keyboard::display::{self, BACKSPACE, ENTER, MODIFIER_SENTINELS, SPACE
|
||||
use crate::keyboard::model::KeyboardModel;
|
||||
use crate::session::result::DrillResult;
|
||||
use crate::ui::components::activity_heatmap::ActivityHeatmap;
|
||||
use crate::i18n::t;
|
||||
use crate::ui::layout::pack_hint_lines;
|
||||
use crate::ui::theme::Theme;
|
||||
|
||||
@@ -95,8 +96,9 @@ impl Widget for StatsDashboard<'_> {
|
||||
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||
let colors = &self.theme.colors;
|
||||
|
||||
let title = t!("stats.title");
|
||||
let block = Block::bordered()
|
||||
.title(" Statistics ")
|
||||
.title(title.as_ref())
|
||||
.border_style(Style::default().fg(colors.accent()))
|
||||
.style(Style::default().bg(colors.bg()));
|
||||
let inner = block.inner(area);
|
||||
@@ -104,7 +106,7 @@ impl Widget for StatsDashboard<'_> {
|
||||
|
||||
if self.history.is_empty() {
|
||||
let msg = Paragraph::new(Line::from(Span::styled(
|
||||
"No drills completed yet. Start typing!",
|
||||
t!("stats.empty").to_string(),
|
||||
Style::default().fg(colors.text_pending()),
|
||||
)));
|
||||
msg.render(inner, buf);
|
||||
@@ -113,10 +115,11 @@ impl Widget for StatsDashboard<'_> {
|
||||
|
||||
// Tab header — width-aware wrapping
|
||||
let width = inner.width as usize;
|
||||
let labels = tab_labels();
|
||||
let mut tab_lines: Vec<Line> = Vec::new();
|
||||
let mut current_spans: Vec<Span> = Vec::new();
|
||||
let mut current_width: usize = 0;
|
||||
for (i, &label) in TAB_LABELS.iter().enumerate() {
|
||||
for (i, label) in labels.iter().enumerate() {
|
||||
let styled_label = format!(" {label} ");
|
||||
let item_width = styled_label.chars().count() + TAB_SEPARATOR.len();
|
||||
if current_width > 0 && current_width + item_width > width {
|
||||
@@ -141,12 +144,13 @@ impl Widget for StatsDashboard<'_> {
|
||||
let tab_line_count = tab_lines.len().max(1) as u16;
|
||||
|
||||
// Footer — width-aware wrapping
|
||||
let footer_hints: Vec<&str> = if self.active_tab == 1 {
|
||||
FOOTER_HINTS_HISTORY.to_vec()
|
||||
let footer_hints = if self.active_tab == 1 {
|
||||
footer_hints_history()
|
||||
} else {
|
||||
FOOTER_HINTS_DEFAULT.to_vec()
|
||||
footer_hints_default()
|
||||
};
|
||||
let footer_lines_vec = pack_hint_lines(&footer_hints, width);
|
||||
let hint_refs: Vec<&str> = footer_hints.iter().map(|s| s.as_str()).collect();
|
||||
let footer_lines_vec = pack_hint_lines(&hint_refs, width);
|
||||
let footer_line_count = footer_lines_vec.len().max(1) as u16;
|
||||
|
||||
let layout = Layout::default()
|
||||
@@ -179,7 +183,8 @@ impl Widget for StatsDashboard<'_> {
|
||||
let dialog_area = Rect::new(dialog_x, dialog_y, dialog_width, dialog_height);
|
||||
|
||||
let idx = self.history.len().saturating_sub(self.history_selected);
|
||||
let dialog_text = format!("Delete session #{idx}? (y/n)");
|
||||
let dialog_text = t!("stats.delete_confirm", idx = idx);
|
||||
let confirm_title = t!("stats.confirm_title");
|
||||
|
||||
Clear.render(dialog_area, buf);
|
||||
let dialog = Paragraph::new(vec![
|
||||
@@ -192,7 +197,7 @@ impl Widget for StatsDashboard<'_> {
|
||||
.style(Style::default().bg(colors.bg()))
|
||||
.block(
|
||||
Block::bordered()
|
||||
.title(" Confirm ")
|
||||
.title(confirm_title.as_ref())
|
||||
.border_style(Style::default().fg(colors.error()))
|
||||
.style(Style::default().bg(colors.bg())),
|
||||
);
|
||||
@@ -281,9 +286,10 @@ impl StatsDashboard<'_> {
|
||||
let avg_acc_str = format!("{avg_accuracy:.1}%");
|
||||
let time_str = format_duration(total_time);
|
||||
|
||||
let summary_title = t!("stats.summary_title");
|
||||
let summary_block = Block::bordered()
|
||||
.title(Line::from(Span::styled(
|
||||
" Summary ",
|
||||
summary_title.to_string(),
|
||||
Style::default()
|
||||
.fg(colors.accent())
|
||||
.add_modifier(Modifier::BOLD),
|
||||
@@ -292,18 +298,23 @@ impl StatsDashboard<'_> {
|
||||
let summary_inner = summary_block.inner(layout[0]);
|
||||
summary_block.render(layout[0], buf);
|
||||
|
||||
let drills_label = t!("stats.drills");
|
||||
let avg_wpm_label = t!("stats.avg_wpm");
|
||||
let best_wpm_label = t!("stats.best_wpm");
|
||||
let accuracy_label = t!("stats.accuracy_label");
|
||||
let total_time_label = t!("stats.total_time");
|
||||
let summary = vec![
|
||||
Line::from(vec![
|
||||
Span::styled(" Drills: ", Style::default().fg(colors.fg())),
|
||||
Span::styled(drills_label.to_string(), Style::default().fg(colors.fg())),
|
||||
Span::styled(
|
||||
&*total_str,
|
||||
Style::default()
|
||||
.fg(colors.accent())
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(" Avg WPM: ", Style::default().fg(colors.fg())),
|
||||
Span::styled(avg_wpm_label.to_string(), Style::default().fg(colors.fg())),
|
||||
Span::styled(&*avg_wpm_str, Style::default().fg(colors.accent())),
|
||||
Span::styled(" Best WPM: ", Style::default().fg(colors.fg())),
|
||||
Span::styled(best_wpm_label.to_string(), Style::default().fg(colors.fg())),
|
||||
Span::styled(
|
||||
&*best_wpm_str,
|
||||
Style::default()
|
||||
@@ -312,7 +323,7 @@ impl StatsDashboard<'_> {
|
||||
),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::styled(" Accuracy: ", Style::default().fg(colors.fg())),
|
||||
Span::styled(accuracy_label.to_string(), Style::default().fg(colors.fg())),
|
||||
Span::styled(
|
||||
&*avg_acc_str,
|
||||
Style::default().fg(if avg_accuracy >= 95.0 {
|
||||
@@ -323,7 +334,7 @@ impl StatsDashboard<'_> {
|
||||
colors.error()
|
||||
}),
|
||||
),
|
||||
Span::styled(" Total time: ", Style::default().fg(colors.fg())),
|
||||
Span::styled(total_time_label.to_string(), Style::default().fg(colors.fg())),
|
||||
Span::styled(&*time_str, Style::default().fg(colors.text_pending())),
|
||||
]),
|
||||
];
|
||||
@@ -345,10 +356,10 @@ impl StatsDashboard<'_> {
|
||||
fn render_wpm_bar_graph(&self, area: Rect, buf: &mut Buffer) {
|
||||
let colors = &self.theme.colors;
|
||||
|
||||
let target_label = format!(" WPM per Drill (Last 20, Target: {}) ", self.target_wpm);
|
||||
let target_label = t!("stats.wpm_chart_title", target = self.target_wpm);
|
||||
let block = Block::bordered()
|
||||
.title(Line::from(Span::styled(
|
||||
target_label,
|
||||
target_label.to_string(),
|
||||
Style::default()
|
||||
.fg(colors.accent())
|
||||
.add_modifier(Modifier::BOLD),
|
||||
@@ -490,7 +501,7 @@ impl StatsDashboard<'_> {
|
||||
if data.is_empty() {
|
||||
let block = Block::bordered()
|
||||
.title(Line::from(Span::styled(
|
||||
" Accuracy % (Last 50 Drills) ",
|
||||
t!("stats.accuracy_chart_title").to_string(),
|
||||
Style::default()
|
||||
.fg(colors.accent())
|
||||
.add_modifier(Modifier::BOLD),
|
||||
@@ -514,7 +525,7 @@ impl StatsDashboard<'_> {
|
||||
.block(
|
||||
Block::bordered()
|
||||
.title(Line::from(Span::styled(
|
||||
" Accuracy % (Last 50 Drills) ",
|
||||
t!("stats.accuracy_chart_title").to_string(),
|
||||
Style::default()
|
||||
.fg(colors.accent())
|
||||
.add_modifier(Modifier::BOLD),
|
||||
@@ -524,13 +535,13 @@ impl StatsDashboard<'_> {
|
||||
)
|
||||
.x_axis(
|
||||
Axis::default()
|
||||
.title("Drill #")
|
||||
.title(t!("stats.chart_drill").to_string())
|
||||
.style(Style::default().fg(colors.text_pending()).bg(colors.bg()))
|
||||
.bounds([0.0, max_x]),
|
||||
)
|
||||
.y_axis(
|
||||
Axis::default()
|
||||
.title("Accuracy %")
|
||||
.title(t!("stats.chart_accuracy_pct").to_string())
|
||||
.style(Style::default().fg(colors.text_pending()).bg(colors.bg()))
|
||||
.labels(vec![
|
||||
Span::styled(
|
||||
@@ -575,7 +586,7 @@ impl StatsDashboard<'_> {
|
||||
} else {
|
||||
colors.accent()
|
||||
};
|
||||
let wpm_label = format!(" WPM: {avg_wpm:.0}/{} ({wpm_pct:.0}%)", self.target_wpm);
|
||||
let wpm_label = t!("stats.wpm_label", avg = format!("{avg_wpm:.0}"), target = self.target_wpm, pct = format!("{wpm_pct:.0}")).to_string();
|
||||
render_text_bar(
|
||||
&wpm_label,
|
||||
wpm_pct / 100.0,
|
||||
@@ -587,7 +598,7 @@ impl StatsDashboard<'_> {
|
||||
|
||||
// Accuracy progress
|
||||
let acc_pct = avg_accuracy.min(100.0);
|
||||
let acc_label = format!(" Acc: {acc_pct:.1}%");
|
||||
let acc_label = t!("stats.acc_label", pct = format!("{acc_pct:.1}")).to_string();
|
||||
let acc_color = if acc_pct >= 95.0 {
|
||||
colors.success()
|
||||
} else if acc_pct >= 85.0 {
|
||||
@@ -610,10 +621,7 @@ impl StatsDashboard<'_> {
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let level_label = format!(
|
||||
" Keys: {}/{} ({} mastered)",
|
||||
self.overall_unlocked, self.overall_total, self.overall_mastered
|
||||
);
|
||||
let level_label = t!("stats.keys_label", unlocked = self.overall_unlocked, total = self.overall_total, mastered = self.overall_mastered).to_string();
|
||||
render_text_bar(
|
||||
&level_label,
|
||||
key_pct,
|
||||
@@ -628,9 +636,10 @@ impl StatsDashboard<'_> {
|
||||
let colors = &self.theme.colors;
|
||||
|
||||
// Recent tests bordered table
|
||||
let sessions_title = t!("stats.sessions_title");
|
||||
let table_block = Block::bordered()
|
||||
.title(Line::from(Span::styled(
|
||||
" Recent Sessions ",
|
||||
sessions_title.to_string(),
|
||||
Style::default()
|
||||
.fg(colors.accent())
|
||||
.add_modifier(Modifier::BOLD),
|
||||
@@ -640,7 +649,7 @@ impl StatsDashboard<'_> {
|
||||
table_block.render(area, buf);
|
||||
|
||||
let header = Line::from(vec![Span::styled(
|
||||
" # WPM Raw Acc% Time Date/Time Mode Ranked Partial",
|
||||
t!("stats.session_header").to_string(),
|
||||
Style::default()
|
||||
.fg(colors.accent())
|
||||
.add_modifier(Modifier::BOLD),
|
||||
@@ -649,7 +658,7 @@ impl StatsDashboard<'_> {
|
||||
let mut lines = vec![
|
||||
header,
|
||||
Line::from(Span::styled(
|
||||
" ─────────────────────────────────────────────────────────────────────",
|
||||
t!("stats.session_separator").to_string(),
|
||||
Style::default().fg(colors.border()),
|
||||
)),
|
||||
];
|
||||
@@ -680,7 +689,8 @@ impl StatsDashboard<'_> {
|
||||
" "
|
||||
};
|
||||
|
||||
let rank_str = if result.ranked { "yes" } else { "no" };
|
||||
let rank_label = if result.ranked { t!("stats.yes") } else { t!("stats.no") };
|
||||
let rank_str = rank_label.as_ref();
|
||||
let partial_pct = if result.partial {
|
||||
result.completion_percent
|
||||
} else {
|
||||
@@ -721,9 +731,10 @@ impl StatsDashboard<'_> {
|
||||
fn render_keyboard_heatmap(&self, area: Rect, buf: &mut Buffer) {
|
||||
let colors = &self.theme.colors;
|
||||
|
||||
let kbd_acc_title = t!("stats.keyboard_accuracy_title");
|
||||
let block = Block::bordered()
|
||||
.title(Line::from(Span::styled(
|
||||
" Keyboard Accuracy % ",
|
||||
kbd_acc_title.to_string(),
|
||||
Style::default()
|
||||
.fg(colors.accent())
|
||||
.add_modifier(Modifier::BOLD),
|
||||
@@ -876,9 +887,10 @@ impl StatsDashboard<'_> {
|
||||
fn render_keyboard_timing(&self, area: Rect, buf: &mut Buffer) {
|
||||
let colors = &self.theme.colors;
|
||||
|
||||
let kbd_timing_title = t!("stats.keyboard_timing_title");
|
||||
let block = Block::bordered()
|
||||
.title(Line::from(Span::styled(
|
||||
" Keyboard Timing (ms) ",
|
||||
kbd_timing_title.to_string(),
|
||||
Style::default()
|
||||
.fg(colors.accent())
|
||||
.add_modifier(Modifier::BOLD),
|
||||
@@ -997,9 +1009,10 @@ impl StatsDashboard<'_> {
|
||||
fn render_slowest_keys(&self, area: Rect, buf: &mut Buffer) {
|
||||
let colors = &self.theme.colors;
|
||||
|
||||
let slowest_title = t!("stats.slowest_keys_title");
|
||||
let block = Block::bordered()
|
||||
.title(Line::from(Span::styled(
|
||||
" Slowest Keys (ms) ",
|
||||
slowest_title.to_string(),
|
||||
Style::default()
|
||||
.fg(colors.accent())
|
||||
.add_modifier(Modifier::BOLD),
|
||||
@@ -1045,9 +1058,10 @@ impl StatsDashboard<'_> {
|
||||
fn render_fastest_keys(&self, area: Rect, buf: &mut Buffer) {
|
||||
let colors = &self.theme.colors;
|
||||
|
||||
let fastest_title = t!("stats.fastest_keys_title");
|
||||
let block = Block::bordered()
|
||||
.title(Line::from(Span::styled(
|
||||
" Fastest Keys (ms) ",
|
||||
fastest_title.to_string(),
|
||||
Style::default()
|
||||
.fg(colors.accent())
|
||||
.add_modifier(Modifier::BOLD),
|
||||
@@ -1093,9 +1107,10 @@ impl StatsDashboard<'_> {
|
||||
fn render_worst_accuracy_keys(&self, area: Rect, buf: &mut Buffer) {
|
||||
let colors = &self.theme.colors;
|
||||
|
||||
let worst_title = t!("stats.worst_accuracy_title");
|
||||
let block = Block::bordered()
|
||||
.title(Line::from(Span::styled(
|
||||
" Worst Accuracy (%) ",
|
||||
worst_title.to_string(),
|
||||
Style::default()
|
||||
.fg(colors.accent())
|
||||
.add_modifier(Modifier::BOLD),
|
||||
@@ -1134,10 +1149,11 @@ impl StatsDashboard<'_> {
|
||||
key_accuracies.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap().then_with(|| a.0.cmp(&b.0)));
|
||||
|
||||
if key_accuracies.is_empty() {
|
||||
let no_data = t!("stats.not_enough_data");
|
||||
buf.set_string(
|
||||
inner.x,
|
||||
inner.y,
|
||||
" Not enough data",
|
||||
no_data.as_ref(),
|
||||
Style::default().fg(colors.text_pending()),
|
||||
);
|
||||
return;
|
||||
@@ -1173,9 +1189,10 @@ impl StatsDashboard<'_> {
|
||||
fn render_best_accuracy_keys(&self, area: Rect, buf: &mut Buffer) {
|
||||
let colors = &self.theme.colors;
|
||||
|
||||
let best_title = t!("stats.best_accuracy_title");
|
||||
let block = Block::bordered()
|
||||
.title(Line::from(Span::styled(
|
||||
" Best Accuracy (%) ",
|
||||
best_title.to_string(),
|
||||
Style::default()
|
||||
.fg(colors.accent())
|
||||
.add_modifier(Modifier::BOLD),
|
||||
@@ -1211,10 +1228,11 @@ impl StatsDashboard<'_> {
|
||||
key_accuracies.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap().then_with(|| a.0.cmp(&b.0)));
|
||||
|
||||
if key_accuracies.is_empty() {
|
||||
let no_data = t!("stats.not_enough_data");
|
||||
buf.set_string(
|
||||
inner.x,
|
||||
inner.y,
|
||||
" Not enough data",
|
||||
no_data.as_ref(),
|
||||
Style::default().fg(colors.text_pending()),
|
||||
);
|
||||
return;
|
||||
@@ -1249,9 +1267,10 @@ impl StatsDashboard<'_> {
|
||||
|
||||
fn render_activity_stats(&self, area: Rect, buf: &mut Buffer) {
|
||||
let colors = &self.theme.colors;
|
||||
let streaks_title = t!("stats.streaks_title");
|
||||
let block = Block::bordered()
|
||||
.title(Line::from(Span::styled(
|
||||
" Streaks ",
|
||||
streaks_title.to_string(),
|
||||
Style::default()
|
||||
.fg(colors.accent())
|
||||
.add_modifier(Modifier::BOLD),
|
||||
@@ -1272,22 +1291,25 @@ impl StatsDashboard<'_> {
|
||||
let mut top_days: Vec<(chrono::NaiveDate, usize)> = day_counts.into_iter().collect();
|
||||
top_days.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| b.0.cmp(&a.0)));
|
||||
|
||||
let current_label = t!("stats.current_streak");
|
||||
let best_label = t!("stats.best_streak");
|
||||
let active_days_label = t!("stats.active_days");
|
||||
let mut lines = vec![Line::from(vec![
|
||||
Span::styled(" Current: ", Style::default().fg(colors.fg())),
|
||||
Span::styled(current_label.to_string(), Style::default().fg(colors.fg())),
|
||||
Span::styled(
|
||||
format!("{current_streak}d"),
|
||||
Style::default()
|
||||
.fg(colors.success())
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(" Best: ", Style::default().fg(colors.fg())),
|
||||
Span::styled(best_label.to_string(), Style::default().fg(colors.fg())),
|
||||
Span::styled(
|
||||
format!("{best_streak}d"),
|
||||
Style::default()
|
||||
.fg(colors.accent())
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(" Active Days: ", Style::default().fg(colors.fg())),
|
||||
Span::styled(active_days_label.to_string(), Style::default().fg(colors.fg())),
|
||||
Span::styled(
|
||||
format!("{active_days_count}"),
|
||||
Style::default().fg(colors.text_pending()),
|
||||
@@ -1295,14 +1317,14 @@ impl StatsDashboard<'_> {
|
||||
])];
|
||||
|
||||
let top_days_text = if top_days.is_empty() {
|
||||
" Top Days: none".to_string()
|
||||
t!("stats.top_days_none").to_string()
|
||||
} else {
|
||||
let parts: Vec<String> = top_days
|
||||
.iter()
|
||||
.take(3)
|
||||
.map(|(d, c)| format!("{} ({})", d.format("%Y-%m-%d"), c))
|
||||
.collect();
|
||||
format!(" Top Days: {}", parts.join(" | "))
|
||||
t!("stats.top_days", days = parts.join(" | ")).to_string()
|
||||
};
|
||||
lines.push(Line::from(Span::styled(
|
||||
top_days_text,
|
||||
@@ -1321,7 +1343,7 @@ impl StatsDashboard<'_> {
|
||||
Some(d) => d,
|
||||
None => {
|
||||
let msg = Paragraph::new(Line::from(Span::styled(
|
||||
"Complete some adaptive drills to see n-gram data",
|
||||
t!("stats.ngram_empty").to_string(),
|
||||
Style::default().fg(colors.text_pending()),
|
||||
)));
|
||||
msg.render(area, buf);
|
||||
@@ -1370,9 +1392,10 @@ impl StatsDashboard<'_> {
|
||||
fn render_ngram_focus(&self, data: &NgramTabData, area: Rect, buf: &mut Buffer) {
|
||||
let colors = &self.theme.colors;
|
||||
|
||||
let focus_title = t!("stats.focus_title");
|
||||
let block = Block::bordered()
|
||||
.title(Line::from(Span::styled(
|
||||
" Active Focus ",
|
||||
focus_title.to_string(),
|
||||
Style::default()
|
||||
.fg(colors.accent())
|
||||
.add_modifier(Modifier::BOLD),
|
||||
@@ -1392,16 +1415,16 @@ impl StatsDashboard<'_> {
|
||||
let bigram_label = format!("\"{}{}\"", key.0[0], key.0[1]);
|
||||
// Line 1: both focuses
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" Focus: ", Style::default().fg(colors.fg())),
|
||||
Span::styled(t!("stats.focus_char_label").to_string(), Style::default().fg(colors.fg())),
|
||||
Span::styled(
|
||||
format!("Char '{ch}'"),
|
||||
t!("stats.focus_char_value", ch = ch).to_string(),
|
||||
Style::default()
|
||||
.fg(colors.focused_key())
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(" + ", Style::default().fg(colors.fg())),
|
||||
Span::styled(t!("stats.focus_plus").to_string(), Style::default().fg(colors.fg())),
|
||||
Span::styled(
|
||||
format!("Bigram {bigram_label}"),
|
||||
t!("stats.focus_bigram_value", label = &bigram_label).to_string(),
|
||||
Style::default()
|
||||
.fg(colors.focused_key())
|
||||
.add_modifier(Modifier::BOLD),
|
||||
@@ -1410,23 +1433,21 @@ impl StatsDashboard<'_> {
|
||||
// Line 2: details
|
||||
if inner.height >= 2 {
|
||||
let type_label = match anomaly_type {
|
||||
AnomalyType::Error => "error",
|
||||
AnomalyType::Speed => "speed",
|
||||
AnomalyType::Error => t!("stats.anomaly_error").to_string(),
|
||||
AnomalyType::Speed => t!("stats.anomaly_speed").to_string(),
|
||||
};
|
||||
let detail = format!(
|
||||
" Char '{ch}': weakest key | Bigram {bigram_label}: {type_label} anomaly {anomaly_pct:.0}%"
|
||||
);
|
||||
let detail = t!("stats.focus_detail_both", ch = ch, label = &bigram_label, r#type = &type_label, pct = format!("{anomaly_pct:.0}"));
|
||||
lines.push(Line::from(Span::styled(
|
||||
detail,
|
||||
detail.to_string(),
|
||||
Style::default().fg(colors.text_pending()),
|
||||
)));
|
||||
}
|
||||
}
|
||||
(Some(ch), None) => {
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" Focus: ", Style::default().fg(colors.fg())),
|
||||
Span::styled(t!("stats.focus_char_label").to_string(), Style::default().fg(colors.fg())),
|
||||
Span::styled(
|
||||
format!("Char '{ch}'"),
|
||||
t!("stats.focus_char_value", ch = ch).to_string(),
|
||||
Style::default()
|
||||
.fg(colors.focused_key())
|
||||
.add_modifier(Modifier::BOLD),
|
||||
@@ -1434,7 +1455,7 @@ impl StatsDashboard<'_> {
|
||||
]));
|
||||
if inner.height >= 2 {
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(" Char '{ch}': weakest key, no confirmed bigram anomalies"),
|
||||
t!("stats.focus_detail_char_only", ch = ch).to_string(),
|
||||
Style::default().fg(colors.text_pending()),
|
||||
)));
|
||||
}
|
||||
@@ -1442,26 +1463,26 @@ impl StatsDashboard<'_> {
|
||||
(None, Some((key, anomaly_pct, anomaly_type))) => {
|
||||
let bigram_label = format!("\"{}{}\"", key.0[0], key.0[1]);
|
||||
let type_label = match anomaly_type {
|
||||
AnomalyType::Error => "error",
|
||||
AnomalyType::Speed => "speed",
|
||||
AnomalyType::Error => t!("stats.anomaly_error").to_string(),
|
||||
AnomalyType::Speed => t!("stats.anomaly_speed").to_string(),
|
||||
};
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" Focus: ", Style::default().fg(colors.fg())),
|
||||
Span::styled(t!("stats.focus_char_label").to_string(), Style::default().fg(colors.fg())),
|
||||
Span::styled(
|
||||
format!("Bigram {bigram_label}"),
|
||||
t!("stats.focus_bigram_value", label = &bigram_label).to_string(),
|
||||
Style::default()
|
||||
.fg(colors.focused_key())
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(
|
||||
format!(" ({type_label} anomaly: {anomaly_pct:.0}%)"),
|
||||
t!("stats.focus_detail_bigram_only", r#type = &type_label, pct = format!("{anomaly_pct:.0}")).to_string(),
|
||||
Style::default().fg(colors.text_pending()),
|
||||
),
|
||||
]));
|
||||
}
|
||||
(None, None) => {
|
||||
lines.push(Line::from(Span::styled(
|
||||
" Complete some adaptive drills to see focus data",
|
||||
t!("stats.focus_empty").to_string(),
|
||||
Style::default().fg(colors.text_pending()),
|
||||
)));
|
||||
}
|
||||
@@ -1512,14 +1533,14 @@ impl StatsDashboard<'_> {
|
||||
// Speed table: Bigram Anom% Speed Smp Strk
|
||||
let header = if narrow {
|
||||
if is_speed {
|
||||
" Bgrm Speed Expct Anom%"
|
||||
t!("stats.ngram_header_speed_narrow").to_string()
|
||||
} else {
|
||||
" Bgrm Err Smp Rate Exp Anom%"
|
||||
t!("stats.ngram_header_error_narrow").to_string()
|
||||
}
|
||||
} else if is_speed {
|
||||
" Bigram Speed Expect Samples Anom%"
|
||||
t!("stats.ngram_header_speed").to_string()
|
||||
} else {
|
||||
" Bigram Errors Samples Rate Expect Anom%"
|
||||
t!("stats.ngram_header_error").to_string()
|
||||
};
|
||||
buf.set_string(
|
||||
inner.x,
|
||||
@@ -1586,10 +1607,11 @@ impl StatsDashboard<'_> {
|
||||
}
|
||||
|
||||
fn render_error_anomalies(&self, data: &NgramTabData, area: Rect, buf: &mut Buffer) {
|
||||
let title = format!(" Error Anomalies ({}) ", data.error_anomalies.len());
|
||||
let title = t!("stats.error_anomalies_title", count = data.error_anomalies.len());
|
||||
let empty_msg = t!("stats.no_error_anomalies");
|
||||
self.render_anomaly_panel(
|
||||
&title,
|
||||
" No error anomalies detected",
|
||||
title.as_ref(),
|
||||
empty_msg.as_ref(),
|
||||
&data.error_anomalies,
|
||||
false,
|
||||
area,
|
||||
@@ -1598,10 +1620,11 @@ impl StatsDashboard<'_> {
|
||||
}
|
||||
|
||||
fn render_speed_anomalies(&self, data: &NgramTabData, area: Rect, buf: &mut Buffer) {
|
||||
let title = format!(" Speed Anomalies ({}) ", data.speed_anomalies.len());
|
||||
let title = t!("stats.speed_anomalies_title", count = data.speed_anomalies.len());
|
||||
let empty_msg = t!("stats.no_speed_anomalies");
|
||||
self.render_anomaly_panel(
|
||||
&title,
|
||||
" No speed anomalies detected",
|
||||
title.as_ref(),
|
||||
empty_msg.as_ref(),
|
||||
&data.speed_anomalies,
|
||||
true,
|
||||
area,
|
||||
@@ -1619,18 +1642,18 @@ impl StatsDashboard<'_> {
|
||||
};
|
||||
|
||||
// Build segments from most to least important, progressively drop from the right
|
||||
let scope = format!(" {}", data.scope_label);
|
||||
let bigrams = format!(" | Bi: {}", data.total_bigrams);
|
||||
let trigrams = format!(" | Tri: {}", data.total_trigrams);
|
||||
let hesitation = format!(" | Hes: >{:.0}ms", data.hesitation_threshold_ms);
|
||||
let gain = format!(" | Gain: {}", gain_str);
|
||||
let gain_note = if data.latest_trigram_gain.is_none() {
|
||||
" (every 50)"
|
||||
let scope = t!("stats.scope_label_prefix", ).to_string() + &data.scope_label;
|
||||
let bigrams = t!("stats.bi_label", count = data.total_bigrams).to_string();
|
||||
let trigrams = t!("stats.tri_label", count = data.total_trigrams).to_string();
|
||||
let hesitation = t!("stats.hes_label", ms = format!("{:.0}", data.hesitation_threshold_ms)).to_string();
|
||||
let gain = t!("stats.gain_label", value = &gain_str).to_string();
|
||||
let gain_note_str = if data.latest_trigram_gain.is_none() {
|
||||
t!("stats.gain_interval").to_string()
|
||||
} else {
|
||||
""
|
||||
String::new()
|
||||
};
|
||||
|
||||
let segments: &[&str] = &[&scope, &bigrams, &trigrams, &hesitation, &gain, gain_note];
|
||||
let segments: &[&str] = &[&scope, &bigrams, &trigrams, &hesitation, &gain, &gain_note_str];
|
||||
let mut line = String::new();
|
||||
for seg in segments {
|
||||
if line.len() + seg.len() <= w {
|
||||
@@ -1649,33 +1672,47 @@ impl StatsDashboard<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
const TAB_LABELS: [&str; 6] = [
|
||||
"[1] Dashboard",
|
||||
"[2] History",
|
||||
"[3] Activity",
|
||||
"[4] Accuracy",
|
||||
"[5] Timing",
|
||||
"[6] N-grams",
|
||||
];
|
||||
fn tab_labels() -> Vec<String> {
|
||||
vec![
|
||||
t!("stats.tab_dashboard").to_string(),
|
||||
t!("stats.tab_history").to_string(),
|
||||
t!("stats.tab_activity").to_string(),
|
||||
t!("stats.tab_accuracy").to_string(),
|
||||
t!("stats.tab_timing").to_string(),
|
||||
t!("stats.tab_ngrams").to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
const TAB_SEPARATOR: &str = " ";
|
||||
const FOOTER_HINTS_DEFAULT: [&str; 3] = ["[ESC] Back", "[Tab] Next tab", "[1-6] Switch tab"];
|
||||
const FOOTER_HINTS_HISTORY: [&str; 6] = [
|
||||
"[ESC] Back",
|
||||
"[Tab] Next tab",
|
||||
"[1-6] Switch tab",
|
||||
"[j/k] Navigate",
|
||||
"[PgUp/PgDn] Page",
|
||||
"[x] Delete",
|
||||
];
|
||||
|
||||
fn footer_hints_default() -> Vec<String> {
|
||||
vec![
|
||||
t!("stats.hint_back").to_string(),
|
||||
t!("stats.hint_next_tab").to_string(),
|
||||
t!("stats.hint_switch_tab").to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
fn footer_hints_history() -> Vec<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 {
|
||||
table_inner.height.saturating_sub(2) as usize
|
||||
}
|
||||
|
||||
fn wrapped_tab_line_count(width: usize) -> usize {
|
||||
let labels = tab_labels();
|
||||
let mut lines = 1usize;
|
||||
let mut current_width = 0usize;
|
||||
for label in TAB_LABELS {
|
||||
for label in &labels {
|
||||
let item_width = format!(" {label} ").chars().count() + TAB_SEPARATOR.len();
|
||||
if current_width > 0 && current_width + item_width > width {
|
||||
lines += 1;
|
||||
@@ -1687,7 +1724,9 @@ fn wrapped_tab_line_count(width: usize) -> usize {
|
||||
}
|
||||
|
||||
fn footer_line_count_for_history(width: usize) -> usize {
|
||||
pack_hint_lines(&FOOTER_HINTS_HISTORY, width).len().max(1)
|
||||
let hints = footer_hints_history();
|
||||
let hint_refs: Vec<&str> = hints.iter().map(|s| s.as_str()).collect();
|
||||
pack_hint_lines(&hint_refs, width).len().max(1)
|
||||
}
|
||||
|
||||
pub fn history_page_size_for_terminal(width: u16, height: u16) -> usize {
|
||||
|
||||
@@ -6,6 +6,7 @@ use ratatui::widgets::{Block, Paragraph, Widget};
|
||||
|
||||
use crate::session::drill::DrillState;
|
||||
use crate::session::result::DrillResult;
|
||||
use crate::i18n::t;
|
||||
use crate::ui::theme::Theme;
|
||||
|
||||
pub struct StatsSidebar<'a> {
|
||||
@@ -80,21 +81,30 @@ impl Widget for StatsSidebar<'_> {
|
||||
let incorrect_str = format!("{incorrect}");
|
||||
let elapsed_str = format!("{elapsed:.1}s");
|
||||
|
||||
let wpm_label = t!("sidebar.wpm");
|
||||
let target_label = t!("sidebar.target");
|
||||
let target_wpm_val = t!("sidebar.target_wpm", wpm = self.target_wpm);
|
||||
let accuracy_label = t!("sidebar.accuracy");
|
||||
let progress_label = t!("sidebar.progress");
|
||||
let correct_label = t!("sidebar.correct");
|
||||
let errors_label = t!("sidebar.errors");
|
||||
let time_label = t!("sidebar.time");
|
||||
|
||||
let lines = vec![
|
||||
Line::from(vec![
|
||||
Span::styled("WPM: ", Style::default().fg(colors.fg())),
|
||||
Span::styled(wpm_label.as_ref(), Style::default().fg(colors.fg())),
|
||||
Span::styled(wpm_str, Style::default().fg(colors.accent())),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::styled("Target: ", Style::default().fg(colors.fg())),
|
||||
Span::styled(target_label.as_ref(), Style::default().fg(colors.fg())),
|
||||
Span::styled(
|
||||
format!("{} WPM", self.target_wpm),
|
||||
target_wpm_val.to_string(),
|
||||
Style::default().fg(colors.text_pending()),
|
||||
),
|
||||
]),
|
||||
Line::from(""),
|
||||
Line::from(vec![
|
||||
Span::styled("Accuracy: ", Style::default().fg(colors.fg())),
|
||||
Span::styled(accuracy_label.as_ref(), Style::default().fg(colors.fg())),
|
||||
Span::styled(
|
||||
acc_str,
|
||||
Style::default().fg(if accuracy >= 95.0 {
|
||||
@@ -108,27 +118,28 @@ impl Widget for StatsSidebar<'_> {
|
||||
]),
|
||||
Line::from(""),
|
||||
Line::from(vec![
|
||||
Span::styled("Progress: ", Style::default().fg(colors.fg())),
|
||||
Span::styled(progress_label.as_ref(), Style::default().fg(colors.fg())),
|
||||
Span::styled(prog_str, Style::default().fg(colors.accent())),
|
||||
]),
|
||||
Line::from(""),
|
||||
Line::from(vec![
|
||||
Span::styled("Correct: ", Style::default().fg(colors.fg())),
|
||||
Span::styled(correct_label.as_ref(), Style::default().fg(colors.fg())),
|
||||
Span::styled(correct_str, Style::default().fg(colors.success())),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::styled("Errors: ", Style::default().fg(colors.fg())),
|
||||
Span::styled(errors_label.as_ref(), Style::default().fg(colors.fg())),
|
||||
Span::styled(incorrect_str, Style::default().fg(colors.error())),
|
||||
]),
|
||||
Line::from(""),
|
||||
Line::from(vec![
|
||||
Span::styled("Time: ", Style::default().fg(colors.fg())),
|
||||
Span::styled(time_label.as_ref(), Style::default().fg(colors.fg())),
|
||||
Span::styled(elapsed_str, Style::default().fg(colors.fg())),
|
||||
]),
|
||||
];
|
||||
|
||||
let stats_title = t!("sidebar.title");
|
||||
let block = Block::bordered()
|
||||
.title(" Stats ")
|
||||
.title(stats_title.to_string())
|
||||
.border_style(Style::default().fg(colors.border()))
|
||||
.style(Style::default().bg(colors.bg()));
|
||||
|
||||
@@ -174,14 +185,20 @@ impl Widget for StatsSidebar<'_> {
|
||||
colors.text_pending()
|
||||
};
|
||||
|
||||
let wpm_label = t!("sidebar.wpm");
|
||||
let vs_avg_label = t!("sidebar.vs_avg");
|
||||
let accuracy_label = t!("sidebar.accuracy");
|
||||
let errors_label = t!("sidebar.errors");
|
||||
let time_label = t!("sidebar.time");
|
||||
|
||||
let mut lines = vec![Line::from(vec![
|
||||
Span::styled("WPM: ", Style::default().fg(colors.fg())),
|
||||
Span::styled(wpm_label.as_ref(), Style::default().fg(colors.fg())),
|
||||
Span::styled(wpm_str, Style::default().fg(colors.accent())),
|
||||
])];
|
||||
|
||||
if prior_count > 0 {
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" vs avg: ", Style::default().fg(colors.text_pending())),
|
||||
Span::styled(vs_avg_label.as_ref(), Style::default().fg(colors.text_pending())),
|
||||
Span::styled(wpm_delta_str, Style::default().fg(wpm_delta_color)),
|
||||
]));
|
||||
}
|
||||
@@ -189,7 +206,7 @@ impl Widget for StatsSidebar<'_> {
|
||||
lines.push(Line::from(""));
|
||||
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled("Accuracy: ", Style::default().fg(colors.fg())),
|
||||
Span::styled(accuracy_label.as_ref(), Style::default().fg(colors.fg())),
|
||||
Span::styled(
|
||||
acc_str,
|
||||
Style::default().fg(if last.accuracy >= 95.0 {
|
||||
@@ -203,25 +220,27 @@ impl Widget for StatsSidebar<'_> {
|
||||
]));
|
||||
|
||||
if prior_count > 0 {
|
||||
let vs_avg_label2 = t!("sidebar.vs_avg").to_string();
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" vs avg: ", Style::default().fg(colors.text_pending())),
|
||||
Span::styled(vs_avg_label2, Style::default().fg(colors.text_pending())),
|
||||
Span::styled(acc_delta_str, Style::default().fg(acc_delta_color)),
|
||||
]));
|
||||
}
|
||||
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled("Errors: ", Style::default().fg(colors.fg())),
|
||||
Span::styled(errors_label.as_ref(), Style::default().fg(colors.fg())),
|
||||
Span::styled(errors_str, Style::default().fg(colors.error())),
|
||||
]));
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled("Time: ", Style::default().fg(colors.fg())),
|
||||
Span::styled(time_label.as_ref(), Style::default().fg(colors.fg())),
|
||||
Span::styled(time_str, Style::default().fg(colors.fg())),
|
||||
]));
|
||||
|
||||
let last_drill_title = t!("sidebar.last_drill");
|
||||
let block = Block::bordered()
|
||||
.title(" Last Drill ")
|
||||
.title(last_drill_title.to_string())
|
||||
.border_style(Style::default().fg(colors.border()))
|
||||
.style(Style::default().bg(colors.bg()));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user