More balanced adaptive drill generation, Tab fixes, mouse control tweaks

This commit is contained in:
2026-02-28 21:11:11 +00:00
parent 8b8703b9b9
commit 5c56a9c3c6
9 changed files with 1789 additions and 174 deletions

View File

@@ -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<char> = all_keys
.iter()
.copied()
.filter(|ch| {
matches!(
ch,
'=' | '+'
| '*'
| '/'
| '-'
| '{'
| '}'
| '['
| ']'
| '<'
| '>'
| '&'
| '|'
| '^'
| '~'
| '@'
| '#'
| '$'
| '%'
| '_'
| '\\'
| '`'
)
})
.collect();
if code_active {
let symbol_keys: Vec<char> = 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<char>) -> Option<char> {
})
}
fn count_matching_chars(text: &str, keys: &[char]) -> usize {
text.chars().filter(|ch| keys.contains(ch)).count()
}
fn first_alpha_index(chars: &[char]) -> Option<usize> {
chars.iter().position(|ch| ch.is_ascii_alphabetic())
}
fn top_up_capitals(
text: String,
cap_keys: &[char],
focused: Option<char>,
needed: usize,
rng: &mut SmallRng,
) -> String {
if needed == 0 || cap_keys.is_empty() {
return text;
}
let mut words: Vec<String> = 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<char> = 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<String> = 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<char>, 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<char>,
needed: usize,
rng: &mut SmallRng,
) -> String {
if needed == 0 || digit_keys.is_empty() {
return text;
}
let mut words: Vec<String> = 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<char>,
needed: usize,
rng: &mut SmallRng,
) -> String {
if needed == 0 || symbol_keys.is_empty() {
return text;
}
let mut words: Vec<String> = 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<char> = 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<usize> = 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<char>,
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<char> = 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).

View File

@@ -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()),

View File

@@ -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<char> = text.chars().collect();
let mut count = chars.iter().filter(|&&ch| ch == focused_upper).count();
if count >= min_count {
let mut words: Vec<String> = 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<char> {
word.chars()
.find(|ch| ch.is_ascii_alphabetic())
.map(|ch| ch.to_ascii_uppercase())
}
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();
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<char> = 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<String>, 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::<usize>();
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::<usize>();
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}"
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -17,6 +17,7 @@ pub struct DrillState {
pub finished_at: Option<Instant>,
pub typo_flags: HashSet<usize>,
pub synthetic_spans: Vec<SyntheticSpan>,
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");

View File

@@ -47,9 +47,9 @@ pub fn process_char(drill: &mut DrillState, ch: char) -> Option<KeystrokeEvent>
} 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' {

View File

@@ -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<char> {
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
}

View File

@@ -162,10 +162,13 @@ fn build_render_tokens(target: &[char]) -> Vec<RenderToken> {
}
'\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<char> = "\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<char> = "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}");
}
}

View File

@@ -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<char> = "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 (.,',')
{