More balanced adaptive drill generation, Tab fixes, mouse control tweaks
This commit is contained in:
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user