Multilingual dictionaries and keyboard layouts

This commit is contained in:
2026-03-06 04:49:51 +00:00
parent f20fa6110d
commit 895e04d6ce
70 changed files with 195109 additions and 1569 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -15,7 +15,7 @@ use keydr::store::schema::{
DrillHistoryData, EXPORT_VERSION, ExportData, KeyStatsData, ProfileData,
};
const SCHEMA_VERSION: u32 = 2;
const SCHEMA_VERSION: u32 = 3;
const TARGET_CPM: f64 = 175.0;
// ── Helpers ──────────────────────────────────────────────────────────────
@@ -271,6 +271,28 @@ fn last_practice_date_from_drills(drills: &[DrillResult]) -> Option<String> {
.map(|d| d.timestamp.format("%Y-%m-%d").to_string())
}
fn make_profile_data(
skill_tree: SkillTreeProgress,
total_score: f64,
total_drills: u32,
streak_days: u32,
best_streak: u32,
last_practice_date: Option<String>,
) -> ProfileData {
let mut skill_tree_by_language = HashMap::new();
skill_tree_by_language.insert("en".to_string(), skill_tree.clone());
ProfileData {
schema_version: SCHEMA_VERSION,
skill_tree,
skill_tree_by_language,
total_score,
total_drills,
streak_days,
best_streak,
last_practice_date,
}
}
// ── Profile Builders ─────────────────────────────────────────────────────
fn build_profile_01() -> ExportData {
@@ -278,15 +300,7 @@ fn build_profile_01() -> ExportData {
make_skill_tree_progress(vec![(BranchId::Lowercase, BranchStatus::InProgress, 0)]);
make_export(
ProfileData {
schema_version: SCHEMA_VERSION,
skill_tree,
total_score: 0.0,
total_drills: 0,
streak_days: 0,
best_streak: 0,
last_practice_date: None,
},
make_profile_data(skill_tree, 0.0, 0, 0, 0, None),
KeyStatsStore::default(),
KeyStatsStore::default(),
Vec::new(),
@@ -340,15 +354,14 @@ fn build_profile_02() -> ExportData {
// total_score: level_from_score(x) = (x/100).sqrt() => for level 2: score ~400
make_export(
ProfileData {
schema_version: SCHEMA_VERSION,
make_profile_data(
skill_tree,
total_score: 350.0,
total_drills: 15,
streak_days: 3,
best_streak: 3,
last_practice_date: last_practice_date_from_drills(&drills),
},
350.0,
15,
3,
3,
last_practice_date_from_drills(&drills),
),
stats,
ranked_stats,
drills,
@@ -402,15 +415,14 @@ fn build_profile_03() -> ExportData {
// level ~3: score ~900
make_export(
ProfileData {
schema_version: SCHEMA_VERSION,
make_profile_data(
skill_tree,
total_score: 900.0,
total_drills: 50,
streak_days: 7,
best_streak: 7,
last_practice_date: last_practice_date_from_drills(&drills),
},
900.0,
50,
7,
7,
last_practice_date_from_drills(&drills),
),
stats,
ranked_stats,
drills,
@@ -461,15 +473,14 @@ fn build_profile_03_near_lowercase_complete() -> ExportData {
);
make_export(
ProfileData {
schema_version: SCHEMA_VERSION,
make_profile_data(
skill_tree,
total_score: 1800.0,
total_drills: 90,
streak_days: 10,
best_streak: 12,
last_practice_date: last_practice_date_from_drills(&drills),
},
1800.0,
90,
10,
12,
last_practice_date_from_drills(&drills),
),
stats,
ranked_stats,
drills,
@@ -516,15 +527,14 @@ fn build_profile_04() -> ExportData {
// level ~5: score ~2500
make_export(
ProfileData {
schema_version: SCHEMA_VERSION,
make_profile_data(
skill_tree,
total_score: 2500.0,
total_drills: 100,
streak_days: 14,
best_streak: 14,
last_practice_date: last_practice_date_from_drills(&drills),
},
2500.0,
100,
14,
14,
last_practice_date_from_drills(&drills),
),
stats,
ranked_stats,
drills,
@@ -601,15 +611,14 @@ fn build_profile_05() -> ExportData {
// level ~7: score ~5000
make_export(
ProfileData {
schema_version: SCHEMA_VERSION,
make_profile_data(
skill_tree,
total_score: 5000.0,
total_drills: 200,
streak_days: 21,
best_streak: 21,
last_practice_date: last_practice_date_from_drills(&drills),
},
5000.0,
200,
21,
21,
last_practice_date_from_drills(&drills),
),
stats,
ranked_stats,
drills,
@@ -695,15 +704,14 @@ fn build_profile_06() -> ExportData {
// level ~12: score ~15000
make_export(
ProfileData {
schema_version: SCHEMA_VERSION,
make_profile_data(
skill_tree,
total_score: 15000.0,
total_drills: 500,
streak_days: 45,
best_streak: 60,
last_practice_date: last_practice_date_from_drills(&drills),
},
15000.0,
500,
45,
60,
last_practice_date_from_drills(&drills),
),
stats,
ranked_stats,
drills,
@@ -776,15 +784,14 @@ fn build_profile_07() -> ExportData {
// level ~18: score ~35000
make_export(
ProfileData {
schema_version: SCHEMA_VERSION,
make_profile_data(
skill_tree,
total_score: 35000.0,
total_drills: 800,
streak_days: 90,
best_streak: 90,
last_practice_date: last_practice_date_from_drills(&drills),
},
35000.0,
800,
90,
90,
last_practice_date_from_drills(&drills),
),
stats,
ranked_stats,
drills,

View File

@@ -1,6 +1,11 @@
use std::fs;
use std::path::PathBuf;
use crate::keyboard::model::KeyboardModel;
use crate::l10n::language_pack::{
LanguageLayoutValidationError, dictionary_languages_for_layout, supported_dictionary_languages,
validate_language_layout_pair,
};
use anyhow::Result;
use serde::{Deserialize, Serialize};
@@ -16,6 +21,8 @@ pub struct Config {
pub word_count: usize,
#[serde(default = "default_code_language")]
pub code_language: String,
#[serde(default = "default_dictionary_language")]
pub dictionary_language: String,
#[serde(default = "default_passage_book")]
pub passage_book: String,
#[serde(default = "default_passage_downloads_enabled")]
@@ -51,6 +58,9 @@ fn default_word_count() -> usize {
fn default_code_language() -> String {
"rust".to_string()
}
fn default_dictionary_language() -> String {
"en".to_string()
}
fn default_passage_book() -> String {
"all".to_string()
}
@@ -97,6 +107,7 @@ impl Default for Config {
keyboard_layout: default_keyboard_layout(),
word_count: default_word_count(),
code_language: default_code_language(),
dictionary_language: default_dictionary_language(),
passage_book: default_passage_book(),
passage_downloads_enabled: default_passage_downloads_enabled(),
passage_download_dir: default_passage_download_dir(),
@@ -149,11 +160,14 @@ impl Config {
self.target_wpm = self.target_wpm.clamp(10, 200);
self.word_count = self.word_count.clamp(5, 100);
self.normalize_code_language(valid_language_keys);
self.normalize_keyboard_layout();
self.normalize_dictionary_language();
self.normalize_language_layout_pair();
}
/// Validate `code_language` against known options, resetting to default if invalid.
/// Call after deserialization to handle stale/renamed keys from old configs.
pub fn normalize_code_language(&mut self, valid_keys: &[&str]) {
fn normalize_code_language(&mut self, valid_keys: &[&str]) {
// Backwards compatibility: old "shell" key is now "bash".
if self.code_language == "shell" {
self.code_language = "bash".to_string();
@@ -162,6 +176,48 @@ impl Config {
self.code_language = default_code_language();
}
}
/// Validate `dictionary_language` against supported keys.
fn normalize_dictionary_language(&mut self) {
if !supported_dictionary_languages().contains(&self.dictionary_language.as_str()) {
self.dictionary_language = default_dictionary_language();
}
}
/// Validate `keyboard_layout` against canonical profile keys.
fn normalize_keyboard_layout(&mut self) {
if !KeyboardModel::supported_layout_keys().contains(&self.keyboard_layout.as_str()) {
self.keyboard_layout = default_keyboard_layout();
}
}
/// Ensure the language/layout combination is explicitly supported.
fn normalize_language_layout_pair(&mut self) {
match self.validate_language_layout_pair() {
Ok(()) => {}
Err(LanguageLayoutValidationError::UnknownLanguage(_))
| Err(LanguageLayoutValidationError::LanguageBlockedBySupportLevel(_)) => {
self.dictionary_language = default_dictionary_language();
}
Err(LanguageLayoutValidationError::UnknownLayout(_)) => {
self.keyboard_layout = default_keyboard_layout();
}
Err(LanguageLayoutValidationError::UnsupportedLanguageLayoutPair { .. }) => {
if let Some(first_supported) =
dictionary_languages_for_layout(&self.keyboard_layout).first()
{
self.dictionary_language = (*first_supported).to_string();
} else {
self.keyboard_layout = default_keyboard_layout();
self.dictionary_language = default_dictionary_language();
}
}
}
}
pub fn validate_language_layout_pair(&self) -> Result<(), LanguageLayoutValidationError> {
validate_language_layout_pair(&self.dictionary_language, &self.keyboard_layout).map(|_| ())
}
}
#[cfg(test)]
@@ -175,6 +231,7 @@ mod tests {
assert_eq!(config.code_downloads_enabled, false);
assert_eq!(config.code_snippets_per_repo, 200);
assert_eq!(config.code_onboarding_done, false);
assert_eq!(config.dictionary_language, "en");
assert!(!config.code_download_dir.is_empty());
assert!(config.code_download_dir.contains("code"));
}
@@ -191,6 +248,7 @@ code_language = "go"
assert_eq!(config.target_wpm, 60);
assert_eq!(config.theme, "monokai");
assert_eq!(config.code_language, "go");
assert_eq!(config.dictionary_language, "en");
// New fields should have defaults
assert_eq!(config.code_downloads_enabled, false);
assert_eq!(config.code_snippets_per_repo, 200);
@@ -215,6 +273,7 @@ code_language = "go"
config.code_onboarding_done,
deserialized.code_onboarding_done
);
assert_eq!(config.dictionary_language, deserialized.dictionary_language);
}
#[test]
@@ -252,4 +311,52 @@ code_language = "go"
config.normalize_code_language(&valid_keys);
assert_eq!(config.code_language, "bash");
}
#[test]
fn test_normalize_dictionary_language_invalid_key_resets() {
let mut config = Config::default();
config.dictionary_language = "zz".to_string();
config.normalize_dictionary_language();
assert_eq!(config.dictionary_language, "en");
}
#[test]
fn test_normalize_keyboard_layout_invalid_key_resets() {
let mut config = Config::default();
config.keyboard_layout = "foo".to_string();
config.normalize_keyboard_layout();
assert_eq!(config.keyboard_layout, "qwerty");
}
#[test]
fn test_normalize_language_layout_pair_resets_invalid_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");
assert_eq!(config.keyboard_layout, "dvorak");
}
#[test]
fn test_validate_language_layout_pair_returns_typed_error() {
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 { .. }
));
}
#[test]
fn test_normalize_language_layout_pair_unknown_language_resets_language_only() {
let mut config = Config::default();
config.dictionary_language = "zz".to_string();
config.keyboard_layout = "qwerty".to_string();
config.normalize_language_layout_pair();
assert_eq!(config.dictionary_language, "en");
assert_eq!(config.keyboard_layout, "qwerty");
}
}

View File

@@ -4,6 +4,9 @@ use serde::{Deserialize, Serialize};
use crate::engine::key_stats::KeyStatsStore;
use crate::keyboard::display::{BACKSPACE, SPACE};
use crate::l10n::language_pack::{
DEFAULT_LATIN_PRIMARY_SEQUENCE, normalized_primary_letter_sequence,
};
/// Events returned by `SkillTree::update` describing what changed.
pub struct SkillTreeUpdate {
@@ -87,6 +90,8 @@ pub struct BranchDefinition {
pub levels: &'static [LevelDefinition],
}
// 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",
keys: &[
@@ -169,12 +174,12 @@ const CODE_SYMBOLS_LEVELS: &[LevelDefinition] = &[
pub const ALL_BRANCHES: &[BranchDefinition] = &[
BranchDefinition {
id: BranchId::Lowercase,
name: "Lowercase a-z",
name: "Primary Letters",
levels: LOWERCASE_LEVELS,
},
BranchDefinition {
id: BranchId::Capitals,
name: "Capitals A-Z",
name: "Capital Letters",
levels: CAPITALS_LEVELS,
},
BranchDefinition {
@@ -272,13 +277,14 @@ impl Default for SkillTreeProgress {
pub enum DrillScope {
/// Global adaptive: all InProgress + Complete branches
Global,
/// Branch-specific drill: specific branch + a-z background
/// Branch-specific drill: specific branch + primary-letter background
Branch(BranchId),
}
pub struct SkillTree {
pub progress: SkillTreeProgress,
pub total_unique_keys: usize,
primary_letters: Vec<char>,
}
/// Number of lowercase letters to start with before unlocking one-at-a-time
@@ -287,26 +293,49 @@ const ALWAYS_UNLOCKED_KEYS: &[char] = &[SPACE, BACKSPACE];
impl SkillTree {
pub fn new(progress: SkillTreeProgress) -> Self {
let total_unique_keys = Self::compute_total_unique_keys();
Self::new_with_primary_sequence(progress, DEFAULT_LATIN_PRIMARY_SEQUENCE)
}
pub fn new_with_primary_sequence(progress: SkillTreeProgress, sequence: &str) -> Self {
let primary_letters = Self::normalize_primary_sequence(sequence);
let total_unique_keys = Self::compute_total_unique_keys(&primary_letters);
Self {
progress,
total_unique_keys,
primary_letters,
}
}
fn compute_total_unique_keys() -> usize {
fn normalize_primary_sequence(sequence: &str) -> Vec<char> {
let normalized = normalized_primary_letter_sequence(sequence);
if normalized.is_empty() {
DEFAULT_LATIN_PRIMARY_SEQUENCE.chars().collect()
} else {
normalized
}
}
fn compute_total_unique_keys(primary_letters: &[char]) -> usize {
let mut all_keys: HashSet<char> = HashSet::new();
for branch in ALL_BRANCHES {
if branch.id == BranchId::Lowercase {
continue;
}
for level in branch.levels {
for &key in level.keys {
all_keys.insert(key);
}
}
}
all_keys.extend(primary_letters.iter().copied());
all_keys.extend(ALWAYS_UNLOCKED_KEYS.iter().copied());
all_keys.len()
}
pub fn primary_letters(&self) -> &[char] {
&self.primary_letters
}
pub fn branch_status(&self, id: BranchId) -> &BranchStatus {
self.progress
.branches
@@ -366,8 +395,12 @@ impl SkillTree {
}
}
BranchStatus::Complete => {
for level in branch_def.levels {
keys.extend_from_slice(level.keys);
if branch_def.id == BranchId::Lowercase {
keys.extend(self.primary_letters.iter().copied());
} else {
for level in branch_def.levels {
keys.extend_from_slice(level.keys);
}
}
}
_ => {}
@@ -379,16 +412,13 @@ impl SkillTree {
fn branch_unlocked_keys(&self, id: BranchId) -> Vec<char> {
let mut keys = ALWAYS_UNLOCKED_KEYS.to_vec();
// Always include a-z background keys
// Always include primary-letter background keys
if id != BranchId::Lowercase {
let lowercase_def = get_branch_definition(BranchId::Lowercase);
let lowercase_bp = self.branch_progress(BranchId::Lowercase);
match lowercase_bp.status {
BranchStatus::InProgress => keys.extend(self.lowercase_unlocked_keys()),
BranchStatus::Complete => {
for level in lowercase_def.levels {
keys.extend_from_slice(level.keys);
}
keys.extend(self.primary_letters.iter().copied());
}
_ => {}
}
@@ -422,9 +452,8 @@ impl SkillTree {
/// Get the progressively-unlocked lowercase keys (mirrors old LetterUnlock logic).
fn lowercase_unlocked_keys(&self) -> Vec<char> {
let def = get_branch_definition(BranchId::Lowercase);
let bp = self.branch_progress(BranchId::Lowercase);
let all_keys = def.levels[0].keys;
let all_keys = self.primary_letters();
match bp.status {
BranchStatus::Complete => all_keys.to_vec(),
@@ -470,8 +499,12 @@ impl SkillTree {
}
}
BranchStatus::Complete => {
for level in branch_def.levels {
focus_candidates.extend_from_slice(level.keys);
if branch_def.id == BranchId::Lowercase {
focus_candidates.extend(self.primary_letters.iter().copied());
} else {
for level in branch_def.levels {
focus_candidates.extend_from_slice(level.keys);
}
}
}
_ => {}
@@ -645,11 +678,11 @@ impl SkillTree {
return;
}
let all_keys = get_branch_definition(BranchId::Lowercase).levels[0].keys;
let all_keys = self.primary_letters.clone();
let current_count = LOWERCASE_MIN_KEYS + bp.current_level;
if current_count >= all_keys.len() {
// All 26 keys unlocked, check if all confident
// All primary letters unlocked, check if all confident
let all_confident = all_keys.iter().all(|&ch| stats.get_confidence(ch) >= 1.0);
if all_confident {
let bp_mut = self.branch_progress_mut(BranchId::Lowercase);
@@ -718,10 +751,16 @@ impl SkillTree {
}
}
BranchStatus::Complete => {
for level in branch_def.levels {
for &key in level.keys {
if branch_def.id == BranchId::Lowercase {
for &key in self.primary_letters() {
keys.insert(key);
}
} else {
for level in branch_def.levels {
for &key in level.keys {
keys.insert(key);
}
}
}
}
_ => {}
@@ -749,7 +788,13 @@ impl SkillTree {
let def = get_branch_definition(id);
let bp = self.branch_progress(id);
match bp.status {
BranchStatus::Complete => def.levels.iter().map(|l| l.keys.len()).sum(),
BranchStatus::Complete => {
if id == BranchId::Lowercase {
self.primary_letters().len()
} else {
def.levels.iter().map(|l| l.keys.len()).sum()
}
}
BranchStatus::InProgress => {
if id == BranchId::Lowercase {
self.lowercase_unlocked_count()
@@ -772,6 +817,15 @@ impl SkillTree {
def.levels.iter().map(|l| l.keys.len()).sum()
}
/// Total keys defined in a branch for this tree configuration.
pub fn branch_total_keys_for(&self, id: BranchId) -> usize {
if id == BranchId::Lowercase {
self.primary_letters().len()
} else {
Self::branch_total_keys(id)
}
}
/// Count of unique confident keys across all branches.
pub fn total_confident_keys(&self, stats: &KeyStatsStore) -> usize {
let mut keys: HashSet<char> = HashSet::new();
@@ -780,7 +834,15 @@ impl SkillTree {
keys.insert(ch);
}
}
for &ch in self.primary_letters() {
if stats.get_confidence(ch) >= 1.0 {
keys.insert(ch);
}
}
for branch_def in ALL_BRANCHES {
if branch_def.id == BranchId::Lowercase {
continue;
}
for level in branch_def.levels {
for &ch in level.keys {
if stats.get_confidence(ch) >= 1.0 {
@@ -794,12 +856,19 @@ impl SkillTree {
/// Count of confident keys in a branch.
pub fn branch_confident_keys(&self, id: BranchId, stats: &KeyStatsStore) -> usize {
let def = get_branch_definition(id);
def.levels
.iter()
.flat_map(|l| l.keys.iter())
.filter(|&&ch| stats.get_confidence(ch) >= 1.0)
.count()
if id == BranchId::Lowercase {
self.primary_letters()
.iter()
.filter(|&&ch| stats.get_confidence(ch) >= 1.0)
.count()
} else {
let def = get_branch_definition(id);
def.levels
.iter()
.flat_map(|l| l.keys.iter())
.filter(|&&ch| stats.get_confidence(ch) >= 1.0)
.count()
}
}
}
@@ -812,6 +881,7 @@ impl Default for SkillTree {
#[cfg(test)]
mod tests {
use super::*;
use crate::l10n::language_pack::language_packs;
fn make_stats_confident(stats: &mut KeyStatsStore, keys: &[char]) {
for &ch in keys {
@@ -851,6 +921,21 @@ mod tests {
assert!(keys.contains(&BACKSPACE));
}
#[test]
fn test_custom_primary_sequence_drives_lowercase_progression() {
let tree = SkillTree::new_with_primary_sequence(SkillTreeProgress::default(), "abcde");
let keys = tree.unlocked_keys(DrillScope::Global);
// With a shorter primary sequence, all primary letters are immediately unlocked.
assert!(keys.contains(&'a'));
assert!(keys.contains(&'e'));
assert_eq!(tree.primary_letters(), &['a', 'b', 'c', 'd', 'e']);
assert_eq!(
tree.branch_total_keys_for(BranchId::Lowercase),
tree.primary_letters().len()
);
}
#[test]
fn test_lowercase_progressive_unlock() {
let mut tree = SkillTree::default();
@@ -871,9 +956,9 @@ mod tests {
let mut tree = SkillTree::default();
let mut stats = KeyStatsStore::default();
// Make all 26 lowercase keys confident
let all_lowercase = get_branch_definition(BranchId::Lowercase).levels[0].keys;
make_stats_confident(&mut stats, all_lowercase);
// Make all primary letters confident.
let all_primary = tree.primary_letters().to_vec();
make_stats_confident(&mut stats, &all_primary);
// Need to repeatedly update as each unlock requires all current keys confident
for _ in 0..30 {
@@ -1041,8 +1126,8 @@ mod tests {
bp.current_level = 1;
let keys = tree.unlocked_keys(DrillScope::Branch(BranchId::Capitals));
// Should include all 26 lowercase + Capitals L1 (8) + Capitals L2 (10)
assert!(keys.contains(&'e')); // lowercase background
// Should include full primary-letter background + Capitals L1 (8) + Capitals L2 (10)
assert!(keys.contains(&tree.primary_letters()[0])); // primary-letter background
assert!(keys.contains(&'T')); // Capitals L1
assert!(keys.contains(&'J')); // Capitals L2 (current level)
assert!(!keys.contains(&'O')); // Capitals L3 (locked)
@@ -1096,6 +1181,73 @@ mod tests {
assert!(branches.len() - 1 < branches.len());
}
#[test]
fn progression_is_monotonic_for_all_language_pack_sequences() {
for pack in language_packs() {
let mut tree = SkillTree::new_with_primary_sequence(
SkillTreeProgress::default(),
pack.primary_letter_sequence,
);
let primary = tree.primary_letters().to_vec();
assert!(
!primary.is_empty(),
"primary sequence should be non-empty for {}",
pack.language_key
);
let mut stats = KeyStatsStore::default();
let mut previous_count = tree.lowercase_unlocked_count();
assert!(
previous_count <= primary.len(),
"initial unlock count must be bounded for {}",
pack.language_key
);
// Master keys in configured sequence order and verify unlocked count never decreases.
for &ch in &primary {
make_stats_confident(&mut stats, &[ch]);
for _ in 0..3 {
tree.update(&stats, None);
let current_count = tree.lowercase_unlocked_count();
assert!(
current_count >= previous_count,
"unlock count regressed for {}: {} -> {}",
pack.language_key,
previous_count,
current_count
);
previous_count = current_count;
}
}
for _ in 0..30 {
tree.update(&stats, None);
let current_count = tree.lowercase_unlocked_count();
assert!(
current_count >= previous_count,
"unlock count regressed in completion pass for {}: {} -> {}",
pack.language_key,
previous_count,
current_count
);
previous_count = current_count;
}
assert_eq!(
tree.lowercase_unlocked_count(),
primary.len(),
"all primary letters should unlock for {}",
pack.language_key
);
assert_eq!(
*tree.branch_status(BranchId::Lowercase),
BranchStatus::Complete,
"lowercase branch should complete for {}",
pack.language_key
);
}
}
#[test]
fn test_update_returns_newly_unlocked() {
let mut tree = SkillTree::default();
@@ -1166,8 +1318,8 @@ mod tests {
let mut tree = SkillTree::default();
let mut stats = KeyStatsStore::default();
let all_lowercase = get_branch_definition(BranchId::Lowercase).levels[0].keys;
make_stats_confident(&mut stats, all_lowercase);
let all_primary = tree.primary_letters().to_vec();
make_stats_confident(&mut stats, &all_primary);
// Run updates to advance through progressive unlock
let mut found_available = false;
@@ -1262,8 +1414,8 @@ mod tests {
// Set all branches to InProgress at last level with all keys confident
// First complete lowercase
let all_lowercase = get_branch_definition(BranchId::Lowercase).levels[0].keys;
make_stats_confident(&mut stats, all_lowercase);
let all_primary = tree.primary_letters().to_vec();
make_stats_confident(&mut stats, &all_primary);
for _ in 0..30 {
tree.update(&stats, None);
}
@@ -1359,8 +1511,8 @@ mod tests {
let mut tree = SkillTree::default();
let mut stats = KeyStatsStore::default();
let all_lowercase = get_branch_definition(BranchId::Lowercase).levels[0].keys;
make_stats_confident(&mut stats, all_lowercase);
let all_primary = tree.primary_letters().to_vec();
make_stats_confident(&mut stats, &all_primary);
for _ in 0..30 {
let result = tree.update(&stats, None);
@@ -1382,8 +1534,8 @@ mod tests {
let mut tree = SkillTree::default();
let mut stats = KeyStatsStore::default();
let all_lowercase = get_branch_definition(BranchId::Lowercase).levels[0].keys;
make_stats_confident(&mut stats, all_lowercase);
let all_primary = tree.primary_letters().to_vec();
make_stats_confident(&mut stats, &all_primary);
for _ in 0..30 {
let result = tree.update(&stats, None);

View File

@@ -1,6 +1,10 @@
use rand::Rng;
use rand::rngs::SmallRng;
fn lowercase_eq(a: char, b: char) -> bool {
a.to_lowercase().eq(b.to_lowercase())
}
/// Post-processing pass that capitalizes words in generated text.
/// Only capitalizes using letters from `unlocked_capitals`.
pub fn apply_capitalization(
@@ -13,7 +17,7 @@ pub fn apply_capitalization(
return text.to_string();
}
let focused_upper = focused.filter(|ch| ch.is_ascii_uppercase());
let focused_upper = focused.filter(|ch| ch.is_uppercase());
let mut words: Vec<String> = text.split_whitespace().map(|w| w.to_string()).collect();
if words.is_empty() {
return text.to_string();
@@ -72,7 +76,7 @@ pub fn apply_capitalization(
if let Some(focused_upper) = focused_upper.filter(|ch| unlocked_capitals.contains(ch)) {
let alpha_words = words
.iter()
.filter(|w| w.chars().any(|ch| ch.is_ascii_alphabetic()))
.filter(|w| w.chars().any(|ch| ch.is_alphabetic()))
.count();
let min_focused = alpha_words.min(4);
ensure_min_focused_occurrences(&mut words, focused_upper, min_focused);
@@ -88,20 +92,20 @@ pub fn apply_capitalization(
fn word_start_upper(word: &str) -> Option<char> {
word.chars()
.find(|ch| ch.is_ascii_alphabetic())
.map(|ch| ch.to_ascii_uppercase())
.find(|ch| ch.is_alphabetic())
.and_then(|ch| ch.to_uppercase().next())
}
fn capitalize_word_start(word: &mut String) -> Option<char> {
let mut chars: Vec<char> = word.chars().collect();
for i in 0..chars.len() {
if chars[i].is_ascii_lowercase() {
chars[i] = chars[i].to_ascii_uppercase();
if chars[i].is_lowercase() {
chars[i] = chars[i].to_uppercase().next().unwrap_or(chars[i]);
let upper = chars[i];
*word = chars.into_iter().collect();
return Some(upper);
}
if chars[i].is_ascii_uppercase() {
if chars[i].is_uppercase() {
return Some(chars[i]);
}
}
@@ -111,20 +115,20 @@ fn capitalize_word_start(word: &mut String) -> Option<char> {
fn ends_sentence(word: &str) -> bool {
word.chars()
.rev()
.find(|ch| !ch.is_ascii_whitespace())
.find(|ch| !ch.is_whitespace())
.is_some_and(|ch| matches!(ch, '.' | '?' | '!'))
}
fn word_starts_with_lower(word: &str, lower: char) -> bool {
word.chars()
.find(|ch| ch.is_ascii_alphabetic())
.is_some_and(|ch| ch == lower)
.find(|ch| ch.is_alphabetic())
.is_some_and(|ch| lowercase_eq(ch, lower))
}
fn force_word_start_to_upper(word: &mut String, upper: char) -> bool {
let mut chars: Vec<char> = word.chars().collect();
for i in 0..chars.len() {
if chars[i].is_ascii_alphabetic() {
if chars[i].is_alphabetic() {
if chars[i] == upper {
return false;
}
@@ -137,7 +141,7 @@ fn force_word_start_to_upper(word: &mut String, upper: char) -> bool {
}
fn ensure_min_focused_occurrences(words: &mut Vec<String>, focused_upper: char, min_count: usize) {
let focused_lower = focused_upper.to_ascii_lowercase();
let focused_lower = focused_upper.to_lowercase().next().unwrap_or(focused_upper);
let mut count = words
.iter()
.map(|w| w.chars().filter(|&ch| ch == focused_upper).count())
@@ -173,8 +177,8 @@ fn ensure_min_focused_occurrences(words: &mut Vec<String>, focused_upper: char,
}
let next_starts_focused = words[i + 1]
.chars()
.find(|ch| ch.is_ascii_alphabetic())
.is_some_and(|ch| ch.eq_ignore_ascii_case(&focused_lower));
.find(|ch| ch.is_alphabetic())
.is_some_and(|ch| lowercase_eq(ch, focused_lower));
if next_starts_focused {
capitalize_word_start(&mut words[i + 1]);
let next = words.remove(i + 1);
@@ -204,7 +208,7 @@ fn ensure_min_total_capitals(
) {
let mut count = words
.iter()
.map(|w| w.chars().filter(|ch| ch.is_ascii_uppercase()).count())
.map(|w| w.chars().filter(|ch| ch.is_uppercase()).count())
.sum::<usize>();
if count >= min_count || unlocked_capitals.is_empty() {
return;
@@ -219,7 +223,7 @@ fn ensure_min_total_capitals(
continue;
};
if unlocked_capitals.contains(&upper)
&& word_starts_with_lower(word, upper.to_ascii_lowercase())
&& word_starts_with_lower(word, upper.to_lowercase().next().unwrap_or(upper))
{
if capitalize_word_start(word) == Some(upper) {
count += 1;

View File

@@ -1,26 +1,87 @@
use crate::engine::filter::CharFilter;
use crate::l10n::unicode::normalize_nfc;
const WORDS_EN: &str = include_str!("../../assets/words-en.json");
const WORDS_CS: &str = include_str!("../../assets/dictionaries/words-cs.json");
const WORDS_DA: &str = include_str!("../../assets/dictionaries/words-da.json");
const WORDS_DE: &str = include_str!("../../assets/dictionaries/words-de.json");
const WORDS_EN: &str = include_str!("../../assets/dictionaries/words-en.json");
const WORDS_ES: &str = include_str!("../../assets/dictionaries/words-es.json");
const WORDS_ET: &str = include_str!("../../assets/dictionaries/words-et.json");
const WORDS_FI: &str = include_str!("../../assets/dictionaries/words-fi.json");
const WORDS_FR: &str = include_str!("../../assets/dictionaries/words-fr.json");
const WORDS_HR: &str = include_str!("../../assets/dictionaries/words-hr.json");
const WORDS_HU: &str = include_str!("../../assets/dictionaries/words-hu.json");
const WORDS_IT: &str = include_str!("../../assets/dictionaries/words-it.json");
const WORDS_LT: &str = include_str!("../../assets/dictionaries/words-lt.json");
const WORDS_LV: &str = include_str!("../../assets/dictionaries/words-lv.json");
const WORDS_NB: &str = include_str!("../../assets/dictionaries/words-nb.json");
const WORDS_NL: &str = include_str!("../../assets/dictionaries/words-nl.json");
const WORDS_PL: &str = include_str!("../../assets/dictionaries/words-pl.json");
const WORDS_PT: &str = include_str!("../../assets/dictionaries/words-pt.json");
const WORDS_RO: &str = include_str!("../../assets/dictionaries/words-ro.json");
const WORDS_SL: &str = include_str!("../../assets/dictionaries/words-sl.json");
const WORDS_SV: &str = include_str!("../../assets/dictionaries/words-sv.json");
const WORDS_TR: &str = include_str!("../../assets/dictionaries/words-tr.json");
#[derive(Clone, Debug)]
pub struct Dictionary {
words: Vec<String>,
}
impl Dictionary {
pub fn load() -> Self {
let words: Vec<String> = serde_json::from_str(WORDS_EN).unwrap_or_default();
// Filter to words of length >= 3 (matching keybr)
let words = words
.into_iter()
.filter(|w| w.len() >= 3 && w.chars().all(|c| c.is_ascii_lowercase()))
.collect();
Self { words }
fn raw_for_language(language_key: &str) -> Option<&'static str> {
match language_key {
"cs" => Some(WORDS_CS),
"da" => Some(WORDS_DA),
"de" => Some(WORDS_DE),
"en" => Some(WORDS_EN),
"es" => Some(WORDS_ES),
"et" => Some(WORDS_ET),
"fi" => Some(WORDS_FI),
"fr" => Some(WORDS_FR),
"hr" => Some(WORDS_HR),
"hu" => Some(WORDS_HU),
"it" => Some(WORDS_IT),
"lt" => Some(WORDS_LT),
"lv" => Some(WORDS_LV),
"nb" => Some(WORDS_NB),
"nl" => Some(WORDS_NL),
"pl" => Some(WORDS_PL),
"pt" => Some(WORDS_PT),
"ro" => Some(WORDS_RO),
"sl" => Some(WORDS_SL),
"sv" => Some(WORDS_SV),
"tr" => Some(WORDS_TR),
_ => None,
}
}
pub fn words_list(&self) -> Vec<String> {
self.words.clone()
pub fn supports_language(language_key: &str) -> bool {
Self::raw_for_language(language_key).is_some()
}
pub fn try_load_for_language(language_key: &str) -> Option<Self> {
let raw = Self::raw_for_language(language_key)?;
let words: Vec<String> = serde_json::from_str(raw).unwrap_or_default();
// Filter to words of length >= 3 and normalize to NFC for consistent
// matching across composed/decomposed forms.
let words = words
.into_iter()
.map(|w| normalize_nfc(&w))
.filter(|w| w.chars().count() >= 3)
.filter(|w| !w.chars().any(|c| c.is_whitespace()))
.collect::<Vec<String>>();
Some(Self { words })
}
pub fn load_for_language(language_key: &str) -> Self {
Self::try_load_for_language(language_key)
.unwrap_or_else(|| panic!("unsupported dictionary language: {language_key}"))
}
pub fn words_list(&self) -> &[String] {
&self.words
}
pub fn find_matching(&self, filter: &CharFilter, focused: Option<char>) -> Vec<&str> {
@@ -43,10 +104,17 @@ impl Dictionary {
#[cfg(test)]
mod tests {
use super::*;
use crate::l10n::language_pack::{language_packs, supported_dictionary_languages};
#[test]
#[should_panic(expected = "unsupported dictionary language")]
fn load_for_language_unknown_panics() {
let _ = Dictionary::load_for_language("zz");
}
#[test]
fn find_matching_focused_is_sort_only() {
let dictionary = Dictionary::load();
let dictionary = Dictionary::load_for_language("en");
let filter = CharFilter::new(('a'..='z').collect());
let without_focus = dictionary.find_matching(&filter, None);
@@ -61,4 +129,34 @@ mod tests {
assert_eq!(sorted_without, sorted_with);
assert_eq!(without_focus.len(), with_focus.len());
}
#[test]
fn non_english_dictionaries_load_substantial_word_lists() {
for &lang in supported_dictionary_languages() {
if lang == "en" {
continue;
}
let dictionary = Dictionary::load_for_language(lang);
assert!(
dictionary.words_list().len() > 100,
"expected substantial dictionary for language {lang}"
);
}
}
#[test]
fn all_registered_language_packs_have_embedded_dictionary_assets() {
for pack in language_packs() {
assert!(
Dictionary::supports_language(pack.language_key),
"language pack {} is missing an embedded dictionary asset",
pack.language_key
);
assert!(
Dictionary::try_load_for_language(pack.language_key).is_some(),
"dictionary load failed for language pack {}",
pack.language_key
);
}
}
}

View File

@@ -75,15 +75,60 @@ impl PhoneticGenerator {
filter: &CharFilter,
focused_char: Option<char>,
focused_bigram: Option<[char; 2]>,
starters: &[(char, f64)],
) -> String {
for _attempt in 0..5 {
let word = self.try_generate_word(filter, focused_char, focused_bigram);
if word.len() >= MIN_WORD_LEN {
let word = self.try_generate_word(filter, focused_char, focused_bigram, starters);
if word.chars().count() >= MIN_WORD_LEN {
return word;
}
}
// Fallback
"the".to_string()
self.default_fallback_word(filter)
}
fn default_fallback_word(&self, filter: &CharFilter) -> String {
let matching = self.dictionary.find_matching(filter, None);
if let Some(word) = matching.first() {
return (*word).to_string();
}
let mut chars: Vec<char> = filter
.allowed
.iter()
.copied()
.filter(|c| !c.is_whitespace())
.collect();
chars.sort_unstable();
let fallback: String = chars.into_iter().take(3).collect();
if fallback.chars().count() >= MIN_WORD_LEN {
return fallback;
}
let seed = fallback
.chars()
.next()
.or_else(|| filter.allowed.iter().copied().find(|c| !c.is_whitespace()))
.unwrap_or('x');
std::iter::repeat_n(seed, MIN_WORD_LEN).collect()
}
fn starter_weights(&self, filter: &CharFilter) -> Vec<(char, f64)> {
let mut weights = std::collections::HashMap::<char, f64>::new();
for word in self.dictionary.words_list() {
if let Some(first) = word.chars().next()
&& filter.is_allowed(first)
{
*weights.entry(first).or_insert(0.0) += 1.0;
}
}
if weights.is_empty() {
return filter
.allowed
.iter()
.copied()
.filter(|c| !c.is_whitespace())
.map(|c| (c, 1.0))
.collect();
}
weights.into_iter().collect()
}
fn try_generate_word(
@@ -91,6 +136,7 @@ impl PhoneticGenerator {
filter: &CharFilter,
focused: Option<char>,
focused_bigram: Option<[char; 2]>,
starters: &[(char, f64)],
) -> String {
let mut word = Vec::new();
@@ -149,22 +195,10 @@ impl PhoneticGenerator {
}
// Fallback: weighted random start
if word.is_empty() {
let starters: Vec<(char, f64)> = filter
.allowed
.iter()
.map(|&ch| {
let w = match ch {
'e' | 't' | 'a' => 3.0,
'o' | 'i' | 'n' | 's' => 2.0,
_ => 1.0,
};
(ch, w)
})
.collect();
if let Some(ch) = Self::pick_weighted_from(&mut self.rng, &starters, filter) {
word.push(ch);
} else {
return "the".to_string();
return self.default_fallback_word(filter);
}
}
}
@@ -224,14 +258,16 @@ impl PhoneticGenerator {
break;
}
} else {
// Fallback to vowel
let vowels: Vec<(char, f64)> = ['a', 'e', 'i', 'o', 'u']
// Fallback to any allowed alphabetic character.
let next_chars: Vec<(char, f64)> = filter
.allowed
.iter()
.filter(|&&v| filter.is_allowed(v))
.map(|&v| (v, 1.0))
.copied()
.filter(|ch| ch.is_alphabetic())
.map(|ch| (ch, 1.0))
.collect();
if let Some(v) = Self::pick_weighted_from(&mut self.rng, &vowels, filter) {
word.push(v);
if let Some(next) = Self::pick_weighted_from(&mut self.rng, &next_chars, filter) {
word.push(next);
} else {
break;
}
@@ -357,6 +393,7 @@ impl TextGenerator for PhoneticGenerator {
.iter()
.map(|s| s.to_string())
.collect();
let starters = self.starter_weights(filter);
let pool_size = matching_words.len();
let use_dict = pool_size >= MIN_REAL_WORDS;
@@ -392,7 +429,7 @@ impl TextGenerator for PhoneticGenerator {
// Pre-categorize words into tiers for dictionary picks
let bigram_str = focused_bigram.map(|b| format!("{}{}", b[0], b[1]));
let focus_char_lower = focused_char.filter(|ch| ch.is_ascii_lowercase());
let focus_char_lower = focused_char.filter(|ch| ch.is_lowercase());
let (bigram_indices, char_indices, other_indices) = if use_dict {
let mut bi = Vec::new();
@@ -436,7 +473,8 @@ impl TextGenerator for PhoneticGenerator {
}
words.push(word);
} else {
let word = self.generate_phonetic_word(filter, focused_char, focused_bigram);
let word =
self.generate_phonetic_word(filter, focused_char, focused_bigram, &starters);
recent.push(word.clone());
if recent.len() > dedup_window {
recent.remove(0);
@@ -456,13 +494,13 @@ mod tests {
#[test]
fn focused_key_biases_real_word_sampling() {
let dictionary = Dictionary::load();
let table = TransitionTable::build_from_words(&dictionary.words_list());
let dictionary = Dictionary::load_for_language("en");
let table = TransitionTable::build_from_words(dictionary.words_list());
let filter = CharFilter::new(('a'..='z').collect());
let mut focused_gen = PhoneticGenerator::new(
table.clone(),
Dictionary::load(),
Dictionary::load_for_language("en"),
SmallRng::seed_from_u64(42),
HashSet::new(),
);
@@ -474,7 +512,7 @@ mod tests {
let mut baseline_gen = PhoneticGenerator::new(
table,
Dictionary::load(),
Dictionary::load_for_language("en"),
SmallRng::seed_from_u64(42),
HashSet::new(),
);
@@ -492,13 +530,13 @@ mod tests {
#[test]
fn test_phonetic_bigram_focus_increases_bigram_words() {
let dictionary = Dictionary::load();
let table = TransitionTable::build_from_words(&dictionary.words_list());
let dictionary = Dictionary::load_for_language("en");
let table = TransitionTable::build_from_words(dictionary.words_list());
let filter = CharFilter::new(('a'..='z').collect());
let mut bigram_gen = PhoneticGenerator::new(
table.clone(),
Dictionary::load(),
Dictionary::load_for_language("en"),
SmallRng::seed_from_u64(42),
HashSet::new(),
);
@@ -510,7 +548,7 @@ mod tests {
let mut baseline_gen = PhoneticGenerator::new(
table,
Dictionary::load(),
Dictionary::load_for_language("en"),
SmallRng::seed_from_u64(42),
HashSet::new(),
);
@@ -528,13 +566,13 @@ mod tests {
#[test]
fn test_phonetic_dual_focus_no_excessive_repeats() {
let dictionary = Dictionary::load();
let table = TransitionTable::build_from_words(&dictionary.words_list());
let dictionary = Dictionary::load_for_language("en");
let table = TransitionTable::build_from_words(dictionary.words_list());
let filter = CharFilter::new(('a'..='z').collect());
let mut generator = PhoneticGenerator::new(
table,
Dictionary::load(),
Dictionary::load_for_language("en"),
SmallRng::seed_from_u64(42),
HashSet::new(),
);
@@ -561,8 +599,8 @@ mod tests {
#[test]
fn cross_drill_history_suppresses_repeats() {
let dictionary = Dictionary::load();
let table = TransitionTable::build_from_words(&dictionary.words_list());
let dictionary = Dictionary::load_for_language("en");
let table = TransitionTable::build_from_words(dictionary.words_list());
// Use a filter yielding a pool above FULL_DICT_THRESHOLD so dict_ratio=1.0
// (all words are dictionary picks, maximizing history suppression signal).
// Focus on 'k' to constrain the effective tier pool further.
@@ -575,7 +613,7 @@ mod tests {
// Drill 1: generate words and collect the set
let mut gen1 = PhoneticGenerator::new(
table.clone(),
Dictionary::load(),
Dictionary::load_for_language("en"),
SmallRng::seed_from_u64(100),
HashSet::new(),
);
@@ -585,7 +623,7 @@ mod tests {
// Drill 2 without history (baseline)
let mut gen2_no_hist = PhoneticGenerator::new(
table.clone(),
Dictionary::load(),
Dictionary::load_for_language("en"),
SmallRng::seed_from_u64(200),
HashSet::new(),
);
@@ -601,7 +639,7 @@ mod tests {
// Drill 2 with history from drill 1
let mut gen2_with_hist = PhoneticGenerator::new(
table.clone(),
Dictionary::load(),
Dictionary::load_for_language("en"),
SmallRng::seed_from_u64(200),
words1.clone(),
);
@@ -626,8 +664,8 @@ mod tests {
#[test]
fn hybrid_mode_produces_mixed_output() {
let dictionary = Dictionary::load();
let table = TransitionTable::build_from_words(&dictionary.words_list());
let dictionary = Dictionary::load_for_language("en");
let table = TransitionTable::build_from_words(dictionary.words_list());
// Use a constrained filter to get a pool in the hybrid range (8-60).
let allowed: Vec<char> = "abcdef ".chars().collect();
let filter = CharFilter::new(allowed);
@@ -647,7 +685,7 @@ mod tests {
let mut generator = PhoneticGenerator::new(
table,
Dictionary::load(),
Dictionary::load_for_language("en"),
SmallRng::seed_from_u64(42),
HashSet::new(),
);
@@ -676,8 +714,8 @@ mod tests {
#[test]
fn boundary_phonetic_only_below_threshold() {
let dictionary = Dictionary::load();
let table = TransitionTable::build_from_words(&dictionary.words_list());
let dictionary = Dictionary::load_for_language("en");
let table = TransitionTable::build_from_words(dictionary.words_list());
// Very small filter — should yield < MIN_REAL_WORDS (8) dictionary matches.
// With pool < MIN_REAL_WORDS, use_dict=false so 0% intentional dictionary
// selections (the code never enters pick_tiered_word).
@@ -697,7 +735,7 @@ mod tests {
let mut generator = PhoneticGenerator::new(
table,
Dictionary::load(),
Dictionary::load_for_language("en"),
SmallRng::seed_from_u64(42),
HashSet::new(),
);
@@ -720,8 +758,8 @@ mod tests {
#[test]
fn boundary_full_dict_above_threshold() {
let dictionary = Dictionary::load();
let table = TransitionTable::build_from_words(&dictionary.words_list());
let dictionary = Dictionary::load_for_language("en");
let table = TransitionTable::build_from_words(dictionary.words_list());
// Full alphabet — should yield 100+ dictionary matches
let filter = CharFilter::new(('a'..='z').collect());
@@ -741,7 +779,7 @@ mod tests {
// All picks come from matching_words → 100% dictionary.
let mut generator = PhoneticGenerator::new(
table,
Dictionary::load(),
Dictionary::load_for_language("en"),
SmallRng::seed_from_u64(42),
HashSet::new(),
);
@@ -759,8 +797,8 @@ mod tests {
#[test]
fn weighted_suppression_graceful_degradation() {
let dictionary = Dictionary::load();
let table = TransitionTable::build_from_words(&dictionary.words_list());
let dictionary = Dictionary::load_for_language("en");
let table = TransitionTable::build_from_words(dictionary.words_list());
// Use a small filter to get a small pool
let allowed: Vec<char> = "abcdefghijk ".chars().collect();
let filter = CharFilter::new(allowed);
@@ -780,7 +818,7 @@ mod tests {
let mut generator = PhoneticGenerator::new(
table,
Dictionary::load(),
Dictionary::load_for_language("en"),
SmallRng::seed_from_u64(42),
history.clone(),
);

View File

@@ -49,10 +49,7 @@ impl TransitionTable {
let prefix_len = 3; // order - 1
for (rank, word) in words.iter().enumerate() {
if word.len() < 3 {
continue;
}
if !word.chars().all(|c| c.is_ascii_lowercase()) {
if word.chars().count() < 3 {
continue;
}
@@ -238,3 +235,48 @@ impl Default for TransitionTable {
Self::new(4)
}
}
#[cfg(test)]
mod tests {
use super::TransitionTable;
#[test]
fn build_from_words_supports_multibyte_utf8_words() {
let words = vec![
"árvore".to_string(),
"über".to_string(),
"mañana".to_string(),
"český".to_string(),
];
let table = TransitionTable::build_from_words(&words);
let start_prefix = vec![' ', ' ', ' '];
let segment = table
.segment(&start_prefix)
.expect("expected start transitions");
assert!(
segment
.iter()
.any(|(ch, _)| ['á', 'ü', 'm', 'č'].contains(ch)),
"expected UTF-8 word starts in transition table"
);
}
#[test]
fn segment_backoff_works_with_unicode_prefixes() {
let mut table = TransitionTable::new(4);
table.add(&['ü'], 'b', 1.0);
// Prefix length is intentionally longer than order-1; `segment` should back off.
let query_prefix = vec!['x', 'x', 'ü'];
let segment = table
.segment(&query_prefix)
.expect("expected backoff match for unicode prefix");
assert!(
segment.iter().any(|(ch, _)| *ch == 'b'),
"expected continuation for 'ü' prefix"
);
}
}

View File

@@ -1,51 +0,0 @@
use serde::{Deserialize, Serialize};
#[allow(dead_code)]
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct KeyboardLayout {
pub name: String,
pub rows: Vec<Vec<char>>,
}
impl KeyboardLayout {
pub fn qwerty() -> Self {
Self {
name: "QWERTY".to_string(),
rows: vec![
vec!['q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p'],
vec!['a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l'],
vec!['z', 'x', 'c', 'v', 'b', 'n', 'm'],
],
}
}
#[allow(dead_code)]
pub fn dvorak() -> Self {
Self {
name: "Dvorak".to_string(),
rows: vec![
vec!['\'', ',', '.', 'p', 'y', 'f', 'g', 'c', 'r', 'l'],
vec!['a', 'o', 'e', 'u', 'i', 'd', 'h', 't', 'n', 's'],
vec![';', 'q', 'j', 'k', 'x', 'b', 'm', 'w', 'v', 'z'],
],
}
}
#[allow(dead_code)]
pub fn colemak() -> Self {
Self {
name: "Colemak".to_string(),
rows: vec![
vec!['q', 'w', 'f', 'p', 'g', 'j', 'l', 'u', 'y'],
vec!['a', 'r', 's', 't', 'd', 'h', 'n', 'e', 'i', 'o'],
vec!['z', 'x', 'c', 'v', 'b', 'k', 'm'],
],
}
}
}
impl Default for KeyboardLayout {
fn default() -> Self {
Self::qwerty()
}
}

View File

@@ -1,4 +1,3 @@
pub mod display;
pub mod finger;
pub mod layout;
pub mod model;

File diff suppressed because it is too large Load Diff

608
src/l10n/language_pack.rs Normal file
View File

@@ -0,0 +1,608 @@
#![allow(dead_code)] // TODO(phase 1+): remove when all language-pack fields are consumed by runtime/UI.
use std::fmt;
use std::sync::OnceLock;
use crate::keyboard::model::KeyboardModel;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Script {
Latin,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SupportLevel {
Full,
Blocked,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CapabilityState {
Enabled,
// Reserved for selector UIs that show but disable unsupported entries.
// Validation APIs still return typed errors for disabled combinations.
Disabled,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum LanguageLayoutValidationError {
UnknownLanguage(String),
UnknownLayout(String),
UnsupportedLanguageLayoutPair {
language_key: String,
layout_key: String,
},
LanguageBlockedBySupportLevel(String),
}
impl fmt::Display for LanguageLayoutValidationError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::UnknownLanguage(key) => write!(f, "Unknown language: {key}"),
Self::UnknownLayout(key) => write!(f, "Unknown keyboard layout: {key}"),
Self::UnsupportedLanguageLayoutPair {
language_key,
layout_key,
} => write!(
f,
"Unsupported language/layout pair: {language_key} + {layout_key}"
),
Self::LanguageBlockedBySupportLevel(key) => {
write!(f, "Language is blocked by support level: {key}")
}
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RankedReadinessError {
InvalidLanguageLayout(LanguageLayoutValidationError),
MissingPrimaryLetterSequence(String),
}
impl fmt::Display for RankedReadinessError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidLanguageLayout(err) => write!(f, "{err}"),
Self::MissingPrimaryLetterSequence(language_key) => {
write!(
f,
"Language '{language_key}' has no usable primary letter sequence"
)
}
}
}
}
#[derive(Clone, Copy, Debug)]
pub struct LanguagePack {
pub language_key: &'static str,
pub display_name: &'static str,
pub script: Script,
pub dictionary_asset_id: &'static str,
pub supported_keyboard_layout_keys: &'static [&'static str],
pub primary_letter_sequence: &'static str,
pub support_level: SupportLevel,
}
pub const DEFAULT_LATIN_PRIMARY_SEQUENCE: &str = "etaoinshrdlcumwfgypbvkjxqz";
const DE_PRIMARY_SEQUENCE: &str = "entrishlagcubdmfokwzüpävößjqxy";
const ES_PRIMARY_SEQUENCE: &str = "aerosintcdlmupbgvófhíjáézqyxñú";
const FR_PRIMARY_SEQUENCE: &str = "erisantoucélpmdvgfbhqzxèyjç";
const IT_PRIMARY_SEQUENCE: &str = "aieortnsclmpdugvfbzhqkyxjw";
const PT_PRIMARY_SEQUENCE: &str = "aeorsitncdmulpvgbfhçãáqíxzjéóõêúâôà";
const NL_PRIMARY_SEQUENCE: &str = "enratiosldgkvuhpmbcjwfzyxq";
const SV_PRIMARY_SEQUENCE: &str = "aertnsldkigoämvbfuöphåyjcxw";
const DA_PRIMARY_SEQUENCE: &str = "ertnsildagokmfvubpæhøyjåcwzxq";
const NB_PRIMARY_SEQUENCE: &str = "ertnsilakogdmpvfubjøyhåæcw";
const FI_PRIMARY_SEQUENCE: &str = "aitneslkuäomvrphyjdögfbcwxzq";
const PL_PRIMARY_SEQUENCE: &str = "aiezornwsycpdkmtułjlbęgćąśhóżfńź";
const CS_PRIMARY_SEQUENCE: &str = "oelantipvdsurmkhíázcěbyřjčýšéžůúfťgňďxó";
const RO_PRIMARY_SEQUENCE: &str = "eiartnuclosăpmdgvbzfîâhjțșx";
const HR_PRIMARY_SEQUENCE: &str = "aitoernspjlkuvdmzbgcčšžćhfđ";
const HU_PRIMARY_SEQUENCE: &str = "etalnskriozáémgdvbyjhpuföóőícüúűwxq";
const LT_PRIMARY_SEQUENCE: &str = "iasteuknrolmpdvgėjyšbžąųįūčęzcfh";
const LV_PRIMARY_SEQUENCE: &str = "asiternlkopmuādīvzēgjbcšfūņļķģžhč";
const SL_PRIMARY_SEQUENCE: &str = "aeiotnrsvpkldjzmučbgcšžhf";
const ET_PRIMARY_SEQUENCE: &str = "aeistulmnkrovpdhgäjõüböfš";
const TR_PRIMARY_SEQUENCE: &str = "aeinrlımkdysutobşzügğcçöhpvfj";
const EN_LAYOUTS: &[&str] = &["qwerty", "dvorak", "colemak"];
const DE_LAYOUTS: &[&str] = &["de_qwertz", "qwerty"];
const FR_LAYOUTS: &[&str] = &["fr_azerty", "qwerty"];
const ES_LAYOUTS: &[&str] = &["es_intl", "qwerty"];
const IT_LAYOUTS: &[&str] = &["it_intl", "qwerty"];
const PT_LAYOUTS: &[&str] = &["pt_intl", "qwerty"];
const NL_LAYOUTS: &[&str] = &["nl_intl", "qwerty"];
const SV_LAYOUTS: &[&str] = &["sv_intl", "qwerty"];
const DA_LAYOUTS: &[&str] = &["da_intl", "qwerty"];
const NB_LAYOUTS: &[&str] = &["nb_intl", "qwerty"];
const FI_LAYOUTS: &[&str] = &["fi_intl", "qwerty"];
const PL_LAYOUTS: &[&str] = &["pl_intl", "qwerty"];
const CS_LAYOUTS: &[&str] = &["cs_intl", "qwerty"];
const RO_LAYOUTS: &[&str] = &["ro_intl", "qwerty"];
const HR_LAYOUTS: &[&str] = &["hr_intl", "qwerty"];
const HU_LAYOUTS: &[&str] = &["hu_intl", "qwerty"];
const LT_LAYOUTS: &[&str] = &["lt_intl", "qwerty"];
const LV_LAYOUTS: &[&str] = &["lv_intl", "qwerty"];
const SL_LAYOUTS: &[&str] = &["sl_intl", "qwerty"];
const ET_LAYOUTS: &[&str] = &["et_intl", "qwerty"];
const TR_LAYOUTS: &[&str] = &["tr_intl", "qwerty"];
// Seed registry for phase 0. Support levels will be tightened as keyboard
// profiles and Unicode handling phases are implemented.
static LANGUAGE_PACKS: &[LanguagePack] = &[
LanguagePack {
language_key: "en",
display_name: "English",
script: Script::Latin,
dictionary_asset_id: "words-en",
supported_keyboard_layout_keys: EN_LAYOUTS,
primary_letter_sequence: DEFAULT_LATIN_PRIMARY_SEQUENCE,
support_level: SupportLevel::Full,
},
LanguagePack {
language_key: "de",
display_name: "German",
script: Script::Latin,
dictionary_asset_id: "words-de",
supported_keyboard_layout_keys: DE_LAYOUTS,
primary_letter_sequence: DE_PRIMARY_SEQUENCE,
support_level: SupportLevel::Full,
},
LanguagePack {
language_key: "es",
display_name: "Spanish",
script: Script::Latin,
dictionary_asset_id: "words-es",
supported_keyboard_layout_keys: ES_LAYOUTS,
primary_letter_sequence: ES_PRIMARY_SEQUENCE,
support_level: SupportLevel::Full,
},
LanguagePack {
language_key: "fr",
display_name: "French",
script: Script::Latin,
dictionary_asset_id: "words-fr",
supported_keyboard_layout_keys: FR_LAYOUTS,
primary_letter_sequence: FR_PRIMARY_SEQUENCE,
support_level: SupportLevel::Full,
},
LanguagePack {
language_key: "it",
display_name: "Italian",
script: Script::Latin,
dictionary_asset_id: "words-it",
supported_keyboard_layout_keys: IT_LAYOUTS,
primary_letter_sequence: IT_PRIMARY_SEQUENCE,
support_level: SupportLevel::Full,
},
LanguagePack {
language_key: "pt",
display_name: "Portuguese",
script: Script::Latin,
dictionary_asset_id: "words-pt",
supported_keyboard_layout_keys: PT_LAYOUTS,
primary_letter_sequence: PT_PRIMARY_SEQUENCE,
support_level: SupportLevel::Full,
},
LanguagePack {
language_key: "nl",
display_name: "Dutch",
script: Script::Latin,
dictionary_asset_id: "words-nl",
supported_keyboard_layout_keys: NL_LAYOUTS,
primary_letter_sequence: NL_PRIMARY_SEQUENCE,
support_level: SupportLevel::Full,
},
LanguagePack {
language_key: "sv",
display_name: "Swedish",
script: Script::Latin,
dictionary_asset_id: "words-sv",
supported_keyboard_layout_keys: SV_LAYOUTS,
primary_letter_sequence: SV_PRIMARY_SEQUENCE,
support_level: SupportLevel::Full,
},
LanguagePack {
language_key: "da",
display_name: "Danish",
script: Script::Latin,
dictionary_asset_id: "words-da",
supported_keyboard_layout_keys: DA_LAYOUTS,
primary_letter_sequence: DA_PRIMARY_SEQUENCE,
support_level: SupportLevel::Full,
},
LanguagePack {
language_key: "nb",
display_name: "Norwegian Bokmal",
script: Script::Latin,
dictionary_asset_id: "words-nb",
supported_keyboard_layout_keys: NB_LAYOUTS,
primary_letter_sequence: NB_PRIMARY_SEQUENCE,
support_level: SupportLevel::Full,
},
LanguagePack {
language_key: "fi",
display_name: "Finnish",
script: Script::Latin,
dictionary_asset_id: "words-fi",
supported_keyboard_layout_keys: FI_LAYOUTS,
primary_letter_sequence: FI_PRIMARY_SEQUENCE,
support_level: SupportLevel::Full,
},
LanguagePack {
language_key: "pl",
display_name: "Polish",
script: Script::Latin,
dictionary_asset_id: "words-pl",
supported_keyboard_layout_keys: PL_LAYOUTS,
primary_letter_sequence: PL_PRIMARY_SEQUENCE,
support_level: SupportLevel::Full,
},
LanguagePack {
language_key: "cs",
display_name: "Czech",
script: Script::Latin,
dictionary_asset_id: "words-cs",
supported_keyboard_layout_keys: CS_LAYOUTS,
primary_letter_sequence: CS_PRIMARY_SEQUENCE,
support_level: SupportLevel::Full,
},
LanguagePack {
language_key: "ro",
display_name: "Romanian",
script: Script::Latin,
dictionary_asset_id: "words-ro",
supported_keyboard_layout_keys: RO_LAYOUTS,
primary_letter_sequence: RO_PRIMARY_SEQUENCE,
support_level: SupportLevel::Full,
},
LanguagePack {
language_key: "hr",
display_name: "Croatian",
script: Script::Latin,
dictionary_asset_id: "words-hr",
supported_keyboard_layout_keys: HR_LAYOUTS,
primary_letter_sequence: HR_PRIMARY_SEQUENCE,
support_level: SupportLevel::Full,
},
LanguagePack {
language_key: "hu",
display_name: "Hungarian",
script: Script::Latin,
dictionary_asset_id: "words-hu",
supported_keyboard_layout_keys: HU_LAYOUTS,
primary_letter_sequence: HU_PRIMARY_SEQUENCE,
support_level: SupportLevel::Full,
},
LanguagePack {
language_key: "lt",
display_name: "Lithuanian",
script: Script::Latin,
dictionary_asset_id: "words-lt",
supported_keyboard_layout_keys: LT_LAYOUTS,
primary_letter_sequence: LT_PRIMARY_SEQUENCE,
support_level: SupportLevel::Full,
},
LanguagePack {
language_key: "lv",
display_name: "Latvian",
script: Script::Latin,
dictionary_asset_id: "words-lv",
supported_keyboard_layout_keys: LV_LAYOUTS,
primary_letter_sequence: LV_PRIMARY_SEQUENCE,
support_level: SupportLevel::Full,
},
LanguagePack {
language_key: "sl",
display_name: "Slovene",
script: Script::Latin,
dictionary_asset_id: "words-sl",
supported_keyboard_layout_keys: SL_LAYOUTS,
primary_letter_sequence: SL_PRIMARY_SEQUENCE,
support_level: SupportLevel::Full,
},
LanguagePack {
language_key: "et",
display_name: "Estonian",
script: Script::Latin,
dictionary_asset_id: "words-et",
supported_keyboard_layout_keys: ET_LAYOUTS,
primary_letter_sequence: ET_PRIMARY_SEQUENCE,
support_level: SupportLevel::Full,
},
LanguagePack {
language_key: "tr",
display_name: "Turkish",
script: Script::Latin,
dictionary_asset_id: "words-tr",
supported_keyboard_layout_keys: TR_LAYOUTS,
primary_letter_sequence: TR_PRIMARY_SEQUENCE,
support_level: SupportLevel::Full,
},
];
pub fn language_packs() -> &'static [LanguagePack] {
LANGUAGE_PACKS
}
pub fn find_language_pack(language_key: &str) -> Option<&'static LanguagePack> {
LANGUAGE_PACKS
.iter()
.find(|pack| pack.language_key == language_key)
}
pub fn supported_dictionary_languages() -> &'static [&'static str] {
static SUPPORTED: OnceLock<Vec<&'static str>> = OnceLock::new();
SUPPORTED
.get_or_init(|| {
LANGUAGE_PACKS
.iter()
.filter(|pack| matches!(pack.support_level, SupportLevel::Full))
.map(|pack| pack.language_key)
.collect()
})
.as_slice()
}
pub fn dictionary_languages_for_layout(layout_key: &str) -> Vec<&'static str> {
LANGUAGE_PACKS
.iter()
.filter_map(
|pack| match validate_language_layout_pair(pack.language_key, layout_key) {
Ok(CapabilityState::Enabled) => Some(pack.language_key),
_ => None,
},
)
.collect()
}
pub fn default_keyboard_layout_for_language(language_key: &str) -> Option<&'static str> {
let pack = find_language_pack(language_key)?;
pack.supported_keyboard_layout_keys.first().copied()
}
pub fn validate_language_layout_pair(
language_key: &str,
layout_key: &str,
) -> Result<CapabilityState, LanguageLayoutValidationError> {
let Some(pack) = find_language_pack(language_key) else {
return Err(LanguageLayoutValidationError::UnknownLanguage(
language_key.to_string(),
));
};
if !KeyboardModel::supported_layout_keys().contains(&layout_key) {
return Err(LanguageLayoutValidationError::UnknownLayout(
layout_key.to_string(),
));
}
if matches!(pack.support_level, SupportLevel::Blocked) {
return Err(
LanguageLayoutValidationError::LanguageBlockedBySupportLevel(language_key.to_string()),
);
}
Ok(CapabilityState::Enabled)
}
pub fn normalized_primary_letter_sequence(sequence: &str) -> Vec<char> {
let mut out = Vec::new();
for ch in sequence.chars().filter(|ch| ch.is_alphabetic()) {
if !out.contains(&ch) {
out.push(ch);
}
}
out
}
pub fn has_usable_primary_letter_sequence(sequence: &str) -> bool {
!normalized_primary_letter_sequence(sequence).is_empty()
}
pub fn ranked_adaptive_readiness(
language_key: &str,
layout_key: &str,
) -> Result<(), RankedReadinessError> {
validate_language_layout_pair(language_key, layout_key)
.map_err(RankedReadinessError::InvalidLanguageLayout)?;
let Some(pack) = find_language_pack(language_key) else {
return Err(RankedReadinessError::InvalidLanguageLayout(
LanguageLayoutValidationError::UnknownLanguage(language_key.to_string()),
));
};
if !has_usable_primary_letter_sequence(pack.primary_letter_sequence) {
return Err(RankedReadinessError::MissingPrimaryLetterSequence(
language_key.to_string(),
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use super::*;
fn enabled_pairs() -> Vec<(&'static str, &'static str)> {
let mut pairs = Vec::new();
for pack in language_packs() {
for &layout_key in KeyboardModel::supported_layout_keys() {
if matches!(
validate_language_layout_pair(pack.language_key, layout_key),
Ok(CapabilityState::Enabled)
) {
pairs.push((pack.language_key, layout_key));
}
}
}
pairs
}
#[test]
fn language_pack_keys_are_unique() {
let mut seen = HashSet::new();
for pack in language_packs() {
assert!(seen.insert(pack.language_key));
assert!(pack.primary_letter_sequence.len() >= 10);
assert!(!pack.dictionary_asset_id.is_empty());
assert!(!pack.supported_keyboard_layout_keys.is_empty());
assert!(matches!(pack.script, Script::Latin));
}
}
#[test]
fn english_pack_exists_and_is_full() {
let en = find_language_pack("en").expect("missing en language pack");
assert_eq!(en.support_level, SupportLevel::Full);
assert_eq!(en.primary_letter_sequence, DEFAULT_LATIN_PRIMARY_SEQUENCE);
assert!(en.primary_letter_sequence.starts_with("etaoin"));
}
#[test]
fn german_pack_primary_sequence_contains_locale_letters() {
let de = find_language_pack("de").expect("missing de language pack");
assert!(de.primary_letter_sequence.contains('ä'));
assert!(de.primary_letter_sequence.contains('ö'));
assert!(de.primary_letter_sequence.contains('ü'));
assert!(de.primary_letter_sequence.contains('ß'));
}
#[test]
fn non_english_packs_have_language_specific_primary_sequences() {
for pack in language_packs() {
if pack.language_key == "en" {
continue;
}
assert_ne!(
pack.primary_letter_sequence, DEFAULT_LATIN_PRIMARY_SEQUENCE,
"language {} should not reuse default English sequence",
pack.language_key
);
}
}
#[test]
fn locale_letters_are_typeable_on_language_native_layouts() {
for pack in language_packs() {
if pack.language_key == "en" {
continue;
}
let native_layout_key = match pack.language_key {
"de" => "de_qwertz".to_string(),
"fr" => "fr_azerty".to_string(),
key => format!("{key}_intl"),
};
let model = KeyboardModel::from_key(&native_layout_key)
.expect("native layout key should map to a keyboard model");
for ch in normalized_primary_letter_sequence(pack.primary_letter_sequence) {
if ch.is_ascii_lowercase() {
continue;
}
assert!(
model.physical_key_for(ch).is_some(),
"native layout {} should type locale letter '{}' for language {}",
native_layout_key,
ch,
pack.language_key
);
}
}
}
#[test]
fn supported_dictionary_languages_are_registry_backed() {
for key in supported_dictionary_languages() {
assert!(find_language_pack(key).is_some());
}
}
#[test]
fn supported_dictionary_languages_include_non_english_languages() {
let supported = supported_dictionary_languages();
assert!(supported.contains(&"en"));
assert!(supported.contains(&"de"));
assert!(supported.contains(&"es"));
}
#[test]
fn validate_language_layout_pair_unknown_language() {
let err = validate_language_layout_pair("zz", "qwerty").unwrap_err();
assert!(matches!(
err,
LanguageLayoutValidationError::UnknownLanguage(_)
));
}
#[test]
fn validate_language_layout_pair_unknown_layout() {
let err = validate_language_layout_pair("en", "foo").unwrap_err();
assert!(matches!(
err,
LanguageLayoutValidationError::UnknownLayout(_)
));
}
#[test]
fn validate_language_layout_pair_allows_cross_language_layout_pair() {
let state = validate_language_layout_pair("en", "de_qwertz")
.expect("cross-language/layout pair should be allowed");
assert_eq!(state, CapabilityState::Enabled);
}
#[test]
fn dictionary_languages_for_layout_qwerty_contains_english() {
let keys = dictionary_languages_for_layout("qwerty");
assert!(keys.contains(&"en"));
}
#[test]
fn dictionary_languages_for_layout_contains_full_language_set_for_supported_layouts() {
let de = dictionary_languages_for_layout("de_qwertz");
assert_eq!(de.len(), supported_dictionary_languages().len());
assert!(de.contains(&"de"));
let fr = dictionary_languages_for_layout("fr_azerty");
assert_eq!(fr.len(), supported_dictionary_languages().len());
assert!(fr.contains(&"fr"));
}
#[test]
fn normalized_primary_sequence_filters_non_letters_and_dedupes() {
assert_eq!(
normalized_primary_letter_sequence("a1áa!bB"),
vec!['a', 'á', 'b', 'B']
);
}
#[test]
fn usable_primary_sequence_requires_at_least_one_letter() {
assert!(!has_usable_primary_letter_sequence("12345!?"));
assert!(has_usable_primary_letter_sequence("é"));
}
#[test]
fn ranked_adaptive_readiness_rejects_invalid_layout() {
let err = ranked_adaptive_readiness("en", "not_a_layout").unwrap_err();
assert!(matches!(
err,
RankedReadinessError::InvalidLanguageLayout(
LanguageLayoutValidationError::UnknownLayout(_)
)
));
}
#[test]
fn ranked_adaptive_readiness_accepts_all_enabled_pairs() {
for (language_key, layout_key) in enabled_pairs() {
assert!(
ranked_adaptive_readiness(language_key, layout_key).is_ok(),
"expected readiness for pair: {language_key}+{layout_key}"
);
}
}
}

2
src/l10n/mod.rs Normal file
View File

@@ -0,0 +1,2 @@
pub mod language_pack;
pub mod unicode;

25
src/l10n/unicode.rs Normal file
View File

@@ -0,0 +1,25 @@
use icu_normalizer::ComposingNormalizerBorrowed;
pub fn normalize_nfc(input: &str) -> String {
ComposingNormalizerBorrowed::new_nfc()
.normalize(input)
.into_owned()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalize_nfc_composes_equivalent_unicode_sequences() {
let composed = "é";
let decomposed = "e\u{0301}";
assert_eq!(normalize_nfc(composed), normalize_nfc(decomposed));
}
#[test]
fn normalize_nfc_is_stable_for_precomposed_and_ascii() {
assert_eq!(normalize_nfc("Árvíztűrő"), "Árvíztűrő");
assert_eq!(normalize_nfc("abc"), "abc");
}
}

View File

@@ -8,6 +8,7 @@
pub mod config;
pub mod engine;
pub mod keyboard;
pub mod l10n;
pub mod session;
pub mod store;

File diff suppressed because it is too large Load Diff

View File

@@ -16,6 +16,13 @@ pub struct JsonStore {
}
impl JsonStore {
const STORE_FILES: [&'static str; 4] = [
"profile.json",
"key_stats.json",
"key_stats_ranked.json",
"lesson_history.json",
];
pub fn new() -> Result<Self> {
let base_dir = dirs::data_dir()
.unwrap_or_else(|| PathBuf::from("."))
@@ -34,6 +41,31 @@ impl JsonStore {
self.base_dir.join(name)
}
pub fn archive_legacy_data_files(&self) {
for name in Self::STORE_FILES {
let path = self.file_path(name);
if !path.exists() {
continue;
}
let legacy_path = self.file_path(&format!("{name}.legacy"));
if let Err(e) = fs::remove_file(&legacy_path)
&& e.kind() != std::io::ErrorKind::NotFound
{
eprintln!(
"warning: failed to remove old legacy archive {}: {e}",
legacy_path.display()
);
}
if let Err(e) = fs::rename(&path, &legacy_path) {
eprintln!(
"warning: failed to archive legacy store file {} -> {}: {e}",
path.display(),
legacy_path.display()
);
}
}
}
fn load<T: DeserializeOwned + Default>(&self, name: &str) -> T {
let path = self.file_path(name);
if path.exists() {
@@ -236,15 +268,9 @@ impl JsonStore {
/// Check for leftover .bak files from an interrupted import.
/// Returns true if recovery files were found (and cleaned up).
pub fn check_interrupted_import(&self) -> bool {
let bak_names = [
"profile.json.bak",
"key_stats.json.bak",
"key_stats_ranked.json.bak",
"lesson_history.json.bak",
];
let mut found = false;
for name in &bak_names {
let bak_path = self.base_dir.join(name);
for name in Self::STORE_FILES {
let bak_path = self.file_path(&format!("{name}.bak"));
if bak_path.exists() {
found = true;
let _ = fs::remove_file(&bak_path);
@@ -404,4 +430,19 @@ mod tests {
// Should have been cleaned up
assert!(!store.file_path("profile.json.bak").exists());
}
#[test]
fn test_archive_legacy_data_files_renames_known_store_files() {
let (_dir, store) = make_test_store();
fs::write(store.file_path("profile.json"), "{}").unwrap();
fs::write(store.file_path("key_stats.json"), "{}").unwrap();
store.archive_legacy_data_files();
assert!(!store.file_path("profile.json").exists());
assert!(store.file_path("profile.json.legacy").exists());
assert!(!store.file_path("key_stats.json").exists());
assert!(store.file_path("key_stats.json.legacy").exists());
}
}

View File

@@ -1,17 +1,23 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use crate::config::Config;
use crate::engine::key_stats::KeyStatsStore;
use crate::engine::skill_tree::SkillTreeProgress;
use crate::session::result::DrillResult;
const SCHEMA_VERSION: u32 = 2;
pub const SCHEMA_VERSION: u32 = 3;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ProfileData {
pub schema_version: u32,
/// Legacy single-scope progress mirror retained for import/export compatibility.
/// Always write this via `set_skill_tree_for_language`, never directly.
pub skill_tree: SkillTreeProgress,
/// Language-scoped skill tree progression state keyed by dictionary language.
#[serde(default)]
pub skill_tree_by_language: HashMap<String, SkillTreeProgress>,
pub total_score: f64,
#[serde(alias = "total_lessons")]
pub total_drills: u32,
@@ -25,6 +31,7 @@ impl Default for ProfileData {
Self {
schema_version: SCHEMA_VERSION,
skill_tree: SkillTreeProgress::default(),
skill_tree_by_language: HashMap::new(),
total_score: 0.0,
total_drills: 0,
streak_days: 0,
@@ -39,6 +46,20 @@ impl ProfileData {
pub fn needs_reset(&self) -> bool {
self.schema_version != SCHEMA_VERSION
}
pub fn skill_tree_for_language(&self, language_key: &str) -> SkillTreeProgress {
self.skill_tree_by_language
.get(language_key)
.cloned()
.unwrap_or_else(|| self.skill_tree.clone())
}
pub fn set_skill_tree_for_language(&mut self, language_key: &str, progress: SkillTreeProgress) {
self.skill_tree_by_language
.insert(language_key.to_string(), progress.clone());
// Keep legacy mirror aligned with the current active scope.
self.skill_tree = progress;
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
@@ -87,3 +108,50 @@ pub struct ExportData {
pub ranked_key_stats: KeyStatsData,
pub drill_history: DrillHistoryData,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn profile_skill_tree_for_language_falls_back_to_legacy() {
let profile = ProfileData::default();
let scoped = profile.skill_tree_for_language("de");
let lowercase = scoped
.branches
.get("lowercase")
.expect("lowercase branch should exist");
assert_eq!(lowercase.current_level, 0);
}
#[test]
fn profile_set_skill_tree_for_language_updates_scoped_map() {
let mut profile = ProfileData::default();
let mut progress = SkillTreeProgress::default();
progress
.branches
.get_mut("lowercase")
.expect("lowercase branch should exist")
.current_level = 3;
profile.set_skill_tree_for_language("de", progress.clone());
let loaded = profile.skill_tree_for_language("de");
assert_eq!(
loaded
.branches
.get("lowercase")
.expect("lowercase branch should exist")
.current_level,
3
);
assert_eq!(
profile
.skill_tree
.branches
.get("lowercase")
.expect("lowercase branch should exist")
.current_level,
3
);
}
}

View File

@@ -1,4 +1,5 @@
use std::collections::HashSet;
use std::collections::{HashMap, HashSet};
use std::sync::{Mutex, OnceLock};
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
@@ -6,7 +7,7 @@ use ratatui::style::{Color, Modifier, Style};
use ratatui::widgets::{Block, Widget};
use crate::keyboard::display::{self, BACKSPACE, ENTER, SPACE, TAB};
use crate::keyboard::model::KeyboardModel;
use crate::keyboard::model::{KeyboardModel, PhysicalKey};
use crate::ui::theme::Theme;
pub struct KeyboardDiagram<'a> {
@@ -21,6 +22,31 @@ pub struct KeyboardDiagram<'a> {
pub caps_lock: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
enum KeyboardRenderMode {
Compact,
Full,
FullFallback,
}
#[derive(Clone, Debug)]
struct KeyboardGeometry {
key_width: u16,
row_offsets: Vec<u16>,
keyboard_width: u16,
start_inset: u16,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
struct GeometryCacheKey {
layout_key: String,
mode: KeyboardRenderMode,
width: u16,
height: u16,
}
const MAX_GEOMETRY_CACHE_ENTRIES: usize = 128;
impl<'a> KeyboardDiagram<'a> {
pub fn new(
next_key: Option<char>,
@@ -73,6 +99,154 @@ impl<'a> KeyboardDiagram<'a> {
}
}
fn geometry_cache() -> &'static Mutex<HashMap<GeometryCacheKey, KeyboardGeometry>> {
static CACHE: OnceLock<Mutex<HashMap<GeometryCacheKey, KeyboardGeometry>>> = OnceLock::new();
CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}
fn rows_for_mode<'a>(model: &'a KeyboardModel, mode: KeyboardRenderMode) -> &'a [Vec<PhysicalKey>] {
match mode {
KeyboardRenderMode::Compact | KeyboardRenderMode::FullFallback => model.letter_rows(),
KeyboardRenderMode::Full => &model.rows,
}
}
fn render_mode_for(inner: Rect, compact: bool) -> KeyboardRenderMode {
if compact {
KeyboardRenderMode::Compact
} else if inner.height >= 4 && inner.width >= 75 {
KeyboardRenderMode::Full
} else {
KeyboardRenderMode::FullFallback
}
}
fn build_geometry(
inner: Rect,
model: &KeyboardModel,
mode: KeyboardRenderMode,
) -> Option<KeyboardGeometry> {
let rows = rows_for_mode(model, mode);
match mode {
KeyboardRenderMode::Compact => {
if inner.height < 3 || inner.width < 21 {
return None;
}
let key_width = 3;
let row_offsets = vec![3, 4, 6];
let keyboard_width = rows
.iter()
.enumerate()
.map(|(row_idx, row)| {
let offset = row_offsets.get(row_idx).copied().unwrap_or(0);
offset + row.len() as u16 * key_width + 3
})
.max()
.unwrap_or(0);
Some(KeyboardGeometry {
key_width,
row_offsets,
keyboard_width,
start_inset: inner.width.saturating_sub(keyboard_width) / 2,
})
}
KeyboardRenderMode::Full => {
if inner.height < 4 || inner.width < 75 {
return None;
}
let key_width = 5;
let row_offsets = vec![0, 5, 5, 6];
let keyboard_width = rows
.iter()
.enumerate()
.map(|(row_idx, row)| {
let offset = row_offsets.get(row_idx).copied().unwrap_or(0);
let row_end = offset + row.len() as u16 * key_width;
match row_idx {
0 => row_end + 6,
2 => row_end + 7,
3 => row_end + 6,
_ => row_end,
}
})
.max()
.unwrap_or(0);
Some(KeyboardGeometry {
key_width,
row_offsets,
keyboard_width,
start_inset: inner.width.saturating_sub(keyboard_width) / 2,
})
}
KeyboardRenderMode::FullFallback => {
if inner.height < 3 || inner.width < 30 {
return None;
}
let key_width = 5;
let row_offsets = vec![1, 3, 5];
let keyboard_width = rows
.iter()
.enumerate()
.map(|(row_idx, row)| {
let offset = row_offsets.get(row_idx).copied().unwrap_or(0);
offset + row.len() as u16 * key_width
})
.max()
.unwrap_or(0);
Some(KeyboardGeometry {
key_width,
row_offsets,
keyboard_width,
start_inset: inner.width.saturating_sub(keyboard_width) / 2,
})
}
}
}
fn geometry_for_mode(
inner: Rect,
model: &KeyboardModel,
mode: KeyboardRenderMode,
) -> Option<KeyboardGeometry> {
let key = GeometryCacheKey {
layout_key: model.layout_key.to_string(),
mode,
width: inner.width,
height: inner.height,
};
if let Some(geom) = geometry_cache()
.lock()
.expect("keyboard geometry cache poisoned")
.get(&key)
.cloned()
{
return Some(geom);
}
let built = build_geometry(inner, model, mode)?;
let mut cache = geometry_cache()
.lock()
.expect("keyboard geometry cache poisoned");
if cache.len() >= MAX_GEOMETRY_CACHE_ENTRIES {
// Bounded cache: simple full-clear avoids unbounded growth across long resize sessions.
cache.clear();
}
cache.insert(key, built.clone());
Some(built)
}
fn geometry_for(inner: Rect, model: &KeyboardModel, compact: bool) -> Option<KeyboardGeometry> {
geometry_for_mode(inner, model, render_mode_for(inner, compact))
}
fn show_shifted_for_key(key: &PhysicalKey, shift_held: bool, caps_lock: bool) -> bool {
if key.base.is_alphabetic() {
shift_held ^ caps_lock
} else {
shift_held
}
}
fn brighten_color(color: Color) -> Color {
match color {
Color::Rgb(r, g, b) => Color::Rgb(
@@ -297,30 +471,13 @@ impl KeyboardDiagram<'_> {
fn render_compact(&self, inner: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors;
let letter_rows = self.model.letter_rows();
let key_width: u16 = 3;
let min_width: u16 = 21;
if inner.height < 3 || inner.width < min_width {
let Some(geometry) = geometry_for_mode(inner, self.model, KeyboardRenderMode::Compact)
else {
return;
}
let offsets: &[u16] = &[3, 4, 6];
let keyboard_width = letter_rows
.iter()
.enumerate()
.map(|(row_idx, row)| {
let offset = offsets.get(row_idx).copied().unwrap_or(0);
let row_end = offset + row.len() as u16 * key_width;
match row_idx {
0 => row_end + 3, // [B]
1 => row_end + 3, // [E]
2 => row_end + 3, // [S]
_ => row_end,
}
})
.max()
.unwrap_or(0);
let start_x = inner.x + inner.width.saturating_sub(keyboard_width) / 2;
};
let key_width = geometry.key_width;
let offsets = &geometry.row_offsets;
let start_x = inner.x + geometry.start_inset;
for (row_idx, row) in letter_rows.iter().enumerate() {
let y = inner.y + row_idx as u16;
@@ -353,12 +510,8 @@ impl KeyboardDiagram<'_> {
break;
}
// Caps lock inverts shift for alpha keys only
let show_shifted = if physical_key.base.is_ascii_alphabetic() {
self.shift_held ^ self.caps_lock
} else {
self.shift_held
};
let show_shifted =
show_shifted_for_key(physical_key, self.shift_held, self.caps_lock);
let display_char = if show_shifted {
physical_key.shifted
} else {
@@ -403,7 +556,7 @@ impl KeyboardDiagram<'_> {
}
// Backspace at end of first row
if inner.height >= 3 {
if inner.height >= 3 && !letter_rows.is_empty() {
let y = inner.y;
let row_end_x = start_x + offsets[0] + letter_rows[0].len() as u16 * key_width;
if row_end_x + 3 <= inner.x + inner.width {
@@ -418,33 +571,17 @@ impl KeyboardDiagram<'_> {
fn render_full(&self, inner: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors;
let key_width: u16 = 5;
let min_width: u16 = 75;
if inner.height < 4 || inner.width < min_width {
let Some(geometry) = geometry_for(inner, self.model, false) else {
return;
};
if render_mode_for(inner, false) != KeyboardRenderMode::Full {
self.render_full_fallback(inner, buf);
return;
}
let offsets: &[u16] = &[0, 5, 5, 6];
let keyboard_width = self
.model
.rows
.iter()
.enumerate()
.map(|(row_idx, row)| {
let offset = offsets.get(row_idx).copied().unwrap_or(0);
let row_end = offset + row.len() as u16 * key_width;
match row_idx {
0 => row_end + 6, // [Bksp]
2 => row_end + 7, // [Enter]
3 => row_end + 6, // [Shft]
_ => row_end,
}
})
.max()
.unwrap_or(0);
let start_x = inner.x + inner.width.saturating_sub(keyboard_width) / 2;
let key_width = geometry.key_width;
let offsets = &geometry.row_offsets;
let keyboard_width = geometry.keyboard_width;
let start_x = inner.x + geometry.start_inset;
for (row_idx, row) in self.model.rows.iter().enumerate() {
let y = inner.y + row_idx as u16;
@@ -496,12 +633,8 @@ impl KeyboardDiagram<'_> {
break;
}
// Caps lock inverts shift for alpha keys only
let show_shifted = if physical_key.base.is_ascii_alphabetic() {
self.shift_held ^ self.caps_lock
} else {
self.shift_held
};
let show_shifted =
show_shifted_for_key(physical_key, self.shift_held, self.caps_lock);
let display_char = if show_shifted {
physical_key.shifted
} else {
@@ -576,22 +709,13 @@ impl KeyboardDiagram<'_> {
fn render_full_fallback(&self, inner: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors;
let letter_rows = self.model.letter_rows();
let key_width: u16 = 5;
let offsets: &[u16] = &[1, 3, 5];
let keyboard_width = letter_rows
.iter()
.enumerate()
.map(|(row_idx, row)| {
let offset = offsets.get(row_idx).copied().unwrap_or(0);
offset + row.len() as u16 * key_width
})
.max()
.unwrap_or(0);
let start_x = inner.x + inner.width.saturating_sub(keyboard_width) / 2;
if inner.height < 3 || inner.width < 30 {
let Some(geometry) = geometry_for_mode(inner, self.model, KeyboardRenderMode::FullFallback)
else {
return;
}
};
let key_width = geometry.key_width;
let offsets = &geometry.row_offsets;
let start_x = inner.x + geometry.start_inset;
for (row_idx, row) in letter_rows.iter().enumerate() {
let y = inner.y + row_idx as u16;
@@ -607,12 +731,8 @@ impl KeyboardDiagram<'_> {
break;
}
// Caps lock inverts shift for alpha keys only
let show_shifted = if physical_key.base.is_ascii_alphabetic() {
self.shift_held ^ self.caps_lock
} else {
self.shift_held
};
let show_shifted =
show_shifted_for_key(physical_key, self.shift_held, self.caps_lock);
let display_char = if show_shifted {
physical_key.shifted
} else {
@@ -641,30 +761,11 @@ fn rect_contains(area: Rect, x: u16, y: u16) -> bool {
}
fn key_at_compact_position(inner: Rect, model: &KeyboardModel, x: u16, y: u16) -> Option<char> {
let geometry = geometry_for_mode(inner, model, KeyboardRenderMode::Compact)?;
let letter_rows = model.letter_rows();
let key_width: u16 = 3;
let min_width: u16 = 21;
if inner.height < 3 || inner.width < min_width {
return None;
}
let offsets: &[u16] = &[3, 4, 6];
let keyboard_width = letter_rows
.iter()
.enumerate()
.map(|(row_idx, row)| {
let offset = offsets.get(row_idx).copied().unwrap_or(0);
let row_end = offset + row.len() as u16 * key_width;
match row_idx {
0 => row_end + 3,
1 => row_end + 3,
2 => row_end + 3,
_ => row_end,
}
})
.max()
.unwrap_or(0);
let start_x = inner.x + inner.width.saturating_sub(keyboard_width) / 2;
let key_width = geometry.key_width;
let offsets = &geometry.row_offsets;
let start_x = inner.x + geometry.start_inset;
for (row_idx, row) in letter_rows.iter().enumerate() {
let row_y = inner.y + row_idx as u16;
@@ -727,30 +828,13 @@ fn key_at_compact_position(inner: Rect, model: &KeyboardModel, x: u16, y: u16) -
}
fn shift_at_compact_position(inner: Rect, model: &KeyboardModel, x: u16, y: u16) -> bool {
let letter_rows = model.letter_rows();
let key_width: u16 = 3;
let min_width: u16 = 21;
if inner.height < 3 || inner.width < min_width {
let Some(geometry) = geometry_for_mode(inner, model, KeyboardRenderMode::Compact) else {
return false;
}
let offsets: &[u16] = &[3, 4, 6];
let keyboard_width = letter_rows
.iter()
.enumerate()
.map(|(row_idx, row)| {
let offset = offsets.get(row_idx).copied().unwrap_or(0);
let row_end = offset + row.len() as u16 * key_width;
match row_idx {
0 => row_end + 3,
1 => row_end + 3,
2 => row_end + 3,
_ => row_end,
}
})
.max()
.unwrap_or(0);
let start_x = inner.x + inner.width.saturating_sub(keyboard_width) / 2;
};
let letter_rows = model.letter_rows();
let key_width = geometry.key_width;
let offsets = &geometry.row_offsets;
let start_x = inner.x + geometry.start_inset;
let shift_row_y = inner.y + 2;
if y != shift_row_y {
return false;
@@ -759,6 +843,9 @@ fn shift_at_compact_position(inner: Rect, model: &KeyboardModel, x: u16, y: u16)
if rect_contains(left_shift, x, y) {
return true;
}
if letter_rows.len() <= 2 {
return false;
}
let offset = offsets[2];
let row_end_x = start_x + offset + letter_rows[2].len() as u16 * key_width;
let right_shift = Rect::new(row_end_x, shift_row_y, 3, 1);
@@ -766,25 +853,11 @@ fn shift_at_compact_position(inner: Rect, model: &KeyboardModel, x: u16, y: u16)
}
fn key_at_full_position(inner: Rect, model: &KeyboardModel, x: u16, y: u16) -> Option<char> {
let key_width: u16 = 5;
let offsets: &[u16] = &[0, 5, 5, 6];
let keyboard_width = model
.rows
.iter()
.enumerate()
.map(|(row_idx, row)| {
let offset = offsets.get(row_idx).copied().unwrap_or(0);
let row_end = offset + row.len() as u16 * key_width;
match row_idx {
0 => row_end + 6,
2 => row_end + 7,
3 => row_end + 6,
_ => row_end,
}
})
.max()
.unwrap_or(0);
let start_x = inner.x + inner.width.saturating_sub(keyboard_width) / 2;
let geometry = geometry_for_mode(inner, model, KeyboardRenderMode::Full)?;
let key_width = geometry.key_width;
let offsets = &geometry.row_offsets;
let keyboard_width = geometry.keyboard_width;
let start_x = inner.x + geometry.start_inset;
for (row_idx, row) in model.rows.iter().enumerate() {
let row_y = inner.y + row_idx as u16;
@@ -862,25 +935,12 @@ fn key_at_full_position(inner: Rect, model: &KeyboardModel, x: u16, y: u16) -> O
}
fn shift_at_full_position(inner: Rect, model: &KeyboardModel, x: u16, y: u16) -> bool {
let key_width: u16 = 5;
let offsets: &[u16] = &[0, 5, 5, 6];
let keyboard_width = model
.rows
.iter()
.enumerate()
.map(|(row_idx, row)| {
let offset = offsets.get(row_idx).copied().unwrap_or(0);
let row_end = offset + row.len() as u16 * key_width;
match row_idx {
0 => row_end + 6,
2 => row_end + 7,
3 => row_end + 6,
_ => row_end,
}
})
.max()
.unwrap_or(0);
let start_x = inner.x + inner.width.saturating_sub(keyboard_width) / 2;
let Some(geometry) = geometry_for_mode(inner, model, KeyboardRenderMode::Full) else {
return false;
};
let key_width = geometry.key_width;
let offsets = &geometry.row_offsets;
let start_x = inner.x + geometry.start_inset;
let shift_row_y = inner.y + 3;
if y != shift_row_y {
return false;
@@ -890,6 +950,9 @@ fn shift_at_full_position(inner: Rect, model: &KeyboardModel, x: u16, y: u16) ->
if rect_contains(left_shift, x, y) {
return true;
}
if model.rows.len() <= 3 {
return false;
}
let offset = offsets[3];
let row_end_x = start_x + offset + model.rows[3].len() as u16 * key_width;
let right_shift = Rect::new(row_end_x, shift_row_y, 6, 1);
@@ -902,23 +965,11 @@ fn key_at_full_fallback_position(
x: u16,
y: u16,
) -> Option<char> {
let geometry = geometry_for_mode(inner, model, KeyboardRenderMode::FullFallback)?;
let letter_rows = model.letter_rows();
let key_width: u16 = 5;
let offsets: &[u16] = &[1, 3, 5];
let keyboard_width = letter_rows
.iter()
.enumerate()
.map(|(row_idx, row)| {
let offset = offsets.get(row_idx).copied().unwrap_or(0);
offset + row.len() as u16 * key_width
})
.max()
.unwrap_or(0);
let start_x = inner.x + inner.width.saturating_sub(keyboard_width) / 2;
if inner.height < 3 || inner.width < 30 {
return None;
}
let key_width = geometry.key_width;
let offsets = &geometry.row_offsets;
let start_x = inner.x + geometry.start_inset;
for (row_idx, row) in letter_rows.iter().enumerate() {
let row_y = inner.y + row_idx as u16;
@@ -940,3 +991,183 @@ fn key_at_full_fallback_position(
fn shift_at_full_fallback_position(_inner: Rect, _model: &KeyboardModel, _x: u16, _y: u16) -> bool {
false
}
#[cfg(test)]
fn geometry_cache_len() -> usize {
geometry_cache()
.lock()
.expect("keyboard geometry cache poisoned")
.len()
}
#[cfg(test)]
fn geometry_cache_clear() {
geometry_cache()
.lock()
.expect("keyboard geometry cache poisoned")
.clear();
}
#[cfg(test)]
fn geometry_cache_matching_entries(
layout_key: &str,
mode: KeyboardRenderMode,
width: u16,
height: u16,
) -> usize {
geometry_cache()
.lock()
.expect("keyboard geometry cache poisoned")
.keys()
.filter(|k| {
k.layout_key == layout_key && k.mode == mode && k.width == width && k.height == height
})
.count()
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Mutex, OnceLock};
fn cache_test_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
fn assert_roundtrip_for_render_mode(
model: &KeyboardModel,
area: Rect,
compact: bool,
mode: KeyboardRenderMode,
) {
let inner = Block::bordered().inner(area);
let geometry =
geometry_for_mode(inner, model, mode).expect("expected geometry for test render mode");
let rows = rows_for_mode(model, mode);
for (row_idx, row) in rows.iter().enumerate() {
let row_y = inner.y + row_idx as u16;
let offset = geometry.row_offsets.get(row_idx).copied().unwrap_or(0);
for (col_idx, key) in row.iter().enumerate() {
let key_x =
inner.x + geometry.start_inset + offset + col_idx as u16 * geometry.key_width;
let hit_x = key_x;
let hit_y = row_y;
let hit = KeyboardDiagram::key_at_position(area, model, compact, hit_x, hit_y);
assert_eq!(
hit,
Some(key.base),
"round-trip hit-test mismatch for layout={}, mode={mode:?}, row={row_idx}, col={col_idx}, key={}",
model.layout_key,
key.base
);
}
}
}
#[test]
fn geometry_cache_reuses_entries_for_same_layout_mode_and_viewport() {
let _guard = cache_test_lock()
.lock()
.expect("cache test lock should not be poisoned");
geometry_cache_clear();
let model = KeyboardModel::from_key("qwerty").expect("qwerty model must exist");
let area = Rect::new(0, 0, 100, 10);
let inner = Block::bordered().inner(area);
let mode = render_mode_for(inner, false);
let _ = KeyboardDiagram::key_at_position(area, &model, false, 10, 2);
assert_eq!(
geometry_cache_matching_entries(model.layout_key, mode, inner.width, inner.height),
1
);
for _ in 0..50 {
let _ = KeyboardDiagram::key_at_position(area, &model, false, 12, 2);
let _ = KeyboardDiagram::shift_at_position(area, &model, false, 5, 3);
}
assert_eq!(
geometry_cache_matching_entries(model.layout_key, mode, inner.width, inner.height),
1,
"expected exactly one cached geometry entry for repeated same key"
);
}
#[test]
fn geometry_cache_distinguishes_layout_and_viewport_keys() {
let _guard = cache_test_lock()
.lock()
.expect("cache test lock should not be poisoned");
geometry_cache_clear();
let qwerty = KeyboardModel::from_key("qwerty").expect("qwerty model must exist");
let azerty = KeyboardModel::from_key("fr_azerty").expect("fr_azerty model must exist");
let _ = KeyboardDiagram::key_at_position(Rect::new(0, 0, 100, 10), &qwerty, false, 8, 2);
let after_first = geometry_cache_len();
assert!(after_first >= 1);
let _ = KeyboardDiagram::key_at_position(Rect::new(0, 0, 120, 10), &qwerty, false, 8, 2);
let _ = KeyboardDiagram::key_at_position(Rect::new(0, 0, 100, 10), &azerty, false, 8, 2);
assert!(
geometry_cache_len() >= after_first + 2,
"expected separate cached geometry entries for viewport/layout changes"
);
}
#[test]
fn geometry_cache_is_bounded() {
let _guard = cache_test_lock()
.lock()
.expect("cache test lock should not be poisoned");
geometry_cache_clear();
let model = KeyboardModel::from_key("qwerty").expect("qwerty model must exist");
for i in 0..(MAX_GEOMETRY_CACHE_ENTRIES as u16 + 10) {
let width = 90 + i;
let area = Rect::new(0, 0, width, 10);
let _ = KeyboardDiagram::key_at_position(area, &model, false, 10, 2);
}
assert!(
geometry_cache_len() <= MAX_GEOMETRY_CACHE_ENTRIES,
"geometry cache exceeded bounded capacity"
);
}
#[test]
fn hit_test_roundtrip_invariants_hold_for_all_layouts() {
let _guard = cache_test_lock()
.lock()
.expect("cache test lock should not be poisoned");
for &layout_key in KeyboardModel::supported_layout_keys() {
let model = KeyboardModel::from_key(layout_key).expect("profile should exist");
// Full render mode.
assert_roundtrip_for_render_mode(
&model,
Rect::new(0, 0, 100, 10),
false,
KeyboardRenderMode::Full,
);
// Full fallback mode (non-compact, but too small for full keyboard).
assert_roundtrip_for_render_mode(
&model,
Rect::new(0, 0, 60, 8),
false,
KeyboardRenderMode::FullFallback,
);
// Compact mode.
assert_roundtrip_for_render_mode(
&model,
Rect::new(0, 0, 60, 8),
true,
KeyboardRenderMode::Compact,
);
}
}
}

View File

@@ -37,6 +37,13 @@ impl<'a> SkillTreeWidget<'a> {
}
}
fn locked_branch_notice(skill_tree: &SkillTreeEngine) -> String {
format!(
"Complete {} primary letters to unlock branches",
skill_tree.primary_letters().len()
)
}
/// Get the list of selectable branch IDs (Lowercase first, then other branches).
pub fn selectable_branches() -> Vec<BranchId> {
vec![
@@ -59,8 +66,21 @@ pub fn detail_line_count(branch_id: BranchId) -> usize {
.sum::<usize>()
}
pub fn detail_line_count_with_level_spacing(branch_id: BranchId, level_spacing: bool) -> usize {
let base = detail_line_count(branch_id);
pub fn detail_line_count_for_tree(skill_tree: &SkillTreeEngine, branch_id: BranchId) -> usize {
if branch_id == BranchId::Lowercase {
// 1 branch header + 1 level header + one line per primary letter.
1 + 1 + skill_tree.primary_letters().len()
} else {
detail_line_count(branch_id)
}
}
pub fn detail_line_count_with_level_spacing_for_tree(
skill_tree: &SkillTreeEngine,
branch_id: BranchId,
level_spacing: bool,
) -> usize {
let base = detail_line_count_for_tree(skill_tree, branch_id);
if !level_spacing {
return base;
}
@@ -68,6 +88,7 @@ pub fn detail_line_count_with_level_spacing(branch_id: BranchId, level_spacing:
base + def.levels.len().saturating_sub(1)
}
#[cfg(test)]
pub fn use_expanded_level_spacing(detail_area_height: u16, branch_id: BranchId) -> bool {
let def = get_branch_definition(branch_id);
let base = detail_line_count(branch_id);
@@ -75,6 +96,17 @@ pub fn use_expanded_level_spacing(detail_area_height: u16, branch_id: BranchId)
(detail_area_height as usize) >= base + extra
}
pub fn use_expanded_level_spacing_for_tree(
skill_tree: &SkillTreeEngine,
detail_area_height: u16,
branch_id: BranchId,
) -> bool {
let def = get_branch_definition(branch_id);
let base = detail_line_count_for_tree(skill_tree, branch_id);
let extra = def.levels.len().saturating_sub(1);
(detail_area_height as usize) >= base + extra
}
pub fn use_side_by_side_layout(inner_width: u16) -> bool {
inner_width >= 100
}
@@ -107,37 +139,48 @@ impl Widget for SkillTreeWidget<'_> {
// Layout: main split (branch list + detail) and footer (adaptive height)
let branches = selectable_branches();
let (footer_hints, footer_notice) = 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",
],
Some("Complete a-z to unlock branches"),
)
} else if bp.status == BranchStatus::Available {
(
vec![
"[Enter] Unlock",
"[↑↓/jk] Navigate",
"[PgUp/PgDn or Ctrl+U/Ctrl+D] Scroll",
"[q] Back",
],
None,
)
} else if bp.status == BranchStatus::InProgress {
(
vec![
"[Enter] Start Drill",
"[↑↓/jk] Navigate",
"[PgUp/PgDn or Ctrl+U/Ctrl+D] Scroll",
"[q] Back",
],
None,
)
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",
],
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",
],
None,
)
} else if bp.status == BranchStatus::InProgress {
(
vec![
"[Enter] Start Drill",
"[↑↓/jk] Navigate",
"[PgUp/PgDn or Ctrl+U/Ctrl+D] Scroll",
"[q] Back",
],
None,
)
} else {
(
vec![
"[↑↓/jk] Navigate",
"[PgUp/PgDn or Ctrl+U/Ctrl+D] Scroll",
"[q] Back",
],
None,
)
}
} else {
(
vec![
@@ -147,19 +190,10 @@ impl Widget for SkillTreeWidget<'_> {
],
None,
)
}
} else {
(
vec![
"[↑↓/jk] Navigate",
"[PgUp/PgDn or Ctrl+U/Ctrl+D] Scroll",
"[q] Back",
],
None,
)
};
};
let hint_lines = pack_hint_lines(&footer_hints, inner.width as usize);
let notice_lines = footer_notice
.as_deref()
.map(|text| wrapped_line_count(text, inner.width as usize))
.unwrap_or(0);
let show_notice = footer_notice.is_some()
@@ -273,7 +307,7 @@ impl SkillTreeWidget<'_> {
let bp = self.skill_tree.branch_progress(branch_id);
let def = get_branch_definition(branch_id);
let total_keys = def.levels.iter().map(|l| l.keys.len()).sum::<usize>();
let total_keys = self.skill_tree.branch_total_keys_for(branch_id);
let confident_keys = self
.skill_tree
.branch_confident_keys(branch_id, self.key_stats);
@@ -346,7 +380,10 @@ impl SkillTreeWidget<'_> {
lines.push(Line::from(""));
}
lines.push(Line::from(Span::styled(
" \u{2500}\u{2500} Branches (available after a-z) \u{2500}\u{2500}",
format!(
" \u{2500}\u{2500} Branches (available after {} primary letters) \u{2500}\u{2500}",
self.skill_tree.primary_letters().len()
),
Style::default().fg(colors.text_pending()),
)));
// If inter-branch spacing is enabled, the next branch will already
@@ -377,15 +414,15 @@ impl SkillTreeWidget<'_> {
let branch_id = branches[self.selected];
let bp = self.skill_tree.branch_progress(branch_id);
let def = get_branch_definition(branch_id);
let expanded_level_spacing =
allow_expanded_level_spacing && use_expanded_level_spacing(area.height, branch_id);
let expanded_level_spacing = allow_expanded_level_spacing
&& use_expanded_level_spacing_for_tree(self.skill_tree, area.height, branch_id);
let mut lines: Vec<Line> = Vec::new();
// Branch title with level info
let level_text = if branch_id == BranchId::Lowercase {
let unlocked = self.skill_tree.branch_unlocked_count(BranchId::Lowercase);
let total = SkillTreeEngine::branch_total_keys(BranchId::Lowercase);
let total = self.skill_tree.branch_total_keys_for(BranchId::Lowercase);
format!("Unlocked {unlocked}/{total} letters")
} else {
match bp.status {
@@ -441,7 +478,12 @@ impl SkillTreeWidget<'_> {
)));
// Per-key mastery bars
for &key in level.keys {
let level_keys: Vec<char> = if branch_id == BranchId::Lowercase {
self.skill_tree.primary_letters().to_vec()
} else {
level.keys.to_vec()
};
for &key in &level_keys {
let is_focused = focused == Some(key);
let confidence = self.key_stats.get_confidence(key).min(1.0);
let is_confident = confidence >= 1.0;

View File

@@ -743,7 +743,7 @@ impl StatsDashboard<'_> {
};
let show_shifted = inner.height >= 10; // 4 base + 4 shifted + 1 mod row + 1 spare
let all_rows = &self.keyboard_model.rows;
let offsets: &[u16] = &[0, 2, 3, 4];
let offsets = self.keyboard_model.geometry_hints.row_offsets;
let kbd_width = all_rows
.iter()
.enumerate()
@@ -898,7 +898,7 @@ impl StatsDashboard<'_> {
};
let show_shifted = inner.height >= 10; // 4 base + 4 shifted + 1 mod row + 1 spare
let all_rows = &self.keyboard_model.rows;
let offsets: &[u16] = &[0, 2, 3, 4];
let offsets = self.keyboard_model.geometry_hints.row_offsets;
let kbd_width = all_rows
.iter()
.enumerate()