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) {
|
||||
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).
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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 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");
|
||||
|
||||
@@ -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' {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user