diff --git a/src/app.rs b/src/app.rs index 16742fe..fb8d828 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2688,8 +2688,7 @@ mod tests { 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()]; + drill.input = vec![crate::session::input::CharStatus::Correct; drill.target.len()]; } } diff --git a/src/engine/skill_tree.rs b/src/engine/skill_tree.rs index d9fc94a..0125ecf 100644 --- a/src/engine/skill_tree.rs +++ b/src/engine/skill_tree.rs @@ -1177,18 +1177,32 @@ mod tests { 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::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)); + 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; } } @@ -1218,7 +1232,10 @@ mod tests { let mut found_complete = false; for _ in 0..5 { let result = tree.update(&stats, None); - if result.branches_newly_completed.contains(&BranchId::Capitals) { + if result + .branches_newly_completed + .contains(&BranchId::Capitals) + { found_complete = true; break; } @@ -1231,7 +1248,9 @@ mod tests { // Second update should not re-report let result2 = tree.update(&stats, None); assert!( - !result2.branches_newly_completed.contains(&BranchId::Capitals), + !result2 + .branches_newly_completed + .contains(&BranchId::Capitals), "should not re-report Capitals as newly completed" ); } diff --git a/src/main.rs b/src/main.rs index 210bb81..49e53b3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -39,8 +39,8 @@ use keyboard::finger::Hand; use ui::components::dashboard::Dashboard; use ui::components::keyboard_diagram::KeyboardDiagram; use ui::components::skill_tree::{ - SkillTreeWidget, detail_line_count_with_level_spacing, selectable_branches, - use_expanded_level_spacing, use_side_by_side_layout, + SkillTreeWidget, branch_list_spacing_flags, detail_line_count_with_level_spacing, + selectable_branches, use_expanded_level_spacing, use_side_by_side_layout, }; use ui::components::stats_dashboard::{ AnomalyBigramRow, NgramTabData, StatsDashboard, history_page_size_for_terminal, @@ -1844,7 +1844,14 @@ fn handle_skill_tree_key(app: &mut App, key: KeyEvent) { } } -fn skill_tree_interactive_areas(app: &App, area: Rect) -> (Rect, Rect) { +struct SkillTreeMouseLayout { + branch_area: Rect, + detail_area: Rect, + inter_branch_spacing: bool, + separator_padding: bool, +} + +fn skill_tree_interactive_areas(app: &App, area: Rect) -> SkillTreeMouseLayout { let centered = skill_tree_popup_rect(area); let inner = Block::bordered().inner(centered); let branches = selectable_branches(); @@ -1914,7 +1921,14 @@ fn skill_tree_interactive_areas(app: &App, area: Rect) -> (Rect, Rect) { Constraint::Percentage(58), ]) .split(layout[0]); - (main[0], main[2]) + let (inter_branch_spacing, separator_padding) = + branch_list_spacing_flags(main[0].height, branches.len()); + SkillTreeMouseLayout { + branch_area: main[0], + detail_area: main[2], + inter_branch_spacing, + separator_padding, + } } else { let branch_list_height = branches.len() as u16 * 2 + 1; let main = Layout::default() @@ -1925,10 +1939,51 @@ fn skill_tree_interactive_areas(app: &App, area: Rect) -> (Rect, Rect) { Constraint::Min(3), ]) .split(layout[0]); - (main[0], main[2]) + SkillTreeMouseLayout { + branch_area: main[0], + detail_area: main[2], + inter_branch_spacing: false, + separator_padding: false, + } } } +fn skill_tree_branch_index_from_y( + branch_area: Rect, + y: u16, + branch_count: usize, + inter_branch_spacing: bool, + separator_padding: bool, +) -> Option { + if y < branch_area.y || y >= branch_area.y + branch_area.height { + return None; + } + let rel_y = (y - branch_area.y) as usize; + let mut line = 0usize; + for idx in 0..branch_count { + if idx > 0 && inter_branch_spacing { + line += 1; + } + let title_line = line; + let progress_line = line + 1; + if rel_y == title_line || rel_y == progress_line { + return Some(idx); + } + line += 2; + + if idx == 0 { + if separator_padding { + line += 1; + } + line += 1; + if separator_padding && !inter_branch_spacing { + line += 1; + } + } + } + None +} + fn handle_skill_tree_mouse(app: &mut App, mouse: MouseEvent) { const DETAIL_SCROLL_STEP: usize = 3; if let Some(branch_id) = app.skill_tree_confirm_unlock { @@ -1975,25 +2030,31 @@ fn handle_skill_tree_mouse(app: &mut App, mouse: MouseEvent) { } MouseEventKind::Down(MouseButton::Left) => { let branches = selectable_branches(); - let (branch_area, detail_area) = skill_tree_interactive_areas(app, terminal_area()); - if point_in_rect(mouse.column, mouse.row, branch_area) { - let relative = (mouse.row - branch_area.y) as usize; - let idx = (relative / 2).min(branches.len().saturating_sub(1)); - let already_selected = idx == app.skill_tree_selected; - app.skill_tree_selected = idx; - app.skill_tree_detail_scroll = 0; - if already_selected { - let branch_id = branches[idx]; - let status = app.skill_tree.branch_status(branch_id).clone(); - if status == BranchStatus::Available { - app.skill_tree_confirm_unlock = Some(branch_id); - } else if status == BranchStatus::InProgress { - app.start_branch_drill(branch_id); + let layout = skill_tree_interactive_areas(app, terminal_area()); + if point_in_rect(mouse.column, mouse.row, layout.branch_area) { + if let Some(idx) = skill_tree_branch_index_from_y( + layout.branch_area, + mouse.row, + branches.len(), + layout.inter_branch_spacing, + layout.separator_padding, + ) { + let already_selected = idx == app.skill_tree_selected; + app.skill_tree_selected = idx; + app.skill_tree_detail_scroll = 0; + if already_selected { + let branch_id = branches[idx]; + let status = app.skill_tree.branch_status(branch_id).clone(); + if status == BranchStatus::Available { + app.skill_tree_confirm_unlock = Some(branch_id); + } else if status == BranchStatus::InProgress { + app.start_branch_drill(branch_id); + } } } - } else if point_in_rect(mouse.column, mouse.row, detail_area) { + } else if point_in_rect(mouse.column, mouse.row, layout.detail_area) { // Click in detail pane focuses selected branch; scroll wheel handles movement. - let _ = detail_area; + let _ = layout.detail_area; } } _ => {} @@ -2439,7 +2500,10 @@ fn render_milestone_overlay( let area = frame.area(); let colors = &app.theme.colors; - let is_key_milestone = matches!(milestone.kind, MilestoneKind::Unlock | MilestoneKind::Mastery); + let is_key_milestone = matches!( + milestone.kind, + MilestoneKind::Unlock | MilestoneKind::Mastery + ); // Determine overlay size based on terminal height: // Key milestones get keyboard diagrams; other milestones are text-only @@ -2610,10 +2674,7 @@ fn render_milestone_overlay( .map(|&id| get_branch_definition(id).name) .collect(); let branches_text = if branch_names.len() == 1 { - format!( - " You've fully mastered the {} branch!", - branch_names[0] - ) + format!(" You've fully mastered the {} branch!", branch_names[0]) } else { let all_but_last = &branch_names[..branch_names.len() - 1]; let last = branch_names[branch_names.len() - 1]; @@ -5211,6 +5272,21 @@ fn handle_keyboard_explorer_mouse(app: &mut App, mouse: MouseEvent) { .split(area); if point_in_rect(mouse.column, mouse.row, layout[3]) { app.go_to_menu(); + return; + } + + if point_in_rect(mouse.column, mouse.row, layout[1]) + && let Some(ch) = KeyboardDiagram::key_at_position( + layout[1], + &app.keyboard_model, + false, + mouse.column, + mouse.row, + ) + { + app.keyboard_explorer_selected = Some(ch); + app.key_accuracy(ch, false); + app.key_accuracy(ch, true); } } diff --git a/src/ui/components/keyboard_diagram.rs b/src/ui/components/keyboard_diagram.rs index 2dc890f..369937c 100644 --- a/src/ui/components/keyboard_diagram.rs +++ b/src/ui/components/keyboard_diagram.rs @@ -256,6 +256,25 @@ impl Widget for KeyboardDiagram<'_> { } impl KeyboardDiagram<'_> { + pub fn key_at_position( + area: Rect, + model: &KeyboardModel, + compact: bool, + x: u16, + y: u16, + ) -> Option { + let inner = Block::bordered().inner(area); + if compact { + return key_at_compact_position(inner, model, x, y); + } + + if inner.height >= 4 && inner.width >= 75 { + key_at_full_position(inner, model, x, y) + } else { + key_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(); @@ -597,3 +616,230 @@ impl KeyboardDiagram<'_> { } } } + +fn rect_contains(area: Rect, x: u16, y: u16) -> bool { + x >= area.x && x < area.x + area.width && y >= area.y && y < area.y + area.height +} + +fn key_at_compact_position(inner: Rect, model: &KeyboardModel, x: u16, y: u16) -> Option { + 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 None; + } + + 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; + + for (row_idx, row) in letter_rows.iter().enumerate() { + let row_y = inner.y + row_idx as u16; + if y != row_y { + continue; + } + + match row_idx { + 0 => { + let tab_rect = Rect::new(start_x, row_y, 3, 1); + if rect_contains(tab_rect, x, y) { + return Some(TAB); + } + } + 2 => { + let shft_rect = Rect::new(start_x, row_y, 3, 1); + if rect_contains(shft_rect, x, y) { + return None; + } + } + _ => {} + } + + let offset = offsets.get(row_idx).copied().unwrap_or(0); + for (col_idx, key) in row.iter().enumerate() { + let key_x = start_x + offset + col_idx as u16 * key_width; + let key_rect = Rect::new(key_x, row_y, 3, 1); + if rect_contains(key_rect, x, y) { + return Some(key.base); + } + } + + let row_end_x = start_x + offset + row.len() as u16 * key_width; + match row_idx { + 1 => { + let enter_rect = Rect::new(row_end_x, row_y, 3, 1); + if rect_contains(enter_rect, x, y) { + return Some(ENTER); + } + } + 2 => { + let shft_rect = Rect::new(row_end_x, row_y, 3, 1); + if rect_contains(shft_rect, x, y) { + return None; + } + } + _ => {} + } + } + + if inner.height >= 3 { + let y0 = inner.y; + let row_end_x = start_x + offsets[0] + letter_rows[0].len() as u16 * key_width; + let back_rect = Rect::new(row_end_x, y0, 3, 1); + if rect_contains(back_rect, x, y) { + return Some(BACKSPACE); + } + } + None +} + +fn key_at_full_position(inner: Rect, model: &KeyboardModel, x: u16, y: u16) -> Option { + 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; + + for (row_idx, row) in model.rows.iter().enumerate() { + let row_y = inner.y + row_idx as u16; + if y != row_y { + continue; + } + let offset = offsets.get(row_idx).copied().unwrap_or(0); + + match row_idx { + 1 => { + let tab_rect = Rect::new(start_x, row_y, 5, 1); + if rect_contains(tab_rect, x, y) { + return Some(TAB); + } + } + 2 => { + let cap_rect = Rect::new(start_x, row_y, 5, 1); + if rect_contains(cap_rect, x, y) { + return None; + } + } + 3 => { + let shft_rect = Rect::new(start_x, row_y, 6, 1); + if rect_contains(shft_rect, x, y) { + return None; + } + } + _ => {} + } + + for (col_idx, key) in row.iter().enumerate() { + let key_x = start_x + offset + col_idx as u16 * key_width; + let key_rect = Rect::new(key_x, row_y, key_width, 1); + if rect_contains(key_rect, x, y) { + return Some(key.base); + } + } + + let after_x = start_x + offset + row.len() as u16 * key_width; + match row_idx { + 0 => { + let rect = Rect::new(after_x, row_y, 6, 1); + if rect_contains(rect, x, y) { + return Some(BACKSPACE); + } + } + 2 => { + let rect = Rect::new(after_x, row_y, 7, 1); + if rect_contains(rect, x, y) { + return Some(ENTER); + } + } + 3 => { + let rect = Rect::new(after_x, row_y, 6, 1); + if rect_contains(rect, x, y) { + return None; + } + } + _ => {} + } + } + + let space_y = inner.y + 4; + if y == space_y { + let space_name = display::key_display_name(SPACE); + let space_label = format!("[ {space_name} ]"); + let space_width = space_label.len() as u16; + let space_x = start_x + (keyboard_width.saturating_sub(space_width)) / 2; + let space_rect = Rect::new(space_x, space_y, space_width, 1); + if rect_contains(space_rect, x, y) { + return Some(SPACE); + } + } + None +} + +fn key_at_full_fallback_position( + inner: Rect, + model: &KeyboardModel, + x: u16, + y: u16, +) -> Option { + let letter_rows = model.letter_rows(); + let key_width: u16 = 5; + let offsets: &[u16] = &[1, 3, 5]; + let keyboard_width = letter_rows + .iter() + .enumerate() + .map(|(row_idx, row)| { + let offset = offsets.get(row_idx).copied().unwrap_or(0); + offset + row.len() as u16 * key_width + }) + .max() + .unwrap_or(0); + let start_x = inner.x + inner.width.saturating_sub(keyboard_width) / 2; + + if inner.height < 3 || inner.width < 30 { + return None; + } + + for (row_idx, row) in letter_rows.iter().enumerate() { + let row_y = inner.y + row_idx as u16; + if y != row_y { + continue; + } + let offset = offsets.get(row_idx).copied().unwrap_or(0); + for (col_idx, key) in row.iter().enumerate() { + let key_x = start_x + offset + col_idx as u16 * key_width; + let key_rect = Rect::new(key_x, row_y, key_width, 1); + if rect_contains(key_rect, x, y) { + return Some(key.base); + } + } + } + None +}