More balanced adaptive drill generation, Tab fixes, mouse control tweaks
This commit is contained in:
509
src/app.rs
509
src/app.rs
@@ -696,7 +696,9 @@ impl App {
|
|||||||
pub fn start_drill(&mut self) {
|
pub fn start_drill(&mut self) {
|
||||||
self.clear_post_drill_input_lock();
|
self.clear_post_drill_input_lock();
|
||||||
let (text, source_info) = self.generate_text();
|
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_source_info = source_info;
|
||||||
self.drill_events.clear();
|
self.drill_events.clear();
|
||||||
self.screen = AppScreen::Drill;
|
self.screen = AppScreen::Drill;
|
||||||
@@ -803,37 +805,37 @@ impl App {
|
|||||||
BranchStatus::InProgress | BranchStatus::Complete
|
BranchStatus::InProgress | BranchStatus::Complete
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
let symbol_keys: Vec<char> = all_keys
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.filter(|ch| {
|
||||||
|
matches!(
|
||||||
|
ch,
|
||||||
|
'=' | '+'
|
||||||
|
| '*'
|
||||||
|
| '/'
|
||||||
|
| '-'
|
||||||
|
| '{'
|
||||||
|
| '}'
|
||||||
|
| '['
|
||||||
|
| ']'
|
||||||
|
| '<'
|
||||||
|
| '>'
|
||||||
|
| '&'
|
||||||
|
| '|'
|
||||||
|
| '^'
|
||||||
|
| '~'
|
||||||
|
| '@'
|
||||||
|
| '#'
|
||||||
|
| '$'
|
||||||
|
| '%'
|
||||||
|
| '_'
|
||||||
|
| '\\'
|
||||||
|
| '`'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
if code_active {
|
if code_active {
|
||||||
let symbol_keys: Vec<char> = all_keys
|
|
||||||
.iter()
|
|
||||||
.copied()
|
|
||||||
.filter(|ch| {
|
|
||||||
matches!(
|
|
||||||
ch,
|
|
||||||
'=' | '+'
|
|
||||||
| '*'
|
|
||||||
| '/'
|
|
||||||
| '-'
|
|
||||||
| '{'
|
|
||||||
| '}'
|
|
||||||
| '['
|
|
||||||
| ']'
|
|
||||||
| '<'
|
|
||||||
| '>'
|
|
||||||
| '&'
|
|
||||||
| '|'
|
|
||||||
| '^'
|
|
||||||
| '~'
|
|
||||||
| '@'
|
|
||||||
| '#'
|
|
||||||
| '$'
|
|
||||||
| '%'
|
|
||||||
| '_'
|
|
||||||
| '\\'
|
|
||||||
| '`'
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
if !symbol_keys.is_empty() {
|
if !symbol_keys.is_empty() {
|
||||||
let mut rng = SmallRng::from_rng(&mut self.rng).unwrap();
|
let mut rng = SmallRng::from_rng(&mut self.rng).unwrap();
|
||||||
text = code_patterns::apply_code_symbols(
|
text = code_patterns::apply_code_symbols(
|
||||||
@@ -846,10 +848,41 @@ impl App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Apply whitespace line breaks if newline is in scope
|
// 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);
|
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)
|
(text, None)
|
||||||
}
|
}
|
||||||
DrillMode::Code => {
|
DrillMode::Code => {
|
||||||
@@ -1441,7 +1474,9 @@ impl App {
|
|||||||
pub fn retry_drill(&mut self) {
|
pub fn retry_drill(&mut self) {
|
||||||
if let Some(ref drill) = self.drill {
|
if let Some(ref drill) = self.drill {
|
||||||
let text: String = drill.target.iter().collect();
|
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.drill_events.clear();
|
||||||
self.last_result = None;
|
self.last_result = None;
|
||||||
self.screen = AppScreen::Drill;
|
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)]
|
#[cfg(test)]
|
||||||
impl App {
|
impl App {
|
||||||
pub fn new_test() -> Self {
|
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.
|
/// Helper: make the current drill look "completed" so finish_drill() processes it.
|
||||||
fn complete_current_drill(app: &mut App) {
|
fn complete_current_drill(app: &mut App) {
|
||||||
if let Some(ref mut drill) = app.drill {
|
if let Some(ref mut drill) = app.drill {
|
||||||
@@ -2627,6 +2978,98 @@ mod tests {
|
|||||||
assert_eq!(lowercase_generation_focus(None), None);
|
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.
|
/// Helper: make a key just below mastery in ranked stats.
|
||||||
/// Uses timing slightly above target (confidence ≈ 0.98), so one fast drill hit
|
/// 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).
|
/// will push it over 1.0. target_time ≈ 342.86ms (60000/175 CPM).
|
||||||
|
|||||||
@@ -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 {
|
fn build_profile_04() -> ExportData {
|
||||||
// Lowercase Complete (level 20), all others Available
|
// Lowercase Complete (level 20), all others Available
|
||||||
let skill_tree = make_skill_tree_progress(vec![
|
let skill_tree = make_skill_tree_progress(vec![
|
||||||
@@ -741,6 +800,10 @@ fn main() {
|
|||||||
("01-brand-new", build_profile_01()),
|
("01-brand-new", build_profile_01()),
|
||||||
("02-early-lowercase", build_profile_02()),
|
("02-early-lowercase", build_profile_02()),
|
||||||
("03-mid-lowercase", build_profile_03()),
|
("03-mid-lowercase", build_profile_03()),
|
||||||
|
(
|
||||||
|
"03-near-lowercase-complete",
|
||||||
|
build_profile_03_near_lowercase_complete(),
|
||||||
|
),
|
||||||
("04-lowercase-complete", build_profile_04()),
|
("04-lowercase-complete", build_profile_04()),
|
||||||
("05-multi-branch", build_profile_05()),
|
("05-multi-branch", build_profile_05()),
|
||||||
("06-advanced", build_profile_06()),
|
("06-advanced", build_profile_06()),
|
||||||
|
|||||||
@@ -13,103 +13,230 @@ pub fn apply_capitalization(
|
|||||||
return text.to_string();
|
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 focused_upper = focused.filter(|ch| ch.is_ascii_uppercase());
|
||||||
|
let mut words: Vec<String> = text.split_whitespace().map(|w| w.to_string()).collect();
|
||||||
let mut result = String::with_capacity(text.len());
|
if words.is_empty() {
|
||||||
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 {
|
|
||||||
return text.to_string();
|
return text.to_string();
|
||||||
}
|
}
|
||||||
|
|
||||||
// First, capitalize matching word starts.
|
// Prefer capitals at starts of words (sentence starts always when possible).
|
||||||
for i in 0..chars.len() {
|
let mut at_sentence_start = true;
|
||||||
if count >= min_count {
|
for i in 0..words.len() {
|
||||||
break;
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
let is_word_start =
|
let next_upper = match word_start_upper(&words[i + 1]) {
|
||||||
i == 0 || matches!(chars.get(i.saturating_sub(1)), Some(' ' | '\n' | '\t'));
|
Some(upper) if unlocked_capitals.contains(&upper) => upper,
|
||||||
if is_word_start {
|
_ => {
|
||||||
chars[i] = focused_upper;
|
i += 1;
|
||||||
count += 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.
|
// Focused capitals should show up multiple times for focused drills.
|
||||||
for ch in &mut chars {
|
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 {
|
if count >= min_count {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if *ch == focused_lower {
|
if !word_starts_with_lower(word, focused_lower) {
|
||||||
*ch = focused_upper;
|
continue;
|
||||||
|
}
|
||||||
|
if capitalize_word_start(word) == Some(focused_upper) {
|
||||||
count += 1;
|
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)]
|
#[cfg(test)]
|
||||||
@@ -182,4 +309,28 @@ mod tests {
|
|||||||
"Expected at least 3 focused capitals, got {focused_count} in: {result}"
|
"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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
917
src/main.rs
917
src/main.rs
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,7 @@ pub struct DrillState {
|
|||||||
pub finished_at: Option<Instant>,
|
pub finished_at: Option<Instant>,
|
||||||
pub typo_flags: HashSet<usize>,
|
pub typo_flags: HashSet<usize>,
|
||||||
pub synthetic_spans: Vec<SyntheticSpan>,
|
pub synthetic_spans: Vec<SyntheticSpan>,
|
||||||
|
pub auto_indent_after_newline: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DrillState {
|
impl DrillState {
|
||||||
@@ -29,6 +30,7 @@ impl DrillState {
|
|||||||
finished_at: None,
|
finished_at: None,
|
||||||
typo_flags: HashSet::new(),
|
typo_flags: HashSet::new(),
|
||||||
synthetic_spans: Vec::new(),
|
synthetic_spans: Vec::new(),
|
||||||
|
auto_indent_after_newline: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,6 +265,18 @@ mod tests {
|
|||||||
assert_eq!(drill.accuracy(), 100.0);
|
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]
|
#[test]
|
||||||
fn test_nested_synthetic_spans_collapse_to_single_error() {
|
fn test_nested_synthetic_spans_collapse_to_single_error() {
|
||||||
let mut drill = DrillState::new("abcd\nefgh");
|
let mut drill = DrillState::new("abcd\nefgh");
|
||||||
|
|||||||
@@ -47,9 +47,9 @@ pub fn process_char(drill: &mut DrillState, ch: char) -> Option<KeystrokeEvent>
|
|||||||
} else if correct {
|
} else if correct {
|
||||||
drill.input.push(CharStatus::Correct);
|
drill.input.push(CharStatus::Correct);
|
||||||
drill.cursor += 1;
|
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.
|
// indentation whitespace on the next line.
|
||||||
if ch == '\n' {
|
if ch == '\n' && drill.auto_indent_after_newline {
|
||||||
apply_auto_indent_after_newline(drill);
|
apply_auto_indent_after_newline(drill);
|
||||||
}
|
}
|
||||||
} else if ch == '\n' {
|
} else if ch == '\n' {
|
||||||
|
|||||||
@@ -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) {
|
fn render_compact(&self, inner: Rect, buf: &mut Buffer) {
|
||||||
let colors = &self.theme.colors;
|
let colors = &self.theme.colors;
|
||||||
let letter_rows = self.model.letter_rows();
|
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
|
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> {
|
fn key_at_full_position(inner: Rect, model: &KeyboardModel, x: u16, y: u16) -> Option<char> {
|
||||||
let key_width: u16 = 5;
|
let key_width: u16 = 5;
|
||||||
let offsets: &[u16] = &[0, 5, 5, 6];
|
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
|
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(
|
fn key_at_full_fallback_position(
|
||||||
inner: Rect,
|
inner: Rect,
|
||||||
model: &KeyboardModel,
|
model: &KeyboardModel,
|
||||||
@@ -843,3 +936,7 @@ fn key_at_full_fallback_position(
|
|||||||
}
|
}
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn shift_at_full_fallback_position(_inner: Rect, _model: &KeyboardModel, _x: u16, _y: u16) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|||||||
@@ -162,10 +162,13 @@ fn build_render_tokens(target: &[char]) -> Vec<RenderToken> {
|
|||||||
}
|
}
|
||||||
'\t' => {
|
'\t' => {
|
||||||
let tab_width = 4 - (col % 4);
|
let tab_width = 4 - (col % 4);
|
||||||
let mut display = String::from("\u{2192}"); // →
|
let mut display = String::new();
|
||||||
for _ in 1..tab_width {
|
if tab_width > 1 {
|
||||||
display.push('\u{00b7}'); // ·
|
for _ in 0..(tab_width - 1) {
|
||||||
|
display.push('\u{2500}'); // ─
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
display.push('\u{21E5}'); // ⇥
|
||||||
tokens.push(RenderToken {
|
tokens.push(RenderToken {
|
||||||
target_idx: i,
|
target_idx: i,
|
||||||
display,
|
display,
|
||||||
@@ -299,18 +302,18 @@ mod tests {
|
|||||||
let target: Vec<char> = "\tx".chars().collect();
|
let target: Vec<char> = "\tx".chars().collect();
|
||||||
let tokens = build_render_tokens(&target);
|
let tokens = build_render_tokens(&target);
|
||||||
assert_eq!(tokens.len(), 2);
|
assert_eq!(tokens.len(), 2);
|
||||||
// Tab at col 0: width = 4 - (0 % 4) = 4 => "→···"
|
// Tab at col 0: width = 4 - (0 % 4) = 4 => "───⇥"
|
||||||
assert_eq!(tokens[0].display, "\u{2192}\u{00b7}\u{00b7}\u{00b7}");
|
assert_eq!(tokens[0].display, "\u{2500}\u{2500}\u{2500}\u{21E5}");
|
||||||
assert!(!tokens[0].is_line_break);
|
assert!(!tokens[0].is_line_break);
|
||||||
assert_eq!(tokens[0].target_idx, 0);
|
assert_eq!(tokens[0].target_idx, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_render_tokens_tab_alignment() {
|
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 target: Vec<char> = "ab\t".chars().collect();
|
||||||
let tokens = build_render_tokens(&target);
|
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]
|
#[test]
|
||||||
@@ -320,6 +323,6 @@ mod tests {
|
|||||||
let tokens = build_render_tokens(&target);
|
let tokens = build_render_tokens(&target);
|
||||||
assert_eq!(tokens.len(), 3);
|
assert_eq!(tokens.len(), 3);
|
||||||
assert!(tokens[0].is_line_break);
|
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}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const ALL_PROFILES: &[&str] = &[
|
|||||||
"01-brand-new.json",
|
"01-brand-new.json",
|
||||||
"02-early-lowercase.json",
|
"02-early-lowercase.json",
|
||||||
"03-mid-lowercase.json",
|
"03-mid-lowercase.json",
|
||||||
|
"03-near-lowercase-complete.json",
|
||||||
"04-lowercase-complete.json",
|
"04-lowercase-complete.json",
|
||||||
"05-multi-branch.json",
|
"05-multi-branch.json",
|
||||||
"06-advanced.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).
|
/// Ensure test-profiles/ exists by running the generator binary (once per test run).
|
||||||
fn ensure_profiles_generated() {
|
fn ensure_profiles_generated() {
|
||||||
GENERATE.call_once(|| {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
let status = Command::new("cargo")
|
let status = Command::new("cargo")
|
||||||
@@ -197,6 +198,11 @@ fn profile_03_mid_lowercase_valid() {
|
|||||||
assert_profile_valid("03-mid-lowercase.json");
|
assert_profile_valid("03-mid-lowercase.json");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn profile_03_near_lowercase_complete_valid() {
|
||||||
|
assert_profile_valid("03-near-lowercase-complete.json");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn profile_04_lowercase_complete_valid() {
|
fn profile_04_lowercase_complete_valid() {
|
||||||
assert_profile_valid("04-lowercase-complete.json");
|
assert_profile_valid("04-lowercase-complete.json");
|
||||||
@@ -286,6 +292,7 @@ fn in_progress_keys_have_partial_confidence() {
|
|||||||
for name in &[
|
for name in &[
|
||||||
"02-early-lowercase.json",
|
"02-early-lowercase.json",
|
||||||
"03-mid-lowercase.json",
|
"03-mid-lowercase.json",
|
||||||
|
"03-near-lowercase-complete.json",
|
||||||
"05-multi-branch.json",
|
"05-multi-branch.json",
|
||||||
"06-advanced.json",
|
"06-advanced.json",
|
||||||
] {
|
] {
|
||||||
@@ -320,6 +327,7 @@ fn synthetic_score_level_in_expected_range() {
|
|||||||
("01-brand-new.json", 1, 1),
|
("01-brand-new.json", 1, 1),
|
||||||
("02-early-lowercase.json", 1, 3),
|
("02-early-lowercase.json", 1, 3),
|
||||||
("03-mid-lowercase.json", 2, 4),
|
("03-mid-lowercase.json", 2, 4),
|
||||||
|
("03-near-lowercase-complete.json", 3, 5),
|
||||||
("04-lowercase-complete.json", 4, 6),
|
("04-lowercase-complete.json", 4, 6),
|
||||||
("05-multi-branch.json", 6, 8),
|
("05-multi-branch.json", 6, 8),
|
||||||
("06-advanced.json", 10, 14),
|
("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),
|
// Profile 05: capitals L2 partial (J,D,R,C,E), numbers partial (1,2,3),
|
||||||
// punctuation partial (.,',')
|
// punctuation partial (.,',')
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user