Mouse support & branch milestone popups
This commit is contained in:
154
docs/plans/2026-02-28-skill-tree-milestone-popups.md
Normal file
154
docs/plans/2026-02-28-skill-tree-milestone-popups.md
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
# Skill Tree Milestone Popups
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
When users reach major skill tree milestones, they should see celebratory popups explaining what they've achieved and what's next. Four milestone types:
|
||||||
|
|
||||||
|
1. **Lowercase complete** — all 26 lowercase keys mastered, other branches become available
|
||||||
|
2. **Branch complete** — a non-lowercase branch fully mastered
|
||||||
|
3. **All keys unlocked** — every key on the keyboard is available for practice
|
||||||
|
4. **All keys mastered** — every key at full confidence, ultimate achievement
|
||||||
|
|
||||||
|
These popups appear after key unlock/mastery popups and before the drill summary screen, using the existing `milestone_queue` system. The existing post-drill input lock (800ms) applies to these popups when they're the first popup shown after a drill.
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
|
||||||
|
### 1. Extend `SkillTreeUpdate` (`src/engine/skill_tree.rs`)
|
||||||
|
|
||||||
|
Add fields to `SkillTreeUpdate`:
|
||||||
|
```rust
|
||||||
|
pub branches_newly_available: Vec<BranchId>, // Locked → Available transitions
|
||||||
|
pub branches_newly_completed: Vec<BranchId>, // → Complete transitions
|
||||||
|
pub all_keys_unlocked: bool, // every key now in practice pool
|
||||||
|
pub all_keys_mastered: bool, // every key at confidence >= 1.0
|
||||||
|
```
|
||||||
|
|
||||||
|
**In `update()`:**
|
||||||
|
- Snapshot non-lowercase branch statuses before the auto-unlock loop. After it, collect `Locked` → `Available` transitions into `branches_newly_available`.
|
||||||
|
- Snapshot all branch statuses before updates. After `update_lowercase()` and all `update_branch_level()` calls, collect branches that became `Complete` into `branches_newly_completed`.
|
||||||
|
- `all_keys_unlocked`: compare `total_unlocked_count()` against `compute_total_unique_keys()`. Set to `true` only if they're equal now AND they weren't equal before (using a before-snapshot of unlocked count).
|
||||||
|
- `all_keys_mastered`: `true` if every branch in `ALL_BRANCHES` has `BranchStatus::Complete` after updates AND at least one wasn't `Complete` before.
|
||||||
|
|
||||||
|
`BranchId` is already used across all layers. Display names come from `get_branch_definition(id).name`.
|
||||||
|
|
||||||
|
### 2. Add milestone variants to `MilestoneKind` (`src/app.rs`)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub enum MilestoneKind {
|
||||||
|
Unlock,
|
||||||
|
Mastery,
|
||||||
|
BranchesAvailable, // lowercase complete → other branches available
|
||||||
|
BranchComplete, // a non-lowercase branch fully completed
|
||||||
|
AllKeysUnlocked, // every key on the keyboard is unlocked
|
||||||
|
AllKeysMastered, // every key at full confidence
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**In `finish_drill()`, after mastery popup queueing**, check each flag and push popups in order:
|
||||||
|
|
||||||
|
1. `branches_newly_available` non-empty → push `BranchesAvailable`
|
||||||
|
2. `branches_newly_completed` non-empty (excluding `BranchId::Lowercase` since `BranchesAvailable` covers it) → push `BranchComplete`
|
||||||
|
3. `all_keys_unlocked` → push `AllKeysUnlocked`
|
||||||
|
4. `all_keys_mastered` → push `AllKeysMastered`
|
||||||
|
|
||||||
|
For all four: `keys` and `finger_info` are empty, `message` is unused. The renderer owns all copy.
|
||||||
|
|
||||||
|
**Input lock**: These popups are pushed to `milestone_queue`, so the existing check `!self.milestone_queue.is_empty()` at `finish_drill()` already triggers `arm_post_drill_input_lock()`. No changes needed — the lock applies to whatever the first popup is.
|
||||||
|
|
||||||
|
### 3. Render popup variants in `render_milestone_overlay()` (`src/main.rs`)
|
||||||
|
|
||||||
|
Each variant gets its own rendering branch. No keyboard diagram for any of these. All use the standard footer (input lock remaining / "Press any key to continue").
|
||||||
|
|
||||||
|
**`BranchesAvailable`:**
|
||||||
|
- Title: `"New Skill Branches Available!"`
|
||||||
|
- Body:
|
||||||
|
```
|
||||||
|
Congratulations! You've mastered all 26 lowercase
|
||||||
|
keys!
|
||||||
|
|
||||||
|
New skill branches are now available:
|
||||||
|
• Capitals A-Z
|
||||||
|
• Numbers 0-9
|
||||||
|
• Prose Punctuation
|
||||||
|
• Whitespace
|
||||||
|
• Code Symbols
|
||||||
|
|
||||||
|
Visit the Skill Tree to unlock a new branch and
|
||||||
|
start training!
|
||||||
|
|
||||||
|
Press [t] from the menu to open the Skill Tree
|
||||||
|
```
|
||||||
|
(Branch names rendered dynamically from `get_branch_definition(id).name` for each ID in `branches_newly_available`.)
|
||||||
|
|
||||||
|
**`BranchComplete`:**
|
||||||
|
- Title: `"Branch Complete!"`
|
||||||
|
- Body:
|
||||||
|
```
|
||||||
|
You've fully mastered the {branch_name} branch!
|
||||||
|
|
||||||
|
Other branches are waiting to be unlocked in the
|
||||||
|
Skill Tree. Keep going!
|
||||||
|
|
||||||
|
Press [t] from the menu to open the Skill Tree
|
||||||
|
```
|
||||||
|
(If multiple branches completed simultaneously, list them all: "You've fully mastered the {name1} and {name2} branches!")
|
||||||
|
|
||||||
|
**`AllKeysUnlocked`:**
|
||||||
|
- Title: `"Every Key Unlocked!"`
|
||||||
|
- Body:
|
||||||
|
```
|
||||||
|
You've unlocked every key on the keyboard!
|
||||||
|
|
||||||
|
All keys are now part of your practice drills.
|
||||||
|
Keep training to build full confidence with each
|
||||||
|
key!
|
||||||
|
```
|
||||||
|
|
||||||
|
**`AllKeysMastered`:**
|
||||||
|
- Title: `"Full Keyboard Mastery!"`
|
||||||
|
- Body:
|
||||||
|
```
|
||||||
|
Incredible! You've reached full confidence with
|
||||||
|
every single key on the keyboard!
|
||||||
|
|
||||||
|
You've completed everything keydr has to teach.
|
||||||
|
Keep practicing to maintain your skills!
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Sequencing
|
||||||
|
|
||||||
|
Queue order in `finish_drill()`:
|
||||||
|
1. Key unlock popups (existing)
|
||||||
|
2. Key mastery popups (existing)
|
||||||
|
3. `BranchesAvailable` (if applicable)
|
||||||
|
4. `BranchComplete` (if applicable, excluding lowercase)
|
||||||
|
5. `AllKeysUnlocked` (if applicable)
|
||||||
|
6. `AllKeysMastered` (if applicable)
|
||||||
|
|
||||||
|
The input lock is armed once when `milestone_queue` is non-empty (existing logic). User dismisses each popup with any keypress.
|
||||||
|
|
||||||
|
### 5. Tests
|
||||||
|
|
||||||
|
**In `src/engine/skill_tree.rs` tests:**
|
||||||
|
- `branches_newly_available` non-empty on first `update()` after lowercase completion, empty on second call
|
||||||
|
- `branches_newly_completed` contains the branch ID when a non-lowercase branch completes
|
||||||
|
- `all_keys_unlocked` fires when the last key becomes available, not on subsequent calls
|
||||||
|
- `all_keys_mastered` fires when all branches reach Complete, not on subsequent calls
|
||||||
|
- `branches_newly_available` only contains the five non-lowercase branch IDs
|
||||||
|
|
||||||
|
**In `src/app.rs` tests:**
|
||||||
|
- Queue order test: last lowercase key mastered → queue contains unlock → mastery → BranchesAvailable (no BranchComplete for lowercase)
|
||||||
|
- Branch complete test: non-lowercase branch completes → BranchComplete queued
|
||||||
|
- Helper: `seed_near_complete_lowercase(app)` — 25 keys at confidence 1.0, last key at 0.95
|
||||||
|
|
||||||
|
## Files to Modify
|
||||||
|
|
||||||
|
1. `src/engine/skill_tree.rs` — Extend `SkillTreeUpdate`, detect transitions in `update()`
|
||||||
|
2. `src/app.rs` — Add variants to `MilestoneKind`, queue popups in `finish_drill()`
|
||||||
|
3. `src/main.rs` — Render the four new popup variants in `render_milestone_overlay()`
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
1. `cargo build` — compiles cleanly
|
||||||
|
2. `cargo test` — all existing + new tests pass
|
||||||
|
3. Manual testing with test profiles for each milestone scenario
|
||||||
397
src/app.rs
397
src/app.rs
@@ -84,9 +84,14 @@ pub enum CodeDownloadCompleteAction {
|
|||||||
ReturnToSettings,
|
ReturnToSettings,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
pub enum MilestoneKind {
|
pub enum MilestoneKind {
|
||||||
Unlock,
|
Unlock,
|
||||||
Mastery,
|
Mastery,
|
||||||
|
BranchesAvailable,
|
||||||
|
BranchComplete,
|
||||||
|
AllKeysUnlocked,
|
||||||
|
AllKeysMastered,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct KeyMilestonePopup {
|
pub struct KeyMilestonePopup {
|
||||||
@@ -94,6 +99,7 @@ pub struct KeyMilestonePopup {
|
|||||||
pub keys: Vec<char>,
|
pub keys: Vec<char>,
|
||||||
pub finger_info: Vec<(char, String)>,
|
pub finger_info: Vec<(char, String)>,
|
||||||
pub message: &'static str,
|
pub message: &'static str,
|
||||||
|
pub branch_ids: Vec<BranchId>,
|
||||||
}
|
}
|
||||||
|
|
||||||
const UNLOCK_MESSAGES: &[&str] = &[
|
const UNLOCK_MESSAGES: &[&str] = &[
|
||||||
@@ -1035,6 +1041,7 @@ impl App {
|
|||||||
keys: update.newly_unlocked,
|
keys: update.newly_unlocked,
|
||||||
finger_info,
|
finger_info,
|
||||||
message: msg,
|
message: msg,
|
||||||
|
branch_ids: vec![],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1054,6 +1061,55 @@ impl App {
|
|||||||
keys: update.newly_mastered,
|
keys: update.newly_mastered,
|
||||||
finger_info,
|
finger_info,
|
||||||
message: msg,
|
message: msg,
|
||||||
|
branch_ids: vec![],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queue milestone popups for branch/global milestones
|
||||||
|
if !update.branches_newly_available.is_empty() {
|
||||||
|
self.milestone_queue.push_back(KeyMilestonePopup {
|
||||||
|
kind: MilestoneKind::BranchesAvailable,
|
||||||
|
keys: vec![],
|
||||||
|
finger_info: vec![],
|
||||||
|
message: "",
|
||||||
|
branch_ids: update.branches_newly_available,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Branch complete (excluding Lowercase, since BranchesAvailable covers it)
|
||||||
|
let completed_non_lowercase: Vec<BranchId> = update
|
||||||
|
.branches_newly_completed
|
||||||
|
.iter()
|
||||||
|
.filter(|&&id| id != BranchId::Lowercase)
|
||||||
|
.copied()
|
||||||
|
.collect();
|
||||||
|
if !completed_non_lowercase.is_empty() {
|
||||||
|
self.milestone_queue.push_back(KeyMilestonePopup {
|
||||||
|
kind: MilestoneKind::BranchComplete,
|
||||||
|
keys: vec![],
|
||||||
|
finger_info: vec![],
|
||||||
|
message: "",
|
||||||
|
branch_ids: completed_non_lowercase,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if update.all_keys_unlocked {
|
||||||
|
self.milestone_queue.push_back(KeyMilestonePopup {
|
||||||
|
kind: MilestoneKind::AllKeysUnlocked,
|
||||||
|
keys: vec![],
|
||||||
|
finger_info: vec![],
|
||||||
|
message: "",
|
||||||
|
branch_ids: vec![],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if update.all_keys_mastered {
|
||||||
|
self.milestone_queue.push_back(KeyMilestonePopup {
|
||||||
|
kind: MilestoneKind::AllKeysMastered,
|
||||||
|
keys: vec![],
|
||||||
|
finger_info: vec![],
|
||||||
|
message: "",
|
||||||
|
branch_ids: vec![],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2523,6 +2579,7 @@ mod tests {
|
|||||||
keys: vec!['a'],
|
keys: vec!['a'],
|
||||||
finger_info: vec![('a', "left pinky".to_string())],
|
finger_info: vec![('a', "left pinky".to_string())],
|
||||||
message: "Test milestone",
|
message: "Test milestone",
|
||||||
|
branch_ids: vec![],
|
||||||
});
|
});
|
||||||
|
|
||||||
complete_current_drill(&mut app);
|
complete_current_drill(&mut app);
|
||||||
@@ -2569,4 +2626,344 @@ mod tests {
|
|||||||
assert_eq!(lowercase_generation_focus(Some('7')), None);
|
assert_eq!(lowercase_generation_focus(Some('7')), None);
|
||||||
assert_eq!(lowercase_generation_focus(None), None);
|
assert_eq!(lowercase_generation_focus(None), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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).
|
||||||
|
fn make_key_near_mastery(app: &mut App, ch: char) {
|
||||||
|
for _ in 0..30 {
|
||||||
|
app.ranked_key_stats.update_key(ch, 350.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper: make a key fully confident in ranked stats.
|
||||||
|
fn make_key_mastered(app: &mut App, ch: char) {
|
||||||
|
for _ in 0..50 {
|
||||||
|
app.ranked_key_stats.update_key(ch, 200.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper: seed a tree where lowercase is nearly complete.
|
||||||
|
/// All 26 lowercase keys are unlocked and at full confidence except 'z'.
|
||||||
|
fn seed_near_complete_lowercase(app: &mut App) {
|
||||||
|
use crate::engine::skill_tree::get_branch_definition;
|
||||||
|
|
||||||
|
let all_lowercase = get_branch_definition(BranchId::Lowercase).levels[0].keys;
|
||||||
|
let almost_all = &all_lowercase[..25]; // everything except 'z'
|
||||||
|
|
||||||
|
for &ch in almost_all {
|
||||||
|
make_key_mastered(app, ch);
|
||||||
|
}
|
||||||
|
// Make 'z' near-mastery so one drill hit completes it
|
||||||
|
make_key_near_mastery(app, 'z');
|
||||||
|
|
||||||
|
// Advance the skill tree through progressive unlock
|
||||||
|
for _ in 0..30 {
|
||||||
|
app.skill_tree.update(&app.ranked_key_stats, None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper: set up a drill with a simple target and create events for it.
|
||||||
|
/// Each char in `target` gets a correct keystroke event with fast timing.
|
||||||
|
fn setup_drill_with_events(app: &mut App, target: &str) {
|
||||||
|
use crate::session::input::KeystrokeEvent;
|
||||||
|
|
||||||
|
app.drill = Some(crate::session::drill::DrillState::new(target));
|
||||||
|
let now = Instant::now();
|
||||||
|
|
||||||
|
// Create keystroke events — need at least 2 for windows(2) to produce per_key_times
|
||||||
|
let mut events = Vec::new();
|
||||||
|
for (i, ch) in target.chars().enumerate() {
|
||||||
|
events.push(KeystrokeEvent {
|
||||||
|
expected: ch,
|
||||||
|
actual: ch,
|
||||||
|
timestamp: now + Duration::from_millis(200 * (i as u64)),
|
||||||
|
correct: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
app.drill_events = events;
|
||||||
|
|
||||||
|
// Mark drill as complete
|
||||||
|
if let Some(ref mut drill) = app.drill {
|
||||||
|
drill.started_at = Some(now);
|
||||||
|
drill.finished_at = Some(now + Duration::from_millis(200 * (target.len() as u64)));
|
||||||
|
drill.cursor = drill.target.len();
|
||||||
|
drill.input =
|
||||||
|
vec![crate::session::input::CharStatus::Correct; drill.target.len()];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn finish_drill_lowercase_complete_queue_order() {
|
||||||
|
let mut app = App::new_test();
|
||||||
|
seed_near_complete_lowercase(&mut app);
|
||||||
|
|
||||||
|
// Set up a drill that types 'z' (the last missing key) to push it to mastery
|
||||||
|
setup_drill_with_events(&mut app, "zz");
|
||||||
|
app.milestone_queue.clear();
|
||||||
|
app.finish_drill();
|
||||||
|
|
||||||
|
let kinds: Vec<&MilestoneKind> = app.milestone_queue.iter().map(|m| &m.kind).collect();
|
||||||
|
|
||||||
|
// Should contain Mastery and BranchesAvailable, but NOT BranchComplete for lowercase
|
||||||
|
assert!(
|
||||||
|
kinds.contains(&&MilestoneKind::Mastery),
|
||||||
|
"Should have Mastery popup, got: {kinds:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
kinds.contains(&&MilestoneKind::BranchesAvailable),
|
||||||
|
"Should have BranchesAvailable popup, got: {kinds:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!kinds.contains(&&MilestoneKind::BranchComplete),
|
||||||
|
"Should NOT have BranchComplete for lowercase, got: {kinds:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify full ordering: any Unlock before Mastery before BranchesAvailable
|
||||||
|
if let Some(unlock_pos) = kinds.iter().position(|k| **k == MilestoneKind::Unlock) {
|
||||||
|
let mastery_pos = kinds
|
||||||
|
.iter()
|
||||||
|
.position(|k| **k == MilestoneKind::Mastery)
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
unlock_pos < mastery_pos,
|
||||||
|
"Unlock should come before Mastery"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let mastery_pos = kinds
|
||||||
|
.iter()
|
||||||
|
.position(|k| **k == MilestoneKind::Mastery)
|
||||||
|
.unwrap();
|
||||||
|
let available_pos = kinds
|
||||||
|
.iter()
|
||||||
|
.position(|k| **k == MilestoneKind::BranchesAvailable)
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
mastery_pos < available_pos,
|
||||||
|
"Mastery should come before BranchesAvailable"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify branch_ids in BranchesAvailable popup are in canonical order
|
||||||
|
let branches_popup = app
|
||||||
|
.milestone_queue
|
||||||
|
.iter()
|
||||||
|
.find(|m| m.kind == MilestoneKind::BranchesAvailable)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
branches_popup.branch_ids,
|
||||||
|
vec![
|
||||||
|
BranchId::Capitals,
|
||||||
|
BranchId::Numbers,
|
||||||
|
BranchId::ProsePunctuation,
|
||||||
|
BranchId::Whitespace,
|
||||||
|
BranchId::CodeSymbols,
|
||||||
|
],
|
||||||
|
"BranchesAvailable branch_ids must be in canonical order"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn finish_drill_branch_complete_queues_popup() {
|
||||||
|
let mut app = App::new_test();
|
||||||
|
|
||||||
|
// Set capitals to InProgress at last level
|
||||||
|
app.skill_tree
|
||||||
|
.branch_progress_mut(BranchId::Capitals)
|
||||||
|
.status = BranchStatus::InProgress;
|
||||||
|
app.skill_tree
|
||||||
|
.branch_progress_mut(BranchId::Capitals)
|
||||||
|
.current_level = 2; // Last level (3 levels, 0-indexed)
|
||||||
|
|
||||||
|
// Make all capitals except 'Z' fully confident, 'Z' near-mastery
|
||||||
|
for ch in 'A'..='Y' {
|
||||||
|
make_key_mastered(&mut app, ch);
|
||||||
|
}
|
||||||
|
make_key_near_mastery(&mut app, 'Z');
|
||||||
|
|
||||||
|
// Advance tree to reflect current confidence state
|
||||||
|
app.skill_tree.update(&app.ranked_key_stats, None);
|
||||||
|
|
||||||
|
// Set up a drill that types 'Z' to push it to mastery
|
||||||
|
setup_drill_with_events(&mut app, "ZZ");
|
||||||
|
app.milestone_queue.clear();
|
||||||
|
app.finish_drill();
|
||||||
|
|
||||||
|
let kinds: Vec<&MilestoneKind> = app.milestone_queue.iter().map(|m| &m.kind).collect();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
kinds.contains(&&MilestoneKind::BranchComplete),
|
||||||
|
"Should have BranchComplete popup, got: {kinds:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The BranchComplete popup should reference Capitals
|
||||||
|
let branch_complete = app
|
||||||
|
.milestone_queue
|
||||||
|
.iter()
|
||||||
|
.find(|m| m.kind == MilestoneKind::BranchComplete)
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
branch_complete.branch_ids.contains(&BranchId::Capitals),
|
||||||
|
"BranchComplete should reference Capitals"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn finish_drill_all_keys_unlocked_queues_once() {
|
||||||
|
use crate::engine::skill_tree::get_branch_definition;
|
||||||
|
|
||||||
|
let mut app = App::new_test();
|
||||||
|
|
||||||
|
// Complete lowercase
|
||||||
|
let all_lowercase = get_branch_definition(BranchId::Lowercase).levels[0].keys;
|
||||||
|
for &ch in all_lowercase {
|
||||||
|
make_key_mastered(&mut app, ch);
|
||||||
|
}
|
||||||
|
for _ in 0..30 {
|
||||||
|
app.skill_tree.update(&app.ranked_key_stats, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start all non-lowercase branches. Master all keys through their levels,
|
||||||
|
// but leave CodeSymbols level 2's last key ('~') near-mastery so level 3
|
||||||
|
// keys are not yet unlocked.
|
||||||
|
for &id in &[
|
||||||
|
BranchId::Capitals,
|
||||||
|
BranchId::Numbers,
|
||||||
|
BranchId::ProsePunctuation,
|
||||||
|
BranchId::Whitespace,
|
||||||
|
] {
|
||||||
|
app.skill_tree.start_branch(id);
|
||||||
|
let def = get_branch_definition(id);
|
||||||
|
for level in def.levels {
|
||||||
|
for &ch in level.keys {
|
||||||
|
make_key_mastered(&mut app, ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CodeSymbols: master levels 0-1 fully, and level 2 except '~'
|
||||||
|
app.skill_tree.start_branch(BranchId::CodeSymbols);
|
||||||
|
let code_def = get_branch_definition(BranchId::CodeSymbols);
|
||||||
|
for &ch in code_def.levels[0].keys {
|
||||||
|
make_key_mastered(&mut app, ch);
|
||||||
|
}
|
||||||
|
for &ch in code_def.levels[1].keys {
|
||||||
|
make_key_mastered(&mut app, ch);
|
||||||
|
}
|
||||||
|
for &ch in code_def.levels[2].keys {
|
||||||
|
if ch == '~' {
|
||||||
|
make_key_near_mastery(&mut app, ch);
|
||||||
|
} else {
|
||||||
|
make_key_mastered(&mut app, ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Level 3 keys (@#$%_\`) are not mastered and not yet unlocked
|
||||||
|
|
||||||
|
// Advance all branches through their levels
|
||||||
|
for _ in 0..20 {
|
||||||
|
app.skill_tree.update(&app.ranked_key_stats, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify CodeSymbols is at level 2 (level 3 keys not yet unlocked)
|
||||||
|
assert_eq!(
|
||||||
|
app.skill_tree
|
||||||
|
.branch_progress(BranchId::CodeSymbols)
|
||||||
|
.current_level,
|
||||||
|
2,
|
||||||
|
"CodeSymbols should be at level 2"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
app.skill_tree.total_unlocked_count() < app.skill_tree.total_unique_keys,
|
||||||
|
"Not all keys should be unlocked yet"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Drill '~' to push it to mastery → advances CodeSymbols to level 3 → all keys unlocked
|
||||||
|
setup_drill_with_events(&mut app, "~~");
|
||||||
|
app.milestone_queue.clear();
|
||||||
|
app.finish_drill();
|
||||||
|
|
||||||
|
let kinds: Vec<&MilestoneKind> = app.milestone_queue.iter().map(|m| &m.kind).collect();
|
||||||
|
|
||||||
|
// AllKeysUnlocked must be present
|
||||||
|
assert!(
|
||||||
|
kinds.contains(&&MilestoneKind::AllKeysUnlocked),
|
||||||
|
"Should have AllKeysUnlocked popup, got: {kinds:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// If AllKeysMastered also fires, AllKeysUnlocked must come first
|
||||||
|
if let (Some(unlocked_pos), Some(mastered_pos)) = (
|
||||||
|
kinds
|
||||||
|
.iter()
|
||||||
|
.position(|k| **k == MilestoneKind::AllKeysUnlocked),
|
||||||
|
kinds
|
||||||
|
.iter()
|
||||||
|
.position(|k| **k == MilestoneKind::AllKeysMastered),
|
||||||
|
) {
|
||||||
|
assert!(
|
||||||
|
unlocked_pos < mastered_pos,
|
||||||
|
"AllKeysUnlocked should come before AllKeysMastered"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second drill should NOT re-queue
|
||||||
|
setup_drill_with_events(&mut app, "~~");
|
||||||
|
app.milestone_queue.clear();
|
||||||
|
app.finish_drill();
|
||||||
|
|
||||||
|
let kinds2: Vec<&MilestoneKind> = app.milestone_queue.iter().map(|m| &m.kind).collect();
|
||||||
|
assert!(
|
||||||
|
!kinds2.contains(&&MilestoneKind::AllKeysUnlocked),
|
||||||
|
"AllKeysUnlocked should not fire on subsequent drill"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn finish_drill_all_keys_mastered_queues_popup() {
|
||||||
|
let mut app = App::new_test();
|
||||||
|
|
||||||
|
// Make every key in every branch mastered except '`'
|
||||||
|
for branch_def in crate::engine::skill_tree::ALL_BRANCHES {
|
||||||
|
for level in branch_def.levels {
|
||||||
|
for &ch in level.keys {
|
||||||
|
if ch == '`' {
|
||||||
|
make_key_near_mastery(&mut app, ch);
|
||||||
|
} else {
|
||||||
|
make_key_mastered(&mut app, ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advance lowercase to Complete
|
||||||
|
for _ in 0..30 {
|
||||||
|
app.skill_tree.update(&app.ranked_key_stats, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start all non-lowercase branches
|
||||||
|
for &id in &[
|
||||||
|
BranchId::Capitals,
|
||||||
|
BranchId::Numbers,
|
||||||
|
BranchId::ProsePunctuation,
|
||||||
|
BranchId::Whitespace,
|
||||||
|
BranchId::CodeSymbols,
|
||||||
|
] {
|
||||||
|
app.skill_tree.start_branch(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advance all branches through their levels
|
||||||
|
for _ in 0..30 {
|
||||||
|
app.skill_tree.update(&app.ranked_key_stats, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now '`' is the only key not fully mastered. Drill it.
|
||||||
|
setup_drill_with_events(&mut app, "``");
|
||||||
|
app.milestone_queue.clear();
|
||||||
|
app.finish_drill();
|
||||||
|
|
||||||
|
let kinds: Vec<&MilestoneKind> = app.milestone_queue.iter().map(|m| &m.kind).collect();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
kinds.contains(&&MilestoneKind::AllKeysMastered),
|
||||||
|
"Should have AllKeysMastered popup, got: {kinds:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ use crate::keyboard::display::{BACKSPACE, SPACE};
|
|||||||
pub struct SkillTreeUpdate {
|
pub struct SkillTreeUpdate {
|
||||||
pub newly_unlocked: Vec<char>,
|
pub newly_unlocked: Vec<char>,
|
||||||
pub newly_mastered: Vec<char>,
|
pub newly_mastered: Vec<char>,
|
||||||
|
pub branches_newly_available: Vec<BranchId>,
|
||||||
|
pub branches_newly_completed: Vec<BranchId>,
|
||||||
|
pub all_keys_unlocked: bool,
|
||||||
|
pub all_keys_mastered: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Branch ID ---
|
// --- Branch ID ---
|
||||||
@@ -522,18 +526,32 @@ impl SkillTree {
|
|||||||
let before_unlocked: HashSet<char> =
|
let before_unlocked: HashSet<char> =
|
||||||
self.unlocked_keys(DrillScope::Global).into_iter().collect();
|
self.unlocked_keys(DrillScope::Global).into_iter().collect();
|
||||||
|
|
||||||
|
// Snapshot branch statuses before any updates
|
||||||
|
let before_branch_statuses: HashMap<BranchId, BranchStatus> = BranchId::all()
|
||||||
|
.iter()
|
||||||
|
.map(|&id| (id, self.branch_status(id).clone()))
|
||||||
|
.collect();
|
||||||
|
let before_unlocked_count = self.total_unlocked_count();
|
||||||
|
|
||||||
// Update lowercase branch (progressive unlock)
|
// Update lowercase branch (progressive unlock)
|
||||||
self.update_lowercase(stats);
|
self.update_lowercase(stats);
|
||||||
|
|
||||||
// Check if lowercase is complete -> unlock other branches
|
// Check if lowercase is complete -> unlock other branches
|
||||||
if *self.branch_status(BranchId::Lowercase) == BranchStatus::Complete {
|
// Snapshot non-lowercase branch statuses before auto-unlock (canonical order)
|
||||||
for &id in &[
|
const NON_LOWERCASE_BRANCHES: &[BranchId] = &[
|
||||||
BranchId::Capitals,
|
BranchId::Capitals,
|
||||||
BranchId::Numbers,
|
BranchId::Numbers,
|
||||||
BranchId::ProsePunctuation,
|
BranchId::ProsePunctuation,
|
||||||
BranchId::Whitespace,
|
BranchId::Whitespace,
|
||||||
BranchId::CodeSymbols,
|
BranchId::CodeSymbols,
|
||||||
] {
|
];
|
||||||
|
let before_auto_unlock: Vec<(BranchId, BranchStatus)> = NON_LOWERCASE_BRANCHES
|
||||||
|
.iter()
|
||||||
|
.map(|&id| (id, self.branch_status(id).clone()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if *self.branch_status(BranchId::Lowercase) == BranchStatus::Complete {
|
||||||
|
for &id in NON_LOWERCASE_BRANCHES {
|
||||||
let bp = self.branch_progress_mut(id);
|
let bp = self.branch_progress_mut(id);
|
||||||
if bp.status == BranchStatus::Locked {
|
if bp.status == BranchStatus::Locked {
|
||||||
bp.status = BranchStatus::Available;
|
bp.status = BranchStatus::Available;
|
||||||
@@ -541,6 +559,16 @@ impl SkillTree {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Detect Locked → Available transitions (maintains canonical order)
|
||||||
|
let branches_newly_available: Vec<BranchId> = before_auto_unlock
|
||||||
|
.iter()
|
||||||
|
.filter(|(id, before_status)| {
|
||||||
|
*before_status == BranchStatus::Locked
|
||||||
|
&& *self.branch_status(*id) == BranchStatus::Available
|
||||||
|
})
|
||||||
|
.map(|(id, _)| *id)
|
||||||
|
.collect();
|
||||||
|
|
||||||
// Update InProgress branches (non-lowercase)
|
// Update InProgress branches (non-lowercase)
|
||||||
for branch_def in ALL_BRANCHES {
|
for branch_def in ALL_BRANCHES {
|
||||||
if branch_def.id == BranchId::Lowercase {
|
if branch_def.id == BranchId::Lowercase {
|
||||||
@@ -553,6 +581,33 @@ impl SkillTree {
|
|||||||
self.update_branch_level(branch_def, stats);
|
self.update_branch_level(branch_def, stats);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Detect branches that became Complete
|
||||||
|
let branches_newly_completed: Vec<BranchId> = BranchId::all()
|
||||||
|
.iter()
|
||||||
|
.filter(|&&id| {
|
||||||
|
before_branch_statuses
|
||||||
|
.get(&id)
|
||||||
|
.map(|s| *s != BranchStatus::Complete)
|
||||||
|
.unwrap_or(true)
|
||||||
|
&& *self.branch_status(id) == BranchStatus::Complete
|
||||||
|
})
|
||||||
|
.copied()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Detect all keys unlocked
|
||||||
|
let after_unlocked_count = self.total_unlocked_count();
|
||||||
|
let all_keys_unlocked = after_unlocked_count == self.total_unique_keys
|
||||||
|
&& before_unlocked_count != self.total_unique_keys;
|
||||||
|
|
||||||
|
// Detect all keys mastered
|
||||||
|
let all_complete_now = BranchId::all()
|
||||||
|
.iter()
|
||||||
|
.all(|&id| *self.branch_status(id) == BranchStatus::Complete);
|
||||||
|
let all_complete_before = BranchId::all()
|
||||||
|
.iter()
|
||||||
|
.all(|id| before_branch_statuses.get(id) == Some(&BranchStatus::Complete));
|
||||||
|
let all_keys_mastered = all_complete_now && !all_complete_before;
|
||||||
|
|
||||||
// Snapshot after
|
// Snapshot after
|
||||||
let after_unlocked: HashSet<char> =
|
let after_unlocked: HashSet<char> =
|
||||||
self.unlocked_keys(DrillScope::Global).into_iter().collect();
|
self.unlocked_keys(DrillScope::Global).into_iter().collect();
|
||||||
@@ -577,6 +632,10 @@ impl SkillTree {
|
|||||||
SkillTreeUpdate {
|
SkillTreeUpdate {
|
||||||
newly_unlocked,
|
newly_unlocked,
|
||||||
newly_mastered,
|
newly_mastered,
|
||||||
|
branches_newly_available,
|
||||||
|
branches_newly_completed,
|
||||||
|
all_keys_unlocked,
|
||||||
|
all_keys_mastered,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1101,4 +1160,229 @@ mod tests {
|
|||||||
fn test_find_key_branch_unknown() {
|
fn test_find_key_branch_unknown() {
|
||||||
assert!(find_key_branch('\x00').is_none());
|
assert!(find_key_branch('\x00').is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_branches_newly_available_on_lowercase_complete() {
|
||||||
|
let mut tree = SkillTree::default();
|
||||||
|
let mut stats = KeyStatsStore::default();
|
||||||
|
|
||||||
|
let all_lowercase = get_branch_definition(BranchId::Lowercase).levels[0].keys;
|
||||||
|
make_stats_confident(&mut stats, all_lowercase);
|
||||||
|
|
||||||
|
// Run updates to advance through progressive unlock
|
||||||
|
let mut found_available = false;
|
||||||
|
for _ in 0..30 {
|
||||||
|
let result = tree.update(&stats, None);
|
||||||
|
if !result.branches_newly_available.is_empty() {
|
||||||
|
found_available = true;
|
||||||
|
// Should contain exactly the 5 non-lowercase branches
|
||||||
|
assert_eq!(result.branches_newly_available.len(), 5);
|
||||||
|
assert!(!result.branches_newly_available.contains(&BranchId::Lowercase));
|
||||||
|
assert!(result.branches_newly_available.contains(&BranchId::Capitals));
|
||||||
|
assert!(result.branches_newly_available.contains(&BranchId::Numbers));
|
||||||
|
assert!(result
|
||||||
|
.branches_newly_available
|
||||||
|
.contains(&BranchId::ProsePunctuation));
|
||||||
|
assert!(result
|
||||||
|
.branches_newly_available
|
||||||
|
.contains(&BranchId::Whitespace));
|
||||||
|
assert!(result
|
||||||
|
.branches_newly_available
|
||||||
|
.contains(&BranchId::CodeSymbols));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(found_available, "branches_newly_available should fire once");
|
||||||
|
|
||||||
|
// Second update should NOT have branches_newly_available
|
||||||
|
let result2 = tree.update(&stats, None);
|
||||||
|
assert!(
|
||||||
|
result2.branches_newly_available.is_empty(),
|
||||||
|
"branches_newly_available should be empty on subsequent call"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_branches_newly_completed_on_branch_complete() {
|
||||||
|
let mut tree = SkillTree::default();
|
||||||
|
let mut stats = KeyStatsStore::default();
|
||||||
|
|
||||||
|
// Set up: capitals InProgress
|
||||||
|
tree.branch_progress_mut(BranchId::Capitals).status = BranchStatus::InProgress;
|
||||||
|
|
||||||
|
// Make all capital letters confident
|
||||||
|
let all_caps: Vec<char> = ('A'..='Z').collect();
|
||||||
|
make_stats_confident(&mut stats, &all_caps);
|
||||||
|
|
||||||
|
// Advance through levels
|
||||||
|
let mut found_complete = false;
|
||||||
|
for _ in 0..5 {
|
||||||
|
let result = tree.update(&stats, None);
|
||||||
|
if result.branches_newly_completed.contains(&BranchId::Capitals) {
|
||||||
|
found_complete = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
found_complete,
|
||||||
|
"branches_newly_completed should contain Capitals"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Second update should not re-report
|
||||||
|
let result2 = tree.update(&stats, None);
|
||||||
|
assert!(
|
||||||
|
!result2.branches_newly_completed.contains(&BranchId::Capitals),
|
||||||
|
"should not re-report Capitals as newly completed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_all_keys_unlocked_fires_once() {
|
||||||
|
let mut tree = SkillTree::default();
|
||||||
|
let mut stats = KeyStatsStore::default();
|
||||||
|
|
||||||
|
// Set all branches to InProgress at last level with all keys confident
|
||||||
|
// First complete lowercase
|
||||||
|
let all_lowercase = get_branch_definition(BranchId::Lowercase).levels[0].keys;
|
||||||
|
make_stats_confident(&mut stats, all_lowercase);
|
||||||
|
for _ in 0..30 {
|
||||||
|
tree.update(&stats, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start all branches and make their keys confident
|
||||||
|
for &id in &[
|
||||||
|
BranchId::Capitals,
|
||||||
|
BranchId::Numbers,
|
||||||
|
BranchId::ProsePunctuation,
|
||||||
|
BranchId::Whitespace,
|
||||||
|
BranchId::CodeSymbols,
|
||||||
|
] {
|
||||||
|
tree.start_branch(id);
|
||||||
|
let def = get_branch_definition(id);
|
||||||
|
for level in def.levels {
|
||||||
|
make_stats_confident(&mut stats, level.keys);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advance all branches through their levels
|
||||||
|
let mut found_all_unlocked = false;
|
||||||
|
for _ in 0..20 {
|
||||||
|
let result = tree.update(&stats, None);
|
||||||
|
if result.all_keys_unlocked {
|
||||||
|
found_all_unlocked = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
found_all_unlocked,
|
||||||
|
"all_keys_unlocked should fire when last key becomes available"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Subsequent call should not fire again
|
||||||
|
let result = tree.update(&stats, None);
|
||||||
|
assert!(
|
||||||
|
!result.all_keys_unlocked,
|
||||||
|
"all_keys_unlocked should not fire on subsequent calls"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_all_keys_mastered_fires_once() {
|
||||||
|
let mut tree = SkillTree::default();
|
||||||
|
let mut stats = KeyStatsStore::default();
|
||||||
|
|
||||||
|
// Make all keys across all branches confident
|
||||||
|
for branch_def in ALL_BRANCHES {
|
||||||
|
for level in branch_def.levels {
|
||||||
|
make_stats_confident(&mut stats, level.keys);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complete lowercase first
|
||||||
|
for _ in 0..30 {
|
||||||
|
tree.update(&stats, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start and advance all other branches
|
||||||
|
for &id in &[
|
||||||
|
BranchId::Capitals,
|
||||||
|
BranchId::Numbers,
|
||||||
|
BranchId::ProsePunctuation,
|
||||||
|
BranchId::Whitespace,
|
||||||
|
BranchId::CodeSymbols,
|
||||||
|
] {
|
||||||
|
tree.start_branch(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut found_all_mastered = false;
|
||||||
|
for _ in 0..30 {
|
||||||
|
let result = tree.update(&stats, None);
|
||||||
|
if result.all_keys_mastered {
|
||||||
|
found_all_mastered = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
found_all_mastered,
|
||||||
|
"all_keys_mastered should fire when all branches complete"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Subsequent call should not fire again
|
||||||
|
let result = tree.update(&stats, None);
|
||||||
|
assert!(
|
||||||
|
!result.all_keys_mastered,
|
||||||
|
"all_keys_mastered should not fire on subsequent calls"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_branches_newly_available_only_non_lowercase() {
|
||||||
|
let mut tree = SkillTree::default();
|
||||||
|
let mut stats = KeyStatsStore::default();
|
||||||
|
|
||||||
|
let all_lowercase = get_branch_definition(BranchId::Lowercase).levels[0].keys;
|
||||||
|
make_stats_confident(&mut stats, all_lowercase);
|
||||||
|
|
||||||
|
for _ in 0..30 {
|
||||||
|
let result = tree.update(&stats, None);
|
||||||
|
if !result.branches_newly_available.is_empty() {
|
||||||
|
for &id in &result.branches_newly_available {
|
||||||
|
assert_ne!(
|
||||||
|
id,
|
||||||
|
BranchId::Lowercase,
|
||||||
|
"branches_newly_available should not contain Lowercase"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_branches_newly_available_canonical_order() {
|
||||||
|
let mut tree = SkillTree::default();
|
||||||
|
let mut stats = KeyStatsStore::default();
|
||||||
|
|
||||||
|
let all_lowercase = get_branch_definition(BranchId::Lowercase).levels[0].keys;
|
||||||
|
make_stats_confident(&mut stats, all_lowercase);
|
||||||
|
|
||||||
|
for _ in 0..30 {
|
||||||
|
let result = tree.update(&stats, None);
|
||||||
|
if !result.branches_newly_available.is_empty() {
|
||||||
|
// Must be in canonical order: Capitals, Numbers, ProsePunctuation, Whitespace, CodeSymbols
|
||||||
|
assert_eq!(
|
||||||
|
result.branches_newly_available,
|
||||||
|
vec![
|
||||||
|
BranchId::Capitals,
|
||||||
|
BranchId::Numbers,
|
||||||
|
BranchId::ProsePunctuation,
|
||||||
|
BranchId::Whitespace,
|
||||||
|
BranchId::CodeSymbols,
|
||||||
|
],
|
||||||
|
"branches_newly_available must be in canonical order"
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ use std::sync::mpsc;
|
|||||||
use std::thread;
|
use std::thread;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use crossterm::event::{self, Event, KeyEvent};
|
use crossterm::event::{self, Event, KeyEvent, MouseEvent};
|
||||||
|
|
||||||
pub enum AppEvent {
|
pub enum AppEvent {
|
||||||
Key(KeyEvent),
|
Key(KeyEvent),
|
||||||
|
Mouse(MouseEvent),
|
||||||
Tick,
|
Tick,
|
||||||
Resize(#[allow(dead_code)] u16, #[allow(dead_code)] u16),
|
Resize(#[allow(dead_code)] u16, #[allow(dead_code)] u16),
|
||||||
}
|
}
|
||||||
@@ -34,6 +35,11 @@ impl EventHandler {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Ok(Event::Mouse(mouse)) => {
|
||||||
|
if tx.send(AppEvent::Mouse(mouse)).is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
} else if tx.send(AppEvent::Tick).is_err() {
|
} else if tx.send(AppEvent::Tick).is_err() {
|
||||||
|
|||||||
1287
src/main.rs
1287
src/main.rs
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user