diff --git a/src/app.rs b/src/app.rs index 2703568..836de40 100644 --- a/src/app.rs +++ b/src/app.rs @@ -226,6 +226,7 @@ pub struct App { pub history_confirm_delete: bool, pub skill_tree_selected: usize, pub skill_tree_detail_scroll: usize, + pub skill_tree_confirm_unlock: Option, pub drill_source_info: Option, pub code_language_selected: usize, pub code_language_scroll: usize, @@ -378,6 +379,7 @@ impl App { history_confirm_delete: false, skill_tree_selected: 0, skill_tree_detail_scroll: 0, + skill_tree_confirm_unlock: None, drill_source_info: None, code_language_selected: 0, code_language_scroll: 0, @@ -727,10 +729,13 @@ impl App { let table = self.transition_table.clone(); let dict = Dictionary::load(); let rng = SmallRng::from_rng(&mut self.rng).unwrap(); - let cross_drill_history: HashSet = - self.adaptive_word_history.iter().flatten().cloned().collect(); - let mut generator = - PhoneticGenerator::new(table, dict, rng, cross_drill_history); + let cross_drill_history: HashSet = self + .adaptive_word_history + .iter() + .flatten() + .cloned() + .collect(); + let mut generator = PhoneticGenerator::new(table, dict, rng, cross_drill_history); let mut text = generator.generate(&filter, lowercase_focused, focused_bigram, word_count); @@ -1523,14 +1528,19 @@ impl App { pub fn go_to_skill_tree(&mut self) { self.skill_tree_selected = 0; self.skill_tree_detail_scroll = 0; + self.skill_tree_confirm_unlock = None; self.screen = AppScreen::SkillTree; } - pub fn start_branch_drill(&mut self, branch_id: BranchId) { - // Start the branch if it's Available + pub fn unlock_branch(&mut self, branch_id: BranchId) { + // Start the branch if it's Available. self.skill_tree.start_branch(branch_id); self.profile.skill_tree = self.skill_tree.progress.clone(); self.save_data(); + } + + pub fn start_branch_drill(&mut self, branch_id: BranchId) { + self.unlock_branch(branch_id); // Use adaptive mode with branch-specific scope let old_mode = self.drill_mode; @@ -2267,6 +2277,7 @@ impl App { history_confirm_delete: false, skill_tree_selected: 0, skill_tree_detail_scroll: 0, + skill_tree_confirm_unlock: None, drill_source_info: None, code_language_selected: 0, code_language_scroll: 0, diff --git a/src/bin/generate_test_profiles.rs b/src/bin/generate_test_profiles.rs index 6e6a4f5..9e3e24b 100644 --- a/src/bin/generate_test_profiles.rs +++ b/src/bin/generate_test_profiles.rs @@ -8,11 +8,11 @@ use rand::{Rng, SeedableRng}; use keydr::config::Config; use keydr::engine::key_stats::{KeyStat, KeyStatsStore}; use keydr::engine::skill_tree::{ - BranchId, BranchProgress, BranchStatus, SkillTreeProgress, ALL_BRANCHES, + ALL_BRANCHES, BranchId, BranchProgress, BranchStatus, SkillTreeProgress, }; use keydr::session::result::{DrillResult, KeyTime}; use keydr::store::schema::{ - DrillHistoryData, ExportData, KeyStatsData, ProfileData, EXPORT_VERSION, + DrillHistoryData, EXPORT_VERSION, ExportData, KeyStatsData, ProfileData, }; const SCHEMA_VERSION: u32 = 2; @@ -62,8 +62,7 @@ fn make_key_stat(rng: &mut SmallRng, confidence: f64, sample_count: usize) -> Ke /// Generate monotonic timestamps: base_date + day_offset days + drill_offset * 2min. fn drill_timestamp(base: DateTime, day: u32, drill_in_day: u32) -> DateTime { - base + chrono::Duration::days(day as i64) - + chrono::Duration::seconds(drill_in_day as i64 * 120) + base + chrono::Duration::days(day as i64) + chrono::Duration::seconds(drill_in_day as i64 * 120) } /// Generate a DrillResult with deterministic per_key_times. @@ -267,15 +266,16 @@ fn generate_drills( } fn last_practice_date_from_drills(drills: &[DrillResult]) -> Option { - drills.last().map(|d| d.timestamp.format("%Y-%m-%d").to_string()) + drills + .last() + .map(|d| d.timestamp.format("%Y-%m-%d").to_string()) } // ── Profile Builders ───────────────────────────────────────────────────── fn build_profile_01() -> ExportData { - let skill_tree = make_skill_tree_progress(vec![ - (BranchId::Lowercase, BranchStatus::InProgress, 0), - ]); + let skill_tree = + make_skill_tree_progress(vec![(BranchId::Lowercase, BranchStatus::InProgress, 0)]); make_export( ProfileData { @@ -295,13 +295,12 @@ fn build_profile_01() -> ExportData { fn build_profile_02() -> ExportData { // Lowercase InProgress level 4 => 6 + 4 = 10 keys: e,t,a,o,i,n,s,h,r,d - let skill_tree = make_skill_tree_progress(vec![ - (BranchId::Lowercase, BranchStatus::InProgress, 4), - ]); + let skill_tree = + make_skill_tree_progress(vec![(BranchId::Lowercase, BranchStatus::InProgress, 4)]); let all_keys = lowercase_keys(10); let mastered_keys = &all_keys[..6]; // e,t,a,o,i,n - let partial_keys = &all_keys[6..]; // s,h,r,d + let partial_keys = &all_keys[6..]; // s,h,r,d let mut rng = SmallRng::seed_from_u64(2002); let mut stats = KeyStatsStore::default(); @@ -323,12 +322,11 @@ fn build_profile_02() -> ExportData { } else { (base.confidence + rng.gen_range(-0.1..0.08)).clamp(0.15, 0.95) }; - let sample_count = ((base.sample_count as f64) * rng.gen_range(0.5..0.8)).round() as usize - + 6; - ranked_stats.stats.insert( - k, - make_key_stat(&mut rng, conf, sample_count), - ); + let sample_count = + ((base.sample_count as f64) * rng.gen_range(0.5..0.8)).round() as usize + 6; + ranked_stats + .stats + .insert(k, make_key_stat(&mut rng, conf, sample_count)); } let drills = generate_drills( @@ -359,9 +357,8 @@ fn build_profile_02() -> ExportData { fn build_profile_03() -> ExportData { // Lowercase InProgress level 12 => 6 + 12 = 18 keys through 'y' - let skill_tree = make_skill_tree_progress(vec![ - (BranchId::Lowercase, BranchStatus::InProgress, 12), - ]); + let skill_tree = + make_skill_tree_progress(vec![(BranchId::Lowercase, BranchStatus::InProgress, 12)]); let all_keys = lowercase_keys(18); let mastered_keys = &all_keys[..14]; @@ -389,10 +386,9 @@ fn build_profile_03() -> ExportData { }; let sample_count = ((base.sample_count as f64) * rng.gen_range(0.52..0.82)).round() as usize + 8; - ranked_stats.stats.insert( - k, - make_key_stat(&mut rng, conf, sample_count), - ); + ranked_stats + .stats + .insert(k, make_key_stat(&mut rng, conf, sample_count)); } let drills = generate_drills( @@ -445,10 +441,9 @@ fn build_profile_04() -> ExportData { let conf = (base.confidence - rng.gen_range(0.0..0.2)).max(1.0); let sample_count = ((base.sample_count as f64) * rng.gen_range(0.55..0.85)).round() as usize + 10; - ranked_stats.stats.insert( - k, - make_key_stat(&mut rng, conf, sample_count), - ); + ranked_stats + .stats + .insert(k, make_key_stat(&mut rng, conf, sample_count)); } let drills = generate_drills( @@ -540,7 +535,9 @@ fn build_profile_05() -> ExportData { // Ranked key stats: cover all keys used in ranked drills (all_unlocked) let mut ranked_stats = KeyStatsStore::default(); for &k in &all_unlocked { - ranked_stats.stats.insert(k, make_key_stat(&mut rng, 1.1, 20)); + ranked_stats + .stats + .insert(k, make_key_stat(&mut rng, 1.1, 20)); } // level ~7: score ~5000 @@ -632,7 +629,9 @@ fn build_profile_06() -> ExportData { // Ranked key stats: cover all keys used in ranked drills (all_unlocked) let mut ranked_stats = KeyStatsStore::default(); for &k in &all_unlocked { - ranked_stats.stats.insert(k, make_key_stat(&mut rng, 1.1, 30)); + ranked_stats + .stats + .insert(k, make_key_stat(&mut rng, 1.1, 30)); } // level ~12: score ~15000 @@ -711,7 +710,9 @@ fn build_profile_07() -> ExportData { // Full ranked stats let mut ranked_stats = KeyStatsStore::default(); for &k in &all_keys { - ranked_stats.stats.insert(k, make_key_stat(&mut rng, 1.4, 80)); + ranked_stats + .stats + .insert(k, make_key_stat(&mut rng, 1.4, 80)); } // level ~18: score ~35000 diff --git a/src/generator/capitalize.rs b/src/generator/capitalize.rs index 0f86e99..f3d424b 100644 --- a/src/generator/capitalize.rs +++ b/src/generator/capitalize.rs @@ -90,8 +90,8 @@ fn ensure_min_focused_occurrences(text: &str, focused_upper: char, min_count: us if chars[i] != focused_lower { continue; } - let is_word_start = i == 0 - || matches!(chars.get(i.saturating_sub(1)), Some(' ' | '\n' | '\t')); + 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; diff --git a/src/generator/phonetic.rs b/src/generator/phonetic.rs index 80d0257..9a53e25 100644 --- a/src/generator/phonetic.rs +++ b/src/generator/phonetic.rs @@ -366,8 +366,7 @@ impl TextGenerator for PhoneticGenerator { } else if pool_size >= FULL_DICT_THRESHOLD { 1.0 } else { - (pool_size - MIN_REAL_WORDS) as f64 - / (FULL_DICT_THRESHOLD - MIN_REAL_WORDS) as f64 + (pool_size - MIN_REAL_WORDS) as f64 / (FULL_DICT_THRESHOLD - MIN_REAL_WORDS) as f64 }; // Scaled within-drill dedup window based on dictionary pool size @@ -379,8 +378,7 @@ impl TextGenerator for PhoneticGenerator { // Cross-drill history accept probability (computed once) let cross_drill_accept_prob = if pool_size > 0 { - let pool_set: HashSet<&str> = - matching_words.iter().map(|s| s.as_str()).collect(); + let pool_set: HashSet<&str> = matching_words.iter().map(|s| s.as_str()).collect(); let history_in_pool = self .cross_drill_history .iter() @@ -474,8 +472,12 @@ mod tests { .filter(|w| w.contains('k')) .count(); - let mut baseline_gen = - PhoneticGenerator::new(table, Dictionary::load(), SmallRng::seed_from_u64(42), HashSet::new()); + let mut baseline_gen = PhoneticGenerator::new( + table, + Dictionary::load(), + SmallRng::seed_from_u64(42), + HashSet::new(), + ); let baseline_text = baseline_gen.generate(&filter, None, None, 1200); let baseline_count = baseline_text .split_whitespace() @@ -506,8 +508,12 @@ mod tests { .filter(|w| w.contains("th")) .count(); - let mut baseline_gen = - PhoneticGenerator::new(table, Dictionary::load(), SmallRng::seed_from_u64(42), HashSet::new()); + let mut baseline_gen = PhoneticGenerator::new( + table, + Dictionary::load(), + SmallRng::seed_from_u64(42), + HashSet::new(), + ); let baseline_text = baseline_gen.generate(&filter, None, None, 1200); let baseline_count = baseline_text .split_whitespace() @@ -526,8 +532,12 @@ mod tests { let table = TransitionTable::build_from_words(&dictionary.words_list()); let filter = CharFilter::new(('a'..='z').collect()); - let mut generator = - PhoneticGenerator::new(table, Dictionary::load(), SmallRng::seed_from_u64(42), HashSet::new()); + let mut generator = PhoneticGenerator::new( + table, + Dictionary::load(), + SmallRng::seed_from_u64(42), + HashSet::new(), + ); let text = generator.generate(&filter, Some('k'), Some(['t', 'h']), 200); let words: Vec<&str> = text.split_whitespace().collect(); @@ -580,8 +590,10 @@ mod tests { HashSet::new(), ); let text2_no_hist = gen2_no_hist.generate(&filter, Some('k'), None, word_count); - let words2_no_hist: HashSet = - text2_no_hist.split_whitespace().map(|w| w.to_string()).collect(); + let words2_no_hist: HashSet = text2_no_hist + .split_whitespace() + .map(|w| w.to_string()) + .collect(); let baseline_intersection = words1.intersection(&words2_no_hist).count(); let baseline_union = words1.union(&words2_no_hist).count(); let baseline_jaccard = baseline_intersection as f64 / baseline_union as f64; @@ -594,8 +606,10 @@ mod tests { words1.clone(), ); let text2_with_hist = gen2_with_hist.generate(&filter, Some('k'), None, word_count); - let words2_with_hist: HashSet = - text2_with_hist.split_whitespace().map(|w| w.to_string()).collect(); + let words2_with_hist: HashSet = text2_with_hist + .split_whitespace() + .map(|w| w.to_string()) + .collect(); let hist_intersection = words1.intersection(&words2_with_hist).count(); let hist_union = words1.union(&words2_with_hist).count(); let hist_jaccard = hist_intersection as f64 / hist_union as f64; @@ -758,7 +772,11 @@ mod tests { .collect(); // Create history containing most of the pool words (up to 8) - let history: HashSet = matching.iter().take(8.min(matching.len())).cloned().collect(); + let history: HashSet = matching + .iter() + .take(8.min(matching.len())) + .cloned() + .collect(); let mut generator = PhoneticGenerator::new( table, @@ -773,10 +791,7 @@ mod tests { assert!(!words.is_empty(), "Should generate non-empty output"); // History words should still appear (suppression is soft, not hard exclusion) - let history_words_in_output: usize = words - .iter() - .filter(|w| history.contains(**w)) - .count(); + let history_words_in_output: usize = words.iter().filter(|w| history.contains(**w)).count(); // With soft suppression, at least some history words should appear // (they're accepted with reduced probability, not blocked) assert!( diff --git a/src/main.rs b/src/main.rs index c452906..06957e7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -29,7 +29,6 @@ use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Paragraph, Widget, Wrap}; use app::{App, AppScreen, DrillMode, MilestoneKind, StatusKind}; -use ui::line_input::{InputResult, LineInput, PathField}; use engine::skill_tree::{DrillScope, find_key_branch}; use event::{AppEvent, EventHandler}; use generator::code_syntax::{code_language_options, is_language_cached, language_by_key}; @@ -37,15 +36,19 @@ use generator::passage::{is_book_cached, passage_options}; use keyboard::display::key_display_name; use keyboard::finger::Hand; use ui::components::dashboard::Dashboard; -use ui::layout::{pack_hint_lines, wrapped_line_count}; use ui::components::keyboard_diagram::KeyboardDiagram; -use ui::components::skill_tree::{SkillTreeWidget, detail_line_count, selectable_branches}; +use ui::components::skill_tree::{ + SkillTreeWidget, 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, }; use ui::components::stats_sidebar::StatsSidebar; use ui::components::typing_area::TypingArea; use ui::layout::AppLayout; +use ui::layout::{pack_hint_lines, wrapped_line_count}; +use ui::line_input::{InputResult, LineInput, PathField}; #[derive(Parser)] #[command( @@ -971,6 +974,20 @@ fn handle_code_download_progress_key(app: &mut App, key: KeyEvent) { fn handle_skill_tree_key(app: &mut App, key: KeyEvent) { const DETAIL_SCROLL_STEP: usize = 10; + if let Some(branch_id) = app.skill_tree_confirm_unlock { + match key.code { + KeyCode::Char('y') => { + app.unlock_branch(branch_id); + app.skill_tree_confirm_unlock = None; + } + KeyCode::Char('n') | KeyCode::Esc => { + app.skill_tree_confirm_unlock = None; + } + _ => {} + } + return; + } + let max_scroll = skill_tree_detail_max_scroll(app); app.skill_tree_detail_scroll = app.skill_tree_detail_scroll.min(max_scroll); let branches = selectable_branches(); @@ -1014,9 +1031,9 @@ fn handle_skill_tree_key(app: &mut App, key: KeyEvent) { if app.skill_tree_selected < branches.len() { let branch_id = branches[app.skill_tree_selected]; let status = app.skill_tree.branch_status(branch_id).clone(); - if status == engine::skill_tree::BranchStatus::Available - || status == engine::skill_tree::BranchStatus::InProgress - { + if status == engine::skill_tree::BranchStatus::Available { + app.skill_tree_confirm_unlock = Some(branch_id); + } else if status == engine::skill_tree::BranchStatus::InProgress { app.start_branch_drill(branch_id); } } @@ -1040,21 +1057,83 @@ fn skill_tree_detail_max_scroll(app: &App) -> usize { if branches.is_empty() { return 0; } - let branch_list_height = branches.len() as u16 * 2 + 1; - let layout = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(branch_list_height.min(inner.height.saturating_sub(6))), - Constraint::Length(1), - Constraint::Min(4), - Constraint::Length(2), - ]) - .split(inner); - let detail_height = layout.get(2).map(|r| r.height as usize).unwrap_or(0); let selected = app .skill_tree_selected .min(branches.len().saturating_sub(1)); - let total_lines = detail_line_count(branches[selected]); + let bp = app.skill_tree.branch_progress(branches[selected]); + let (footer_hints, footer_notice) = if *app.skill_tree.branch_status(branches[selected]) + == engine::skill_tree::BranchStatus::Locked + { + ( + vec![ + "[↑↓/jk] Navigate", + "[PgUp/PgDn or Ctrl+U/Ctrl+D] Scroll", + "[q] Back", + ], + Some("Complete a-z to unlock branches"), + ) + } else if bp.status == engine::skill_tree::BranchStatus::Available { + ( + vec![ + "[Enter] Unlock", + "[↑↓/jk] Navigate", + "[PgUp/PgDn or Ctrl+U/Ctrl+D] Scroll", + "[q] Back", + ], + None, + ) + } else if bp.status == engine::skill_tree::BranchStatus::InProgress { + ( + vec![ + "[Enter] Start Drill", + "[↑↓/jk] Navigate", + "[PgUp/PgDn or Ctrl+U/Ctrl+D] Scroll", + "[q] Back", + ], + None, + ) + } else { + ( + vec![ + "[↑↓/jk] Navigate", + "[PgUp/PgDn or Ctrl+U/Ctrl+D] Scroll", + "[q] Back", + ], + None, + ) + }; + let hint_lines = pack_hint_lines(&footer_hints, inner.width as usize); + let notice_lines = footer_notice + .map(|text| wrapped_line_count(text, inner.width as usize)) + .unwrap_or(0); + let show_notice = + footer_notice.is_some() && (inner.height as usize >= hint_lines.len() + notice_lines + 8); + let footer_needed = hint_lines.len() + if show_notice { notice_lines } else { 0 } + 1; + let footer_height = footer_needed + .min(inner.height.saturating_sub(5) as usize) + .max(1) as u16; + + let layout = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(4), Constraint::Length(footer_height)]) + .split(inner); + let side_by_side = use_side_by_side_layout(inner.width); + let detail_height = if side_by_side { + layout.first().map(|r| r.height as usize).unwrap_or(0) + } else { + let branch_list_height = branches.len() as u16 * 2 + 1; + let main = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(branch_list_height.min(layout[0].height.saturating_sub(4))), + Constraint::Length(1), + Constraint::Min(3), + ]) + .split(layout[0]); + main.get(2).map(|r| r.height as usize).unwrap_or(0) + }; + let expanded = use_expanded_level_spacing(detail_height as u16, branches[selected]); + let total_lines = detail_line_count_with_level_spacing(branches[selected], expanded); total_lines.saturating_sub(detail_height) } @@ -2252,7 +2331,7 @@ mod review_tests { #[test] fn build_ngram_tab_data_maps_fields_correctly() { - use crate::engine::ngram_stats::{BigramKey, ANOMALY_STREAK_REQUIRED}; + use crate::engine::ngram_stats::{ANOMALY_STREAK_REQUIRED, BigramKey}; let mut app = test_app(); @@ -2402,7 +2481,10 @@ mod review_tests { ); let after_cursor = app.drill.as_ref().unwrap().cursor; - assert_eq!(before_cursor, after_cursor, "Key should be blocked during input lock on Drill screen"); + assert_eq!( + before_cursor, after_cursor, + "Key should be blocked during input lock on Drill screen" + ); assert_eq!(app.screen, AppScreen::Drill); } @@ -2419,15 +2501,34 @@ mod review_tests { KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), ); - assert!(app.should_quit, "Ctrl+C should set should_quit even during input lock"); + assert!( + app.should_quit, + "Ctrl+C should set should_quit even during input lock" + ); } /// Helper: render settings to a test buffer and return its text content. fn render_settings_to_string(app: &App) -> String { let backend = ratatui::backend::TestBackend::new(80, 40); let mut terminal = Terminal::new(backend).unwrap(); + terminal.draw(|frame| render_settings(frame, app)).unwrap(); + let buf = terminal.backend().buffer().clone(); + let mut text = String::new(); + for y in 0..buf.area.height { + for x in 0..buf.area.width { + text.push(buf[(x, y)].symbol().chars().next().unwrap_or(' ')); + } + text.push('\n'); + } + text + } + + /// Helper: render skill tree to a test buffer and return its text content. + fn render_skill_tree_to_string_with_size(app: &App, width: u16, height: u16) -> String { + let backend = ratatui::backend::TestBackend::new(width, height); + let mut terminal = Terminal::new(backend).unwrap(); terminal - .draw(|frame| render_settings(frame, app)) + .draw(|frame| render_skill_tree(frame, app)) .unwrap(); let buf = terminal.backend().buffer().clone(); let mut text = String::new(); @@ -2440,6 +2541,10 @@ mod review_tests { text } + fn render_skill_tree_to_string(app: &App) -> String { + render_skill_tree_to_string_with_size(app, 120, 40) + } + #[test] fn footer_shows_completion_error_and_clears_on_keystroke() { let mut app = test_app(); @@ -2468,10 +2573,7 @@ mod review_tests { ); // Press a non-tab key to clear the error - handle_settings_key( - &mut app, - KeyEvent::new(KeyCode::Left, KeyModifiers::NONE), - ); + handle_settings_key(&mut app, KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)); // Render again — error hint should be gone let output_after = render_settings_to_string(&app); @@ -2500,6 +2602,152 @@ mod review_tests { assert!(output_during.contains("[Esc] Cancel")); assert!(output_during.contains("[Tab] Complete")); } + + #[test] + fn skill_tree_available_branch_enter_opens_unlock_confirm() { + let mut app = test_app(); + app.screen = AppScreen::SkillTree; + app.skill_tree_confirm_unlock = None; + app.skill_tree + .branch_progress_mut(engine::skill_tree::BranchId::Capitals) + .status = engine::skill_tree::BranchStatus::Available; + app.skill_tree_selected = selectable_branches() + .iter() + .position(|id| *id == engine::skill_tree::BranchId::Capitals) + .unwrap(); + + handle_skill_tree_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_eq!( + app.skill_tree_confirm_unlock, + Some(engine::skill_tree::BranchId::Capitals) + ); + assert_eq!( + *app.skill_tree + .branch_status(engine::skill_tree::BranchId::Capitals), + engine::skill_tree::BranchStatus::Available + ); + assert_eq!(app.screen, AppScreen::SkillTree); + } + + #[test] + fn skill_tree_unlock_confirm_yes_unlocks_without_starting_drill() { + let mut app = test_app(); + app.screen = AppScreen::SkillTree; + app.skill_tree_confirm_unlock = Some(engine::skill_tree::BranchId::Capitals); + app.skill_tree + .branch_progress_mut(engine::skill_tree::BranchId::Capitals) + .status = engine::skill_tree::BranchStatus::Available; + + handle_skill_tree_key( + &mut app, + KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE), + ); + + assert_eq!(app.skill_tree_confirm_unlock, None); + assert_eq!( + *app.skill_tree + .branch_status(engine::skill_tree::BranchId::Capitals), + engine::skill_tree::BranchStatus::InProgress + ); + assert_eq!(app.screen, AppScreen::SkillTree); + } + + #[test] + fn skill_tree_in_progress_branch_enter_starts_branch_drill() { + let mut app = test_app(); + app.screen = AppScreen::SkillTree; + app.skill_tree + .branch_progress_mut(engine::skill_tree::BranchId::Capitals) + .status = engine::skill_tree::BranchStatus::InProgress; + app.skill_tree_selected = selectable_branches() + .iter() + .position(|id| *id == engine::skill_tree::BranchId::Capitals) + .unwrap(); + + handle_skill_tree_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_eq!(app.skill_tree_confirm_unlock, None); + assert_eq!(app.screen, AppScreen::Drill); + assert_eq!( + app.drill_scope, + DrillScope::Branch(engine::skill_tree::BranchId::Capitals) + ); + } + + #[test] + fn skill_tree_available_branch_footer_shows_unlock_hint() { + let mut app = test_app(); + app.screen = AppScreen::SkillTree; + app.skill_tree + .branch_progress_mut(engine::skill_tree::BranchId::Capitals) + .status = engine::skill_tree::BranchStatus::Available; + app.skill_tree_selected = selectable_branches() + .iter() + .position(|id| *id == engine::skill_tree::BranchId::Capitals) + .unwrap(); + + let output = render_skill_tree_to_string(&app); + assert!(output.contains("[Enter] Unlock")); + } + + #[test] + fn skill_tree_unlock_modal_shows_body_and_prompt_text() { + let mut app = test_app(); + app.screen = AppScreen::SkillTree; + app.skill_tree_confirm_unlock = Some(engine::skill_tree::BranchId::Capitals); + + let output = render_skill_tree_to_string(&app); + assert!(output.contains("default adaptive drill will mix in keys")); + assert!(output.contains("focus only on this branch")); + assert!(output.contains("from this branch in the Skill Tree.")); + assert!(output.contains("Proceed? (y/n)")); + } + + #[test] + fn skill_tree_unlock_modal_keeps_full_second_sentence_on_smaller_terminal() { + let mut app = test_app(); + app.screen = AppScreen::SkillTree; + app.skill_tree_confirm_unlock = Some(engine::skill_tree::BranchId::Capitals); + + let output = render_skill_tree_to_string_with_size(&app, 90, 24); + assert!(output.contains("focus only on this branch")); + assert!(output.contains("from this branch in the Skill Tree.")); + assert!(output.contains("Proceed? (y/n)")); + } + + #[test] + fn skill_tree_layout_switches_with_width() { + assert!(!use_side_by_side_layout(99)); + assert!(use_side_by_side_layout(100)); + } + + #[test] + fn skill_tree_expanded_branch_spacing_threshold() { + // 6 branches => base=13 lines, inter-branch spacing needs +5, separator padding needs +2. + assert_eq!( + crate::ui::components::skill_tree::branch_list_spacing_flags(17, 6), + (false, false) + ); + assert_eq!( + crate::ui::components::skill_tree::branch_list_spacing_flags(18, 6), + (true, false) + ); + assert_eq!( + crate::ui::components::skill_tree::branch_list_spacing_flags(20, 6), + (true, true) + ); + } + + #[test] + fn skill_tree_expanded_level_spacing_threshold() { + use crate::engine::skill_tree::BranchId; + let id = BranchId::Capitals; + let base = crate::ui::components::skill_tree::detail_line_count(id) as u16; + // Capitals has 3 levels, so expanded spacing needs +2 lines. + assert!(!use_expanded_level_spacing(base + 1, id)); + assert!(use_expanded_level_spacing(base + 2, id)); + } } fn render_result(frame: &mut ratatui::Frame, app: &App) { @@ -2856,9 +3104,7 @@ fn render_settings(frame: &mut ratatui::Frame, app: &App) { if is_editing_this_path { if let Some((_, ref input)) = app.settings_editing_path { let (before, cursor_ch, after) = input.render_parts(); - let cursor_style = Style::default() - .fg(colors.bg()) - .bg(colors.focused_key()); + let cursor_style = Style::default().fg(colors.bg()).bg(colors.focused_key()); let path_spans = match cursor_ch { Some(ch) => vec![ Span::styled(format!(" {before}"), value_style), @@ -3009,7 +3255,6 @@ fn render_settings(frame: &mut ratatui::Frame, app: &App) { } } - fn render_code_language_select(frame: &mut ratatui::Frame, app: &App) { let area = frame.area(); let colors = &app.theme.colors; @@ -3715,6 +3960,7 @@ fn render_code_download_progress(frame: &mut ratatui::Frame, app: &App) { fn render_skill_tree(frame: &mut ratatui::Frame, app: &App) { let area = frame.area(); + let colors = &app.theme.colors; let centered = skill_tree_popup_rect(area); let widget = SkillTreeWidget::new( &app.skill_tree, @@ -3724,6 +3970,89 @@ fn render_skill_tree(frame: &mut ratatui::Frame, app: &App) { app.theme, ); frame.render_widget(widget, centered); + + if let Some(branch_id) = app.skill_tree_confirm_unlock { + let sentence_one = "Once unlocked, the default adaptive drill will mix in keys in this branch that are unlocked."; + let sentence_two = "If you want to focus only on this branch, launch a drill directly from this branch in the Skill Tree."; + let branch_name = engine::skill_tree::get_branch_definition(branch_id).name; + let dialog_width = 72u16.min(area.width.saturating_sub(4)); + let content_width = dialog_width.saturating_sub(6).max(1) as usize; // border + side margins + let body_required = 4 // blank + title + blank + blank-between-sentences + + wrapped_line_count(sentence_one, content_width) + + wrapped_line_count(sentence_two, content_width); + // Add one safety line because `wrapped_line_count` is a cheap estimator. + let body_required = body_required + 1; + let min_dialog_height = (body_required + 1 + 2) as u16; // body + prompt + border + let preferred_dialog_height = (body_required + 2 + 2) as u16; // + blank before prompt + let max_dialog_height = area.height.saturating_sub(1).max(7); + let dialog_height = preferred_dialog_height + .min(max_dialog_height) + .max(min_dialog_height.min(max_dialog_height)); + let dialog_x = area.x + area.width.saturating_sub(dialog_width) / 2; + let dialog_y = area.y + area.height.saturating_sub(dialog_height) / 2; + let dialog_area = Rect::new(dialog_x, dialog_y, dialog_width, dialog_height); + + frame.render_widget(ratatui::widgets::Clear, dialog_area); + let block = Block::bordered() + .title(" Confirm Unlock ") + .border_style(Style::default().fg(colors.error())) + .style(Style::default().bg(colors.bg())); + let inner = block.inner(dialog_area); + frame.render_widget(block, dialog_area); + let content = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Length(2), + Constraint::Min(1), + Constraint::Length(2), + ]) + .split(inner)[1]; + let prompt_block_height = if content.height as usize > body_required + 1 { + 2 + } else { + 1 + }; + let content_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(1), Constraint::Length(prompt_block_height)]) + .split(content); + let body = Paragraph::new(vec![ + Line::from(""), + Line::from(Span::styled( + format!("Unlock {branch_name}?"), + Style::default().fg(colors.fg()), + )), + Line::from(""), + Line::from(Span::styled( + sentence_one, + Style::default().fg(colors.text_pending()), + )), + Line::from(""), + Line::from(Span::styled( + sentence_two, + Style::default().fg(colors.text_pending()), + )), + ]) + .wrap(Wrap { trim: false }) + .style(Style::default().bg(colors.bg())); + frame.render_widget(body, content_layout[0]); + let confirm_lines = if prompt_block_height > 1 { + vec![ + Line::from(""), + Line::from(Span::styled( + "Proceed? (y/n)", + Style::default().fg(colors.fg()), + )), + ] + } else { + vec![Line::from(Span::styled( + "Proceed? (y/n)", + Style::default().fg(colors.fg()), + ))] + }; + let confirm = Paragraph::new(confirm_lines).style(Style::default().bg(colors.bg())); + frame.render_widget(confirm, content_layout[1]); + } } fn handle_keyboard_explorer_key(app: &mut App, key: KeyEvent) { diff --git a/src/ui/components/branch_progress_list.rs b/src/ui/components/branch_progress_list.rs index 777847f..9bf1b6a 100644 --- a/src/ui/components/branch_progress_list.rs +++ b/src/ui/components/branch_progress_list.rs @@ -32,9 +32,8 @@ fn wrapped_branch_columns(area_width: u16, branch_count: usize) -> usize { return 1; } let width = area_width as usize; - let max_cols_by_width = ((width + BRANCH_CELL_GUTTER) - / (MIN_BRANCH_CELL_WIDTH + BRANCH_CELL_GUTTER)) - .max(1); + let max_cols_by_width = + ((width + BRANCH_CELL_GUTTER) / (MIN_BRANCH_CELL_WIDTH + BRANCH_CELL_GUTTER)).max(1); max_cols_by_width.min(branch_count) } diff --git a/src/ui/components/dashboard.rs b/src/ui/components/dashboard.rs index 0138241..84aa81f 100644 --- a/src/ui/components/dashboard.rs +++ b/src/ui/components/dashboard.rs @@ -160,12 +160,7 @@ impl Widget for Dashboard<'_> { ]; let lines: Vec = pack_hint_lines(&hints, inner.width as usize) .into_iter() - .map(|line| { - Line::from(Span::styled( - line, - Style::default().fg(colors.accent()), - )) - }) + .map(|line| Line::from(Span::styled(line, Style::default().fg(colors.accent())))) .collect(); Paragraph::new(lines) }; diff --git a/src/ui/components/skill_tree.rs b/src/ui/components/skill_tree.rs index 07f2162..8f3d9fa 100644 --- a/src/ui/components/skill_tree.rs +++ b/src/ui/components/skill_tree.rs @@ -59,6 +59,41 @@ pub fn detail_line_count(branch_id: BranchId) -> usize { .sum::() } +pub fn detail_line_count_with_level_spacing(branch_id: BranchId, level_spacing: bool) -> usize { + let base = detail_line_count(branch_id); + if !level_spacing { + return base; + } + let def = get_branch_definition(branch_id); + base + def.levels.len().saturating_sub(1) +} + +pub fn use_expanded_level_spacing(detail_area_height: u16, branch_id: BranchId) -> bool { + let def = get_branch_definition(branch_id); + let base = detail_line_count(branch_id); + let extra = def.levels.len().saturating_sub(1); + (detail_area_height as usize) >= base + extra +} + +pub fn use_side_by_side_layout(inner_width: u16) -> bool { + inner_width >= 100 +} + +pub fn branch_list_spacing_flags(branch_area_height: u16, branch_count: usize) -> (bool, bool) { + if branch_count == 0 { + return (false, false); + } + // Base lines: 2 per branch + 1 separator after lowercase. + let base_lines = branch_count * 2 + 1; + let extra_lines = (branch_area_height as usize).saturating_sub(base_lines); + // Priority 1: one spacer between each progress bar and following branch title. + let inter_branch_needed = branch_count.saturating_sub(1); + let inter_branch_spacing = extra_lines >= inter_branch_needed; + // Priority 2: one extra line above and below "Branches (...)" separator. + let separator_padding = inter_branch_spacing && extra_lines >= inter_branch_needed + 2; + (inter_branch_spacing, separator_padding) +} + impl Widget for SkillTreeWidget<'_> { fn render(self, area: Rect, buf: &mut Buffer) { let colors = &self.theme.colors; @@ -70,9 +105,8 @@ impl Widget for SkillTreeWidget<'_> { let inner = block.inner(area); block.render(area, buf); - // Layout: branch list, separator, detail panel, footer (adaptive height) + // Layout: main split (branch list + detail) and footer (adaptive height) let branches = selectable_branches(); - let branch_list_height = branches.len() as u16 * 2 + 1; // all branches * 2 lines + separator after Lowercase let (footer_hints, footer_notice) = if self.selected < branches.len() { let bp = self.skill_tree.branch_progress(branches[self.selected]); if *self.skill_tree.branch_status(branches[self.selected]) == BranchStatus::Locked { @@ -84,8 +118,17 @@ impl Widget for SkillTreeWidget<'_> { ], Some("Complete a-z to unlock branches"), ) - } else if bp.status == BranchStatus::Available || bp.status == BranchStatus::InProgress - { + } else if bp.status == BranchStatus::Available { + ( + vec![ + "[Enter] Unlock", + "[↑↓/jk] Navigate", + "[PgUp/PgDn or Ctrl+U/Ctrl+D] Scroll", + "[q] Back", + ], + None, + ) + } else if bp.status == BranchStatus::InProgress { ( vec![ "[Enter] Start Drill", @@ -128,28 +171,67 @@ impl Widget for SkillTreeWidget<'_> { let layout = Layout::default() .direction(Direction::Vertical) - .constraints([ - Constraint::Length( - branch_list_height.min(inner.height.saturating_sub(footer_height + 4)), - ), - Constraint::Length(1), - Constraint::Min(4), - Constraint::Length(footer_height), - ]) + .constraints([Constraint::Min(4), Constraint::Length(footer_height)]) .split(inner); - // --- Branch list --- - self.render_branch_list(layout[0], buf, &branches); + if use_side_by_side_layout(inner.width) { + let main = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(42), + Constraint::Length(1), + Constraint::Percentage(58), + ]) + .split(layout[0]); - // --- Separator --- - let sep = Paragraph::new(Line::from(Span::styled( - "\u{2500}".repeat(layout[1].width as usize), - Style::default().fg(colors.border()), - ))); - sep.render(layout[1], buf); + // --- Branch list (left pane) --- + let (inter_branch_spacing, separator_padding) = + branch_list_spacing_flags(main[0].height, branches.len()); + self.render_branch_list( + main[0], + buf, + &branches, + inter_branch_spacing, + separator_padding, + ); - // --- Detail panel for selected branch --- - self.render_detail_panel(layout[2], buf, &branches); + // --- Vertical separator --- + let sep_lines: Vec = (0..main[1].height) + .map(|_| { + Line::from(Span::styled( + "\u{2502}", + Style::default().fg(colors.border()), + )) + }) + .collect(); + Paragraph::new(sep_lines).render(main[1], buf); + + // --- Detail panel for selected branch (right pane) --- + self.render_detail_panel(main[2], buf, &branches, true); + } else { + let branch_list_height = branches.len() as u16 * 2 + 1; + let main = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(branch_list_height.min(layout[0].height.saturating_sub(4))), + Constraint::Length(1), + Constraint::Min(3), + ]) + .split(layout[0]); + + // --- Branch list (top pane) --- + self.render_branch_list(main[0], buf, &branches, false, false); + + // --- Horizontal separator --- + let sep = Paragraph::new(Line::from(Span::styled( + "\u{2500}".repeat(main[1].width as usize), + Style::default().fg(colors.border()), + ))); + sep.render(main[1], buf); + + // --- Detail panel (bottom pane) --- + self.render_detail_panel(main[2], buf, &branches, true); + } // --- Footer --- let mut footer_lines: Vec = Vec::new(); @@ -168,16 +250,27 @@ impl Widget for SkillTreeWidget<'_> { )) })); let footer = Paragraph::new(footer_lines).wrap(Wrap { trim: false }); - footer.render(layout[3], buf); + footer.render(layout[1], buf); } } impl SkillTreeWidget<'_> { - fn render_branch_list(&self, area: Rect, buf: &mut Buffer, branches: &[BranchId]) { + fn render_branch_list( + &self, + area: Rect, + buf: &mut Buffer, + branches: &[BranchId], + inter_branch_spacing: bool, + separator_padding: bool, + ) { let colors = &self.theme.colors; let mut lines: Vec = Vec::new(); for (i, &branch_id) in branches.iter().enumerate() { + if i > 0 && inter_branch_spacing { + lines.push(Line::from("")); + } + let bp = self.skill_tree.branch_progress(branch_id); let def = get_branch_definition(branch_id); let total_keys = def.levels.iter().map(|l| l.keys.len()).sum::(); @@ -249,10 +342,18 @@ impl SkillTreeWidget<'_> { // Add separator after Lowercase (index 0) if branch_id == BranchId::Lowercase { + if separator_padding { + lines.push(Line::from("")); + } lines.push(Line::from(Span::styled( - " \u{2500}\u{2500} Branches (unlocked after a-z) \u{2500}\u{2500}", - Style::default().fg(colors.border()), + " \u{2500}\u{2500} Branches (available after a-z) \u{2500}\u{2500}", + Style::default().fg(colors.text_pending()), ))); + // If inter-branch spacing is enabled, the next branch will already + // insert one blank line before its title. + if separator_padding && !inter_branch_spacing { + lines.push(Line::from("")); + } } } @@ -260,7 +361,13 @@ impl SkillTreeWidget<'_> { paragraph.render(area, buf); } - fn render_detail_panel(&self, area: Rect, buf: &mut Buffer, branches: &[BranchId]) { + fn render_detail_panel( + &self, + area: Rect, + buf: &mut Buffer, + branches: &[BranchId], + allow_expanded_level_spacing: bool, + ) { let colors = &self.theme.colors; if self.selected >= branches.len() { @@ -270,6 +377,8 @@ impl SkillTreeWidget<'_> { let branch_id = branches[self.selected]; let bp = self.skill_tree.branch_progress(branch_id); let def = get_branch_definition(branch_id); + let expanded_level_spacing = + allow_expanded_level_spacing && use_expanded_level_spacing(area.height, branch_id); let mut lines: Vec = Vec::new(); @@ -401,6 +510,10 @@ impl SkillTreeWidget<'_> { ])); } } + + if expanded_level_spacing && level_idx + 1 < def.levels.len() { + lines.push(Line::from("")); + } } let visible_height = area.height as usize; @@ -437,4 +550,3 @@ fn dual_progress_bar_parts( "\u{2591}".repeat(empty_cells), ) } - diff --git a/src/ui/components/stats_dashboard.rs b/src/ui/components/stats_dashboard.rs index a194580..d7b3a89 100644 --- a/src/ui/components/stats_dashboard.rs +++ b/src/ui/components/stats_dashboard.rs @@ -166,12 +166,7 @@ impl Widget for StatsDashboard<'_> { // Footer let footer_lines: Vec = footer_lines_vec .into_iter() - .map(|line| { - Line::from(Span::styled( - line, - Style::default().fg(colors.accent()), - )) - }) + .map(|line| Line::from(Span::styled(line, Style::default().fg(colors.accent())))) .collect(); Paragraph::new(footer_lines).render(layout[2], buf); diff --git a/src/ui/line_input.rs b/src/ui/line_input.rs index 27adec8..fdf5be8 100644 --- a/src/ui/line_input.rs +++ b/src/ui/line_input.rs @@ -89,7 +89,8 @@ impl LineInput { if self.cursor > 0 { let byte_offset = self.char_to_byte(self.cursor - 1); let ch = self.text[byte_offset..].chars().next().unwrap(); - self.text.replace_range(byte_offset..byte_offset + ch.len_utf8(), ""); + self.text + .replace_range(byte_offset..byte_offset + ch.len_utf8(), ""); self.cursor -= 1; } } @@ -99,7 +100,8 @@ impl LineInput { if self.cursor < len { let byte_offset = self.char_to_byte(self.cursor); let ch = self.text[byte_offset..].chars().next().unwrap(); - self.text.replace_range(byte_offset..byte_offset + ch.len_utf8(), ""); + self.text + .replace_range(byte_offset..byte_offset + ch.len_utf8(), ""); } } KeyCode::Tab => { @@ -216,11 +218,7 @@ impl LineInput { // Split seed into (dir_part, partial_filename) by last path separator. // Accept both '/' and '\\' so user-typed alternate separators work on any platform. - let last_sep_pos = seed - .rfind('/') - .into_iter() - .chain(seed.rfind('\\')) - .max(); + let last_sep_pos = seed.rfind('/').into_iter().chain(seed.rfind('\\')).max(); let (dir_str, partial) = if let Some(pos) = last_sep_pos { (&seed[..=pos], &seed[pos + 1..]) } else { @@ -638,7 +636,10 @@ mod tests { let mut input = LineInput::new(""); let entries: Vec> = vec![ Ok(("alpha.txt".to_string(), false)), - Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "mock")), + Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "mock", + )), Ok(("beta.txt".to_string(), false)), ]; diff --git a/tests/test_profile_fixtures.rs b/tests/test_profile_fixtures.rs index 79c07fa..f027ccb 100644 --- a/tests/test_profile_fixtures.rs +++ b/tests/test_profile_fixtures.rs @@ -6,9 +6,7 @@ use std::sync::Once; use chrono::Datelike; use keydr::engine::scoring::level_from_score; -use keydr::engine::skill_tree::{ - BranchId, BranchStatus, DrillScope, SkillTree, ALL_BRANCHES, -}; +use keydr::engine::skill_tree::{ALL_BRANCHES, BranchId, BranchStatus, DrillScope, SkillTree}; use keydr::store::json_store::JsonStore; use keydr::store::schema::ExportData; @@ -34,7 +32,10 @@ fn ensure_profiles_generated() { .args(["run", "--bin", "generate_test_profiles"]) .status() .expect("failed to run generate_test_profiles"); - assert!(status.success(), "generate_test_profiles exited with {status}"); + assert!( + status.success(), + "generate_test_profiles exited with {status}" + ); }); } @@ -49,11 +50,7 @@ fn load_profile(name: &str) -> ExportData { fn completed_branch_keys(data: &ExportData) -> HashSet { let mut keys = HashSet::new(); for branch_def in ALL_BRANCHES { - let bp = data - .profile - .skill_tree - .branches - .get(branch_def.id.to_key()); + let bp = data.profile.skill_tree.branches.get(branch_def.id.to_key()); let is_complete = matches!(bp, Some(bp) if bp.status == BranchStatus::Complete); if is_complete { for level in branch_def.levels { @@ -229,8 +226,16 @@ fn profile_01_has_empty_ranked_stats() { data.ranked_key_stats.stats.stats.is_empty(), "01-brand-new.json: ranked_key_stats should be empty" ); - let ranked_count = data.drill_history.drills.iter().filter(|d| d.ranked).count(); - assert_eq!(ranked_count, 0, "01-brand-new.json: should have no ranked drills"); + let ranked_count = data + .drill_history + .drills + .iter() + .filter(|d| d.ranked) + .count(); + assert_eq!( + ranked_count, 0, + "01-brand-new.json: should have no ranked drills" + ); } #[test] @@ -241,7 +246,12 @@ fn profiles_02_to_07_have_ranked_stats_and_ranked_drills() { !data.ranked_key_stats.stats.stats.is_empty(), "{name}: ranked_key_stats should not be empty" ); - let ranked_count = data.drill_history.drills.iter().filter(|d| d.ranked).count(); + let ranked_count = data + .drill_history + .drills + .iter() + .filter(|d| d.ranked) + .count(); assert!( ranked_count > 0, "{name}: expected at least one ranked drill to populate ranked stores" @@ -374,7 +384,12 @@ fn streak_and_last_practice_date_consistent_with_history() { ); } else { // last_practice_date should match the last drill's date - let last_drill_date = drills.last().unwrap().timestamp.format("%Y-%m-%d").to_string(); + let last_drill_date = drills + .last() + .unwrap() + .timestamp + .format("%Y-%m-%d") + .to_string(); assert_eq!( data.profile.last_practice_date.as_deref(), Some(last_drill_date.as_str()), @@ -495,10 +510,7 @@ fn imports_all_profiles_into_temp_store() { // Verify we can reload the imported data let profile = store.load_profile(); - assert!( - profile.is_some(), - "{name}: profile not found after import" - ); + assert!(profile.is_some(), "{name}: profile not found after import"); let profile = profile.unwrap(); assert_eq!( profile.total_drills, data.profile.total_drills,