From 5c56a9c3c63bc115f86d6cbc1d40f05492982951 Mon Sep 17 00:00:00 2001 From: Tyler Hallada Date: Sat, 28 Feb 2026 21:11:11 +0000 Subject: [PATCH] More balanced adaptive drill generation, Tab fixes, mouse control tweaks --- src/app.rs | 509 +++++++++++++- src/bin/generate_test_profiles.rs | 63 ++ src/generator/capitalize.rs | 311 ++++++--- src/main.rs | 917 ++++++++++++++++++++++++-- src/session/drill.rs | 14 + src/session/input.rs | 4 +- src/ui/components/keyboard_diagram.rs | 97 +++ src/ui/components/typing_area.rs | 19 +- tests/test_profile_fixtures.rs | 29 +- 9 files changed, 1789 insertions(+), 174 deletions(-) diff --git a/src/app.rs b/src/app.rs index fb8d828..4c5088e 100644 --- a/src/app.rs +++ b/src/app.rs @@ -696,7 +696,9 @@ impl App { pub fn start_drill(&mut self) { self.clear_post_drill_input_lock(); let (text, source_info) = self.generate_text(); - self.drill = Some(DrillState::new(&text)); + let mut drill = DrillState::new(&text); + drill.auto_indent_after_newline = self.drill_mode != DrillMode::Adaptive; + self.drill = Some(drill); self.drill_source_info = source_info; self.drill_events.clear(); self.screen = AppScreen::Drill; @@ -803,37 +805,37 @@ impl App { BranchStatus::InProgress | BranchStatus::Complete ), }; + let symbol_keys: Vec = all_keys + .iter() + .copied() + .filter(|ch| { + matches!( + ch, + '=' | '+' + | '*' + | '/' + | '-' + | '{' + | '}' + | '[' + | ']' + | '<' + | '>' + | '&' + | '|' + | '^' + | '~' + | '@' + | '#' + | '$' + | '%' + | '_' + | '\\' + | '`' + ) + }) + .collect(); if code_active { - let symbol_keys: Vec = all_keys - .iter() - .copied() - .filter(|ch| { - matches!( - ch, - '=' | '+' - | '*' - | '/' - | '-' - | '{' - | '}' - | '[' - | ']' - | '<' - | '>' - | '&' - | '|' - | '^' - | '~' - | '@' - | '#' - | '$' - | '%' - | '_' - | '\\' - | '`' - ) - }) - .collect(); if !symbol_keys.is_empty() { let mut rng = SmallRng::from_rng(&mut self.rng).unwrap(); text = code_patterns::apply_code_symbols( @@ -846,10 +848,41 @@ impl App { } // Apply whitespace line breaks if newline is in scope - if all_keys.contains(&'\n') { + let has_newline = all_keys.contains(&'\n'); + let has_tab = all_keys.contains(&'\t'); + if has_newline { text = insert_line_breaks(&text); } + // Balance injection density so unlocked branches contribute + // roughly similar amounts of practice content. + let active_branch_count = [ + !cap_keys.is_empty(), + !punct_keys.is_empty(), + !digit_keys.is_empty(), + code_active && !symbol_keys.is_empty(), + has_newline || has_tab, + ] + .into_iter() + .filter(|active| *active) + .count(); + if active_branch_count > 1 || has_tab { + let target_per_branch = (word_count / 6).clamp(2, 6); + let mut rng = SmallRng::from_rng(&mut self.rng).unwrap(); + text = rebalance_branch_injections( + text, + &cap_keys, + &punct_keys, + &digit_keys, + if code_active { &symbol_keys } else { &[] }, + has_newline, + has_tab, + focused_char, + target_per_branch, + &mut rng, + ); + } + (text, None) } DrillMode::Code => { @@ -1441,7 +1474,9 @@ impl App { pub fn retry_drill(&mut self) { if let Some(ref drill) = self.drill { let text: String = drill.target.iter().collect(); - self.drill = Some(DrillState::new(&text)); + let mut retry = DrillState::new(&text); + retry.auto_indent_after_newline = self.drill_mode != DrillMode::Adaptive; + self.drill = Some(retry); self.drill_events.clear(); self.last_result = None; self.screen = AppScreen::Drill; @@ -2296,6 +2331,289 @@ fn lowercase_generation_focus(focused: Option) -> Option { }) } +fn count_matching_chars(text: &str, keys: &[char]) -> usize { + text.chars().filter(|ch| keys.contains(ch)).count() +} + +fn first_alpha_index(chars: &[char]) -> Option { + chars.iter().position(|ch| ch.is_ascii_alphabetic()) +} + +fn top_up_capitals( + text: String, + cap_keys: &[char], + focused: Option, + needed: usize, + rng: &mut SmallRng, +) -> String { + if needed == 0 || cap_keys.is_empty() { + return text; + } + let mut words: Vec = text.split_whitespace().map(|w| w.to_string()).collect(); + if words.is_empty() { + return text; + } + + let mut remaining = needed; + let mut attempts = 0usize; + let max_attempts = words.len().saturating_mul(10).max(needed); + while remaining > 0 && attempts < max_attempts { + attempts += 1; + let idx = rng.gen_range(0..words.len()); + let mut chars: Vec = words[idx].chars().collect(); + let Some(pos) = first_alpha_index(&chars) else { + continue; + }; + if chars[pos].is_ascii_uppercase() { + continue; + } + let replacement = if let Some(ch) = focused.filter(|ch| ch.is_ascii_uppercase()) { + ch + } else { + cap_keys[rng.gen_range(0..cap_keys.len())] + }; + chars[pos] = replacement; + words[idx] = chars.into_iter().collect(); + remaining = remaining.saturating_sub(1); + } + words.join(" ") +} + +fn top_up_punctuation( + text: String, + punct_keys: &[char], + needed: usize, + rng: &mut SmallRng, +) -> String { + if needed == 0 || punct_keys.is_empty() { + return text; + } + let mut words: Vec = text.split_whitespace().map(|w| w.to_string()).collect(); + if words.is_empty() { + return text; + } + for _ in 0..needed { + let idx = rng.gen_range(0..words.len()); + let p = punct_keys[rng.gen_range(0..punct_keys.len())]; + words[idx].push(p); + } + words.join(" ") +} + +fn random_number_token(digit_keys: &[char], focused: Option, rng: &mut SmallRng) -> String { + let len = rng.gen_range(1..=3); + let focused_digit = focused.filter(|ch| ch.is_ascii_digit()); + (0..len) + .map(|_| { + if let Some(fd) = focused_digit { + if rng.gen_bool(0.45) { + return fd; + } + } + digit_keys[rng.gen_range(0..digit_keys.len())] + }) + .collect() +} + +fn top_up_numbers( + text: String, + digit_keys: &[char], + focused: Option, + needed: usize, + rng: &mut SmallRng, +) -> String { + if needed == 0 || digit_keys.is_empty() { + return text; + } + let mut words: Vec = text.split_whitespace().map(|w| w.to_string()).collect(); + if words.is_empty() { + return text; + } + for _ in 0..needed { + let idx = rng.gen_range(0..words.len()); + words[idx] = random_number_token(digit_keys, focused, rng); + } + words.join(" ") +} + +fn top_up_code_symbols( + text: String, + symbol_keys: &[char], + focused: Option, + needed: usize, + rng: &mut SmallRng, +) -> String { + if needed == 0 || symbol_keys.is_empty() { + return text; + } + let mut words: Vec = text.split_whitespace().map(|w| w.to_string()).collect(); + if words.is_empty() { + return text; + } + let focused_symbol = focused.filter(|ch| symbol_keys.contains(ch)); + for _ in 0..needed { + let idx = rng.gen_range(0..words.len()); + let sym = focused_symbol + .filter(|_| rng.gen_bool(0.5)) + .unwrap_or_else(|| symbol_keys[rng.gen_range(0..symbol_keys.len())]); + if rng.gen_bool(0.5) { + words[idx].insert(0, sym); + } else { + words[idx].push(sym); + } + } + words.join(" ") +} + +fn top_up_whitespace( + text: String, + has_newline: bool, + has_tab: bool, + needed: usize, + rng: &mut SmallRng, +) -> String { + if needed == 0 || (!has_newline && !has_tab) { + return text; + } + let mut chars: Vec = text.chars().collect(); + let mut added = 0usize; + let mut attempts = 0usize; + let max_attempts = needed.saturating_mul(12).max(24); + + while added < needed && attempts < max_attempts { + attempts += 1; + let space_positions: Vec = chars + .iter() + .enumerate() + .filter_map(|(i, ch)| if *ch == ' ' { Some(i) } else { None }) + .collect(); + if space_positions.is_empty() { + break; + } + let pos = space_positions[rng.gen_range(0..space_positions.len())]; + + // When both are unlocked, prefer indentation-like `\n\t` insertions. + if has_newline && has_tab && added + 2 <= needed && rng.gen_bool(0.70) { + chars[pos] = '\n'; + chars.insert(pos + 1, '\t'); + added += 2; + continue; + } + + // Newline-only insertion. + if has_newline && (!has_tab || rng.gen_bool(0.80)) { + chars[pos] = '\n'; + added += 1; + continue; + } + + // Mid-line tabs are allowed but intentionally rare. + if has_tab { + chars[pos] = '\t'; + added += 1; + } + } + + // Ensure at least one tab is present when tab is unlocked. + if has_tab && !chars.contains(&'\t') { + if let Some(pos) = chars.iter().position(|ch| *ch == ' ') { + if has_newline { + chars[pos] = '\n'; + chars.insert(pos + 1, '\t'); + } else { + chars[pos] = '\t'; + } + } else if has_newline { + chars.push('\n'); + chars.push('\t'); + } else { + chars.push('\t'); + } + } + + chars.into_iter().collect() +} + +#[allow(clippy::too_many_arguments)] +fn rebalance_branch_injections( + mut text: String, + cap_keys: &[char], + punct_keys: &[char], + digit_keys: &[char], + symbol_keys: &[char], + has_newline: bool, + has_tab: bool, + focused: Option, + target_per_branch: usize, + rng: &mut SmallRng, +) -> String { + let bonus = 2usize; + + if !punct_keys.is_empty() { + let current = count_matching_chars(&text, punct_keys); + let target = if focused.is_some_and(|ch| punct_keys.contains(&ch)) { + target_per_branch + bonus + } else { + target_per_branch + }; + if current < target { + text = top_up_punctuation(text, punct_keys, target - current, rng); + } + } + + if !digit_keys.is_empty() { + let current = text.chars().filter(|ch| ch.is_ascii_digit()).count(); + let target = if focused.is_some_and(|ch| ch.is_ascii_digit()) { + target_per_branch + bonus + } else { + target_per_branch + }; + if current < target { + text = top_up_numbers(text, digit_keys, focused, target - current, rng); + } + } + + if !symbol_keys.is_empty() { + let current = count_matching_chars(&text, symbol_keys); + let target = if focused.is_some_and(|ch| symbol_keys.contains(&ch)) { + target_per_branch + bonus + } else { + target_per_branch + }; + if current < target { + text = top_up_code_symbols(text, symbol_keys, focused, target - current, rng); + } + } + + // Run capitals late so replacement passes (e.g. numbers) don't erase them. + if !cap_keys.is_empty() { + let current = text.chars().filter(|ch| ch.is_ascii_uppercase()).count(); + let target = if focused.is_some_and(|ch| ch.is_ascii_uppercase()) { + target_per_branch + bonus + } else { + target_per_branch + }; + if current < target { + text = top_up_capitals(text, cap_keys, focused, target - current, rng); + } + } + + // Run whitespace last because word-based passes normalize separators. + if has_newline || has_tab { + let current = text.chars().filter(|ch| *ch == '\n' || *ch == '\t').count(); + let target = if focused.is_some_and(|ch| ch == '\n' || ch == '\t') { + target_per_branch + bonus + } else { + target_per_branch + }; + if current < target { + text = top_up_whitespace(text, has_newline, has_tab, target - current, rng); + } + } + + text +} + #[cfg(test)] impl App { pub fn new_test() -> Self { @@ -2535,6 +2853,39 @@ mod tests { ); } + #[test] + fn auto_indent_disabled_for_adaptive_enabled_for_code_and_passage() { + let mut app = App::new_test(); + assert_eq!(app.drill_mode, DrillMode::Adaptive); + assert!( + app.drill + .as_ref() + .is_some_and(|d| !d.auto_indent_after_newline), + "adaptive drills should not auto-indent" + ); + + app.code_drill_language_override = Some("rust".to_string()); + app.start_code_drill(); + assert_eq!(app.drill_mode, DrillMode::Code); + assert!( + app.drill + .as_ref() + .is_some_and(|d| d.auto_indent_after_newline), + "code drills should auto-indent" + ); + + app.config.passage_downloads_enabled = false; + app.passage_drill_selection_override = Some("builtin".to_string()); + app.start_passage_drill(); + assert_eq!(app.drill_mode, DrillMode::Passage); + assert!( + app.drill + .as_ref() + .is_some_and(|d| d.auto_indent_after_newline), + "passage drills should auto-indent" + ); + } + /// Helper: make the current drill look "completed" so finish_drill() processes it. fn complete_current_drill(app: &mut App) { if let Some(ref mut drill) = app.drill { @@ -2627,6 +2978,98 @@ mod tests { assert_eq!(lowercase_generation_focus(None), None); } + #[test] + fn branch_injection_rebalance_hits_shared_minimums() { + let mut rng = SmallRng::seed_from_u64(42); + let base = "alpha beta gamma delta epsilon zeta eta theta iota kappa".to_string(); + let out = rebalance_branch_injections( + base, + &['A'], + &['.'], + &['1'], + &['='], + true, + true, + None, + 3, + &mut rng, + ); + + let cap_count = out.chars().filter(|ch| ch.is_ascii_uppercase()).count(); + let punct_count = count_matching_chars(&out, &['.']); + let digit_count = out.chars().filter(|ch| ch.is_ascii_digit()).count(); + let symbol_count = count_matching_chars(&out, &['=']); + let whitespace_count = out.chars().filter(|ch| *ch == '\n' || *ch == '\t').count(); + + assert!(cap_count >= 3, "capitals too low in: {out}"); + assert!(punct_count >= 3, "punctuation too low in: {out}"); + assert!(digit_count >= 3, "digits too low in: {out}"); + assert!(symbol_count >= 3, "symbols too low in: {out}"); + assert!(whitespace_count >= 3, "whitespace too low in: {out}"); + } + + #[test] + fn branch_injection_rebalance_boosts_focused_branch() { + let mut rng = SmallRng::seed_from_u64(7); + let base = "alpha beta gamma delta epsilon zeta eta theta iota kappa".to_string(); + let out = rebalance_branch_injections( + base, + &['A'], + &['.'], + &['1'], + &['='], + true, + false, + Some('1'), + 3, + &mut rng, + ); + + let digit_count = out.chars().filter(|ch| ch.is_ascii_digit()).count(); + assert!( + digit_count >= 5, + "focused digit branch should get bonus density, got {digit_count} in: {out}" + ); + } + + #[test] + fn branch_injection_rebalance_includes_tab_when_unlocked() { + let mut rng = SmallRng::seed_from_u64(17); + let base = "alpha beta gamma delta epsilon zeta eta theta iota kappa".to_string(); + let out = + rebalance_branch_injections(base, &[], &[], &[], &[], true, true, None, 3, &mut rng); + let tab_count = out.chars().filter(|ch| *ch == '\t').count(); + assert!( + tab_count >= 1, + "expected at least one tab when tab is unlocked, got: {out:?}" + ); + } + + #[test] + fn whitespace_top_up_prefers_tabs_after_newlines() { + let mut rng = SmallRng::seed_from_u64(1234); + let base = "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu".to_string(); + let out = top_up_whitespace(base, true, true, 10, &mut rng); + let chars: Vec = out.chars().collect(); + + let mut tabs = 0usize; + let mut tabs_after_newline = 0usize; + for i in 0..chars.len() { + if chars[i] == '\t' { + tabs += 1; + if i > 0 && chars[i - 1] == '\n' { + tabs_after_newline += 1; + } + } + } + + assert!(tabs > 0, "expected at least one tab in: {out:?}"); + assert!( + tabs_after_newline * 2 >= tabs, + "expected most tabs to be indentation-style after newline; got {tabs_after_newline}/{tabs} in: {out:?}" + ); + } + /// Helper: make a key just below mastery in ranked stats. /// Uses timing slightly above target (confidence ≈ 0.98), so one fast drill hit /// will push it over 1.0. target_time ≈ 342.86ms (60000/175 CPM). diff --git a/src/bin/generate_test_profiles.rs b/src/bin/generate_test_profiles.rs index 9e3e24b..67400e9 100644 --- a/src/bin/generate_test_profiles.rs +++ b/src/bin/generate_test_profiles.rs @@ -417,6 +417,65 @@ fn build_profile_03() -> ExportData { ) } +fn build_profile_03_near_lowercase_complete() -> ExportData { + // Lowercase InProgress level 19 => 6 + 19 = 25 keys unlocked. + // One unlocked key is just below mastery so a drill can trigger the final unlock popup. + let skill_tree = + make_skill_tree_progress(vec![(BranchId::Lowercase, BranchStatus::InProgress, 19)]); + + let all_keys = lowercase_keys(25); + let mastered_keys = &all_keys[..24]; + let near_mastery_key = all_keys[24]; + + let mut rng = SmallRng::seed_from_u64(2303); + let mut stats = KeyStatsStore::default(); + for &k in mastered_keys { + stats.stats.insert(k, make_key_stat(&mut rng, 1.35, 75)); + } + // Slightly below mastery, so one good drill can push over 1.0. + stats + .stats + .insert(near_mastery_key, make_key_stat(&mut rng, 0.97, 28)); + + let mut ranked_stats = KeyStatsStore::default(); + for (&k, base) in &stats.stats { + let conf = if base.confidence >= 1.0 { + (base.confidence - rng.gen_range(0.0..0.2)).max(1.0) + } else { + (base.confidence + rng.gen_range(-0.08..0.06)).clamp(0.85, 0.99) + }; + let sample_count = + ((base.sample_count as f64) * rng.gen_range(0.5..0.8)).round() as usize + 8; + ranked_stats + .stats + .insert(k, make_key_stat(&mut rng, conf, sample_count)); + } + + let drills = generate_drills( + &mut rng, + 90, + 10, + &all_keys, + &[("adaptive", false, 62), ("adaptive", true, 28)], + 34.0, + ); + + make_export( + ProfileData { + schema_version: SCHEMA_VERSION, + skill_tree, + total_score: 1800.0, + total_drills: 90, + streak_days: 10, + best_streak: 12, + last_practice_date: last_practice_date_from_drills(&drills), + }, + stats, + ranked_stats, + drills, + ) +} + fn build_profile_04() -> ExportData { // Lowercase Complete (level 20), all others Available let skill_tree = make_skill_tree_progress(vec![ @@ -741,6 +800,10 @@ fn main() { ("01-brand-new", build_profile_01()), ("02-early-lowercase", build_profile_02()), ("03-mid-lowercase", build_profile_03()), + ( + "03-near-lowercase-complete", + build_profile_03_near_lowercase_complete(), + ), ("04-lowercase-complete", build_profile_04()), ("05-multi-branch", build_profile_05()), ("06-advanced", build_profile_06()), diff --git a/src/generator/capitalize.rs b/src/generator/capitalize.rs index f3d424b..e48c287 100644 --- a/src/generator/capitalize.rs +++ b/src/generator/capitalize.rs @@ -13,103 +13,230 @@ pub fn apply_capitalization( return text.to_string(); } - // If focused key is an uppercase letter, boost its probability let focused_upper = focused.filter(|ch| ch.is_ascii_uppercase()); - - let mut result = String::with_capacity(text.len()); - let mut at_sentence_start = true; - - for (i, ch) in text.chars().enumerate() { - if at_sentence_start && ch.is_ascii_lowercase() { - let upper = ch.to_ascii_uppercase(); - if unlocked_capitals.contains(&upper) { - result.push(upper); - at_sentence_start = false; - continue; - } - } - - // After period/question/exclamation + space, next word starts a sentence - if ch == ' ' && i > 0 { - let prev = text.as_bytes().get(i - 1).map(|&b| b as char); - if matches!(prev, Some('.' | '?' | '!')) { - at_sentence_start = true; - } - } - - // Capitalize word starts: boosted for focused key, ~12% for others - if ch.is_ascii_lowercase() && !at_sentence_start { - let is_word_start = - i == 0 || text.as_bytes().get(i - 1).map(|&b| b as char) == Some(' '); - if is_word_start { - let upper = ch.to_ascii_uppercase(); - if unlocked_capitals.contains(&upper) { - let prob = if focused_upper == Some(upper) { - 0.40 - } else { - 0.12 - }; - if rng.gen_bool(prob) { - result.push(upper); - continue; - } - } - } - } - - if ch != '.' && ch != '?' && ch != '!' { - at_sentence_start = false; - } - - result.push(ch); - } - - // Focused capitals should show up multiple times when possible so they are - // introduced at a similar density to other focused key types. - if let Some(focused_upper) = focused_upper.filter(|ch| unlocked_capitals.contains(ch)) { - return ensure_min_focused_occurrences(&result, focused_upper, 3); - } - - result -} - -fn ensure_min_focused_occurrences(text: &str, focused_upper: char, min_count: usize) -> String { - let focused_lower = focused_upper.to_ascii_lowercase(); - let mut chars: Vec = text.chars().collect(); - let mut count = chars.iter().filter(|&&ch| ch == focused_upper).count(); - - if count >= min_count { + let mut words: Vec = text.split_whitespace().map(|w| w.to_string()).collect(); + if words.is_empty() { return text.to_string(); } - // First, capitalize matching word starts. - for i in 0..chars.len() { - if count >= min_count { - break; + // Prefer capitals at starts of words (sentence starts always when possible). + let mut at_sentence_start = true; + for i in 0..words.len() { + if let Some(upper) = word_start_upper(&words[i]) { + if unlocked_capitals.contains(&upper) { + let should_cap = if at_sentence_start { + true + } else if focused_upper == Some(upper) { + rng.gen_bool(0.55) + } else { + rng.gen_bool(0.22) + }; + if should_cap { + capitalize_word_start(&mut words[i]); + } + } } - if chars[i] != focused_lower { + at_sentence_start = ends_sentence(&words[i]); + } + + // Occasional mid-word capitals are injected as camelCase joins only. + // This keeps internal capitals realistic for code contexts. + let mut i = 0; + while i + 1 < words.len() { + if ends_sentence(&words[i]) { + i += 1; continue; } - let is_word_start = - i == 0 || matches!(chars.get(i.saturating_sub(1)), Some(' ' | '\n' | '\t')); - if is_word_start { - chars[i] = focused_upper; - count += 1; + let next_upper = match word_start_upper(&words[i + 1]) { + Some(upper) if unlocked_capitals.contains(&upper) => upper, + _ => { + i += 1; + continue; + } + }; + let prob = if focused_upper == Some(next_upper) { + 0.35 + } else { + 0.09 + }; + if rng.gen_bool(prob) { + capitalize_word_start(&mut words[i + 1]); + let next = words.remove(i + 1); + words[i].push_str(&next); + } else { + i += 1; } } - // If still short, capitalize matching letters anywhere in the text. - for ch in &mut chars { + // Focused capitals should show up multiple times for focused drills. + 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())) + .count(); + let min_focused = alpha_words.min(4); + ensure_min_focused_occurrences(&mut words, focused_upper, min_focused); + } + + // Keep a baseline capital density so branch/global drills with capitals + // unlocked do not feel too sparse. + let min_total_caps = words.len().clamp(3, 6) / 2; // ~3 for 6+ words + ensure_min_total_capitals(&mut words, unlocked_capitals, min_total_caps, rng); + + words.join(" ") +} + +fn word_start_upper(word: &str) -> Option { + word.chars() + .find(|ch| ch.is_ascii_alphabetic()) + .map(|ch| ch.to_ascii_uppercase()) +} + +fn capitalize_word_start(word: &mut String) -> Option { + let mut chars: Vec = word.chars().collect(); + for i in 0..chars.len() { + if chars[i].is_ascii_lowercase() { + chars[i] = chars[i].to_ascii_uppercase(); + let upper = chars[i]; + *word = chars.into_iter().collect(); + return Some(upper); + } + if chars[i].is_ascii_uppercase() { + return Some(chars[i]); + } + } + None +} + +fn ends_sentence(word: &str) -> bool { + word.chars() + .rev() + .find(|ch| !ch.is_ascii_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) +} + +fn force_word_start_to_upper(word: &mut String, upper: char) -> bool { + let mut chars: Vec = word.chars().collect(); + for i in 0..chars.len() { + if chars[i].is_ascii_alphabetic() { + if chars[i] == upper { + return false; + } + chars[i] = upper; + *word = chars.into_iter().collect(); + return true; + } + } + false +} + +fn ensure_min_focused_occurrences(words: &mut Vec, focused_upper: char, min_count: usize) { + let focused_lower = focused_upper.to_ascii_lowercase(); + let mut count = words + .iter() + .map(|w| w.chars().filter(|&ch| ch == focused_upper).count()) + .sum::(); + + if count >= min_count { + return; + } + + // First, capitalize focused matching word starts. + for word in words.iter_mut() { if count >= min_count { break; } - if *ch == focused_lower { - *ch = focused_upper; + if !word_starts_with_lower(word, focused_lower) { + continue; + } + if capitalize_word_start(word) == Some(focused_upper) { count += 1; } } - chars.into_iter().collect() + // If still short, create camelCase joins where the second word starts + // with the focused letter. + let mut i = 0; + while i + 1 < words.len() { + if count >= min_count { + break; + } + if ends_sentence(&words[i]) { + i += 1; + continue; + } + 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)); + if next_starts_focused { + capitalize_word_start(&mut words[i + 1]); + let next = words.remove(i + 1); + words[i].push_str(&next); + count += 1; + } else { + i += 1; + } + } + + // Last resort: force focused uppercase at word starts. + for word in words.iter_mut() { + if count >= min_count { + break; + } + if force_word_start_to_upper(word, focused_upper) { + count += 1; + } + } +} + +fn ensure_min_total_capitals( + words: &mut [String], + unlocked_capitals: &[char], + min_count: usize, + rng: &mut SmallRng, +) { + let mut count = words + .iter() + .map(|w| w.chars().filter(|ch| ch.is_ascii_uppercase()).count()) + .sum::(); + if count >= min_count || unlocked_capitals.is_empty() { + return; + } + + // Prefer natural capitalization when the word already starts with an unlocked letter. + for word in words.iter_mut() { + if count >= min_count { + break; + } + let Some(upper) = word_start_upper(word) else { + continue; + }; + if unlocked_capitals.contains(&upper) + && word_starts_with_lower(word, upper.to_ascii_lowercase()) + { + if capitalize_word_start(word) == Some(upper) { + count += 1; + } + } + } + + // If still short, force additional capitalized starts from unlocked set. + for word in words.iter_mut() { + if count >= min_count { + break; + } + let upper = unlocked_capitals[rng.gen_range(0..unlocked_capitals.len())]; + if force_word_start_to_upper(word, upper) { + count += 1; + } + } } #[cfg(test)] @@ -182,4 +309,28 @@ mod tests { "Expected at least 3 focused capitals, got {focused_count} in: {result}" ); } + + #[test] + fn test_no_interior_focus_caps_without_word_start_or_camel_case_opportunity() { + let mut rng = SmallRng::seed_from_u64(7); + let text = "awful claw draw"; + let result = apply_capitalization(text, &['W'], Some('W'), &mut rng); + assert!(result.starts_with('W') || result.contains(" W")); + assert!( + !result.contains("aW"), + "Should avoid interior non-camel W: {result}" + ); + } + + #[test] + fn test_focused_capital_forced_to_multiple_occurrences() { + let mut rng = SmallRng::seed_from_u64(11); + let text = "alpha beta gamma delta epsilon zeta eta theta iota"; + let result = apply_capitalization(text, &['Q'], Some('Q'), &mut rng); + let focused_count = result.chars().filter(|&ch| ch == 'Q').count(); + assert!( + focused_count >= 4, + "Expected forced focused Q occurrences, got {focused_count} in: {result}" + ); + } } diff --git a/src/main.rs b/src/main.rs index 49e53b3..10934f6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -296,9 +296,14 @@ fn handle_key(app: &mut App, key: KeyEvent) { return; } - // Milestone overlays are modal: any key dismisses exactly one popup and is consumed. - if !app.milestone_queue.is_empty() { + // Milestone overlays are modal: one key action applies and is consumed. + if let Some(milestone) = app.milestone_queue.front() { + let open_skill_tree = milestone_supports_skill_tree_shortcut(milestone) + && matches!(key.code, KeyCode::Char(ch) if ch.eq_ignore_ascii_case(&'t')); app.milestone_queue.pop_front(); + if open_skill_tree { + app.go_to_skill_tree(); + } return; } @@ -319,6 +324,13 @@ fn handle_key(app: &mut App, key: KeyEvent) { } } +fn milestone_supports_skill_tree_shortcut(milestone: &app::KeyMilestonePopup) -> bool { + matches!( + milestone.kind, + MilestoneKind::BranchesAvailable | MilestoneKind::BranchComplete + ) +} + fn terminal_area() -> Rect { let (w, h) = crossterm::terminal::size().unwrap_or((120, 40)); Rect::new(0, 0, w, h) @@ -331,6 +343,136 @@ fn point_in_rect(x: u16, y: u16, rect: Rect) -> bool { && y < rect.y.saturating_add(rect.height) } +fn hint_token_at(area: Rect, hints: &[&str], x: u16, y: u16) -> Option { + if !point_in_rect(x, y, area) { + return None; + } + + let prefix = " "; + let separator = " "; + let width = area.width as usize; + if width == 0 || hints.is_empty() { + return None; + } + + let row = y.saturating_sub(area.y) as usize; + let col = x.saturating_sub(area.x) as usize; + let prefix_len = prefix.chars().count(); + let sep_len = separator.chars().count(); + + let mut current_line = 0usize; + let mut line_len = prefix_len; + let mut has_hint_on_line = false; + + for hint in hints { + if hint.is_empty() { + continue; + } + let hint_len = hint.chars().count(); + let candidate_len = if has_hint_on_line { + line_len + sep_len + hint_len + } else { + line_len + hint_len + }; + + if candidate_len > width && has_hint_on_line { + current_line += 1; + line_len = prefix_len; + has_hint_on_line = false; + } + + let start_col = if has_hint_on_line { + line_len + sep_len + } else { + line_len + }; + let end_col = start_col + hint_len; + if current_line == row && (start_col..end_col).contains(&col) { + if let (Some(lb), Some(rb)) = (hint.find('['), hint.find(']')) + && rb > lb + 1 + { + return Some(hint[lb + 1..rb].to_string()); + } + return None; + } + + line_len = end_col; + has_hint_on_line = true; + } + + // Fallback for unexpected layout drift: use packed lines and bracket search. + let lines = pack_hint_lines(hints, area.width as usize); + if row >= lines.len() { + return None; + } + let chars: Vec = lines[row].chars().collect(); + if col >= chars.len() { + return None; + } + for hint in hints { + if hint.is_empty() { + continue; + } + let line = &lines[row]; + if let Some(start) = line.find(hint) { + let start_col = line[..start].chars().count(); + let end_col = start_col + hint.chars().count(); + if (start_col..end_col).contains(&col) { + if let (Some(lb), Some(rb)) = (hint.find('['), hint.find(']')) + && rb > lb + 1 + { + return Some(hint[lb + 1..rb].to_string()); + } + } + } + } + None +} + +fn milestone_footer_hint_token_at( + milestone: &app::KeyMilestonePopup, + x: u16, + y: u16, +) -> Option { + if !milestone_supports_skill_tree_shortcut(milestone) { + return None; + } + let area = terminal_area(); + let is_key_milestone = matches!( + milestone.kind, + MilestoneKind::Unlock | MilestoneKind::Mastery + ); + let kbd_mode = if is_key_milestone { + overlay_keyboard_mode(area.height) + } else { + 0 + }; + let overlay_height = match &milestone.kind { + MilestoneKind::BranchesAvailable => 18u16.min(area.height.saturating_sub(2)), + MilestoneKind::BranchComplete + | MilestoneKind::AllKeysUnlocked + | MilestoneKind::AllKeysMastered => 12u16.min(area.height.saturating_sub(2)), + _ => match kbd_mode { + 2 => 18u16.min(area.height.saturating_sub(2)), + 1 => 14u16.min(area.height.saturating_sub(2)), + _ => 10u16.min(area.height.saturating_sub(2)), + }, + }; + let overlay_width = 60u16.min(area.width.saturating_sub(4)); + let left = area.x + (area.width.saturating_sub(overlay_width)) / 2; + let top = area.y + (area.height.saturating_sub(overlay_height)) / 2; + let overlay_area = Rect::new(left, top, overlay_width, overlay_height); + let inner = Block::bordered().inner(overlay_area); + let footer_y = inner.y + inner.height.saturating_sub(1); + let footer_area = Rect::new(inner.x, footer_y, inner.width, 1); + hint_token_at( + footer_area, + &["[t] Open Skill Tree", "[Any other key] Continue"], + x, + y, + ) +} + fn handle_mouse(app: &mut App, mouse: MouseEvent) { if app.post_drill_input_lock_remaining_ms().is_some() && (!app.milestone_queue.is_empty() @@ -341,7 +483,18 @@ fn handle_mouse(app: &mut App, mouse: MouseEvent) { } if !app.milestone_queue.is_empty() { - if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { + if matches!( + mouse.kind, + MouseEventKind::Down(MouseButton::Left) | MouseEventKind::Down(MouseButton::Right) + ) { + if let Some(milestone) = app.milestone_queue.front() + && milestone_footer_hint_token_at(milestone, mouse.column, mouse.row) + .is_some_and(|t| t == "t") + { + app.milestone_queue.pop_front(); + app.go_to_skill_tree(); + return; + } app.milestone_queue.pop_front(); } return; @@ -397,7 +550,8 @@ fn handle_menu_mouse(app: &mut App, mouse: MouseEvent) { match mouse.kind { MouseEventKind::ScrollUp => app.menu.prev(), MouseEventKind::ScrollDown => app.menu.next(), - MouseEventKind::Down(MouseButton::Left) => { + MouseEventKind::Down(MouseButton::Left) | MouseEventKind::Down(MouseButton::Right) => { + let is_secondary = matches!(mouse.kind, MouseEventKind::Down(MouseButton::Right)); let area = terminal_area(); let menu_hints = [ "[1-3] Start", @@ -418,6 +572,39 @@ fn handle_menu_mouse(app: &mut App, mouse: MouseEvent) { Constraint::Length(footer_line_count), ]) .split(area); + if let Some(token) = hint_token_at(layout[2], &menu_hints, mouse.column, mouse.row) { + match token.as_str() { + "1-3" => { + let mut selected = app.menu.selected.min(2); + selected = if is_secondary { + if selected == 0 { 2 } else { selected - 1 } + } else { + (selected + 1) % 3 + }; + app.menu.selected = selected; + activate_menu_selected(app); + } + "t" => { + app.menu.selected = 3; + activate_menu_selected(app); + } + "b" => { + app.menu.selected = 4; + activate_menu_selected(app); + } + "s" => { + app.menu.selected = 5; + activate_menu_selected(app); + } + "c" => { + app.menu.selected = 6; + activate_menu_selected(app); + } + "q" => app.should_quit = true, + _ => {} + } + return; + } let menu_area = ui::layout::centered_rect(50, 80, layout[1]); let inner = Block::bordered().inner(menu_area); let sections = Layout::default() @@ -442,16 +629,37 @@ fn handle_menu_mouse(app: &mut App, mouse: MouseEvent) { } fn handle_drill_mouse(app: &mut App, mouse: MouseEvent) { - if !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { + if !matches!( + mouse.kind, + MouseEventKind::Down(MouseButton::Left) | MouseEventKind::Down(MouseButton::Right) + ) { return; } let layout = AppLayout::new(terminal_area()); if point_in_rect(mouse.column, mouse.row, layout.footer) { - let has_progress = app.drill.as_ref().is_some_and(|d| d.cursor > 0); - if has_progress { - app.finish_partial_drill(); - } else { - app.go_to_menu(); + let hints = ["[ESC] End drill", "[Backspace] Delete"]; + if let Some(token) = hint_token_at(layout.footer, &hints, mouse.column, mouse.row) { + match token.as_str() { + "ESC" => { + let has_progress = app.drill.as_ref().is_some_and(|d| d.cursor > 0); + if has_progress { + app.finish_partial_drill(); + } else { + app.go_to_menu(); + } + } + "Backspace" => app.backspace(), + _ => {} + } + return; + } + if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { + let has_progress = app.drill.as_ref().is_some_and(|d| d.cursor > 0); + if has_progress { + app.finish_partial_drill(); + } else { + app.go_to_menu(); + } } } } @@ -466,7 +674,10 @@ fn delete_confirm_dialog_area() -> Rect { } fn handle_result_mouse(app: &mut App, mouse: MouseEvent) { - if !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { + if !matches!( + mouse.kind, + MouseEventKind::Down(MouseButton::Left) | MouseEventKind::Down(MouseButton::Right) + ) { return; } if app.history_confirm_delete && !app.drill_history.is_empty() { @@ -482,6 +693,39 @@ fn handle_result_mouse(app: &mut App, mouse: MouseEvent) { } return; } + if app.last_result.is_some() { + let area = terminal_area(); + let centered = ui::layout::centered_rect(60, 70, area); + let inner = Block::bordered().inner(centered); + let hints = [ + "[c/Enter/Space] Continue", + "[r] Retry", + "[q] Menu", + "[s] Stats", + "[x] Delete", + ]; + let footer_line_count = pack_hint_lines(&hints, inner.width as usize).len().max(1) as u16; + let footer_y = inner + .y + .saturating_add(inner.height.saturating_sub(footer_line_count)); + let footer_area = Rect::new(inner.x, footer_y, inner.width, footer_line_count); + if let Some(token) = hint_token_at(footer_area, &hints, mouse.column, mouse.row) { + match token.as_str() { + "c/Enter/Space" => app.continue_drill(), + "r" => app.retry_drill(), + "q" => app.go_to_menu(), + "s" => app.go_to_stats(), + "x" => { + if !app.drill_history.is_empty() { + app.history_selected = 0; + app.history_confirm_delete = true; + } + } + _ => {} + } + return; + } + } if app.last_result.is_some() { app.continue_drill(); } @@ -580,7 +824,67 @@ fn handle_stats_mouse(app: &mut App, mouse: MouseEvent) { .split(inner); match mouse.kind { - MouseEventKind::Down(MouseButton::Left) => { + MouseEventKind::Down(MouseButton::Left) | MouseEventKind::Down(MouseButton::Right) => { + let is_secondary = matches!(mouse.kind, MouseEventKind::Down(MouseButton::Right)); + if let Some(token) = hint_token_at(layout[2], &footer_hints, mouse.column, mouse.row) { + match token.as_str() { + "ESC" => app.go_to_menu(), + "Tab" => { + app.stats_tab = if is_secondary { + if app.stats_tab == 0 { + STATS_TAB_COUNT - 1 + } else { + app.stats_tab - 1 + } + } else { + (app.stats_tab + 1) % STATS_TAB_COUNT + }; + } + "1-6" => { + app.stats_tab = if is_secondary { + if app.stats_tab == 0 { + STATS_TAB_COUNT - 1 + } else { + app.stats_tab - 1 + } + } else { + (app.stats_tab + 1) % STATS_TAB_COUNT + }; + } + "j/k" => { + if app.stats_tab == 1 && !app.drill_history.is_empty() { + if is_secondary { + app.history_selected = app.history_selected.saturating_sub(1); + } else { + app.history_selected = + (app.history_selected + 1).min(app.drill_history.len() - 1); + } + keep_history_selection_visible(app, current_history_page_size()); + } + } + "PgUp/PgDn" => { + if app.stats_tab == 1 && !app.drill_history.is_empty() { + let page_size = current_history_page_size(); + if is_secondary { + let max_idx = app.drill_history.len() - 1; + app.history_selected = + (app.history_selected + page_size).min(max_idx); + } else { + app.history_selected = + app.history_selected.saturating_sub(page_size); + } + keep_history_selection_visible(app, page_size); + } + } + "x" => { + if app.stats_tab == 1 && !app.drill_history.is_empty() { + app.history_confirm_delete = true; + } + } + _ => {} + } + return; + } if point_in_rect(mouse.column, mouse.row, layout[0]) && let Some(tab) = stats_tab_at_point(layout[0], width, mouse.column, mouse.row) { @@ -715,6 +1019,12 @@ fn settings_fields(app: &App) -> Vec<(String, String, bool)> { } fn handle_settings_mouse(app: &mut App, mouse: MouseEvent) { + let is_click = matches!( + mouse.kind, + MouseEventKind::Down(MouseButton::Left) | MouseEventKind::Down(MouseButton::Right) + ); + let is_secondary = matches!(mouse.kind, MouseEventKind::Down(MouseButton::Right)); + match mouse.kind { MouseEventKind::ScrollUp => { app.settings_selected = app.settings_selected.saturating_sub(1); @@ -724,7 +1034,7 @@ fn handle_settings_mouse(app: &mut App, mouse: MouseEvent) { app.settings_selected = (app.settings_selected + 1).min(15); return; } - MouseEventKind::Down(MouseButton::Left) => {} + MouseEventKind::Down(MouseButton::Left) | MouseEventKind::Down(MouseButton::Right) => {} _ => return, } @@ -773,10 +1083,6 @@ fn handle_settings_mouse(app: &mut App, mouse: MouseEvent) { return; } - if app.settings_editing_path.is_some() { - return; - } - let area = terminal_area(); let centered = ui::layout::centered_rect(60, 80, area); let inner = Block::bordered().inner(centered); @@ -804,6 +1110,78 @@ fn handle_settings_mouse(app: &mut App, mouse: MouseEvent) { ]) .split(inner); + if is_click && layout[2].height > 0 { + let footer_hints: Vec<&str> = if app.settings_editing_path.is_some() { + vec![ + "[←→] Move", + "[Tab] Complete (at end)", + "[Enter] Confirm", + "[Esc] Cancel", + ] + } else { + vec![ + "[ESC] Save & back", + "[Enter/arrows] Change value", + "[Enter on path] Edit", + ] + }; + if let Some(token) = hint_token_at(layout[2], &footer_hints, mouse.column, mouse.row) { + if app.settings_editing_path.is_some() { + match token.as_str() { + "←→" => { + let code = if is_secondary { + KeyCode::Left + } else { + KeyCode::Right + }; + handle_settings_key(app, KeyEvent::new(code, KeyModifiers::NONE)); + } + "Tab" => handle_settings_key( + app, + KeyEvent::new( + if is_secondary { + KeyCode::BackTab + } else { + KeyCode::Tab + }, + KeyModifiers::NONE, + ), + ), + "Enter" => { + handle_settings_key(app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) + } + "Esc" => { + handle_settings_key(app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)) + } + _ => {} + } + } else { + match token.as_str() { + "ESC" => { + handle_settings_key(app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)) + } + "Enter/arrows" => { + let code = if is_secondary { + KeyCode::Right + } else { + KeyCode::Enter + }; + handle_settings_key(app, KeyEvent::new(code, KeyModifiers::NONE)); + } + "Enter on path" => { + handle_settings_key(app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) + } + _ => {} + } + } + return; + } + } + + if app.settings_editing_path.is_some() { + return; + } + let row_height = 2u16; let visible_rows = (layout[1].height / row_height).max(1) as usize; let max_start = fields.len().saturating_sub(visible_rows); @@ -1260,6 +1638,7 @@ fn handle_code_language_mouse(app: &mut App, mouse: MouseEvent) { if len == 0 { return; } + let is_secondary = matches!(mouse.kind, MouseEventKind::Down(MouseButton::Right)); match mouse.kind { MouseEventKind::ScrollUp => { app.code_language_selected = app.code_language_selected.saturating_sub(1); @@ -1269,7 +1648,56 @@ fn handle_code_language_mouse(app: &mut App, mouse: MouseEvent) { app.code_language_selected += 1; } } - MouseEventKind::Down(MouseButton::Left) => { + MouseEventKind::Down(MouseButton::Left) | MouseEventKind::Down(MouseButton::Right) => { + let centered = ui::layout::centered_rect(50, 70, terminal_area()); + let inner = Block::bordered().inner(centered); + let hints = [ + "[Up/Down/PgUp/PgDn] Navigate", + "[Enter] Confirm", + "[ESC] Back", + ]; + let hint_lines = pack_hint_lines(&hints, inner.width as usize); + let disabled_notice = + " Some languages are disabled: enable network downloads in intro/settings."; + let has_disabled = !app.config.code_downloads_enabled + && options + .iter() + .any(|(key, _)| is_code_language_disabled(app, key)); + let notice_lines = wrapped_line_count(disabled_notice, inner.width as usize); + let total_height = inner.height as usize; + let show_notice = has_disabled && total_height >= hint_lines.len() + notice_lines + 3; + let desired_footer_height = + hint_lines.len() + if show_notice { notice_lines } else { 0 }; + let footer_height = desired_footer_height.min(total_height.saturating_sub(1)) as u16; + if footer_height > 0 { + let footer_area = Rect::new( + inner.x, + inner.y + inner.height.saturating_sub(footer_height), + inner.width, + footer_height, + ); + if let Some(token) = hint_token_at(footer_area, &hints, mouse.column, mouse.row) { + match token.as_str() { + "Up/Down/PgUp/PgDn" => { + if is_secondary { + app.code_language_selected = + app.code_language_selected.saturating_sub(1); + } else if app.code_language_selected + 1 < len { + app.code_language_selected += 1; + } + } + "Enter" => { + handle_code_language_key( + app, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + } + "ESC" => app.go_to_menu(), + _ => {} + } + return; + } + } let list_area = code_language_list_area(app, terminal_area()); if !point_in_rect(mouse.column, mouse.row, list_area) { return; @@ -1399,6 +1827,7 @@ fn handle_passage_book_mouse(app: &mut App, mouse: MouseEvent) { if options.is_empty() { return; } + let is_secondary = matches!(mouse.kind, MouseEventKind::Down(MouseButton::Right)); match mouse.kind { MouseEventKind::ScrollUp => { app.passage_book_selected = app.passage_book_selected.saturating_sub(1); @@ -1408,7 +1837,50 @@ fn handle_passage_book_mouse(app: &mut App, mouse: MouseEvent) { app.passage_book_selected += 1; } } - MouseEventKind::Down(MouseButton::Left) => { + MouseEventKind::Down(MouseButton::Left) | MouseEventKind::Down(MouseButton::Right) => { + let centered = ui::layout::centered_rect(60, 70, terminal_area()); + let inner = Block::bordered().inner(centered); + let hints = ["[Up/Down] Navigate", "[Enter] Confirm", "[ESC] Back"]; + let hint_lines = pack_hint_lines(&hints, inner.width as usize); + let disabled_notice = + " Some sources are disabled: enable network downloads in intro/settings."; + let has_disabled = !app.config.passage_downloads_enabled + && options + .iter() + .any(|(key, _)| is_passage_option_disabled(app, key)); + let notice_lines = wrapped_line_count(disabled_notice, inner.width as usize); + let total_height = inner.height as usize; + let show_notice = has_disabled && total_height >= hint_lines.len() + notice_lines + 3; + let desired_footer_height = + hint_lines.len() + if show_notice { notice_lines } else { 0 }; + let footer_height = desired_footer_height.min(total_height.saturating_sub(1)) as u16; + if footer_height > 0 { + let footer_area = Rect::new( + inner.x, + inner.y + inner.height.saturating_sub(footer_height), + inner.width, + footer_height, + ); + if let Some(token) = hint_token_at(footer_area, &hints, mouse.column, mouse.row) { + match token.as_str() { + "Up/Down" => { + if is_secondary { + app.passage_book_selected = + app.passage_book_selected.saturating_sub(1); + } else if app.passage_book_selected + 1 < options.len() { + app.passage_book_selected += 1; + } + } + "Enter" => handle_passage_book_key( + app, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ), + "ESC" => app.go_to_menu(), + _ => {} + } + return; + } + } let list_area = passage_book_list_area(app, terminal_area()); if !point_in_rect(mouse.column, mouse.row, list_area) { return; @@ -1577,6 +2049,7 @@ fn handle_passage_intro_mouse(app: &mut App, mouse: MouseEvent) { if app.passage_intro_downloading { return; } + let is_secondary = matches!(mouse.kind, MouseEventKind::Down(MouseButton::Right)); match mouse.kind { MouseEventKind::ScrollUp => { app.passage_intro_selected = app.passage_intro_selected.saturating_sub(1); @@ -1584,7 +2057,67 @@ fn handle_passage_intro_mouse(app: &mut App, mouse: MouseEvent) { MouseEventKind::ScrollDown => { app.passage_intro_selected = (app.passage_intro_selected + 1).min(3); } - MouseEventKind::Down(MouseButton::Left) => { + MouseEventKind::Down(MouseButton::Left) | MouseEventKind::Down(MouseButton::Right) => { + let centered = ui::layout::centered_rect(75, 80, terminal_area()); + let inner = Block::bordered().inner(centered); + let hints = [ + "[Up/Down] Navigate", + "[Left/Right] Adjust", + "[Type/Backspace] Edit", + "[Enter] Confirm", + "[ESC] Cancel", + ]; + let hint_lines = pack_hint_lines(&hints, inner.width as usize); + let footer_height = (hint_lines.len() + 1) as u16; + if footer_height > 0 && footer_height < inner.height { + let footer_area = Rect::new( + inner.x, + inner.y + inner.height.saturating_sub(footer_height), + inner.width, + footer_height, + ); + if let Some(token) = hint_token_at(footer_area, &hints, mouse.column, mouse.row) { + match token.as_str() { + "Up/Down" => { + if is_secondary { + app.passage_intro_selected = + app.passage_intro_selected.saturating_sub(1); + } else { + app.passage_intro_selected = + (app.passage_intro_selected + 1).min(3); + } + } + "Left/Right" => { + handle_passage_intro_key( + app, + KeyEvent::new( + if is_secondary { + KeyCode::Right + } else { + KeyCode::Left + }, + KeyModifiers::NONE, + ), + ); + } + "Type/Backspace" => { + if is_secondary { + handle_passage_intro_key( + app, + KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE), + ); + } + } + "Enter" => handle_passage_intro_key( + app, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ), + "ESC" => app.go_to_menu(), + _ => {} + } + return; + } + } let content = passage_intro_content_area(terminal_area()); if !point_in_rect(mouse.column, mouse.row, content) { return; @@ -1615,7 +2148,10 @@ fn handle_passage_download_progress_key(app: &mut App, key: KeyEvent) { } fn handle_passage_download_progress_mouse(app: &mut App, mouse: MouseEvent) { - if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { + if matches!( + mouse.kind, + MouseEventKind::Down(MouseButton::Left) | MouseEventKind::Down(MouseButton::Right) + ) { app.go_to_menu(); } } @@ -1730,6 +2266,7 @@ fn handle_code_intro_mouse(app: &mut App, mouse: MouseEvent) { if app.code_intro_downloading { return; } + let is_secondary = matches!(mouse.kind, MouseEventKind::Down(MouseButton::Right)); match mouse.kind { MouseEventKind::ScrollUp => { app.code_intro_selected = app.code_intro_selected.saturating_sub(1); @@ -1737,7 +2274,65 @@ fn handle_code_intro_mouse(app: &mut App, mouse: MouseEvent) { MouseEventKind::ScrollDown => { app.code_intro_selected = (app.code_intro_selected + 1).min(3); } - MouseEventKind::Down(MouseButton::Left) => { + MouseEventKind::Down(MouseButton::Left) | MouseEventKind::Down(MouseButton::Right) => { + let centered = ui::layout::centered_rect(75, 80, terminal_area()); + let inner = Block::bordered().inner(centered); + let hints = [ + "[Up/Down] Navigate", + "[Left/Right] Adjust", + "[Type/Backspace] Edit", + "[Enter] Confirm", + "[ESC] Cancel", + ]; + let hint_lines = pack_hint_lines(&hints, inner.width as usize); + let footer_height = (hint_lines.len() + 1) as u16; + if footer_height > 0 && footer_height < inner.height { + let footer_area = Rect::new( + inner.x, + inner.y + inner.height.saturating_sub(footer_height), + inner.width, + footer_height, + ); + if let Some(token) = hint_token_at(footer_area, &hints, mouse.column, mouse.row) { + match token.as_str() { + "Up/Down" => { + if is_secondary { + app.code_intro_selected = app.code_intro_selected.saturating_sub(1); + } else { + app.code_intro_selected = (app.code_intro_selected + 1).min(3); + } + } + "Left/Right" => { + handle_code_intro_key( + app, + KeyEvent::new( + if is_secondary { + KeyCode::Right + } else { + KeyCode::Left + }, + KeyModifiers::NONE, + ), + ); + } + "Type/Backspace" => { + if is_secondary { + handle_code_intro_key( + app, + KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE), + ); + } + } + "Enter" => handle_code_intro_key( + app, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ), + "ESC" => app.go_to_menu(), + _ => {} + } + return; + } + } let content = code_intro_content_area(terminal_area()); if !point_in_rect(mouse.column, mouse.row, content) { return; @@ -1768,7 +2363,10 @@ fn handle_code_download_progress_key(app: &mut App, key: KeyEvent) { } fn handle_code_download_progress_mouse(app: &mut App, mouse: MouseEvent) { - if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { + if matches!( + mouse.kind, + MouseEventKind::Down(MouseButton::Left) | MouseEventKind::Down(MouseButton::Right) + ) { app.cancel_code_download(); app.go_to_menu(); } @@ -1987,7 +2585,10 @@ fn skill_tree_branch_index_from_y( fn handle_skill_tree_mouse(app: &mut App, mouse: MouseEvent) { const DETAIL_SCROLL_STEP: usize = 3; if let Some(branch_id) = app.skill_tree_confirm_unlock { - if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { + if matches!( + mouse.kind, + MouseEventKind::Down(MouseButton::Left) | MouseEventKind::Down(MouseButton::Right) + ) { let area = terminal_area(); let dialog_width = 72u16.min(area.width.saturating_sub(4)); let sentence_one = "Once unlocked, the default adaptive drill will mix in keys in this branch that are unlocked."; @@ -2006,7 +2607,11 @@ fn handle_skill_tree_mouse(app: &mut App, mouse: MouseEvent) { let dialog_y = area.y + area.height.saturating_sub(dialog_height) / 2; let dialog = Rect::new(dialog_x, dialog_y, dialog_width, dialog_height); if point_in_rect(mouse.column, mouse.row, dialog) { - if mouse.column < dialog.x + dialog.width / 2 { + if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Right)) + || mouse.column >= dialog.x + dialog.width / 2 + { + // right-click (or right half) maps to "No" + } else { app.unlock_branch(branch_id); } app.skill_tree_confirm_unlock = None; @@ -2028,7 +2633,118 @@ fn handle_skill_tree_mouse(app: &mut App, mouse: MouseEvent) { .saturating_add(DETAIL_SCROLL_STEP) .min(max_scroll); } - MouseEventKind::Down(MouseButton::Left) => { + MouseEventKind::Down(MouseButton::Left) | MouseEventKind::Down(MouseButton::Right) => { + let is_secondary = matches!(mouse.kind, MouseEventKind::Down(MouseButton::Right)); + let screen = terminal_area(); + let centered = skill_tree_popup_rect(screen); + let inner = Block::bordered().inner(centered); + let branches = selectable_branches(); + let selected = app + .skill_tree_selected + .min(branches.len().saturating_sub(1)); + let bp = app.skill_tree.branch_progress(branches[selected]); + let (footer_hints, footer_notice) = if *app.skill_tree.branch_status(branches[selected]) + == engine::skill_tree::BranchStatus::Locked + { + ( + vec![ + "[↑↓/jk] Navigate", + "[PgUp/PgDn or Ctrl+U/Ctrl+D] Scroll", + "[q] Back", + ], + Some("Complete a-z to unlock branches"), + ) + } else if bp.status == engine::skill_tree::BranchStatus::Available { + ( + vec![ + "[Enter] Unlock", + "[↑↓/jk] Navigate", + "[PgUp/PgDn or Ctrl+U/Ctrl+D] Scroll", + "[q] Back", + ], + None, + ) + } else if bp.status == engine::skill_tree::BranchStatus::InProgress { + ( + vec![ + "[Enter] Start Drill", + "[↑↓/jk] Navigate", + "[PgUp/PgDn or Ctrl+U/Ctrl+D] Scroll", + "[q] Back", + ], + 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 + .map(|text| wrapped_line_count(text, inner.width as usize)) + .unwrap_or(0); + let show_notice = footer_notice.is_some() + && (inner.height as usize >= hint_lines.len() + notice_lines + 8); + let footer_needed = hint_lines.len() + if show_notice { notice_lines } else { 0 } + 1; + let footer_height = footer_needed + .min(inner.height.saturating_sub(5) as usize) + .max(1) as u16; + let footer_area = Rect::new( + inner.x, + inner.y + inner.height.saturating_sub(footer_height), + inner.width, + footer_height, + ); + if let Some(token) = hint_token_at( + footer_area, + &footer_hints + .iter() + .map(|s| s.as_ref()) + .collect::>(), + mouse.column, + mouse.row, + ) { + match token.as_str() { + "q" => app.go_to_menu(), + "Enter" => { + let branch_id = branches[selected]; + let status = app.skill_tree.branch_status(branch_id).clone(); + if status == BranchStatus::Available { + app.skill_tree_confirm_unlock = Some(branch_id); + } else if status == BranchStatus::InProgress { + app.start_branch_drill(branch_id); + } + } + "↑↓/jk" => { + if is_secondary { + if app.skill_tree_selected + 1 < branches.len() { + app.skill_tree_selected += 1; + } + } else { + app.skill_tree_selected = app.skill_tree_selected.saturating_sub(1); + } + app.skill_tree_detail_scroll = 0; + } + "PgUp/PgDn or Ctrl+U/Ctrl+D" => { + let max_scroll = skill_tree_detail_max_scroll(app); + app.skill_tree_detail_scroll = if is_secondary { + app.skill_tree_detail_scroll + .saturating_add(DETAIL_SCROLL_STEP) + .min(max_scroll) + } else { + app.skill_tree_detail_scroll + .saturating_sub(DETAIL_SCROLL_STEP) + }; + } + _ => {} + } + return; + } let branches = selectable_branches(); let layout = skill_tree_interactive_areas(app, terminal_area()); if point_in_rect(mouse.column, mouse.row, layout.branch_area) { @@ -2661,7 +3377,7 @@ fn render_milestone_overlay( ))); lines.push(Line::from("")); lines.push(Line::from(Span::styled( - " Press [t] from the menu to open the Skill Tree", + " Press [t] to open the Skill Tree now", Style::default().fg(colors.text_pending()), ))); } @@ -2701,7 +3417,7 @@ fn render_milestone_overlay( ))); lines.push(Line::from("")); lines.push(Line::from(Span::styled( - " Press [t] from the menu to open the Skill Tree", + " Press [t] to open the Skill Tree now", Style::default().fg(colors.text_pending()), ))); } @@ -2798,6 +3514,8 @@ fn render_milestone_overlay( let footer_area = Rect::new(inner.x, footer_y, inner.width, 1); let footer_text = if let Some(ms) = app.post_drill_input_lock_remaining_ms() { format!(" Input temporarily blocked ({ms}ms remaining)") + } else if milestone_supports_skill_tree_shortcut(milestone) { + " [t] Open Skill Tree [Any other key] Continue".to_string() } else { " Press any key to continue".to_string() }; @@ -2956,6 +3674,28 @@ mod review_tests { assert_eq!(app.milestone_queue.len(), 1); } + #[test] + fn milestone_t_shortcut_opens_skill_tree_for_congrats_popup() { + let mut app = test_app(); + app.screen = AppScreen::DrillResult; + app.milestone_queue + .push_back(crate::app::KeyMilestonePopup { + kind: crate::app::MilestoneKind::BranchesAvailable, + keys: vec![], + finger_info: vec![], + message: "msg", + branch_ids: vec![engine::skill_tree::BranchId::Capitals], + }); + + handle_key( + &mut app, + KeyEvent::new(KeyCode::Char('t'), KeyModifiers::NONE), + ); + + assert!(app.milestone_queue.is_empty()); + assert_eq!(app.screen, AppScreen::SkillTree); + } + #[test] fn overlay_mode_height_boundaries() { assert_eq!(overlay_keyboard_mode(14), 0); @@ -3718,6 +4458,21 @@ mod review_tests { render_skill_tree_to_string_with_size(app, 120, 40) } + fn render_app_to_string_with_size(app: &App, width: u16, height: u16) -> String { + let backend = ratatui::backend::TestBackend::new(width, height); + let mut terminal = Terminal::new(backend).unwrap(); + terminal.draw(|frame| render(frame, app)).unwrap(); + let buf = terminal.backend().buffer().clone(); + let mut text = String::new(); + for y in 0..buf.area.height { + for x in 0..buf.area.width { + text.push(buf[(x, y)].symbol().chars().next().unwrap_or(' ')); + } + text.push('\n'); + } + text + } + #[test] fn footer_shows_completion_error_and_clears_on_keystroke() { let mut app = test_app(); @@ -3889,6 +4644,47 @@ mod review_tests { assert!(output.contains("Proceed? (y/n)")); } + #[test] + fn milestone_popup_footer_shows_skill_tree_hint() { + let mut app = test_app(); + app.screen = AppScreen::DrillResult; + app.milestone_queue + .push_back(crate::app::KeyMilestonePopup { + kind: crate::app::MilestoneKind::BranchesAvailable, + keys: vec![], + finger_info: vec![], + message: "msg", + branch_ids: vec![engine::skill_tree::BranchId::Capitals], + }); + + let output = render_app_to_string_with_size(&app, 100, 28); + assert!(output.contains("[t] Open Skill Tree")); + } + + #[test] + fn keyboard_explorer_non_shifted_selection_clears_latched_shift() { + let mut app = test_app(); + app.screen = AppScreen::Keyboard; + app.shift_held = true; + + keyboard_explorer_select_key(&mut app, 'a'); + + assert_eq!(app.keyboard_explorer_selected, Some('a')); + assert!(!app.shift_held); + } + + #[test] + fn keyboard_explorer_shifted_selection_keeps_latched_shift() { + let mut app = test_app(); + app.screen = AppScreen::Keyboard; + app.shift_held = true; + + keyboard_explorer_select_key(&mut app, 'A'); + + assert_eq!(app.keyboard_explorer_selected, Some('A')); + assert!(app.shift_held); + } + #[test] fn skill_tree_layout_switches_with_width() { assert!(!use_side_by_side_layout(99)); @@ -5233,31 +6029,35 @@ fn handle_keyboard_explorer_key(app: &mut App, key: KeyEvent) { KeyCode::Esc => app.go_to_menu(), KeyCode::Char('q') if app.keyboard_explorer_selected.is_none() => app.go_to_menu(), KeyCode::Char(ch) => { - app.keyboard_explorer_selected = Some(ch); - app.key_accuracy(ch, false); - app.key_accuracy(ch, true); + keyboard_explorer_select_key(app, ch); } KeyCode::Tab => { - app.keyboard_explorer_selected = Some('\t'); - app.key_accuracy('\t', false); - app.key_accuracy('\t', true); + keyboard_explorer_select_key(app, '\t'); } KeyCode::Enter => { - app.keyboard_explorer_selected = Some('\n'); - app.key_accuracy('\n', false); - app.key_accuracy('\n', true); + keyboard_explorer_select_key(app, '\n'); } KeyCode::Backspace => { - app.keyboard_explorer_selected = Some('\x08'); - app.key_accuracy('\x08', false); - app.key_accuracy('\x08', true); + keyboard_explorer_select_key(app, '\x08'); } _ => {} } } +fn keyboard_explorer_select_key(app: &mut App, ch: char) { + app.keyboard_explorer_selected = Some(ch); + app.key_accuracy(ch, false); + app.key_accuracy(ch, true); + if app.shift_held && app.keyboard_model.shifted_to_base(ch).is_none() { + app.shift_held = false; + } +} + fn handle_keyboard_explorer_mouse(app: &mut App, mouse: MouseEvent) { - if !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { + if !matches!( + mouse.kind, + MouseEventKind::Down(MouseButton::Left) | MouseEventKind::Down(MouseButton::Right) + ) { return; } let area = terminal_area(); @@ -5270,23 +6070,40 @@ fn handle_keyboard_explorer_mouse(app: &mut App, mouse: MouseEvent) { Constraint::Length(1), ]) .split(area); - if point_in_rect(mouse.column, mouse.row, layout[3]) { + let footer_hints = ["[ESC] Back"]; + if hint_token_at(layout[3], &footer_hints, mouse.column, mouse.row).is_some() + || point_in_rect(mouse.column, mouse.row, layout[3]) + { app.go_to_menu(); return; } - if point_in_rect(mouse.column, mouse.row, layout[1]) - && let Some(ch) = KeyboardDiagram::key_at_position( + if point_in_rect(mouse.column, mouse.row, layout[1]) { + if KeyboardDiagram::shift_at_position( layout[1], &app.keyboard_model, false, mouse.column, mouse.row, - ) - { - app.keyboard_explorer_selected = Some(ch); - app.key_accuracy(ch, false); - app.key_accuracy(ch, true); + ) { + app.shift_held = !app.shift_held; + return; + } + + if let Some(mut ch) = KeyboardDiagram::key_at_position( + layout[1], + &app.keyboard_model, + false, + mouse.column, + mouse.row, + ) { + if app.shift_held + && let Some(shifted) = app.keyboard_model.base_to_shifted(ch) + { + ch = shifted; + } + keyboard_explorer_select_key(app, ch); + } } } @@ -5314,7 +6131,7 @@ fn render_keyboard_explorer(frame: &mut ratatui::Frame, app: &App) { .add_modifier(Modifier::BOLD), )), Line::from(Span::styled( - "Press any key to see details", + "Press any key or click a key", Style::default().fg(colors.text_pending()), )), ]; diff --git a/src/session/drill.rs b/src/session/drill.rs index 7cb4a95..660adbe 100644 --- a/src/session/drill.rs +++ b/src/session/drill.rs @@ -17,6 +17,7 @@ pub struct DrillState { pub finished_at: Option, pub typo_flags: HashSet, pub synthetic_spans: Vec, + pub auto_indent_after_newline: bool, } impl DrillState { @@ -29,6 +30,7 @@ impl DrillState { finished_at: None, typo_flags: HashSet::new(), synthetic_spans: Vec::new(), + auto_indent_after_newline: true, } } @@ -263,6 +265,18 @@ mod tests { assert_eq!(drill.accuracy(), 100.0); } + #[test] + fn test_correct_enter_no_auto_indent_when_disabled() { + let mut drill = DrillState::new("if x:\n\tpass"); + drill.auto_indent_after_newline = false; + for ch in "if x:".chars() { + input::process_char(&mut drill, ch); + } + input::process_char(&mut drill, '\n'); + let expected_cursor = "if x:\n".chars().count(); + assert_eq!(drill.cursor, expected_cursor); + } + #[test] fn test_nested_synthetic_spans_collapse_to_single_error() { let mut drill = DrillState::new("abcd\nefgh"); diff --git a/src/session/input.rs b/src/session/input.rs index 582cf00..f20f2d0 100644 --- a/src/session/input.rs +++ b/src/session/input.rs @@ -47,9 +47,9 @@ pub fn process_char(drill: &mut DrillState, ch: char) -> Option } else if correct { drill.input.push(CharStatus::Correct); drill.cursor += 1; - // IDE-like behavior: when Enter is correctly typed, auto-consume + // Optional IDE-like behavior: when Enter is correctly typed, auto-consume // indentation whitespace on the next line. - if ch == '\n' { + if ch == '\n' && drill.auto_indent_after_newline { apply_auto_indent_after_newline(drill); } } else if ch == '\n' { diff --git a/src/ui/components/keyboard_diagram.rs b/src/ui/components/keyboard_diagram.rs index 369937c..e0130e2 100644 --- a/src/ui/components/keyboard_diagram.rs +++ b/src/ui/components/keyboard_diagram.rs @@ -275,6 +275,25 @@ impl KeyboardDiagram<'_> { } } + pub fn shift_at_position( + area: Rect, + model: &KeyboardModel, + compact: bool, + x: u16, + y: u16, + ) -> bool { + let inner = Block::bordered().inner(area); + if compact { + return shift_at_compact_position(inner, model, x, y); + } + + if inner.height >= 4 && inner.width >= 75 { + shift_at_full_position(inner, model, x, y) + } else { + shift_at_full_fallback_position(inner, model, x, y) + } + } + fn render_compact(&self, inner: Rect, buf: &mut Buffer) { let colors = &self.theme.colors; let letter_rows = self.model.letter_rows(); @@ -707,6 +726,45 @@ fn key_at_compact_position(inner: Rect, model: &KeyboardModel, x: u16, y: u16) - None } +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 { + 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 shift_row_y = inner.y + 2; + if y != shift_row_y { + return false; + } + let left_shift = Rect::new(start_x, shift_row_y, 3, 1); + if rect_contains(left_shift, x, y) { + return true; + } + 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); + rect_contains(right_shift, x, y) +} + fn key_at_full_position(inner: Rect, model: &KeyboardModel, x: u16, y: u16) -> Option { let key_width: u16 = 5; let offsets: &[u16] = &[0, 5, 5, 6]; @@ -803,6 +861,41 @@ fn key_at_full_position(inner: Rect, model: &KeyboardModel, x: u16, y: u16) -> O None } +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 shift_row_y = inner.y + 3; + if y != shift_row_y { + return false; + } + + let left_shift = Rect::new(start_x, shift_row_y, 6, 1); + if rect_contains(left_shift, x, y) { + return true; + } + 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); + rect_contains(right_shift, x, y) +} + fn key_at_full_fallback_position( inner: Rect, model: &KeyboardModel, @@ -843,3 +936,7 @@ fn key_at_full_fallback_position( } None } + +fn shift_at_full_fallback_position(_inner: Rect, _model: &KeyboardModel, _x: u16, _y: u16) -> bool { + false +} diff --git a/src/ui/components/typing_area.rs b/src/ui/components/typing_area.rs index 84f5889..3ad8615 100644 --- a/src/ui/components/typing_area.rs +++ b/src/ui/components/typing_area.rs @@ -162,10 +162,13 @@ fn build_render_tokens(target: &[char]) -> Vec { } '\t' => { let tab_width = 4 - (col % 4); - let mut display = String::from("\u{2192}"); // → - for _ in 1..tab_width { - display.push('\u{00b7}'); // · + let mut display = String::new(); + if tab_width > 1 { + for _ in 0..(tab_width - 1) { + display.push('\u{2500}'); // ─ + } } + display.push('\u{21E5}'); // ⇥ tokens.push(RenderToken { target_idx: i, display, @@ -299,18 +302,18 @@ mod tests { let target: Vec = "\tx".chars().collect(); let tokens = build_render_tokens(&target); assert_eq!(tokens.len(), 2); - // Tab at col 0: width = 4 - (0 % 4) = 4 => "→···" - assert_eq!(tokens[0].display, "\u{2192}\u{00b7}\u{00b7}\u{00b7}"); + // Tab at col 0: width = 4 - (0 % 4) = 4 => "───⇥" + assert_eq!(tokens[0].display, "\u{2500}\u{2500}\u{2500}\u{21E5}"); assert!(!tokens[0].is_line_break); assert_eq!(tokens[0].target_idx, 0); } #[test] fn test_render_tokens_tab_alignment() { - // "ab\t" -> col 2, tab_width = 4 - (2 % 4) = 2 => "→·" + // "ab\t" -> col 2, tab_width = 4 - (2 % 4) = 2 => "─⇥" let target: Vec = "ab\t".chars().collect(); let tokens = build_render_tokens(&target); - assert_eq!(tokens[2].display, "\u{2192}\u{00b7}"); + assert_eq!(tokens[2].display, "\u{2500}\u{21E5}"); } #[test] @@ -320,6 +323,6 @@ mod tests { let tokens = build_render_tokens(&target); assert_eq!(tokens.len(), 3); assert!(tokens[0].is_line_break); - assert_eq!(tokens[1].display, "\u{2192}\u{00b7}\u{00b7}\u{00b7}"); + assert_eq!(tokens[1].display, "\u{2500}\u{2500}\u{2500}\u{21E5}"); } } diff --git a/tests/test_profile_fixtures.rs b/tests/test_profile_fixtures.rs index f027ccb..87a7d8d 100644 --- a/tests/test_profile_fixtures.rs +++ b/tests/test_profile_fixtures.rs @@ -14,6 +14,7 @@ const ALL_PROFILES: &[&str] = &[ "01-brand-new.json", "02-early-lowercase.json", "03-mid-lowercase.json", + "03-near-lowercase-complete.json", "04-lowercase-complete.json", "05-multi-branch.json", "06-advanced.json", @@ -25,7 +26,7 @@ static GENERATE: Once = Once::new(); /// Ensure test-profiles/ exists by running the generator binary (once per test run). fn ensure_profiles_generated() { GENERATE.call_once(|| { - if Path::new("test-profiles/07-fully-complete.json").exists() { + if Path::new("test-profiles/03-near-lowercase-complete.json").exists() { return; } let status = Command::new("cargo") @@ -197,6 +198,11 @@ fn profile_03_mid_lowercase_valid() { assert_profile_valid("03-mid-lowercase.json"); } +#[test] +fn profile_03_near_lowercase_complete_valid() { + assert_profile_valid("03-near-lowercase-complete.json"); +} + #[test] fn profile_04_lowercase_complete_valid() { assert_profile_valid("04-lowercase-complete.json"); @@ -286,6 +292,7 @@ fn in_progress_keys_have_partial_confidence() { for name in &[ "02-early-lowercase.json", "03-mid-lowercase.json", + "03-near-lowercase-complete.json", "05-multi-branch.json", "06-advanced.json", ] { @@ -320,6 +327,7 @@ fn synthetic_score_level_in_expected_range() { ("01-brand-new.json", 1, 1), ("02-early-lowercase.json", 1, 3), ("03-mid-lowercase.json", 2, 4), + ("03-near-lowercase-complete.json", 3, 5), ("04-lowercase-complete.json", 4, 6), ("05-multi-branch.json", 6, 8), ("06-advanced.json", 10, 14), @@ -446,6 +454,25 @@ fn profile_specific_confidence_bands() { } } + // Profile 03-near: first 24 lowercase keys mastered, one key near mastery. + { + let data = load_profile("03-near-lowercase-complete.json"); + let stats = &data.key_stats.stats.stats; + let almost_all_lc: Vec = "etaoinshrdlcumwfgypbvkjx".chars().collect(); + for &k in &almost_all_lc { + let conf = stats[&k].confidence; + assert!( + conf >= 1.0, + "03-near: key '{k}' should be mastered, got {conf}" + ); + } + let q_conf = stats[&'q'].confidence; + assert!( + (0.8..1.0).contains(&q_conf), + "03-near: key 'q' should be near mastery (0.8-1.0), got {q_conf}" + ); + } + // Profile 05: capitals L2 partial (J,D,R,C,E), numbers partial (1,2,3), // punctuation partial (.,',') {